@automattic/charts 4.14.0 → 4.15.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 +10 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +1249 -325
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +31 -0
- package/dist/index.d.cts +41 -7
- package/dist/index.d.ts +41 -7
- package/dist/index.js +964 -325
- package/dist/index.js.map +1 -1
- package/package.json +9 -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/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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMemo } from 'react';
|
|
2
2
|
import { useChartLegendItems } from '../../components/legend/hooks/use-chart-legend-items';
|
|
3
|
+
import { getDrawnSeries } from './private/stack-layout';
|
|
3
4
|
import { useBarColors } from './use-bar-colors';
|
|
4
5
|
import type { ChartLegendOptions } from '../../components/legend/hooks/use-chart-legend-items';
|
|
5
6
|
import type { BaseLegendItem } from '../../components/legend/types';
|
|
@@ -58,17 +59,18 @@ function forBarLayout(
|
|
|
58
59
|
/**
|
|
59
60
|
* Builds legend items that match `BarChart`'s bars in a layout, for a custom legend or a chart's own.
|
|
60
61
|
*
|
|
61
|
-
* @param
|
|
62
|
+
* @param allData - The chart's series; those the layout does not draw get no item.
|
|
62
63
|
* @param options - The legend options, and the layout the bars are drawn in.
|
|
63
64
|
* @param legendShape - The shape of the legend swatches.
|
|
64
65
|
* @return The legend items.
|
|
65
66
|
*/
|
|
66
67
|
export function useBarLegendItems(
|
|
67
|
-
|
|
68
|
+
allData: SeriesData[],
|
|
68
69
|
options: BarLegendOptions = {},
|
|
69
70
|
legendShape?: LegendShape< SeriesData[], number >
|
|
70
71
|
): BaseLegendItem[] {
|
|
71
72
|
const { layout } = options;
|
|
73
|
+
const data = useMemo( () => getDrawnSeries( allData, layout ), [ allData, layout ] );
|
|
72
74
|
const getBarColors = useBarColors();
|
|
73
75
|
const seriesColors = useMemo(
|
|
74
76
|
() => ( layout ? getBarColors( data, { layout } ) : undefined ),
|
|
@@ -20,6 +20,8 @@ export interface BarListChartProps extends Exclude<
|
|
|
20
20
|
BarChartProps,
|
|
21
21
|
'orientation' | 'size' | 'gridVisibility'
|
|
22
22
|
> {
|
|
23
|
+
/** Bar lists are always horizontal, and horizontal bars ignore `compactWhenShort`. */
|
|
24
|
+
compactWhenShort?: never;
|
|
23
25
|
options?: {
|
|
24
26
|
/**
|
|
25
27
|
* Scale for the y axis. Exclude the type property.
|
|
@@ -41,9 +41,15 @@ import { ChartLayout } from '../private/chart-layout';
|
|
|
41
41
|
import { DefaultGlyph } from '../private/default-glyph';
|
|
42
42
|
import { formatReading, isInvalidReading, isReading } from '../private/readings';
|
|
43
43
|
import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
|
|
44
|
+
import {
|
|
45
|
+
getTickDecimals,
|
|
46
|
+
getTickDecimalsDomain,
|
|
47
|
+
hasOnlyWholeNumbers,
|
|
48
|
+
TickDecimals,
|
|
49
|
+
} from '../private/tick-decimals';
|
|
44
50
|
import { getCurveType } from '../private/time-axis';
|
|
45
51
|
import { buildTimeAxisOptions } from '../private/time-axis-options';
|
|
46
|
-
import {
|
|
52
|
+
import { useCompactWhenShort } from '../private/use-compact-when-short';
|
|
47
53
|
import { withResponsive } from '../private/with-responsive';
|
|
48
54
|
import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
|
|
49
55
|
import plotStyles from '../private/xy-plot/xy-plot.module.scss';
|
|
@@ -267,6 +273,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
267
273
|
children,
|
|
268
274
|
gridVisibility,
|
|
269
275
|
gap = 'md',
|
|
276
|
+
compactWhenShort = false,
|
|
270
277
|
},
|
|
271
278
|
ref
|
|
272
279
|
) => {
|
|
@@ -301,6 +308,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
301
308
|
|
|
302
309
|
// Process children for composition API (Legend, etc.)
|
|
303
310
|
const { legendChildren, nonLegendChildren } = useChartChildren( children, 'LineChart' );
|
|
311
|
+
const { isCompact, compactMargin } = useCompactWhenShort( compactWhenShort, height );
|
|
304
312
|
const [ measuredChartHeight, setMeasuredChartHeight ] = useState< number | undefined >();
|
|
305
313
|
|
|
306
314
|
// Callback for ChartLayout to notify us when the measured content height changes.
|
|
@@ -438,31 +446,47 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
438
446
|
() => hasOnlyWholeNumbers( visibleSeries ),
|
|
439
447
|
[ visibleSeries ]
|
|
440
448
|
);
|
|
449
|
+
const isLogScale = options?.yScale?.type === 'log';
|
|
450
|
+
// A log axis has no decimal steps to hold ticks to.
|
|
451
|
+
const callerTickDecimals = isLogScale ? undefined : options?.axis?.y?.tickDecimals;
|
|
452
|
+
const yTickDecimals = getTickDecimals( hasWholeNumberValues, callerTickDecimals );
|
|
441
453
|
|
|
442
454
|
const chartOptions = useMemo( () => {
|
|
443
|
-
const fallbackYDomain = getFallbackYDomain(
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
455
|
+
const fallbackYDomain = getFallbackYDomain( visibleReadingExtent, isLogScale );
|
|
456
|
+
const { tickDecimals, ...yAxisOptions } = options?.axis?.y ?? {};
|
|
457
|
+
const tickDecimalsDomain =
|
|
458
|
+
isLogScale ||
|
|
459
|
+
tickDecimals === undefined ||
|
|
460
|
+
options?.yScale?.domain ||
|
|
461
|
+
yAxisOptions.tickValues
|
|
462
|
+
? undefined
|
|
463
|
+
: getTickDecimalsDomain(
|
|
464
|
+
yDomain ?? fallbackYDomain ?? visibleReadingExtent,
|
|
465
|
+
yTickDecimals
|
|
466
|
+
);
|
|
447
467
|
|
|
448
468
|
return {
|
|
449
469
|
axis: {
|
|
450
|
-
x:
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
470
|
+
x: {
|
|
471
|
+
...buildTimeAxisOptions( {
|
|
472
|
+
dataSorted,
|
|
473
|
+
width,
|
|
474
|
+
axisOptions: options?.axis?.x,
|
|
475
|
+
scaleDomain: options?.xScale?.domain,
|
|
476
|
+
zoomDomain: zoom.domain,
|
|
477
|
+
formatting,
|
|
478
|
+
// A hidden line is unmounted, so visx scales to the rest.
|
|
479
|
+
isSeriesRendered: series => isSeriesVisible( series.label ),
|
|
480
|
+
} ),
|
|
481
|
+
...( isCompact && { display: false } ),
|
|
482
|
+
},
|
|
460
483
|
y: {
|
|
461
484
|
orientation: 'left' as const,
|
|
462
485
|
numTicks: 4,
|
|
463
486
|
tickFormat: formatNumberCompact as TickFormatter< number >,
|
|
464
487
|
display: true,
|
|
465
|
-
...
|
|
488
|
+
...yAxisOptions,
|
|
489
|
+
...( isCompact && { display: false } ),
|
|
466
490
|
},
|
|
467
491
|
},
|
|
468
492
|
xScale: {
|
|
@@ -476,6 +500,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
476
500
|
zero: false,
|
|
477
501
|
...( fallbackYDomain ? { domain: fallbackYDomain } : {} ),
|
|
478
502
|
...( yDomain ? { domain: yDomain } : {} ),
|
|
503
|
+
...( tickDecimalsDomain ? { domain: tickDecimalsDomain } : {} ),
|
|
479
504
|
...options?.yScale,
|
|
480
505
|
},
|
|
481
506
|
};
|
|
@@ -486,8 +511,11 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
486
511
|
zoom.domain,
|
|
487
512
|
yDomain,
|
|
488
513
|
visibleReadingExtent,
|
|
514
|
+
isLogScale,
|
|
515
|
+
yTickDecimals,
|
|
489
516
|
formatting,
|
|
490
517
|
isSeriesVisible,
|
|
518
|
+
isCompact,
|
|
491
519
|
] );
|
|
492
520
|
|
|
493
521
|
// Classified from the rendered series, like the axis above: a hidden
|
|
@@ -588,7 +616,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
588
616
|
return <div className={ clsx( 'line-chart', styles[ 'line-chart' ] ) }>{ error }</div>;
|
|
589
617
|
}
|
|
590
618
|
|
|
591
|
-
const legendElement = showLegend && (
|
|
619
|
+
const legendElement = showLegend && ! isCompact && (
|
|
592
620
|
<Legend
|
|
593
621
|
orientation={ legend.orientation ?? 'horizontal' }
|
|
594
622
|
alignment={ legend.alignment ?? 'center' }
|
|
@@ -617,7 +645,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
617
645
|
<ChartLayout
|
|
618
646
|
legendPosition={ legendPosition }
|
|
619
647
|
legendElement={ legendElement }
|
|
620
|
-
legendChildren={ legendChildren }
|
|
648
|
+
legendChildren={ isCompact ? [] : legendChildren }
|
|
621
649
|
gap={ gap }
|
|
622
650
|
className={ clsx(
|
|
623
651
|
'line-chart',
|
|
@@ -655,6 +683,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
655
683
|
height={ chartHeight }
|
|
656
684
|
margin={ {
|
|
657
685
|
...defaultMargin,
|
|
686
|
+
...compactMargin,
|
|
658
687
|
...margin,
|
|
659
688
|
} }
|
|
660
689
|
// xScale and yScale could be set in Axis as well, but they are `scale` props there.
|
|
@@ -666,18 +695,18 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
666
695
|
{ /* With every series hidden there is no data to scale against, so the grid and
|
|
667
696
|
axes are dropped while the empty state stands in — otherwise they render
|
|
668
697
|
squished at the top. */ }
|
|
669
|
-
<
|
|
698
|
+
<TickDecimals
|
|
670
699
|
axis="y"
|
|
671
700
|
numTicks={ chartOptions.axis.y.numTicks }
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
701
|
+
decimals={
|
|
702
|
+
! chartOptions.axis.y.tickValues && ! options?.yScale?.domain
|
|
703
|
+
? yTickDecimals
|
|
704
|
+
: undefined
|
|
676
705
|
}
|
|
677
706
|
>
|
|
678
707
|
{ tickValues => (
|
|
679
708
|
<>
|
|
680
|
-
{ ! allSeriesHidden && gridVisibility !== 'none' && (
|
|
709
|
+
{ ! allSeriesHidden && gridVisibility !== 'none' && ! isCompact && (
|
|
681
710
|
<Grid
|
|
682
711
|
columns={ false }
|
|
683
712
|
numTicks={ chartOptions.axis.y.numTicks }
|
|
@@ -695,7 +724,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
695
724
|
) }
|
|
696
725
|
</>
|
|
697
726
|
) }
|
|
698
|
-
</
|
|
727
|
+
</TickDecimals>
|
|
699
728
|
|
|
700
729
|
{ allSeriesHidden ? (
|
|
701
730
|
<SvgEmptyState
|
|
@@ -704,7 +733,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
704
733
|
width={ width }
|
|
705
734
|
height={ chartHeight }
|
|
706
735
|
>
|
|
707
|
-
{ getAllHiddenMessage( legendInteractive, 'series' ) }
|
|
736
|
+
{ getAllHiddenMessage( legendInteractive && ! isCompact, 'series' ) }
|
|
708
737
|
</SvgEmptyState>
|
|
709
738
|
) : null }
|
|
710
739
|
|
|
@@ -313,6 +313,92 @@ describe( 'LineChart', () => {
|
|
|
313
313
|
expect( ticks.sort() ).toEqual( [ '0', '1' ] );
|
|
314
314
|
} );
|
|
315
315
|
|
|
316
|
+
test.each( [
|
|
317
|
+
[ 'small amounts', 0, [ 0.4, 1.2, 2.1, 2.6 ], [ '0', '1', '2' ] ],
|
|
318
|
+
[ 'amounts under one step', 0, [ 0.31, 0.55, 0.73 ], [ '0', '1' ] ],
|
|
319
|
+
[ 'cents', 2, [ 0.4, 1.2, 1.6 ], [ '0.00', '0.50', '1.00', '1.50' ] ],
|
|
320
|
+
] )( 'holds ticks to tickDecimals for %s', ( _, tickDecimals, values, expected ) => {
|
|
321
|
+
renderWithTheme( {
|
|
322
|
+
data: [
|
|
323
|
+
{
|
|
324
|
+
label: 'Series A',
|
|
325
|
+
data: values.map( ( value, i ) => ( { date: new Date( 2024, i + 2, 1 ), value } ) ),
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
options: {
|
|
329
|
+
yScale: { zero: true },
|
|
330
|
+
axis: {
|
|
331
|
+
y: { tickDecimals, tickFormat: ( value: number ) => value.toFixed( tickDecimals ) },
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
} );
|
|
335
|
+
|
|
336
|
+
const chart = screen.getByRole( 'application', { name: /line chart/i } );
|
|
337
|
+
const ticks = within( chart )
|
|
338
|
+
.getAllByText( /^-?[\d.,]+$/ )
|
|
339
|
+
.map( el => el.textContent );
|
|
340
|
+
expect( ticks.sort() ).toEqual( expected );
|
|
341
|
+
} );
|
|
342
|
+
|
|
343
|
+
test( "leaves a caller's tickValues axis unwidened by tickDecimals", () => {
|
|
344
|
+
const linePath = ( y: { tickValues: number[]; tickDecimals?: number } ) => {
|
|
345
|
+
const { unmount } = renderWithTheme( {
|
|
346
|
+
data: [
|
|
347
|
+
{
|
|
348
|
+
label: 'Series A',
|
|
349
|
+
data: [ 0.31, 0.55, 0.73 ].map( ( value, i ) => ( {
|
|
350
|
+
date: new Date( 2024, i + 2, 1 ),
|
|
351
|
+
value,
|
|
352
|
+
} ) ),
|
|
353
|
+
},
|
|
354
|
+
],
|
|
355
|
+
options: { axis: { y } },
|
|
356
|
+
} );
|
|
357
|
+
const chart = screen.getByRole( 'application', { name: /line chart/i } );
|
|
358
|
+
// eslint-disable-next-line testing-library/no-node-access -- visx's stroke is a <path class="visx-line">.
|
|
359
|
+
const d = chart.querySelector( 'path.visx-line' )?.getAttribute( 'd' );
|
|
360
|
+
unmount();
|
|
361
|
+
return d;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
expect( linePath( { tickValues: [ 0.4, 0.6 ], tickDecimals: 0 } ) ).toBe(
|
|
365
|
+
linePath( { tickValues: [ 0.4, 0.6 ] } )
|
|
366
|
+
);
|
|
367
|
+
} );
|
|
368
|
+
|
|
369
|
+
test( 'widens with the decimals it ticks at when a hidden series keeps its place in the domain', () => {
|
|
370
|
+
renderWithTheme( {
|
|
371
|
+
rescaleYOnVisibilityChange: false,
|
|
372
|
+
defaultHiddenSeries: [ 'Series B' ],
|
|
373
|
+
data: [
|
|
374
|
+
{
|
|
375
|
+
label: 'Series A',
|
|
376
|
+
data: [ 0, 0, 0 ].map( ( value, i ) => ( {
|
|
377
|
+
date: new Date( 2024, i + 2, 1 ),
|
|
378
|
+
value,
|
|
379
|
+
} ) ),
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
label: 'Series B',
|
|
383
|
+
data: [ 0.001, 0.003, 0.004 ].map( ( value, i ) => ( {
|
|
384
|
+
date: new Date( 2024, i + 2, 1 ),
|
|
385
|
+
value,
|
|
386
|
+
} ) ),
|
|
387
|
+
},
|
|
388
|
+
],
|
|
389
|
+
options: {
|
|
390
|
+
yScale: { zero: true },
|
|
391
|
+
axis: { y: { tickDecimals: 2, tickFormat: ( value: number ) => value.toFixed( 2 ) } },
|
|
392
|
+
},
|
|
393
|
+
} );
|
|
394
|
+
|
|
395
|
+
const chart = screen.getByRole( 'application', { name: /line chart/i } );
|
|
396
|
+
const ticks = within( chart )
|
|
397
|
+
.getAllByText( /^-?[\d.,]+$/ )
|
|
398
|
+
.map( el => el.textContent );
|
|
399
|
+
expect( ticks.sort() ).toEqual( [ '0.00', '1.00' ] );
|
|
400
|
+
} );
|
|
401
|
+
|
|
316
402
|
test( 'ignores a hidden fractional series when checking for whole numbers', () => {
|
|
317
403
|
renderWithTheme( {
|
|
318
404
|
defaultHiddenSeries: [ 'Series B' ],
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
SeriesData,
|
|
6
6
|
SeriesChartLegendConfig,
|
|
7
7
|
SeriesVisibilityProps,
|
|
8
|
+
CompactWhenShortProps,
|
|
8
9
|
AnnotationStyles,
|
|
9
10
|
DataPoint,
|
|
10
11
|
} from '../../types';
|
|
@@ -30,7 +31,8 @@ export type RenderLineGlyphProps< Datum extends object > = GlyphProps< Datum > &
|
|
|
30
31
|
position?: 'start' | 'end';
|
|
31
32
|
};
|
|
32
33
|
|
|
33
|
-
export interface LineChartProps
|
|
34
|
+
export interface LineChartProps
|
|
35
|
+
extends BaseChartProps< SeriesData[] >, SeriesVisibilityProps, CompactWhenShortProps {
|
|
34
36
|
/**
|
|
35
37
|
* Legend configuration. Supports `collapseGroups` on top of the shared options.
|
|
36
38
|
*/
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { createRef } from 'react';
|
|
3
|
+
import { GlobalChartsProvider, defaultTheme } from '../../../providers';
|
|
4
|
+
import { AreaChartUnresponsive } from '../../area-chart/area-chart';
|
|
5
|
+
import { BarChartUnresponsive } from '../../bar-chart/bar-chart';
|
|
6
|
+
import { LineChartUnresponsive } from '../../line-chart/line-chart';
|
|
7
|
+
import type { ChartInstanceRef } from '../chart-instance-context';
|
|
8
|
+
import type { ComponentProps, ComponentType, ReactNode, Ref } from 'react';
|
|
9
|
+
|
|
10
|
+
const mockRefCallback = jest.fn();
|
|
11
|
+
jest.mock( '../../../hooks/use-element-size', () => ( {
|
|
12
|
+
useElementSize: () => [ mockRefCallback, 500, 300 ],
|
|
13
|
+
} ) );
|
|
14
|
+
|
|
15
|
+
type CompactChartProps = {
|
|
16
|
+
data: typeof data;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
compactWhenShort?: boolean;
|
|
20
|
+
showLegend?: boolean;
|
|
21
|
+
orientation?: 'horizontal' | 'vertical';
|
|
22
|
+
margin?: { left?: number };
|
|
23
|
+
withGradientFill?: boolean;
|
|
24
|
+
chartId?: string;
|
|
25
|
+
legend?: { interactive?: boolean };
|
|
26
|
+
defaultHiddenSeries?: string[];
|
|
27
|
+
children?: ReactNode;
|
|
28
|
+
ref?: Ref< ChartInstanceRef >;
|
|
29
|
+
};
|
|
30
|
+
type ScaleRange = { range: () => number[] };
|
|
31
|
+
type CompactChart = ComponentType< CompactChartProps > & {
|
|
32
|
+
Legend: ComponentType;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const data = [ 'A', 'B' ].map( ( label, offset ) => ( {
|
|
36
|
+
label,
|
|
37
|
+
data: [ 1, 2, 3 ].map( day => ( {
|
|
38
|
+
date: new Date( 2024, 0, day ),
|
|
39
|
+
value: day * 10 + offset,
|
|
40
|
+
} ) ),
|
|
41
|
+
} ) );
|
|
42
|
+
|
|
43
|
+
const CHARTS = [
|
|
44
|
+
[ 'LineChart', LineChartUnresponsive ],
|
|
45
|
+
[ 'AreaChart', AreaChartUnresponsive ],
|
|
46
|
+
[ 'BarChart', BarChartUnresponsive ],
|
|
47
|
+
] as unknown as [ string, CompactChart ][];
|
|
48
|
+
|
|
49
|
+
const chartTree = (
|
|
50
|
+
Chart: CompactChart,
|
|
51
|
+
props: Partial< CompactChartProps > = {},
|
|
52
|
+
theme?: ComponentProps< typeof GlobalChartsProvider >[ 'theme' ]
|
|
53
|
+
) => (
|
|
54
|
+
<GlobalChartsProvider theme={ theme }>
|
|
55
|
+
<Chart
|
|
56
|
+
data={ data }
|
|
57
|
+
width={ 500 }
|
|
58
|
+
height={ 300 }
|
|
59
|
+
withGradientFill={ false }
|
|
60
|
+
showLegend
|
|
61
|
+
{ ...props }
|
|
62
|
+
>
|
|
63
|
+
<Chart.Legend />
|
|
64
|
+
</Chart>
|
|
65
|
+
</GlobalChartsProvider>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const renderChart = ( ...args: Parameters< typeof chartTree > ) => render( chartTree( ...args ) );
|
|
69
|
+
|
|
70
|
+
/* eslint-disable testing-library/no-node-access -- visx renders axes and grid lines without roles or test IDs. */
|
|
71
|
+
const chrome = () => ( {
|
|
72
|
+
axes: document.querySelectorAll( '.visx-axis' ).length,
|
|
73
|
+
grid: document.querySelectorAll( '.visx-rows, .visx-columns' ).length > 0,
|
|
74
|
+
legends: screen.queryAllByTestId( 'legend-horizontal' ).length,
|
|
75
|
+
} );
|
|
76
|
+
/* eslint-enable testing-library/no-node-access */
|
|
77
|
+
|
|
78
|
+
const FULL = { axes: 2, grid: true, legends: 2 };
|
|
79
|
+
const COMPACT = { axes: 0, grid: false, legends: 0 };
|
|
80
|
+
|
|
81
|
+
describe( 'compactWhenShort', () => {
|
|
82
|
+
describe.each( CHARTS )( '%s', ( _name, Chart ) => {
|
|
83
|
+
it.each( [
|
|
84
|
+
[ 'an unmeasured chart', 0, true, FULL ],
|
|
85
|
+
[ 'a short chart', 100, true, COMPACT ],
|
|
86
|
+
[ 'a tall chart', 300, true, FULL ],
|
|
87
|
+
[ 'a short chart that did not opt in', 100, false, FULL ],
|
|
88
|
+
] )( 'draws %s with the expected chrome', ( _label, height, compactWhenShort, expected ) => {
|
|
89
|
+
renderChart( Chart, { height, compactWhenShort } );
|
|
90
|
+
|
|
91
|
+
expect( chrome() ).toEqual( expected );
|
|
92
|
+
} );
|
|
93
|
+
|
|
94
|
+
it( 'restores the chrome when a short chart grows back', () => {
|
|
95
|
+
const { rerender } = renderChart( Chart, { height: 100, compactWhenShort: true } );
|
|
96
|
+
rerender( chartTree( Chart, { height: 300, compactWhenShort: true } ) );
|
|
97
|
+
|
|
98
|
+
expect( chrome() ).toEqual( FULL );
|
|
99
|
+
} );
|
|
100
|
+
|
|
101
|
+
it( 'does not point at the hidden legend when every series is hidden', () => {
|
|
102
|
+
renderChart( Chart, {
|
|
103
|
+
height: 100,
|
|
104
|
+
compactWhenShort: true,
|
|
105
|
+
chartId: 'all-hidden',
|
|
106
|
+
legend: { interactive: true },
|
|
107
|
+
defaultHiddenSeries: [ 'A', 'B' ],
|
|
108
|
+
} );
|
|
109
|
+
|
|
110
|
+
expect( screen.getByText( 'All series are hidden.' ) ).toBeInTheDocument();
|
|
111
|
+
} );
|
|
112
|
+
} );
|
|
113
|
+
|
|
114
|
+
it( 'reads the threshold from the theme', () => {
|
|
115
|
+
renderChart(
|
|
116
|
+
LineChartUnresponsive as unknown as CompactChart,
|
|
117
|
+
{ height: 160, compactWhenShort: true },
|
|
118
|
+
{ compactWhenShort: { below: 200 } }
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
expect( chrome() ).toEqual( COMPACT );
|
|
122
|
+
} );
|
|
123
|
+
|
|
124
|
+
it( 'leaves horizontal bars alone', () => {
|
|
125
|
+
renderChart( BarChartUnresponsive as unknown as CompactChart, {
|
|
126
|
+
height: 100,
|
|
127
|
+
compactWhenShort: true,
|
|
128
|
+
orientation: 'horizontal',
|
|
129
|
+
} );
|
|
130
|
+
|
|
131
|
+
expect( chrome() ).toEqual( FULL );
|
|
132
|
+
} );
|
|
133
|
+
|
|
134
|
+
describe.each( CHARTS.slice( 0, 2 ) )( '%s margin', ( _name, Chart ) => {
|
|
135
|
+
it.each( [
|
|
136
|
+
[ 'defaults to the compact margin', undefined, 2 ],
|
|
137
|
+
[ 'keeps a side the caller set', { left: 40 }, 40 ],
|
|
138
|
+
] )( '%s', ( _label, margin, left ) => {
|
|
139
|
+
const ref = createRef< ChartInstanceRef >();
|
|
140
|
+
renderChart( Chart, { height: 100, compactWhenShort: true, margin, ref } );
|
|
141
|
+
|
|
142
|
+
const scales = ref.current?.getScales() as Record< 'xScale' | 'yScale', ScaleRange >;
|
|
143
|
+
expect( scales.xScale.range()[ 0 ] ).toBe( left );
|
|
144
|
+
expect( scales.yScale.range()[ 1 ] ).toBe( 2 );
|
|
145
|
+
} );
|
|
146
|
+
} );
|
|
147
|
+
|
|
148
|
+
describe( 'BarChart margin', () => {
|
|
149
|
+
const leftmostBarX = ( margin?: { left?: number } ) => {
|
|
150
|
+
const { unmount } = renderChart( BarChartUnresponsive as unknown as CompactChart, {
|
|
151
|
+
height: 100,
|
|
152
|
+
compactWhenShort: true,
|
|
153
|
+
margin,
|
|
154
|
+
} );
|
|
155
|
+
// eslint-disable-next-line testing-library/no-node-access -- visx bars take no role or test ID.
|
|
156
|
+
const bars = [ ...document.querySelectorAll( '.visx-bar' ) ];
|
|
157
|
+
const x = Math.min( ...bars.map( bar => Number( bar.getAttribute( 'x' ) ) ) );
|
|
158
|
+
unmount();
|
|
159
|
+
return x;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
it( 'defaults to the compact margin', () => {
|
|
163
|
+
expect( leftmostBarX() ).toBe( leftmostBarX( defaultTheme.compactWhenShort.margin ) );
|
|
164
|
+
} );
|
|
165
|
+
|
|
166
|
+
it( 'keeps a side the caller set', () => {
|
|
167
|
+
expect( leftmostBarX( { left: 40 } ) ).toBeGreaterThan( leftmostBarX() );
|
|
168
|
+
} );
|
|
169
|
+
} );
|
|
170
|
+
} );
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createScale } from '@visx/scale';
|
|
2
|
+
import {
|
|
3
|
+
getTickDecimals,
|
|
4
|
+
getTickDecimalsDomain,
|
|
5
|
+
getTickDecimalValues,
|
|
6
|
+
hasOnlyWholeNumbers,
|
|
7
|
+
} from '../tick-decimals';
|
|
8
|
+
import type { SeriesData } from '../../../types';
|
|
9
|
+
|
|
10
|
+
const linear = ( domain: [ number, number ] ) =>
|
|
11
|
+
createScale( { type: 'linear', domain, range: [ 100, 0 ] } );
|
|
12
|
+
|
|
13
|
+
const series = ( ...values: Array< number | null > ): SeriesData => ( {
|
|
14
|
+
label: 'A',
|
|
15
|
+
data: values.map( ( value, i ) => ( { date: new Date( 2024, i, 1 ), value } ) ),
|
|
16
|
+
} );
|
|
17
|
+
|
|
18
|
+
describe( 'getTickDecimalValues', () => {
|
|
19
|
+
it( 'keeps only the whole ticks of a range smaller than the tick count', () => {
|
|
20
|
+
expect( getTickDecimalValues( linear( [ 0, 1 ] ), 4 ) ).toEqual( [ 0, 1 ] );
|
|
21
|
+
expect( getTickDecimalValues( linear( [ 0, 2 ] ), 4 ) ).toEqual( [ 0, 1, 2 ] );
|
|
22
|
+
} );
|
|
23
|
+
|
|
24
|
+
it( 'leaves a scale whose ticks are already whole to visx', () => {
|
|
25
|
+
expect( getTickDecimalValues( linear( [ 0, 1000 ] ), 4 ) ).toBeUndefined();
|
|
26
|
+
} );
|
|
27
|
+
|
|
28
|
+
it( 'leaves non-numeric scales to visx', () => {
|
|
29
|
+
const band = createScale( { type: 'band', domain: [ 'a', 'b' ], range: [ 0, 100 ] } );
|
|
30
|
+
const time = createScale( {
|
|
31
|
+
type: 'time',
|
|
32
|
+
domain: [ new Date( 2024, 0, 1 ), new Date( 2024, 0, 2 ) ],
|
|
33
|
+
range: [ 0, 100 ],
|
|
34
|
+
} );
|
|
35
|
+
expect( getTickDecimalValues( band, 4 ) ).toBeUndefined();
|
|
36
|
+
expect( getTickDecimalValues( time, 4 ) ).toBeUndefined();
|
|
37
|
+
expect( getTickDecimalValues( undefined, 4 ) ).toBeUndefined();
|
|
38
|
+
} );
|
|
39
|
+
|
|
40
|
+
it( 'leaves a range with no whole tick to visx', () => {
|
|
41
|
+
expect( getTickDecimalValues( linear( [ 0.2, 0.8 ] ), 4 ) ).toBeUndefined();
|
|
42
|
+
} );
|
|
43
|
+
|
|
44
|
+
it( 'leaves a range with only one whole tick to visx', () => {
|
|
45
|
+
expect( getTickDecimalValues( linear( [ 0, 0.8 ] ), 4 ) ).toBeUndefined();
|
|
46
|
+
} );
|
|
47
|
+
|
|
48
|
+
it( 'keeps ticks at the given decimal places, through float error on small and large values', () => {
|
|
49
|
+
expect( getTickDecimalValues( linear( [ 0, 0.07 ] ), 10, 2 ) ).toEqual( [
|
|
50
|
+
0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07,
|
|
51
|
+
] );
|
|
52
|
+
expect( getTickDecimalValues( linear( [ 131072.01, 131072.02 ] ), 4, 2 ) ).toEqual( [
|
|
53
|
+
131072.01, 131072.02,
|
|
54
|
+
] );
|
|
55
|
+
} );
|
|
56
|
+
} );
|
|
57
|
+
|
|
58
|
+
describe( 'getTickDecimalsDomain', () => {
|
|
59
|
+
it.each( [
|
|
60
|
+
[ 'widens a range inside one step', [ 0.3, 0.73 ], 0, [ 0, 1 ] ],
|
|
61
|
+
[ 'widens a range between two steps', [ 1.2, 1.8 ], 0, [ 1, 2 ] ],
|
|
62
|
+
[ 'widens at two decimals', [ 0.031, 0.034 ], 2, [ 0.03, 0.04 ] ],
|
|
63
|
+
[ 'widens from a step float error puts just below', [ 0.29, 0.295 ], 2, [ 0.29, 0.3 ] ],
|
|
64
|
+
[ 'widens ends float noise rounds to one step', [ 0.3, 0.1 + 0.2 ], 2, [ 0.3, 0.31 ] ],
|
|
65
|
+
[ 'keeps a range holding two steps', [ 0, 1.2 ], 0, undefined ],
|
|
66
|
+
[ 'keeps a range without tickDecimals', [ 0.3, 0.73 ], undefined, undefined ],
|
|
67
|
+
] )( '%s', ( _, domain, decimals, expected ) => {
|
|
68
|
+
expect( getTickDecimalsDomain( domain as [ number, number ], decimals ) ).toEqual( expected );
|
|
69
|
+
} );
|
|
70
|
+
} );
|
|
71
|
+
|
|
72
|
+
describe( 'getTickDecimals', () => {
|
|
73
|
+
it.each( [
|
|
74
|
+
[ 'whole-number data over a caller decimals', true, 2, 0 ],
|
|
75
|
+
[ 'fractional data at the caller decimals', false, 2, 2 ],
|
|
76
|
+
[ 'fractional data without tickDecimals', false, undefined, undefined ],
|
|
77
|
+
] )( 'holds %s', ( _, whole, decimals, expected ) => {
|
|
78
|
+
expect( getTickDecimals( whole, decimals ) ).toBe( expected );
|
|
79
|
+
} );
|
|
80
|
+
} );
|
|
81
|
+
|
|
82
|
+
describe( 'hasOnlyWholeNumbers', () => {
|
|
83
|
+
it( 'is true when every reading is whole, skipping missing ones', () => {
|
|
84
|
+
expect( hasOnlyWholeNumbers( [ series( 0, 1, null, 3 ) ] ) ).toBe( true );
|
|
85
|
+
} );
|
|
86
|
+
|
|
87
|
+
it( 'is false when any reading has a fraction', () => {
|
|
88
|
+
expect( hasOnlyWholeNumbers( [ series( 0, 1 ), series( 0.5 ) ] ) ).toBe( false );
|
|
89
|
+
} );
|
|
90
|
+
|
|
91
|
+
it( 'is false when there is no reading at all', () => {
|
|
92
|
+
expect( hasOnlyWholeNumbers( [ series( null, null ) ] ) ).toBe( false );
|
|
93
|
+
expect( hasOnlyWholeNumbers( [] ) ).toBe( false );
|
|
94
|
+
} );
|
|
95
|
+
} );
|