@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.
- package/CHANGELOG.md +34 -29
- package/TOKENS.md +4 -1
- package/dist/index.cjs +1069 -1620
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +33 -1
- package/dist/index.d.cts +60 -15
- package/dist/index.d.ts +60 -15
- package/dist/index.js +1072 -1575
- package/dist/index.js.map +1 -1
- package/package.json +11 -10
- 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/leaderboard-chart/leaderboard-chart.module.scss +1 -1
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +26 -9
- package/src/charts/leaderboard-chart/private/resolve-leaderboard-entries.ts +53 -0
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +41 -0
- package/src/charts/leaderboard-chart/test/resolve-leaderboard-entries.test.ts +146 -0
- package/src/charts/leaderboard-chart/types.ts +6 -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/components/legend/private/base-legend.tsx +13 -8
- package/src/components/legend/test/legend.test.tsx +125 -0
- package/src/components/legend/types.ts +9 -1
- package/src/providers/chart-context/themes.ts +4 -0
- package/src/styles/chart-scope.scss +1 -0
- package/src/types.ts +40 -9
- package/src/charts/private/test/whole-number-ticks.test.tsx +0 -57
- package/src/charts/private/whole-number-ticks.tsx +0 -79
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useMemo } from 'react';
|
|
2
2
|
import { useChartLegendItems } from '../../components/legend/hooks/use-chart-legend-items';
|
|
3
|
+
import { getDrawnSeries } from './private/stack-layout';
|
|
3
4
|
import { useBarColors } from './use-bar-colors';
|
|
4
5
|
import type { ChartLegendOptions } from '../../components/legend/hooks/use-chart-legend-items';
|
|
5
6
|
import type { BaseLegendItem } from '../../components/legend/types';
|
|
@@ -58,17 +59,18 @@ function forBarLayout(
|
|
|
58
59
|
/**
|
|
59
60
|
* Builds legend items that match `BarChart`'s bars in a layout, for a custom legend or a chart's own.
|
|
60
61
|
*
|
|
61
|
-
* @param
|
|
62
|
+
* @param allData - The chart's series; those the layout does not draw get no item.
|
|
62
63
|
* @param options - The legend options, and the layout the bars are drawn in.
|
|
63
64
|
* @param legendShape - The shape of the legend swatches.
|
|
64
65
|
* @return The legend items.
|
|
65
66
|
*/
|
|
66
67
|
export function useBarLegendItems(
|
|
67
|
-
|
|
68
|
+
allData: SeriesData[],
|
|
68
69
|
options: BarLegendOptions = {},
|
|
69
70
|
legendShape?: LegendShape< SeriesData[], number >
|
|
70
71
|
): BaseLegendItem[] {
|
|
71
72
|
const { layout } = options;
|
|
73
|
+
const data = useMemo( () => getDrawnSeries( allData, layout ), [ allData, layout ] );
|
|
72
74
|
const getBarColors = useBarColors();
|
|
73
75
|
const seriesColors = useMemo(
|
|
74
76
|
() => ( layout ? getBarColors( data, { layout } ) : undefined ),
|
|
@@ -20,6 +20,8 @@ export interface BarListChartProps extends Exclude<
|
|
|
20
20
|
BarChartProps,
|
|
21
21
|
'orientation' | 'size' | 'gridVisibility'
|
|
22
22
|
> {
|
|
23
|
+
/** Bar lists are always horizontal, and horizontal bars ignore `compactWhenShort`. */
|
|
24
|
+
compactWhenShort?: never;
|
|
23
25
|
options?: {
|
|
24
26
|
/**
|
|
25
27
|
* Scale for the y axis. Exclude the type property.
|
|
@@ -23,6 +23,10 @@ import { getAllHiddenMessage } from '../private/svg-empty-state';
|
|
|
23
23
|
import { withResponsive } from '../private/with-responsive';
|
|
24
24
|
import { useFittedRowCount, useLeaderboardLegendItems } from './hooks';
|
|
25
25
|
import styles from './leaderboard-chart.module.scss';
|
|
26
|
+
import {
|
|
27
|
+
resolveLeaderboardEntries,
|
|
28
|
+
type ResolvedLeaderboardEntry,
|
|
29
|
+
} from './private/resolve-leaderboard-entries';
|
|
26
30
|
import type { LeaderboardChartProps } from './types';
|
|
27
31
|
import type { LeaderboardEntry } from '../../types';
|
|
28
32
|
|
|
@@ -75,15 +79,15 @@ const getBarWidth = ( share: number ): string =>
|
|
|
75
79
|
`calc(${ share }% - var(--a8c-charts-dimension-leaderboard-bar-hover-inset, 0px) * ${ share } / 100)`;
|
|
76
80
|
|
|
77
81
|
const hasPreviousValue = (
|
|
78
|
-
entry:
|
|
79
|
-
): entry is
|
|
82
|
+
entry: ResolvedLeaderboardEntry
|
|
83
|
+
): entry is ResolvedLeaderboardEntry & {
|
|
80
84
|
previousValue: number;
|
|
81
85
|
previousShare: number;
|
|
82
86
|
} => entry.previousValue != null && entry.previousShare != null;
|
|
83
87
|
|
|
84
88
|
const hasDeltaValue = (
|
|
85
|
-
entry:
|
|
86
|
-
): entry is
|
|
89
|
+
entry: ResolvedLeaderboardEntry
|
|
90
|
+
): entry is ResolvedLeaderboardEntry & {
|
|
87
91
|
previousValue: number;
|
|
88
92
|
previousShare: number;
|
|
89
93
|
delta: number;
|
|
@@ -128,6 +132,7 @@ const Bar = ( {
|
|
|
128
132
|
|
|
129
133
|
const bar = (
|
|
130
134
|
<div
|
|
135
|
+
data-testid="leaderboard-chart-bar"
|
|
131
136
|
className={ clsx( styles.bar, {
|
|
132
137
|
[ styles[ 'bar--animated' ] ]: animation,
|
|
133
138
|
[ styles[ 'bar--negative' ] ]: divergingScale && isNegative,
|
|
@@ -167,7 +172,7 @@ const BarWithLabel = ( {
|
|
|
167
172
|
isPrimaryVisible = true,
|
|
168
173
|
isComparisonVisible = true,
|
|
169
174
|
}: {
|
|
170
|
-
entry:
|
|
175
|
+
entry: ResolvedLeaderboardEntry;
|
|
171
176
|
withComparison?: boolean;
|
|
172
177
|
withOverlayLabel?: boolean;
|
|
173
178
|
primaryColor: string;
|
|
@@ -225,6 +230,7 @@ const BarWithLabel = ( {
|
|
|
225
230
|
* @param props.height - Optional height of the chart container in pixels
|
|
226
231
|
* @param props.withComparison - Whether to show comparison data
|
|
227
232
|
* @param props.withOverlayLabel - Whether to overlay the label on top of the bar
|
|
233
|
+
* @param props.negativeBars - How a negative value draws: from a shared zero point, or as an empty bar
|
|
228
234
|
* @param props.primaryColor - Primary color for current period bars
|
|
229
235
|
* @param props.secondaryColor - Secondary color for comparison period bars
|
|
230
236
|
* @param props.valueFormatter - Custom formatter for values
|
|
@@ -249,6 +255,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
249
255
|
height: propHeight,
|
|
250
256
|
withComparison = false,
|
|
251
257
|
withOverlayLabel = false,
|
|
258
|
+
negativeBars = 'diverge',
|
|
252
259
|
primaryColor,
|
|
253
260
|
secondaryColor,
|
|
254
261
|
valueFormatter = defaultValueFormatter,
|
|
@@ -318,11 +325,21 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
318
325
|
return ! isPrimaryVisible;
|
|
319
326
|
}, [ isPrimaryVisible, isComparisonVisible, withComparison, withOverlayLabel ] );
|
|
320
327
|
|
|
328
|
+
const withComparisonBars = withComparison && ! withOverlayLabel;
|
|
329
|
+
|
|
330
|
+
const resolvedData = useMemo(
|
|
331
|
+
() =>
|
|
332
|
+
resolveLeaderboardEntries( data ?? [], {
|
|
333
|
+
includePrevious: withComparisonBars,
|
|
334
|
+
clampNegative: negativeBars === 'clamp',
|
|
335
|
+
} ),
|
|
336
|
+
[ data, withComparisonBars, negativeBars ]
|
|
337
|
+
);
|
|
338
|
+
|
|
321
339
|
// One negative share puts every row on a shared zero point, so the bars stay comparable.
|
|
322
340
|
const divergingScale = useMemo( (): DivergingScale | undefined => {
|
|
323
|
-
const withComparisonBars = withComparison && ! withOverlayLabel;
|
|
324
341
|
const scale = { negative: 0, positive: 0 };
|
|
325
|
-
for ( const entry of
|
|
342
|
+
for ( const entry of resolvedData ) {
|
|
326
343
|
const shares = [ entry.currentShare ];
|
|
327
344
|
if ( withComparisonBars && hasPreviousValue( entry ) ) {
|
|
328
345
|
shares.push( entry.previousShare );
|
|
@@ -333,7 +350,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
333
350
|
}
|
|
334
351
|
}
|
|
335
352
|
return scale.negative > 0 ? scale : undefined;
|
|
336
|
-
}, [
|
|
353
|
+
}, [ resolvedData, withComparisonBars ] );
|
|
337
354
|
|
|
338
355
|
// Validate data
|
|
339
356
|
const isDataValid = Boolean( data && data.length > 0 );
|
|
@@ -471,7 +488,7 @@ const LeaderboardChartInternal: FC< LeaderboardChartProps > = ( {
|
|
|
471
488
|
columnGap={ COLUMN_GAP }
|
|
472
489
|
data-leaderboard-grid
|
|
473
490
|
>
|
|
474
|
-
{
|
|
491
|
+
{ resolvedData.map( ( entry, rowIndex ) => {
|
|
475
492
|
// visibility, not clipping: hidden rows keep their geometry (so a
|
|
476
493
|
// taller container can reveal them) but leave hit testing, focus,
|
|
477
494
|
// and the accessibility tree.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { LeaderboardEntry } from '../../../types';
|
|
2
|
+
|
|
3
|
+
export type ResolvedLeaderboardEntry = LeaderboardEntry & { currentShare: number };
|
|
4
|
+
|
|
5
|
+
const toShare = ( value: number, max: number ) => ( max === 0 ? 0 : ( value / max ) * 100 );
|
|
6
|
+
|
|
7
|
+
const toDelta = ( currentValue: number, previousValue: number | undefined ) => {
|
|
8
|
+
if ( previousValue == null ) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
if ( previousValue === 0 ) {
|
|
12
|
+
return currentValue === 0 ? 0 : undefined;
|
|
13
|
+
}
|
|
14
|
+
return ( ( currentValue - previousValue ) / Math.abs( previousValue ) ) * 100;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Fills in the shares and delta an entry omits, derived from its values.
|
|
19
|
+
*
|
|
20
|
+
* @param data - Leaderboard entries; passed shares and delta win over derived ones.
|
|
21
|
+
* @param options - Resolver options.
|
|
22
|
+
* @param options.includePrevious - Whether previous values count toward the shared max.
|
|
23
|
+
* @param options.clampNegative - Whether a negative share draws as an empty bar instead of below zero.
|
|
24
|
+
* @return Entries with `currentShare` always set.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveLeaderboardEntries(
|
|
27
|
+
data: LeaderboardEntry[],
|
|
28
|
+
{ includePrevious, clampNegative = false }: { includePrevious: boolean; clampNegative?: boolean }
|
|
29
|
+
): ResolvedLeaderboardEntry[] {
|
|
30
|
+
const magnitude = clampNegative ? ( value: number ) => Math.max( value, 0 ) : Math.abs;
|
|
31
|
+
const clamp = ( share: number ) => ( clampNegative ? Math.max( share, 0 ) : share );
|
|
32
|
+
|
|
33
|
+
const max = data.reduce( ( largest, { currentValue, previousValue } ) => {
|
|
34
|
+
const previous = includePrevious && previousValue != null ? magnitude( previousValue ) : 0;
|
|
35
|
+
return Math.max( largest, magnitude( currentValue ), previous );
|
|
36
|
+
}, 0 );
|
|
37
|
+
|
|
38
|
+
return data.map( entry => {
|
|
39
|
+
const passedOrDerivedPrevious =
|
|
40
|
+
entry.previousShare ??
|
|
41
|
+
( entry.previousValue != null ? toShare( entry.previousValue, max ) : undefined );
|
|
42
|
+
const previousShare =
|
|
43
|
+
passedOrDerivedPrevious !== undefined ? clamp( passedOrDerivedPrevious ) : undefined;
|
|
44
|
+
const delta = entry.delta ?? toDelta( entry.currentValue, entry.previousValue );
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
...entry,
|
|
48
|
+
currentShare: clamp( entry.currentShare ?? toShare( entry.currentValue, max ) ),
|
|
49
|
+
...( previousShare !== undefined && { previousShare } ),
|
|
50
|
+
...( delta !== undefined && { delta } ),
|
|
51
|
+
};
|
|
52
|
+
} );
|
|
53
|
+
}
|
|
@@ -81,6 +81,22 @@ describe( 'LeaderboardChart', () => {
|
|
|
81
81
|
expect( screen.getByText( '-8%' ) ).toBeInTheDocument();
|
|
82
82
|
} );
|
|
83
83
|
|
|
84
|
+
it( 'derives the delta of entries that pass only values', () => {
|
|
85
|
+
render(
|
|
86
|
+
<LeaderboardChart
|
|
87
|
+
data={ [
|
|
88
|
+
{ id: 'up', label: 'Up', currentValue: 100, previousValue: 80 },
|
|
89
|
+
{ id: 'down', label: 'Down', currentValue: 50, previousValue: 100 },
|
|
90
|
+
] }
|
|
91
|
+
withComparison={ true }
|
|
92
|
+
deltaFormatter={ testDeltaFormatter }
|
|
93
|
+
/>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect( screen.getByText( '25delta' ) ).toBeInTheDocument();
|
|
97
|
+
expect( screen.getByText( '-50delta' ) ).toBeInTheDocument();
|
|
98
|
+
} );
|
|
99
|
+
|
|
84
100
|
it( 'shows a placeholder when a row has no previous value', () => {
|
|
85
101
|
render(
|
|
86
102
|
<LeaderboardChart
|
|
@@ -170,6 +186,31 @@ describe( 'LeaderboardChart', () => {
|
|
|
170
186
|
expect( screen.queryAllByTestId( 'leaderboard-chart-diverging-row' ) ).toHaveLength( expected );
|
|
171
187
|
} );
|
|
172
188
|
|
|
189
|
+
it( 'keeps every bar on one side when negative bars clamp', () => {
|
|
190
|
+
const data = [
|
|
191
|
+
{ ...mockData[ 0 ], currentShare: undefined, currentValue: -40 },
|
|
192
|
+
mockData[ 1 ],
|
|
193
|
+
];
|
|
194
|
+
render( <LeaderboardChart data={ data } withComparison negativeBars="clamp" /> );
|
|
195
|
+
|
|
196
|
+
expect( screen.queryAllByTestId( 'leaderboard-chart-diverging-row' ) ).toHaveLength( 0 );
|
|
197
|
+
} );
|
|
198
|
+
|
|
199
|
+
it.each( [
|
|
200
|
+
[ 'comparison bars draw', false, /^calc\(50%/ ],
|
|
201
|
+
[ 'the overlay label hides comparison bars', true, /^calc\(100%/ ],
|
|
202
|
+
] )( 'sizes the current bar against the max when %s', ( _, withOverlayLabel, expected ) => {
|
|
203
|
+
const data = [
|
|
204
|
+
{ id: 'a', label: 'A', currentValue: 50, previousValue: 100 },
|
|
205
|
+
{ id: 'b', label: 'B', currentValue: 25, previousValue: 10 },
|
|
206
|
+
] as LeaderboardEntry[];
|
|
207
|
+
render(
|
|
208
|
+
<LeaderboardChart data={ data } withComparison withOverlayLabel={ withOverlayLabel } />
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect( screen.getAllByTestId( 'leaderboard-chart-bar' )[ 0 ].style.width ).toMatch( expected );
|
|
212
|
+
} );
|
|
213
|
+
|
|
173
214
|
it( 'shows custom label when provided', () => {
|
|
174
215
|
render(
|
|
175
216
|
<LeaderboardChart
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { resolveLeaderboardEntries } from '../private/resolve-leaderboard-entries';
|
|
2
|
+
import type { LeaderboardEntry } from '../../../types';
|
|
3
|
+
|
|
4
|
+
type EntryInput = Partial< LeaderboardEntry > & { currentValue: number };
|
|
5
|
+
|
|
6
|
+
const toEntries = ( rows: EntryInput[] ) =>
|
|
7
|
+
rows.map( ( row, index ) => ( {
|
|
8
|
+
id: `row-${ index }`,
|
|
9
|
+
label: `Row ${ index }`,
|
|
10
|
+
...row,
|
|
11
|
+
} ) ) as LeaderboardEntry[];
|
|
12
|
+
|
|
13
|
+
describe( 'resolveLeaderboardEntries', () => {
|
|
14
|
+
const comparisonRows = [
|
|
15
|
+
{ currentValue: 50, previousValue: 100 },
|
|
16
|
+
{ currentValue: 25, previousValue: 40 },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
it( 'derives both shares and delta against one max across both periods', () => {
|
|
20
|
+
const result = resolveLeaderboardEntries( toEntries( comparisonRows ), {
|
|
21
|
+
includePrevious: true,
|
|
22
|
+
} );
|
|
23
|
+
|
|
24
|
+
expect(
|
|
25
|
+
result.map( ( { currentShare, previousShare, delta } ) => ( {
|
|
26
|
+
currentShare,
|
|
27
|
+
previousShare,
|
|
28
|
+
delta,
|
|
29
|
+
} ) )
|
|
30
|
+
).toEqual( [
|
|
31
|
+
{ currentShare: 50, previousShare: 100, delta: -50 },
|
|
32
|
+
{ currentShare: 25, previousShare: 40, delta: -37.5 },
|
|
33
|
+
] );
|
|
34
|
+
} );
|
|
35
|
+
|
|
36
|
+
it( 'lets a negative previous value set the max', () => {
|
|
37
|
+
const [ entry ] = resolveLeaderboardEntries(
|
|
38
|
+
toEntries( [ { currentValue: 10, previousValue: -40 } ] ),
|
|
39
|
+
{ includePrevious: true }
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect( entry ).toMatchObject( { currentShare: 25, previousShare: -100 } );
|
|
43
|
+
} );
|
|
44
|
+
|
|
45
|
+
it( 'ignores previous values for the max when comparison bars are not drawn', () => {
|
|
46
|
+
const result = resolveLeaderboardEntries( toEntries( comparisonRows ), {
|
|
47
|
+
includePrevious: false,
|
|
48
|
+
} );
|
|
49
|
+
|
|
50
|
+
expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ 100, 50 ] );
|
|
51
|
+
} );
|
|
52
|
+
|
|
53
|
+
it( 'keeps passed fields and derives the rest', () => {
|
|
54
|
+
const [ entry ] = resolveLeaderboardEntries(
|
|
55
|
+
toEntries( [ { currentValue: 50, currentShare: 10, previousValue: 100, delta: 7 } ] ),
|
|
56
|
+
{ includePrevious: true }
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
expect( entry ).toMatchObject( { currentShare: 10, delta: 7, previousShare: 100 } );
|
|
60
|
+
} );
|
|
61
|
+
|
|
62
|
+
it( 'derives a missing share from the max over every row, passed or not', () => {
|
|
63
|
+
const result = resolveLeaderboardEntries(
|
|
64
|
+
toEntries( [ { currentValue: 10, currentShare: 100 }, { currentValue: 20 } ] ),
|
|
65
|
+
{ includePrevious: false }
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ 100, 100 ] );
|
|
69
|
+
} );
|
|
70
|
+
|
|
71
|
+
it( 'keeps the sign of negative values', () => {
|
|
72
|
+
const result = resolveLeaderboardEntries(
|
|
73
|
+
toEntries( [ { currentValue: -30 }, { currentValue: 60 } ] ),
|
|
74
|
+
{ includePrevious: false }
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ -50, 100 ] );
|
|
78
|
+
} );
|
|
79
|
+
|
|
80
|
+
it( 'clamps negative shares to 0 and sizes the rest against the largest positive value', () => {
|
|
81
|
+
const result = resolveLeaderboardEntries(
|
|
82
|
+
toEntries( [
|
|
83
|
+
{ currentValue: -90, previousValue: 30 },
|
|
84
|
+
{ currentValue: 60, previousValue: -10, previousShare: -20 },
|
|
85
|
+
] ),
|
|
86
|
+
{ includePrevious: true, clampNegative: true }
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(
|
|
90
|
+
result.map( ( { currentShare, previousShare, delta } ) => ( {
|
|
91
|
+
currentShare,
|
|
92
|
+
previousShare,
|
|
93
|
+
delta,
|
|
94
|
+
} ) )
|
|
95
|
+
).toEqual( [
|
|
96
|
+
{ currentShare: 0, previousShare: 50, delta: -400 },
|
|
97
|
+
{ currentShare: 100, previousShare: 0, delta: 700 },
|
|
98
|
+
] );
|
|
99
|
+
} );
|
|
100
|
+
|
|
101
|
+
it.each( [
|
|
102
|
+
{ currentValue: 5, previousValue: -10, expected: 150 },
|
|
103
|
+
{ currentValue: 0, previousValue: 0, expected: 0 },
|
|
104
|
+
{ currentValue: 5, previousValue: 0, expected: undefined },
|
|
105
|
+
{ currentValue: 5, previousValue: undefined, expected: undefined },
|
|
106
|
+
] )(
|
|
107
|
+
'derives delta $expected for current $currentValue and previous $previousValue',
|
|
108
|
+
( { currentValue, previousValue, expected } ) => {
|
|
109
|
+
const [ entry ] = resolveLeaderboardEntries(
|
|
110
|
+
toEntries( [ { currentValue, previousValue } ] ),
|
|
111
|
+
{ includePrevious: true }
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect( entry.delta ).toBe( expected );
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
it( 'sizes rows without a previous value against the shared max', () => {
|
|
119
|
+
const result = resolveLeaderboardEntries(
|
|
120
|
+
toEntries( [ { currentValue: 50, previousValue: 100 }, { currentValue: 25 } ] ),
|
|
121
|
+
{ includePrevious: true }
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
expect( result.map( ( { currentShare } ) => currentShare ) ).toEqual( [ 50, 25 ] );
|
|
125
|
+
expect( result[ 1 ] ).not.toHaveProperty( 'previousShare' );
|
|
126
|
+
} );
|
|
127
|
+
|
|
128
|
+
it( 'gives shares of 0 when every value is 0', () => {
|
|
129
|
+
const [ entry ] = resolveLeaderboardEntries(
|
|
130
|
+
toEntries( [ { currentValue: 0, previousValue: 0 } ] ),
|
|
131
|
+
{ includePrevious: true }
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect( entry ).toMatchObject( { currentShare: 0, previousShare: 0, delta: 0 } );
|
|
135
|
+
} );
|
|
136
|
+
|
|
137
|
+
it( 'leaves other fields untouched', () => {
|
|
138
|
+
const onClick = jest.fn();
|
|
139
|
+
const [ entry ] = resolveLeaderboardEntries(
|
|
140
|
+
toEntries( [ { id: 'a', label: 'A', currentValue: 1, onClick } ] ),
|
|
141
|
+
{ includePrevious: false }
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect( entry ).toMatchObject( { id: 'a', label: 'A', onClick } );
|
|
145
|
+
} );
|
|
146
|
+
} );
|
|
@@ -30,6 +30,12 @@ export interface LeaderboardChartProps extends Pick<
|
|
|
30
30
|
*/
|
|
31
31
|
withOverlayLabel?: boolean;
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* How a negative value draws: `diverge` puts every row on a shared zero point, `clamp` draws it as an empty bar.
|
|
35
|
+
* @default 'diverge'
|
|
36
|
+
*/
|
|
37
|
+
negativeBars?: 'diverge' | 'clamp';
|
|
38
|
+
|
|
33
39
|
/**
|
|
34
40
|
* Primary color for current period bars
|
|
35
41
|
*/
|
|
@@ -41,9 +41,15 @@ import { ChartLayout } from '../private/chart-layout';
|
|
|
41
41
|
import { DefaultGlyph } from '../private/default-glyph';
|
|
42
42
|
import { formatReading, isInvalidReading, isReading } from '../private/readings';
|
|
43
43
|
import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
|
|
44
|
+
import {
|
|
45
|
+
getTickDecimals,
|
|
46
|
+
getTickDecimalsDomain,
|
|
47
|
+
hasOnlyWholeNumbers,
|
|
48
|
+
TickDecimals,
|
|
49
|
+
} from '../private/tick-decimals';
|
|
44
50
|
import { getCurveType } from '../private/time-axis';
|
|
45
51
|
import { buildTimeAxisOptions } from '../private/time-axis-options';
|
|
46
|
-
import {
|
|
52
|
+
import { useCompactWhenShort } from '../private/use-compact-when-short';
|
|
47
53
|
import { withResponsive } from '../private/with-responsive';
|
|
48
54
|
import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
|
|
49
55
|
import plotStyles from '../private/xy-plot/xy-plot.module.scss';
|
|
@@ -267,6 +273,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
267
273
|
children,
|
|
268
274
|
gridVisibility,
|
|
269
275
|
gap = 'md',
|
|
276
|
+
compactWhenShort = false,
|
|
270
277
|
},
|
|
271
278
|
ref
|
|
272
279
|
) => {
|
|
@@ -301,6 +308,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
301
308
|
|
|
302
309
|
// Process children for composition API (Legend, etc.)
|
|
303
310
|
const { legendChildren, nonLegendChildren } = useChartChildren( children, 'LineChart' );
|
|
311
|
+
const { isCompact, compactMargin } = useCompactWhenShort( compactWhenShort, height );
|
|
304
312
|
const [ measuredChartHeight, setMeasuredChartHeight ] = useState< number | undefined >();
|
|
305
313
|
|
|
306
314
|
// Callback for ChartLayout to notify us when the measured content height changes.
|
|
@@ -438,31 +446,47 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
438
446
|
() => hasOnlyWholeNumbers( visibleSeries ),
|
|
439
447
|
[ visibleSeries ]
|
|
440
448
|
);
|
|
449
|
+
const isLogScale = options?.yScale?.type === 'log';
|
|
450
|
+
// A log axis has no decimal steps to hold ticks to.
|
|
451
|
+
const callerTickDecimals = isLogScale ? undefined : options?.axis?.y?.tickDecimals;
|
|
452
|
+
const yTickDecimals = getTickDecimals( hasWholeNumberValues, callerTickDecimals );
|
|
441
453
|
|
|
442
454
|
const chartOptions = useMemo( () => {
|
|
443
|
-
const fallbackYDomain = getFallbackYDomain(
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
455
|
+
const fallbackYDomain = getFallbackYDomain( visibleReadingExtent, isLogScale );
|
|
456
|
+
const { tickDecimals, ...yAxisOptions } = options?.axis?.y ?? {};
|
|
457
|
+
const tickDecimalsDomain =
|
|
458
|
+
isLogScale ||
|
|
459
|
+
tickDecimals === undefined ||
|
|
460
|
+
options?.yScale?.domain ||
|
|
461
|
+
yAxisOptions.tickValues
|
|
462
|
+
? undefined
|
|
463
|
+
: getTickDecimalsDomain(
|
|
464
|
+
yDomain ?? fallbackYDomain ?? visibleReadingExtent,
|
|
465
|
+
yTickDecimals
|
|
466
|
+
);
|
|
447
467
|
|
|
448
468
|
return {
|
|
449
469
|
axis: {
|
|
450
|
-
x:
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
470
|
+
x: {
|
|
471
|
+
...buildTimeAxisOptions( {
|
|
472
|
+
dataSorted,
|
|
473
|
+
width,
|
|
474
|
+
axisOptions: options?.axis?.x,
|
|
475
|
+
scaleDomain: options?.xScale?.domain,
|
|
476
|
+
zoomDomain: zoom.domain,
|
|
477
|
+
formatting,
|
|
478
|
+
// A hidden line is unmounted, so visx scales to the rest.
|
|
479
|
+
isSeriesRendered: series => isSeriesVisible( series.label ),
|
|
480
|
+
} ),
|
|
481
|
+
...( isCompact && { display: false } ),
|
|
482
|
+
},
|
|
460
483
|
y: {
|
|
461
484
|
orientation: 'left' as const,
|
|
462
485
|
numTicks: 4,
|
|
463
486
|
tickFormat: formatNumberCompact as TickFormatter< number >,
|
|
464
487
|
display: true,
|
|
465
|
-
...
|
|
488
|
+
...yAxisOptions,
|
|
489
|
+
...( isCompact && { display: false } ),
|
|
466
490
|
},
|
|
467
491
|
},
|
|
468
492
|
xScale: {
|
|
@@ -476,6 +500,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
476
500
|
zero: false,
|
|
477
501
|
...( fallbackYDomain ? { domain: fallbackYDomain } : {} ),
|
|
478
502
|
...( yDomain ? { domain: yDomain } : {} ),
|
|
503
|
+
...( tickDecimalsDomain ? { domain: tickDecimalsDomain } : {} ),
|
|
479
504
|
...options?.yScale,
|
|
480
505
|
},
|
|
481
506
|
};
|
|
@@ -486,8 +511,11 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
486
511
|
zoom.domain,
|
|
487
512
|
yDomain,
|
|
488
513
|
visibleReadingExtent,
|
|
514
|
+
isLogScale,
|
|
515
|
+
yTickDecimals,
|
|
489
516
|
formatting,
|
|
490
517
|
isSeriesVisible,
|
|
518
|
+
isCompact,
|
|
491
519
|
] );
|
|
492
520
|
|
|
493
521
|
// Classified from the rendered series, like the axis above: a hidden
|
|
@@ -588,7 +616,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
588
616
|
return <div className={ clsx( 'line-chart', styles[ 'line-chart' ] ) }>{ error }</div>;
|
|
589
617
|
}
|
|
590
618
|
|
|
591
|
-
const legendElement = showLegend && (
|
|
619
|
+
const legendElement = showLegend && ! isCompact && (
|
|
592
620
|
<Legend
|
|
593
621
|
orientation={ legend.orientation ?? 'horizontal' }
|
|
594
622
|
alignment={ legend.alignment ?? 'center' }
|
|
@@ -617,7 +645,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
617
645
|
<ChartLayout
|
|
618
646
|
legendPosition={ legendPosition }
|
|
619
647
|
legendElement={ legendElement }
|
|
620
|
-
legendChildren={ legendChildren }
|
|
648
|
+
legendChildren={ isCompact ? [] : legendChildren }
|
|
621
649
|
gap={ gap }
|
|
622
650
|
className={ clsx(
|
|
623
651
|
'line-chart',
|
|
@@ -655,6 +683,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
655
683
|
height={ chartHeight }
|
|
656
684
|
margin={ {
|
|
657
685
|
...defaultMargin,
|
|
686
|
+
...compactMargin,
|
|
658
687
|
...margin,
|
|
659
688
|
} }
|
|
660
689
|
// xScale and yScale could be set in Axis as well, but they are `scale` props there.
|
|
@@ -666,18 +695,18 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
666
695
|
{ /* With every series hidden there is no data to scale against, so the grid and
|
|
667
696
|
axes are dropped while the empty state stands in — otherwise they render
|
|
668
697
|
squished at the top. */ }
|
|
669
|
-
<
|
|
698
|
+
<TickDecimals
|
|
670
699
|
axis="y"
|
|
671
700
|
numTicks={ chartOptions.axis.y.numTicks }
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
701
|
+
decimals={
|
|
702
|
+
! chartOptions.axis.y.tickValues && ! options?.yScale?.domain
|
|
703
|
+
? yTickDecimals
|
|
704
|
+
: undefined
|
|
676
705
|
}
|
|
677
706
|
>
|
|
678
707
|
{ tickValues => (
|
|
679
708
|
<>
|
|
680
|
-
{ ! allSeriesHidden && gridVisibility !== 'none' && (
|
|
709
|
+
{ ! allSeriesHidden && gridVisibility !== 'none' && ! isCompact && (
|
|
681
710
|
<Grid
|
|
682
711
|
columns={ false }
|
|
683
712
|
numTicks={ chartOptions.axis.y.numTicks }
|
|
@@ -695,7 +724,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
695
724
|
) }
|
|
696
725
|
</>
|
|
697
726
|
) }
|
|
698
|
-
</
|
|
727
|
+
</TickDecimals>
|
|
699
728
|
|
|
700
729
|
{ allSeriesHidden ? (
|
|
701
730
|
<SvgEmptyState
|
|
@@ -704,7 +733,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
704
733
|
width={ width }
|
|
705
734
|
height={ chartHeight }
|
|
706
735
|
>
|
|
707
|
-
{ getAllHiddenMessage( legendInteractive, 'series' ) }
|
|
736
|
+
{ getAllHiddenMessage( legendInteractive && ! isCompact, 'series' ) }
|
|
708
737
|
</SvgEmptyState>
|
|
709
738
|
) : null }
|
|
710
739
|
|