@automattic/charts 4.14.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.
- package/CHANGELOG.md +10 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +1249 -325
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +31 -0
- package/dist/index.d.cts +41 -7
- package/dist/index.d.ts +41 -7
- package/dist/index.js +964 -325
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
- package/src/charts/area-chart/area-chart.tsx +29 -20
- package/src/charts/area-chart/types.ts +3 -1
- package/src/charts/bar-chart/bar-chart.module.scss +38 -0
- package/src/charts/bar-chart/bar-chart.tsx +159 -56
- package/src/charts/bar-chart/private/band-tooltip.tsx +40 -5
- package/src/charts/bar-chart/private/bar-layer.tsx +66 -13
- package/src/charts/bar-chart/private/category.ts +25 -0
- package/src/charts/bar-chart/private/comparison-bars-geometry.ts +13 -1
- package/src/charts/bar-chart/private/index.ts +3 -1
- package/src/charts/bar-chart/private/selected-bar-ring.tsx +17 -6
- package/src/charts/bar-chart/private/stack-layout.ts +99 -0
- package/src/charts/bar-chart/private/stacked-bar-series.tsx +145 -0
- package/src/charts/bar-chart/private/test/stack-layout.test.ts +81 -0
- package/src/charts/bar-chart/private/test/value-domain.test.ts +10 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +35 -11
- package/src/charts/bar-chart/private/{use-overlap-slots.ts → use-bar-slots.ts} +36 -38
- package/src/charts/bar-chart/private/use-bar-stops.ts +19 -19
- package/src/charts/bar-chart/private/value-domain.ts +10 -8
- package/src/charts/bar-chart/test/bar-chart-stacked.test.tsx +546 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +75 -0
- package/src/charts/bar-chart/test/use-bar-legend-items.test.tsx +18 -0
- package/src/charts/bar-chart/types.ts +3 -1
- package/src/charts/bar-chart/use-bar-colors.ts +12 -0
- package/src/charts/bar-chart/use-bar-legend-items.ts +4 -2
- package/src/charts/bar-list-chart/bar-list-chart.tsx +2 -0
- package/src/charts/line-chart/line-chart.tsx +55 -26
- package/src/charts/line-chart/test/line-chart.test.tsx +86 -0
- package/src/charts/line-chart/types.ts +3 -1
- package/src/charts/private/test/compact-when-short.test.tsx +170 -0
- package/src/charts/private/test/tick-decimals.test.tsx +95 -0
- package/src/charts/private/tick-decimals.tsx +132 -0
- package/src/charts/private/use-compact-when-short.ts +18 -0
- package/src/providers/chart-context/themes.ts +4 -0
- package/src/types.ts +35 -2
- package/src/charts/private/test/whole-number-ticks.test.tsx +0 -57
- 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,
|
|
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 ?
|
|
107
|
-
distanceY: horizontal ? distance :
|
|
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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
|
|
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 (
|
|
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
|
-
{
|
|
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={
|
|
74
|
-
slotKey={
|
|
75
|
-
thickness={
|
|
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={
|
|
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 {
|
|
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 {
|
|
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.
|
|
65
|
-
|
|
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
|
|
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
|
+
} );
|