@automattic/charts 4.11.0 → 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 +18 -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 +10 -10
  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,29 @@
1
+ import { TooltipContext } from '@visx/xychart';
2
+ import { useContext, useEffect } from 'react';
3
+ import type { DataPointDate } from '../../../types';
4
+
5
+ /** A bar at the keyboard-selected category. */
6
+ export type SlotMember = { key: string; datum: DataPointDate };
7
+
8
+ /**
9
+ * Fills the tooltip with every bar of the keyboard-selected category, and only those.
10
+ *
11
+ * The last member shown becomes the tooltip's nearest datum, so the caller lists the keyboard stop last.
12
+ *
13
+ * @param props - Component props.
14
+ * @param props.members - The category's bars, the keyboard stop last.
15
+ * @param props.index - The selected data point.
16
+ * @return No visual content.
17
+ */
18
+ export function SlotTooltipMembers( { members, index }: { members: SlotMember[]; index: number } ) {
19
+ const { showTooltip, updateTooltip } = useContext( TooltipContext ) ?? {};
20
+
21
+ useEffect( () => {
22
+ updateTooltip?.( state => ( { ...state, tooltipData: undefined } ) );
23
+ members.forEach( ( { key, datum } ) => showTooltip?.( { key, datum, index } ) );
24
+ // `showTooltip` and `updateTooltip` are stable; the context object is not.
25
+ // eslint-disable-next-line react-hooks/exhaustive-deps
26
+ }, [ members, index ] );
27
+
28
+ return null;
29
+ }
@@ -0,0 +1,84 @@
1
+ import { DataContext, GlyphSeries } from '@visx/xychart';
2
+ import clsx from 'clsx';
3
+ import { useCallback, useContext, useMemo } from 'react';
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 { BarChartProps } from '../types';
9
+ import type { AxisScale } from '@visx/axis';
10
+ import type { GlyphProps, SeriesProps } from '@visx/xychart';
11
+
12
+ type Props = Pick<
13
+ SeriesProps< AxisScale, AxisScale, DataPointDate >,
14
+ 'xAccessor' | 'yAccessor'
15
+ > & {
16
+ dataKey: string;
17
+ data: DataPointDate[];
18
+ colorAccessor: ( datum: DataPointDate ) => string;
19
+ barClassName?: BarChartProps[ 'barClassName' ];
20
+ /** The slots of a category, in the order the band lays them out. */
21
+ slotKeys: string[];
22
+ /** The slot the bars sit in. */
23
+ slotKey: string;
24
+ /** The bars' thickness as a share of the slot, centered in it. */
25
+ thickness: number;
26
+ groupPadding: number;
27
+ };
28
+
29
+ const SlottedBarSeries = ( {
30
+ barClassName,
31
+ slotKeys,
32
+ slotKey,
33
+ thickness,
34
+ groupPadding,
35
+ ...props
36
+ }: Props ) => {
37
+ const { xScale, yScale, horizontal } = useContext( DataContext );
38
+ const bandScale = ( horizontal ? yScale : xScale ) as BandScale | undefined;
39
+ const valueScale = horizontal ? xScale : yScale;
40
+ const groupScale = useMemo(
41
+ () => createGroupScale( slotKeys, bandScale?.bandwidth?.() ?? 0, groupPadding ),
42
+ [ slotKeys, bandScale, groupPadding ]
43
+ );
44
+ const renderGlyph = useCallback(
45
+ ( { datum, x, y, color }: GlyphProps< DataPointDate > ) => {
46
+ if ( ! bandScale?.bandwidth || ! valueScale ) return null;
47
+ const baseline = getValueScaleBaseline(
48
+ valueScale as Parameters< typeof getValueScaleBaseline >[ 0 ]
49
+ );
50
+ const rect = computeSlotRect( {
51
+ horizontal: !! horizontal,
52
+ bandPosition: Number(
53
+ bandScale( ( horizontal ? props.yAccessor : props.xAccessor )( datum ) )
54
+ ),
55
+ slotOffset: groupScale( slotKey ) ?? 0,
56
+ slotThickness: groupScale.bandwidth(),
57
+ valuePositions: [ baseline, horizontal ? x : y ],
58
+ widthFactor: thickness,
59
+ } );
60
+ return (
61
+ <rect
62
+ { ...rect }
63
+ className={ clsx( 'visx-bar', barClassName?.( datum ) ) }
64
+ fill={ color }
65
+ />
66
+ );
67
+ },
68
+ [
69
+ bandScale,
70
+ valueScale,
71
+ horizontal,
72
+ groupScale,
73
+ slotKey,
74
+ thickness,
75
+ props.xAccessor,
76
+ props.yAccessor,
77
+ barClassName,
78
+ ]
79
+ );
80
+ // BandTooltip owns pointer selection: these handlers subscribe after it and would rank bars by their tops, not their slots.
81
+ return <GlyphSeries { ...props } enableEvents={ false } renderGlyph={ renderGlyph } />;
82
+ };
83
+
84
+ export default SlottedBarSeries;
@@ -1,4 +1,4 @@
1
- import { computeComparisonRect, getValueScaleBaseline } from '../comparison-bars-geometry';
1
+ import { computeSlotRect, getValueScaleBaseline } from '../comparison-bars-geometry';
2
2
 
