@automattic/charts 4.11.0 → 4.12.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 +18 -0
- package/dist/index.cjs +856 -236
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +19 -13
- package/dist/index.d.cts +43 -4
- package/dist/index.d.ts +43 -4
- package/dist/index.js +855 -237
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/charts/bar-chart/bar-chart.tsx +134 -138
- package/src/charts/bar-chart/index.ts +3 -0
- package/src/charts/bar-chart/private/band-tooltip.tsx +41 -15
- package/src/charts/bar-chart/private/bar-layer.tsx +132 -0
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +22 -34
- package/src/charts/bar-chart/private/comparison-bars.tsx +9 -18
- package/src/charts/bar-chart/private/comparison-constants.ts +4 -10
- package/src/charts/bar-chart/private/grouped-bars.ts +68 -0
- package/src/charts/bar-chart/private/index.ts +7 -1
- package/src/charts/bar-chart/private/overlap-bars.ts +183 -0
- package/src/charts/bar-chart/private/selected-bar-ring.tsx +84 -0
- package/src/charts/bar-chart/private/slot-tooltip-members.tsx +29 -0
- package/src/charts/bar-chart/private/slotted-bar-series.tsx +84 -0
- package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +4 -11
- package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +2 -3
- package/src/charts/bar-chart/private/test/grouped-bars.test.ts +87 -0
- package/src/charts/bar-chart/private/test/overlap-bars.test.ts +159 -0
- package/src/charts/bar-chart/private/use-bar-stops.ts +89 -0
- package/src/charts/bar-chart/private/use-overlap-slots.ts +194 -0
- package/src/charts/bar-chart/private/value-domain.ts +0 -10
- package/src/charts/bar-chart/test/bar-chart-overlap.test.tsx +347 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +66 -19
- package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +81 -0
- package/src/charts/bar-chart/types.ts +3 -0
- package/src/charts/bar-chart/use-bar-colors.ts +46 -0
- package/src/charts/bar-chart/use-bar-legend-items.ts +87 -0
- package/src/charts/line-chart/line-chart.tsx +22 -17
- package/src/charts/line-chart/test/line-chart.test.tsx +165 -1
- package/src/charts/pie-chart/pie-chart.tsx +7 -2
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +7 -2
- package/src/charts/private/pie-keyboard-navigation/index.ts +0 -1
- package/src/charts/private/{pie-keyboard-navigation/pie-keyboard-navigation.module.scss → selected-ring/selected-ring.module.scss} +11 -6
- package/src/charts/private/selected-ring/selected-ring.tsx +73 -0
- package/src/charts/private/{pie-keyboard-navigation/test/selected-segment-ring.test.ts → selected-ring/test/selected-ring.test.ts} +1 -1
- package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +20 -0
- package/src/components/legend/hooks/use-chart-legend-items.ts +27 -3
- package/src/components/tooltip/xy-chart-tooltip.tsx +3 -2
- package/src/index.ts +8 -2
- package/src/providers/chart-context/global-charts-provider.tsx +2 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +0 -13
- package/src/providers/chart-context/themes.ts +0 -1
- package/src/providers/chart-context/types.ts +2 -0
- package/src/test-utils/selected-bar-ring.ts +14 -0
- package/src/types.ts +10 -2
- package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +0 -47
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { TooltipContext } from '@visx/xychart';
|
|
2
|
+
import { useContext, useEffect } from 'react';
|
|
3
|
+
import type { DataPointDate } from '../../../types';
|
|
4
|
+
|
|
5
|
+
/** A bar at the keyboard-selected category. */
|
|
6
|
+
export type SlotMember = { key: string; datum: DataPointDate };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Fills the tooltip with every bar of the keyboard-selected category, and only those.
|
|
10
|
+
*
|
|
11
|
+
* The last member shown becomes the tooltip's nearest datum, so the caller lists the keyboard stop last.
|
|
12
|
+
*
|
|
13
|
+
* @param props - Component props.
|
|
14
|
+
* @param props.members - The category's bars, the keyboard stop last.
|
|
15
|
+
* @param props.index - The selected data point.
|
|
16
|
+
* @return No visual content.
|
|
17
|
+
*/
|
|
18
|
+
export function SlotTooltipMembers( { members, index }: { members: SlotMember[]; index: number } ) {
|
|
19
|
+
const { showTooltip, updateTooltip } = useContext( TooltipContext ) ?? {};
|
|
20
|
+
|
|
21
|
+
useEffect( () => {
|
|
22
|
+
updateTooltip?.( state => ( { ...state, tooltipData: undefined } ) );
|
|
23
|
+
members.forEach( ( { key, datum } ) => showTooltip?.( { key, datum, index } ) );
|
|
24
|
+
// `showTooltip` and `updateTooltip` are stable; the context object is not.
|
|
25
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
26
|
+
}, [ members, index ] );
|
|
27
|
+
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
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 { computeSlotRect, 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?: BarChartProps[ 'barClassName' ];
|
|
20
|
+
/** The slots of a category, in the order the band lays them out. */
|
|
21
|
+
slotKeys: string[];
|
|
22
|
+
/** The slot the bars sit in. */
|
|
23
|
+
slotKey: string;
|
|
24
|
+
/** The bars' thickness as a share of the slot, centered in it. */
|
|
25
|
+
thickness: number;
|
|
26
|
+
groupPadding: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const SlottedBarSeries = ( {
|
|
30
|
+
barClassName,
|
|
31
|
+
slotKeys,
|
|
32
|
+
slotKey,
|
|
33
|
+
thickness,
|
|
34
|
+
groupPadding,
|
|
35
|
+
...props
|
|
36
|
+
}: Props ) => {
|
|
37
|
+
const { xScale, yScale, horizontal } = useContext( DataContext );
|
|
38
|
+
const bandScale = ( horizontal ? yScale : xScale ) as BandScale | undefined;
|
|
39
|
+
const valueScale = horizontal ? xScale : yScale;
|
|
40
|
+
const groupScale = useMemo(
|
|
41
|
+
() => createGroupScale( slotKeys, bandScale?.bandwidth?.() ?? 0, groupPadding ),
|
|
42
|
+
[ slotKeys, bandScale, groupPadding ]
|
|
43
|
+
);
|
|
44
|
+
const renderGlyph = useCallback(
|
|
45
|
+
( { datum, x, y, color }: GlyphProps< DataPointDate > ) => {
|
|
46
|
+
if ( ! bandScale?.bandwidth || ! valueScale ) return null;
|
|
47
|
+
const baseline = getValueScaleBaseline(
|
|
48
|
+
valueScale as Parameters< typeof getValueScaleBaseline >[ 0 ]
|
|
49
|
+
);
|
|
50
|
+
const rect = computeSlotRect( {
|
|
51
|
+
horizontal: !! horizontal,
|
|
52
|
+
bandPosition: Number(
|
|
53
|
+
bandScale( ( horizontal ? props.yAccessor : props.xAccessor )( datum ) )
|
|
54
|
+
),
|
|
55
|
+
slotOffset: groupScale( slotKey ) ?? 0,
|
|
56
|
+
slotThickness: groupScale.bandwidth(),
|
|
57
|
+
valuePositions: [ baseline, horizontal ? x : y ],
|
|
58
|
+
widthFactor: thickness,
|
|
59
|
+
} );
|
|
60
|
+
return (
|
|
61
|
+
<rect
|
|
62
|
+
{ ...rect }
|
|
63
|
+
className={ clsx( 'visx-bar', barClassName?.( datum ) ) }
|
|
64
|
+
fill={ color }
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
},
|
|
68
|
+
[
|
|
69
|
+
bandScale,
|
|
70
|
+
valueScale,
|
|
71
|
+
horizontal,
|
|
72
|
+
groupScale,
|
|
73
|
+
slotKey,
|
|
74
|
+
thickness,
|
|
75
|
+
props.xAccessor,
|
|
76
|
+
props.yAccessor,
|
|
77
|
+
barClassName,
|
|
78
|
+
]
|
|
79
|
+
);
|
|
80
|
+
// BandTooltip owns pointer selection: these handlers subscribe after it and would rank bars by their tops, not their slots.
|
|
81
|
+
return <GlyphSeries { ...props } enableEvents={ false } renderGlyph={ renderGlyph } />;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export default SlottedBarSeries;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { computeSlotRect, getValueScaleBaseline } from '../comparison-bars-geometry';
|
|
2
2
|
|
|
3
3
|
describe( 'getValueScaleBaseline', () => {
|
|
4
4
|
it( 'clamps to the bottom (range max) when 0 is below the domain (vertical, descending range)', () => {
|
|
@@ -17,29 +17,22 @@ describe( 'getValueScaleBaseline', () => {
|
|
|
17
17
|
} );
|
|
18
18
|
} );
|
|
19
19
|
|
|
20
|
-
describe( '
|
|
20
|
+
describe( 'computeSlotRect', () => {
|
|
21
21
|
const base = {
|
|
22
22
|
bandPosition: 100,
|
|
23
23
|
slotOffset: 10,
|
|
24
24
|
slotThickness: 40,
|
|
25
|
-
valuePosition: 50,
|
|
26
|
-
baseline: 200,
|
|
27
25
|
widthFactor: 1.5,
|
|
28
26
|
};
|
|
29
27
|
|
|
30
28
|
it( 'centers a 150% wide shadow on the primary slot (vertical)', () => {
|
|
31
|
-
const r =
|
|
29
|
+
const r = computeSlotRect( { ...base, horizontal: false, valuePositions: [ 200, 50 ] } );
|
|
32
30
|
// slotStart=110, center=110+20=130, shadowThickness=60, shadowStart=130-30=100
|
|
33
31
|
expect( r ).toEqual( { x: 100, y: 50, width: 60, height: 150 } );
|
|
34
32
|
} );
|
|
35
33
|
|
|
36
34
|
it( 'centers a 150% tall shadow on the primary slot (horizontal)', () => {
|
|
37
|
-
const r =
|
|
38
|
-
...base,
|
|
39
|
-
horizontal: true,
|
|
40
|
-
valuePosition: 150,
|
|
41
|
-
baseline: 0,
|
|
42
|
-
} );
|
|
35
|
+
const r = computeSlotRect( { ...base, horizontal: true, valuePositions: [ 0, 150 ] } );
|
|
43
36
|
// slotStart=110, center=130, shadowThickness=60, shadowStart=100
|
|
44
37
|
expect( r ).toEqual( { x: 0, y: 100, width: 150, height: 60 } );
|
|
45
38
|
} );
|
|
@@ -56,12 +56,12 @@ const seriesData: SeriesData = {
|
|
|
56
56
|
};
|
|
57
57
|
|
|
58
58
|
const comparisonEntries: ComparisonSeriesEntry[] = [
|
|
59
|
-
{ series: seriesData, index: 0, primaryKey: 'current'
|
|
59
|
+
{ series: seriesData, index: 0, primaryKey: 'current' },
|
|
60
60
|
];
|
|
61
61
|
|
|
62
62
|
const getElementStyles = () => ( {
|
|
63
63
|
color: '#f00',
|
|
64
|
-
barStyles: { widthFactor: 1.5
|
|
64
|
+
barStyles: { widthFactor: 1.5 },
|
|
65
65
|
lineStyles: {} as never,
|
|
66
66
|
glyph: undefined as never,
|
|
67
67
|
shapeStyles: {} as never,
|
|
@@ -94,7 +94,6 @@ describe( 'ComparisonBars', () => {
|
|
|
94
94
|
const rects = screen.getAllByTestId( /^bar-chart-comparison-0-/ );
|
|
95
95
|
expect( rects ).toHaveLength( 2 );
|
|
96
96
|
expect( rects[ 0 ] ).toHaveAttribute( 'fill', '#f00' );
|
|
97
|
-
expect( rects[ 0 ] ).toHaveAttribute( 'opacity', '0.5' );
|
|
98
97
|
// width == widthFactor (1.5) * groupScale.bandwidth()
|
|
99
98
|
expect( Number( rects[ 0 ].getAttribute( 'width' ) ) ).toBeCloseTo(
|
|
100
99
|
1.5 * groupScale.bandwidth()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { contrastRatio } from '../../../../providers/chart-context/private/perceptual-color';
|
|
2
|
+
import { getGroupedBarColors } from '../grouped-bars';
|
|
3
|
+
import { tintWithContrast } from '../overlap-bars';
|
|
4
|
+
import type { SeriesData } from '../../../../types';
|
|
5
|
+
|
|
6
|
+
const series = ( label: string, group?: string, options: SeriesData[ 'options' ] = {} ) => ( {
|
|
7
|
+
label,
|
|
8
|
+
group,
|
|
9
|
+
data: [],
|
|
10
|
+
options,
|
|
11
|
+
} );
|
|
12
|
+
|
|
13
|
+
const BLUE = '#3858e9';
|
|
14
|
+
const GREEN = '#008a20';
|
|
15
|
+
|
|
16
|
+
describe( 'getGroupedBarColors', () => {
|
|
17
|
+
it.each( [ [ '#ffffff' ], [ '#1e1e1e' ] ] )(
|
|
18
|
+
'tints each comparison to 3:1 against %s and keeps its primary at 3:1 against the tint',
|
|
19
|
+
background => {
|
|
20
|
+
const data = [
|
|
21
|
+
series( 'Views', 'views' ),
|
|
22
|
+
series( 'Previous views', 'views', { type: 'comparison' } ),
|
|
23
|
+
series( 'Visitors', 'visitors' ),
|
|
24
|
+
series( 'Previous visitors', 'visitors', { type: 'comparison' } ),
|
|
25
|
+
];
|
|
26
|
+
const [ views, previousViews, visitors, previousVisitors ] = getGroupedBarColors(
|
|
27
|
+
data,
|
|
28
|
+
[ BLUE, BLUE, GREEN, GREEN ],
|
|
29
|
+
background
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
for ( const [ primary, comparison ] of [
|
|
33
|
+
[ views, previousViews ],
|
|
34
|
+
[ visitors, previousVisitors ],
|
|
35
|
+
] ) {
|
|
36
|
+
expect( contrastRatio( comparison, background ) ).toBeGreaterThanOrEqual( 3 );
|
|
37
|
+
expect( contrastRatio( comparison, background ) ).toBeLessThan( 3.1 );
|
|
38
|
+
expect( contrastRatio( primary, comparison ) ).toBeGreaterThanOrEqual( 3 );
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
it( 'pairs a comparison without a group with the only primary', () => {
|
|
44
|
+
const [ primary, comparison ] = getGroupedBarColors(
|
|
45
|
+
[ series( 'Views' ), series( 'Previous', undefined, { type: 'comparison' } ) ],
|
|
46
|
+
[ BLUE, GREEN ],
|
|
47
|
+
'#ffffff'
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect( contrastRatio( primary, comparison ) ).toBeGreaterThanOrEqual( 3 );
|
|
51
|
+
} );
|
|
52
|
+
|
|
53
|
+
it( "keeps a primary's own stroke and tints its comparison from it", () => {
|
|
54
|
+
const [ primary, comparison ] = getGroupedBarColors(
|
|
55
|
+
[
|
|
56
|
+
series( 'Views', 'views', { stroke: '#ff0000' } ),
|
|
57
|
+
series( 'Previous', 'views', { type: 'comparison' } ),
|
|
58
|
+
],
|
|
59
|
+
[ '#ff0000', BLUE ],
|
|
60
|
+
'#ffffff'
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect( [ primary, comparison ] ).toEqual( [
|
|
64
|
+
'#ff0000',
|
|
65
|
+
tintWithContrast( '#ff0000', '#ffffff' ),
|
|
66
|
+
] );
|
|
67
|
+
} );
|
|
68
|
+
|
|
69
|
+
it.each( [
|
|
70
|
+
[
|
|
71
|
+
'a comparison stroked in a color of its own',
|
|
72
|
+
[
|
|
73
|
+
series( 'Views', 'views' ),
|
|
74
|
+
series( 'Previous', 'views', { type: 'comparison', stroke: '#dbdbdb' } ),
|
|
75
|
+
],
|
|
76
|
+
[ BLUE, '#dbdbdb' ],
|
|
77
|
+
],
|
|
78
|
+
[
|
|
79
|
+
'a primary color that is not hex',
|
|
80
|
+
[ series( 'Views', 'views' ), series( 'Previous', 'views', { type: 'comparison' } ) ],
|
|
81
|
+
[ 'currentColor', 'currentColor' ],
|
|
82
|
+
],
|
|
83
|
+
[ 'no comparison', [ series( 'Views' ), series( 'Visitors' ) ], [ BLUE, GREEN ] ],
|
|
84
|
+
] )( 'keeps the colors given with %s', ( _case, data, colors ) => {
|
|
85
|
+
expect( getGroupedBarColors( data, colors, '#ffffff' ) ).toEqual( colors );
|
|
86
|
+
} );
|
|
87
|
+
} );
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { contrastRatio } from '../../../../providers/chart-context/private/perceptual-color';
|
|
2
|
+
import { relativeLuminance } from '../../../../utils/color-utils';
|
|
3
|
+
import { getOverlapBarColors, getOverlapPlacements, tintWithContrast } from '../overlap-bars';
|
|
4
|
+
import type { SeriesData } from '../../../../types';
|
|
5
|
+
|
|
6
|
+
const series = ( label: string, group?: string, options: SeriesData[ 'options' ] = {} ) => ( {
|
|
7
|
+
label,
|
|
8
|
+
group,
|
|
9
|
+
data: [],
|
|
10
|
+
options,
|
|
11
|
+
} );
|
|
12
|
+
|
|
13
|
+
const VIEWS_AND_VISITORS: SeriesData[] = [
|
|
14
|
+
series( 'Views', 'views' ),
|
|
15
|
+
series( 'Previous views', 'views', { type: 'comparison' } ),
|
|
16
|
+
series( 'Visitors', 'visitors' ),
|
|
17
|
+
series( 'Previous visitors', 'visitors', { type: 'comparison' } ),
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const BLUE = '#3858e9';
|
|
21
|
+
const GREEN = '#008a20';
|
|
22
|
+
|
|
23
|
+
describe( 'getOverlapPlacements', () => {
|
|
24
|
+
it( 'puts primaries and comparisons in their own slots, with each group a layer in order of first appearance', () => {
|
|
25
|
+
expect( getOverlapPlacements( VIEWS_AND_VISITORS ) ).toEqual( [
|
|
26
|
+
{ slot: 'primary', layer: 0, thickness: 1 },
|
|
27
|
+
{ slot: 'comparison', layer: 0, thickness: 1 },
|
|
28
|
+
{ slot: 'primary', layer: 1, thickness: 0.5 },
|
|
29
|
+
{ slot: 'comparison', layer: 1, thickness: 0.5 },
|
|
30
|
+
] );
|
|
31
|
+
} );
|
|
32
|
+
|
|
33
|
+
it.each( [
|
|
34
|
+
[ 'one group', [ series( 'Views', 'views' ) ], [ 1 ] ],
|
|
35
|
+
[
|
|
36
|
+
'three groups',
|
|
37
|
+
[ series( 'A', 'a' ), series( 'B', 'b' ), series( 'C', 'c' ) ],
|
|
38
|
+
[ 1, 0.75, 0.5 ],
|
|
39
|
+
],
|
|
40
|
+
] )( 'narrows each layer in front with %s', ( _case, data, thicknesses ) => {
|
|
41
|
+
expect( getOverlapPlacements( data ).map( ( { thickness } ) => thickness ) ).toEqual(
|
|
42
|
+
thicknesses
|
|
43
|
+
);
|
|
44
|
+
} );
|
|
45
|
+
} );
|
|
46
|
+
|
|
47
|
+
describe( 'getOverlapBarColors', () => {
|
|
48
|
+
const colorsOf = ( data: SeriesData[] ) =>
|
|
49
|
+
data.map( item => item.options?.stroke ?? ( item.group === 'views' ? BLUE : GREEN ) );
|
|
50
|
+
|
|
51
|
+
it.each( [
|
|
52
|
+
[ 'a light background', '#ffffff', -1 ],
|
|
53
|
+
[ 'a dark background', '#1e1e1e', 1 ],
|
|
54
|
+
] )(
|
|
55
|
+
'tints the back bar to 3:1 against %s and moves the front bar away from it to 3:1',
|
|
56
|
+
( _case, background, direction ) => {
|
|
57
|
+
const [ back, previousBack, front, previousFront ] = getOverlapBarColors(
|
|
58
|
+
VIEWS_AND_VISITORS,
|
|
59
|
+
colorsOf( VIEWS_AND_VISITORS ),
|
|
60
|
+
[ BLUE, GREEN ],
|
|
61
|
+
background
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
for ( const [ slotBack, slotFront ] of [
|
|
65
|
+
[ back, front ],
|
|
66
|
+
[ previousBack, previousFront ],
|
|
67
|
+
] ) {
|
|
68
|
+
expect( contrastRatio( slotBack, background ) ).toBeGreaterThanOrEqual( 3 );
|
|
69
|
+
expect( contrastRatio( slotBack, background ) ).toBeLessThan( 3.1 );
|
|
70
|
+
expect( contrastRatio( slotFront, slotBack ) ).toBeGreaterThanOrEqual( 3 );
|
|
71
|
+
expect( Math.sign( relativeLuminance( slotFront ) - relativeLuminance( slotBack ) ) ).toBe(
|
|
72
|
+
direction
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
it( 'turns the front bar back toward the background when the far side cannot reach 3:1', () => {
|
|
79
|
+
const background = '#686884';
|
|
80
|
+
const [ back, , front ] = getOverlapBarColors(
|
|
81
|
+
VIEWS_AND_VISITORS,
|
|
82
|
+
colorsOf( VIEWS_AND_VISITORS ),
|
|
83
|
+
[ '#9f4902', GREEN ],
|
|
84
|
+
background
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect( contrastRatio( front, back ) ).toBeGreaterThanOrEqual( 3 );
|
|
88
|
+
} );
|
|
89
|
+
|
|
90
|
+
it( "tones each slot from its palette color, not from the series' group colors", () => {
|
|
91
|
+
const toned = getOverlapBarColors(
|
|
92
|
+
VIEWS_AND_VISITORS,
|
|
93
|
+
colorsOf( VIEWS_AND_VISITORS ),
|
|
94
|
+
[ BLUE, GREEN ],
|
|
95
|
+
'#ffffff'
|
|
96
|
+
);
|
|
97
|
+
const regrouped = getOverlapBarColors(
|
|
98
|
+
VIEWS_AND_VISITORS,
|
|
99
|
+
[ '#111111', '#222222', '#333333', '#444444' ],
|
|
100
|
+
[ BLUE, GREEN ],
|
|
101
|
+
'#ffffff'
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect( regrouped ).toEqual( toned );
|
|
105
|
+
} );
|
|
106
|
+
|
|
107
|
+
it( "tones a slot from its back series' own stroke", () => {
|
|
108
|
+
const data = [
|
|
109
|
+
series( 'Views', 'views', { stroke: GREEN } ),
|
|
110
|
+
series( 'Visitors', 'visitors' ),
|
|
111
|
+
];
|
|
112
|
+
const [ back ] = getOverlapBarColors( data, [ GREEN, BLUE ], [ BLUE, BLUE ], '#ffffff' );
|
|
113
|
+
|
|
114
|
+
expect( back ).toBe( tintWithContrast( GREEN, '#ffffff' ) );
|
|
115
|
+
} );
|
|
116
|
+
|
|
117
|
+
it.each( [ BLUE, '#dbdbdb' ] )( 'tints a bar alone in its slot from %s, as a back bar', base => {
|
|
118
|
+
const [ bar ] = getOverlapBarColors(
|
|
119
|
+
[ series( 'Comments' ) ],
|
|
120
|
+
[ base ],
|
|
121
|
+
[ base, GREEN ],
|
|
122
|
+
'#ffffff'
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
expect( bar ).toBe( tintWithContrast( base, '#ffffff' ) );
|
|
126
|
+
} );
|
|
127
|
+
|
|
128
|
+
it.each( [
|
|
129
|
+
[
|
|
130
|
+
'a front series stroked in a color of its own',
|
|
131
|
+
[
|
|
132
|
+
series( 'Previous views', 'views', { type: 'comparison', stroke: '#dbdbdb' } ),
|
|
133
|
+
series( 'Previous visitors', 'visitors', { type: 'comparison', stroke: '#707070' } ),
|
|
134
|
+
],
|
|
135
|
+
[ '#dbdbdb', '#707070' ],
|
|
136
|
+
'#ffffff',
|
|
137
|
+
],
|
|
138
|
+
[
|
|
139
|
+
'a bar alone in its slot stroked in a color of its own',
|
|
140
|
+
[ series( 'Previous comments', 'comments', { type: 'comparison', stroke: '#dbdbdb' } ) ],
|
|
141
|
+
[ '#dbdbdb' ],
|
|
142
|
+
'#ffffff',
|
|
143
|
+
],
|
|
144
|
+
[
|
|
145
|
+
'a base that is not hex',
|
|
146
|
+
[ series( 'Views', 'views', { stroke: 'currentColor' } ), series( 'Visitors', 'visitors' ) ],
|
|
147
|
+
[ 'currentColor', BLUE ],
|
|
148
|
+
'#ffffff',
|
|
149
|
+
],
|
|
150
|
+
[
|
|
151
|
+
'a background that is not hex',
|
|
152
|
+
[ series( 'Views', 'views' ), series( 'Visitors', 'visitors' ) ],
|
|
153
|
+
[ BLUE, GREEN ],
|
|
154
|
+
'transparent',
|
|
155
|
+
],
|
|
156
|
+
] )( 'keeps the colors given with %s', ( _case, data, colors, background ) => {
|
|
157
|
+
expect( getOverlapBarColors( data, colors, [ BLUE, GREEN ], background ) ).toEqual( colors );
|
|
158
|
+
} );
|
|
159
|
+
} );
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useCallback, useMemo } from 'react';
|
|
2
|
+
import { isSameCategory } from './use-overlap-slots';
|
|
3
|
+
import type { OverlapSlots } from './use-overlap-slots';
|
|
4
|
+
import type { DataPointDate, SeriesData } from '../../../types';
|
|
5
|
+
|
|
6
|
+
type SeriesEntry = { series: SeriesData; index: number };
|
|
7
|
+
|
|
8
|
+
/** A bar the keyboard can stop at. */
|
|
9
|
+
export type StopBar = { datum: DataPointDate; index: number; key: string };
|
|
10
|
+
|
|
11
|
+
export type BarStops = {
|
|
12
|
+
count: number;
|
|
13
|
+
/** The series `XYChartTooltip` reads a stop's datum from. Overlap has none: `SlotTooltipMembers` fills its tooltip. */
|
|
14
|
+
series: SeriesData[];
|
|
15
|
+
/** Changes when the stops do, so keyboard navigation starts over. */
|
|
16
|
+
key: string;
|
|
17
|
+
barAt: ( stop: number ) => StopBar | undefined;
|
|
18
|
+
stopOf: ( key: string, dataPointIndex: number ) => number | undefined;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Lays out the keyboard stops: one per slot and category in an overlap layout, else one per primary bar, series-major within each category.
|
|
23
|
+
*
|
|
24
|
+
* @param overlapSlots - The overlap layout, or undefined for a grouped one.
|
|
25
|
+
* @param primaryEntries - The visible primary series.
|
|
26
|
+
* @return The stops.
|
|
27
|
+
*/
|
|
28
|
+
export function useBarStops(
|
|
29
|
+
overlapSlots: OverlapSlots | undefined,
|
|
30
|
+
primaryEntries: SeriesEntry[]
|
|
31
|
+
): BarStops {
|
|
32
|
+
const series = useMemo(
|
|
33
|
+
() => ( overlapSlots ? [] : primaryEntries.map( entry => entry.series ) ),
|
|
34
|
+
[ overlapSlots, primaryEntries ]
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const count =
|
|
38
|
+
overlapSlots?.positions.length ??
|
|
39
|
+
Math.max( 0, ...series.map( item => item.data.length ) ) * series.length;
|
|
40
|
+
|
|
41
|
+
const key = useMemo(
|
|
42
|
+
() => JSON.stringify( overlapSlots?.keys ?? series.map( item => item.label ) ),
|
|
43
|
+
[ overlapSlots, series ]
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const barAt = useCallback(
|
|
47
|
+
( stop: number ): StopBar | undefined => {
|
|
48
|
+
if ( overlapSlots ) {
|
|
49
|
+
const position = overlapSlots.positions[ stop ];
|
|
50
|
+
return (
|
|
51
|
+
position && {
|
|
52
|
+
datum: position.datum,
|
|
53
|
+
index: position.dataPointIndex,
|
|
54
|
+
key: position.key,
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if ( ! series.length ) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const index = Math.floor( stop / series.length );
|
|
62
|
+
const item = series[ stop % series.length ];
|
|
63
|
+
const datum = item?.data[ index ] as DataPointDate | undefined;
|
|
64
|
+
return item && datum ? { datum, index, key: item.label } : undefined;
|
|
65
|
+
},
|
|
66
|
+
[ overlapSlots, series ]
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const stopOf = useCallback(
|
|
70
|
+
( seriesKey: string, dataPointIndex: number ): number | undefined => {
|
|
71
|
+
if ( overlapSlots ) {
|
|
72
|
+
const hovered = overlapSlots.entries.find( entry => entry.series.label === seriesKey )
|
|
73
|
+
?.series.data[ dataPointIndex ] as DataPointDate | undefined;
|
|
74
|
+
const slotKey = overlapSlots.slotOf( seriesKey );
|
|
75
|
+
const stop = hovered
|
|
76
|
+
? overlapSlots.positions.findIndex(
|
|
77
|
+
position => position.slotKey === slotKey && isSameCategory( position.datum, hovered )
|
|
78
|
+
)
|
|
79
|
+
: -1;
|
|
80
|
+
return stop >= 0 ? stop : undefined;
|
|
81
|
+
}
|
|
82
|
+
const seriesIndex = series.findIndex( item => item.label === seriesKey );
|
|
83
|
+
return seriesIndex >= 0 ? dataPointIndex * series.length + seriesIndex : undefined;
|
|
84
|
+
},
|
|
85
|
+
[ overlapSlots, series ]
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return { count, series, key, barAt, stopOf };
|
|
89
|
+
}
|