@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +2 -2
  3. package/dist/index.cjs +380 -60
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +12 -7
  6. package/dist/index.d.cts +119 -30
  7. package/dist/index.d.ts +122 -33
  8. package/dist/index.js +380 -60
  9. package/dist/index.js.map +1 -1
  10. package/package.json +22 -21
  11. package/src/charts/area-chart/types.ts +1 -1
  12. package/src/charts/bar-chart/bar-chart.tsx +197 -43
  13. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +70 -0
  14. package/src/charts/bar-chart/private/comparison-bars.tsx +154 -0
  15. package/src/charts/bar-chart/private/comparison-constants.ts +33 -0
  16. package/src/charts/bar-chart/private/index.ts +9 -0
  17. package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +47 -0
  18. package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +183 -0
  19. package/src/charts/bar-chart/private/use-bar-chart-options.ts +46 -5
  20. package/src/charts/bar-chart/test/bar-chart.test.tsx +191 -1
  21. package/src/charts/geo-chart/geo-chart.tsx +8 -10
  22. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +27 -14
  23. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +7 -4
  24. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +51 -4
  25. package/src/charts/line-chart/line-chart.tsx +1 -1
  26. package/src/charts/line-chart/private/line-chart-annotation-label-popover.tsx +18 -2
  27. package/src/charts/line-chart/private/line-chart-annotation.tsx +1 -1
  28. package/src/charts/line-chart/types.ts +1 -1
  29. package/src/charts/pie-chart/pie-chart.module.scss +0 -4
  30. package/src/charts/pie-chart/pie-chart.tsx +3 -8
  31. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +0 -5
  32. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +5 -10
  33. package/src/charts/private/center/center.module.scss +4 -0
  34. package/src/charts/private/center/center.tsx +33 -0
  35. package/src/charts/private/center/index.ts +2 -0
  36. package/src/charts/private/center/test/center.test.tsx +60 -0
  37. package/src/charts/private/chart-layout/chart-layout.tsx +1 -2
  38. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +0 -2
  39. package/src/charts/private/svg-empty-state/svg-empty-state.tsx +2 -4
  40. package/src/charts/private/x-zoom.tsx +2 -2
  41. package/src/components/legend/hooks/use-chart-legend-items.ts +6 -2
  42. package/src/components/legend/legend.tsx +1 -2
  43. package/src/components/legend/utils/label-transform-factory.ts +1 -1
  44. package/src/components/tooltip/accessible-tooltip.tsx +2 -6
  45. package/src/index.ts +6 -5
  46. package/src/providers/chart-context/global-charts-provider.tsx +2 -0
  47. package/src/providers/chart-context/test/chart-context.test.tsx +13 -1
  48. package/src/providers/chart-context/themes.ts +9 -1
  49. package/src/providers/chart-context/types.ts +9 -2
  50. package/src/types.ts +120 -11
  51. package/src/utils/get-styles.ts +36 -14
  52. package/src/utils/index.ts +6 -1
  53. package/src/utils/test/get-styles.test.ts +47 -1
  54. 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.6.0",
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": "9.7.5",
69
- "@visx/annotation": "^3.12.0",
70
- "@visx/axis": "^3.12.0",
71
- "@visx/curve": "^3.12.0",
72
- "@visx/event": "^3.12.0",
73
- "@visx/gradient": "^3.12.0",
74
- "@visx/grid": "^3.12.0",
75
- "@visx/group": "^3.12.0",
76
- "@visx/legend": "^3.12.0",
77
- "@visx/pattern": "^3.12.0",
78
- "@visx/responsive": "^3.12.0",
79
- "@visx/scale": "^3.12.0",
80
- "@visx/shape": "^3.12.0",
81
- "@visx/text": "^3.12.0",
82
- "@visx/tooltip": "^3.12.0",
83
- "@visx/vendor": "^3.12.0",
84
- "@visx/xychart": "^3.12.0",
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": "3.12.0",
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": "^17.0.0 || ^18.0.0",
128
- "react-dom": "^17.0.0 || ^18.0.0"
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 { useBarChartOptions } from './private';
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, ...data.map( series => series.data?.length || 0 ) ) * data.length;
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
- // Calculate which bar should be highlighted based on selectedIndex
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 maxDataPoints = Math.max( ...data.map( s => s.data.length ) );
259
- const dataPointIndex = Math.floor( selectedIndex / data.length );
260
- const seriesIndex = selectedIndex % data.length;
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 >= data.length ) {
409
+ if ( dataPointIndex >= maxDataPoints || seriesIndex >= primaryCount ) {
264
410
  return '';
265
411
  }
266
412
 
267
- const seriesData = data[ seriesIndex ];
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, data, chartId ] );
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={ chartOptions.xScale }
390
- yScale={ chartOptions.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
- <BarGroup padding={ chartOptions.barGroup.padding }>
438
- { seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => {
439
- // Skip rendering invisible series
440
- if ( ! isVisible ) {
441
- return null;
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
- return (
445
- <BarSeries
446
- key={ seriesData?.label }
447
- dataKey={ seriesData?.label }
448
- data={ seriesData.data as DataPointDate[] }
449
- yAccessor={ chartOptions.accessors.yAccessor }
450
- xAccessor={ chartOptions.accessors.xAccessor }
451
- colorAccessor={ getBarBackground( index ) }
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;