@automattic/charts 4.11.1 → 4.12.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 +13 -0
  2. package/dist/index.cjs +856 -236
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +19 -13
  5. package/dist/index.d.cts +43 -4
  6. package/dist/index.d.ts +43 -4
  7. package/dist/index.js +855 -237
  8. package/dist/index.js.map +1 -1
  9. package/package.json +9 -9
  10. package/src/charts/bar-chart/bar-chart.tsx +134 -138
  11. package/src/charts/bar-chart/index.ts +3 -0
  12. package/src/charts/bar-chart/private/band-tooltip.tsx +41 -15
  13. package/src/charts/bar-chart/private/bar-layer.tsx +132 -0
  14. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +22 -34
  15. package/src/charts/bar-chart/private/comparison-bars.tsx +9 -18
  16. package/src/charts/bar-chart/private/comparison-constants.ts +4 -10
  17. package/src/charts/bar-chart/private/grouped-bars.ts +68 -0
  18. package/src/charts/bar-chart/private/index.ts +7 -1
  19. package/src/charts/bar-chart/private/overlap-bars.ts +183 -0
  20. package/src/charts/bar-chart/private/selected-bar-ring.tsx +84 -0
  21. package/src/charts/bar-chart/private/slot-tooltip-members.tsx +29 -0
  22. package/src/charts/bar-chart/private/slotted-bar-series.tsx +84 -0
  23. package/src/charts/bar-chart/private/test/comparison-bars-geometry.test.ts +4 -11
  24. package/src/charts/bar-chart/private/test/comparison-bars.test.tsx +2 -3
  25. package/src/charts/bar-chart/private/test/grouped-bars.test.ts +87 -0
  26. package/src/charts/bar-chart/private/test/overlap-bars.test.ts +159 -0
  27. package/src/charts/bar-chart/private/use-bar-stops.ts +89 -0
  28. package/src/charts/bar-chart/private/use-overlap-slots.ts +194 -0
  29. package/src/charts/bar-chart/private/value-domain.ts +0 -10
  30. package/src/charts/bar-chart/test/bar-chart-overlap.test.tsx +347 -0
  31. package/src/charts/bar-chart/test/bar-chart.test.tsx +66 -19
  32. package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +81 -0
  33. package/src/charts/bar-chart/types.ts +3 -0
  34. package/src/charts/bar-chart/use-bar-colors.ts +46 -0
  35. package/src/charts/bar-chart/use-bar-legend-items.ts +87 -0
  36. package/src/charts/line-chart/line-chart.tsx +22 -17
  37. package/src/charts/line-chart/test/line-chart.test.tsx +165 -1
  38. package/src/charts/pie-chart/pie-chart.tsx +7 -2
  39. package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +7 -2
  40. package/src/charts/private/pie-keyboard-navigation/index.ts +0 -1
  41. package/src/charts/private/{pie-keyboard-navigation/pie-keyboard-navigation.module.scss → selected-ring/selected-ring.module.scss} +11 -6
  42. package/src/charts/private/selected-ring/selected-ring.tsx +73 -0
  43. package/src/charts/private/{pie-keyboard-navigation/test/selected-segment-ring.test.ts → selected-ring/test/selected-ring.test.ts} +1 -1
  44. package/src/components/legend/hooks/test/use-chart-legend-items.test.tsx +20 -0
  45. package/src/components/legend/hooks/use-chart-legend-items.ts +27 -3
  46. package/src/components/tooltip/xy-chart-tooltip.tsx +3 -2
  47. package/src/index.ts +8 -2
  48. package/src/providers/chart-context/global-charts-provider.tsx +2 -0
  49. package/src/providers/chart-context/test/chart-context.test.tsx +0 -13
  50. package/src/providers/chart-context/themes.ts +0 -1
  51. package/src/providers/chart-context/types.ts +2 -0
  52. package/src/test-utils/selected-bar-ring.ts +14 -0
  53. package/src/types.ts +10 -2
  54. package/src/charts/private/pie-keyboard-navigation/selected-segment-ring.tsx +0 -47
