@automattic/charts 1.7.0 → 1.8.1
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 +13 -0
- package/dist/index.cjs +347 -61
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +6 -4
- package/dist/index.d.cts +16 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +348 -62
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/charts/bar-chart/bar-chart.tsx +210 -49
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +70 -0
- package/src/charts/bar-chart/private/comparison-bars.tsx +155 -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 +49 -5
- package/src/charts/bar-chart/test/bar-chart.test.tsx +329 -1
- package/src/charts/geo-chart/geo-chart.tsx +5 -9
- 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 +3 -8
- 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/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/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 +8 -0
- package/src/providers/chart-context/types.ts +2 -0
- package/src/types.ts +16 -0
- package/src/utils/get-styles.ts +36 -13
- package/src/utils/index.ts +6 -1
- package/src/utils/test/get-styles.test.ts +47 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
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.5",
|
|
67
67
|
"@babel/runtime": "7.29.7",
|
|
68
68
|
"@react-spring/web": "10.1.1",
|
|
69
69
|
"@visx/annotation": "^4.0.0",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { formatNumber } from '@automattic/number-formatters';
|
|
2
2
|
import { PatternLines, PatternCircles, PatternWaves, PatternHexagons } from '@visx/pattern';
|
|
3
3
|
import { Axis, BarSeries, BarGroup, Grid, XYChart } from '@visx/xychart';
|
|
4
|
-
import { __ } from '@wordpress/i18n';
|
|
4
|
+
import { __, sprintf } from '@wordpress/i18n';
|
|
5
5
|
import clsx from 'clsx';
|
|
6
6
|
import { useCallback, useContext, useState, useRef, useMemo } from 'react';
|
|
7
7
|
import { Legend, useChartLegendItems } from '../../components/legend';
|
|
@@ -27,7 +27,16 @@ import { SingleChartContext } from '../private/single-chart-context';
|
|
|
27
27
|
import { SvgEmptyState } from '../private/svg-empty-state';
|
|
28
28
|
import { withResponsive } from '../private/with-responsive';
|
|
29
29
|
import styles from './bar-chart.module.scss';
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
useBarChartOptions,
|
|
32
|
+
ComparisonBars,
|
|
33
|
+
DEFAULT_COMPARISON_WIDTH_FACTOR,
|
|
34
|
+
COMPARISON_INNER_GAP,
|
|
35
|
+
MAX_GROUP_PADDING,
|
|
36
|
+
COMPARISON_TICK_GAP_FACTOR,
|
|
37
|
+
BASE_BAND_PADDING_INNER,
|
|
38
|
+
} from './private';
|
|
39
|
+
import type { ComparisonSeriesEntry } from './private';
|
|
31
40
|
import type { BaseChartProps, DataPointDate, SeriesData, Optional } from '../../types';
|
|
32
41
|
import type { RenderTooltipParams } from '../../visx/types';
|
|
33
42
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
@@ -74,6 +83,19 @@ const validateData = ( data: SeriesData[] ) => {
|
|
|
74
83
|
|
|
75
84
|
const getPatternId = ( chartId: string, index: number ) => `bar-pattern-${ chartId }-${ index }`;
|
|
76
85
|
|
|
86
|
+
// A "label: value" tooltip row. The join is a translated format string so the
|
|
87
|
+
// separator (a colon + space here) can be adapted per locale.
|
|
88
|
+
const renderTooltipRow = ( label: string | undefined, value: string ) => (
|
|
89
|
+
<div className={ styles[ 'bar-chart__tooltip-row' ] }>
|
|
90
|
+
{ sprintf(
|
|
91
|
+
/* translators: 1: data series, period, or category label. 2: its formatted value. */
|
|
92
|
+
__( '%1$s: %2$s', 'jetpack-charts' ),
|
|
93
|
+
label,
|
|
94
|
+
value
|
|
95
|
+
) }
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
|
|
77
99
|
const BarChartInternal: FC< BarChartProps > = ( {
|
|
78
100
|
data,
|
|
79
101
|
chartId: providedChartId,
|
|
@@ -128,8 +150,12 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
128
150
|
const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined );
|
|
129
151
|
const [ isNavigating, setIsNavigating ] = useState( false );
|
|
130
152
|
|
|
153
|
+
// Comparison series have no .visx-bar elements; count only primary series so
|
|
154
|
+
// keyboard navigation doesn't cycle phantom indices into comparison-only slots.
|
|
155
|
+
const primarySeriesForNav = dataWithVisibleZeros.filter( s => s.options?.type !== 'comparison' );
|
|
131
156
|
const totalPoints =
|
|
132
|
-
Math.max( 0, ...
|
|
157
|
+
Math.max( 0, ...primarySeriesForNav.map( s => s.data?.length || 0 ) ) *
|
|
158
|
+
primarySeriesForNav.length;
|
|
133
159
|
|
|
134
160
|
// Use the keyboard navigation hook
|
|
135
161
|
const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
|
|
@@ -164,6 +190,100 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
164
190
|
return seriesWithVisibility.every( ( { isVisible } ) => ! isVisible );
|
|
165
191
|
}, [ seriesWithVisibility ] );
|
|
166
192
|
|
|
193
|
+
// Derive primary vs comparison entries for comparison mode support.
|
|
194
|
+
const primaryEntries = useMemo(
|
|
195
|
+
() =>
|
|
196
|
+
seriesWithVisibility.filter(
|
|
197
|
+
( { isVisible, series } ) => isVisible && series.options?.type !== 'comparison'
|
|
198
|
+
),
|
|
199
|
+
[ seriesWithVisibility ]
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const primaryKeys = useMemo(
|
|
203
|
+
() => primaryEntries.map( ( { series } ) => series.label ),
|
|
204
|
+
[ primaryEntries ]
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// The keyboard-navigation index space and the highlight CSS both stride over primary
|
|
208
|
+
// bars only; the accessible tooltip must use the same list, or its datum diverges from
|
|
209
|
+
// the highlighted bar once a comparison series shifts the indices.
|
|
210
|
+
const primarySeries = useMemo(
|
|
211
|
+
() => primaryEntries.map( ( { series } ) => series ),
|
|
212
|
+
[ primaryEntries ]
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const comparisonEntries = useMemo( () => {
|
|
216
|
+
const primaryByGroup = new Map< string | undefined, { label: string; index: number } >(
|
|
217
|
+
primaryEntries.map( ( { series, index } ) => [
|
|
218
|
+
series.group,
|
|
219
|
+
{ label: series.label, index },
|
|
220
|
+
] )
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const entries: ComparisonSeriesEntry[] = [];
|
|
224
|
+
seriesWithVisibility.forEach( ( { series, index, isVisible } ) => {
|
|
225
|
+
if ( ! isVisible || series.options?.type !== 'comparison' ) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const primary =
|
|
229
|
+
primaryByGroup.get( series.group ) ??
|
|
230
|
+
( primaryEntries.length === 1
|
|
231
|
+
? { label: primaryEntries[ 0 ].series.label, index: primaryEntries[ 0 ].index }
|
|
232
|
+
: undefined );
|
|
233
|
+
if ( ! primary || ! primaryKeys.includes( primary.label ) ) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
entries.push( { series, index, primaryKey: primary.label, primaryIndex: primary.index } );
|
|
237
|
+
} );
|
|
238
|
+
return entries;
|
|
239
|
+
}, [ seriesWithVisibility, primaryEntries, primaryKeys ] );
|
|
240
|
+
|
|
241
|
+
// Comparison widthFactor (how much wider the shadow is than the primary) drives both the
|
|
242
|
+
// shadow width (in ComparisonBars) and the primary narrowing.
|
|
243
|
+
const comparisonWidthFactor = useMemo( () => {
|
|
244
|
+
if ( comparisonEntries.length === 0 ) return undefined;
|
|
245
|
+
return (
|
|
246
|
+
getElementStyles( {
|
|
247
|
+
data: comparisonEntries[ 0 ].series,
|
|
248
|
+
index: comparisonEntries[ 0 ].index,
|
|
249
|
+
} ).barStyles?.widthFactor ?? DEFAULT_COMPARISON_WIDTH_FACTOR
|
|
250
|
+
);
|
|
251
|
+
}, [ comparisonEntries, getElementStyles ] );
|
|
252
|
+
|
|
253
|
+
// Narrow the primary bars by widening the visx group padding — a real geometry change, so
|
|
254
|
+
// pattern fills and borders are not distorted (unlike a CSS transform/scale). The padding is
|
|
255
|
+
// set so the comparison shadow (drawn at slot × widthFactor) fills all but a small gap of each
|
|
256
|
+
// per-series step, leaving a small gap between series within a tick (the larger gap between
|
|
257
|
+
// ticks comes from the category band's own padding). Because shadow = step × (1 - p) ×
|
|
258
|
+
// widthFactor, choosing p = 1 - (1 - innerGap)/widthFactor makes the shadow span (1 - innerGap)
|
|
259
|
+
// of the step; the primary stays 1/widthFactor of the shadow.
|
|
260
|
+
const groupPadding = useMemo( () => {
|
|
261
|
+
const basePadding = chartOptions.barGroup.padding;
|
|
262
|
+
if ( ! comparisonWidthFactor || comparisonWidthFactor <= 1 ) {
|
|
263
|
+
return basePadding;
|
|
264
|
+
}
|
|
265
|
+
const p = 1 - ( 1 - COMPARISON_INNER_GAP ) / comparisonWidthFactor;
|
|
266
|
+
return Math.min( Math.max( p, basePadding ), MAX_GROUP_PADDING );
|
|
267
|
+
}, [ chartOptions.barGroup.padding, comparisonWidthFactor ] );
|
|
268
|
+
|
|
269
|
+
// In comparison mode, tighten the gap between ticks by reducing the category band's inner
|
|
270
|
+
// padding (the value axis is left untouched). COMPARISON_TICK_GAP_FACTOR is the multiplier.
|
|
271
|
+
const { xScale, yScale } = useMemo( () => {
|
|
272
|
+
if ( comparisonEntries.length === 0 ) {
|
|
273
|
+
return { xScale: chartOptions.xScale, yScale: chartOptions.yScale };
|
|
274
|
+
}
|
|
275
|
+
const tighten = < T extends object >( scale: T ): T =>
|
|
276
|
+
( {
|
|
277
|
+
...scale,
|
|
278
|
+
paddingInner:
|
|
279
|
+
( ( scale as { paddingInner?: number } ).paddingInner ?? BASE_BAND_PADDING_INNER ) *
|
|
280
|
+
COMPARISON_TICK_GAP_FACTOR,
|
|
281
|
+
} ) as T;
|
|
282
|
+
return horizontal
|
|
283
|
+
? { xScale: chartOptions.xScale, yScale: tighten( chartOptions.yScale ) }
|
|
284
|
+
: { xScale: tighten( chartOptions.xScale ), yScale: chartOptions.yScale };
|
|
285
|
+
}, [ comparisonEntries.length, chartOptions.xScale, chartOptions.yScale, horizontal ] );
|
|
286
|
+
|
|
167
287
|
const getBarBackground = useCallback(
|
|
168
288
|
( index: number ) => () =>
|
|
169
289
|
withPatterns
|
|
@@ -172,33 +292,61 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
172
292
|
[ withPatterns, getElementStyles, dataSorted, chartId ]
|
|
173
293
|
);
|
|
174
294
|
|
|
295
|
+
// Comparison shadow fill: when patterns are on, reuse the paired primary's pattern so the
|
|
296
|
+
// shadow reads as the same series; otherwise use the comparison series' resolved color.
|
|
297
|
+
const resolveComparisonFill = useCallback(
|
|
298
|
+
( entry: ComparisonSeriesEntry ) =>
|
|
299
|
+
withPatterns
|
|
300
|
+
? `url(#${ getPatternId( chartId, entry.primaryIndex ) })`
|
|
301
|
+
: getElementStyles( { data: entry.series, index: entry.index } ).color,
|
|
302
|
+
[ withPatterns, chartId, getElementStyles ]
|
|
303
|
+
);
|
|
304
|
+
|
|
175
305
|
const renderDefaultTooltip = useCallback(
|
|
176
306
|
( { tooltipData }: RenderTooltipParams< DataPointDate > ) => {
|
|
177
307
|
const nearestDatum = tooltipData?.nearestDatum?.datum;
|
|
178
308
|
if ( ! nearestDatum ) return null;
|
|
179
309
|
|
|
310
|
+
const primaryKey = tooltipData?.nearestDatum?.key;
|
|
311
|
+
const categoryLabel = chartOptions.tooltip.labelFormatter(
|
|
312
|
+
nearestDatum.label || ( nearestDatum.date ? nearestDatum.date.getTime() : 0 ),
|
|
313
|
+
0,
|
|
314
|
+
[]
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// Find the comparison value paired with the hovered primary series (same group)
|
|
318
|
+
// at the same category, so the tooltip can show both periods at once.
|
|
319
|
+
const comparisonEntry = comparisonEntries.find( entry => entry.primaryKey === primaryKey );
|
|
320
|
+
const comparisonDatum = comparisonEntry?.series.data.find( point => {
|
|
321
|
+
const p = point as DataPointDate;
|
|
322
|
+
return nearestDatum.label != null
|
|
323
|
+
? p.label === nearestDatum.label
|
|
324
|
+
: !! nearestDatum.date && !! p.date && p.date.getTime() === nearestDatum.date.getTime();
|
|
325
|
+
} ) as DataPointDate | undefined;
|
|
326
|
+
|
|
327
|
+
// With a paired comparison value, show the category as the header and one row
|
|
328
|
+
// per period (primary + comparison).
|
|
329
|
+
if ( comparisonEntry && comparisonDatum && comparisonDatum.value != null ) {
|
|
330
|
+
return (
|
|
331
|
+
<div className={ styles[ 'bar-chart__tooltip' ] }>
|
|
332
|
+
<div className={ styles[ 'bar-chart__tooltip-header' ] }>{ categoryLabel }</div>
|
|
333
|
+
{ renderTooltipRow( primaryKey, formatNumber( nearestDatum.value as number ) ) }
|
|
334
|
+
{ renderTooltipRow(
|
|
335
|
+
comparisonEntry.series.label,
|
|
336
|
+
formatNumber( comparisonDatum.value as number )
|
|
337
|
+
) }
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
180
342
|
return (
|
|
181
343
|
<div className={ styles[ 'bar-chart__tooltip' ] }>
|
|
182
|
-
<div className={ styles[ 'bar-chart__tooltip-header' ] }>
|
|
183
|
-
|
|
184
|
-
</div>
|
|
185
|
-
<div className={ styles[ 'bar-chart__tooltip-row' ] }>
|
|
186
|
-
<span className={ styles[ 'bar-chart__tooltip-label' ] }>
|
|
187
|
-
{ chartOptions.tooltip.labelFormatter(
|
|
188
|
-
nearestDatum.label || ( nearestDatum.date ? nearestDatum.date.getTime() : 0 ),
|
|
189
|
-
0,
|
|
190
|
-
[]
|
|
191
|
-
) }
|
|
192
|
-
:
|
|
193
|
-
</span>
|
|
194
|
-
<span className={ styles[ 'bar-chart__tooltip-value' ] }>
|
|
195
|
-
{ formatNumber( nearestDatum.value as number ) }
|
|
196
|
-
</span>
|
|
197
|
-
</div>
|
|
344
|
+
<div className={ styles[ 'bar-chart__tooltip-header' ] }>{ primaryKey }</div>
|
|
345
|
+
{ renderTooltipRow( categoryLabel, formatNumber( nearestDatum.value as number ) ) }
|
|
198
346
|
</div>
|
|
199
347
|
);
|
|
200
348
|
},
|
|
201
|
-
[ chartOptions.tooltip ]
|
|
349
|
+
[ chartOptions.tooltip, comparisonEntries ]
|
|
202
350
|
);
|
|
203
351
|
|
|
204
352
|
const renderPattern = useCallback(
|
|
@@ -240,8 +388,11 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
240
388
|
const createPatternBorderStyle = useCallback(
|
|
241
389
|
( index: number, color: string ) => {
|
|
242
390
|
const patternId = getPatternId( chartId, index );
|
|
391
|
+
// Border the primary bars and any comparison shadow reusing the same pattern,
|
|
392
|
+
// so a patterned shadow gets the same outline as its primary bar.
|
|
243
393
|
return `
|
|
244
|
-
.visx-bar[fill="url(#${ patternId })"]
|
|
394
|
+
.visx-bar[fill="url(#${ patternId })"],
|
|
395
|
+
.bar-chart__comparison-bars rect[fill="url(#${ patternId })"] {
|
|
245
396
|
stroke: ${ color };
|
|
246
397
|
stroke-width: 1;
|
|
247
398
|
}
|
|
@@ -253,19 +404,21 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
253
404
|
const createKeyboardHighlightStyle = useCallback( () => {
|
|
254
405
|
if ( selectedIndex === undefined ) return '';
|
|
255
406
|
|
|
256
|
-
//
|
|
407
|
+
// Use only primary entries — comparison series have no .visx-bar elements so
|
|
408
|
+
// their indices must not appear in the nth-child selector.
|
|
257
409
|
// Pattern: [series1[0], series2[0], series3[0], series1[1], series2[1], series3[1], ...]
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
const
|
|
410
|
+
const primaryCount = primaryEntries.length;
|
|
411
|
+
const maxDataPoints = Math.max( ...primaryEntries.map( e => e.series.data.length ) );
|
|
412
|
+
const dataPointIndex = Math.floor( selectedIndex / primaryCount );
|
|
413
|
+
const seriesIndex = selectedIndex % primaryCount;
|
|
261
414
|
|
|
262
415
|
// Only highlight if we're within valid bounds
|
|
263
|
-
if ( dataPointIndex >= maxDataPoints || seriesIndex >=
|
|
416
|
+
if ( dataPointIndex >= maxDataPoints || seriesIndex >= primaryCount ) {
|
|
264
417
|
return '';
|
|
265
418
|
}
|
|
266
419
|
|
|
267
|
-
const seriesData =
|
|
268
|
-
if ( dataPointIndex >= seriesData.data.length ) {
|
|
420
|
+
const seriesData = primaryEntries[ seriesIndex ]?.series;
|
|
421
|
+
if ( ! seriesData || dataPointIndex >= seriesData.data.length ) {
|
|
269
422
|
return '';
|
|
270
423
|
}
|
|
271
424
|
|
|
@@ -286,7 +439,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
286
439
|
`;
|
|
287
440
|
|
|
288
441
|
return generatedStyles;
|
|
289
|
-
}, [ selectedIndex,
|
|
442
|
+
}, [ selectedIndex, primaryEntries, chartId ] );
|
|
290
443
|
|
|
291
444
|
// Validate data first
|
|
292
445
|
const error = validateData( dataSorted );
|
|
@@ -386,8 +539,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
386
539
|
...defaultMargin,
|
|
387
540
|
...margin,
|
|
388
541
|
} }
|
|
389
|
-
xScale={
|
|
390
|
-
yScale={
|
|
542
|
+
xScale={ xScale }
|
|
543
|
+
yScale={ yScale }
|
|
391
544
|
horizontal={ horizontal }
|
|
392
545
|
pointerEventsDataKey="nearest"
|
|
393
546
|
>
|
|
@@ -434,24 +587,32 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
434
587
|
</SvgEmptyState>
|
|
435
588
|
) : null }
|
|
436
589
|
|
|
437
|
-
<
|
|
438
|
-
{
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
590
|
+
<ComparisonBars
|
|
591
|
+
comparisonEntries={ comparisonEntries }
|
|
592
|
+
primaryKeys={ primaryKeys }
|
|
593
|
+
groupPadding={ groupPadding }
|
|
594
|
+
horizontal={ horizontal }
|
|
595
|
+
xAccessor={ chartOptions.accessors.xAccessor }
|
|
596
|
+
yAccessor={
|
|
597
|
+
chartOptions.accessors.yAccessor as (
|
|
598
|
+
d: DataPointDate
|
|
599
|
+
) => number | undefined
|
|
600
|
+
}
|
|
601
|
+
getElementStyles={ getElementStyles }
|
|
602
|
+
resolveFill={ resolveComparisonFill }
|
|
603
|
+
/>
|
|
443
604
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
605
|
+
<BarGroup padding={ groupPadding }>
|
|
606
|
+
{ primaryEntries.map( ( { series: seriesData, index } ) => (
|
|
607
|
+
<BarSeries
|
|
608
|
+
key={ seriesData?.label }
|
|
609
|
+
dataKey={ seriesData?.label }
|
|
610
|
+
data={ seriesData.data as DataPointDate[] }
|
|
611
|
+
yAccessor={ chartOptions.accessors.yAccessor }
|
|
612
|
+
xAccessor={ chartOptions.accessors.xAccessor }
|
|
613
|
+
colorAccessor={ getBarBackground( index ) }
|
|
614
|
+
/>
|
|
615
|
+
) ) }
|
|
455
616
|
</BarGroup>
|
|
456
617
|
|
|
457
618
|
<Axis { ...chartOptions.axis.x } />
|
|
@@ -468,7 +629,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
468
629
|
keyboardFocusedClassName={
|
|
469
630
|
styles[ 'bar-chart__tooltip--keyboard-focused' ]
|
|
470
631
|
}
|
|
471
|
-
series={
|
|
632
|
+
series={ primarySeries }
|
|
472
633
|
mode="individual"
|
|
473
634
|
/>
|
|
474
635
|
) }
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export type ComparisonRect = { x: number; y: number; width: number; height: number };
|
|
2
|
+
|
|
3
|
+
type ValueScale = ( ( v: number ) => number ) & { range: () => unknown[] };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Output position of a value scale's baseline: zero if in-domain, else the
|
|
7
|
+
* nearest range edge. Mirrors visx's getScaleBaseline so comparison shadows
|
|
8
|
+
* sit on the same baseline as primary bars.
|
|
9
|
+
*
|
|
10
|
+
* @param {ValueScale} scale - The continuous value scale.
|
|
11
|
+
* @return {number} The baseline output position in pixels.
|
|
12
|
+
*/
|
|
13
|
+
export function getValueScaleBaseline( scale: ValueScale ): number {
|
|
14
|
+
const [ a, b ] = scale.range().map( r => Number( r ) || 0 );
|
|
15
|
+
const isDescending = b < a;
|
|
16
|
+
const maybeZero = scale( 0 );
|
|
17
|
+
const [ minOutput, maxOutput ] = isDescending ? [ b, a ] : [ a, b ];
|
|
18
|
+
if ( isDescending ) {
|
|
19
|
+
return Number.isFinite( maybeZero )
|
|
20
|
+
? Math.min( Math.max( minOutput, maybeZero ), maxOutput )
|
|
21
|
+
: maxOutput;
|
|
22
|
+
}
|
|
23
|
+
return Number.isFinite( maybeZero )
|
|
24
|
+
? Math.min( Math.max( maybeZero, minOutput ), maxOutput )
|
|
25
|
+
: minOutput;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compute the rect for a comparison "shadow" bar, centered on the paired
|
|
30
|
+
* primary bar slot and scaled by `widthFactor`.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} params - Geometry inputs.
|
|
33
|
+
* @param {boolean} params.horizontal - True for a horizontal bar chart, false for vertical.
|
|
34
|
+
* @param {number} params.bandPosition - bandScale(category): start px of the category band.
|
|
35
|
+
* @param {number} params.slotOffset - groupScale(primaryKey): offset of the primary slot within the band.
|
|
36
|
+
* @param {number} params.slotThickness - groupScale.bandwidth(): primary bar thickness in px.
|
|
37
|
+
* @param {number} params.valuePosition - valueScale(value): output px for the bar's data value.
|
|
38
|
+
* @param {number} params.baseline - getValueScaleBaseline(valueScale): zero-line output px.
|
|
39
|
+
* @param {number} params.widthFactor - Shadow thickness multiplier, e.g. 1.5 for 150% width.
|
|
40
|
+
* @return {ComparisonRect} The {x, y, width, height} of the shadow rect.
|
|
41
|
+
*/
|
|
42
|
+
export function computeComparisonRect( params: {
|
|
43
|
+
horizontal: boolean;
|
|
44
|
+
bandPosition: number;
|
|
45
|
+
slotOffset: number;
|
|
46
|
+
slotThickness: number;
|
|
47
|
+
valuePosition: number;
|
|
48
|
+
baseline: number;
|
|
49
|
+
widthFactor: number;
|
|
50
|
+
} ): ComparisonRect {
|
|
51
|
+
const {
|
|
52
|
+
horizontal,
|
|
53
|
+
bandPosition,
|
|
54
|
+
slotOffset,
|
|
55
|
+
slotThickness,
|
|
56
|
+
valuePosition,
|
|
57
|
+
baseline,
|
|
58
|
+
widthFactor,
|
|
59
|
+
} = params;
|
|
60
|
+
const slotStart = bandPosition + slotOffset;
|
|
61
|
+
const shadowThickness = slotThickness * widthFactor;
|
|
62
|
+
const shadowStart = slotStart + slotThickness / 2 - shadowThickness / 2;
|
|
63
|
+
const valueStart = Math.min( valuePosition, baseline );
|
|
64
|
+
const valueLength = Math.abs( baseline - valuePosition );
|
|
65
|
+
|
|
66
|
+
if ( horizontal ) {
|
|
67
|
+
return { x: valueStart, y: shadowStart, width: valueLength, height: shadowThickness };
|
|
68
|
+
}
|
|
69
|
+
return { x: shadowStart, y: valueStart, width: shadowThickness, height: valueLength };
|
|
70
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { scaleBand } from '@visx/scale';
|
|
2
|
+
import { DataContext } from '@visx/xychart';
|
|
3
|
+
import { useContext } from 'react';
|
|
4
|
+
import { computeComparisonRect, getValueScaleBaseline } from './comparison-bars-geometry';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_COMPARISON_OPACITY,
|
|
7
|
+
DEFAULT_COMPARISON_WIDTH_FACTOR,
|
|
8
|
+
} from './comparison-constants';
|
|
9
|
+
import type { ElementStyles, GetElementStylesParams } from '../../../providers';
|
|
10
|
+
import type { DataPointDate, SeriesData } from '../../../types';
|
|
11
|
+
import type { FC, ReactNode } from 'react';
|
|
12
|
+
|
|
13
|
+
export type ComparisonSeriesEntry = {
|
|
14
|
+
series: SeriesData;
|
|
15
|
+
index: number;
|
|
16
|
+
primaryKey: string;
|
|
17
|
+
/** dataSorted index of the paired primary series (used to share its pattern fill). */
|
|
18
|
+
primaryIndex: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Minimal shape we need from visx scales — avoids spreading `any` while
|
|
22
|
+
// remaining compatible with both band and continuous scale return types.
|
|
23
|
+
type AnyScale = ( ( input: unknown ) => number ) & {
|
|
24
|
+
bandwidth?: () => number;
|
|
25
|
+
range: () => unknown[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Renders translucent "shadow" bars for comparison series behind their paired primary bars.
|
|
29
|
+
// IMPORTANT: each comparison datum's category key (label or date) must exactly match a key
|
|
30
|
+
// used by the primary series — if it doesn't, bandScale() returns undefined and the shadow
|
|
31
|
+
// is silently skipped. Ensure comparison data reuses the same label/date values as primary.
|
|
32
|
+
export const ComparisonBars: FC< {
|
|
33
|
+
comparisonEntries: ComparisonSeriesEntry[];
|
|
34
|
+
primaryKeys: string[];
|
|
35
|
+
groupPadding: number;
|
|
36
|
+
horizontal: boolean;
|
|
37
|
+
xAccessor: ( d: DataPointDate ) => string | number | Date | undefined;
|
|
38
|
+
yAccessor: ( d: DataPointDate ) => number | undefined;
|
|
39
|
+
getElementStyles: ( params: GetElementStylesParams ) => ElementStyles;
|
|
40
|
+
/** Resolves the shadow fill — the paired primary's pattern when patterns are on, else a color. */
|
|
41
|
+
resolveFill: ( entry: ComparisonSeriesEntry ) => string;
|
|
42
|
+
} > = ( {
|
|
43
|
+
comparisonEntries,
|
|
44
|
+
primaryKeys,
|
|
45
|
+
groupPadding,
|
|
46
|
+
horizontal,
|
|
47
|
+
xAccessor,
|
|
48
|
+
yAccessor,
|
|
49
|
+
getElementStyles,
|
|
50
|
+
resolveFill,
|
|
51
|
+
} ) => {
|
|
52
|
+
const context = useContext( DataContext );
|
|
53
|
+
const xScale = context?.xScale as AnyScale | undefined;
|
|
54
|
+
const yScale = context?.yScale as AnyScale | undefined;
|
|
55
|
+
|
|
56
|
+
if ( ! xScale || ! yScale || primaryKeys.length === 0 ) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Vertical: band axis is x, value axis is y. Horizontal: reversed.
|
|
61
|
+
const bandScale = ( horizontal ? yScale : xScale ) as AnyScale;
|
|
62
|
+
const valueScale = ( horizontal ? xScale : yScale ) as AnyScale;
|
|
63
|
+
|
|
64
|
+
const bandwidth = bandScale.bandwidth ? bandScale.bandwidth() : 0;
|
|
65
|
+
if ( ! bandwidth ) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Rebuild visx's inner group scale exactly as BarGroup does.
|
|
70
|
+
const groupScale = scaleBand( {
|
|
71
|
+
domain: primaryKeys,
|
|
72
|
+
range: [ 0, bandwidth ],
|
|
73
|
+
padding: groupPadding,
|
|
74
|
+
} );
|
|
75
|
+
const slotThickness = groupScale.bandwidth();
|
|
76
|
+
const baseline = getValueScaleBaseline( valueScale );
|
|
77
|
+
|
|
78
|
+
// Vertical uses xAccessor for category label; horizontal uses yAccessor.
|
|
79
|
+
const bandAccessor = horizontal ? yAccessor : xAccessor;
|
|
80
|
+
const valueAccessor = horizontal ? xAccessor : yAccessor;
|
|
81
|
+
|
|
82
|
+
const rects: ReactNode[] = [];
|
|
83
|
+
|
|
84
|
+
comparisonEntries.forEach( entry => {
|
|
85
|
+
const { series, index, primaryKey } = entry;
|
|
86
|
+
const slotOffset = groupScale( primaryKey );
|
|
87
|
+
if ( slotOffset == null || ! Number.isFinite( slotOffset ) ) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const { barStyles } = getElementStyles( { data: series, index } );
|
|
92
|
+
const opacity = barStyles?.opacity ?? DEFAULT_COMPARISON_OPACITY; // safety net; CompleteChartTheme guarantees this value
|
|
93
|
+
const widthFactor = barStyles?.widthFactor ?? DEFAULT_COMPARISON_WIDTH_FACTOR;
|
|
94
|
+
// Fill is the paired primary's pattern (when patterns are on) or its resolved color.
|
|
95
|
+
const fill = resolveFill( entry );
|
|
96
|
+
// The shadow is `widthFactor` × the (narrowed) primary slot. bar-chart.tsx narrows the
|
|
97
|
+
// primary bars by widening the group padding so this ratio holds with real geometry.
|
|
98
|
+
|
|
99
|
+
( series.data as DataPointDate[] ).forEach( ( datum, i ) => {
|
|
100
|
+
const bandPosition = Number( bandScale( bandAccessor( datum ) as never ) );
|
|
101
|
+
const valuePosition = Number( valueScale( Number( valueAccessor( datum ) ) as never ) );
|
|
102
|
+
|
|
103
|
+
if ( ! Number.isFinite( bandPosition ) || ! Number.isFinite( valuePosition ) ) {
|
|
104
|
+
if ( process.env.NODE_ENV !== 'production' && ! Number.isFinite( bandPosition ) ) {
|
|
105
|
+
// eslint-disable-next-line no-console
|
|
106
|
+
console.warn(
|
|
107
|
+
`[Charts] ComparisonBars: datum key "${ String(
|
|
108
|
+
bandAccessor( datum )
|
|
109
|
+
) }" did not match any primary category. Shadow will not be rendered. ` +
|
|
110
|
+
'Ensure comparison series data uses the same label/date keys as the primary series.'
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const rect = computeComparisonRect( {
|
|
117
|
+
horizontal,
|
|
118
|
+
bandPosition,
|
|
119
|
+
slotOffset: slotOffset as number,
|
|
120
|
+
slotThickness,
|
|
121
|
+
valuePosition,
|
|
122
|
+
baseline,
|
|
123
|
+
widthFactor,
|
|
124
|
+
} );
|
|
125
|
+
|
|
126
|
+
rects.push(
|
|
127
|
+
<rect
|
|
128
|
+
key={ `${ index }-${ i }` }
|
|
129
|
+
data-testid={ `bar-chart-comparison-${ index }-${ i }` }
|
|
130
|
+
x={ rect.x }
|
|
131
|
+
y={ rect.y }
|
|
132
|
+
width={ rect.width }
|
|
133
|
+
height={ rect.height }
|
|
134
|
+
fill={ fill }
|
|
135
|
+
opacity={ opacity }
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
} );
|
|
139
|
+
} );
|
|
140
|
+
|
|
141
|
+
if ( rects.length === 0 ) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<g
|
|
147
|
+
className="bar-chart__comparison-bars"
|
|
148
|
+
pointerEvents="none"
|
|
149
|
+
aria-hidden="true"
|
|
150
|
+
data-testid="bar-chart-comparison-bars"
|
|
151
|
+
>
|
|
152
|
+
{ rects }
|
|
153
|
+
</g>
|
|
154
|
+
);
|
|
155
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Shared comparison-mode tuning constants. These drive the geometry of the
|
|
2
|
+
// translucent "shadow" bars and how the primary bars are narrowed to sit in front of them.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How much wider the comparison shadow is than the primary bar. Fallback used when the
|
|
6
|
+
* theme value (`barChart.barStyles.comparison.widthFactor`) is absent. Also drives the
|
|
7
|
+
* primary narrowing — the primary stays `1 / widthFactor` of the shadow.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_COMPARISON_WIDTH_FACTOR = 1.5;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Opacity of the comparison shadow. Fallback used when the theme value
|
|
13
|
+
* (`barChart.barStyles.comparison.opacity`) is absent.
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULT_COMPARISON_OPACITY = 0.5;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fraction of each per-series step left as a gap between bars within a single tick.
|
|
19
|
+
* Larger = more space between adjacent series; the shadow spans `1 - COMPARISON_INNER_GAP` of the step.
|
|
20
|
+
*/
|
|
21
|
+
export const COMPARISON_INNER_GAP = 0.1;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Upper clamp on the computed group padding, so bars can never collapse to zero width
|
|
25
|
+
* even at very large `widthFactor` values.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_GROUP_PADDING = 0.9;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Factor applied to the category band's `paddingInner` in comparison mode to tighten the
|
|
31
|
+
* gap between ticks. `0.75` = a 25% reduction of the tick-gap padding.
|
|
32
|
+
*/
|
|
33
|
+
export const COMPARISON_TICK_GAP_FACTOR = 0.75;
|
|
@@ -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';
|