@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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": {
|
|
@@ -65,23 +65,23 @@
|
|
|
65
65
|
"dependencies": {
|
|
66
66
|
"@automattic/number-formatters": "^1.2.4",
|
|
67
67
|
"@babel/runtime": "7.29.7",
|
|
68
|
-
"@react-spring/web": "
|
|
69
|
-
"@visx/annotation": "^
|
|
70
|
-
"@visx/axis": "^
|
|
71
|
-
"@visx/curve": "^
|
|
72
|
-
"@visx/event": "^
|
|
73
|
-
"@visx/gradient": "^
|
|
74
|
-
"@visx/grid": "^
|
|
75
|
-
"@visx/group": "^
|
|
76
|
-
"@visx/legend": "^
|
|
77
|
-
"@visx/pattern": "^
|
|
78
|
-
"@visx/responsive": "^
|
|
79
|
-
"@visx/scale": "^
|
|
80
|
-
"@visx/shape": "^
|
|
81
|
-
"@visx/text": "^
|
|
82
|
-
"@visx/tooltip": "^
|
|
83
|
-
"@visx/vendor": "^
|
|
84
|
-
"@visx/xychart": "^
|
|
68
|
+
"@react-spring/web": "10.1.1",
|
|
69
|
+
"@visx/annotation": "^4.0.0",
|
|
70
|
+
"@visx/axis": "^4.0.0",
|
|
71
|
+
"@visx/curve": "^4.0.0",
|
|
72
|
+
"@visx/event": "^4.0.0",
|
|
73
|
+
"@visx/gradient": "^4.0.0",
|
|
74
|
+
"@visx/grid": "^4.0.0",
|
|
75
|
+
"@visx/group": "^4.0.0",
|
|
76
|
+
"@visx/legend": "^4.0.0",
|
|
77
|
+
"@visx/pattern": "^4.0.0",
|
|
78
|
+
"@visx/responsive": "^4.0.0",
|
|
79
|
+
"@visx/scale": "^4.0.0",
|
|
80
|
+
"@visx/shape": "^4.0.0",
|
|
81
|
+
"@visx/text": "^4.0.0",
|
|
82
|
+
"@visx/tooltip": "^4.0.0",
|
|
83
|
+
"@visx/vendor": "^4.0.0",
|
|
84
|
+
"@visx/xychart": "^4.0.0",
|
|
85
85
|
"@wordpress/i18n": "^6.0.0",
|
|
86
86
|
"@wordpress/icons": "^13.0.0",
|
|
87
87
|
"@wordpress/theme": "0.15.1",
|
|
@@ -96,6 +96,7 @@
|
|
|
96
96
|
},
|
|
97
97
|
"devDependencies": {
|
|
98
98
|
"@automattic/jetpack-webpack-config": "workspace:*",
|
|
99
|
+
"@babel/core": "^7.29.7",
|
|
99
100
|
"@storybook/addon-docs": "10.3.6",
|
|
100
101
|
"@storybook/react": "10.3.6",
|
|
101
102
|
"@testing-library/dom": "^10.0.0",
|
|
@@ -107,7 +108,7 @@
|
|
|
107
108
|
"@types/react": "18.3.28",
|
|
108
109
|
"@types/react-dom": "18.3.7",
|
|
109
110
|
"@typescript/native-preview": "7.0.0-dev.20260225.1",
|
|
110
|
-
"@visx/glyph": "
|
|
111
|
+
"@visx/glyph": "4.0.0",
|
|
111
112
|
"@wordpress/components": "35.0.1",
|
|
112
113
|
"@wordpress/element": "8.0.1",
|
|
113
114
|
"@wordpress/private-apis": "1.48.1",
|
|
@@ -124,8 +125,8 @@
|
|
|
124
125
|
"typescript": "5.9.3"
|
|
125
126
|
},
|
|
126
127
|
"peerDependencies": {
|
|
127
|
-
"react": "^
|
|
128
|
-
"react-dom": "^
|
|
128
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
129
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
129
130
|
},
|
|
130
131
|
"engines": {
|
|
131
132
|
"node": ">=20.11.0"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BaseChartProps, DataPointDate, SeriesData } from '../../types';
|
|
2
|
+
import type { RenderTooltipParams } from '../../visx/types';
|
|
2
3
|
import type { CurveType } from '../line-chart/types';
|
|
3
|
-
import type { RenderTooltipParams } from '@visx/xychart/lib/components/Tooltip';
|
|
4
4
|
import type { ReactNode } from 'react';
|
|
5
5
|
|
|
6
6
|
export interface AreaChartProps extends BaseChartProps< SeriesData[] > {
|
|
@@ -27,10 +27,19 @@ 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';
|
|
41
|
+
import type { RenderTooltipParams } from '../../visx/types';
|
|
32
42
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
33
|
-
import type { RenderTooltipParams } from '@visx/xychart/lib/components/Tooltip';
|
|
34
43
|
import type { FC, ReactNode, ComponentType } from 'react';
|
|
35
44
|
|
|
36
45
|
export interface BarChartProps extends BaseChartProps< SeriesData[] > {
|
|
@@ -128,8 +137,12 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
128
137
|
const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >( undefined );
|
|
129
138
|
const [ isNavigating, setIsNavigating ] = useState( false );
|
|
130
139
|
|
|
140
|
+
// Comparison series have no .visx-bar elements; count only primary series so
|
|
141
|
+
// keyboard navigation doesn't cycle phantom indices into comparison-only slots.
|
|
142
|
+
const primarySeriesForNav = dataWithVisibleZeros.filter( s => s.options?.type !== 'comparison' );
|
|
131
143
|
const totalPoints =
|
|
132
|
-
Math.max( 0, ...
|
|
144
|
+
Math.max( 0, ...primarySeriesForNav.map( s => s.data?.length || 0 ) ) *
|
|
145
|
+
primarySeriesForNav.length;
|
|
133
146
|
|
|
134
147
|
// Use the keyboard navigation hook
|
|
135
148
|
const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
|
|
@@ -164,6 +177,92 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
164
177
|
return seriesWithVisibility.every( ( { isVisible } ) => ! isVisible );
|
|
165
178
|
}, [ seriesWithVisibility ] );
|
|
166
179
|
|
|
180
|
+
// Derive primary vs comparison entries for comparison mode support.
|
|
181
|
+
const primaryEntries = useMemo(
|
|
182
|
+
() =>
|
|
183
|
+
seriesWithVisibility.filter(
|
|
184
|
+
( { isVisible, series } ) => isVisible && series.options?.type !== 'comparison'
|
|
185
|
+
),
|
|
186
|
+
[ seriesWithVisibility ]
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const primaryKeys = useMemo(
|
|
190
|
+
() => primaryEntries.map( ( { series } ) => series.label ),
|
|
191
|
+
[ primaryEntries ]
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const comparisonEntries = useMemo( () => {
|
|
195
|
+
const primaryByGroup = new Map< string | undefined, { label: string; index: number } >(
|
|
196
|
+
primaryEntries.map( ( { series, index } ) => [
|
|
197
|
+
series.group,
|
|
198
|
+
{ label: series.label, index },
|
|
199
|
+
] )
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const entries: ComparisonSeriesEntry[] = [];
|
|
203
|
+
seriesWithVisibility.forEach( ( { series, index, isVisible } ) => {
|
|
204
|
+
if ( ! isVisible || series.options?.type !== 'comparison' ) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const primary =
|
|
208
|
+
primaryByGroup.get( series.group ) ??
|
|
209
|
+
( primaryEntries.length === 1
|
|
210
|
+
? { label: primaryEntries[ 0 ].series.label, index: primaryEntries[ 0 ].index }
|
|
211
|
+
: undefined );
|
|
212
|
+
if ( ! primary || ! primaryKeys.includes( primary.label ) ) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
entries.push( { series, index, primaryKey: primary.label, primaryIndex: primary.index } );
|
|
216
|
+
} );
|
|
217
|
+
return entries;
|
|
218
|
+
}, [ seriesWithVisibility, primaryEntries, primaryKeys ] );
|
|
219
|
+
|
|
220
|
+
// Comparison widthFactor (how much wider the shadow is than the primary) drives both the
|
|
221
|
+
// shadow width (in ComparisonBars) and the primary narrowing.
|
|
222
|
+
const comparisonWidthFactor = useMemo( () => {
|
|
223
|
+
if ( comparisonEntries.length === 0 ) return undefined;
|
|
224
|
+
return (
|
|
225
|
+
getElementStyles( {
|
|
226
|
+
data: comparisonEntries[ 0 ].series,
|
|
227
|
+
index: comparisonEntries[ 0 ].index,
|
|
228
|
+
} ).barStyles?.widthFactor ?? DEFAULT_COMPARISON_WIDTH_FACTOR
|
|
229
|
+
);
|
|
230
|
+
}, [ comparisonEntries, getElementStyles ] );
|
|
231
|
+
|
|
232
|
+
// Narrow the primary bars by widening the visx group padding — a real geometry change, so
|
|
233
|
+
// pattern fills and borders are not distorted (unlike a CSS transform/scale). The padding is
|
|
234
|
+
// set so the comparison shadow (drawn at slot × widthFactor) fills all but a small gap of each
|
|
235
|
+
// per-series step, leaving a small gap between series within a tick (the larger gap between
|
|
236
|
+
// ticks comes from the category band's own padding). Because shadow = step × (1 - p) ×
|
|
237
|
+
// widthFactor, choosing p = 1 - (1 - innerGap)/widthFactor makes the shadow span (1 - innerGap)
|
|
238
|
+
// of the step; the primary stays 1/widthFactor of the shadow.
|
|
239
|
+
const groupPadding = useMemo( () => {
|
|
240
|
+
const basePadding = chartOptions.barGroup.padding;
|
|
241
|
+
if ( ! comparisonWidthFactor || comparisonWidthFactor <= 1 ) {
|
|
242
|
+
return basePadding;
|
|
243
|
+
}
|
|
244
|
+
const p = 1 - ( 1 - COMPARISON_INNER_GAP ) / comparisonWidthFactor;
|
|
245
|
+
return Math.min( Math.max( p, basePadding ), MAX_GROUP_PADDING );
|
|
246
|
+
}, [ chartOptions.barGroup.padding, comparisonWidthFactor ] );
|
|
247
|
+
|
|
248
|
+
// In comparison mode, tighten the gap between ticks by reducing the category band's inner
|
|
249
|
+
// padding (the value axis is left untouched). COMPARISON_TICK_GAP_FACTOR is the multiplier.
|
|
250
|
+
const { xScale, yScale } = useMemo( () => {
|
|
251
|
+
if ( comparisonEntries.length === 0 ) {
|
|
252
|
+
return { xScale: chartOptions.xScale, yScale: chartOptions.yScale };
|
|
253
|
+
}
|
|
254
|
+
const tighten = < T extends object >( scale: T ): T =>
|
|
255
|
+
( {
|
|
256
|
+
...scale,
|
|
257
|
+
paddingInner:
|
|
258
|
+
( ( scale as { paddingInner?: number } ).paddingInner ?? BASE_BAND_PADDING_INNER ) *
|
|
259
|
+
COMPARISON_TICK_GAP_FACTOR,
|
|
260
|
+
} ) as T;
|
|
261
|
+
return horizontal
|
|
262
|
+
? { xScale: chartOptions.xScale, yScale: tighten( chartOptions.yScale ) }
|
|
263
|
+
: { xScale: tighten( chartOptions.xScale ), yScale: chartOptions.yScale };
|
|
264
|
+
}, [ comparisonEntries.length, chartOptions.xScale, chartOptions.yScale, horizontal ] );
|
|
265
|
+
|
|
167
266
|
const getBarBackground = useCallback(
|
|
168
267
|
( index: number ) => () =>
|
|
169
268
|
withPatterns
|
|
@@ -172,25 +271,67 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
172
271
|
[ withPatterns, getElementStyles, dataSorted, chartId ]
|
|
173
272
|
);
|
|
174
273
|
|
|
274
|
+
// Comparison shadow fill: when patterns are on, reuse the paired primary's pattern so the
|
|
275
|
+
// shadow reads as the same series; otherwise use the comparison series' resolved color.
|
|
276
|
+
const resolveComparisonFill = useCallback(
|
|
277
|
+
( entry: ComparisonSeriesEntry ) =>
|
|
278
|
+
withPatterns
|
|
279
|
+
? `url(#${ getPatternId( chartId, entry.primaryIndex ) })`
|
|
280
|
+
: getElementStyles( { data: entry.series, index: entry.index } ).color,
|
|
281
|
+
[ withPatterns, chartId, getElementStyles ]
|
|
282
|
+
);
|
|
283
|
+
|
|
175
284
|
const renderDefaultTooltip = useCallback(
|
|
176
285
|
( { tooltipData }: RenderTooltipParams< DataPointDate > ) => {
|
|
177
286
|
const nearestDatum = tooltipData?.nearestDatum?.datum;
|
|
178
287
|
if ( ! nearestDatum ) return null;
|
|
179
288
|
|
|
289
|
+
const primaryKey = tooltipData?.nearestDatum?.key;
|
|
290
|
+
const categoryLabel = chartOptions.tooltip.labelFormatter(
|
|
291
|
+
nearestDatum.label || ( nearestDatum.date ? nearestDatum.date.getTime() : 0 ),
|
|
292
|
+
0,
|
|
293
|
+
[]
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// Find the comparison value paired with the hovered primary series (same group)
|
|
297
|
+
// at the same category, so the tooltip can show both periods at once.
|
|
298
|
+
const comparisonEntry = comparisonEntries.find( entry => entry.primaryKey === primaryKey );
|
|
299
|
+
const comparisonDatum = comparisonEntry?.series.data.find( point => {
|
|
300
|
+
const p = point as DataPointDate;
|
|
301
|
+
return nearestDatum.label != null
|
|
302
|
+
? p.label === nearestDatum.label
|
|
303
|
+
: !! nearestDatum.date && !! p.date && p.date.getTime() === nearestDatum.date.getTime();
|
|
304
|
+
} ) as DataPointDate | undefined;
|
|
305
|
+
|
|
306
|
+
// With a paired comparison value, show the category as the header and one row
|
|
307
|
+
// per period (primary + comparison).
|
|
308
|
+
if ( comparisonEntry && comparisonDatum && comparisonDatum.value != null ) {
|
|
309
|
+
return (
|
|
310
|
+
<div className={ styles[ 'bar-chart__tooltip' ] }>
|
|
311
|
+
<div className={ styles[ 'bar-chart__tooltip-header' ] }>{ categoryLabel }</div>
|
|
312
|
+
<div className={ styles[ 'bar-chart__tooltip-row' ] }>
|
|
313
|
+
<span className={ styles[ 'bar-chart__tooltip-label' ] }>{ primaryKey }:</span>
|
|
314
|
+
<span className={ styles[ 'bar-chart__tooltip-value' ] }>
|
|
315
|
+
{ formatNumber( nearestDatum.value as number ) }
|
|
316
|
+
</span>
|
|
317
|
+
</div>
|
|
318
|
+
<div className={ styles[ 'bar-chart__tooltip-row' ] }>
|
|
319
|
+
<span className={ styles[ 'bar-chart__tooltip-label' ] }>
|
|
320
|
+
{ comparisonEntry.series.label }:
|
|
321
|
+
</span>
|
|
322
|
+
<span className={ styles[ 'bar-chart__tooltip-value' ] }>
|
|
323
|
+
{ formatNumber( comparisonDatum.value as number ) }
|
|
324
|
+
</span>
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
180
330
|
return (
|
|
181
331
|
<div className={ styles[ 'bar-chart__tooltip' ] }>
|
|
182
|
-
<div className={ styles[ 'bar-chart__tooltip-header' ] }>
|
|
183
|
-
{ tooltipData?.nearestDatum?.key }
|
|
184
|
-
</div>
|
|
332
|
+
<div className={ styles[ 'bar-chart__tooltip-header' ] }>{ primaryKey }</div>
|
|
185
333
|
<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>
|
|
334
|
+
<span className={ styles[ 'bar-chart__tooltip-label' ] }>{ categoryLabel }:</span>
|
|
194
335
|
<span className={ styles[ 'bar-chart__tooltip-value' ] }>
|
|
195
336
|
{ formatNumber( nearestDatum.value as number ) }
|
|
196
337
|
</span>
|
|
@@ -198,7 +339,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
198
339
|
</div>
|
|
199
340
|
);
|
|
200
341
|
},
|
|
201
|
-
[ chartOptions.tooltip ]
|
|
342
|
+
[ chartOptions.tooltip, comparisonEntries ]
|
|
202
343
|
);
|
|
203
344
|
|
|
204
345
|
const renderPattern = useCallback(
|
|
@@ -240,8 +381,11 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
240
381
|
const createPatternBorderStyle = useCallback(
|
|
241
382
|
( index: number, color: string ) => {
|
|
242
383
|
const patternId = getPatternId( chartId, index );
|
|
384
|
+
// Border the primary bars and any comparison shadow reusing the same pattern,
|
|
385
|
+
// so a patterned shadow gets the same outline as its primary bar.
|
|
243
386
|
return `
|
|
244
|
-
.visx-bar[fill="url(#${ patternId })"]
|
|
387
|
+
.visx-bar[fill="url(#${ patternId })"],
|
|
388
|
+
.bar-chart__comparison-bars rect[fill="url(#${ patternId })"] {
|
|
245
389
|
stroke: ${ color };
|
|
246
390
|
stroke-width: 1;
|
|
247
391
|
}
|
|
@@ -253,19 +397,21 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
253
397
|
const createKeyboardHighlightStyle = useCallback( () => {
|
|
254
398
|
if ( selectedIndex === undefined ) return '';
|
|
255
399
|
|
|
256
|
-
//
|
|
400
|
+
// Use only primary entries — comparison series have no .visx-bar elements so
|
|
401
|
+
// their indices must not appear in the nth-child selector.
|
|
257
402
|
// Pattern: [series1[0], series2[0], series3[0], series1[1], series2[1], series3[1], ...]
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
const
|
|
403
|
+
const primaryCount = primaryEntries.length;
|
|
404
|
+
const maxDataPoints = Math.max( ...primaryEntries.map( e => e.series.data.length ) );
|
|
405
|
+
const dataPointIndex = Math.floor( selectedIndex / primaryCount );
|
|
406
|
+
const seriesIndex = selectedIndex % primaryCount;
|
|
261
407
|
|
|
262
408
|
// Only highlight if we're within valid bounds
|
|
263
|
-
if ( dataPointIndex >= maxDataPoints || seriesIndex >=
|
|
409
|
+
if ( dataPointIndex >= maxDataPoints || seriesIndex >= primaryCount ) {
|
|
264
410
|
return '';
|
|
265
411
|
}
|
|
266
412
|
|
|
267
|
-
const seriesData =
|
|
268
|
-
if ( dataPointIndex >= seriesData.data.length ) {
|
|
413
|
+
const seriesData = primaryEntries[ seriesIndex ]?.series;
|
|
414
|
+
if ( ! seriesData || dataPointIndex >= seriesData.data.length ) {
|
|
269
415
|
return '';
|
|
270
416
|
}
|
|
271
417
|
|
|
@@ -286,7 +432,7 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
286
432
|
`;
|
|
287
433
|
|
|
288
434
|
return generatedStyles;
|
|
289
|
-
}, [ selectedIndex,
|
|
435
|
+
}, [ selectedIndex, primaryEntries, chartId ] );
|
|
290
436
|
|
|
291
437
|
// Validate data first
|
|
292
438
|
const error = validateData( dataSorted );
|
|
@@ -386,8 +532,8 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
386
532
|
...defaultMargin,
|
|
387
533
|
...margin,
|
|
388
534
|
} }
|
|
389
|
-
xScale={
|
|
390
|
-
yScale={
|
|
535
|
+
xScale={ xScale }
|
|
536
|
+
yScale={ yScale }
|
|
391
537
|
horizontal={ horizontal }
|
|
392
538
|
pointerEventsDataKey="nearest"
|
|
393
539
|
>
|
|
@@ -434,24 +580,32 @@ const BarChartInternal: FC< BarChartProps > = ( {
|
|
|
434
580
|
</SvgEmptyState>
|
|
435
581
|
) : null }
|
|
436
582
|
|
|
437
|
-
<
|
|
438
|
-
{
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
583
|
+
<ComparisonBars
|
|
584
|
+
comparisonEntries={ comparisonEntries }
|
|
585
|
+
primaryKeys={ primaryKeys }
|
|
586
|
+
groupPadding={ groupPadding }
|
|
587
|
+
horizontal={ horizontal }
|
|
588
|
+
xAccessor={ chartOptions.accessors.xAccessor }
|
|
589
|
+
yAccessor={
|
|
590
|
+
chartOptions.accessors.yAccessor as (
|
|
591
|
+
d: DataPointDate
|
|
592
|
+
) => number | undefined
|
|
593
|
+
}
|
|
594
|
+
getElementStyles={ getElementStyles }
|
|
595
|
+
resolveFill={ resolveComparisonFill }
|
|
596
|
+
/>
|
|
443
597
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
598
|
+
<BarGroup padding={ groupPadding }>
|
|
599
|
+
{ primaryEntries.map( ( { series: seriesData, index } ) => (
|
|
600
|
+
<BarSeries
|
|
601
|
+
key={ seriesData?.label }
|
|
602
|
+
dataKey={ seriesData?.label }
|
|
603
|
+
data={ seriesData.data as DataPointDate[] }
|
|
604
|
+
yAccessor={ chartOptions.accessors.yAccessor }
|
|
605
|
+
xAccessor={ chartOptions.accessors.xAccessor }
|
|
606
|
+
colorAccessor={ getBarBackground( index ) }
|
|
607
|
+
/>
|
|
608
|
+
) ) }
|
|
455
609
|
</BarGroup>
|
|
456
610
|
|
|
457
611
|
<Axis { ...chartOptions.axis.x } />
|
|
@@ -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,154 @@
|
|
|
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
|
+
data-testid="bar-chart-comparison-bars"
|
|
150
|
+
>
|
|
151
|
+
{ rects }
|
|
152
|
+
</g>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
@@ -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;
|