@@ -0,0 +1,132 @@
1
+ import { BarGroup, BarSeries } from '@visx/xychart';
2
+ import ClassifiedBarSeries from './classified-bar-series';
3
+ import { SelectedBarRing } from './selected-bar-ring';
4
+ import SlottedBarSeries from './slotted-bar-series';
5
+ import type { SelectedBar } from './selected-bar-ring';
6
+ import type { OverlapSlots } from './use-overlap-slots';
7
+ import type { DataPointDate, SeriesData } from '../../../types';
8
+ import type { BarChartProps } from '../types';
9
+ import type { AxisScale } from '@visx/axis';
10
+ import type { SeriesProps } from '@visx/xychart';
11
+ import type { FC } from 'react';
12
+
13
+ type Accessors = Pick<
14
+ SeriesProps< AxisScale, AxisScale, DataPointDate >,
15
+ 'xAccessor' | 'yAccessor'
16
+ >;
17
+
18
+ type Props = Accessors & {
19
+ chartId: string;
20
+ overlapSlots: OverlapSlots | undefined;
21
+ selectedBar: SelectedBar | undefined;
22
+ primaryEntries: { series: SeriesData; index: number }[];
23
+ primaryKeys: string[];
24
+ getBarBackground: ( index: number ) => ( datum: DataPointDate ) => string;
25
+ barClassName: BarChartProps[ 'barClassName' ];
26
+ groupPadding: number;
27
+ horizontal: boolean;
28
+ };
29
+
30
+ /**
31
+ * Draws the bars in front of any comparison shadows, slotted for an overlap layout and grouped by visx otherwise, then the selected bar's ring.
32
+ *
33
+ * @param props - Component props.
34
+ * @param props.chartId - The chart's id, which keeps the ring's clip path unique on a page with several charts.
35
+ * @param props.overlapSlots - The overlap layout, or undefined for a grouped one.
36
+ * @param props.selectedBar - The bar or slot the keyboard has selected.
37
+ * @param props.primaryEntries - The visible primary series, for a grouped layout.
38
+ * @param props.primaryKeys - Their labels, in group order.
39
+ * @param props.getBarBackground - Reads a series' bar fill.
40
+ * @param props.barClassName - Adds classes to a bar.
41
+ * @param props.groupPadding - The padding between the bars of a category.
42
+ * @param props.horizontal - Whether the bars run horizontally.
43
+ * @param props.xAccessor - Reads a datum's x value.
44
+ * @param props.yAccessor - Reads a datum's y value.
45
+ * @return The bars.
46
+ */
47
+ export const BarLayer: FC< Props > = ( {
48
+ chartId,
49
+ overlapSlots,
50
+ selectedBar,
51
+ primaryEntries,
52
+ primaryKeys,
53
+ getBarBackground,
54
+ barClassName,
55
+ groupPadding,
56
+ horizontal,
57
+ xAccessor,
58
+ yAccessor,
59
+ } ) => {
60
+ let bars;
61
+ if ( overlapSlots ) {
62
+ bars = (
63
+ <g className="visx-bar-group">
64
+ { overlapSlots.entries.map( ( { series, index } ) => (
65
+ <SlottedBarSeries
66
+ key={ series.label }
67
+ dataKey={ series.label }
68
+ data={ series.data as DataPointDate[] }
69
+ xAccessor={ xAccessor }
70
+ yAccessor={ yAccessor }
71
+ colorAccessor={ getBarBackground( index ) }
72
+ barClassName={ barClassName }
73
+ slotKeys={ overlapSlots.slotKeys }
74
+ slotKey={ overlapSlots.slotOf( series.label ) }
75
+ thickness={ overlapSlots.thicknessOf( series.label ) }
76
+ groupPadding={ groupPadding }
77
+ />
78
+ ) ) }
79
+ </g>
80
+ );
81
+ } else if ( barClassName ) {
82
+ bars = (
83
+ <g className="visx-bar-group">
84
+ { primaryEntries.map( ( { series, index } ) => (
85
+ <ClassifiedBarSeries
86
+ key={ series.label }
87
+ dataKey={ series.label }
88
+ data={ series.data as DataPointDate[] }
89
+ xAccessor={ xAccessor }
90
+ yAccessor={ yAccessor }
91
+ colorAccessor={ getBarBackground( index ) }
92
+ barClassName={ barClassName }
93
+ primaryKeys={ primaryKeys }
94
+ groupPadding={ groupPadding }
95
+ />
96
+ ) ) }
97
+ </g>
98
+ );
99
+ } else {
100
+ bars = (
101
+ <BarGroup padding={ groupPadding }>
102
+ { primaryEntries.map( ( { series, index } ) => (
103
+ <BarSeries
104
+ key={ series.label }
105
+ dataKey={ series.label }
106
+ data={ series.data as DataPointDate[] }
107
+ xAccessor={ xAccessor }
108
+ yAccessor={ yAccessor }
109
+ colorAccessor={ getBarBackground( index ) }
110
+ />
111
+ ) ) }
112
+ </BarGroup>
113
+ );
114
+ }
115
+
116
+ return (
117
+ <>
118
+ { bars }
119
+ { selectedBar && (
120
+ <SelectedBarRing
121
+ chartId={ chartId }
122
+ selected={ selectedBar }
123
+ slotKeys={ overlapSlots?.slotKeys ?? primaryKeys }
124
+ groupPadding={ groupPadding }
125
+ horizontal={ horizontal }
126
+ xAccessor={ xAccessor }
127
+ yAccessor={ yAccessor as ( datum: DataPointDate ) => number | undefined }
128
+ />
129
+ ) }
130
+ </>
131
+ );
132
+ };
@@ -1,10 +1,9 @@
1
- export type ComparisonRect = { x: number; y: number; width: number; height: number };
1
+ export type SlotRect = { x: number; y: number; width: number; height: number };
2
2
 
