@automattic/charts 4.4.0 → 4.6.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/TOKENS.md +2 -2
- package/dist/index.cjs +524 -203
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +4 -0
- package/dist/index.d.cts +22 -4
- package/dist/index.d.ts +22 -4
- package/dist/index.js +524 -203
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/charts/bar-chart/bar-chart.tsx +22 -10
- package/src/charts/bar-chart/private/band-tooltip.tsx +18 -12
- package/src/charts/geo-chart/geo-chart.tsx +8 -0
- package/src/charts/geo-chart/index.ts +7 -1
- package/src/charts/geo-chart/test/geo-chart.test.tsx +16 -0
- package/src/charts/geo-chart/types.ts +17 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +1 -1
- package/src/charts/pie-chart/pie-chart.module.scss +4 -0
- package/src/charts/pie-chart/pie-chart.tsx +103 -7
- package/src/charts/pie-chart/test/pie-chart.test.tsx +149 -0
- package/src/charts/private/chart-layout/chart-layout.tsx +5 -1
- package/src/components/tooltip/accessible-tooltip.tsx +40 -4
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +82 -5
- package/src/index.ts +7 -1
- package/src/providers/chart-context/global-charts-provider.tsx +57 -41
- package/src/providers/chart-context/private/catalog-pointers.ts +9 -1
- package/src/providers/chart-context/private/palette-generator.ts +305 -0
- package/src/providers/chart-context/private/perceptual-color.ts +219 -0
- package/src/providers/chart-context/private/series-palette.ts +1 -1
- package/src/providers/chart-context/test/chart-context.test.tsx +88 -8
- package/src/providers/chart-context/test/global-charts-provider-first-render.test.tsx +27 -0
- package/src/providers/chart-context/test/palette-generator.test.ts +181 -0
- package/src/providers/chart-context/test/perceptual-color.test.ts +75 -0
- package/src/test-utils/zoned-parsing-suite.ts +1 -1
- package/src/utils/color-utils.ts +5 -3
- package/src/utils/test/date-formatting.test.ts +1 -1
- package/src/providers/chart-context/private/get-chart-color.ts +0 -223
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.6.0",
|
|
4
4
|
"description": "Display charts within Automattic products.",
|
|
5
5
|
"homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"typecheck": "tsgo --noEmit"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@automattic/number-formatters": "^1.2.
|
|
66
|
+
"@automattic/number-formatters": "^1.2.10",
|
|
67
67
|
"@babel/runtime": "7.29.7",
|
|
68
68
|
"@date-fns/tz": "1.5.0",
|
|
69
69
|
"@react-spring/web": "10.1.1",
|
|
@@ -272,16 +272,27 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
272
272
|
|
|
273
273
|
const visibleSeriesKey = useMemo( () => JSON.stringify( primaryKeys ), [ primaryKeys ] );
|
|
274
274
|
|
|
275
|
-
const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } =
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
275
|
+
const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown, onChartPointerMove } =
|
|
276
|
+
useKeyboardNavigation( {
|
|
277
|
+
selectedIndex,
|
|
278
|
+
setSelectedIndex,
|
|
279
|
+
isNavigating,
|
|
280
|
+
setIsNavigating,
|
|
281
|
+
chartRef,
|
|
282
|
+
totalPoints,
|
|
283
|
+
onActivate: activateSelectedBar,
|
|
284
|
+
visibleSeriesKey,
|
|
285
|
+
} );
|
|
286
|
+
|
|
287
|
+
const handlePointerMove = useCallback(
|
|
288
|
+
( { key, index }: { key: string; index: number } ) => {
|
|
289
|
+
const seriesIndex = primaryKeys.indexOf( key );
|
|
290
|
+
if ( seriesIndex >= 0 ) {
|
|
291
|
+
onChartPointerMove( index * primaryKeys.length + seriesIndex );
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
[ primaryKeys, onChartPointerMove ]
|
|
295
|
+
);
|
|
285
296
|
|
|
286
297
|
const comparisonEntries = useMemo( () => {
|
|
287
298
|
const primaryByGroup = new Map< string | undefined, { label: string; index: number } >(
|
|
@@ -745,6 +756,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
745
756
|
withTooltips={ withTooltips }
|
|
746
757
|
onPointerDown={ onPointerDown }
|
|
747
758
|
onPointerUp={ onPointerUp }
|
|
759
|
+
onPointerMove={ handlePointerMove }
|
|
748
760
|
/>
|
|
749
761
|
) }
|
|
750
762
|
|
|
@@ -45,6 +45,7 @@ export function nearestBandIndex< Datum >(
|
|
|
45
45
|
* @param root0.withTooltips - Whether pointer events update the tooltip.
|
|
46
46
|
* @param root0.onPointerDown - Receives the corrected pointer-down datum.
|
|
47
47
|
* @param root0.onPointerUp - Receives the corrected pointer-up datum.
|
|
48
|
+
* @param root0.onPointerMove - Receives the corrected datum under a moving pointer.
|
|
48
49
|
* @return No visual content.
|
|
49
50
|
*/
|
|
50
51
|
export function BandTooltip( {
|
|
@@ -53,10 +54,12 @@ export function BandTooltip( {
|
|
|
53
54
|
withTooltips,
|
|
54
55
|
onPointerDown,
|
|
55
56
|
onPointerUp,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
onPointerMove,
|
|
58
|
+
}: {
|
|
59
|
+
keys: string[];
|
|
60
|
+
groupPadding: number;
|
|
61
|
+
onPointerMove?: ( selection: PointerSelection ) => void;
|
|
62
|
+
} & Pick< BarChartProps, 'withTooltips' | 'onPointerDown' | 'onPointerUp' > ) {
|
|
60
63
|
const { xScale, yScale, dataRegistry, horizontal } = useContext( DataContext );
|
|
61
64
|
const { showTooltip } = useContext( TooltipContext );
|
|
62
65
|
const scale = ( horizontal ? yScale : xScale ) as BandScale | undefined;
|
|
@@ -110,13 +113,6 @@ export function BandTooltip( {
|
|
|
110
113
|
const handlePointer = useCallback< PointerHandler >(
|
|
111
114
|
params => {
|
|
112
115
|
const selections = getSelections( params );
|
|
113
|
-
if ( withTooltips && params?.event.type !== 'pointerup' ) {
|
|
114
|
-
selections.forEach( showTooltip );
|
|
115
|
-
}
|
|
116
|
-
const callback = params?.event.type === 'pointerdown' ? onPointerDown : onPointerUp;
|
|
117
|
-
if ( params?.event.type === 'pointermove' || ! callback ) {
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
116
|
const nearest = selections.reduce(
|
|
121
117
|
( best, selection ) => {
|
|
122
118
|
const distance = ( value: typeof selection ) =>
|
|
@@ -125,11 +121,21 @@ export function BandTooltip( {
|
|
|
125
121
|
},
|
|
126
122
|
undefined as ( typeof selections )[ number ] | undefined
|
|
127
123
|
);
|
|
124
|
+
if ( params?.event.type === 'pointermove' && nearest ) {
|
|
125
|
+
onPointerMove?.( nearest );
|
|
126
|
+
}
|
|
127
|
+
if ( withTooltips && params?.event.type !== 'pointerup' ) {
|
|
128
|
+
selections.forEach( showTooltip );
|
|
129
|
+
}
|
|
130
|
+
const callback = params?.event.type === 'pointerdown' ? onPointerDown : onPointerUp;
|
|
131
|
+
if ( params?.event.type === 'pointermove' || ! callback ) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
128
134
|
if ( nearest ) {
|
|
129
135
|
callback( nearest );
|
|
130
136
|
}
|
|
131
137
|
},
|
|
132
|
-
[ getSelections, withTooltips, showTooltip, onPointerDown, onPointerUp ]
|
|
138
|
+
[ getSelections, withTooltips, showTooltip, onPointerDown, onPointerUp, onPointerMove ]
|
|
133
139
|
);
|
|
134
140
|
useEventEmitter( 'pointermove', withTooltips ? handlePointer : undefined );
|
|
135
141
|
useEventEmitter( 'pointerdown', handlePointer );
|
|
@@ -130,6 +130,8 @@ function normalizeGeoChartError( eventArgs: unknown ): GeoChartError {
|
|
|
130
130
|
* @param props.height - Height of the chart in pixels
|
|
131
131
|
* @param props.region - Region to display ('world', 'US', or ISO 3166-1 alpha-2 code)
|
|
132
132
|
* @param props.resolution - Resolution level ('countries', 'provinces', or 'metros')
|
|
133
|
+
* @param props.displayMode - Plot rows as shaded areas ('regions') or markers ('markers')
|
|
134
|
+
* @param props.domain - Country code whose viewpoint sets the disputed borders
|
|
133
135
|
* @param props.onError - Optional callback for Google Charts errors
|
|
134
136
|
* @param props.className - Additional CSS class name for the chart container
|
|
135
137
|
* @param props.renderPlaceholder - Optional render function for the loading placeholder
|
|
@@ -142,6 +144,8 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
142
144
|
height,
|
|
143
145
|
region = 'world',
|
|
144
146
|
resolution = 'countries',
|
|
147
|
+
displayMode = 'regions',
|
|
148
|
+
domain,
|
|
145
149
|
onError,
|
|
146
150
|
renderPlaceholder,
|
|
147
151
|
} ) => {
|
|
@@ -262,6 +266,8 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
262
266
|
() => ( {
|
|
263
267
|
...( region !== 'world' && { region } ),
|
|
264
268
|
...( resolution !== 'countries' && { resolution } ),
|
|
269
|
+
...( displayMode !== 'regions' && { displayMode } ),
|
|
270
|
+
...( domain && { domain } ),
|
|
265
271
|
colorAxis: { colors: [ lightColorHex, fullColorHex ] },
|
|
266
272
|
backgroundColor: backgroundColorHex,
|
|
267
273
|
datalessRegionColor: defaultFillColorHex,
|
|
@@ -273,6 +279,8 @@ const GeoChartInternal: FC< GeoChartProps > = ( {
|
|
|
273
279
|
[
|
|
274
280
|
region,
|
|
275
281
|
resolution,
|
|
282
|
+
displayMode,
|
|
283
|
+
domain,
|
|
276
284
|
lightColorHex,
|
|
277
285
|
fullColorHex,
|
|
278
286
|
backgroundColorHex,
|
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
export { default as GeoChart, GeoChartUnresponsive } from './geo-chart';
|
|
2
|
-
export type {
|
|
2
|
+
export type {
|
|
3
|
+
GeoChartProps,
|
|
4
|
+
GeoRegion,
|
|
5
|
+
GeoResolution,
|
|
6
|
+
GeoDisplayMode,
|
|
7
|
+
GeoChartError,
|
|
8
|
+
} from './types';
|
|
@@ -216,6 +216,22 @@ describe( 'GeoChart', () => {
|
|
|
216
216
|
expect( options.region ).toBeUndefined();
|
|
217
217
|
} );
|
|
218
218
|
|
|
219
|
+
test( 'passes the domain so disputed borders follow that country', () => {
|
|
220
|
+
renderWithTheme( { domain: 'IN' } );
|
|
221
|
+
|
|
222
|
+
const options = JSON.parse( screen.getByTestId( 'chart-options' ).textContent || '{}' );
|
|
223
|
+
|
|
224
|
+
expect( options.domain ).toBe( 'IN' );
|
|
225
|
+
} );
|
|
226
|
+
|
|
227
|
+
test( 'passes the markers display mode so rows draw as points, not shaded areas', () => {
|
|
228
|
+
renderWithTheme( { displayMode: 'markers' } );
|
|
229
|
+
|
|
230
|
+
const options = JSON.parse( screen.getByTestId( 'chart-options' ).textContent || '{}' );
|
|
231
|
+
|
|
232
|
+
expect( options.displayMode ).toBe( 'markers' );
|
|
233
|
+
} );
|
|
234
|
+
|
|
219
235
|
test( 'does not include resolution in options when set to countries (default)', () => {
|
|
220
236
|
renderWithTheme();
|
|
221
237
|
|
|
@@ -15,6 +15,13 @@ export type GeoRegion = 'world' | ( string & {} );
|
|
|
15
15
|
*/
|
|
16
16
|
export type GeoResolution = 'countries' | 'provinces' | 'metros';
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* How the map plots its rows.
|
|
20
|
+
* - 'regions': Shades each area the rows name
|
|
21
|
+
* - 'markers': Draws a sized, colored circle per row; a row can name a place or give `[ latitude, longitude ]` as its first two columns
|
|
22
|
+
*/
|
|
23
|
+
export type GeoDisplayMode = 'regions' | 'markers';
|
|
24
|
+
|
|
18
25
|
export interface GeoChartError {
|
|
19
26
|
id?: string;
|
|
20
27
|
message?: string;
|
|
@@ -48,6 +55,16 @@ export interface GeoChartProps extends Pick<
|
|
|
48
55
|
* @default 'countries'
|
|
49
56
|
*/
|
|
50
57
|
resolution?: GeoResolution;
|
|
58
|
+
/**
|
|
59
|
+
* How the map plots its rows: shaded areas, or one marker per row.
|
|
60
|
+
* @default 'regions'
|
|
61
|
+
*/
|
|
62
|
+
displayMode?: GeoDisplayMode;
|
|
63
|
+
/**
|
|
64
|
+
* ISO 3166-1 alpha-2 code of the country whose viewpoint sets the disputed borders
|
|
65
|
+
* (e.g., 'IN' draws Kashmir as India shows it). Google's default borders apply when omitted.
|
|
66
|
+
*/
|
|
67
|
+
domain?: string;
|
|
51
68
|
/**
|
|
52
69
|
* Callback fired when Google Charts emits a chart error.
|
|
53
70
|
*/
|
|
@@ -494,7 +494,7 @@ describe( 'HeatmapChart keyboard tooltip', () => {
|
|
|
494
494
|
} );
|
|
495
495
|
|
|
496
496
|
describe( 'HeatmapChart tooltip position', () => {
|
|
497
|
-
// Restore only this spy: `restoreAllMocks` would also unspy
|
|
497
|
+
// Restore only this spy: `restoreAllMocks` would also unspy the console guard, and
|
|
498
498
|
// every later `toHaveWarned` in the file would find no spy.
|
|
499
499
|
let rects: jest.SpyInstance;
|
|
500
500
|
afterEach( () => {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Group } from '@visx/group';
|
|
2
2
|
import { Pie } from '@visx/shape';
|
|
3
3
|
import { useTooltip } from '@visx/tooltip';
|
|
4
|
+
import { color as d3Color } from '@visx/vendor/d3-color';
|
|
4
5
|
import clsx from 'clsx';
|
|
5
|
-
import { useCallback, useContext, useMemo, useRef } from 'react';
|
|
6
|
+
import { useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
6
7
|
import { Legend, useChartLegendItems } from '../../components/legend';
|
|
7
8
|
import { BaseTooltip } from '../../components/tooltip';
|
|
8
9
|
import { BoundedTooltip } from '../../components/tooltip/private/bounded-tooltip';
|
|
@@ -19,8 +20,16 @@ import {
|
|
|
19
20
|
useGlobalChartsTheme,
|
|
20
21
|
GlobalChartsContext,
|
|
21
22
|
} from '../../providers';
|
|
23
|
+
import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
|
|
24
|
+
import { contrastRatio } from '../../providers/chart-context/private/perceptual-color';
|
|
22
25
|
import { useStandaloneScopeClass } from '../../providers/chart-scope';
|
|
23
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
attachSubComponents,
|
|
28
|
+
createCssVariableResolver,
|
|
29
|
+
isValidHexColor,
|
|
30
|
+
normalizeColorToHex,
|
|
31
|
+
resolveFontSize,
|
|
32
|
+
} from '../../utils';
|
|
24
33
|
import { getStringWidth } from '../../visx/text';
|
|
25
34
|
import { Center } from '../private/center';
|
|
26
35
|
import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
|
|
@@ -161,6 +170,50 @@ const validateData = ( data: DataPointPercentage[] ) => {
|
|
|
161
170
|
return { isValid: true, message: '' };
|
|
162
171
|
};
|
|
163
172
|
|
|
173
|
+
/**
|
|
174
|
+
* The label pointers, resolved at the pie chart's own root element so JS and CSS agree on what paints.
|
|
175
|
+
*/
|
|
176
|
+
interface ResolvedLabelPointers {
|
|
177
|
+
/** True while a label plate is set; the label text then always uses the inverse role. */
|
|
178
|
+
hasPlate: boolean;
|
|
179
|
+
labelHex: string;
|
|
180
|
+
labelInverseHex: string;
|
|
181
|
+
/** True when the inverse role is see-through, so only the `label` role can be seen on a fill. */
|
|
182
|
+
isInverseSeeThrough: boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether a pie slice's label should use the dark `label` role instead of the default `label-inverse` role.
|
|
187
|
+
*
|
|
188
|
+
* A label plate always wins: the text sits on the plate, not the slice fill, so it never flips.
|
|
189
|
+
* Otherwise the role that contrasts more with the resolved slice fill wins. `null` pointers (not
|
|
190
|
+
* yet resolved, or labels off) keep the default inverse role.
|
|
191
|
+
*
|
|
192
|
+
* @param fill - The slice's resolved fill color.
|
|
193
|
+
* @param pointers - The label pointers, resolved at the chart's own root element.
|
|
194
|
+
* @return Whether the label needs dark text.
|
|
195
|
+
*/
|
|
196
|
+
const labelNeedsDarkText = ( fill: string, pointers: ResolvedLabelPointers | null ): boolean => {
|
|
197
|
+
if ( ! pointers || pointers.hasPlate ) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const fillHex = normalizeColorToHex( fill );
|
|
202
|
+
if ( ! isValidHexColor( fillHex ) || ! isValidHexColor( pointers.labelHex ) ) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
if ( pointers.isInverseSeeThrough ) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
if ( ! isValidHexColor( pointers.labelInverseHex ) ) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
contrastRatio( fillHex, pointers.labelHex ) > contrastRatio( fillHex, pointers.labelInverseHex )
|
|
214
|
+
);
|
|
215
|
+
};
|
|
216
|
+
|
|
164
217
|
/**
|
|
165
218
|
* Renders a pie or donut chart using the provided data.
|
|
166
219
|
*
|
|
@@ -202,6 +255,11 @@ const PieChartInternal = ( {
|
|
|
202
255
|
// The tooltip renders inside this element, so pointer coordinates are taken relative to it.
|
|
203
256
|
const containerRef = useRef< HTMLDivElement >( null );
|
|
204
257
|
|
|
258
|
+
// The element the chart's own `className` lands on, so an override set there reaches this
|
|
259
|
+
// decision the same way it reaches CSS.
|
|
260
|
+
const rootRef = useRef< HTMLDivElement >( null );
|
|
261
|
+
const [ labelPointers, setLabelPointers ] = useState< ResolvedLabelPointers | null >( null );
|
|
262
|
+
|
|
205
263
|
const onMouseLeave = useCallback( () => {
|
|
206
264
|
if ( ! withTooltips ) {
|
|
207
265
|
return;
|
|
@@ -209,7 +267,41 @@ const PieChartInternal = ( {
|
|
|
209
267
|
hideTooltip();
|
|
210
268
|
}, [ withTooltips, hideTooltip ] );
|
|
211
269
|
|
|
212
|
-
const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
|
|
270
|
+
const { getElementStyles, isSeriesVisible, isColorPaletteResolved } = useGlobalChartsContext();
|
|
271
|
+
const { isValid, message } = validateData( data );
|
|
272
|
+
|
|
273
|
+
// Skipped when labels are off, or before the chart's own root element mounts (the invalid-data
|
|
274
|
+
// branch renders a plain div, so `rootRef` is not yet attached).
|
|
275
|
+
useLayoutEffect( () => {
|
|
276
|
+
if ( ! showLabels || ! rootRef.current ) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const resolve = createCssVariableResolver( rootRef.current );
|
|
281
|
+
const rawLabelBackground = resolve( CATALOG_POINTERS.labelBackground );
|
|
282
|
+
// Hex drops alpha, so a see-through label role would win the comparison and paint nothing.
|
|
283
|
+
const rawLabel = resolve( CATALOG_POINTERS.label );
|
|
284
|
+
const isLabelOpaque = rawLabel ? d3Color( rawLabel )?.opacity === 1 : false;
|
|
285
|
+
const rawLabelInverse = resolve( CATALOG_POINTERS.labelInverse );
|
|
286
|
+
const labelInverseColor = rawLabelInverse ? d3Color( rawLabelInverse ) : null;
|
|
287
|
+
// A plate value d3 cannot parse (CSS Color 4 syntax, say) is still one CSS paints, so it counts.
|
|
288
|
+
const plateColor = rawLabelBackground ? d3Color( rawLabelBackground ) : null;
|
|
289
|
+
const next: ResolvedLabelPointers = {
|
|
290
|
+
hasPlate: rawLabelBackground ? ! plateColor || plateColor.opacity > 0 : false,
|
|
291
|
+
labelHex: isLabelOpaque ? normalizeColorToHex( CATALOG_POINTERS.label, null, resolve ) : '',
|
|
292
|
+
labelInverseHex: normalizeColorToHex( CATALOG_POINTERS.labelInverse, null, resolve ),
|
|
293
|
+
isInverseSeeThrough: labelInverseColor ? labelInverseColor.opacity < 1 : false,
|
|
294
|
+
};
|
|
295
|
+
setLabelPointers( previous =>
|
|
296
|
+
previous &&
|
|
297
|
+
previous.hasPlate === next.hasPlate &&
|
|
298
|
+
previous.labelHex === next.labelHex &&
|
|
299
|
+
previous.labelInverseHex === next.labelInverseHex &&
|
|
300
|
+
previous.isInverseSeeThrough === next.isInverseSeeThrough
|
|
301
|
+
? previous
|
|
302
|
+
: next
|
|
303
|
+
);
|
|
304
|
+
}, [ showLabels, className, isColorPaletteResolved, isValid ] );
|
|
213
305
|
|
|
214
306
|
// Calculate percentages from values (single source of truth)
|
|
215
307
|
const dataWithPercentages = useDataWithPercentages( data );
|
|
@@ -230,8 +322,6 @@ const PieChartInternal = ( {
|
|
|
230
322
|
// Create legend items using legendData (has recalculated percentages for visible items)
|
|
231
323
|
const legendItems = useChartLegendItems( legendData, legendOptions );
|
|
232
324
|
|
|
233
|
-
const { isValid, message } = validateData( data );
|
|
234
|
-
|
|
235
325
|
// Process children to extract compound components
|
|
236
326
|
const { svgChildren, htmlChildren, legendChildren, otherChildren } = useChartChildren(
|
|
237
327
|
children,
|
|
@@ -309,6 +399,7 @@ const PieChartInternal = ( {
|
|
|
309
399
|
legendElement={ legendElement }
|
|
310
400
|
legendChildren={ legendChildren }
|
|
311
401
|
gap={ gap }
|
|
402
|
+
rootRef={ rootRef }
|
|
312
403
|
className={ clsx(
|
|
313
404
|
'pie-chart',
|
|
314
405
|
styles[ 'pie-chart' ],
|
|
@@ -405,11 +496,12 @@ const PieChartInternal = ( {
|
|
|
405
496
|
} );
|
|
406
497
|
};
|
|
407
498
|
|
|
499
|
+
const fill = accessors.fill( arc.data );
|
|
408
500
|
const pathProps: SVGProps< SVGPathElement > & {
|
|
409
501
|
'data-testid'?: string;
|
|
410
502
|
} = {
|
|
411
503
|
d: pie.path( arc ) || '',
|
|
412
|
-
fill
|
|
504
|
+
fill,
|
|
413
505
|
'data-testid': 'pie-segment',
|
|
414
506
|
};
|
|
415
507
|
|
|
@@ -446,7 +538,11 @@ const PieChartInternal = ( {
|
|
|
446
538
|
pointerEvents="none"
|
|
447
539
|
/>
|
|
448
540
|
<text
|
|
449
|
-
className={ styles[ 'pie-chart__label-text' ]
|
|
541
|
+
className={ clsx( styles[ 'pie-chart__label-text' ], {
|
|
542
|
+
[ styles[ 'pie-chart__label-text--on-light' ] ]:
|
|
543
|
+
labelNeedsDarkText( fill, labelPointers ),
|
|
544
|
+
} ) }
|
|
545
|
+
data-testid="pie-label"
|
|
450
546
|
x={ centroidX }
|
|
451
547
|
y={ centroidY }
|
|
452
548
|
dy=".33em"
|
|
@@ -149,6 +149,155 @@ describe( 'PieChart', () => {
|
|
|
149
149
|
} );
|
|
150
150
|
} );
|
|
151
151
|
|
|
152
|
+
describe( 'Label Text Contrast', () => {
|
|
153
|
+
const contrastData = [
|
|
154
|
+
{ label: 'Light', value: 60, color: '#f5d76e' },
|
|
155
|
+
{ label: 'Dark', value: 40, color: '#1e3a8a' },
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
let injectedStyle: HTMLStyleElement | null = null;
|
|
159
|
+
|
|
160
|
+
afterEach( () => {
|
|
161
|
+
if ( injectedStyle ) {
|
|
162
|
+
document.head.removeChild( injectedStyle );
|
|
163
|
+
injectedStyle = null;
|
|
164
|
+
}
|
|
165
|
+
} );
|
|
166
|
+
|
|
167
|
+
test( 'uses dark label text on a light slice', () => {
|
|
168
|
+
renderWithTheme( { data: contrastData } );
|
|
169
|
+
|
|
170
|
+
const [ light, dark ] = screen.getAllByTestId( 'pie-label' );
|
|
171
|
+
expect( light ).toHaveClass( 'pie-chart__label-text--on-light' );
|
|
172
|
+
expect( dark ).not.toHaveClass( 'pie-chart__label-text--on-light' );
|
|
173
|
+
} );
|
|
174
|
+
|
|
175
|
+
test( "keeps the inverse label color when a label plate is set on the chart's own class", () => {
|
|
176
|
+
injectedStyle = document.createElement( 'style' );
|
|
177
|
+
injectedStyle.textContent =
|
|
178
|
+
'.plated-pie { --a8c-charts-color-label-background: rgba(0, 0, 0, 0.75); }';
|
|
179
|
+
document.head.appendChild( injectedStyle );
|
|
180
|
+
|
|
181
|
+
renderWithTheme( { data: contrastData, className: 'plated-pie' } );
|
|
182
|
+
|
|
183
|
+
screen
|
|
184
|
+
.getAllByTestId( 'pie-label' )
|
|
185
|
+
.forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
186
|
+
} );
|
|
187
|
+
|
|
188
|
+
test( "compares against a label-inverse override set on the chart's own class", () => {
|
|
189
|
+
injectedStyle = document.createElement( 'style' );
|
|
190
|
+
// Matches the slice fill exactly, so the inverse role loses to the default dark label role.
|
|
191
|
+
injectedStyle.textContent = '.overridden-pie { --a8c-charts-color-label-inverse: #1e3a8a; }';
|
|
192
|
+
document.head.appendChild( injectedStyle );
|
|
193
|
+
|
|
194
|
+
renderWithTheme( {
|
|
195
|
+
data: [ { label: 'Dark', value: 100, color: '#1e3a8a' } ],
|
|
196
|
+
className: 'overridden-pie',
|
|
197
|
+
} );
|
|
198
|
+
|
|
199
|
+
const [ label ] = screen.getAllByTestId( 'pie-label' );
|
|
200
|
+
expect( label ).toHaveClass( 'pie-chart__label-text--on-light' );
|
|
201
|
+
} );
|
|
202
|
+
|
|
203
|
+
test( 'keeps the inverse label color when the plate uses color syntax d3 cannot parse', () => {
|
|
204
|
+
injectedStyle = document.createElement( 'style' );
|
|
205
|
+
injectedStyle.textContent =
|
|
206
|
+
'.modern-plate-pie { --a8c-charts-color-label-background: rgb(0 0 0 / 50%); }';
|
|
207
|
+
document.head.appendChild( injectedStyle );
|
|
208
|
+
|
|
209
|
+
renderWithTheme( { data: contrastData, className: 'modern-plate-pie' } );
|
|
210
|
+
|
|
211
|
+
screen
|
|
212
|
+
.getAllByTestId( 'pie-label' )
|
|
213
|
+
.forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
214
|
+
} );
|
|
215
|
+
|
|
216
|
+
test( 'keeps the inverse label color when the label role is see-through', () => {
|
|
217
|
+
injectedStyle = document.createElement( 'style' );
|
|
218
|
+
injectedStyle.textContent =
|
|
219
|
+
'.clear-label-pie { --a8c-charts-color-label: rgba(0, 0, 0, 0); }';
|
|
220
|
+
document.head.appendChild( injectedStyle );
|
|
221
|
+
|
|
222
|
+
renderWithTheme( { data: contrastData, className: 'clear-label-pie' } );
|
|
223
|
+
|
|
224
|
+
screen
|
|
225
|
+
.getAllByTestId( 'pie-label' )
|
|
226
|
+
.forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
227
|
+
} );
|
|
228
|
+
|
|
229
|
+
test( 'keeps one label color on every slice when both label roles are the same', () => {
|
|
230
|
+
injectedStyle = document.createElement( 'style' );
|
|
231
|
+
injectedStyle.textContent =
|
|
232
|
+
'.single-label-pie { --a8c-charts-color-label: #ffffff; --a8c-charts-color-label-inverse: #ffffff; }';
|
|
233
|
+
document.head.appendChild( injectedStyle );
|
|
234
|
+
|
|
235
|
+
renderWithTheme( { data: contrastData, className: 'single-label-pie' } );
|
|
236
|
+
|
|
237
|
+
screen
|
|
238
|
+
.getAllByTestId( 'pie-label' )
|
|
239
|
+
.forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
240
|
+
} );
|
|
241
|
+
|
|
242
|
+
test( 'uses the label role on every slice when the inverse role is see-through', () => {
|
|
243
|
+
injectedStyle = document.createElement( 'style' );
|
|
244
|
+
injectedStyle.textContent =
|
|
245
|
+
'.clear-inverse-pie { --a8c-charts-color-label-inverse: rgba(255, 255, 255, 0); }';
|
|
246
|
+
document.head.appendChild( injectedStyle );
|
|
247
|
+
|
|
248
|
+
renderWithTheme( { data: contrastData, className: 'clear-inverse-pie' } );
|
|
249
|
+
|
|
250
|
+
screen
|
|
251
|
+
.getAllByTestId( 'pie-label' )
|
|
252
|
+
.forEach( label => expect( label ).toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
253
|
+
} );
|
|
254
|
+
|
|
255
|
+
test( 'keeps the inverse label color when the fill cannot be resolved', () => {
|
|
256
|
+
renderWithTheme( {
|
|
257
|
+
data: [ { label: 'Unresolved', value: 100, color: 'var(--not-set)' } ],
|
|
258
|
+
} );
|
|
259
|
+
|
|
260
|
+
screen
|
|
261
|
+
.getAllByTestId( 'pie-label' )
|
|
262
|
+
.forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
263
|
+
} );
|
|
264
|
+
|
|
265
|
+
describe( 'after an invalid-to-valid data transition', () => {
|
|
266
|
+
test( "keeps the inverse label color when a plate is set on the chart's own class", () => {
|
|
267
|
+
injectedStyle = document.createElement( 'style' );
|
|
268
|
+
injectedStyle.textContent =
|
|
269
|
+
'.plated-late-pie { --a8c-charts-color-label-background: rgba(0, 0, 0, 0.75); }';
|
|
270
|
+
document.head.appendChild( injectedStyle );
|
|
271
|
+
|
|
272
|
+
const { rerender } = renderWithTheme( { data: [], className: 'plated-late-pie' } );
|
|
273
|
+
|
|
274
|
+
rerender(
|
|
275
|
+
<GlobalChartsProvider>
|
|
276
|
+
<PieChart { ...defaultProps } data={ contrastData } className="plated-late-pie" />
|
|
277
|
+
</GlobalChartsProvider>
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
screen
|
|
281
|
+
.getAllByTestId( 'pie-label' )
|
|
282
|
+
.forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
283
|
+
} );
|
|
284
|
+
|
|
285
|
+
test( 'resolves the label pointers once the chart mounts its own root element', () => {
|
|
286
|
+
const { rerender } = renderWithTheme( { data: [] } );
|
|
287
|
+
|
|
288
|
+
rerender(
|
|
289
|
+
<GlobalChartsProvider>
|
|
290
|
+
<PieChart { ...defaultProps } data={ contrastData } />
|
|
291
|
+
</GlobalChartsProvider>
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
const [ light, dark ] = screen.getAllByTestId( 'pie-label' );
|
|
295
|
+
expect( light ).toHaveClass( 'pie-chart__label-text--on-light' );
|
|
296
|
+
expect( dark ).not.toHaveClass( 'pie-chart__label-text--on-light' );
|
|
297
|
+
} );
|
|
298
|
+
} );
|
|
299
|
+
} );
|
|
300
|
+
|
|
152
301
|
describe( 'Legend Value Display', () => {
|
|
153
302
|
// Values that give clean percentages: 60/100=60%, 23/100=23%, 17/100=17%
|
|
154
303
|
const testData = [
|
|
@@ -5,7 +5,7 @@ import { renderLegendSlot } from '../chart-composition';
|
|
|
5
5
|
import styles from './chart-layout.module.scss';
|
|
6
6
|
import type { GapSize, LegendPosition } from '../../../types';
|
|
7
7
|
import type { LegendChild } from '../chart-composition/use-chart-children';
|
|
8
|
-
import type { CSSProperties, ReactNode } from 'react';
|
|
8
|
+
import type { CSSProperties, ReactNode, Ref } from 'react';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Measurements provided to the render prop when ChartLayout handles resize listening.
|
|
@@ -36,6 +36,8 @@ export interface ChartLayoutProps {
|
|
|
36
36
|
gap?: GapSize;
|
|
37
37
|
/** Additional class names */
|
|
38
38
|
className?: string;
|
|
39
|
+
/** Ref to the root element, the one `className` lands on */
|
|
40
|
+
rootRef?: Ref< HTMLDivElement >;
|
|
39
41
|
/** Inline styles (width, height, etc.) */
|
|
40
42
|
style?: CSSProperties;
|
|
41
43
|
/** Test ID for the container */
|
|
@@ -53,6 +55,7 @@ export const ChartLayout = ( {
|
|
|
53
55
|
onContentHeightChange,
|
|
54
56
|
gap,
|
|
55
57
|
className,
|
|
58
|
+
rootRef,
|
|
56
59
|
style,
|
|
57
60
|
'data-testid': dataTestId,
|
|
58
61
|
'data-chart-id': dataChartId,
|
|
@@ -78,6 +81,7 @@ export const ChartLayout = ( {
|
|
|
78
81
|
|
|
79
82
|
return (
|
|
80
83
|
<Stack
|
|
84
|
+
ref={ rootRef }
|
|
81
85
|
direction="column"
|
|
82
86
|
gap={ gap }
|
|
83
87
|
className={ className }
|