@automattic/charts 1.6.0 → 1.8.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 +17 -0
- package/README.md +2 -2
- package/dist/index.cjs +380 -60
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +12 -7
- package/dist/index.d.cts +119 -30
- package/dist/index.d.ts +122 -33
- package/dist/index.js +380 -60
- package/dist/index.js.map +1 -1
- package/package.json +22 -21
- package/src/charts/area-chart/types.ts +1 -1
- package/src/charts/bar-chart/bar-chart.tsx +197 -43
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +70 -0
- package/src/charts/bar-chart/private/comparison-bars.tsx +154 -0
- package/src/charts/bar-chart/private/comparison-constants.ts +33 -0
- package/src/charts/bar-chart/private/index.ts +9 -0
- package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +47 -0
- package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +183 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +46 -5
- package/src/charts/bar-chart/test/bar-chart.test.tsx +191 -1
- package/src/charts/geo-chart/geo-chart.tsx +8 -10
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +27 -14
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +7 -4
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +51 -4
- package/src/charts/line-chart/line-chart.tsx +1 -1
- package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +18 -2
- package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
- package/src/charts/line-chart/types.ts +1 -1
- package/src/charts/pie-chart/pie-chart.module.scss +0 -4
- package/src/charts/pie-chart/pie-chart.tsx +3 -8
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +0 -5
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +5 -10
- package/src/charts/private/center/center.module.scss +4 -0
- package/src/charts/private/center/center.tsx +33 -0
- package/src/charts/private/center/index.ts +2 -0
- package/src/charts/private/center/test/center.test.tsx +60 -0
- package/src/charts/private/chart-layout/chart-layout.tsx +1 -2
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +0 -2
- package/src/charts/private/svg-empty-state/svg-empty-state.tsx +2 -4
- package/src/charts/private/x-zoom.tsx +2 -2
- package/src/components/legend/hooks/use-chart-legend-items.ts +6 -2
- package/src/components/legend/legend.tsx +1 -2
- package/src/components/legend/utils/label-transform-factory.ts +1 -1
- package/src/components/tooltip/accessible-tooltip.tsx +2 -6
- package/src/index.ts +6 -5
- package/src/providers/chart-context/global-charts-provider.tsx +2 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +13 -1
- package/src/providers/chart-context/themes.ts +9 -1
- package/src/providers/chart-context/types.ts +9 -2
- package/src/types.ts +120 -11
- package/src/utils/get-styles.ts +36 -14
- package/src/utils/index.ts +6 -1
- package/src/utils/test/get-styles.test.ts +47 -1
- package/src/visx/types.ts +30 -1
|
@@ -1,2 +1,11 @@
|
|
|
1
1
|
export { useBarChartOptions } from './use-bar-chart-options';
|
|
2
2
|
export { TruncatedXTickComponent, TruncatedYTickComponent } from './truncated-tick-component';
|
|
3
|
+
export { ComparisonBars } from './comparison-bars';
|
|
4
|
+
export type { ComparisonSeriesEntry } from './comparison-bars';
|
|
5
|
+
export {
|
|
6
|
+
DEFAULT_COMPARISON_WIDTH_FACTOR,
|
|
7
|
+
COMPARISON_INNER_GAP,
|
|
8
|
+
MAX_GROUP_PADDING,
|
|
9
|
+
COMPARISON_TICK_GAP_FACTOR,
|
|
10
|
+
} from './comparison-constants';
|
|
11
|
+
export { BASE_BAND_PADDING_INNER } from './use-bar-chart-options';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { computeComparisonRect, getValueScaleBaseline } from '../comparison-bars-geometry';
|
|
2
|
+
|
|
3
|
+
describe( 'getValueScaleBaseline', () => {
|
|
4
|
+
it( 'clamps to the bottom (range max) when 0 is below the domain (vertical, descending range)', () => {
|
|
5
|
+
// vertical linear scale: range [200, 0] (bottom -> top), domain [10, 100] (zero excluded)
|
|
6
|
+
const scale = ( ( v: number ) => 200 - ( ( v - 10 ) / 90 ) * 200 ) as never;
|
|
7
|
+
( scale as { range: () => number[] } ).range = () => [ 200, 0 ];
|
|
8
|
+
// scale(0) = 200 - (-10/90)*200 ≈ 222 -> clamped to 200
|
|
9
|
+
expect( getValueScaleBaseline( scale ) ).toBe( 200 );
|
|
10
|
+
} );
|
|
11
|
+
|
|
12
|
+
it( 'clamps to the end (range max) when 0 is outside an ascending range scale', () => {
|
|
13
|
+
// horizontal linear scale: range [0, 200], domain [-100, -10] (zero excluded)
|
|
14
|
+
const scale = ( ( v: number ) => ( ( v + 100 ) / 90 ) * 200 ) as never;
|
|
15
|
+
( scale as { range: () => number[] } ).range = () => [ 0, 200 ];
|
|
16
|
+
// scale(0) = (100/90)*200 ≈ 222 -> clamped to 200
|
|
17
|
+
expect( getValueScaleBaseline( scale ) ).toBe( 200 );
|
|
18
|
+
} );
|
|
19
|
+
} );
|
|
20
|
+
|
|
21
|
+
describe( 'computeComparisonRect', () => {
|
|
22
|
+
const base = {
|
|
23
|
+
bandPosition: 100,
|
|
24
|
+
slotOffset: 10,
|
|
25
|
+
slotThickness: 40,
|
|
26
|
+
valuePosition: 50,
|
|
27
|
+
baseline: 200,
|
|
28
|
+
widthFactor: 1.5,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
it( 'centers a 150% wide shadow on the primary slot (vertical)', () => {
|
|
32
|
+
const r = computeComparisonRect( { ...base, horizontal: false } );
|
|
33
|
+
// slotStart=110, center=110+20=130, shadowThickness=60, shadowStart=130-30=100
|
|
34
|
+
expect( r ).toEqual( { x: 100, y: 50, width: 60, height: 150 } );
|
|
35
|
+
} );
|
|
36
|
+
|
|
37
|
+
it( 'centers a 150% tall shadow on the primary slot (horizontal)', () => {
|
|
38
|
+
const r = computeComparisonRect( {
|
|
39
|
+
...base,
|
|
40
|
+
horizontal: true,
|
|
41
|
+
valuePosition: 150,
|
|
42
|
+
baseline: 0,
|
|
43
|
+
} );
|
|
44
|
+
// slotStart=110, center=130, shadowThickness=60, shadowStart=100
|
|
45
|
+
expect( r ).toEqual( { x: 0, y: 100, width: 150, height: 60 } );
|
|
46
|
+
} );
|
|
47
|
+
} );
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { scaleBand, scaleLinear } from '@visx/scale';
|
|
3
|
+
import { DataContext } from '@visx/xychart';
|
|
4
|
+
import { ComparisonBars } from '../comparison-bars';
|
|
5
|
+
import type { DataPointDate, SeriesData } from '../../../../types';
|
|
6
|
+
import type { ComparisonSeriesEntry } from '../comparison-bars';
|
|
7
|
+
|
|
8
|
+
// Build the band scale over two categories (matches how BarGroup sets it up)
|
|
9
|
+
const categories = [ 'Jan', 'Feb' ];
|
|
10
|
+
const xScale = scaleBand( {
|
|
11
|
+
domain: categories,
|
|
12
|
+
range: [ 0, 200 ],
|
|
13
|
+
padding: 0.1,
|
|
14
|
+
} );
|
|
15
|
+
|
|
16
|
+
// Simple linear value scale: domain 0..100 -> range 200..0 (SVG coords, descending)
|
|
17
|
+
const yScale = scaleLinear( { domain: [ 0, 100 ], range: [ 200, 0 ] } );
|
|
18
|
+
|
|
19
|
+
// The primaryKeys in this test
|
|
20
|
+
const primaryKeys = [ 'current' ];
|
|
21
|
+
const groupPadding = 0.1;
|
|
22
|
+
|
|
23
|
+
// Build the same groupScale so tests can compute expected width
|
|
24
|
+
const groupScale = scaleBand( {
|
|
25
|
+
domain: primaryKeys,
|
|
26
|
+
range: [ 0, xScale.bandwidth() ],
|
|
27
|
+
padding: groupPadding,
|
|
28
|
+
} );
|
|
29
|
+
|
|
30
|
+
// Minimal DataContext value
|
|
31
|
+
const dataContextValue = {
|
|
32
|
+
xScale: xScale as never,
|
|
33
|
+
yScale: yScale as never,
|
|
34
|
+
horizontal: false,
|
|
35
|
+
// Other DataContext fields the component doesn't use
|
|
36
|
+
width: 200,
|
|
37
|
+
height: 200,
|
|
38
|
+
margin: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
39
|
+
dataRegistry: {} as never,
|
|
40
|
+
registerData: () => undefined,
|
|
41
|
+
unregisterData: () => undefined,
|
|
42
|
+
setDimensions: () => undefined,
|
|
43
|
+
innerWidth: 200,
|
|
44
|
+
innerHeight: 200,
|
|
45
|
+
theme: {} as never,
|
|
46
|
+
colorScale: {} as never,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const seriesData: SeriesData = {
|
|
50
|
+
label: 'Comparison',
|
|
51
|
+
data: [
|
|
52
|
+
{ label: 'Jan', value: 40 } as DataPointDate,
|
|
53
|
+
{ label: 'Feb', value: 70 } as DataPointDate,
|
|
54
|
+
] as DataPointDate[],
|
|
55
|
+
options: { type: 'comparison' },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const comparisonEntries: ComparisonSeriesEntry[] = [
|
|
59
|
+
{ series: seriesData, index: 0, primaryKey: 'current', primaryIndex: 0 },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const getElementStyles = () => ( {
|
|
63
|
+
color: '#f00',
|
|
64
|
+
barStyles: { widthFactor: 1.5, opacity: 0.5 },
|
|
65
|
+
lineStyles: {} as never,
|
|
66
|
+
glyph: undefined as never,
|
|
67
|
+
shapeStyles: {} as never,
|
|
68
|
+
} );
|
|
69
|
+
|
|
70
|
+
const resolveFill = () => '#f00';
|
|
71
|
+
|
|
72
|
+
const xAccessor = ( d: DataPointDate ) => d.label;
|
|
73
|
+
const yAccessor = ( d: DataPointDate ) => d.value ?? undefined;
|
|
74
|
+
|
|
75
|
+
describe( 'ComparisonBars', () => {
|
|
76
|
+
it( 'renders two shadow rects for two data points', () => {
|
|
77
|
+
render(
|
|
78
|
+
<svg>
|
|
79
|
+
<DataContext.Provider value={ dataContextValue }>
|
|
80
|
+
<ComparisonBars
|
|
81
|
+
comparisonEntries={ comparisonEntries }
|
|
82
|
+
primaryKeys={ primaryKeys }
|
|
83
|
+
groupPadding={ groupPadding }
|
|
84
|
+
horizontal={ false }
|
|
85
|
+
xAccessor={ xAccessor }
|
|
86
|
+
yAccessor={ yAccessor }
|
|
87
|
+
getElementStyles={ getElementStyles }
|
|
88
|
+
resolveFill={ resolveFill }
|
|
89
|
+
/>
|
|
90
|
+
</DataContext.Provider>
|
|
91
|
+
</svg>
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const rects = screen.getAllByTestId( /^bar-chart-comparison-0-/ );
|
|
95
|
+
expect( rects ).toHaveLength( 2 );
|
|
96
|
+
expect( rects[ 0 ] ).toHaveAttribute( 'fill', '#f00' );
|
|
97
|
+
expect( rects[ 0 ] ).toHaveAttribute( 'opacity', '0.5' );
|
|
98
|
+
// width == widthFactor (1.5) * groupScale.bandwidth()
|
|
99
|
+
expect( Number( rects[ 0 ].getAttribute( 'width' ) ) ).toBeCloseTo(
|
|
100
|
+
1.5 * groupScale.bandwidth()
|
|
101
|
+
);
|
|
102
|
+
} );
|
|
103
|
+
|
|
104
|
+
it( 'wraps rects in a g with correct class and pointerEvents', () => {
|
|
105
|
+
render(
|
|
106
|
+
<svg>
|
|
107
|
+
<DataContext.Provider value={ dataContextValue }>
|
|
108
|
+
<ComparisonBars
|
|
109
|
+
comparisonEntries={ comparisonEntries }
|
|
110
|
+
primaryKeys={ primaryKeys }
|
|
111
|
+
groupPadding={ groupPadding }
|
|
112
|
+
horizontal={ false }
|
|
113
|
+
xAccessor={ xAccessor }
|
|
114
|
+
yAccessor={ yAccessor }
|
|
115
|
+
getElementStyles={ getElementStyles }
|
|
116
|
+
resolveFill={ resolveFill }
|
|
117
|
+
/>
|
|
118
|
+
</DataContext.Provider>
|
|
119
|
+
</svg>
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const g = screen.getByTestId( 'bar-chart-comparison-bars' );
|
|
123
|
+
expect( g ).toHaveClass( 'bar-chart__comparison-bars', { exact: true } );
|
|
124
|
+
expect( g ).toHaveAttribute( 'pointer-events', 'none' );
|
|
125
|
+
} );
|
|
126
|
+
|
|
127
|
+
it( 'shadow rect is horizontally centered on the primary bar slot at widthFactor × slot width', () => {
|
|
128
|
+
render(
|
|
129
|
+
<svg>
|
|
130
|
+
<DataContext.Provider value={ dataContextValue }>
|
|
131
|
+
<ComparisonBars
|
|
132
|
+
comparisonEntries={ comparisonEntries }
|
|
133
|
+
primaryKeys={ primaryKeys }
|
|
134
|
+
groupPadding={ groupPadding }
|
|
135
|
+
horizontal={ false }
|
|
136
|
+
xAccessor={ xAccessor }
|
|
137
|
+
yAccessor={ yAccessor }
|
|
138
|
+
getElementStyles={ getElementStyles }
|
|
139
|
+
resolveFill={ resolveFill }
|
|
140
|
+
/>
|
|
141
|
+
</DataContext.Provider>
|
|
142
|
+
</svg>
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const rects = screen.getAllByTestId( /^bar-chart-comparison-0-/ );
|
|
146
|
+
const rect = rects[ 0 ]; // 'Jan' datum
|
|
147
|
+
|
|
148
|
+
const slotThickness = groupScale.bandwidth();
|
|
149
|
+
const expectedWidth = 1.5 * slotThickness; // shadow = widthFactor × slot width
|
|
150
|
+
// Shadow x = bandStart + slotOffset + slotThickness/2 - shadowWidth/2
|
|
151
|
+
const bandStart = Number( xScale( 'Jan' ) );
|
|
152
|
+
const slotOffset = Number( groupScale( 'current' ) );
|
|
153
|
+
const expectedX = bandStart + slotOffset + slotThickness / 2 - expectedWidth / 2;
|
|
154
|
+
const expectedCenterX = expectedX + expectedWidth / 2;
|
|
155
|
+
const primarySlotCenterX = bandStart + slotOffset + slotThickness / 2;
|
|
156
|
+
|
|
157
|
+
expect( Number( rect.getAttribute( 'width' ) ) ).toBeCloseTo( expectedWidth );
|
|
158
|
+
expect( Number( rect.getAttribute( 'x' ) ) ).toBeCloseTo( expectedX );
|
|
159
|
+
// Center of shadow matches center of primary slot
|
|
160
|
+
expect( expectedCenterX ).toBeCloseTo( primarySlotCenterX );
|
|
161
|
+
} );
|
|
162
|
+
|
|
163
|
+
it( 'renders nothing when primaryKeys is empty', () => {
|
|
164
|
+
render(
|
|
165
|
+
<svg>
|
|
166
|
+
<DataContext.Provider value={ dataContextValue }>
|
|
167
|
+
<ComparisonBars
|
|
168
|
+
comparisonEntries={ comparisonEntries }
|
|
169
|
+
primaryKeys={ [] }
|
|
170
|
+
groupPadding={ groupPadding }
|
|
171
|
+
horizontal={ false }
|
|
172
|
+
xAccessor={ xAccessor }
|
|
173
|
+
yAccessor={ yAccessor }
|
|
174
|
+
getElementStyles={ getElementStyles }
|
|
175
|
+
resolveFill={ resolveFill }
|
|
176
|
+
/>
|
|
177
|
+
</DataContext.Provider>
|
|
178
|
+
</svg>
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect( screen.queryByTestId( 'bar-chart-comparison-bars' ) ).not.toBeInTheDocument();
|
|
182
|
+
} );
|
|
183
|
+
} );
|
|
@@ -5,6 +5,11 @@ import type { EnhancedDataPoint } from '../../../hooks/use-zero-value-display';
|
|
|
5
5
|
import type { DataPointDate, BaseChartProps, SeriesData } from '../../../types';
|
|
6
6
|
import type { TickFormatter } from '@visx/axis';
|
|
7
7
|
|
|
8
|
+
/** Outer padding of the category band scale (space at the chart edges). */
|
|
9
|
+
export const BASE_BAND_PADDING = 0.2;
|
|
10
|
+
/** Inner padding of the category band scale (the base gap between ticks). */
|
|
11
|
+
export const BASE_BAND_PADDING_INNER = 0.1;
|
|
12
|
+
|
|
8
13
|
const formatDateTick = ( timestamp: number ) => {
|
|
9
14
|
const date = new Date( timestamp );
|
|
10
15
|
return date.toLocaleDateString( undefined, {
|
|
@@ -39,8 +44,8 @@ export function useBarChartOptions(
|
|
|
39
44
|
const defaultOptions = useMemo( () => {
|
|
40
45
|
const bandScale = {
|
|
41
46
|
type: 'band' as const,
|
|
42
|
-
padding:
|
|
43
|
-
paddingInner:
|
|
47
|
+
padding: BASE_BAND_PADDING,
|
|
48
|
+
paddingInner: BASE_BAND_PADDING_INNER,
|
|
44
49
|
};
|
|
45
50
|
const linearScale = {
|
|
46
51
|
type: 'linear' as const,
|
|
@@ -97,8 +102,44 @@ export function useBarChartOptions(
|
|
|
97
102
|
yScale: baseYScale,
|
|
98
103
|
} = defaultOptions[ orientationKey ];
|
|
99
104
|
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
// When comparison series are present, visx only sees primary BarSeries and computes
|
|
106
|
+
// a too-narrow domain. Compute an explicit domain spanning all series so comparison
|
|
107
|
+
// shadows aren't clipped. Skip when the user has already provided an explicit domain.
|
|
108
|
+
let valueScaleDomainOverride: { domain?: [ number, number ] } = {};
|
|
109
|
+
const hasComparisonSeries = data.some( s => s.options?.type === 'comparison' );
|
|
110
|
+
if ( hasComparisonSeries ) {
|
|
111
|
+
const valueAxisIsY = ! horizontal;
|
|
112
|
+
const userDomain = valueAxisIsY ? options.yScale?.domain : options.xScale?.domain;
|
|
113
|
+
if ( ! userDomain ) {
|
|
114
|
+
const allValues: number[] = [];
|
|
115
|
+
data.forEach( series => {
|
|
116
|
+
series.data.forEach( d => {
|
|
117
|
+
const enhanced = d as { visualValue?: number };
|
|
118
|
+
const v =
|
|
119
|
+
enhanced.visualValue !== undefined ? enhanced.visualValue : ( d.value as number );
|
|
120
|
+
if ( typeof v === 'number' && Number.isFinite( v ) ) {
|
|
121
|
+
allValues.push( v );
|
|
122
|
+
}
|
|
123
|
+
} );
|
|
124
|
+
} );
|
|
125
|
+
if ( allValues.length > 0 ) {
|
|
126
|
+
valueScaleDomainOverride = {
|
|
127
|
+
domain: [ Math.min( ...allValues ), Math.max( ...allValues ) ],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const xScale = {
|
|
134
|
+
...baseXScale,
|
|
135
|
+
...( options.xScale || {} ),
|
|
136
|
+
...( horizontal ? valueScaleDomainOverride : {} ),
|
|
137
|
+
};
|
|
138
|
+
const yScale = {
|
|
139
|
+
...baseYScale,
|
|
140
|
+
...( options.yScale || {} ),
|
|
141
|
+
...( ! horizontal ? valueScaleDomainOverride : {} ),
|
|
142
|
+
};
|
|
102
143
|
const providedToolTipLabelFormatter = horizontal
|
|
103
144
|
? options.axis?.y?.tickFormat
|
|
104
145
|
: options.axis?.x?.tickFormat;
|
|
@@ -137,5 +178,5 @@ export function useBarChartOptions(
|
|
|
137
178
|
labelFormatter: providedToolTipLabelFormatter || defaultTooltipLabelFormatter,
|
|
138
179
|
},
|
|
139
180
|
};
|
|
140
|
-
}, [ defaultOptions, options, horizontal ] );
|
|
181
|
+
}, [ defaultOptions, options, horizontal, data ] );
|
|
141
182
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { render, screen } from '@testing-library/react';
|
|
1
|
+
import { render, renderHook, screen } from '@testing-library/react';
|
|
2
2
|
import userEvent from '@testing-library/user-event';
|
|
3
3
|
import { GlobalChartsProvider } from '../../../providers';
|
|
4
4
|
import BarChart from '../bar-chart';
|
|
5
|
+
import { useBarChartOptions } from '../private';
|
|
5
6
|
|
|
6
7
|
// Mock useElementSize to return non-zero dimensions in jsdom so charts render
|
|
7
8
|
const mockRefCallback = jest.fn();
|
|
@@ -233,6 +234,37 @@ describe( 'BarChart', () => {
|
|
|
233
234
|
// Check that no pattern definitions container is present
|
|
234
235
|
expect( screen.queryByTestId( 'bar-chart-patterns' ) ).not.toBeInTheDocument();
|
|
235
236
|
} );
|
|
237
|
+
|
|
238
|
+
test( 'comparison shadow reuses the primary bar pattern when patterns are enabled', () => {
|
|
239
|
+
renderWithTheme( {
|
|
240
|
+
withPatterns: true,
|
|
241
|
+
data: [
|
|
242
|
+
{
|
|
243
|
+
label: 'This period',
|
|
244
|
+
group: 'views',
|
|
245
|
+
data: [
|
|
246
|
+
{ label: 'Mon', value: 10 },
|
|
247
|
+
{ label: 'Tue', value: 20 },
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
label: 'Previous period',
|
|
252
|
+
group: 'views',
|
|
253
|
+
options: { type: 'comparison' as const },
|
|
254
|
+
data: [
|
|
255
|
+
{ label: 'Mon', value: 15 },
|
|
256
|
+
{ label: 'Tue', value: 25 },
|
|
257
|
+
],
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
} );
|
|
261
|
+
|
|
262
|
+
const shadow = screen.getAllByTestId( /^bar-chart-comparison-\d+-\d+$/ )[ 0 ];
|
|
263
|
+
const shadowFill = shadow.getAttribute( 'fill' );
|
|
264
|
+
// Shadow is filled with a pattern, not a solid color, and it references the PRIMARY
|
|
265
|
+
// series' pattern (index 0) rather than the comparison series' own index.
|
|
266
|
+
expect( shadowFill ).toMatch( /^url\(#bar-pattern-.+-0\)$/ );
|
|
267
|
+
} );
|
|
236
268
|
} );
|
|
237
269
|
|
|
238
270
|
describe( 'Keyboard Navigation Accessibility', () => {
|
|
@@ -346,6 +378,45 @@ describe( 'BarChart', () => {
|
|
|
346
378
|
} );
|
|
347
379
|
} );
|
|
348
380
|
|
|
381
|
+
describe( 'Comparison tooltip', () => {
|
|
382
|
+
test( 'tooltip shows both the primary and comparison values', async () => {
|
|
383
|
+
const user = userEvent.setup();
|
|
384
|
+
renderWithTheme( {
|
|
385
|
+
withTooltips: true,
|
|
386
|
+
data: [
|
|
387
|
+
{
|
|
388
|
+
label: 'This period',
|
|
389
|
+
group: 'views',
|
|
390
|
+
data: [
|
|
391
|
+
{ date: new Date( '2024-01-01' ), value: 10, label: 'Jan 1' },
|
|
392
|
+
{ date: new Date( '2024-01-02' ), value: 20, label: 'Jan 2' },
|
|
393
|
+
],
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
label: 'Previous period',
|
|
397
|
+
group: 'views',
|
|
398
|
+
options: { type: 'comparison' as const },
|
|
399
|
+
data: [
|
|
400
|
+
{ date: new Date( '2024-01-01' ), value: 15, label: 'Jan 1' },
|
|
401
|
+
{ date: new Date( '2024-01-02' ), value: 25, label: 'Jan 2' },
|
|
402
|
+
],
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
} );
|
|
406
|
+
|
|
407
|
+
const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
|
|
408
|
+
chart.focus();
|
|
409
|
+
|
|
410
|
+
await user.keyboard( '{ArrowRight}' );
|
|
411
|
+
const tooltip = screen.getByTestId( 'chart-tooltip-0' );
|
|
412
|
+
// Both the current and previous period values are shown.
|
|
413
|
+
expect( tooltip ).toHaveTextContent( 'This period' );
|
|
414
|
+
expect( tooltip ).toHaveTextContent( '10' );
|
|
415
|
+
expect( tooltip ).toHaveTextContent( 'Previous period' );
|
|
416
|
+
expect( tooltip ).toHaveTextContent( '15' );
|
|
417
|
+
} );
|
|
418
|
+
} );
|
|
419
|
+
|
|
349
420
|
describe( 'Tab Key Navigation', () => {
|
|
350
421
|
test( 'tab key exits navigation when reaching end of data points', async () => {
|
|
351
422
|
const user = userEvent.setup();
|
|
@@ -754,6 +825,125 @@ describe( 'BarChart', () => {
|
|
|
754
825
|
} );
|
|
755
826
|
} );
|
|
756
827
|
|
|
828
|
+
describe( 'Comparison Series', () => {
|
|
829
|
+
it( 'renders a translucent shadow behind the primary bar for a comparison series', () => {
|
|
830
|
+
const data = [
|
|
831
|
+
{
|
|
832
|
+
label: 'This year',
|
|
833
|
+
group: 'views',
|
|
834
|
+
data: [
|
|
835
|
+
{ label: 'Jan', value: 100 },
|
|
836
|
+
{ label: 'Feb', value: 120 },
|
|
837
|
+
],
|
|
838
|
+
},
|
|
839
|
+
{
|
|
840
|
+
label: 'Last year',
|
|
841
|
+
group: 'views',
|
|
842
|
+
options: { type: 'comparison' as const },
|
|
843
|
+
data: [
|
|
844
|
+
{ label: 'Jan', value: 80 },
|
|
845
|
+
{ label: 'Feb', value: 140 },
|
|
846
|
+
],
|
|
847
|
+
},
|
|
848
|
+
];
|
|
849
|
+
render( <BarChart data={ data } width={ 400 } height={ 300 } /> );
|
|
850
|
+
|
|
851
|
+
// two comparison shadow rects (one per data point)
|
|
852
|
+
// Match individual rect testids like bar-chart-comparison-1-0 (not the group wrapper)
|
|
853
|
+
const shadows = screen.getAllByTestId( /^bar-chart-comparison-\d+-\d+$/ );
|
|
854
|
+
expect( shadows ).toHaveLength( 2 );
|
|
855
|
+
expect( shadows[ 0 ] ).toHaveAttribute( 'opacity', '0.5' );
|
|
856
|
+
|
|
857
|
+
// primary bars still render for the single primary series (visx .visx-bar)
|
|
858
|
+
// eslint-disable-next-line testing-library/no-node-access
|
|
859
|
+
const bars = document.querySelectorAll( '.visx-bar' );
|
|
860
|
+
expect( bars.length ).toBeGreaterThanOrEqual( 2 );
|
|
861
|
+
} );
|
|
862
|
+
|
|
863
|
+
it( 'expands value-axis domain to include comparison values exceeding the primary max', () => {
|
|
864
|
+
// Comparison value 150 exceeds primary max 100.
|
|
865
|
+
// Without domain expansion the value-axis scale config has no explicit domain,
|
|
866
|
+
// so visx derives it from primary BarSeries only (max=100).
|
|
867
|
+
// With the fix, an explicit domain [min,150] is set on the value-axis scale config.
|
|
868
|
+
const data = [
|
|
869
|
+
{
|
|
870
|
+
label: 'This year',
|
|
871
|
+
group: 'views',
|
|
872
|
+
data: [ { label: 'Jan', value: 100 } ],
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
label: 'Last year',
|
|
876
|
+
group: 'views',
|
|
877
|
+
options: { type: 'comparison' as const },
|
|
878
|
+
data: [ { label: 'Jan', value: 150 } ],
|
|
879
|
+
},
|
|
880
|
+
];
|
|
881
|
+
|
|
882
|
+
const { result } = renderHook( () => useBarChartOptions( data, false, {} ) );
|
|
883
|
+
const yScale = result.current.yScale as { domain?: number[] };
|
|
884
|
+
// domain must be set explicitly and its max must be >= 150
|
|
885
|
+
expect( yScale.domain ).toBeDefined();
|
|
886
|
+
expect( ( yScale.domain as number[] )[ 1 ] ).toBeGreaterThanOrEqual( 150 );
|
|
887
|
+
} );
|
|
888
|
+
|
|
889
|
+
it( 'counts only primary series for keyboard navigation when a comparison series is present', async () => {
|
|
890
|
+
const user = userEvent.setup();
|
|
891
|
+
// 2 primary + 1 comparison. totalPoints should be 2*2=4, not 3*2=6.
|
|
892
|
+
// If comparison is counted, the 5th ArrowRight would reference a phantom series
|
|
893
|
+
// and could throw or show an undefined tooltip key.
|
|
894
|
+
const data = [
|
|
895
|
+
{
|
|
896
|
+
label: 'Series A',
|
|
897
|
+
group: 'g',
|
|
898
|
+
data: [
|
|
899
|
+
{ label: 'Jan', value: 10 },
|
|
900
|
+
{ label: 'Feb', value: 20 },
|
|
901
|
+
],
|
|
902
|
+
},
|
|
903
|
+
{
|
|
904
|
+
label: 'Series B',
|
|
905
|
+
group: 'g2',
|
|
906
|
+
data: [
|
|
907
|
+
{ label: 'Jan', value: 15 },
|
|
908
|
+
{ label: 'Feb', value: 25 },
|
|
909
|
+
],
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
label: 'Last year',
|
|
913
|
+
group: 'g',
|
|
914
|
+
options: { type: 'comparison' as const },
|
|
915
|
+
data: [
|
|
916
|
+
{ label: 'Jan', value: 8 },
|
|
917
|
+
{ label: 'Feb', value: 18 },
|
|
918
|
+
],
|
|
919
|
+
},
|
|
920
|
+
];
|
|
921
|
+
render(
|
|
922
|
+
<GlobalChartsProvider>
|
|
923
|
+
<BarChart data={ data } width={ 400 } height={ 300 } withTooltips />
|
|
924
|
+
</GlobalChartsProvider>
|
|
925
|
+
);
|
|
926
|
+
|
|
927
|
+
const chart = screen.getByRole( 'grid', { name: /bar chart/i } );
|
|
928
|
+
chart.focus();
|
|
929
|
+
|
|
930
|
+
// Navigate through all 4 primary slots (2 series × 2 data points)
|
|
931
|
+
for ( let i = 0; i < 4; i++ ) {
|
|
932
|
+
await user.keyboard( '{ArrowRight}' );
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// If totalPoints was 6 (counted comparison), tooltip 4 would still show.
|
|
936
|
+
// With correct totalPoints=4, navigation wraps and tooltip 0 is shown again,
|
|
937
|
+
// not phantom slot 4 (which would reference data[2], a comparison series).
|
|
938
|
+
// Either way, there must be no crash and navigation must stay in bounds.
|
|
939
|
+
const tooltips = screen.queryAllByTestId( /^chart-tooltip-/ );
|
|
940
|
+
expect( tooltips ).toHaveLength( 1 );
|
|
941
|
+
// The visible tooltip must belong to a primary series
|
|
942
|
+
const tooltipText = tooltips[ 0 ].textContent ?? '';
|
|
943
|
+
expect( [ 'Series A', 'Series B' ].some( k => tooltipText.includes( k ) ) ).toBe( true );
|
|
944
|
+
} );
|
|
945
|
+
} );
|
|
946
|
+
|
|
757
947
|
describe( 'Interactive Legend', () => {
|
|
758
948
|
it( 'filters series when interactive legend is enabled and series is toggled', async () => {
|
|
759
949
|
const user = userEvent.setup();
|
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
* External dependencies
|
|
3
3
|
*/
|
|
4
4
|
import { __ } from '@wordpress/i18n';
|
|
5
|
-
import { Stack } from '@wordpress/ui';
|
|
6
5
|
import clsx from 'clsx';
|
|
7
6
|
import { FC, useContext, useMemo } from 'react';
|
|
8
|
-
import { Chart
|
|
7
|
+
import { Chart } from 'react-google-charts';
|
|
9
8
|
/**
|
|
10
9
|
* Internal dependencies
|
|
11
10
|
*/
|
|
@@ -13,6 +12,7 @@ import { GlobalChartsContext, GlobalChartsProvider, useGlobalChartsContext } fro
|
|
|
13
12
|
import { lightenHexColor, normalizeColorToHex } from '../../utils/color-utils';
|
|
14
13
|
import { resolveCssVariable } from '../../utils/resolve-css-var';
|
|
15
14
|
import { sanitizeHtml } from '../../utils/sanitize-html';
|
|
15
|
+
import { Center } from '../private/center';
|
|
16
16
|
import { withResponsive } from '../private/with-responsive';
|
|
17
17
|
import styles from './geo-chart.module.scss';
|
|
18
18
|
import { GeoChartProps } from './types';
|
|
@@ -20,6 +20,8 @@ import { GeoChartProps } from './types';
|
|
|
20
20
|
const DEFAULT_FEATURE_FILL_COLOR = '#ffffff';
|
|
21
21
|
const DEFAULT_BACKGROUND_COLOR = '#ffffff';
|
|
22
22
|
|
|
23
|
+
type GoogleChartOptions = Record< string, unknown >;
|
|
24
|
+
|
|
23
25
|
/**
|
|
24
26
|
* Renders a geographical chart using Google Charts GeoChart to visualize data.
|
|
25
27
|
*
|
|
@@ -58,15 +60,13 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
58
60
|
|
|
59
61
|
// Render loading placeholder
|
|
60
62
|
const loadingPlaceholder = (
|
|
61
|
-
<
|
|
62
|
-
align="center"
|
|
63
|
-
justify="center"
|
|
63
|
+
<Center
|
|
64
64
|
className={ clsx( 'geo-chart', styles.container, className ) }
|
|
65
65
|
data-testid="geo-chart-loading"
|
|
66
66
|
style={ { width, height } }
|
|
67
67
|
>
|
|
68
68
|
{ renderPlaceholder ? renderPlaceholder() : __( 'Loading map', 'jetpack-charts' ) }
|
|
69
|
-
</
|
|
69
|
+
</Center>
|
|
70
70
|
);
|
|
71
71
|
|
|
72
72
|
// Google charts doesn't accept CSS variables, so we need to convert them to hex colors
|
|
@@ -147,9 +147,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
147
147
|
);
|
|
148
148
|
|
|
149
149
|
return (
|
|
150
|
-
<
|
|
151
|
-
align="center"
|
|
152
|
-
justify="center"
|
|
150
|
+
<Center
|
|
153
151
|
className={ clsx( 'geo-chart', styles.container, className ) }
|
|
154
152
|
data-testid="geo-chart"
|
|
155
153
|
style={ { width, height, backgroundColor } }
|
|
@@ -162,7 +160,7 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
162
160
|
options={ options }
|
|
163
161
|
loader={ loadingPlaceholder }
|
|
164
162
|
/>
|
|
165
|
-
</
|
|
163
|
+
</Center>
|
|
166
164
|
);
|
|
167
165
|
};
|
|
168
166
|
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
&.is-overlay {
|
|
32
32
|
grid-template: "overlap" 1fr / 1fr;
|
|
33
33
|
|
|
34
|
-
/* contains a single cell
|
|
34
|
+
/* contains a single cell called 'overlap' */
|
|
35
35
|
|
|
36
36
|
>* {
|
|
37
37
|
grid-area: overlap;
|
|
@@ -85,6 +85,8 @@
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
.interactiveRow {
|
|
88
|
+
--focus-ring-width: var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px));
|
|
89
|
+
|
|
88
90
|
display: grid;
|
|
89
91
|
grid-column: 1 / -1;
|
|
90
92
|
grid-template-columns: subgrid;
|
|
@@ -92,10 +94,13 @@
|
|
|
92
94
|
position: relative;
|
|
93
95
|
|
|
94
96
|
// Native button reset.
|
|
97
|
+
box-sizing: border-box;
|
|
95
98
|
appearance: none;
|
|
96
99
|
width: 100%;
|
|
97
100
|
margin: 0;
|
|
98
|
-
|
|
101
|
+
// Reserve a ring's worth of space on every edge so the inset focus outline
|
|
102
|
+
// hugs the row without the bar painting over it (see :focus-visible below).
|
|
103
|
+
padding: var(--focus-ring-width);
|
|
99
104
|
border: 0;
|
|
100
105
|
background: none;
|
|
101
106
|
color: inherit;
|
|
@@ -103,14 +108,15 @@
|
|
|
103
108
|
text-align: inherit;
|
|
104
109
|
cursor: pointer;
|
|
105
110
|
|
|
106
|
-
// On hover/focus the bar dims and its inline-end edge pulls back
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
111
|
+
// On hover/focus the bar dims and its inline-end edge pulls back while the
|
|
112
|
+
// value/delta slides toward the inline start to clear room for the chevron
|
|
113
|
+
// revealed at the inline-end edge. The value always slides the full inset
|
|
114
|
+
// (it must clear the chevron), but the bar pulls back in proportion to its
|
|
115
|
+
// length (see getBarWidth in leaderboard-chart.tsx): the full-length bar —
|
|
116
|
+
// the one that reaches the value — pulls back the whole inset to keep its
|
|
117
|
+
// gap, while shorter bars move proportionally less, down to ~0. The bar is
|
|
118
|
+
// a grid item aligned to the inline start, so its inset mirrors in RTL; the
|
|
119
|
+
// value slide is given an RTL override below. No row background.
|
|
114
120
|
.bar {
|
|
115
121
|
transition: opacity 0.15s ease, width 0.15s ease;
|
|
116
122
|
}
|
|
@@ -122,8 +128,8 @@
|
|
|
122
128
|
&:hover,
|
|
123
129
|
&:focus-visible {
|
|
124
130
|
|
|
125
|
-
// Fixed reserve for the chevron —
|
|
126
|
-
// (see leaderboard-chart.tsx)
|
|
131
|
+
// Fixed reserve for the chevron — the value slides by it in full, and the
|
|
132
|
+
// bar width inset (see leaderboard-chart.tsx) scales it by the bar's share.
|
|
127
133
|
--a8c--charts--leaderboard--bar--hover-inset: var(--wpds-dimension-gap-2xl, 32px);
|
|
128
134
|
|
|
129
135
|
.bar {
|
|
@@ -146,8 +152,15 @@
|
|
|
146
152
|
}
|
|
147
153
|
|
|
148
154
|
&:focus-visible {
|
|
149
|
-
|
|
150
|
-
outline
|
|
155
|
+
border-radius: var(--wpds-border-radius-sm, 2px);
|
|
156
|
+
outline:
|
|
157
|
+
var(--focus-ring-width) solid
|
|
158
|
+
var(--wpds-color-stroke-focus-brand, var(--wp-admin-theme-color, #3858e9));
|
|
159
|
+
// Inset by its own width so the outer edge hugs the row boundary: the
|
|
160
|
+
// outline then sits in the row's padding (also a ring width), clear of the
|
|
161
|
+
// bar, and stays inside the overflow:auto container that would clip an
|
|
162
|
+
// outset ring.
|
|
163
|
+
outline-offset: calc(-1 * var(--focus-ring-width));
|
|
151
164
|
}
|
|
152
165
|
}
|
|
153
166
|
|