@automattic/charts 4.13.0 → 4.15.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 (56) hide show
  1. package/CHANGELOG.md +34 -29
  2. package/TOKENS.md +4 -1
  3. package/dist/index.cjs +1069 -1620
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +33 -1
  6. package/dist/index.d.cts +60 -15
  7. package/dist/index.d.ts +60 -15
  8. package/dist/index.js +1072 -1575
  9. package/dist/index.js.map +1 -1
  10. package/package.json +11 -10
  11. package/src/charts/area-chart/area-chart.tsx +29 -20
  12. package/src/charts/area-chart/types.ts +3 -1
  13. package/src/charts/bar-chart/bar-chart.module.scss +38 -0
  14. package/src/charts/bar-chart/bar-chart.tsx +159 -56
  15. package/src/charts/bar-chart/private/band-tooltip.tsx +40 -5
  16. package/src/charts/bar-chart/private/bar-layer.tsx +66 -13
  17. package/src/charts/bar-chart/private/category.ts +25 -0
  18. package/src/charts/bar-chart/private/comparison-bars-geometry.ts +13 -1
  19. package/src/charts/bar-chart/private/index.ts +3 -1
  20. package/src/charts/bar-chart/private/selected-bar-ring.tsx +17 -6
  21. package/src/charts/bar-chart/private/stack-layout.ts +99 -0
  22. package/src/charts/bar-chart/private/stacked-bar-series.tsx +145 -0
  23. package/src/charts/bar-chart/private/test/stack-layout.test.ts +81 -0
  24. package/src/charts/bar-chart/private/test/value-domain.test.ts +10 -0
  25. package/src/charts/bar-chart/private/use-bar-chart-options.ts +35 -11
  26. package/src/charts/bar-chart/private/{use-overlap-slots.ts → use-bar-slots.ts} +36 -38
  27. package/src/charts/bar-chart/private/use-bar-stops.ts +19 -19
  28. package/src/charts/bar-chart/private/value-domain.ts +10 -8
  29. package/src/charts/bar-chart/test/bar-chart-stacked.test.tsx +546 -0
  30. package/src/charts/bar-chart/test/bar-chart.test.tsx +75 -0
  31. package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +18 -0
  32. package/src/charts/bar-chart/types.ts +3 -1
  33. package/src/charts/bar-chart/use-bar-colors.ts +12 -0
  34. package/src/charts/bar-chart/use-bar-legend-items.ts +4 -2
  35. package/src/charts/bar-list-chart/bar-list-chart.tsx +2 -0
  36. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +1 -1
  37. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +26 -9
  38. package/src/charts/leaderboard-chart/private/resolve-leaderboard-entries.ts +53 -0
  39. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +41 -0
  40. package/src/charts/leaderboard-chart/test/resolve-leaderboard-entries.test.ts +146 -0
  41. package/src/charts/leaderboard-chart/types.ts +6 -0
  42. package/src/charts/line-chart/line-chart.tsx +55 -26
  43. package/src/charts/line-chart/test/line-chart.test.tsx +86 -0
  44. package/src/charts/line-chart/types.ts +3 -1
  45. package/src/charts/private/test/compact-when-short.test.tsx +170 -0
  46. package/src/charts/private/test/tick-decimals.test.tsx +95 -0
  47. package/src/charts/private/tick-decimals.tsx +132 -0
  48. package/src/charts/private/use-compact-when-short.ts +18 -0
  49. package/src/components/legend/private/base-legend.tsx +13 -8
  50. package/src/components/legend/test/legend.test.tsx +125 -0
  51. package/src/components/legend/types.ts +9 -1
  52. package/src/providers/chart-context/themes.ts +4 -0
  53. package/src/styles/chart-scope.scss +1 -0
  54. package/src/types.ts +40 -9
  55. package/src/charts/private/test/whole-number-ticks.test.tsx +0 -57
  56. package/src/charts/private/whole-number-ticks.tsx +0 -79
@@ -1,7 +1,10 @@
1
1
  import { DataContext, TooltipContext, useEventEmitter } from '@visx/xychart';
2
2
  import { useCallback, useContext, useMemo } from 'react';
3
3
  import { createGroupScale } from './band-scale';
4
+ import { getValueScalePosition } from './comparison-bars-geometry';
4
5
  import type { BandScale } from './band-scale';
6
+ import type { ValueScale } from './comparison-bars-geometry';
7
+ import type { StackLayout, StackSegment } from './stack-layout';
5
8
  import type { BarChartProps } from '../types';
6
9
 
7
10
  type PointerHandler = NonNullable< Parameters< typeof useEventEmitter >[ 1 ] >;
