@automattic/charts 4.3.0 → 4.4.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 +8 -0
- package/dist/index.cjs +63 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +64 -7
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/bar-chart/bar-chart.tsx +35 -13
- package/src/charts/bar-chart/private/classified-bar-series.tsx +85 -0
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +3 -6
- package/src/charts/bar-chart/private/index.ts +1 -0
- package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +2 -3
- package/src/charts/bar-chart/test/bar-chart.test.tsx +130 -1
- package/src/charts/bar-chart/types.ts +5 -1
package/package.json
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
useBarChartOptions,
|
|
36
36
|
BandHighlight,
|
|
37
37
|
BandTooltip,
|
|
38
|
+
ClassifiedBarSeries,
|
|
38
39
|
ComparisonBars,
|
|
39
40
|
DEFAULT_COMPARISON_WIDTH_FACTOR,
|
|
40
41
|
COMPARISON_INNER_GAP,
|
|
@@ -111,6 +112,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
111
112
|
renderTooltip,
|
|
112
113
|
tooltipPlacement,
|
|
113
114
|
tooltipAnchorTop,
|
|
115
|
+
tooltipStyle,
|
|
116
|
+
barClassName,
|
|
114
117
|
options = {},
|
|
115
118
|
orientation = 'vertical',
|
|
116
119
|
withPatterns = false,
|
|
@@ -704,19 +707,37 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
704
707
|
resolveFill={ resolveComparisonFill }
|
|
705
708
|
/>
|
|
706
709
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
710
|
+
{ barClassName ? (
|
|
711
|
+
<g className="visx-bar-group">
|
|
712
|
+
{ primaryEntries.map( ( { series: seriesData, index } ) => (
|
|
713
|
+
<ClassifiedBarSeries
|
|
714
|
+
key={ seriesData.label }
|
|
715
|
+
dataKey={ seriesData.label }
|
|
716
|
+
data={ seriesData.data as DataPointDate[] }
|
|
717
|
+
xAccessor={ chartOptions.accessors.xAccessor }
|
|
718
|
+
yAccessor={ chartOptions.accessors.yAccessor }
|
|
719
|
+
colorAccessor={ getBarBackground( index ) }
|
|
720
|
+
barClassName={ barClassName }
|
|
721
|
+
primaryKeys={ primaryKeys }
|
|
722
|
+
groupPadding={ groupPadding }
|
|
723
|
+
/>
|
|
724
|
+
) ) }
|
|
725
|
+
</g>
|
|
726
|
+
) : (
|
|
727
|
+
<BarGroup padding={ groupPadding }>
|
|
728
|
+
{ primaryEntries.map( ( { series: seriesData, index } ) => (
|
|
729
|
+
<BarSeries
|
|
730
|
+
key={ seriesData?.label }
|
|
731
|
+
dataKey={ seriesData?.label }
|
|
732
|
+
data={ seriesData.data as DataPointDate[] }
|
|
733
|
+
yAccessor={ chartOptions.accessors.yAccessor }
|
|
734
|
+
xAccessor={ chartOptions.accessors.xAccessor }
|
|
735
|
+
colorAccessor={ getBarBackground( index ) }
|
|
736
|
+
/>
|
|
737
|
+
) ) }
|
|
738
|
+
</BarGroup>
|
|
739
|
+
) }
|
|
740
|
+
{ /* Do not reorder: for one key the last showTooltip wins, so this must run after the primary series. */ }
|
|
720
741
|
{ ( withTooltips || onPointerDown || onPointerUp ) && (
|
|
721
742
|
<BandTooltip
|
|
722
743
|
keys={ primaryKeys }
|
|
@@ -753,6 +774,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
753
774
|
<AccessibleTooltip
|
|
754
775
|
tooltipPlacement={ tooltipPlacement }
|
|
755
776
|
tooltipAnchorTop={ tooltipAnchorTop }
|
|
777
|
+
style={ tooltipStyle }
|
|
756
778
|
detectBounds
|
|
757
779
|
snapTooltipToDatumX
|
|
758
780
|
snapTooltipToDatumY
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { DataContext, GlyphSeries } from '@visx/xychart';
|
|
2
|
+
import clsx from 'clsx';
|
|
3
|
+
import { useCallback, useContext, useMemo } from 'react';
|
|
4
|
+
import { createGroupScale } from './band-scale';
|
|
5
|
+
import { getValueScaleBaseline } from './comparison-bars-geometry';
|
|
6
|
+
import type { BandScale } from './band-scale';
|
|
7
|
+
import type { DataPointDate } from '../../../types';
|
|
8
|
+
import type { BarChartProps } from '../types';
|
|
9
|
+
import type { AxisScale } from '@visx/axis';
|
|
10
|
+
import type { GlyphProps, SeriesProps } from '@visx/xychart';
|
|
11
|
+
|
|
12
|
+
type Props = Pick<
|
|
13
|
+
SeriesProps< AxisScale, AxisScale, DataPointDate >,
|
|
14
|
+
'xAccessor' | 'yAccessor'
|
|
15
|
+
> & {
|
|
16
|
+
dataKey: string;
|
|
17
|
+
data: DataPointDate[];
|
|
18
|
+
colorAccessor: ( datum: DataPointDate ) => string;
|
|
19
|
+
barClassName: NonNullable< BarChartProps[ 'barClassName' ] >;
|
|
20
|
+
primaryKeys: string[];
|
|
21
|
+
groupPadding: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const ClassifiedBarSeries = ( { barClassName, primaryKeys, groupPadding, ...props }: Props ) => {
|
|
25
|
+
const { xScale, yScale, horizontal } = useContext( DataContext );
|
|
26
|
+
const bandScale = ( horizontal ? yScale : xScale ) as BandScale | undefined;
|
|
27
|
+
const valueScale = horizontal ? xScale : yScale;
|
|
28
|
+
const groupScale = useMemo(
|
|
29
|
+
() => createGroupScale( primaryKeys, bandScale?.bandwidth?.() ?? 0, groupPadding ),
|
|
30
|
+
[ primaryKeys, bandScale, groupPadding ]
|
|
31
|
+
);
|
|
32
|
+
const renderGlyph = useCallback(
|
|
33
|
+
( {
|
|
34
|
+
datum,
|
|
35
|
+
x,
|
|
36
|
+
y,
|
|
37
|
+
color,
|
|
38
|
+
onBlur,
|
|
39
|
+
onFocus,
|
|
40
|
+
onPointerMove,
|
|
41
|
+
onPointerOut,
|
|
42
|
+
onPointerUp,
|
|
43
|
+
}: GlyphProps< DataPointDate > ) => {
|
|
44
|
+
if ( ! bandScale?.bandwidth || ! valueScale ) return null;
|
|
45
|
+
const baseline = getValueScaleBaseline(
|
|
46
|
+
valueScale as Parameters< typeof getValueScaleBaseline >[ 0 ]
|
|
47
|
+
);
|
|
48
|
+
const position =
|
|
49
|
+
Number( bandScale( ( horizontal ? props.yAccessor : props.xAccessor )( datum ) ) ) +
|
|
50
|
+
( groupScale( props.dataKey ) ?? 0 );
|
|
51
|
+
const value = horizontal ? x : y;
|
|
52
|
+
return (
|
|
53
|
+
<rect
|
|
54
|
+
className={ clsx( 'visx-bar', barClassName( datum ) ) }
|
|
55
|
+
// visx's own Bars makes a bar focusable on the same condition.
|
|
56
|
+
tabIndex={ onFocus || onBlur ? 0 : undefined }
|
|
57
|
+
onBlur={ onBlur }
|
|
58
|
+
onFocus={ onFocus }
|
|
59
|
+
onPointerMove={ onPointerMove }
|
|
60
|
+
onPointerOut={ onPointerOut }
|
|
61
|
+
onPointerUp={ onPointerUp }
|
|
62
|
+
// Same arithmetic as visx BaseBarGroup, so rounding matches the unclassified bars.
|
|
63
|
+
x={ horizontal ? baseline + Math.min( 0, value - baseline ) : position }
|
|
64
|
+
y={ horizontal ? position : baseline + Math.min( 0, value - baseline ) }
|
|
65
|
+
width={ horizontal ? Math.abs( value - baseline ) : groupScale.bandwidth() }
|
|
66
|
+
height={ horizontal ? groupScale.bandwidth() : Math.abs( value - baseline ) }
|
|
67
|
+
fill={ color }
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
},
|
|
71
|
+
[
|
|
72
|
+
bandScale,
|
|
73
|
+
valueScale,
|
|
74
|
+
horizontal,
|
|
75
|
+
groupScale,
|
|
76
|
+
props.dataKey,
|
|
77
|
+
props.xAccessor,
|
|
78
|
+
props.yAccessor,
|
|
79
|
+
barClassName,
|
|
80
|
+
]
|
|
81
|
+
);
|
|
82
|
+
return <GlyphSeries { ...props } renderGlyph={ renderGlyph } />;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export default ClassifiedBarSeries;
|
|
@@ -3,9 +3,8 @@ export type ComparisonRect = { x: number; y: number; width: number; height: numb
|
|
|
3
3
|
type ValueScale = ( ( v: number ) => number ) & { range: () => unknown[] };
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* sit on the same baseline as primary bars.
|
|
6
|
+
* Mirrors visx's getScaleBaseline so comparison shadows and primary bars
|
|
7
|
+
* share the same baseline, including out-of-domain zero values.
|
|
9
8
|
*
|
|
10
9
|
* @param {ValueScale} scale - The continuous value scale.
|
|
11
10
|
* @return {number} The baseline output position in pixels.
|
|
@@ -20,9 +19,7 @@ export function getValueScaleBaseline( scale: ValueScale ): number {
|
|
|
20
19
|
? Math.min( Math.max( minOutput, maybeZero ), maxOutput )
|
|
21
20
|
: maxOutput;
|
|
22
21
|
}
|
|
23
|
-
return Number.isFinite( maybeZero )
|
|
24
|
-
? Math.min( Math.max( maybeZero, minOutput ), maxOutput )
|
|
25
|
-
: minOutput;
|
|
22
|
+
return Number.isFinite( maybeZero ) ? Math.max( maybeZero, minOutput ) : minOutput;
|
|
26
23
|
}
|
|
27
24
|
|
|
28
25
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { useBarChartOptions } from './use-bar-chart-options';
|
|
2
2
|
export { TruncatedXTickComponent, TruncatedYTickComponent } from './truncated-tick-component';
|
|
3
|
+
export { default as ClassifiedBarSeries } from './classified-bar-series';
|
|
3
4
|
export { ComparisonBars } from './comparison-bars';
|
|
4
5
|
export type { ComparisonSeriesEntry } from './comparison-bars';
|
|
5
6
|
export {
|
|
@@ -9,12 +9,11 @@ describe( 'getValueScaleBaseline', () => {
|
|
|
9
9
|
expect( getValueScaleBaseline( scale ) ).toBe( 200 );
|
|
10
10
|
} );
|
|
11
11
|
|
|
12
|
-
it( '
|
|
12
|
+
it( 'preserves an out-of-range zero baseline for an ascending scale', () => {
|
|
13
13
|
// horizontal linear scale: range [0, 200], domain [-100, -10] (zero excluded)
|
|
14
14
|
const scale = ( ( v: number ) => ( ( v + 100 ) / 90 ) * 200 ) as never;
|
|
15
15
|
( scale as { range: () => number[] } ).range = () => [ 0, 200 ];
|
|
16
|
-
|
|
17
|
-
expect( getValueScaleBaseline( scale ) ).toBe( 200 );
|
|
16
|
+
expect( getValueScaleBaseline( scale ) ).toBeCloseTo( 222.222222 );
|
|
18
17
|
} );
|
|
19
18
|
} );
|
|
20
19
|
|
|
@@ -14,7 +14,7 @@ import { steadyTrafficData } from '../../../stories/sample-data';
|
|
|
14
14
|
import BarChart, { BarChartUnresponsive } from '../bar-chart';
|
|
15
15
|
import { useBarChartOptions } from '../private';
|
|
16
16
|
import type { GlobalChartsContextValue } from '../../../providers/chart-context/types';
|
|
17
|
-
import type { SeriesData } from '../../../types';
|
|
17
|
+
import type { DataPointDate, SeriesData } from '../../../types';
|
|
18
18
|
|
|
19
19
|
// Mock useElementSize to return non-zero dimensions in jsdom so charts render
|
|
20
20
|
const mockRefCallback = jest.fn();
|
|
@@ -60,6 +60,70 @@ describe( 'BarChart', () => {
|
|
|
60
60
|
);
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
test.each(
|
|
64
|
+
[ 'vertical', 'horizontal' ].flatMap( orientation =>
|
|
65
|
+
[
|
|
66
|
+
[ 10, 20, 30 ],
|
|
67
|
+
[ -20, -10, -30 ],
|
|
68
|
+
[ -20, 10, 30 ],
|
|
69
|
+
].map( values => ( { orientation, values } ) )
|
|
70
|
+
)
|
|
71
|
+
)(
|
|
72
|
+
'adds datum classes without changing $orientation bar attributes for $values',
|
|
73
|
+
async ( { orientation, values } ) => {
|
|
74
|
+
const series = {
|
|
75
|
+
...defaultProps.data[ 0 ],
|
|
76
|
+
data: defaultProps.data[ 0 ].data.map( ( datum, index ) => ( {
|
|
77
|
+
...datum,
|
|
78
|
+
value: values[ index ],
|
|
79
|
+
} ) ),
|
|
80
|
+
};
|
|
81
|
+
const props = {
|
|
82
|
+
orientation,
|
|
83
|
+
data: [ series, { ...series, label: 'Series B' } ],
|
|
84
|
+
};
|
|
85
|
+
const view = renderWithTheme( props );
|
|
86
|
+
await waitFor( () => expect( getBarRects() ).toHaveLength( 6 ) );
|
|
87
|
+
const attributes = () =>
|
|
88
|
+
Array.from( getBarRects(), bar =>
|
|
89
|
+
[ 'x', 'y', 'width', 'height', 'fill', 'tabindex' ].map( attribute =>
|
|
90
|
+
bar.getAttribute( attribute )
|
|
91
|
+
)
|
|
92
|
+
);
|
|
93
|
+
const original = attributes();
|
|
94
|
+
view.unmount();
|
|
95
|
+
renderWithTheme( {
|
|
96
|
+
...props,
|
|
97
|
+
barClassName: ( datum: DataPointDate ) =>
|
|
98
|
+
datum.value === values[ 0 ] ? 'first-value' : undefined,
|
|
99
|
+
} );
|
|
100
|
+
await waitFor( () => expect( getBarRects() ).toHaveLength( 6 ) );
|
|
101
|
+
expect( attributes() ).toEqual( original );
|
|
102
|
+
expect( getBarRects()[ 0 ] ).toHaveClass( 'visx-bar', 'first-value' );
|
|
103
|
+
expect( getBarRects()[ 1 ] ).not.toHaveClass( 'first-value' );
|
|
104
|
+
expect( getBarRects()[ 3 ] ).toHaveClass( 'first-value' );
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
test( 'applies tooltip style overrides while keeping keyboard tooltip content', async () => {
|
|
109
|
+
const user = userEvent.setup();
|
|
110
|
+
renderWithTheme( {
|
|
111
|
+
withTooltips: true,
|
|
112
|
+
barClassName: () => 'styled-bar',
|
|
113
|
+
tooltipStyle: { padding: 0, backgroundColor: 'transparent', boxShadow: 'none' },
|
|
114
|
+
} );
|
|
115
|
+
await user.tab();
|
|
116
|
+
await user.keyboard( '{ArrowRight}' );
|
|
117
|
+
const tooltip = await screen.findByRole( 'tooltip' );
|
|
118
|
+
expect( tooltip ).toHaveTextContent( 'Jan 1' );
|
|
119
|
+
expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( {
|
|
120
|
+
padding: '0px',
|
|
121
|
+
// jsdom normalizes transparent in computed styles.
|
|
122
|
+
'background-color': 'rgba(0, 0, 0, 0)',
|
|
123
|
+
'box-shadow': 'none',
|
|
124
|
+
} );
|
|
125
|
+
} );
|
|
126
|
+
|
|
63
127
|
describe( 'pointer selection and focus return', () => {
|
|
64
128
|
const screenTransform = Object.getOwnPropertyDescriptor( SVGElement.prototype, 'getScreenCTM' );
|
|
65
129
|
beforeAll( () => {
|
|
@@ -677,6 +741,71 @@ describe( 'BarChart', () => {
|
|
|
677
741
|
} );
|
|
678
742
|
} );
|
|
679
743
|
|
|
744
|
+
describe( 'Grid tick counts', () => {
|
|
745
|
+
const getPositions = ( selector: string, coordinate: string ) => {
|
|
746
|
+
// eslint-disable-next-line testing-library/no-node-access -- See the visx node constraint above.
|
|
747
|
+
const lines = screen.getByRole( 'grid' ).querySelectorAll( selector );
|
|
748
|
+
return Array.from( lines, line => Number( line.getAttribute( coordinate ) ) );
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
test.each( [ 'x', 'y' ] )( 'aligns an explicit tick count with the %s axis', axis => {
|
|
752
|
+
renderWithTheme( {
|
|
753
|
+
data: [
|
|
754
|
+
{
|
|
755
|
+
label: 'Views',
|
|
756
|
+
data: Array.from( { length: 8 }, ( _, index ) => ( {
|
|
757
|
+
label: `Day ${ index + 1 }`,
|
|
758
|
+
value: ( index + 1 ) * 10,
|
|
759
|
+
} ) ),
|
|
760
|
+
},
|
|
761
|
+
],
|
|
762
|
+
gridVisibility: 'xy',
|
|
763
|
+
options: {
|
|
764
|
+
axis: { [ axis ]: { numTicks: 2, tickFormat: String, axisClassName: 'test-value-axis' } },
|
|
765
|
+
},
|
|
766
|
+
} );
|
|
767
|
+
const grid = getPositions(
|
|
768
|
+
axis === 'x' ? '.visx-columns line' : '.visx-rows line',
|
|
769
|
+
`${ axis }1`
|
|
770
|
+
);
|
|
771
|
+
const ticks = getPositions( '.test-value-axis .visx-axis-tick line', `${ axis }1` );
|
|
772
|
+
expect( ticks.length ).toBeGreaterThan( 0 );
|
|
773
|
+
expect( grid ).toEqual( ticks );
|
|
774
|
+
} );
|
|
775
|
+
|
|
776
|
+
test.each( [ false, true ] )(
|
|
777
|
+
'preserves the default tick count (horizontal=%s)',
|
|
778
|
+
horizontal => {
|
|
779
|
+
const axis = horizontal ? 'x' : 'y';
|
|
780
|
+
const positionsFor = ( axisOptions: object ) => {
|
|
781
|
+
const { unmount } = renderWithTheme( {
|
|
782
|
+
orientation: horizontal ? 'horizontal' : 'vertical',
|
|
783
|
+
gridVisibility: 'xy',
|
|
784
|
+
options: {
|
|
785
|
+
axis: { [ axis ]: { axisClassName: 'test-value-axis', ...axisOptions } },
|
|
786
|
+
},
|
|
787
|
+
} );
|
|
788
|
+
const positions = {
|
|
789
|
+
grid: getPositions(
|
|
790
|
+
horizontal ? '.visx-columns line' : '.visx-rows line',
|
|
791
|
+
`${ axis }1`
|
|
792
|
+
),
|
|
793
|
+
ticks: getPositions( '.test-value-axis .visx-axis-tick line', `${ axis }1` ),
|
|
794
|
+
};
|
|
795
|
+
unmount();
|
|
796
|
+
return positions;
|
|
797
|
+
};
|
|
798
|
+
|
|
799
|
+
const omitted = positionsFor( {} );
|
|
800
|
+
const explicit = positionsFor( { numTicks: 4 } );
|
|
801
|
+
|
|
802
|
+
expect( omitted.ticks.length ).toBeGreaterThan( 0 );
|
|
803
|
+
expect( omitted.grid ).toEqual( omitted.ticks );
|
|
804
|
+
expect( omitted.grid ).toEqual( explicit.grid );
|
|
805
|
+
}
|
|
806
|
+
);
|
|
807
|
+
} );
|
|
808
|
+
|
|
680
809
|
describe( 'Grid Visibility', () => {
|
|
681
810
|
test( 'renders with different grid visibility options', () => {
|
|
682
811
|
const { rerender } = renderWithTheme( { gridVisibility: 'x' } );
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
SeriesVisibilityProps,
|
|
7
7
|
} from '../../types';
|
|
8
8
|
import type { RenderTooltipParams } from '../../visx/types';
|
|
9
|
-
import type { ReactNode } from 'react';
|
|
9
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
10
10
|
|
|
11
11
|
export type BandHighlightSelection = {
|
|
12
12
|
datum: DataPointDate;
|
|
@@ -28,6 +28,10 @@ export interface BarChartProps extends BaseChartProps< SeriesData[] >, SeriesVis
|
|
|
28
28
|
tooltipPlacement?: 'auto' | 'beside';
|
|
29
29
|
/** Tooltip top anchor in SVG coordinates; negative offsets are supported. */
|
|
30
30
|
tooltipAnchorTop?: number;
|
|
31
|
+
/** Inline styles merged over the tooltip box defaults. */
|
|
32
|
+
tooltipStyle?: CSSProperties;
|
|
33
|
+
/** CSS class for each primary bar, in addition to visx-bar. */
|
|
34
|
+
barClassName?: ( datum: DataPointDate ) => string | undefined;
|
|
31
35
|
orientation?: 'horizontal' | 'vertical';
|
|
32
36
|
withPatterns?: boolean;
|
|
33
37
|
showZeroValues?: boolean;
|