@automattic/charts 4.11.1 → 4.13.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 +24 -0
- package/dist/index.cjs +1412 -787
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +59 -25
- package/dist/index.d.cts +64 -120
- package/dist/index.d.ts +64 -120
- package/dist/index.js +1400 -765
- package/dist/index.js.map +1 -1
- package/package.json +9 -9
- package/src/charts/area-chart/area-chart.tsx +16 -6
- package/src/charts/area-chart/test/area-chart.test.tsx +42 -9
- package/src/charts/bar-chart/bar-chart.tsx +143 -142
- 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 +28 -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 +87 -0
- package/src/charts/bar-chart/private/use-overlap-slots.ts +213 -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 +181 -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/conversion-funnel-chart/conversion-funnel-chart.module.scss +24 -11
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +235 -182
- package/src/charts/conversion-funnel-chart/index.ts +2 -0
- package/src/charts/conversion-funnel-chart/private/index.ts +1 -0
- package/src/charts/conversion-funnel-chart/private/use-funnel-selection.ts +9 -47
- package/src/charts/conversion-funnel-chart/private/use-roving-step-focus.ts +83 -0
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +312 -92
- package/src/charts/conversion-funnel-chart/test/use-funnel-selection.test.ts +23 -188
- package/src/charts/conversion-funnel-chart/types.ts +22 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +41 -1
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +101 -30
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +47 -0
- package/src/charts/line-chart/line-chart.tsx +25 -18
- package/src/charts/line-chart/test/line-chart.test.tsx +240 -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/private/at-nearest-date.ts +49 -0
- package/src/components/tooltip/private/keyboard-stops.ts +67 -0
- package/src/components/tooltip/private/xy-chart-tooltip-overlay.tsx +10 -2
- package/src/components/tooltip/test/keyboard-stops.test.ts +36 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +135 -1
- package/src/components/tooltip/xy-chart-tooltip.tsx +11 -14
- package/src/index.ts +10 -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 +14 -5
- package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +0 -47
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { MIN_BACKGROUND_CONTRAST } from '../../../providers/chart-context/private/palette-generator';
|
|
2
|
+
import { contrastRatio } from '../../../providers/chart-context/private/perceptual-color';
|
|
3
|
+
import { isValidHexColor, mixHexColors } from '../../../utils/color-utils';
|
|
4
|
+
import type { SeriesData } from '../../../types';
|
|
5
|
+
|
|
6
|
+
export const PRIMARY_SLOT = 'primary';
|
|
7
|
+
export const COMPARISON_SLOT = 'comparison';
|
|
8
|
+
|
|
9
|
+
const OVERLAP_SLOTS = [ PRIMARY_SLOT, COMPARISON_SLOT ] as const;
|
|
10
|
+
|
|
11
|
+
/** Thickness of the front-most layer, as a share of its slot. */
|
|
12
|
+
const FRONT_THICKNESS = 0.5;
|
|
13
|
+
|
|
14
|
+
const SEARCH_STEPS = 16;
|
|
15
|
+
|
|
16
|
+
/** Where a series sits in an overlap layout. */
|
|
17
|
+
export type OverlapPlacement = {
|
|
18
|
+
slot: ( typeof OVERLAP_SLOTS )[ number ];
|
|
19
|
+
/** 0 for the first `group` in the data, which is drawn at the back. */
|
|
20
|
+
layer: number;
|
|
21
|
+
/** The bar's thickness as a share of its slot. */
|
|
22
|
+
thickness: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Places each series of an overlap layout: primaries in one slot and comparisons in the other, with each `group` a layer, back to front in order of first appearance.
|
|
27
|
+
*
|
|
28
|
+
* @param series - The series to place.
|
|
29
|
+
* @return Each series' placement, by index.
|
|
30
|
+
*/
|
|
31
|
+
export function getOverlapPlacements( series: SeriesData[] ): OverlapPlacement[] {
|
|
32
|
+
const layerByGroup = new Map< string | undefined, number >();
|
|
33
|
+
|
|
34
|
+
series.forEach( ( { group } ) => {
|
|
35
|
+
if ( ! layerByGroup.has( group ) ) {
|
|
36
|
+
layerByGroup.set( group, layerByGroup.size );
|
|
37
|
+
}
|
|
38
|
+
} );
|
|
39
|
+
|
|
40
|
+
const layerCount = layerByGroup.size;
|
|
41
|
+
|
|
42
|
+
return series.map( item => {
|
|
43
|
+
const layer = layerByGroup.get( item.group ) ?? 0;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
slot: item.options?.type === 'comparison' ? COMPARISON_SLOT : PRIMARY_SLOT,
|
|
47
|
+
layer,
|
|
48
|
+
thickness: layerCount > 1 ? 1 - ( layer * ( 1 - FRONT_THICKNESS ) ) / ( layerCount - 1 ) : 1,
|
|
49
|
+
};
|
|
50
|
+
} );
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Blends `from` toward `to` and returns the blend nearest the point where `passes` changes, on its passing side.
|
|
55
|
+
*
|
|
56
|
+
* @param from - Where the blend starts.
|
|
57
|
+
* @param to - Where the blend ends.
|
|
58
|
+
* @param passes - A test that changes at most once along the blend.
|
|
59
|
+
* @return A hex color.
|
|
60
|
+
*/
|
|
61
|
+
function blendToEdge( from: string, to: string, passes: ( color: string ) => boolean ): string {
|
|
62
|
+
const passesAtStart = passes( from );
|
|
63
|
+
let low = 0;
|
|
64
|
+
let high = 1;
|
|
65
|
+
|
|
66
|
+
for ( let step = 0; step < SEARCH_STEPS; step++ ) {
|
|
67
|
+
const middle = ( low + high ) / 2;
|
|
68
|
+
|
|
69
|
+
if ( passes( mixHexColors( from, to, middle ) ) === passesAtStart ) {
|
|
70
|
+
low = middle;
|
|
71
|
+
} else {
|
|
72
|
+
high = middle;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return mixHexColors( from, to, passesAtStart ? low : high );
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const awayFrom = ( background: string ): string =>
|
|
80
|
+
contrastRatio( background, '#000000' ) >= contrastRatio( background, '#ffffff' )
|
|
81
|
+
? '#000000'
|
|
82
|
+
: '#ffffff';
|
|
83
|
+
|
|
84
|
+
const opposite = ( end: string ): string => ( end === '#000000' ? '#ffffff' : '#000000' );
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The tone of a color nearest the background that keeps 3:1 against it.
|
|
88
|
+
*
|
|
89
|
+
* @param color - A hex color.
|
|
90
|
+
* @param background - The hex chart background.
|
|
91
|
+
* @return A hex color.
|
|
92
|
+
*/
|
|
93
|
+
export function tintWithContrast( color: string, background: string ): string {
|
|
94
|
+
const passes = ( tone: string ) => contrastRatio( tone, background ) >= MIN_BACKGROUND_CONTRAST;
|
|
95
|
+
|
|
96
|
+
return blendToEdge( color, passes( color ) ? background : awayFrom( background ), passes );
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The tone of a color nearest the original that keeps 3:1 against the bar it is drawn over, on the far side of that bar from the background when that side can reach 3:1.
|
|
101
|
+
*
|
|
102
|
+
* @param color - A hex color.
|
|
103
|
+
* @param under - The hex color of the bar behind it.
|
|
104
|
+
* @param background - The hex chart background.
|
|
105
|
+
* @return A hex color.
|
|
106
|
+
*/
|
|
107
|
+
export function shadeWithContrast( color: string, under: string, background: string ): string {
|
|
108
|
+
const passes = ( tone: string ) => contrastRatio( tone, under ) >= MIN_BACKGROUND_CONTRAST;
|
|
109
|
+
// Starting from a color on the background's side of `under` would cross it, so the search could not tell which side it ended on.
|
|
110
|
+
const start =
|
|
111
|
+
contrastRatio( color, background ) > contrastRatio( under, background ) ? color : under;
|
|
112
|
+
|
|
113
|
+
if ( passes( start ) ) {
|
|
114
|
+
return start;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const away = awayFrom( background );
|
|
118
|
+
|
|
119
|
+
return blendToEdge( start, passes( away ) ? away : opposite( away ), passes );
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The colors an overlap layout fills its series with: per slot, the back layer tinted to 3:1 against the background and each layer in front shaded to 3:1 against the one behind. A slot whose front-most series has its own `stroke` keeps its colors as given.
|
|
124
|
+
*
|
|
125
|
+
* @param series - Every series of the chart.
|
|
126
|
+
* @param colors - Each series' resolved color.
|
|
127
|
+
* @param slotColors - The palette color of each slot, primaries first.
|
|
128
|
+
* @param background - The hex chart background.
|
|
129
|
+
* @return Each series' fill, by index.
|
|
130
|
+
*/
|
|
131
|
+
export function getOverlapBarColors(
|
|
132
|
+
series: SeriesData[],
|
|
133
|
+
colors: string[],
|
|
134
|
+
slotColors: readonly [ string, string ],
|
|
135
|
+
background: string
|
|
136
|
+
): string[] {
|
|
137
|
+
const placements = getOverlapPlacements( series );
|
|
138
|
+
const barColors = [ ...colors ];
|
|
139
|
+
|
|
140
|
+
OVERLAP_SLOTS.forEach( ( slot, slotIndex ) => {
|
|
141
|
+
const members = placements.flatMap( ( placement, index ) =>
|
|
142
|
+
placement.slot === slot ? [ { index, layer: placement.layer } ] : []
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if ( ! members.length ) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const layers = [ ...new Set( members.map( ( { layer } ) => layer ) ) ].sort(
|
|
150
|
+
( a, b ) => a - b
|
|
151
|
+
);
|
|
152
|
+
const back = members.find( ( { layer } ) => layer === layers[ 0 ] );
|
|
153
|
+
const base =
|
|
154
|
+
back && series[ back.index ].options?.stroke ? colors[ back.index ] : slotColors[ slotIndex ];
|
|
155
|
+
const isFrontStroked = members.some(
|
|
156
|
+
( { index, layer } ) =>
|
|
157
|
+
layer === layers[ layers.length - 1 ] && !! series[ index ].options?.stroke
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// A paint such as `currentColor` or `url(#gradient)` cannot be toned, so the slot keeps the colors it was given.
|
|
161
|
+
if ( isFrontStroked || ! isValidHexColor( base ) || ! isValidHexColor( background ) ) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const toneByLayer = new Map< number, string >();
|
|
166
|
+
let under: string | undefined;
|
|
167
|
+
|
|
168
|
+
layers.forEach( layer => {
|
|
169
|
+
const tone =
|
|
170
|
+
under === undefined
|
|
171
|
+
? tintWithContrast( base, background )
|
|
172
|
+
: shadeWithContrast( base, under, background );
|
|
173
|
+
toneByLayer.set( layer, tone );
|
|
174
|
+
under = tone;
|
|
175
|
+
} );
|
|
176
|
+
|
|
177
|
+
members.forEach( ( { index, layer } ) => {
|
|
178
|
+
barColors[ index ] = toneByLayer.get( layer ) ?? colors[ index ];
|
|
179
|
+
} );
|
|
180
|
+
} );
|
|
181
|
+
|
|
182
|
+
return barColors;
|
|
183
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { DataContext } from '@visx/xychart';
|
|
2
|
+
import { useContext } from 'react';
|
|
3
|
+
import { SelectedRing } from '../../private/selected-ring/selected-ring';
|
|
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 { FC } from 'react';
|
|
9
|
+
|
|
10
|
+
/** The slot the keyboard has selected. */
|
|
11
|
+
export type SelectedBar = {
|
|
12
|
+
/** The data of the bars the slot draws at the selected category, which the ring encloses; only bars the chart draws (a finite value). */
|
|
13
|
+
data: DataPointDate[];
|
|
14
|
+
/** The selected slot. */
|
|
15
|
+
slotKey: string;
|
|
16
|
+
/** The thickness of the slot's widest bar, as a share of the slot. */
|
|
17
|
+
thickness: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type AnyScale = ( ( input: unknown ) => number ) &
|
|
21
|
+
Partial< Pick< BandScale, 'bandwidth' > > & { range: () => unknown[] };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Marks the keyboard-selected slot with a two-tone ring outside its edge, so the ring reads against any bar color.
|
|
25
|
+
*
|
|
26
|
+
* @param props - Component props.
|
|
27
|
+
* @param props.chartId - The chart's id, which keeps the clip path unique on a page with several charts.
|
|
28
|
+
* @param props.selected - The selected slot.
|
|
29
|
+
* @param props.slotKeys - The slots, in the order the band lays them out.
|
|
30
|
+
* @param props.groupPadding - The padding between slots.
|
|
31
|
+
* @param props.horizontal - Whether the bars run horizontally.
|
|
32
|
+
* @param props.xAccessor - Reads a datum's x value.
|
|
33
|
+
* @param props.yAccessor - Reads a datum's y value.
|
|
34
|
+
* @return The ring, or nothing when the slot has no position.
|
|
35
|
+
*/
|
|
36
|
+
export const SelectedBarRing: FC< {
|
|
37
|
+
chartId: string;
|
|
38
|
+
selected: SelectedBar;
|
|
39
|
+
slotKeys: string[];
|
|
40
|
+
groupPadding: number;
|
|
41
|
+
horizontal: boolean;
|
|
42
|
+
xAccessor: ( d: DataPointDate ) => string | number | Date | undefined;
|
|
43
|
+
yAccessor: ( d: DataPointDate ) => number | undefined;
|
|
44
|
+
} > = ( { chartId, selected, slotKeys, groupPadding, horizontal, xAccessor, yAccessor } ) => {
|
|
45
|
+
const context = useContext( DataContext );
|
|
46
|
+
const bandScale = ( horizontal ? context?.yScale : context?.xScale ) as AnyScale | undefined;
|
|
47
|
+
const valueScale = ( horizontal ? context?.xScale : context?.yScale ) as AnyScale | undefined;
|
|
48
|
+
const bandwidth = bandScale?.bandwidth?.() ?? 0;
|
|
49
|
+
const [ category ] = selected.data;
|
|
50
|
+
|
|
51
|
+
if ( ! bandScale || ! valueScale || ! bandwidth || ! category ) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const groupScale = createGroupScale( slotKeys, bandwidth, groupPadding );
|
|
56
|
+
const slotOffset = groupScale( selected.slotKey );
|
|
57
|
+
const bandPosition = Number(
|
|
58
|
+
bandScale( ( horizontal ? yAccessor : xAccessor )( category ) as never )
|
|
59
|
+
);
|
|
60
|
+
if ( slotOffset == null || ! Number.isFinite( bandPosition ) ) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const valuePositions = selected.data.map( datum =>
|
|
65
|
+
Number( valueScale( Number( ( horizontal ? xAccessor : yAccessor )( datum ) ) as never ) )
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const rect = computeSlotRect( {
|
|
69
|
+
horizontal,
|
|
70
|
+
bandPosition,
|
|
71
|
+
slotOffset,
|
|
72
|
+
slotThickness: groupScale.bandwidth(),
|
|
73
|
+
valuePositions: [ getValueScaleBaseline( valueScale ), ...valuePositions ],
|
|
74
|
+
widthFactor: selected.thickness,
|
|
75
|
+
} );
|
|
76
|
+
return (
|
|
77
|
+
<SelectedRing
|
|
78
|
+
chartId={ chartId }
|
|
79
|
+
d={ `M${ rect.x } ${ rect.y }h${ rect.width }v${ rect.height }h${ -rect.width }Z` }
|
|
80
|
+
side="outside"
|
|
81
|
+
testId="bar-chart-selected-ring"
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
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; index: number };
|
|
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
|
+
* @return No visual content.
|
|
16
|
+
*/
|
|
17
|
+
export function SlotTooltipMembers( { members }: { members: SlotMember[] } ) {
|
|
18
|
+
const { showTooltip, updateTooltip } = useContext( TooltipContext ) ?? {};
|
|
19
|
+
|
|
20
|
+
useEffect( () => {
|
|
21
|
+
updateTooltip?.( state => ( { ...state, tooltipData: undefined } ) );
|
|
22
|
+
members.forEach( member => showTooltip?.( member ) );
|
|
23
|
+
// `showTooltip` and `updateTooltip` are stable; the context object is not.
|
|
24
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
25
|
+
}, [ members ] );
|
|
26
|
+
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
@@ -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
|
+
} );
|