3
3
  describe( 'getValueScaleBaseline', () => {
4
4
  it( 'clamps to the bottom (range max) when 0 is below the domain (vertical, descending range)', () => {
@@ -17,29 +17,22 @@ describe( 'getValueScaleBaseline', () => {
17
17
  } );
18
18
  } );
19
19
 
20
- describe( 'computeComparisonRect', () => {
20
+ describe( 'computeSlotRect', () => {
21
21
  const base = {
22
22
  bandPosition: 100,
23
23
  slotOffset: 10,
24
24
  slotThickness: 40,
25
- valuePosition: 50,
26
- baseline: 200,
27
25
  widthFactor: 1.5,
28
26
  };
29
27
 
30
28
  it( 'centers a 150% wide shadow on the primary slot (vertical)', () => {
31
- const r = computeComparisonRect( { ...base, horizontal: false } );
29
+ const r = computeSlotRect( { ...base, horizontal: false, valuePositions: [ 200, 50 ] } );
32
30
  // slotStart=110, center=110+20=130, shadowThickness=60, shadowStart=130-30=100
33
31
  expect( r ).toEqual( { x: 100, y: 50, width: 60, height: 150 } );
34
32
  } );
35
33
 
36
34
  it( 'centers a 150% tall shadow on the primary slot (horizontal)', () => {
37
- const r = computeComparisonRect( {
38
- ...base,
39
- horizontal: true,
40
- valuePosition: 150,
41
- baseline: 0,
42
- } );
35
+ const r = computeSlotRect( { ...base, horizontal: true, valuePositions: [ 0, 150 ] } );
43
36
  // slotStart=110, center=130, shadowThickness=60, shadowStart=100
44
37
  expect( r ).toEqual( { x: 0, y: 100, width: 150, height: 60 } );
45
38
  } );
@@ -56,12 +56,12 @@ const seriesData: SeriesData = {
56
56
  };
57
57
 
58
58
  const comparisonEntries: ComparisonSeriesEntry[] = [
59
- { series: seriesData, index: 0, primaryKey: 'current', primaryIndex: 0 },
59
+ { series: seriesData, index: 0, primaryKey: 'current' },
60
60
  ];
61
61
 
62
62
  const getElementStyles = () => ( {
63
63
  color: '#f00',
64
- barStyles: { widthFactor: 1.5, opacity: 0.5 },
64
+ barStyles: { widthFactor: 1.5 },
65
65
  lineStyles: {} as never,
66
66
  glyph: undefined as never,
67
67
  shapeStyles: {} as never,
@@ -94,7 +94,6 @@ describe( 'ComparisonBars', () => {
94
94
  const rects = screen.getAllByTestId( /^bar-chart-comparison-0-/ );
95
95
  expect( rects ).toHaveLength( 2 );
96
96
  expect( rects[ 0 ] ).toHaveAttribute( 'fill', '#f00' );
97
- expect( rects[ 0 ] ).toHaveAttribute( 'opacity', '0.5' );
98
97
  // width == widthFactor (1.5) * groupScale.bandwidth()
99
98
  expect( Number( rects[ 0 ].getAttribute( 'width' ) ) ).toBeCloseTo(
100
99
  1.5 * groupScale.bandwidth()
@@ -0,0 +1,87 @@
1
+ import { contrastRatio } from '../../../../providers/chart-context/private/perceptual-color';
2
+ import { getGroupedBarColors } from '../grouped-bars';
3
+ import { tintWithContrast } from '../overlap-bars';
4
+ import type { SeriesData } from '../../../../types';
5
+
6
+ const series = ( label: string, group?: string, options: SeriesData[ 'options' ] = {} ) => ( {
7
+ label,
8
+ group,
9
+ data: [],
10
+ options,
11
+ } );
12
+
13
+ const BLUE = '#3858e9';
14
+ const GREEN = '#008a20';
15
+
16
+ describe( 'getGroupedBarColors', () => {
17
+ it.each( [ [ '#ffffff' ], [ '#1e1e1e' ] ] )(
18
+ 'tints each comparison to 3:1 against %s and keeps its primary at 3:1 against the tint',
19
+ background => {
20
+ const data = [
21
+ series( 'Views', 'views' ),
22
+ series( 'Previous views', 'views', { type: 'comparison' } ),
23
+ series( 'Visitors', 'visitors' ),
24
+ series( 'Previous visitors', 'visitors', { type: 'comparison' } ),
25
+ ];
26
+ const [ views, previousViews, visitors, previousVisitors ] = getGroupedBarColors(
27
+ data,
28
+ [ BLUE, BLUE, GREEN, GREEN ],
29
+ background
30
+ );
31
+
32
+ for ( const [ primary, comparison ] of [
33
+ [ views, previousViews ],
34
+ [ visitors, previousVisitors ],
35
+ ] ) {
36
+ expect( contrastRatio( comparison, background ) ).toBeGreaterThanOrEqual( 3 );
37
+ expect( contrastRatio( comparison, background ) ).toBeLessThan( 3.1 );
38
+ expect( contrastRatio( primary, comparison ) ).toBeGreaterThanOrEqual( 3 );
39
+ }
40
+ }
41
+ );
42
+
43
+ it( 'pairs a comparison without a group with the only primary', () => {
44
+ const [ primary, comparison ] = getGroupedBarColors(
45
+ [ series( 'Views' ), series( 'Previous', undefined, { type: 'comparison' } ) ],
46
+ [ BLUE, GREEN ],
47
+ '#ffffff'
48
+ );
49
+
50
+ expect( contrastRatio( primary, comparison ) ).toBeGreaterThanOrEqual( 3 );
51
+ } );
52
+
53
+ it( "keeps a primary's own stroke and tints its comparison from it", () => {
54
+ const [ primary, comparison ] = getGroupedBarColors(
55
+ [
56
+ series( 'Views', 'views', { stroke: '#ff0000' } ),
57
+ series( 'Previous', 'views', { type: 'comparison' } ),
58
+ ],
59
+ [ '#ff0000', BLUE ],
60
+ '#ffffff'
61
+ );
62
+
63
+ expect( [ primary, comparison ] ).toEqual( [
64
+ '#ff0000',
65
+ tintWithContrast( '#ff0000', '#ffffff' ),
66
+ ] );
67
+ } );
68
+
69
+ it.each( [
70
+ [
71
+ 'a comparison stroked in a color of its own',
72
+ [
73
+ series( 'Views', 'views' ),
74
+ series( 'Previous', 'views', { type: 'comparison', stroke: '#dbdbdb' } ),
75
+ ],
76
+ [ BLUE, '#dbdbdb' ],
77
+ ],
78
+ [
79
+ 'a primary color that is not hex',
80
+ [ series( 'Views', 'views' ), series( 'Previous', 'views', { type: 'comparison' } ) ],
81
+ [ 'currentColor', 'currentColor' ],
82
+ ],
83
+ [ 'no comparison', [ series( 'Views' ), series( 'Visitors' ) ], [ BLUE, GREEN ] ],
84
+ ] )( 'keeps the colors given with %s', ( _case, data, colors ) => {
85
+ expect( getGroupedBarColors( data, colors, '#ffffff' ) ).toEqual( colors );
86
+ } );
87
+ } );
@@ -0,0 +1,159 @@
1
+ import { contrastRatio } from '../../../../providers/chart-context/private/perceptual-color';
2
+ import { relativeLuminance } from '../../../../utils/color-utils';
3
+ import { getOverlapBarColors, getOverlapPlacements, tintWithContrast } from '../overlap-bars';
4
+ import type { SeriesData } from '../../../../types';
5
+
6
+ const series = ( label: string, group?: string, options: SeriesData[ 'options' ] = {} ) => ( {
7
+ label,
8
+ group,
9
+ data: [],
10
+ options,
11
+ } );
12
+
13
+ const VIEWS_AND_VISITORS: SeriesData[] = [
14
+ series( 'Views', 'views' ),
15
+ series( 'Previous views', 'views', { type: 'comparison' } ),
16
+ series( 'Visitors', 'visitors' ),
17
+ series( 'Previous visitors', 'visitors', { type: 'comparison' } ),
18
+ ];
19
+
20
+ const BLUE = '#3858e9';
21
+ const GREEN = '#008a20';
22
+
23
+ describe( 'getOverlapPlacements', () => {
24
+ it( 'puts primaries and comparisons in their own slots, with each group a layer in order of first appearance', () => {
25
+ expect( getOverlapPlacements( VIEWS_AND_VISITORS ) ).toEqual( [
26
+ { slot: 'primary', layer: 0, thickness: 1 },
27
+ { slot: 'comparison', layer: 0, thickness: 1 },
28
+ { slot: 'primary', layer: 1, thickness: 0.5 },
29
+ { slot: 'comparison', layer: 1, thickness: 0.5 },
30
+ ] );
31
+ } );
32
+
33
+ it.each( [
34
+ [ 'one group', [ series( 'Views', 'views' ) ], [ 1 ] ],
35
+ [
36
+ 'three groups',
37
+ [ series( 'A', 'a' ), series( 'B', 'b' ), series( 'C', 'c' ) ],
38
+ [ 1, 0.75, 0.5 ],
39
+ ],
40
+ ] )( 'narrows each layer in front with %s', ( _case, data, thicknesses ) => {
41
+ expect( getOverlapPlacements( data ).map( ( { thickness } ) => thickness ) ).toEqual(
42
+ thicknesses
43
+ );
44
+ } );
45
+ } );
46
+
47
+ describe( 'getOverlapBarColors', () => {
48
+ const colorsOf = ( data: SeriesData[] ) =>
49
+ data.map( item => item.options?.stroke ?? ( item.group === 'views' ? BLUE : GREEN ) );
50
+
51
+ it.each( [
52
+ [ 'a light background', '#ffffff', -1 ],
53
+ [ 'a dark background', '#1e1e1e', 1 ],
54
+ ] )(
55
+ 'tints the back bar to 3:1 against %s and moves the front bar away from it to 3:1',
56
+ ( _case, background, direction ) => {
57
+ const [ back, previousBack, front, previousFront ] = getOverlapBarColors(
58
+ VIEWS_AND_VISITORS,
59
+ colorsOf( VIEWS_AND_VISITORS ),
60
+ [ BLUE, GREEN ],
61
+ background
62
+ );
63
+
64
+ for ( const [ slotBack, slotFront ] of [
65
+ [ back, front ],
66
+ [ previousBack, previousFront ],
67
+ ] ) {
68
+ expect( contrastRatio( slotBack, background ) ).toBeGreaterThanOrEqual( 3 );
69
+ expect( contrastRatio( slotBack, background ) ).toBeLessThan( 3.1 );
70
+ expect( contrastRatio( slotFront, slotBack ) ).toBeGreaterThanOrEqual( 3 );
71
+ expect( Math.sign( relativeLuminance( slotFront ) - relativeLuminance( slotBack ) ) ).toBe(
72
+ direction
73
+ );
74
+ }
75
+ }
76
+ );
77
+
78
+ it( 'turns the front bar back toward the background when the far side cannot reach 3:1', () => {
79
+ const background = '#686884';
80
+ const [ back, , front ] = getOverlapBarColors(
81
+ VIEWS_AND_VISITORS,
82
+ colorsOf( VIEWS_AND_VISITORS ),
83
+ [ '#9f4902', GREEN ],
84
+ background
85
+ );
86
+
87
+ expect( contrastRatio( front, back ) ).toBeGreaterThanOrEqual( 3 );
88
+ } );
89
+
90
+ it( "tones each slot from its palette color, not from the series' group colors", () => {
91
+ const toned = getOverlapBarColors(
92
+ VIEWS_AND_VISITORS,
93
+ colorsOf( VIEWS_AND_VISITORS ),
94
+ [ BLUE, GREEN ],
95
+ '#ffffff'
96
+ );
97
+ const regrouped = getOverlapBarColors(
98
+ VIEWS_AND_VISITORS,
99
+ [ '#111111', '#222222', '#333333', '#444444' ],
100
+ [ BLUE, GREEN ],
101
+ '#ffffff'
102
+ );
103
+
104
+ expect( regrouped ).toEqual( toned );
105
+ } );
106
+
107
+ it( "tones a slot from its back series' own stroke", () => {
108
+ const data = [
109
+ series( 'Views', 'views', { stroke: GREEN } ),
110
+ series( 'Visitors', 'visitors' ),
111
+ ];
112
+ const [ back ] = getOverlapBarColors( data, [ GREEN, BLUE ], [ BLUE, BLUE ], '#ffffff' );
113
+
114
+ expect( back ).toBe( tintWithContrast( GREEN, '#ffffff' ) );
115
+ } );
116
+
117
+ it.each( [ BLUE, '#dbdbdb' ] )( 'tints a bar alone in its slot from %s, as a back bar', base => {
118
+ const [ bar ] = getOverlapBarColors(
119
+ [ series( 'Comments' ) ],
120
+ [ base ],
121
+ [ base, GREEN ],
122
+ '#ffffff'
123
+ );
124
+
125
+ expect( bar ).toBe( tintWithContrast( base, '#ffffff' ) );
126
+ } );
127
+
128
+ it.each( [
129
+ [
130
+ 'a front series stroked in a color of its own',
131
+ [
132
+ series( 'Previous views', 'views', { type: 'comparison', stroke: '#dbdbdb' } ),
133
+ series( 'Previous visitors', 'visitors', { type: 'comparison', stroke: '#707070' } ),
134
+ ],
135
+ [ '#dbdbdb', '#707070' ],
136
+ '#ffffff',
137
+ ],
138
+ [
139
+ 'a bar alone in its slot stroked in a color of its own',
140
+ [ series( 'Previous comments', 'comments', { type: 'comparison', stroke: '#dbdbdb' } ) ],
141
+ [ '#dbdbdb' ],
142
+ '#ffffff',
143
+ ],
144
+ [
145
+ 'a base that is not hex',
146
+ [ series( 'Views', 'views', { stroke: 'currentColor' } ), series( 'Visitors', 'visitors' ) ],
147
+ [ 'currentColor', BLUE ],
148
+ '#ffffff',
149
+ ],
150
+ [
151
+ 'a background that is not hex',
152
+ [ series( 'Views', 'views' ), series( 'Visitors', 'visitors' ) ],
153
+ [ BLUE, GREEN ],
154
+ 'transparent',
155
+ ],
156
+ ] )( 'keeps the colors given with %s', ( _case, data, colors, background ) => {
157
+ expect( getOverlapBarColors( data, colors, [ BLUE, GREEN ], background ) ).toEqual( colors );
158
+ } );
159
+ } );
@@ -0,0 +1,89 @@
1
+ import { useCallback, useMemo } from 'react';
2
+ import { isSameCategory } from './use-overlap-slots';
3
+ import type { OverlapSlots } from './use-overlap-slots';
4
+ import type { DataPointDate, SeriesData } from '../../../types';
5
+
6
+ type SeriesEntry = { series: SeriesData; index: number };
7
+
8
+ /** A bar the keyboard can stop at. */
9
+ export type StopBar = { datum: DataPointDate; index: number; key: string };
10
+
11
+ export type BarStops = {
12
+ count: number;
13
+ /** The series `XYChartTooltip` reads a stop's datum from. Overlap has none: `SlotTooltipMembers` fills its tooltip. */
14
+ series: SeriesData[];
15
+ /** Changes when the stops do, so keyboard navigation starts over. */
16
+ key: string;
17
+ barAt: ( stop: number ) => StopBar | undefined;
18
+ stopOf: ( key: string, dataPointIndex: number ) => number | undefined;
19
+ };
20
+
21
+ /**
22
+ * Lays out the keyboard stops: one per slot and category in an overlap layout, else one per primary bar, series-major within each category.
23
+ *
24
+ * @param overlapSlots - The overlap layout, or undefined for a grouped one.
25
+ * @param primaryEntries - The visible primary series.
26
+ * @return The stops.
27
+ */
28
+ export function useBarStops(
29
+ overlapSlots: OverlapSlots | undefined,
30
+ primaryEntries: SeriesEntry[]
31
+ ): BarStops {
32
+ const series = useMemo(
33
+ () => ( overlapSlots ? [] : primaryEntries.map( entry => entry.series ) ),
34
+ [ overlapSlots, primaryEntries ]
35
+ );
36
+
37
+ const count =
38
+ overlapSlots?.positions.length ??
39
+ Math.max( 0, ...series.map( item => item.data.length ) ) * series.length;
40
+
41
+ const key = useMemo(
42
+ () => JSON.stringify( overlapSlots?.keys ?? series.map( item => item.label ) ),
43
+ [ overlapSlots, series ]
44
+ );
45
+
46
+ const barAt = useCallback(
47
+ ( stop: number ): StopBar | undefined => {
48
+ if ( overlapSlots ) {
49
+ const position = overlapSlots.positions[ stop ];
50
+ return (
51
+ position && {
52
+ datum: position.datum,
53
+ index: position.dataPointIndex,
54
+ key: position.key,
55
+ }
56
+ );
57
+ }
58
+ if ( ! series.length ) {
59
+ return undefined;
60
+ }
61
+ const index = Math.floor( stop / series.length );
62
+ const item = series[ stop % series.length ];
63
+ const datum = item?.data[ index ] as DataPointDate | undefined;
64
+ return item && datum ? { datum, index, key: item.label } : undefined;
65
+ },
66
+ [ overlapSlots, series ]
67
+ );
68
+
69
+ const stopOf = useCallback(
70
+ ( seriesKey: string, dataPointIndex: number ): number | undefined => {
71
+ if ( overlapSlots ) {
72
+ const hovered = overlapSlots.entries.find( entry => entry.series.label === seriesKey )
73
+ ?.series.data[ dataPointIndex ] as DataPointDate | undefined;
74
+ const slotKey = overlapSlots.slotOf( seriesKey );
75
+ const stop = hovered
76
+ ? overlapSlots.positions.findIndex(
77
+ position => position.slotKey === slotKey && isSameCategory( position.datum, hovered )
78
+ )
79
+ : -1;
80
+ return stop >= 0 ? stop : undefined;
81
+ }
82
+ const seriesIndex = series.findIndex( item => item.label === seriesKey );
83
+ return seriesIndex >= 0 ? dataPointIndex * series.length + seriesIndex : undefined;
84
+ },
85
+ [ overlapSlots, series ]
86
+ );
87
+
88
+ return { count, series, key, barAt, stopOf };
89
+ }