@@ -37,11 +40,33 @@ export function nearestBandIndex< Datum >(
37
40
  return nearest;
38
41
  }
39
42
 
43
+ /**
44
+ * How far a pointer sits from a stacked segment along the value axis.
45
+ *
46
+ * @param segment - The segment, or undefined when its series has no reading there.
47
+ * @param scale - The value scale.
48
+ * @param position - Pointer coordinate on the value axis.
49
+ * @return 0 inside the segment, Infinity without one, otherwise the gap to its nearer end.
50
+ */
51
+ function distanceToSegment(
52
+ segment: StackSegment | undefined,
53
+ scale: ValueScale,
54
+ position: number
55
+ ): number {
56
+ if ( ! segment ) {
57
+ return Infinity;
58
+ }
59
+ const [ low, high ] = [ segment.start, segment.end ]
60
+ .map( value => getValueScalePosition( scale, value ) )
61
+ .sort( ( a, b ) => a - b );
62
+ return Math.max( low - position, 0, position - high );
63
+ }
64
+
40
65
  /**
41
66
  * Correct visx's range-based band inversion, which omits outer padding.
42
67
  * @param root0 - Registered series selection.
43
68
  * @param root0.keys - Visible series keys, back to front where bars overlap.
44
- * @param root0.slots - Where series share slots: the slots of a category, in the order the band lays them out, and the slot each series sits in.
69
+ * @param root0.slots - Where series share slots: the slots of a category, in the order the band lays them out, the slot each series sits in, and for a stack where each segment runs.
45
70
  * @param root0.groupPadding - Padding between grouped bars.
46
71
  * @param root0.withTooltips - Whether pointer events update the tooltip.
47
72
  * @param root0.onPointerDown - Receives the corrected pointer-down datum.
@@ -59,13 +84,14 @@ export function BandTooltip( {
59
84
  onPointerMove,
60
85
  }: {
61
86
  keys: string[];
62
- slots?: { keys: string[]; slotOf: ( key: string ) => string };
87
+ slots?: { keys: string[]; slotOf: ( key: string ) => string; stack?: StackLayout };
63
88
  groupPadding: number;
64
89
  onPointerMove?: ( selection: PointerSelection ) => void;
65
90
  } & Pick< BarChartProps, 'withTooltips' | 'onPointerDown' | 'onPointerUp' > ) {
66
91
  const { xScale, yScale, dataRegistry, horizontal } = useContext( DataContext );
67
92
  const { showTooltip, hideTooltip, updateTooltip } = useContext( TooltipContext );
68
93
  const scale = ( horizontal ? yScale : xScale ) as BandScale | undefined;
94
+ const valueScale = ( horizontal ? xScale : yScale ) as ValueScale | undefined;
69
95
  const bandwidth = scale?.bandwidth?.() ?? 0;
70
96
  const groupScale = useMemo(
71
97
  () => createGroupScale( slots?.keys ?? keys, bandwidth, groupPadding ),
@@ -96,6 +122,15 @@ export function BandTooltip( {
96
122
  const end = start + groupScale.step();
97
123
  const distance =
98
124
  position >= start && position <= end ? 0 : Math.abs( position - ( start + end ) / 2 );
125
+ // Stacked segments share a slot, so the value axis tells them apart; one with no reading loses to any that has one.
126
+ const valueDistance =
127
+ slots?.stack && valueScale
128
+ ? distanceToSegment(
129
+ slots.stack.segmentOf( key, datum ),
130
+ valueScale,
131
+ horizontal ? point.x : point.y
132
+ )
133
+ : 0;
99
134
  bands.push( band );
100
135
  selections.push( {
101
136
  event: params.event,
@@ -103,8 +138,8 @@ export function BandTooltip( {
103
138
  datum,
104
139
  index,
105
140
  svgPoint: point,
106
- distanceX: horizontal ? 0 : distance,
107
- distanceY: horizontal ? distance : 0,
141
+ distanceX: horizontal ? valueDistance : distance,
142
+ distanceY: horizontal ? distance : valueDistance,
108
143
  } );
109
144
  }
110
145
  if ( ! slots ) {
@@ -119,7 +154,7 @@ export function BandTooltip( {
119
154
  );
120
155
  return selections.filter( ( _, index ) => bands[ index ] === hoveredBand );
121
156
  },
122
- [ scale, dataRegistry, horizontal, keys, slots, groupScale ]
157
+ [ scale, valueScale, dataRegistry, horizontal, keys, slots, groupScale ]
123
158
  );
124
159
  const handlePointer = useCallback< PointerHandler >(
125
160
  params => {
@@ -1,14 +1,18 @@
1
- import { BarGroup, BarSeries } from '@visx/xychart';
1
+ import { BarGroup, BarSeries, DataContext } from '@visx/xychart';
2
+ import { useContext } from 'react';
2
3
  import ClassifiedBarSeries from './classified-bar-series';
4
+ import { getValueScalePosition } from './comparison-bars-geometry';
3
5
  import { SelectedBarRing } from './selected-bar-ring';
4
6
  import SlottedBarSeries from './slotted-bar-series';
7
+ import StackedBarSeries from './stacked-bar-series';
8
+ import type { ValueScale } from './comparison-bars-geometry';
5
9
  import type { SelectedBar } from './selected-bar-ring';
6
- import type { OverlapSlots } from './use-overlap-slots';
10
+ import type { BarSlots } from './use-bar-slots';
7
11
  import type { DataPointDate, SeriesData } from '../../../types';
8
12
  import type { BarChartProps } from '../types';
9
13
  import type { AxisScale } from '@visx/axis';
10
14
  import type { SeriesProps } from '@visx/xychart';
11
- import type { FC } from 'react';
15
+ import type { FC, ReactNode } from 'react';
12
16
 
13
17
  type Accessors = Pick<
14
18
  SeriesProps< AxisScale, AxisScale, DataPointDate >,
@@ -17,7 +21,7 @@ type Accessors = Pick<
17
21
 
18
22
  type Props = Accessors & {
19
23
  chartId: string;
20
- overlapSlots: OverlapSlots | undefined;
24
+ barSlots: BarSlots | undefined;
21
25
  selectedBar: SelectedBar | undefined;
22
26
  primaryEntries: { series: SeriesData; index: number }[];
23
27
  primaryKeys: string[];
@@ -28,11 +32,40 @@ type Props = Accessors & {
28
32
  };
29
33
 
30
34
  /**
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.
35
+ * Wraps the stacked bars so the entrance animation grows them from the zero line.
36
+ *
37
+ * Reading the scales here, not in `BarLayer`, keeps the series' own data registration from re-rendering it in a loop.
38
+ *
39
+ * @param props - Component props.
40
+ * @param props.horizontal - Whether the bars run horizontally.
41
+ * @param props.children - The stacked series.
42
+ * @return The group.
43
+ */
44
+ const StackedBarGroup: FC< { horizontal: boolean; children: ReactNode } > = ( {
45
+ horizontal,
46
+ children,
47
+ } ) => {
48
+ const { xScale, yScale } = useContext( DataContext );
49
+ const valueScale = ( horizontal ? xScale : yScale ) as ValueScale | undefined;
50
+ const zero = valueScale ? getValueScalePosition( valueScale, 0 ) : 0;
51
+
52
+ return (
53
+ <g
54
+ className="visx-bar-group"
55
+ data-testid="bar-chart-stack-group"
56
+ style={ { transformOrigin: horizontal ? `${ zero }px 0` : `0 ${ zero }px` } }
57
+ >
58
+ { children }
59
+ </g>
60
+ );
61
+ };
62
+
63
+ /**
64
+ * Draws the bars in front of any comparison shadows, stacked or slotted in the layout's slots and grouped by visx otherwise, then the selected bar's ring.
32
65
  *
33
66
  * @param props - Component props.
34
67
  * @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.
68
+ * @param props.barSlots - The overlap or stacked slots, or undefined for a grouped layout.
36
69
  * @param props.selectedBar - The bar or slot the keyboard has selected.
37
70
  * @param props.primaryEntries - The visible primary series, for a grouped layout.
38
71
  * @param props.primaryKeys - Their labels, in group order.
@@ -46,7 +79,7 @@ type Props = Accessors & {
46
79
  */