3
3
  type ValueScale = ( ( v: number ) => number ) & { range: () => unknown[] };
4
4
 
5
5
  /**
6
- * Mirrors visx's getScaleBaseline so comparison shadows and primary bars
7
- * share the same baseline, including out-of-domain zero values.
6
+ * Mirrors visx's getScaleBaseline so bars drawn outside visx share the baseline of its bars, including out-of-domain zero values.
8
7
  *
9
8
  * @param {ValueScale} scale - The continuous value scale.
10
9
  * @return {number} The baseline output position in pixels.
@@ -23,45 +22,34 @@ export function getValueScaleBaseline( scale: ValueScale ): number {
23
22
  }
24
23
 
25
24
  /**
26
- * Compute the rect for a comparison "shadow" bar, centered on the paired
27
- * primary bar slot and scaled by `widthFactor`.
25
+ * Compute the rect that spans `valuePositions` along the value axis, centered on a slot and scaled by `widthFactor` across it.
28
26
  *
29
- * @param {object} params - Geometry inputs.
30
- * @param {boolean} params.horizontal - True for a horizontal bar chart, false for vertical.
31
- * @param {number} params.bandPosition - bandScale(category): start px of the category band.
32
- * @param {number} params.slotOffset - groupScale(primaryKey): offset of the primary slot within the band.
33
- * @param {number} params.slotThickness - groupScale.bandwidth(): primary bar thickness in px.
34
- * @param {number} params.valuePosition - valueScale(value): output px for the bar's data value.
35
- * @param {number} params.baseline - getValueScaleBaseline(valueScale): zero-line output px.
36
- * @param {number} params.widthFactor - Shadow thickness multiplier, e.g. 1.5 for 150% width.
37
- * @return {ComparisonRect} The {x, y, width, height} of the shadow rect.
27
+ * @param {object} params - Geometry inputs.
28
+ * @param {boolean} params.horizontal - True for a horizontal bar chart, false for vertical.
29
+ * @param {number} params.bandPosition - bandScale(category): start px of the category band.
30
+ * @param {number} params.slotOffset - groupScale(slotKey): offset of the slot within the band.
31
+ * @param {number} params.slotThickness - groupScale.bandwidth(): slot thickness in px.
32
+ * @param {number[]} params.valuePositions - Output px the rect must reach, such as the baseline and each bar's value.
33
+ * @param {number} params.widthFactor - Thickness multiplier, e.g. 0.5 for half the slot.
34
+ * @return {SlotRect} The {x, y, width, height} of the rect.
38
35
  */
