@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/index.cjs +347 -61
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +6 -4
  5. package/dist/index.d.cts +16 -0
  6. package/dist/index.d.ts +16 -0
  7. package/dist/index.js +348 -62
  8. package/dist/index.js.map +1 -1
  9. package/package.json +2 -2
  10. package/src/charts/bar-chart/bar-chart.tsx +210 -49
  11. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +70 -0
  12. package/src/charts/bar-chart/private/comparison-bars.tsx +155 -0
  13. package/src/charts/bar-chart/private/comparison-constants.ts +33 -0
  14. package/src/charts/bar-chart/private/index.ts +9 -0
  15. package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +47 -0
  16. package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +183 -0
  17. package/src/charts/bar-chart/private/use-bar-chart-options.ts +49 -5
  18. package/src/charts/bar-chart/test/bar-chart.test.tsx +329 -1
  19. package/src/charts/geo-chart/geo-chart.tsx +5 -9
  20. package/src/charts/pie-chart/pie-chart.module.scss +0 -4
  21. package/src/charts/pie-chart/pie-chart.tsx +3 -8
  22. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +0 -5
  23. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +3 -8
  24. package/src/charts/private/center/center.module.scss +4 -0
  25. package/src/charts/private/center/center.tsx +33 -0
  26. package/src/charts/private/center/index.ts +2 -0
  27. package/src/charts/private/center/test/center.test.tsx +60 -0
  28. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +0 -2
  29. package/src/charts/private/svg-empty-state/svg-empty-state.tsx +2 -4
  30. package/src/providers/chart-context/global-charts-provider.tsx +2 -0
  31. package/src/providers/chart-context/test/chart-context.test.tsx +13 -1
  32. package/src/providers/chart-context/themes.ts +8 -0
  33. package/src/providers/chart-context/types.ts +2 -0
  34. package/src/types.ts +16 -0
  35. package/src/utils/get-styles.ts +36 -13
  36. package/src/utils/index.ts +6 -1
  37. 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.7.0",
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.4",
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 { 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';
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, ...data.map( series => series.data?.length || 0 ) ) * data.length;
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
- { tooltipData?.nearestDatum?.key }
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
- // Calculate which bar should be highlighted based on selectedIndex
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 maxDataPoints = Math.max( ...data.map( s => s.data.length ) );
259
- const dataPointIndex = Math.floor( selectedIndex / data.length );
260
- const seriesIndex = selectedIndex % data.length;
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 >= data.length ) {
416
+ if ( dataPointIndex >= maxDataPoints || seriesIndex >= primaryCount ) {
264
417
  return '';
265
418
  }
266
419
 
267
- const seriesData = data[ seriesIndex ];
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, data, chartId ] );
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={ chartOptions.xScale }
390
- yScale={ chartOptions.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
- <BarGroup padding={ chartOptions.barGroup.padding }>
438
- { seriesWithVisibility.map( ( { series: seriesData, index, isVisible } ) => {
439
- // Skip rendering invisible series
440
- if ( ! isVisible ) {
441
- return null;
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
- 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
- } ) }
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={ data }
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';