47
80
  export const BarLayer: FC< Props > = ( {
48
81
  chartId,
49
- overlapSlots,
82
+ barSlots,
50
83
  selectedBar,
51
84
  primaryEntries,
52
85
  primaryKeys,
@@ -58,10 +91,30 @@ export const BarLayer: FC< Props > = ( {
58
91
  yAccessor,
59
92
  } ) => {
60
93
  let bars;
61
- if ( overlapSlots ) {
94
+ if ( barSlots?.kind === 'stacked' ) {
95
+ bars = (
96
+ <StackedBarGroup horizontal={ horizontal }>
97
+ { barSlots.entries.map( ( { series, index } ) => (
98
+ <StackedBarSeries
99
+ key={ series.label }
100
+ dataKey={ series.label }
101
+ data={ series.data as DataPointDate[] }
102
+ xAccessor={ xAccessor }
103
+ yAccessor={ yAccessor }
104
+ colorAccessor={ getBarBackground( index ) }
105
+ barClassName={ barClassName }
106
+ slotKeys={ barSlots.slotKeys }
107
+ slotKey={ barSlots.slotOf( series.label ) }
108
+ groupPadding={ groupPadding }
109
+ stack={ barSlots.stack }
110
+ />
111
+ ) ) }
112
+ </StackedBarGroup>
113
+ );
114
+ } else if ( barSlots ) {
62
115
  bars = (
63
116
  <g className="visx-bar-group">
64
- { overlapSlots.entries.map( ( { series, index } ) => (
117
+ { barSlots.entries.map( ( { series, index } ) => (
65
118
  <SlottedBarSeries
66
119
  key={ series.label }
67
120
  dataKey={ series.label }
@@ -70,9 +123,9 @@ export const BarLayer: FC< Props > = ( {
70
123
  yAccessor={ yAccessor }
71
124
  colorAccessor={ getBarBackground( index ) }
72
125
  barClassName={ barClassName }
73
- slotKeys={ overlapSlots.slotKeys }
74
- slotKey={ overlapSlots.slotOf( series.label ) }
75
- thickness={ overlapSlots.thicknessOf( series.label ) }
126
+ slotKeys={ barSlots.slotKeys }
127
+ slotKey={ barSlots.slotOf( series.label ) }
128
+ thickness={ barSlots.thicknessOf( series.label ) }
76
129
  groupPadding={ groupPadding }
77
130
  />
78
131
  ) ) }
@@ -120,7 +173,7 @@ export const BarLayer: FC< Props > = ( {
120
173
  <SelectedBarRing
121
174
  chartId={ chartId }
122
175
  selected={ selectedBar }
123
- slotKeys={ overlapSlots?.slotKeys ?? primaryKeys }
176
+ slotKeys={ barSlots?.slotKeys ?? primaryKeys }
124
177
  groupPadding={ groupPadding }
125
178
  horizontal={ horizontal }
126
179
  xAccessor={ xAccessor }
@@ -0,0 +1,25 @@
1
+ import type { DataPointDate } from '../../../types';
2
+
3
+ /**
4
+ * Whether two data points sit at the same category: the same label, or the same date.
5
+ *
6
+ * @param point - A data point.
7
+ * @param category - The data point naming the category.
8
+ * @return Whether `point` is at `category`.
9
+ */
10
+ export function isSameCategory( point: DataPointDate, category: DataPointDate ): boolean {
11
+ // An empty label falls back to the date, as the band axis's accessor does.
12
+ return category.label
13
+ ? point.label === category.label
14
+ : !! category.date && !! point.date && point.date.getTime() === category.date.getTime();
15
+ }
16
+
17
+ /**
18
+ * The category a data point sits at, as a key two points share when `isSameCategory` holds.
19
+ *
20
+ * @param point - A data point.
21
+ * @return The category key.
22
+ */
23
+ export function categoryKey( point: DataPointDate ): string {
24
+ return point.label ? `label:${ point.label }` : `date:${ point.date?.getTime() }`;
25
+ }
@@ -1,6 +1,6 @@
1
1
  export type SlotRect = { x: number; y: number; width: number; height: number };
2
2
 
3
- type ValueScale = ( ( v: number ) => number ) & { range: () => unknown[] };
3
+ export type ValueScale = ( ( v: number ) => number ) & { range: () => unknown[] };
4
4
 
5
5
  /**
6
6
  * Mirrors visx's getScaleBaseline so bars drawn outside visx share the baseline of its bars, including out-of-domain zero values.
@@ -21,6 +21,18 @@ export function getValueScaleBaseline( scale: ValueScale ): number {
21
21
  return Number.isFinite( maybeZero ) ? Math.max( maybeZero, minOutput ) : minOutput;
22
22
  }
23
23
 
24
+ /**
25
+ * A value's position on a continuous scale, held inside its range so a domain that leaves the value out cannot push a bar off the plot.
26
+ *
27
+ * @param {ValueScale} scale - The continuous value scale.
28
+ * @param {number} value - The value to place.
29
+ * @return {number} The position in pixels.
30
+ */
31
+ export function getValueScalePosition( scale: ValueScale, value: number ): number {
32
+ const [ a, b ] = scale.range().map( r => Number( r ) || 0 );
33
+ return Math.min( Math.max( scale( value ), Math.min( a, b ) ), Math.max( a, b ) );
34
+ }
35
+
24
36
  /**
25
37
  * Compute the rect that spans `valuePositions` along the value axis, centered on a slot and scaled by `widthFactor` across it.
26
38
  *
@@ -5,7 +5,9 @@ export { default as SlottedBarSeries } from './slotted-bar-series';
5
5
  export { BarLayer } from './bar-layer';
6
6
  export { SelectedBarRing, type SelectedBar } from './selected-bar-ring';
7
7
  export { SlotTooltipMembers, type SlotMember } from './slot-tooltip-members';
8
- export { useOverlapSlots, getSelectedSlot, isSameCategory, membersAt } from './use-overlap-slots';
8
+ export { useBarSlots, getSelectedSlot, membersAt } from './use-bar-slots';
9
+ export { isSameCategory } from './category';
10
+ export { getDrawnSeries } from './stack-layout';
9
11
  export { useBarStops } from './use-bar-stops';
10
12
  export { ComparisonBars } from './comparison-bars';
11
13
  export type { ComparisonSeriesEntry } from './comparison-bars';
@@ -2,19 +2,25 @@ import { DataContext } from '@visx/xychart';
2
2
  import { useContext } from 'react';
3
3
  import { SelectedRing } from '../../private/selected-ring/selected-ring';
4
4
  import { createGroupScale } from './band-scale';
5
- import { computeSlotRect, getValueScaleBaseline } from './comparison-bars-geometry';
5
+ import {
6
+ computeSlotRect,
7
+ getValueScaleBaseline,
8
+ getValueScalePosition,
9
+ } from './comparison-bars-geometry';
6
10
  import type { BandScale } from './band-scale';
7
11
  import type { DataPointDate } from '../../../types';
8
12
  import type { FC } from 'react';
9
13
 
10
14
  /** The slot the keyboard has selected. */
11
15
  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). */
16
+ /** The data of the bars the slot draws at the selected category, which the ring encloses unless `valueExtent` is set; only bars the chart draws (a finite value). */
13
17
  data: DataPointDate[];
14
18
  /** The selected slot. */
15
19
  slotKey: string;
16
20
  /** The thickness of the slot's widest bar, as a share of the slot. */
17
21
  thickness: number;
22
+ /** The values the ring spans instead of the baseline and each bar's value, for a stack. */
23
+ valueExtent?: [ number, number ];
18
24
  };
19
25
 
20
26
  type AnyScale = ( ( input: unknown ) => number ) &
@@ -61,16 +67,21 @@ export const SelectedBarRing: FC< {
61
67
  return null;
62
68
  }
63
69
 
64
- const valuePositions = selected.data.map( datum =>
65
- Number( valueScale( Number( ( horizontal ? xAccessor : yAccessor )( datum ) ) as never ) )
66
- );
70
+ const valuePositions = selected.valueExtent
71
+ ? selected.valueExtent.map( value => getValueScalePosition( valueScale, value ) )
72
+ : [
73
+ getValueScaleBaseline( valueScale ),
74
+ ...selected.data.map( datum =>
75
+ Number( valueScale( Number( ( horizontal ? xAccessor : yAccessor )( datum ) ) as never ) )
76
+ ),
77
+ ];
67
78
 
68
79
  const rect = computeSlotRect( {
69
80
  horizontal,
70
81
  bandPosition,
71
82
  slotOffset,
72
83
  slotThickness: groupScale.bandwidth(),
73
- valuePositions: [ getValueScaleBaseline( valueScale ), ...valuePositions ],
84
+ valuePositions,
74
85
  widthFactor: selected.thickness,
75
86
  } );
76
87
  return (
@@ -0,0 +1,99 @@
1
+ import { getTickDecimalsDomain } from '../../private/tick-decimals';
2
+ import { categoryKey } from './category';
3
+ import { EMPTY_DOMAIN, getBarValue } from './value-domain';
4
+ import type { BarChartLayout, DataPointDate, SeriesData } from '../../../types';
5
+
6
+ /** Where a bar runs along the value axis: from where the stack reached before it, to where it ends. */
7
+ export type StackSegment = { start: number; end: number };
8
+
9
+ /** The one source of a stacked layout's value-axis positions, for its bars, ring, tooltip and domain. */
10
+ export type StackLayout = {
11
+ /** Where a series' bar runs at a datum's category; undefined when that datum has no reading. */
12
+ segmentOf: ( key: string, datum: DataPointDate ) => StackSegment | undefined;
13
+ /** The lowest negative sum and highest positive sum at the datum's category, both including 0. */
14
+ extentAt: ( category: DataPointDate ) => [ number, number ];
15
+ /** The widest `extentAt` over every category, or null without a finite value. */
16
+ extent: [ number, number ] | null;
17
+ };
18
+
19
+ /**
20
+ * The series a layout draws: a stacked layout leaves comparison series out for now.
21
+ *
22
+ * @param series - The chart's series.
23
+ * @param layout - The layout the bars are drawn in.
24
+ * @return The drawn series.
25
+ */
26
+ export function getDrawnSeries< T extends SeriesData >(
27
+ series: T[],
28
+ layout?: BarChartLayout
29
+ ): T[] {
30
+ return layout === 'stacked'
31
+ ? series.filter( item => item.options?.type !== 'comparison' )
32
+ : series;
33
+ }
34
+
35
+ /**
36
+ * Stacks the series in order at each category: positives upward and negatives downward from zero.
37
+ *
38
+ * @param series - The series to stack, bottom first.
39
+ * @return The stack layout.
40
+ */
41
+ export function getStackLayout( series: SeriesData[] ): StackLayout {
42
+ const segments = new Map< string, Map< string, StackSegment > >();
43
+ const extents = new Map< string, [ number, number ] >();
44
+
45
+ for ( const { label, data } of series ) {
46
+ const byCategory = new Map< string, StackSegment >();
47
+ segments.set( label, byCategory );
48
+ for ( const point of data as DataPointDate[] ) {
49
+ const value = getBarValue( point );
50
+ if ( ! Number.isFinite( value ) ) {
51
+ continue;
52
+ }
53
+ const key = categoryKey( point );
54
+ // A repeated category is invalid data; skipping it keeps the bars, total and tooltip in step.
55
+ if ( byCategory.has( key ) ) {
56
+ continue;
57
+ }
58
+ const extent = extents.get( key ) ?? [ 0, 0 ];
59
+ const side = value < 0 ? 0 : 1;
60
+ const start = extent[ side ];
61
+ extent[ side ] = start + value;
62
+ extents.set( key, extent );
63
+ byCategory.set( key, { start, end: start + value } );
64
+ }
65
+ }
66
+
67
+ let extent: [ number, number ] | null = null;
68
+ for ( const [ low, high ] of extents.values() ) {
69
+ extent = extent
70
+ ? [ Math.min( extent[ 0 ], low ), Math.max( extent[ 1 ], high ) ]
71
+ : [ low, high ];
72
+ }
73
+
74
+ return {
75
+ segmentOf: ( key, datum ) => segments.get( key )?.get( categoryKey( datum ) ),
76
+ extentAt: category => {
77
+ const [ low, high ] = extents.get( categoryKey( category ) ) ?? [ 0, 0 ];
78
+ return [ low, high ];
79
+ },
80
+ extent,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * The value domain of a computed stack, falling back to an empty chart's domain.
86
+ *
87
+ * @param stack - The precomputed stack.
88
+ * @param tickDecimals - The value axis precision, when set.
89
+ * @return The extent widened for tick precision, or the empty chart domain.
90
+ */
91
+ export function getStackedValueDomain(
92
+ stack: StackLayout,
93
+ tickDecimals?: number
94
+ ): [ number, number ] {
95
+ const { extent } = stack;
96
+ const domain: [ number, number ] =
97
+ ! extent || extent[ 0 ] === extent[ 1 ] ? [ ...EMPTY_DOMAIN ] : extent;
98
+ return getTickDecimalsDomain( domain, tickDecimals ) ?? domain;
99
+ }
@@ -0,0 +1,145 @@
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, getValueScalePosition } from './comparison-bars-geometry';
6
+ import type { BandScale } from './band-scale';
7
+ import type { ValueScale } from './comparison-bars-geometry';
8
+ import type { StackLayout, StackSegment } from './stack-layout';
9
+ import type { DataPointDate } from '../../../types';
10
+ import type { BarChartProps } from '../types';
11
+ import type { AxisScale } from '@visx/axis';
12
+ import type { GlyphProps, SeriesProps } from '@visx/xychart';
13
+
14
+ type Props = Pick<
15
+ SeriesProps< AxisScale, AxisScale, DataPointDate >,
16
+ 'xAccessor' | 'yAccessor'
17
+ > & {
18
+ dataKey: string;
19
+ data: DataPointDate[];
20
+ colorAccessor: ( datum: DataPointDate ) => string;
21
+ barClassName?: BarChartProps[ 'barClassName' ];
22
+ /** The slots of a category, in the order the band lays them out. */
23
+ slotKeys: string[];
24
+ /** The slot the stack sits in. */
25
+ slotKey: string;
26
+ groupPadding: number;
27
+ stack: StackLayout;
28
+ };
29
+
30
+ /**
31
+ * The side a segment rounds when it ends its drawn stack: the far end of the positives or of the negatives.
32
+ *
33
+ * @param stack - The stack layout.
34
+ * @param segment - The segment.
35
+ * @param category - A datum at the segment's category.
36
+ * @param horizontal - Whether the bars run horizontally.
37
+ * @param clamp - Holds a value inside the value domain, where the bars are cut off.
38
+ * @return The side to round, or undefined for a segment inside its stack or drawn with no length.
39
+ */
40
+ function getStackEnd(
41
+ stack: StackLayout,
42
+ segment: StackSegment,
43
+ category: DataPointDate,
44
+ horizontal: boolean,
45
+ clamp: ( value: number ) => number
46
+ ): 'top' | 'bottom' | 'left' | 'right' | undefined {
47
+ const [ start, end ] = [ segment.start, segment.end ].map( clamp );
48
+ if ( start === end ) {
49
+ return undefined;
50
+ }
51
+ const extent = stack.extentAt( category ).map( clamp );
52
+ if ( segment.end > 0 && end === extent[ 1 ] ) {
53
+ return horizontal ? 'right' : 'top';
54
+ }
55
+ if ( segment.end < 0 && end === extent[ 0 ] ) {
56
+ return horizontal ? 'left' : 'bottom';
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ const StackedBarSeries = ( {
62
+ barClassName,
63
+ slotKeys,
64
+ slotKey,
65
+ groupPadding,
66
+ stack,
67
+ ...props
68
+ }: Props ) => {
69
+ const { xScale, yScale, horizontal } = useContext( DataContext );
70
+ const bandScale = ( horizontal ? yScale : xScale ) as BandScale | undefined;
71
+ const valueScale = ( horizontal ? xScale : yScale ) as ValueScale | undefined;
72
+ const groupScale = useMemo(
73
+ () => createGroupScale( slotKeys, bandScale?.bandwidth?.() ?? 0, groupPadding ),
74
+ [ slotKeys, bandScale, groupPadding ]
75
+ );
76
+ const { dataKey, xAccessor, yAccessor } = props;
77
+ const [ domainStart, domainEnd ] = ( horizontal ? xScale : yScale )?.domain() ?? [
78
+ -Infinity,
79
+ Infinity,
80
+ ];
81
+ const domainMin = Math.min( Number( domainStart ), Number( domainEnd ) );
82
+ const domainMax = Math.max( Number( domainStart ), Number( domainEnd ) );
83
+ const clamp = useCallback(
84
+ ( value: number ) => Math.min( domainMax, Math.max( domainMin, value ) ),
85
+ [ domainMin, domainMax ]
86
+ );
87
+ // The positive end, or the negative end when nothing is positive; clamped so a caller domain keeps it on the drawn bar.
88
+ const stackEnd = useCallback(
89
+ ( datum: DataPointDate ) => {
90
+ const [ low, high ] = stack.extentAt( datum );
91
+ return clamp( high > 0 ? high : low );
92
+ },
93
+ [ stack, clamp ]
94
+ );
95
+ const renderGlyph = useCallback(
96
+ ( { datum, color }: GlyphProps< DataPointDate > ) => {
97
+ const segment = stack.segmentOf( dataKey, datum );
98
+ if ( ! segment || ! bandScale?.bandwidth || ! valueScale ) return null;
99
+ const rect = computeSlotRect( {
100
+ horizontal: !! horizontal,
101
+ bandPosition: Number( bandScale( ( horizontal ? yAccessor : xAccessor )( datum ) ) ),
102
+ slotOffset: groupScale( slotKey ) ?? 0,
103
+ slotThickness: groupScale.bandwidth(),
104
+ valuePositions: [ segment.start, segment.end ].map( value =>
105
+ getValueScalePosition( valueScale, value )
106
+ ),
107
+ widthFactor: 1,
108
+ } );
109
+ return (
110
+ <rect
111
+ { ...rect }
112
+ data-testid="bar-chart-stacked-bar"
113
+ data-stack-end={ getStackEnd( stack, segment, datum, !! horizontal, clamp ) }
114
+ className={ clsx( 'visx-bar', barClassName?.( datum ) ) }
115
+ fill={ color }
116
+ />
117
+ );
118
+ },
119
+ [
120
+ stack,
121
+ clamp,
122
+ dataKey,
123
+ bandScale,
124
+ valueScale,
125
+ horizontal,
126
+ groupScale,
127
+ slotKey,
128
+ xAccessor,
129
+ yAccessor,
130
+ barClassName,
131
+ ]
132
+ );
133
+ // BandTooltip owns pointer selection, as for slotted bars.
134
+ return (
135
+ <GlyphSeries
136
+ { ...props }
137
+ xAccessor={ horizontal ? stackEnd : xAccessor }
138
+ yAccessor={ horizontal ? yAccessor : stackEnd }
139
+ enableEvents={ false }
140
+ renderGlyph={ renderGlyph }
141
+ />
142
+ );
143
+ };
144
+
145
+ export default StackedBarSeries;
@@ -0,0 +1,81 @@
1
+ import { getStackLayout, getStackedValueDomain } from '../stack-layout';
2
+ import type { DataPointDate, SeriesData } from '../../../../types';
3
+
4
+ const s = (
5
+ label: string,
6
+ values: Array< number | null >,
7
+ days = [ 'Mon', 'Tue' ]
8
+ ): SeriesData => ( {
9
+ label,
10
+ data: values.map( ( value, index ) => ( { label: days[ index ], value } ) ),
11
+ } );
12
+
13
+ const at = ( label: string ): DataPointDate => ( { label, value: 0 } );
14
+
15
+ const SERIES = [
16
+ s( 'Desktop', [ 100, 50 ] ),
17
+ s( 'Mobile', [ 60, null ] ),
18
+ s( 'Tablet', [ 20, 10 ] ),
19
+ ];
20
+
21
+ describe( 'getStackLayout', () => {
22
+ it( 'runs each series from the top of the series before it at the same category', () => {
23
+ const { segmentOf } = getStackLayout( SERIES );
24
+ expect( [ 'Desktop', 'Mobile', 'Tablet' ].map( key => segmentOf( key, at( 'Mon' ) ) ) ).toEqual(
25
+ [
26
+ { start: 0, end: 100 },
27
+ { start: 100, end: 160 },
28
+ { start: 160, end: 180 },
29
+ ]
30
+ );
31
+ } );
32
+
33
+ it( 'gives a missing reading no segment and stacks past it', () => {
34
+ const { segmentOf } = getStackLayout( SERIES );
35
+ expect( segmentOf( 'Mobile', at( 'Tue' ) ) ).toBeUndefined();
36
+ expect( segmentOf( 'Tablet', at( 'Tue' ) ) ).toEqual( { start: 50, end: 60 } );
37
+ } );
38
+
39
+ it( 'stacks negatives downward apart from positives', () => {
40
+ const layout = getStackLayout( [
41
+ s( 'A', [ 10 ] ),
42
+ s( 'B', [ -5 ] ),
43
+ s( 'C', [ -3 ] ),
44
+ s( 'D', [ 4 ] ),
45
+ ] );
46
+ expect( layout.segmentOf( 'C', at( 'Mon' ) ) ).toEqual( { start: -5, end: -8 } );
47
+ expect( layout.segmentOf( 'D', at( 'Mon' ) ) ).toEqual( { start: 10, end: 14 } );
48
+ expect( layout.extentAt( at( 'Mon' ) ) ).toEqual( [ -8, 14 ] );
49
+ } );
50
+
51
+ it( 'keeps the first reading of a category a series repeats', () => {
52
+ const layout = getStackLayout( [ s( 'A', [ 50, 30 ], [ 'Mon', 'Mon' ] ), s( 'B', [ 20 ] ) ] );
53
+ expect( layout.segmentOf( 'A', at( 'Mon' ) ) ).toEqual( { start: 0, end: 50 } );
54
+ expect( layout.segmentOf( 'B', at( 'Mon' ) ) ).toEqual( { start: 50, end: 70 } );
55
+ expect( layout.extentAt( at( 'Mon' ) ) ).toEqual( [ 0, 70 ] );
56
+ } );
57
+
58
+ it( 'matches dated points by time', () => {
59
+ const dated = ( label: string, value: number ): SeriesData => ( {
60
+ label,
61
+ data: [ { date: new Date( 2026, 4, 13 ), value } ],
62
+ } );
63
+ const { segmentOf } = getStackLayout( [ dated( 'A', 30 ), dated( 'B', 12 ) ] );
64
+ expect( segmentOf( 'B', { date: new Date( 2026, 4, 13 ), value: 12 } ) ).toEqual( {
65
+ start: 30,
66
+ end: 42,
67
+ } );
68
+ } );
69
+
70
+ it( 'spans the widest category, or nothing without a reading', () => {
71
+ expect( getStackLayout( SERIES ).extent ).toEqual( [ 0, 180 ] );
72
+ expect( getStackLayout( [ s( 'A', [ null ] ) ] ).extent ).toBeNull();
73
+ } );
74
+ } );
75
+
76
+ describe( 'getStackedValueDomain', () => {
77
+ it( 'reads 0 to 1 with nothing to stack', () => {
78
+ expect( getStackedValueDomain( getStackLayout( [ s( 'A', [ null ] ) ] ) ) ).toEqual( [ 0, 1 ] );
79
+ expect( getStackedValueDomain( getStackLayout( [ s( 'A', [ 0 ] ) ] ) ) ).toEqual( [ 0, 1 ] );
80
+ } );
81
+ } );