@automattic/charts 4.14.0 → 4.15.1
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 +18 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +649 -882
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +58 -1
- package/dist/index.d.cts +41 -7
- package/dist/index.d.ts +41 -7
- package/dist/index.js +650 -883
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/area-chart/area-chart.tsx +29 -20
- package/src/charts/area-chart/types.ts +3 -1
- package/src/charts/bar-chart/bar-chart.module.scss +38 -0
- package/src/charts/bar-chart/bar-chart.tsx +159 -56
- package/src/charts/bar-chart/private/band-tooltip.tsx +40 -5
- package/src/charts/bar-chart/private/bar-layer.tsx +66 -13
- package/src/charts/bar-chart/private/category.ts +25 -0
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +13 -1
- package/src/charts/bar-chart/private/index.ts +3 -1
- package/src/charts/bar-chart/private/selected-bar-ring.tsx +17 -6
- package/src/charts/bar-chart/private/stack-layout.ts +99 -0
- package/src/charts/bar-chart/private/stacked-bar-series.tsx +145 -0
- package/src/charts/bar-chart/private/test/stack-layout.test.ts +81 -0
- package/src/charts/bar-chart/private/test/value-domain.test.ts +10 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +35 -11
- package/src/charts/bar-chart/private/{use-overlap-slots.ts → use-bar-slots.ts} +36 -38
- package/src/charts/bar-chart/private/use-bar-stops.ts +19 -19
- package/src/charts/bar-chart/private/value-domain.ts +10 -8
- package/src/charts/bar-chart/test/bar-chart-stacked.test.tsx +546 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +75 -0
- package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +18 -0
- package/src/charts/bar-chart/types.ts +3 -1
- package/src/charts/bar-chart/use-bar-colors.ts +12 -0
- package/src/charts/bar-chart/use-bar-legend-items.ts +4 -2
- package/src/charts/bar-list-chart/bar-list-chart.tsx +2 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +43 -1
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +25 -20
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +15 -20
- package/src/charts/line-chart/line-chart.tsx +55 -26
- package/src/charts/line-chart/test/line-chart.test.tsx +86 -0
- package/src/charts/line-chart/types.ts +3 -1
- package/src/charts/private/test/compact-when-short.test.tsx +170 -0
- package/src/charts/private/test/tick-decimals.test.tsx +95 -0
- package/src/charts/private/tick-decimals.tsx +132 -0
- package/src/charts/private/use-compact-when-short.ts +18 -0
- package/src/providers/chart-context/themes.ts +4 -0
- package/src/types.ts +35 -2
- package/src/charts/private/test/whole-number-ticks.test.tsx +0 -57
- package/src/charts/private/whole-number-ticks.tsx +0 -79
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { GlobalChartsProvider } from '../../../providers';
|
|
4
|
+
import { selectedBarRingBounds as selectedRing } from '../../../test-utils/selected-bar-ring';
|
|
5
|
+
import { BarChartUnresponsive } from '../bar-chart';
|
|
6
|
+
import type { SeriesData } from '../../../types';
|
|
7
|
+
import type { BarChartProps } from '../types';
|
|
8
|
+
|
|
9
|
+
const mockRefCallback = jest.fn();
|
|
10
|
+
jest.mock( '../../../hooks/use-element-size', () => ( {
|
|
11
|
+
useElementSize: () => [ mockRefCallback, 500, 300 ],
|
|
12
|
+
} ) );
|
|
13
|
+
|
|
14
|
+
// The anchor the tooltip snaps to, before the box is placed against bounds jsdom cannot measure.
|
|
15
|
+
const anchors: Array< { left?: number; top?: number } > = [];
|
|
16
|
+
jest.mock( '../../../components/tooltip/private/bounded-tooltip', () => {
|
|
17
|
+
const actual = jest.requireActual( '../../../components/tooltip/private/bounded-tooltip' );
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
BoundedTooltip: ( props: { left?: number; top?: number } ) => {
|
|
21
|
+
anchors.push( { left: props.left, top: props.top } );
|
|
22
|
+
return actual.BoundedTooltip( props );
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
} );
|
|
26
|
+
const lastAnchor = () => anchors[ anchors.length - 1 ];
|
|
27
|
+
|
|
28
|
+
const day = ( label: string, value: number | null ) => ( { label, value } );
|
|
29
|
+
|
|
30
|
+
const DATA: SeriesData[] = [
|
|
31
|
+
{ label: 'Desktop', data: [ day( 'Mon', 100 ), day( 'Tue', 50 ) ] },
|
|
32
|
+
{ label: 'Mobile', data: [ day( 'Mon', 60 ), day( 'Tue', null ) ] },
|
|
33
|
+
{ label: 'Tablet', data: [ day( 'Mon', 20 ), day( 'Tue', 10 ) ] },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
type Rect = { x: number; y: number; width: number; height: number };
|
|
37
|
+
|
|
38
|
+
const barRects = () => screen.getAllByTestId( 'bar-chart-stacked-bar' );
|
|
39
|
+
|
|
40
|
+
// visx draws the axes and the pointer target; neither accepts a test id.
|
|
41
|
+
/* eslint-disable testing-library/no-node-access */
|
|
42
|
+
const pointer = ( type: string, clientX: number, clientY = 150 ) =>
|
|
43
|
+
fireEvent(
|
|
44
|
+
screen.getByRole( 'application' ).querySelector( 'svg > rect[fill="transparent"]' ),
|
|
45
|
+
new MouseEvent( type, { bubbles: true, clientX, clientY } )
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// The x axis renders first; the value axis is the y axis unless the bars run horizontally.
|
|
49
|
+
const valueTicks = ( horizontal = false ) =>
|
|
50
|
+
Array.from(
|
|
51
|
+
screen
|
|
52
|
+
.getByRole( 'application' )
|
|
53
|
+
.querySelectorAll( '.visx-axis' )
|
|
54
|
+
[ horizontal ? 0 : 1 ].querySelectorAll( '.visx-axis-tick text' )
|
|
55
|
+
).map( tick => Number( tick.textContent?.replace( /,/g, '' ) ) );
|
|
56
|
+
|
|
57
|
+
// The x axis sits on the bottom edge of the plot, placed by its group's transform.
|
|
58
|
+
const plotBottom = () => {
|
|
59
|
+
const transform =
|
|
60
|
+
screen.getByRole( 'application' ).querySelector( '.visx-axis' )?.getAttribute( 'transform' ) ??
|
|
61
|
+
'';
|
|
62
|
+
return Number( transform.match( /translate\(\s*[-\d.]+,\s*([-\d.]+)\)/ )?.[ 1 ] );
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const categoryTicks = () =>
|
|
66
|
+
Array.from(
|
|
67
|
+
screen
|
|
68
|
+
.getByRole( 'application' )
|
|
69
|
+
.querySelectorAll( '.visx-axis' )[ 0 ]
|
|
70
|
+
.querySelectorAll( '.visx-axis-tick line' )
|
|
71
|
+
).map( tick => Number( tick.getAttribute( 'x1' ) ) );
|
|
72
|
+
/* eslint-enable testing-library/no-node-access */
|
|
73
|
+
|
|
74
|
+
const geometry = ( element: Element ): Rect => ( {
|
|
75
|
+
x: Number( element.getAttribute( 'x' ) ),
|
|
76
|
+
y: Number( element.getAttribute( 'y' ) ),
|
|
77
|
+
width: Number( element.getAttribute( 'width' ) ),
|
|
78
|
+
height: Number( element.getAttribute( 'height' ) ),
|
|
79
|
+
} );
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A drawn bar: the series' bars follow each other in data order, each skipping a missing reading.
|
|
83
|
+
*
|
|
84
|
+
* @param data - The series the chart draws.
|
|
85
|
+
* @param label - The series.
|
|
86
|
+
* @param category - The category's index in that series.
|
|
87
|
+
* @return The bar's geometry.
|
|
88
|
+
*/
|
|
89
|
+
const barIn = ( data: SeriesData[], label: string, category = 0 ): Rect => {
|
|
90
|
+
let index = 0;
|
|
91
|
+
for ( const series of data ) {
|
|
92
|
+
const drawn = series.data.map( point => point.value !== null );
|
|
93
|
+
if ( series.label === label ) {
|
|
94
|
+
return geometry( barRects()[ index + drawn.slice( 0, category ).filter( Boolean ).length ] );
|
|
95
|
+
}
|
|
96
|
+
index += drawn.filter( Boolean ).length;
|
|
97
|
+
}
|
|
98
|
+
throw new Error( `No series ${ label }` );
|
|
99
|
+
};
|
|
100
|
+
const barOf = ( label: string, category = 0 ) => barIn( DATA, label, category );
|
|
101
|
+
|
|
102
|
+
const renderStacked = ( props: Partial< BarChartProps > = {} ) =>
|
|
103
|
+
render(
|
|
104
|
+
<GlobalChartsProvider>
|
|
105
|
+
<BarChartUnresponsive
|
|
106
|
+
width={ 500 }
|
|
107
|
+
height={ 300 }
|
|
108
|
+
data={ DATA }
|
|
109
|
+
layout="stacked"
|
|
110
|
+
withTooltips
|
|
111
|
+
{ ...props }
|
|
112
|
+
/>
|
|
113
|
+
</GlobalChartsProvider>
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const center = ( { x, width }: Rect ) => x + width / 2;
|
|
117
|
+
const middle = ( { y, height }: Rect ) => y + height / 2;
|
|
118
|
+
|
|
119
|
+
const union = ( rects: Rect[] ): Rect => {
|
|
120
|
+
const x = Math.min( ...rects.map( r => r.x ) );
|
|
121
|
+
const y = Math.min( ...rects.map( r => r.y ) );
|
|
122
|
+
return {
|
|
123
|
+
x,
|
|
124
|
+
y,
|
|
125
|
+
width: Math.max( ...rects.map( r => r.x + r.width ) ) - x,
|
|
126
|
+
height: Math.max( ...rects.map( r => r.y + r.height ) ) - y,
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const rounded = ( rect: Rect ) =>
|
|
131
|
+
Object.fromEntries(
|
|
132
|
+
Object.entries( rect ).map( ( [ side, value ] ) => [ side, value.toFixed( 1 ) ] )
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
describe( 'BarChart stacked layout', () => {
|
|
136
|
+
const screenTransform = Object.getOwnPropertyDescriptor( SVGElement.prototype, 'getScreenCTM' );
|
|
137
|
+
beforeAll( () => {
|
|
138
|
+
// jsdom lacks the SVG transform used by visx to convert pointer coordinates.
|
|
139
|
+
Object.defineProperty( SVGElement.prototype, 'getScreenCTM', {
|
|
140
|
+
configurable: true,
|
|
141
|
+
value: () => null,
|
|
142
|
+
} );
|
|
143
|
+
} );
|
|
144
|
+
|
|
145
|
+
beforeEach( () => {
|
|
146
|
+
anchors.length = 0;
|
|
147
|
+
} );
|
|
148
|
+
|
|
149
|
+
afterAll( () => {
|
|
150
|
+
if ( screenTransform ) {
|
|
151
|
+
Object.defineProperty( SVGElement.prototype, 'getScreenCTM', screenTransform );
|
|
152
|
+
} else {
|
|
153
|
+
Reflect.deleteProperty( SVGElement.prototype, 'getScreenCTM' );
|
|
154
|
+
}
|
|
155
|
+
} );
|
|
156
|
+
|
|
157
|
+
it( 'stacks each category bottom to top in data order, one bar wide', () => {
|
|
158
|
+
renderStacked();
|
|
159
|
+
|
|
160
|
+
expect( barRects() ).toHaveLength( 5 );
|
|
161
|
+
const [ desktop, mobile, tablet ] = [ 'Desktop', 'Mobile', 'Tablet' ].map( label =>
|
|
162
|
+
barOf( label )
|
|
163
|
+
);
|
|
164
|
+
expect( mobile.y + mobile.height ).toBeCloseTo( desktop.y );
|
|
165
|
+
expect( tablet.y + tablet.height ).toBeCloseTo( mobile.y );
|
|
166
|
+
expect( new Set( [ desktop.x, mobile.x, tablet.x ] ).size ).toBe( 1 );
|
|
167
|
+
expect( new Set( [ desktop.width, mobile.width, tablet.width ] ).size ).toBe( 1 );
|
|
168
|
+
} );
|
|
169
|
+
|
|
170
|
+
it( 'centers each stack on its category', () => {
|
|
171
|
+
renderStacked();
|
|
172
|
+
|
|
173
|
+
const [ monday, tuesday ] = categoryTicks();
|
|
174
|
+
expect( center( barOf( 'Desktop' ) ) ).toBeCloseTo( monday );
|
|
175
|
+
expect( center( barOf( 'Desktop', 1 ) ) ).toBeCloseTo( tuesday );
|
|
176
|
+
} );
|
|
177
|
+
|
|
178
|
+
it( 'scales the value axis to the tallest stack', () => {
|
|
179
|
+
renderStacked();
|
|
180
|
+
|
|
181
|
+
// The tallest single bar is 100; a domain short of the 180 stack would clip Tablet's top segment.
|
|
182
|
+
expect( barOf( 'Tablet' ).height / barOf( 'Desktop' ).height ).toBeCloseTo( 20 / 100 );
|
|
183
|
+
} );
|
|
184
|
+
|
|
185
|
+
it( 'widens small stacked totals so rounded value ticks stay distinct', () => {
|
|
186
|
+
renderStacked( {
|
|
187
|
+
data: [
|
|
188
|
+
{ label: 'A', data: [ day( 'Mon', 0.1 ) ] },
|
|
189
|
+
{ label: 'B', data: [ day( 'Mon', 0.15 ) ] },
|
|
190
|
+
],
|
|
191
|
+
options: {
|
|
192
|
+
axis: { y: { tickDecimals: 0, tickFormat: value => Number( value ).toFixed( 0 ) } },
|
|
193
|
+
},
|
|
194
|
+
} );
|
|
195
|
+
|
|
196
|
+
expect( valueTicks() ).toEqual( [ 0, 1 ] );
|
|
197
|
+
} );
|
|
198
|
+
|
|
199
|
+
it( 'keeps the stack inside a caller domain that leaves out zero', () => {
|
|
200
|
+
renderStacked( { options: { yScale: { domain: [ 50, 200 ] } } } );
|
|
201
|
+
|
|
202
|
+
const desktop = barOf( 'Desktop' );
|
|
203
|
+
expect( desktop.y + desktop.height ).toBeCloseTo( plotBottom() );
|
|
204
|
+
} );
|
|
205
|
+
|
|
206
|
+
it.each< [ string, NonNullable< BarChartProps[ 'options' ] >[ 'yScale' ], string ] >( [
|
|
207
|
+
[ 'a log scale', { type: 'log' }, 'log scale is ignored' ],
|
|
208
|
+
[ 'a sqrt scale', { type: 'sqrt' }, 'sqrt scale is ignored' ],
|
|
209
|
+
[ 'zero: false', { zero: false }, 'zero: false has no effect' ],
|
|
210
|
+
] )( 'says %s on the value axis is ignored', ( _name, yScale, message ) => {
|
|
211
|
+
const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
|
|
212
|
+
renderStacked( { options: { yScale } } );
|
|
213
|
+
|
|
214
|
+
expect( warn ).toHaveBeenCalledWith( expect.stringContaining( message ) );
|
|
215
|
+
warn.mockRestore();
|
|
216
|
+
} );
|
|
217
|
+
|
|
218
|
+
it.each( [ 'vertical', 'horizontal' ] as const )(
|
|
219
|
+
'draws a %s stack on a linear value scale when a log scale is asked for',
|
|
220
|
+
orientation => {
|
|
221
|
+
const scale = orientation === 'horizontal' ? 'xScale' : 'yScale';
|
|
222
|
+
const bars = ( options: BarChartProps[ 'options' ] ) => {
|
|
223
|
+
const { unmount } = renderStacked( { orientation, options } );
|
|
224
|
+
const drawn = barRects().map( geometry );
|
|
225
|
+
unmount();
|
|
226
|
+
return drawn;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
expect( bars( { [ scale ]: { type: 'log', zero: false } } ) ).toEqual( bars( {} ) );
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
it.each( [ 'vertical', 'horizontal' ] as const )(
|
|
234
|
+
'anchors the %s tooltip at the drawn stack end when its total exceeds the caller domain',
|
|
235
|
+
async orientation => {
|
|
236
|
+
const user = userEvent.setup();
|
|
237
|
+
const horizontal = orientation === 'horizontal';
|
|
238
|
+
renderStacked( {
|
|
239
|
+
orientation,
|
|
240
|
+
options: horizontal
|
|
241
|
+
? { xScale: { domain: [ 0, 100 ] } }
|
|
242
|
+
: { yScale: { domain: [ 0, 100 ] } },
|
|
243
|
+
} );
|
|
244
|
+
|
|
245
|
+
await user.tab();
|
|
246
|
+
await user.keyboard( '{ArrowRight}' );
|
|
247
|
+
const desktop = barOf( 'Desktop' );
|
|
248
|
+
expect( Number( lastAnchor()[ horizontal ? 'left' : 'top' ] ) ).toBeCloseTo(
|
|
249
|
+
horizontal ? desktop.x + desktop.width : desktop.y
|
|
250
|
+
);
|
|
251
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Total: 180' );
|
|
252
|
+
}
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
it.each( [
|
|
256
|
+
[ 'vertical', 'height', 'top' ],
|
|
257
|
+
[ 'horizontal', 'width', 'right' ],
|
|
258
|
+
] as const )(
|
|
259
|
+
'cuts a %s stack off at a caller domain it exceeds, and rounds the end that is drawn',
|
|
260
|
+
( orientation, length, end ) => {
|
|
261
|
+
renderStacked( {
|
|
262
|
+
orientation,
|
|
263
|
+
options:
|
|
264
|
+
orientation === 'horizontal'
|
|
265
|
+
? { xScale: { domain: [ 0, 100 ] } }
|
|
266
|
+
: { yScale: { domain: [ 0, 100 ] } },
|
|
267
|
+
} );
|
|
268
|
+
|
|
269
|
+
expect( barOf( 'Mobile' )[ length ] ).toBeCloseTo( 0 );
|
|
270
|
+
expect( barOf( 'Tablet' )[ length ] ).toBeCloseTo( 0 );
|
|
271
|
+
// Desktop Mon, Desktop Tue, Mobile Mon, Tablet Mon, Tablet Tue.
|
|
272
|
+
expect( barRects().map( bar => bar.getAttribute( 'data-stack-end' ) ) ).toEqual( [
|
|
273
|
+
end,
|
|
274
|
+
null,
|
|
275
|
+
null,
|
|
276
|
+
null,
|
|
277
|
+
end,
|
|
278
|
+
] );
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
it.each< [ string, ( bar: Rect ) => string ] >( [
|
|
283
|
+
[ 'vertical', bar => `0 ${ bar.y + bar.height }px` ],
|
|
284
|
+
[ 'horizontal', bar => `${ bar.x }px 0` ],
|
|
285
|
+
] )( 'grows a %s stack with negative readings from the zero line', ( orientation, origin ) => {
|
|
286
|
+
const data: SeriesData[] = [
|
|
287
|
+
{ label: 'A', data: [ day( 'Mon', 40 ) ] },
|
|
288
|
+
{ label: 'B', data: [ day( 'Mon', -20 ) ] },
|
|
289
|
+
];
|
|
290
|
+
renderStacked( { data, orientation: orientation as BarChartProps[ 'orientation' ] } );
|
|
291
|
+
|
|
292
|
+
expect( screen.getByTestId( 'bar-chart-stack-group' ) ).toHaveStyle( {
|
|
293
|
+
transformOrigin: origin( barIn( data, 'A' ) ),
|
|
294
|
+
} );
|
|
295
|
+
} );
|
|
296
|
+
|
|
297
|
+
it( 'leaves comparison series out of the bars, colors and legend, and says so', () => {
|
|
298
|
+
const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
|
|
299
|
+
const fills = () => barRects().map( bar => bar.getAttribute( 'fill' ) );
|
|
300
|
+
const { unmount } = renderStacked( { showLegend: true } );
|
|
301
|
+
const plainFills = fills();
|
|
302
|
+
unmount();
|
|
303
|
+
|
|
304
|
+
renderStacked( {
|
|
305
|
+
showLegend: true,
|
|
306
|
+
data: [
|
|
307
|
+
...DATA,
|
|
308
|
+
{ label: 'Previous', options: { type: 'comparison' }, data: [ day( 'Mon', 500 ) ] },
|
|
309
|
+
],
|
|
310
|
+
} );
|
|
311
|
+
|
|
312
|
+
expect( fills() ).toEqual( plainFills );
|
|
313
|
+
expect( screen.queryByText( 'Previous' ) ).not.toBeInTheDocument();
|
|
314
|
+
expect( warn ).toHaveBeenCalledWith( expect.stringContaining( 'comparison series' ) );
|
|
315
|
+
warn.mockRestore();
|
|
316
|
+
} );
|
|
317
|
+
|
|
318
|
+
it( 'ignores showZeroValues, so a zero adds nothing to the stack, and says so', () => {
|
|
319
|
+
const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
|
|
320
|
+
const data: SeriesData[] = [
|
|
321
|
+
{ label: 'A', data: [ day( 'Mon', 100 ) ] },
|
|
322
|
+
{ label: 'B', data: [ day( 'Mon', 0 ) ] },
|
|
323
|
+
{ label: 'C', data: [ day( 'Mon', 20 ) ] },
|
|
324
|
+
];
|
|
325
|
+
renderStacked( { data, showZeroValues: true } );
|
|
326
|
+
|
|
327
|
+
expect( barIn( data, 'B' ).height ).toBeCloseTo( 0 );
|
|
328
|
+
const c = barIn( data, 'C' );
|
|
329
|
+
expect( c.y + c.height ).toBeCloseTo( barIn( data, 'A' ).y );
|
|
330
|
+
expect( warn ).toHaveBeenCalledWith( expect.stringContaining( 'showZeroValues' ) );
|
|
331
|
+
warn.mockRestore();
|
|
332
|
+
} );
|
|
333
|
+
|
|
334
|
+
it( 'adds the barClassName classes to stacked bars', () => {
|
|
335
|
+
renderStacked( {
|
|
336
|
+
barClassName: datum => ( datum.label === 'Mon' ? 'is-monday' : undefined ),
|
|
337
|
+
} );
|
|
338
|
+
|
|
339
|
+
expect( barRects().filter( bar => bar.classList.contains( 'is-monday' ) ) ).toHaveLength( 3 );
|
|
340
|
+
} );
|
|
341
|
+
|
|
342
|
+
it( 'lists every series at the hovered category, top first, with a total', async () => {
|
|
343
|
+
renderStacked();
|
|
344
|
+
|
|
345
|
+
pointer( 'pointermove', center( barOf( 'Desktop' ) ) );
|
|
346
|
+
const tooltip = await screen.findByRole( 'tooltip' );
|
|
347
|
+
expect( tooltip ).toHaveTextContent( /Mon.*Tablet: 20.*Mobile: 60.*Desktop: 100.*Total: 180/ );
|
|
348
|
+
} );
|
|
349
|
+
|
|
350
|
+
it( 'lists a series without a reading as no data, and totals the readings', async () => {
|
|
351
|
+
const user = userEvent.setup();
|
|
352
|
+
renderStacked();
|
|
353
|
+
|
|
354
|
+
await user.tab();
|
|
355
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}' );
|
|
356
|
+
expect( screen.getByTestId( 'chart-tooltip-1' ) ).toHaveTextContent(
|
|
357
|
+
/Tue.*Tablet: 10.*Mobile: No data.*Desktop: 50.*Total: 60/
|
|
358
|
+
);
|
|
359
|
+
} );
|
|
360
|
+
|
|
361
|
+
it( 'totals negative and positive readings together', async () => {
|
|
362
|
+
const user = userEvent.setup();
|
|
363
|
+
renderStacked( {
|
|
364
|
+
data: [
|
|
365
|
+
{ label: 'A', data: [ day( 'Mon', 10 ) ] },
|
|
366
|
+
{ label: 'B', data: [ day( 'Mon', -5 ) ] },
|
|
367
|
+
],
|
|
368
|
+
} );
|
|
369
|
+
|
|
370
|
+
await user.tab();
|
|
371
|
+
await user.keyboard( '{ArrowRight}' );
|
|
372
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( 'Total: 5' );
|
|
373
|
+
expect( Math.min( ...valueTicks() ) ).toBeLessThan( 0 );
|
|
374
|
+
} );
|
|
375
|
+
|
|
376
|
+
it( 'leaves the total out when a category has one reading', async () => {
|
|
377
|
+
const user = userEvent.setup();
|
|
378
|
+
renderStacked( {
|
|
379
|
+
data: [
|
|
380
|
+
{ label: 'A', data: [ day( 'Mon', 5 ), day( 'Tue', 7 ) ] },
|
|
381
|
+
{ label: 'B', data: [ day( 'Mon', 3 ), day( 'Tue', null ) ] },
|
|
382
|
+
],
|
|
383
|
+
} );
|
|
384
|
+
|
|
385
|
+
await user.tab();
|
|
386
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}' );
|
|
387
|
+
const tooltip = screen.getByTestId( 'chart-tooltip-1' );
|
|
388
|
+
expect( tooltip ).toHaveTextContent( 'A: 7' );
|
|
389
|
+
expect( tooltip ).toHaveTextContent( 'B: No data' );
|
|
390
|
+
expect( tooltip ).not.toHaveTextContent( 'Total' );
|
|
391
|
+
} );
|
|
392
|
+
|
|
393
|
+
it.each( [ 'vertical', 'horizontal' ] as const )(
|
|
394
|
+
'steps the keyboard one category at a time, outlining the whole %s stack',
|
|
395
|
+
async orientation => {
|
|
396
|
+
const user = userEvent.setup();
|
|
397
|
+
renderStacked( { orientation } );
|
|
398
|
+
|
|
399
|
+
await user.tab();
|
|
400
|
+
await user.keyboard( '{ArrowRight}' );
|
|
401
|
+
expect( rounded( selectedRing() ) ).toEqual(
|
|
402
|
+
rounded( union( [ 'Desktop', 'Mobile', 'Tablet' ].map( label => barOf( label ) ) ) )
|
|
403
|
+
);
|
|
404
|
+
|
|
405
|
+
await user.keyboard( '{ArrowRight}' );
|
|
406
|
+
expect( rounded( selectedRing() ) ).toEqual(
|
|
407
|
+
rounded( union( [ 'Desktop', 'Tablet' ].map( label => barOf( label, 1 ) ) ) )
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
// Monday's top segment is the shorter one, but Monday's stack is the taller one.
|
|
413
|
+
const ANCHOR_DATA: SeriesData[] = [
|
|
414
|
+
{ label: 'Desktop', data: [ day( 'Mon', 100 ), day( 'Tue', 20 ) ] },
|
|
415
|
+
{ label: 'Tablet', data: [ day( 'Mon', 5 ), day( 'Tue', 10 ) ] },
|
|
416
|
+
];
|
|
417
|
+
|
|
418
|
+
const NEGATIVE_DATA: SeriesData[] = [
|
|
419
|
+
{ label: 'Desktop', data: [ day( 'Mon', -100 ) ] },
|
|
420
|
+
{ label: 'Tablet', data: [ day( 'Mon', -5 ) ] },
|
|
421
|
+
];
|
|
422
|
+
|
|
423
|
+
it.each< [ string, string, SeriesData[], 'top' | 'left', ( bar: Rect ) => number ] >( [
|
|
424
|
+
[ 'vertical', 'positive', ANCHOR_DATA, 'top', bar => bar.y ],
|
|
425
|
+
[ 'horizontal', 'positive', ANCHOR_DATA, 'left', bar => bar.x + bar.width ],
|
|
426
|
+
[ 'vertical', 'all-negative', NEGATIVE_DATA, 'top', bar => bar.y + bar.height ],
|
|
427
|
+
[ 'horizontal', 'all-negative', NEGATIVE_DATA, 'left', bar => bar.x ],
|
|
428
|
+
] )(
|
|
429
|
+
'anchors the keyboard tooltip at the drawn end of a %s %s stack',
|
|
430
|
+
async ( orientation, _sign, data, side, end ) => {
|
|
431
|
+
const user = userEvent.setup();
|
|
432
|
+
renderStacked( { orientation: orientation as BarChartProps[ 'orientation' ], data } );
|
|
433
|
+
|
|
434
|
+
await user.tab();
|
|
435
|
+
await user.keyboard( '{ArrowRight}' );
|
|
436
|
+
|
|
437
|
+
expect( Number( lastAnchor()[ side ] ) ).toBeCloseTo( end( barIn( data, 'Tablet' ) ) );
|
|
438
|
+
}
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
it( 'outlines both sides of a stack with positive and negative readings', async () => {
|
|
442
|
+
const user = userEvent.setup();
|
|
443
|
+
const data: SeriesData[] = [
|
|
444
|
+
{ label: 'A', data: [ day( 'Mon', 40 ) ] },
|
|
445
|
+
{ label: 'B', data: [ day( 'Mon', -20 ) ] },
|
|
446
|
+
];
|
|
447
|
+
renderStacked( { data } );
|
|
448
|
+
|
|
449
|
+
await user.tab();
|
|
450
|
+
await user.keyboard( '{ArrowRight}' );
|
|
451
|
+
expect( rounded( selectedRing() ) ).toEqual(
|
|
452
|
+
rounded( union( [ barIn( data, 'A' ), barIn( data, 'B' ) ] ) )
|
|
453
|
+
);
|
|
454
|
+
} );
|
|
455
|
+
|
|
456
|
+
it( 'restacks and rescales when a series is hidden from the legend, and again when restored', async () => {
|
|
457
|
+
const user = userEvent.setup();
|
|
458
|
+
renderStacked( { showLegend: true, legend: { interactive: true } } );
|
|
459
|
+
const mobile = screen.getByRole( 'button', { name: /Mobile/ } );
|
|
460
|
+
const topTick = Math.max( ...valueTicks() );
|
|
461
|
+
|
|
462
|
+
await user.click( mobile );
|
|
463
|
+
const withoutMobile = DATA.filter( series => series.label !== 'Mobile' );
|
|
464
|
+
const tablet = barIn( withoutMobile, 'Tablet' );
|
|
465
|
+
expect( tablet.y + tablet.height ).toBeCloseTo( barIn( withoutMobile, 'Desktop' ).y );
|
|
466
|
+
expect( Math.max( ...valueTicks() ) ).not.toBe( topTick );
|
|
467
|
+
|
|
468
|
+
await user.click( mobile );
|
|
469
|
+
const restored = barOf( 'Mobile' );
|
|
470
|
+
expect( restored.y + restored.height ).toBeCloseTo( barOf( 'Desktop' ).y );
|
|
471
|
+
expect( Math.max( ...valueTicks() ) ).toBe( topTick );
|
|
472
|
+
} );
|
|
473
|
+
|
|
474
|
+
it( 'anchors the hovered tooltip at the stack top when its top series has no reading there', async () => {
|
|
475
|
+
const data: SeriesData[] = [
|
|
476
|
+
{ label: 'Desktop', data: [ day( 'Mon', 100 ), day( 'Tue', 50 ) ] },
|
|
477
|
+
{ label: 'Mobile', data: [ day( 'Mon', 60 ), day( 'Tue', 40 ) ] },
|
|
478
|
+
{ label: 'Tablet', data: [ day( 'Mon', 20 ), day( 'Tue', null ) ] },
|
|
479
|
+
];
|
|
480
|
+
renderStacked( { data } );
|
|
481
|
+
const tuesday = barIn( data, 'Desktop', 1 );
|
|
482
|
+
|
|
483
|
+
for ( const clientY of [ 10, 250 ] ) {
|
|
484
|
+
pointer( 'pointermove', center( tuesday ), clientY );
|
|
485
|
+
await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
|
|
486
|
+
expect( Number( lastAnchor().top ) ).toBeCloseTo( barIn( data, 'Mobile', 1 ).y );
|
|
487
|
+
}
|
|
488
|
+
} );
|
|
489
|
+
|
|
490
|
+
const POINTER_DATA: SeriesData[] = [
|
|
491
|
+
{ label: 'Desktop', data: [ day( 'Mon', 100 ), day( 'Tue', 50 ) ] },
|
|
492
|
+
{ label: 'Mobile', data: [ day( 'Mon', 60 ), day( 'Tue', 40 ) ] },
|
|
493
|
+
{ label: 'Tablet', data: [ day( 'Mon', 20 ), day( 'Tue', null ) ] },
|
|
494
|
+
];
|
|
495
|
+
const on = ( label: string ) => () => {
|
|
496
|
+
const bar = barIn( POINTER_DATA, label, 1 );
|
|
497
|
+
return [ center( bar ), middle( bar ) ] as const;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
it.each( [
|
|
501
|
+
[ 'vertical', 'Desktop', on( 'Desktop' ) ],
|
|
502
|
+
[ 'vertical', 'Mobile', on( 'Mobile' ) ],
|
|
503
|
+
// Above the stack, where the top series has no reading on Tuesday.
|
|
504
|
+
[ 'vertical', 'Mobile', () => [ center( barIn( POINTER_DATA, 'Desktop', 1 ) ), 5 ] as const ],
|
|
505
|
+
[ 'horizontal', 'Desktop', on( 'Desktop' ) ],
|
|
506
|
+
[ 'horizontal', 'Mobile', on( 'Mobile' ) ],
|
|
507
|
+
] as const )(
|
|
508
|
+
'reports, on a %s stack, %s for a pointer over its segment',
|
|
509
|
+
( orientation, key, at ) => {
|
|
510
|
+
const onPointerUp = jest.fn();
|
|
511
|
+
renderStacked( { data: POINTER_DATA, orientation, onPointerUp } );
|
|
512
|
+
|
|
513
|
+
pointer( 'pointerup', ...at() );
|
|
514
|
+
expect( onPointerUp ).toHaveBeenLastCalledWith(
|
|
515
|
+
expect.objectContaining( {
|
|
516
|
+
key,
|
|
517
|
+
datum: POINTER_DATA.find( series => series.label === key )?.data[ 1 ],
|
|
518
|
+
} )
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
it.each( [
|
|
524
|
+
[ 'vertical', 'top', 'bottom' ],
|
|
525
|
+
[ 'horizontal', 'right', 'left' ],
|
|
526
|
+
] as const )(
|
|
527
|
+
'marks only the outer end of each %s stack for rounding',
|
|
528
|
+
( orientation, positiveEnd, negativeEnd ) => {
|
|
529
|
+
const data: SeriesData[] = [
|
|
530
|
+
{ label: 'A', data: [ day( 'Mon', 10 ), day( 'Tue', 8 ) ] },
|
|
531
|
+
{ label: 'B', data: [ day( 'Mon', -5 ), day( 'Tue', 4 ) ] },
|
|
532
|
+
{ label: 'C', data: [ day( 'Mon', 6 ), day( 'Tue', null ) ] },
|
|
533
|
+
];
|
|
534
|
+
renderStacked( { data, orientation } );
|
|
535
|
+
|
|
536
|
+
// Each series' bars, Mon then Tue, skipping C's missing Tuesday.
|
|
537
|
+
expect( barRects().map( bar => bar.getAttribute( 'data-stack-end' ) ) ).toEqual( [
|
|
538
|
+
null,
|
|
539
|
+
null,
|
|
540
|
+
negativeEnd,
|
|
541
|
+
positiveEnd,
|
|
542
|
+
positiveEnd,
|
|
543
|
+
] );
|
|
544
|
+
}
|
|
545
|
+
);
|
|
546
|
+
} );
|
|
@@ -550,6 +550,81 @@ describe( 'BarChart', () => {
|
|
|
550
550
|
}
|
|
551
551
|
);
|
|
552
552
|
|
|
553
|
+
test.each( [
|
|
554
|
+
[ 'small amounts', 0, [ 0.4, 1.2, 2.6 ], [ '0', '1', '2' ] ],
|
|
555
|
+
[ 'amounts under one step', 0, [ 0.31, 0.73 ], [ '0', '1' ] ],
|
|
556
|
+
[ 'cents', 2, [ 0.4, 1.2, 1.6 ], [ '0.00', '0.50', '1.00', '1.50' ] ],
|
|
557
|
+
] )( 'holds value ticks to tickDecimals for %s', ( _, tickDecimals, values, expected ) => {
|
|
558
|
+
renderWithTheme( {
|
|
559
|
+
data: [
|
|
560
|
+
{
|
|
561
|
+
label: 'Series A',
|
|
562
|
+
data: values.map( ( value, i ) => ( { label: `Day ${ i }`, value } ) ),
|
|
563
|
+
},
|
|
564
|
+
],
|
|
565
|
+
options: {
|
|
566
|
+
axis: {
|
|
567
|
+
y: { tickDecimals, tickFormat: ( value: number ) => value.toFixed( tickDecimals ) },
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
} );
|
|
571
|
+
const chart = screen.getByRole( 'application', { name: /bar chart/i } );
|
|
572
|
+
const ticks = within( chart )
|
|
573
|
+
.getAllByText( /^-?[\d.,]+$/ )
|
|
574
|
+
.map( el => el.textContent );
|
|
575
|
+
expect( ticks.sort() ).toEqual( expected );
|
|
576
|
+
} );
|
|
577
|
+
|
|
578
|
+
test.each( [ 'vertical', 'horizontal' ] as const )(
|
|
579
|
+
'keeps logarithmic bars unchanged by tickDecimals (%s)',
|
|
580
|
+
orientation => {
|
|
581
|
+
const valueAxis = orientation === 'horizontal' ? 'x' : 'y';
|
|
582
|
+
const barGeometry = ( tickDecimals?: number ) => {
|
|
583
|
+
const { unmount } = renderWithTheme( {
|
|
584
|
+
orientation,
|
|
585
|
+
data: [
|
|
586
|
+
{
|
|
587
|
+
label: 'Series A',
|
|
588
|
+
data: [ 0.31, 0.73 ].map( ( value, i ) => ( { label: `Day ${ i }`, value } ) ),
|
|
589
|
+
},
|
|
590
|
+
],
|
|
591
|
+
options: {
|
|
592
|
+
[ `${ valueAxis }Scale` ]: { type: 'log', zero: false },
|
|
593
|
+
axis: { [ valueAxis ]: { tickDecimals } },
|
|
594
|
+
},
|
|
595
|
+
} );
|
|
596
|
+
const geometry = Array.from( getBarRects(), geometryOf );
|
|
597
|
+
unmount();
|
|
598
|
+
return geometry;
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const baseline = barGeometry();
|
|
602
|
+
expect( baseline ).toHaveLength( 2 );
|
|
603
|
+
expect( barGeometry( 0 ) ).toEqual( baseline );
|
|
604
|
+
}
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
test( "leaves a caller's value tickValues axis unwidened by tickDecimals", () => {
|
|
608
|
+
const barHeights = ( y: { tickValues: number[]; tickDecimals?: number } ) => {
|
|
609
|
+
const { unmount } = renderWithTheme( {
|
|
610
|
+
data: [
|
|
611
|
+
{
|
|
612
|
+
label: 'Series A',
|
|
613
|
+
data: [ 0.31, 0.73 ].map( ( value, i ) => ( { label: `Day ${ i }`, value } ) ),
|
|
614
|
+
},
|
|
615
|
+
],
|
|
616
|
+
options: { yScale: { zero: false }, axis: { y } },
|
|
617
|
+
} );
|
|
618
|
+
const heights = Array.from( getBarRects(), rect => rect.getAttribute( 'height' ) );
|
|
619
|
+
unmount();
|
|
620
|
+
return heights;
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
expect( barHeights( { tickValues: [ 0.4, 0.6 ], tickDecimals: 0 } ) ).toEqual(
|
|
624
|
+
barHeights( { tickValues: [ 0.4, 0.6 ] } )
|
|
625
|
+
);
|
|
626
|
+
} );
|
|
627
|
+
|
|
553
628
|
test( "keeps a caller's value tickValues", () => {
|
|
554
629
|
renderWithTheme( {
|
|
555
630
|
data: wholeNumberData,
|
|
@@ -66,6 +66,24 @@ describe( 'useBarLegendItems', () => {
|
|
|
66
66
|
);
|
|
67
67
|
} );
|
|
68
68
|
|
|
69
|
+
test( 'colors and lists only the series a stacked chart draws', () => {
|
|
70
|
+
const drawn = twoMetricData.filter( series => series.options?.type !== 'comparison' );
|
|
71
|
+
const { result } = renderHook(
|
|
72
|
+
() => ( {
|
|
73
|
+
items: useBarLegendItems( twoMetricData, { layout: 'stacked', comparisonItem: true } ),
|
|
74
|
+
getBarColors: useBarColors(),
|
|
75
|
+
} ),
|
|
76
|
+
{ wrapper }
|
|
77
|
+
);
|
|
78
|
+
const chartColors = result.current.getBarColors( drawn, { layout: 'stacked' } );
|
|
79
|
+
const colors = result.current.getBarColors( twoMetricData, { layout: 'stacked' } );
|
|
80
|
+
|
|
81
|
+
expect( [ colors[ 0 ], colors[ 2 ] ] ).toEqual( chartColors );
|
|
82
|
+
expect( result.current.items.map( item => [ item.label, item.color ] ) ).toEqual(
|
|
83
|
+
drawn.map( ( series, index ) => [ series.label, chartColors[ index ] ] )
|
|
84
|
+
);
|
|
85
|
+
} );
|
|
86
|
+
|
|
69
87
|
test( "returns the chart legend's own items without a layout", () => {
|
|
70
88
|
const options = { collapseGroups: true };
|
|
71
89
|
const { result } = renderHook(
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
SeriesData,
|
|
6
6
|
SeriesChartLegendConfig,
|
|
7
7
|
SeriesVisibilityProps,
|
|
8
|
+
CompactWhenShortProps,
|
|
8
9
|
} from '../../types';
|
|
9
10
|
import type { RenderTooltipParams } from '../../visx/types';
|
|
10
11
|
import type { CSSProperties, ReactNode } from 'react';
|
|
@@ -19,7 +20,8 @@ export type BandHighlightSelection = {
|
|
|
19
20
|
height: number;
|
|
20
21
|
};
|
|
21
22
|
|
|
22
|
-
export interface BarChartProps
|
|
23
|
+
export interface BarChartProps
|
|
24
|
+
extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps, CompactWhenShortProps {
|
|
23
25
|
/**
|
|
24
26
|
* Legend configuration. Supports `collapseGroups` on top of the shared options.
|
|
25
27
|
*/
|
|
@@ -3,6 +3,7 @@ import { useGlobalChartsContext } from '../../providers/chart-context/hooks/use-
|
|
|
3
3
|
import { BACKGROUND_FALLBACK } from '../../providers/chart-context/private/catalog-pointers';
|
|
4
4
|
import { getGroupedBarColors } from './private/grouped-bars';
|
|
5
5
|
import { getOverlapBarColors } from './private/overlap-bars';
|
|
6
|
+
import { getDrawnSeries } from './private/stack-layout';
|
|
6
7
|
import type { BarChartLayout, SeriesData } from '../../types';
|
|
7
8
|
|
|
8
9
|
export type GetBarColors = (
|
|
@@ -27,6 +28,17 @@ export function useBarColors(): GetBarColors {
|
|
|
27
28
|
( series, { layout = 'grouped' } = {} ) => {
|
|
28
29
|
const colors = series.map( ( data, index ) => getElementStyles( { data, index } ).color );
|
|
29
30
|
|
|
31
|
+
if ( layout === 'stacked' ) {
|
|
32
|
+
// Undrawn comparisons must not take a palette slot, so the drawn series are colored as the chart colors them.
|
|
33
|
+
const drawn = getDrawnSeries( series, layout );
|
|
34
|
+
return series.map( ( data, index ) => {
|
|
35
|
+
const drawnIndex = drawn.indexOf( data );
|
|
36
|
+
return drawnIndex === -1
|
|
37
|
+
? colors[ index ]
|
|
38
|
+
: getElementStyles( { data, index: drawnIndex } ).color;
|
|
39
|
+
} );
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
if ( layout !== 'overlap' ) {
|
|
31
43
|
// The deprecated comparison opacity keeps the translucent bars in their series colors.
|
|
32
44
|
return comparisonOpacity === undefined
|