@automattic/charts 4.5.0 → 4.7.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 -2
- package/TOKENS.md +12 -10
- package/dist/index.cjs +248 -146
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +33 -20
- package/dist/index.d.cts +22 -11
- package/dist/index.d.ts +22 -11
- package/dist/index.js +249 -147
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +0 -10
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +4 -2
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +11 -1
- package/src/charts/conversion-funnel-chart/types.ts +3 -0
- package/src/charts/geo-chart/geo-chart.tsx +4 -0
- package/src/charts/geo-chart/index.ts +7 -1
- package/src/charts/geo-chart/test/geo-chart.test.tsx +8 -0
- package/src/charts/geo-chart/types.ts +12 -0
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +10 -3
- package/src/charts/heatmap-chart/heatmap-chart.tsx +54 -37
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +129 -25
- package/src/charts/heatmap-chart/types.ts +2 -6
- package/src/charts/line-chart/crosshair-style-types.fixture.ts +23 -0
- package/src/charts/line-chart/line-chart.module.scss +0 -5
- package/src/charts/line-chart/line-chart.tsx +8 -40
- package/src/charts/line-chart/test/line-chart.test.tsx +19 -47
- package/src/charts/pie-chart/pie-chart.module.scss +9 -0
- package/src/charts/pie-chart/pie-chart.tsx +22 -76
- package/src/charts/pie-chart/test/pie-chart.test.tsx +21 -8
- package/src/charts/private/chart-composition/chart-html.tsx +1 -1
- package/src/charts/private/chart-composition/chart-svg.tsx +1 -1
- package/src/charts/private/label-text-color.ts +116 -0
- package/src/charts/private/radial-wipe-animation/radial-wipe-animation.tsx +2 -0
- package/src/charts/private/svg-empty-state/svg-empty-state.tsx +1 -1
- package/src/charts/private/test/label-text-color.test.ts +191 -0
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +1 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +2 -5
- package/src/components/tooltip/accessible-tooltip.tsx +1 -1
- package/src/components/tooltip/base-tooltip.module.scss +12 -8
- package/src/components/tooltip/base-tooltip.tsx +11 -9
- package/src/components/tooltip/private/bounded-tooltip.tsx +27 -8
- package/src/components/tooltip/private/tooltip-theme.tsx +37 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +82 -1
- package/src/components/tooltip/test/tool-tip.test.tsx +5 -0
- package/src/components/tooltip/test/tooltip-theme.test.tsx +61 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +41 -28
- package/src/components/tooltip/xy-chart-tooltip.tsx +29 -29
- package/src/components/trend-indicator/trend-indicator.tsx +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/test/use-xychart-theme.test.tsx +3 -3
- package/src/hooks/use-isomorphic-layout-effect.ts +5 -0
- package/src/hooks/use-xychart-theme.ts +4 -12
- package/src/index.ts +7 -1
- package/src/providers/chart-context/hooks/use-default-hidden-series.ts +2 -4
- package/src/providers/chart-context/private/perceptual-color.ts +45 -4
- package/src/providers/chart-context/test/perceptual-color.test.ts +31 -0
- package/src/styles/chart-scope-class.ts +5 -0
- package/src/styles/chart-scope.scss +9 -6
- package/src/styles/test/chart-scope.test.ts +3 -3
- package/src/utils/color-utils.ts +2 -1
- package/src/visx/types.ts +1 -1
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +0 -31
|
@@ -66,7 +66,7 @@ import type { RenderTooltipParams } from '../../visx/types';
|
|
|
66
66
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
67
67
|
import type { TickFormatter } from '@visx/axis';
|
|
68
68
|
import type { GlyphProps } from '@visx/xychart';
|
|
69
|
-
import type {
|
|
69
|
+
import type { FC, Ref } from 'react';
|
|
70
70
|
|
|
71
71
|
const defaultRenderGlyph = < Datum extends object >( props: RenderLineGlyphProps< Datum > ) => {
|
|
72
72
|
return <DefaultGlyph { ...props } key={ props.key } />;
|
|
@@ -116,13 +116,11 @@ const TooltipDate: FC< { date?: Date; displayResolution: Exclude< TickResolution
|
|
|
116
116
|
* one row per visible series (label + formatted value), sorted descending by
|
|
117
117
|
* value. Reused by AreaChart, which has the same multi-series shape.
|
|
118
118
|
*
|
|
119
|
-
* @param params
|
|
120
|
-
* @param contentStyle - Explicit tooltip content color overrides.
|
|
119
|
+
* @param params - visx tooltip data and the chart's optional `bucketInfo`.
|
|
121
120
|
* @return Tooltip JSX, or `null` when no datum is hovered.
|
|
122
121
|
*/
|
|
123
122
|
export const renderDefaultTooltip = (
|
|
124
|
-
params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo }
|
|
125
|
-
contentStyle?: Pick< CSSProperties, 'color' | 'background' | 'backgroundColor' >
|
|
123
|
+
params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo }
|
|
126
124
|
) => {
|
|
127
125
|
const { tooltipData, bucketInfo } = params;
|
|
128
126
|
const nearestDatum = tooltipData?.nearestDatum?.datum;
|
|
@@ -141,11 +139,7 @@ export const renderDefaultTooltip = (
|
|
|
141
139
|
} );
|
|
142
140
|
|
|
143
141
|
return (
|
|
144
|
-
<div
|
|
145
|
-
className={ styles[ 'line-chart__tooltip' ] }
|
|
146
|
-
data-testid="line-chart-tooltip-content"
|
|
147
|
-
style={ contentStyle }
|
|
148
|
-
>
|
|
142
|
+
<div data-testid="line-chart-tooltip-content">
|
|
149
143
|
<div className={ styles[ 'line-chart__tooltip-date' ] }>
|
|
150
144
|
<TooltipDate
|
|
151
145
|
date={ nearestDatum.date }
|
|
@@ -153,13 +147,7 @@ export const renderDefaultTooltip = (
|
|
|
153
147
|
/>
|
|
154
148
|
</div>
|
|
155
149
|
{ tooltipPoints.map( point => (
|
|
156
|
-
<Stack
|
|
157
|
-
key={ point.key }
|
|
158
|
-
direction="row"
|
|
159
|
-
align="center"
|
|
160
|
-
justify="space-between"
|
|
161
|
-
className={ styles[ 'line-chart__tooltip-row' ] }
|
|
162
|
-
>
|
|
150
|
+
<Stack key={ point.key } direction="row" align="center" justify="space-between">
|
|
163
151
|
<span className={ styles[ 'line-chart__tooltip-label' ] }>{ point.key }:</span>
|
|
164
152
|
<span className={ styles[ 'line-chart__tooltip-value' ] }>
|
|
165
153
|
{ formatReading( point.value ) }
|
|
@@ -577,32 +565,12 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
577
565
|
yAccessor: ( d: DataPointDate ) => d?.value,
|
|
578
566
|
};
|
|
579
567
|
|
|
580
|
-
const resolvedTooltipStyle = useMemo( () => {
|
|
581
|
-
if ( renderTooltip !== renderDefaultTooltip || ! tooltipStyle ) return tooltipStyle;
|
|
582
|
-
if ( ! tooltipStyle.color || tooltipStyle.background || tooltipStyle.backgroundColor ) {
|
|
583
|
-
return tooltipStyle;
|
|
584
|
-
}
|
|
585
|
-
return {
|
|
586
|
-
backgroundColor: 'var(--a8c-charts-color-tooltip-surface, rgb(0 0 0 / 85%))',
|
|
587
|
-
...tooltipStyle,
|
|
588
|
-
};
|
|
589
|
-
}, [ renderTooltip, tooltipStyle ] );
|
|
590
|
-
|
|
591
568
|
// Augments every renderTooltip call with the chart's bucket classification,
|
|
592
569
|
// default or custom, so a heading keyed on it can't disagree with the axis.
|
|
593
570
|
const tooltipRenderer = useMemo(
|
|
594
571
|
() => ( params: RenderTooltipParams< DataPointDate > ) =>
|
|
595
|
-
renderTooltip
|
|
596
|
-
|
|
597
|
-
{ ...params, bucketInfo },
|
|
598
|
-
{
|
|
599
|
-
color: tooltipStyle?.color,
|
|
600
|
-
background: tooltipStyle?.background,
|
|
601
|
-
backgroundColor: tooltipStyle?.backgroundColor,
|
|
602
|
-
}
|
|
603
|
-
)
|
|
604
|
-
: renderTooltip( { ...params, bucketInfo } ),
|
|
605
|
-
[ renderTooltip, bucketInfo, tooltipStyle ]
|
|
572
|
+
renderTooltip( { ...params, bucketInfo } ),
|
|
573
|
+
[ renderTooltip, bucketInfo ]
|
|
606
574
|
);
|
|
607
575
|
|
|
608
576
|
if ( error ) {
|
|
@@ -820,7 +788,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
820
788
|
detectBounds
|
|
821
789
|
snapTooltipToDatumX
|
|
822
790
|
tooltipPlacement={ tooltipPlacement }
|
|
823
|
-
style={
|
|
791
|
+
style={ tooltipStyle }
|
|
824
792
|
snapTooltipToDatumY
|
|
825
793
|
showSeriesGlyphs
|
|
826
794
|
renderTooltip={ tooltipRenderer }
|
|
@@ -140,54 +140,26 @@ describe( 'LineChart', () => {
|
|
|
140
140
|
} );
|
|
141
141
|
}
|
|
142
142
|
);
|
|
143
|
-
test
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
{ color: 'rgb(255, 255, 255)' },
|
|
149
|
-
],
|
|
150
|
-
[ { backgroundColor: 'white' }, 'var(--a8c-charts-color-label)', 'rgb(255, 255, 255)', {} ],
|
|
151
|
-
[ { background: 'white' }, 'var(--a8c-charts-color-label)', 'white', {} ],
|
|
152
|
-
[
|
|
153
|
-
{ color: 'white', background: 'black' },
|
|
154
|
-
'rgb(255, 255, 255)',
|
|
155
|
-
'black',
|
|
156
|
-
{ color: 'rgb(255, 255, 255)' },
|
|
157
|
-
],
|
|
158
|
-
] )(
|
|
159
|
-
'applies directional tooltip color defaults: %j',
|
|
160
|
-
async ( tooltipStyle, color, background, containerColorStyle ) => {
|
|
161
|
-
const user = userEvent.setup();
|
|
162
|
-
const defaults = document.createElement( 'style' );
|
|
163
|
-
defaults.textContent = `.line-chart__tooltip {
|
|
164
|
-
color: var(--a8c-charts-color-label);
|
|
165
|
-
background: var(--a8c-charts-color-surface);
|
|
166
|
-
}`;
|
|
167
|
-
renderWithTheme( { tooltipStyle } );
|
|
168
|
-
const chart = screen.getByTestId( 'line-chart' );
|
|
169
|
-
chart.appendChild( defaults );
|
|
170
|
-
chart.style.setProperty( '--a8c-charts-color-label', '#000' );
|
|
171
|
-
chart.style.setProperty( '--a8c-charts-color-label-axis', '#aaa' );
|
|
172
|
-
chart.style.setProperty( '--a8c-charts-color-surface', '#fff' );
|
|
173
|
-
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
174
|
-
await user.keyboard( '{ArrowRight}' );
|
|
143
|
+
test( 'passes a color-only tooltipStyle through with no background added', async () => {
|
|
144
|
+
const user = userEvent.setup();
|
|
145
|
+
renderWithTheme( { tooltipStyle: { color: 'white' } } );
|
|
146
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
147
|
+
await user.keyboard( '{ArrowRight}' );
|
|
175
148
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
);
|
|
149
|
+
const box = screen.getByTestId( 'bounded-tooltip' );
|
|
150
|
+
expect( box ).toHaveStyle( { color: 'rgb(255, 255, 255)' } );
|
|
151
|
+
expect( box ).not.toHaveAttribute( 'style', expect.stringContaining( 'background' ) );
|
|
152
|
+
} );
|
|
153
|
+
|
|
154
|
+
test( 'paints a tooltipStyle background on the box, not on the default content', async () => {
|
|
155
|
+
const user = userEvent.setup();
|
|
156
|
+
renderWithTheme( { tooltipStyle: { background: 'black' } } );
|
|
157
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
158
|
+
await user.keyboard( '{ArrowRight}' );
|
|
159
|
+
|
|
160
|
+
expect( screen.getByTestId( 'bounded-tooltip' ) ).toHaveStyle( { background: 'black' } );
|
|
161
|
+
expect( screen.getByTestId( 'line-chart-tooltip-content' ) ).not.toHaveAttribute( 'style' );
|
|
162
|
+
} );
|
|
191
163
|
|
|
192
164
|
test( 'preserves the existing tooltip colors for noncolor overrides', async () => {
|
|
193
165
|
const user = userEvent.setup();
|
|
@@ -3,6 +3,7 @@ import { Pie } from '@visx/shape';
|
|
|
3
3
|
import { useTooltip } from '@visx/tooltip';
|
|
4
4
|
import { color as d3Color } from '@visx/vendor/d3-color';
|
|
5
5
|
import clsx from 'clsx';
|
|
6
|
+
import isEqual from 'fast-deep-equal';
|
|
6
7
|
import { useCallback, useContext, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
7
8
|
import { Legend, useChartLegendItems } from '../../components/legend';
|
|
8
9
|
import { BaseTooltip } from '../../components/tooltip';
|
|
@@ -21,20 +22,14 @@ import {
|
|
|
21
22
|
GlobalChartsContext,
|
|
22
23
|
} from '../../providers';
|
|
23
24
|
import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
|
|
24
|
-
import { contrastRatio } from '../../providers/chart-context/private/perceptual-color';
|
|
25
25
|
import { useStandaloneScopeClass } from '../../providers/chart-scope';
|
|
26
|
-
import {
|
|
27
|
-
attachSubComponents,
|
|
28
|
-
createCssVariableResolver,
|
|
29
|
-
isValidHexColor,
|
|
30
|
-
normalizeColorToHex,
|
|
31
|
-
resolveFontSize,
|
|
32
|
-
} from '../../utils';
|
|
26
|
+
import { attachSubComponents, createCssVariableResolver, resolveFontSize } from '../../utils';
|
|
33
27
|
import { getStringWidth } from '../../visx/text';
|
|
34
28
|
import { Center } from '../private/center';
|
|
35
29
|
import { ChartSVG, ChartHTML, useChartChildren } from '../private/chart-composition';
|
|
36
30
|
import { ChartInstanceContext } from '../private/chart-instance-context';
|
|
37
31
|
import { ChartLayout } from '../private/chart-layout';
|
|
32
|
+
import { pickLabelTextColor, resolveLabelRoles } from '../private/label-text-color';
|
|
38
33
|
import { RadialWipeAnimation } from '../private/radial-wipe-animation/';
|
|
39
34
|
import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
|
|
40
35
|
import { withResponsive, ResponsiveConfig } from '../private/with-responsive';
|
|
@@ -47,7 +42,8 @@ import type {
|
|
|
47
42
|
Optional,
|
|
48
43
|
} from '../../types';
|
|
49
44
|
import type { ChartComponentWithComposition } from '../private/chart-composition';
|
|
50
|
-
import type {
|
|
45
|
+
import type { LabelRoles, LabelTextColor } from '../private/label-text-color';
|
|
46
|
+
import type { JSX, SVGProps, MouseEvent, ReactNode, FC } from 'react';
|
|
51
47
|
|
|
52
48
|
/**
|
|
53
49
|
* Parameters passed to the renderTooltip function for pie charts.
|
|
@@ -170,48 +166,12 @@ const validateData = ( data: DataPointPercentage[] ) => {
|
|
|
170
166
|
return { isValid: true, message: '' };
|
|
171
167
|
};
|
|
172
168
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
);
|
|
169
|
+
// `label-inverse` is the stylesheet's default, so it needs no modifier.
|
|
170
|
+
const LABEL_TEXT_MODIFIER: Record< LabelTextColor, string | undefined > = {
|
|
171
|
+
label: styles[ 'pie-chart__label-text--on-light' ],
|
|
172
|
+
'label-inverse': undefined,
|
|
173
|
+
black: styles[ 'pie-chart__label-text--black' ],
|
|
174
|
+
white: styles[ 'pie-chart__label-text--white' ],
|
|
215
175
|
};
|
|
216
176
|
|
|
217
177
|
/**
|
|
@@ -258,7 +218,8 @@ const PieChartInternal = ( {
|
|
|
258
218
|
// The element the chart's own `className` lands on, so an override set there reaches this
|
|
259
219
|
// decision the same way it reaches CSS.
|
|
260
220
|
const rootRef = useRef< HTMLDivElement >( null );
|
|
261
|
-
|
|
221
|
+
// Null until read, and while a label plate is set: text on the plate keeps the inverse role.
|
|
222
|
+
const [ labelRoles, setLabelRoles ] = useState< LabelRoles | null >( null );
|
|
262
223
|
|
|
263
224
|
const onMouseLeave = useCallback( () => {
|
|
264
225
|
if ( ! withTooltips ) {
|
|
@@ -279,28 +240,11 @@ const PieChartInternal = ( {
|
|
|
279
240
|
|
|
280
241
|
const resolve = createCssVariableResolver( rootRef.current );
|
|
281
242
|
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
243
|
// A plate value d3 cannot parse (CSS Color 4 syntax, say) is still one CSS paints, so it counts.
|
|
288
244
|
const plateColor = rawLabelBackground ? d3Color( rawLabelBackground ) : null;
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
);
|
|
245
|
+
const hasPlate = rawLabelBackground ? ! plateColor || plateColor.opacity > 0 : false;
|
|
246
|
+
const next = hasPlate ? null : resolveLabelRoles( resolve );
|
|
247
|
+
setLabelRoles( previous => ( isEqual( previous, next ) ? previous : next ) );
|
|
304
248
|
}, [ showLabels, className, isColorPaletteResolved, isValid ] );
|
|
305
249
|
|
|
306
250
|
// Calculate percentages from values (single source of truth)
|
|
@@ -538,10 +482,12 @@ const PieChartInternal = ( {
|
|
|
538
482
|
pointerEvents="none"
|
|
539
483
|
/>
|
|
540
484
|
<text
|
|
541
|
-
className={ clsx(
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
485
|
+
className={ clsx(
|
|
486
|
+
styles[ 'pie-chart__label-text' ],
|
|
487
|
+
LABEL_TEXT_MODIFIER[
|
|
488
|
+
pickLabelTextColor( fill, labelRoles, 'label-inverse' )
|
|
489
|
+
]
|
|
490
|
+
) }
|
|
545
491
|
data-testid="pie-label"
|
|
546
492
|
x={ centroidX }
|
|
547
493
|
y={ centroidY }
|
|
@@ -187,17 +187,17 @@ describe( 'PieChart', () => {
|
|
|
187
187
|
|
|
188
188
|
test( "compares against a label-inverse override set on the chart's own class", () => {
|
|
189
189
|
injectedStyle = document.createElement( 'style' );
|
|
190
|
-
//
|
|
191
|
-
injectedStyle.textContent = '.overridden-pie { --a8c-charts-color-label-inverse: #
|
|
190
|
+
// Black out-contrasts the dark label role on a light slice, where the default inverse would lose.
|
|
191
|
+
injectedStyle.textContent = '.overridden-pie { --a8c-charts-color-label-inverse: #000000; }';
|
|
192
192
|
document.head.appendChild( injectedStyle );
|
|
193
193
|
|
|
194
194
|
renderWithTheme( {
|
|
195
|
-
data: [ { label: '
|
|
195
|
+
data: [ { label: 'Light', value: 100, color: '#f5d76e' } ],
|
|
196
196
|
className: 'overridden-pie',
|
|
197
197
|
} );
|
|
198
198
|
|
|
199
199
|
const [ label ] = screen.getAllByTestId( 'pie-label' );
|
|
200
|
-
expect( label ).toHaveClass( 'pie-chart__label-text--on-light' );
|
|
200
|
+
expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' );
|
|
201
201
|
} );
|
|
202
202
|
|
|
203
203
|
test( 'keeps the inverse label color when the plate uses color syntax d3 cannot parse', () => {
|
|
@@ -239,7 +239,7 @@ describe( 'PieChart', () => {
|
|
|
239
239
|
.forEach( label => expect( label ).not.toHaveClass( 'pie-chart__label-text--on-light' ) );
|
|
240
240
|
} );
|
|
241
241
|
|
|
242
|
-
test( '
|
|
242
|
+
test( 'never picks a see-through inverse role, falling back to white where the label role fails', () => {
|
|
243
243
|
injectedStyle = document.createElement( 'style' );
|
|
244
244
|
injectedStyle.textContent =
|
|
245
245
|
'.clear-inverse-pie { --a8c-charts-color-label-inverse: rgba(255, 255, 255, 0); }';
|
|
@@ -247,9 +247,22 @@ describe( 'PieChart', () => {
|
|
|
247
247
|
|
|
248
248
|
renderWithTheme( { data: contrastData, className: 'clear-inverse-pie' } );
|
|
249
249
|
|
|
250
|
-
screen
|
|
251
|
-
|
|
252
|
-
|
|
250
|
+
const [ light, dark ] = screen.getAllByTestId( 'pie-label' );
|
|
251
|
+
expect( light ).toHaveClass( 'pie-chart__label-text--on-light' );
|
|
252
|
+
expect( dark ).toHaveClass( 'pie-chart__label-text--white' );
|
|
253
|
+
} );
|
|
254
|
+
|
|
255
|
+
test( 'falls back to black or white on a slice where neither label role reaches AA', () => {
|
|
256
|
+
renderWithTheme( {
|
|
257
|
+
data: [
|
|
258
|
+
{ label: 'Mid-light', value: 50, color: '#516dec' },
|
|
259
|
+
{ label: 'Mid-dark', value: 50, color: '#4663ea' },
|
|
260
|
+
],
|
|
261
|
+
} );
|
|
262
|
+
|
|
263
|
+
const [ midLight, midDark ] = screen.getAllByTestId( 'pie-label' );
|
|
264
|
+
expect( midLight ).toHaveClass( 'pie-chart__label-text--black' );
|
|
265
|
+
expect( midDark ).toHaveClass( 'pie-chart__label-text--white' );
|
|
253
266
|
} );
|
|
254
267
|
|
|
255
268
|
test( 'keeps the inverse label color when the fill cannot be resolved', () => {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { color as d3Color } from '@visx/vendor/d3-color';
|
|
2
|
+
import isEqual from 'fast-deep-equal';
|
|
3
|
+
import { CATALOG_POINTERS } from '../../providers/chart-context/private/catalog-pointers';
|
|
4
|
+
import { MIN_LABEL_CONTRAST } from '../../providers/chart-context/private/palette-generator';
|
|
5
|
+
import {
|
|
6
|
+
blendRgb,
|
|
7
|
+
hexToRgb,
|
|
8
|
+
luminanceContrastRatio,
|
|
9
|
+
rgbLuminance,
|
|
10
|
+
} from '../../providers/chart-context/private/perceptual-color';
|
|
11
|
+
import { isValidHexColor, normalizeColorToHex } from '../../utils/color-utils';
|
|
12
|
+
import type { Rgb } from '../../providers/chart-context/private/perceptual-color';
|
|
13
|
+
|
|
14
|
+
// The browser paints the fill on 8-bit channels, which can cost a mid-tone ~0.03 of contrast.
|
|
15
|
+
const ROUNDING_HEADROOM = 0.05;
|
|
16
|
+
|
|
17
|
+
/** Which color a label drawn on a fill paints with: one of the two catalog roles, or the black/white floor. */
|
|
18
|
+
export type LabelTextColor = 'label' | 'label-inverse' | 'black' | 'white';
|
|
19
|
+
|
|
20
|
+
/** A label role as the chart's own element resolves it. `unreadable` keeps the raw value, which CSS may still paint. */
|
|
21
|
+
export type ResolvedRole =
|
|
22
|
+
{ kind: 'color'; rgb: Rgb; alpha: number } | { kind: 'unreadable'; raw: string | null };
|
|
23
|
+
|
|
24
|
+
export interface LabelRoles {
|
|
25
|
+
label: ResolvedRole;
|
|
26
|
+
labelInverse: ResolvedRole;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const resolveRole = (
|
|
30
|
+
pointer: string,
|
|
31
|
+
resolve: ( value: string ) => string | null
|
|
32
|
+
): ResolvedRole => {
|
|
33
|
+
const raw = resolve( pointer );
|
|
34
|
+
// d3 reads no CSS Color 4 syntax (`rgb(0 0 0)`, `oklch()`), though the browser paints it.
|
|
35
|
+
const parsed = raw ? d3Color( raw ) : null;
|
|
36
|
+
if ( ! parsed ) {
|
|
37
|
+
return { kind: 'unreadable', raw };
|
|
38
|
+
}
|
|
39
|
+
// d3 gives `transparent` NaN channels; with no alpha they never reach the composite anyway.
|
|
40
|
+
const { r, g, b } = parsed.opacity > 0 ? parsed.rgb() : { r: 0, g: 0, b: 0 };
|
|
41
|
+
return { kind: 'color', rgb: [ r, g, b ], alpha: parsed.opacity };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads both label roles at the element the label text inherits from, so JS and CSS agree on what paints.
|
|
46
|
+
*
|
|
47
|
+
* @param resolve - A CSS variable resolver bound to that element.
|
|
48
|
+
* @return The resolved label roles.
|
|
49
|
+
*/
|
|
50
|
+
export const resolveLabelRoles = ( resolve: ( value: string ) => string | null ): LabelRoles => ( {
|
|
51
|
+
label: resolveRole( CATALOG_POINTERS.label, resolve ),
|
|
52
|
+
labelInverse: resolveRole( CATALOG_POINTERS.labelInverse, resolve ),
|
|
53
|
+
} );
|
|
54
|
+
|
|
55
|
+
// Text with alpha shows the fill through it, so it is measured as the blend the browser paints.
|
|
56
|
+
const roleContrast = ( role: ResolvedRole, fill: Rgb, fillLuminance: number ): number => {
|
|
57
|
+
if ( role.kind === 'unreadable' ) {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
return luminanceContrastRatio(
|
|
61
|
+
fillLuminance,
|
|
62
|
+
rgbLuminance( blendRgb( role.rgb, fill, role.alpha ) )
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Picks the label color for text drawn on a fill: the role that contrasts more, or black/white when neither reaches AA.
|
|
68
|
+
*
|
|
69
|
+
* Both roles set to one color is a host's choice to keep that color, so it never falls back.
|
|
70
|
+
*
|
|
71
|
+
* @param fill - The fill the text sits on, as unrounded sRGB channels.
|
|
72
|
+
* @param roles - The label roles resolved at the chart's element, or null before they are read.
|
|
73
|
+
* @param defaultRole - The role the chart's stylesheet paints when nothing can be decided.
|
|
74
|
+
* @return The color the label should paint with.
|
|
75
|
+
*/
|
|
76
|
+
export const pickLabelTextColorForFill = (
|
|
77
|
+
fill: Rgb,
|
|
78
|
+
roles: LabelRoles | null,
|
|
79
|
+
defaultRole: 'label' | 'label-inverse'
|
|
80
|
+
): LabelTextColor => {
|
|
81
|
+
if ( ! roles || isEqual( roles.label, roles.labelInverse ) ) {
|
|
82
|
+
return defaultRole;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const fillLuminance = rgbLuminance( fill );
|
|
86
|
+
const labelContrast = roleContrast( roles.label, fill, fillLuminance );
|
|
87
|
+
const inverseContrast = roleContrast( roles.labelInverse, fill, fillLuminance );
|
|
88
|
+
if ( Math.max( labelContrast, inverseContrast ) >= MIN_LABEL_CONTRAST + ROUNDING_HEADROOM ) {
|
|
89
|
+
return labelContrast > inverseContrast ? 'label' : 'label-inverse';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// On any opaque fill one of these reaches at least 4.58:1.
|
|
93
|
+
return luminanceContrastRatio( fillLuminance, 0 ) > luminanceContrastRatio( fillLuminance, 1 )
|
|
94
|
+
? 'black'
|
|
95
|
+
: 'white';
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `pickLabelTextColorForFill` for a fill given as a color.
|
|
100
|
+
*
|
|
101
|
+
* @param fill - The fill, in any color syntax `normalizeColorToHex` reads.
|
|
102
|
+
* @param roles - The label roles resolved at the chart's element, or null before they are read.
|
|
103
|
+
* @param defaultRole - The role the chart's stylesheet paints when nothing can be decided.
|
|
104
|
+
* @return The color the label should paint with.
|
|
105
|
+
*/
|
|
106
|
+
export const pickLabelTextColor = (
|
|
107
|
+
fill: string,
|
|
108
|
+
roles: LabelRoles | null,
|
|
109
|
+
defaultRole: 'label' | 'label-inverse'
|
|
110
|
+
): LabelTextColor => {
|
|
111
|
+
const fillHex = normalizeColorToHex( fill );
|
|
112
|
+
if ( ! isValidHexColor( fillHex ) ) {
|
|
113
|
+
return defaultRole;
|
|
114
|
+
}
|
|
115
|
+
return pickLabelTextColorForFill( hexToRgb( fillHex ), roles, defaultRole );
|
|
116
|
+
};
|