39
- export function computeComparisonRect( params: {
36
+ export function computeSlotRect( params: {
40
37
  horizontal: boolean;
41
38
  bandPosition: number;
42
39
  slotOffset: number;
43
40
  slotThickness: number;
44
- valuePosition: number;
45
- baseline: number;
41
+ valuePositions: number[];
46
42
  widthFactor: number;
47
- } ): ComparisonRect {
48
- const {
49
- horizontal,
50
- bandPosition,
51
- slotOffset,
52
- slotThickness,
53
- valuePosition,
54
- baseline,
55
- widthFactor,
56
- } = params;
57
- const slotStart = bandPosition + slotOffset;
58
- const shadowThickness = slotThickness * widthFactor;
59
- const shadowStart = slotStart + slotThickness / 2 - shadowThickness / 2;
60
- const valueStart = Math.min( valuePosition, baseline );
61
- const valueLength = Math.abs( baseline - valuePosition );
43
+ } ): SlotRect {
44
+ const { horizontal, bandPosition, slotOffset, slotThickness, valuePositions, widthFactor } =
45
+ params;
46
+ const thickness = slotThickness * widthFactor;
47
+ const start = bandPosition + slotOffset + slotThickness / 2 - thickness / 2;
48
+ const valueStart = Math.min( ...valuePositions );
49
+ const valueLength = Math.max( ...valuePositions ) - valueStart;
62
50
 
63
51
  if ( horizontal ) {
64
- return { x: valueStart, y: shadowStart, width: valueLength, height: shadowThickness };
52
+ return { x: valueStart, y: start, width: valueLength, height: thickness };
65
53
  }
66
- return { x: shadowStart, y: valueStart, width: shadowThickness, height: valueLength };
54
+ return { x: start, y: valueStart, width: thickness, height: valueLength };
67
55
  }
@@ -1,11 +1,8 @@
1
1
  import { DataContext } from '@visx/xychart';
2
2
  import { useContext } from 'react';
3
3
  import { createGroupScale } from './band-scale';
4
- import { computeComparisonRect, getValueScaleBaseline } from './comparison-bars-geometry';
5
- import {
6
- DEFAULT_COMPARISON_OPACITY,
7
- DEFAULT_COMPARISON_WIDTH_FACTOR,
8
- } from './comparison-constants';
4
+ import { computeSlotRect, getValueScaleBaseline } from './comparison-bars-geometry';
5
+ import { DEFAULT_COMPARISON_WIDTH_FACTOR } from './comparison-constants';
9
6
  import type { BandScale } from './band-scale';
10
7
  import type { ElementStyles, GetElementStylesParams } from '../../../providers';
11
8
  import type { DataPointDate, SeriesData } from '../../../types';
@@ -21,8 +18,6 @@ export type ComparisonSeriesEntry = {
21
18
  series: SeriesData;
22
19
  index: number;
23
20
  primaryKey: string;
24
- /** dataSorted index of the paired primary series (used to share its pattern fill). */
25
- primaryIndex: number;
26
21
  };
27
22
 
28
23
  // Minimal shape we need from visx scales — avoids spreading `any` while
@@ -30,7 +25,7 @@ export type ComparisonSeriesEntry = {
30
25
  type AnyScale = ( ( input: unknown ) => number ) &
31
26
  Partial< Pick< BandScale, 'bandwidth' > > & { range: () => unknown[] };
32
27
 
33
- // Renders translucent "shadow" bars for comparison series behind their paired primary bars.
28
+ // Renders the bars of comparison series, wider and behind their paired primary bars.
34
29
  // IMPORTANT: each comparison datum's category key (label or date) must exactly match a key
35
30
  // used by the primary series — if it doesn't, bandScale() returns undefined and the shadow
36
31
  // is silently skipped. Ensure comparison data reuses the same label/date values as primary.
@@ -42,8 +37,8 @@ export const ComparisonBars: FC< {
42
37
  xAccessor: ( d: DataPointDate ) => string | number | Date | undefined;
43
38
  yAccessor: ( d: DataPointDate ) => number | undefined;
44
39
  getElementStyles: ( params: GetElementStylesParams ) => ElementStyles;
45
- /** Resolves the shadow fill — the paired primary's pattern when patterns are on, else a color. */
46
- resolveFill: ( entry: ComparisonSeriesEntry ) => string;
40
+ /** Resolves a comparison bar's fill: its pattern when patterns are on, else its datum's or its series' color. */
41
+ resolveFill: ( entry: ComparisonSeriesEntry, datum: DataPointDate ) => string;
47
42
  } > = ( {
48
43
  comparisonEntries,
49
44
  primaryKeys,
@@ -90,10 +85,7 @@ export const ComparisonBars: FC< {
90
85
  }
91
86
 
92
87
  const { barStyles } = getElementStyles( { data: series, index } );
93
- const opacity = barStyles?.opacity ?? DEFAULT_COMPARISON_OPACITY; // safety net; CompleteChartTheme guarantees this value
94
88
  const widthFactor = barStyles?.widthFactor ?? DEFAULT_COMPARISON_WIDTH_FACTOR;
95
- // Fill is the paired primary's pattern (when patterns are on) or its resolved color.
96
- const fill = resolveFill( entry );
97
89
  // The shadow is `widthFactor` × the (narrowed) primary slot. bar-chart.tsx narrows the
98
90
  // primary bars by widening the group padding so this ratio holds with real geometry.
99
91
 
@@ -114,13 +106,12 @@ export const ComparisonBars: FC< {
114
106
  return;
115
107
  }
116
108
 
117
- const rect = computeComparisonRect( {
109
+ const rect = computeSlotRect( {
118
110
  horizontal,
119
111
  bandPosition,
120
112
  slotOffset: slotOffset as number,
121
113
  slotThickness,
122
- valuePosition,
123
- baseline,
114
+ valuePositions: [ baseline, valuePosition ],
124
115
  widthFactor,
125
116
  } );
126
117
 
@@ -133,8 +124,8 @@ export const ComparisonBars: FC< {
133
124
  y={ rect.y }
134
125
  width={ rect.width }
135
126
  height={ rect.height }
136
- fill={ fill }
137
- opacity={ opacity }
127
+ fill={ resolveFill( entry, datum ) }
128
+ opacity={ barStyles?.opacity }
138
129
  />
139
130
  );
140
131
  } );
@@ -1,22 +1,16 @@
1
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.
2
+ // comparison bars and how the primary bars are narrowed to sit in front of them.
3
3
 
4
4
  /**
5
- * How much wider the comparison shadow is than the primary bar. Fallback used when the
5
+ * How much wider the comparison bar is than the primary bar. Fallback used when the
6
6
  * theme value (`barChart.barStyles.comparison.widthFactor`) is absent. Also drives the
7
- * primary narrowing — the primary stays `1 / widthFactor` of the shadow.
7
+ * primary narrowing — the primary stays `1 / widthFactor` of the comparison.
8
8
  */
9
9
  export const DEFAULT_COMPARISON_WIDTH_FACTOR = 1.5;
10
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
11
  /**
18
12
  * 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.
13
+ * Larger = more space between adjacent series; the comparison bar spans `1 - COMPARISON_INNER_GAP` of the step.
20
14
  */
21
15
  export const COMPARISON_INNER_GAP = 0.1;
22
16
 
@@ -0,0 +1,68 @@
1
+ import { isValidHexColor } from '../../../utils/color-utils';
2
+ import { shadeWithContrast, tintWithContrast } from './overlap-bars';
3
+ import type { SeriesData } from '../../../types';
4
+
5
+ /**
6
+ * Pairs each comparison series with the primary of its `group`, or with the only primary.
7
+ *
8
+ * @param series - The chart's series.
9
+ * @return The index of each paired comparison's primary, keyed by the comparison's index.
10
+ */
11
+ function getComparisonPrimaries( series: SeriesData[] ): Map< number, number > {
12
+ const primaries = series.flatMap( ( item, index ) =>
13
+ item.options?.type === 'comparison' ? [] : [ { group: item.group, index } ]
14
+ );
15
+ const primaryByGroup = new Map( primaries.map( ( { group, index } ) => [ group, index ] ) );
16
+ const onlyPrimary = primaries.length === 1 ? primaries[ 0 ].index : undefined;
17
+ const pairs = new Map< number, number >();
18
+
19
+ series.forEach( ( item, index ) => {
20
+ const primary =
21
+ item.options?.type === 'comparison'
22
+ ? ( primaryByGroup.get( item.group ) ?? onlyPrimary )
23
+ : undefined;
24
+
25
+ if ( primary !== undefined ) {
26
+ pairs.set( index, primary );
27
+ }
28
+ } );
29
+
30
+ return pairs;
31
+ }
32
+
33
+ /**
34
+ * The colors a grouped bar chart fills its series with. A comparison takes the lightest tint of its primary's color that keeps 3:1 against the background, and the primary in front of it the shade that keeps 3:1 against that tint. A primary with its own `stroke` keeps it, and a comparison with its own, different `stroke` keeps the pair's colors as given.
35
+ *
36
+ * @param series - The chart's series.
37
+ * @param colors - Each series' resolved color.
38
+ * @param background - The hex chart background.
39
+ * @return Each series' fill, by index.
40
+ */
41
+ export function getGroupedBarColors(
42
+ series: SeriesData[],
43
+ colors: string[],
44
+ background: string
45
+ ): string[] {
46
+ const barColors = [ ...colors ];
47
+
48
+ getComparisonPrimaries( series ).forEach( ( primary, comparison ) => {
49
+ const base = colors[ primary ];
50
+ const hasOwnColor =
51
+ !! series[ comparison ].options?.stroke &&
52
+ colors[ comparison ].toLowerCase() !== base.toLowerCase();
53
+
54
+ // A paint such as `currentColor` or `url(#gradient)` cannot be toned, so the pair keeps the colors it was given.
55
+ if ( hasOwnColor || ! isValidHexColor( base ) || ! isValidHexColor( background ) ) {
56
+ return;
57
+ }
58
+
59
+ const tint = tintWithContrast( base, background );
60
+ barColors[ comparison ] = tint;
61
+
62
+ if ( ! series[ primary ].options?.stroke ) {
63
+ barColors[ primary ] = shadeWithContrast( base, tint, background );
64
+ }
65
+ } );
66
+
67
+ return barColors;
68
+ }
@@ -1,6 +1,12 @@
1
1
  export { useBarChartOptions } from './use-bar-chart-options';
2
2
  export { TruncatedXTickComponent, TruncatedYTickComponent } from './truncated-tick-component';
3
3
  export { default as ClassifiedBarSeries } from './classified-bar-series';
4
+ export { default as SlottedBarSeries } from './slotted-bar-series';
5
+ export { BarLayer } from './bar-layer';
6
+ export { SelectedBarRing, type SelectedBar } from './selected-bar-ring';
7
+ export { SlotTooltipMembers, type SlotMember } from './slot-tooltip-members';
8
+ export { useOverlapSlots, getSelectedSlot, isSameCategory } from './use-overlap-slots';
9
+ export { useBarStops } from './use-bar-stops';
4
10
  export { ComparisonBars } from './comparison-bars';
5
11
  export type { ComparisonSeriesEntry } from './comparison-bars';
6
12
  export {
@@ -10,6 +16,6 @@ export {
10
16
  COMPARISON_TICK_GAP_FACTOR,
11
17
  } from './comparison-constants';
12
18
  export { BASE_BAND_PADDING_INNER } from './use-bar-chart-options';
13
- export { countRenderedBars, isBarRendered } from './value-domain';
19
+ export { isBarRendered } from './value-domain';
14
20
  export { BandHighlight } from './band-highlight';
15
21
  export { BandTooltip } from './band-tooltip';
@@ -0,0 +1,183 @@
1
+ import { MIN_BACKGROUND_CONTRAST } from '../../../providers/chart-context/private/palette-generator';
2
+ import { contrastRatio } from '../../../providers/chart-context/private/perceptual-color';
3
+ import { isValidHexColor, mixHexColors } from '../../../utils/color-utils';
4
+ import type { SeriesData } from '../../../types';
5
+
6
+ export const PRIMARY_SLOT = 'primary';
7
+ export const COMPARISON_SLOT = 'comparison';
8
+
9
+ const OVERLAP_SLOTS = [ PRIMARY_SLOT, COMPARISON_SLOT ] as const;
10
+
11
+ /** Thickness of the front-most layer, as a share of its slot. */
12
+ const FRONT_THICKNESS = 0.5;
13
+
14
+ const SEARCH_STEPS = 16;
15
+
16
+ /** Where a series sits in an overlap layout. */
17
+ export type OverlapPlacement = {
18
+ slot: ( typeof OVERLAP_SLOTS )[ number ];
19
+ /** 0 for the first `group` in the data, which is drawn at the back. */
20
+ layer: number;
21
+ /** The bar's thickness as a share of its slot. */
22
+ thickness: number;
23
+ };
24
+
25
+ /**
26
+ * Places each series of an overlap layout: primaries in one slot and comparisons in the other, with each `group` a layer, back to front in order of first appearance.
27
+ *
28
+ * @param series - The series to place.
29
+ * @return Each series' placement, by index.
30
+ */
31
+ export function getOverlapPlacements( series: SeriesData[] ): OverlapPlacement[] {
32
+ const layerByGroup = new Map< string | undefined, number >();
33
+
34
+ series.forEach( ( { group } ) => {
35
+ if ( ! layerByGroup.has( group ) ) {
36
+ layerByGroup.set( group, layerByGroup.size );
37
+ }
38
+ } );
39
+
40
+ const layerCount = layerByGroup.size;
41
+
42
+ return series.map( item => {
43
+ const layer = layerByGroup.get( item.group ) ?? 0;
44
+
45
+ return {
46
+ slot: item.options?.type === 'comparison' ? COMPARISON_SLOT : PRIMARY_SLOT,
47
+ layer,
48
+ thickness: layerCount > 1 ? 1 - ( layer * ( 1 - FRONT_THICKNESS ) ) / ( layerCount - 1 ) : 1,
49
+ };
50
+ } );
51
+ }
52
+
53
+ /**
54
+ * Blends `from` toward `to` and returns the blend nearest the point where `passes` changes, on its passing side.
55
+ *
56
+ * @param from - Where the blend starts.
57
+ * @param to - Where the blend ends.
58
+ * @param passes - A test that changes at most once along the blend.
59
+ * @return A hex color.
60
+ */
61
+ function blendToEdge( from: string, to: string, passes: ( color: string ) => boolean ): string {
62
+ const passesAtStart = passes( from );
63
+ let low = 0;
64
+ let high = 1;
65
+
66
+ for ( let step = 0; step < SEARCH_STEPS; step++ ) {
67
+ const middle = ( low + high ) / 2;
68
+
69
+ if ( passes( mixHexColors( from, to, middle ) ) === passesAtStart ) {
70
+ low = middle;
71
+ } else {
72
+ high = middle;
73
+ }
74
+ }
75
+
76
+ return mixHexColors( from, to, passesAtStart ? low : high );
77
+ }
78
+
79
+ const awayFrom = ( background: string ): string =>
80
+ contrastRatio( background, '#000000' ) >= contrastRatio( background, '#ffffff' )
81
+ ? '#000000'
82
+ : '#ffffff';
83
+
84
+ const opposite = ( end: string ): string => ( end === '#000000' ? '#ffffff' : '#000000' );
85
+
86
+ /**
87
+ * The tone of a color nearest the background that keeps 3:1 against it.
88
+ *
89
+ * @param color - A hex color.
90
+ * @param background - The hex chart background.
91
+ * @return A hex color.
92
+ */
93
+ export function tintWithContrast( color: string, background: string ): string {
94
+ const passes = ( tone: string ) => contrastRatio( tone, background ) >= MIN_BACKGROUND_CONTRAST;
95
+
96
+ return blendToEdge( color, passes( color ) ? background : awayFrom( background ), passes );
97
+ }
98
+
99
+ /**
100
+ * The tone of a color nearest the original that keeps 3:1 against the bar it is drawn over, on the far side of that bar from the background when that side can reach 3:1.
101
+ *
102
+ * @param color - A hex color.
103
+ * @param under - The hex color of the bar behind it.
104
+ * @param background - The hex chart background.
105
+ * @return A hex color.
106
+ */
107
+ export function shadeWithContrast( color: string, under: string, background: string ): string {
108
+ const passes = ( tone: string ) => contrastRatio( tone, under ) >= MIN_BACKGROUND_CONTRAST;
109
+ // Starting from a color on the background's side of `under` would cross it, so the search could not tell which side it ended on.
110
+ const start =
111
+ contrastRatio( color, background ) > contrastRatio( under, background ) ? color : under;
112
+
113
+ if ( passes( start ) ) {
114
+ return start;
115
+ }
116
+
117
+ const away = awayFrom( background );
118
+
119
+ return blendToEdge( start, passes( away ) ? away : opposite( away ), passes );
120
+ }
121
+
122
+ /**
123
+ * The colors an overlap layout fills its series with: per slot, the back layer tinted to 3:1 against the background and each layer in front shaded to 3:1 against the one behind. A slot whose front-most series has its own `stroke` keeps its colors as given.
124
+ *
125
+ * @param series - Every series of the chart.
126
+ * @param colors - Each series' resolved color.
127
+ * @param slotColors - The palette color of each slot, primaries first.
128
+ * @param background - The hex chart background.
129
+ * @return Each series' fill, by index.
130
+ */
131
+ export function getOverlapBarColors(
132
+ series: SeriesData[],
133
+ colors: string[],
134
+ slotColors: readonly [ string, string ],
135
+ background: string
136
+ ): string[] {
137
+ const placements = getOverlapPlacements( series );
138
+ const barColors = [ ...colors ];
139
+
140
+ OVERLAP_SLOTS.forEach( ( slot, slotIndex ) => {
141
+ const members = placements.flatMap( ( placement, index ) =>
142
+ placement.slot === slot ? [ { index, layer: placement.layer } ] : []
143
+ );
144
+
145
+ if ( ! members.length ) {
146
+ return;
147
+ }
148
+
149
+ const layers = [ ...new Set( members.map( ( { layer } ) => layer ) ) ].sort(
150
+ ( a, b ) => a - b
151
+ );
152
+ const back = members.find( ( { layer } ) => layer === layers[ 0 ] );
153
+ const base =
154
+ back && series[ back.index ].options?.stroke ? colors[ back.index ] : slotColors[ slotIndex ];
155
+ const isFrontStroked = members.some(
156
+ ( { index, layer } ) =>
157
+ layer === layers[ layers.length - 1 ] && !! series[ index ].options?.stroke
158
+ );
159
+
160
+ // A paint such as `currentColor` or `url(#gradient)` cannot be toned, so the slot keeps the colors it was given.
161
+ if ( isFrontStroked || ! isValidHexColor( base ) || ! isValidHexColor( background ) ) {
162
+ return;
163
+ }
164
+
165
+ const toneByLayer = new Map< number, string >();
166
+ let under: string | undefined;
167
+
168
+ layers.forEach( layer => {
169
+ const tone =
170
+ under === undefined
171
+ ? tintWithContrast( base, background )
172
+ : shadeWithContrast( base, under, background );
173
+ toneByLayer.set( layer, tone );
174
+ under = tone;
175
+ } );
176
+
177
+ members.forEach( ( { index, layer } ) => {
178
+ barColors[ index ] = toneByLayer.get( layer ) ?? colors[ index ];
179
+ } );
180
+ } );
181
+
182
+ return barColors;
183
+ }
@@ -0,0 +1,84 @@
1
+ import { DataContext } from '@visx/xychart';
2
+ import { useContext } from 'react';
3
+ import { SelectedRing } from '../../private/selected-ring/selected-ring';
4
+ import { createGroupScale } from './band-scale';
5
+ import { computeSlotRect, getValueScaleBaseline } from './comparison-bars-geometry';
6
+ import type { BandScale } from './band-scale';
7
+ import type { DataPointDate } from '../../../types';
8
+ import type { FC } from 'react';
9
+
10
+ /** The slot the keyboard has selected. */
11
+ export type SelectedBar = {
12
+ /** The data of the bars the slot draws at the selected category, which the ring encloses; only bars the chart draws (a finite value). */
13
+ data: DataPointDate[];
14
+ /** The selected slot. */
15
+ slotKey: string;
16
+ /** The thickness of the slot's widest bar, as a share of the slot. */
17
+ thickness: number;
18
+ };
19
+
20
+ type AnyScale = ( ( input: unknown ) => number ) &
21
+ Partial< Pick< BandScale, 'bandwidth' > > & { range: () => unknown[] };
22
+
23
+ /**
24
+ * Marks the keyboard-selected slot with a two-tone ring outside its edge, so the ring reads against any bar color.
25
+ *
26
+ * @param props - Component props.
27
+ * @param props.chartId - The chart's id, which keeps the clip path unique on a page with several charts.
28
+ * @param props.selected - The selected slot.
29
+ * @param props.slotKeys - The slots, in the order the band lays them out.
30
+ * @param props.groupPadding - The padding between slots.
31
+ * @param props.horizontal - Whether the bars run horizontally.
32
+ * @param props.xAccessor - Reads a datum's x value.
33
+ * @param props.yAccessor - Reads a datum's y value.
34
+ * @return The ring, or nothing when the slot has no position.
35
+ */
36
+ export const SelectedBarRing: FC< {
37
+ chartId: string;
38
+ selected: SelectedBar;
39
+ slotKeys: string[];
40
+ groupPadding: number;
41
+ horizontal: boolean;
42
+ xAccessor: ( d: DataPointDate ) => string | number | Date | undefined;
43
+ yAccessor: ( d: DataPointDate ) => number | undefined;
44
+ } > = ( { chartId, selected, slotKeys, groupPadding, horizontal, xAccessor, yAccessor } ) => {
45
+ const context = useContext( DataContext );
46
+ const bandScale = ( horizontal ? context?.yScale : context?.xScale ) as AnyScale | undefined;
47
+ const valueScale = ( horizontal ? context?.xScale : context?.yScale ) as AnyScale | undefined;
48
+ const bandwidth = bandScale?.bandwidth?.() ?? 0;
49
+ const [ category ] = selected.data;
50
+
51
+ if ( ! bandScale || ! valueScale || ! bandwidth || ! category ) {
52
+ return null;
53
+ }
54
+
55
+ const groupScale = createGroupScale( slotKeys, bandwidth, groupPadding );
56
+ const slotOffset = groupScale( selected.slotKey );
57
+ const bandPosition = Number(
58
+ bandScale( ( horizontal ? yAccessor : xAccessor )( category ) as never )
59
+ );
60
+ if ( slotOffset == null || ! Number.isFinite( bandPosition ) ) {
61
+ return null;
62
+ }
63
+
64
+ const valuePositions = selected.data.map( datum =>
65
+ Number( valueScale( Number( ( horizontal ? xAccessor : yAccessor )( datum ) ) as never ) )
66
+ );
67
+
68
+ const rect = computeSlotRect( {
69
+ horizontal,
70
+ bandPosition,
71
+ slotOffset,
72
+ slotThickness: groupScale.bandwidth(),
73
+ valuePositions: [ getValueScaleBaseline( valueScale ), ...valuePositions ],
74
+ widthFactor: selected.thickness,
75
+ } );
76
+ return (
77
+ <SelectedRing
78
+ chartId={ chartId }
79
+ d={ `M${ rect.x } ${ rect.y }h${ rect.width }v${ rect.height }h${ -rect.width }Z` }
80
+ side="outside"
81
+ testId="bar-chart-selected-ring"
82
+ />
83
+ );
84
+ };