@automattic/charts 4.14.0 → 4.15.1

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 (49) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/TOKENS.md +1 -1
  3. package/dist/index.cjs +649 -882
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +58 -1
  6. package/dist/index.d.cts +41 -7
  7. package/dist/index.d.ts +41 -7
  8. package/dist/index.js +650 -883
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  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 +43 -1
  37. package/src/charts/leaderboard-chart/leaderboard-chart.tsx +25 -20
  38. package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +15 -20
  39. package/src/charts/line-chart/line-chart.tsx +55 -26
  40. package/src/charts/line-chart/test/line-chart.test.tsx +86 -0
  41. package/src/charts/line-chart/types.ts +3 -1
  42. package/src/charts/private/test/compact-when-short.test.tsx +170 -0
  43. package/src/charts/private/test/tick-decimals.test.tsx +95 -0
  44. package/src/charts/private/tick-decimals.tsx +132 -0
  45. package/src/charts/private/use-compact-when-short.ts +18 -0
  46. package/src/providers/chart-context/themes.ts +4 -0
  47. package/src/types.ts +35 -2
  48. package/src/charts/private/test/whole-number-ticks.test.tsx +0 -57
  49. package/src/charts/private/whole-number-ticks.tsx +0 -79
@@ -88,6 +88,16 @@ describe( 'getValueScaleDomain', () => {
88
88
  expect( getValueScaleDomain( data, true, isSeriesRendered ) ).toEqual( [ 0, 25 ] );
89
89
  } );
90
90
 
91
+ test.each( [
92
+ [ 'widens a varying series inside one step', [ 1.2, 1.8 ], [ 1, 2 ] ],
93
+ [ 'widens a flat series under one step', [ 0.4, 0.4 ], [ 0, 1 ] ],
94
+ [ 'leaves a varying series holding two steps to visx', [ 1.2, 3.4 ], null ],
95
+ ] )( 'with tickDecimals and zero opted out, %s', ( _, values, expected ) => {
96
+ expect( getValueScaleDomain( [ series( values ) ], false, allRendered, 0 ) ).toEqual(
97
+ expected
98
+ );
99
+ } );
100
+
91
101
  test( 'reads visualValue in preference to value', () => {
92
102
  const data: SeriesData[] = [
93
103
  {
@@ -6,8 +6,10 @@ import { useChartFormatting } from '../../../providers';
6
6
  import { getBucketResolution } from '../../../utils/bucket-info';
7
7
  import { createDateFormatter } from '../../../utils/date-formatting';
8
8
  import { getBandTickValues, getFormatter } from '../../private/time-axis';
9
+ import { getStackedValueDomain } from './stack-layout';
9
10
  import { TruncatedXTickComponent, TruncatedYTickComponent } from './truncated-tick-component';
10
11
  import { getBarValue, getValueScaleDomain } from './value-domain';
12
+ import type { StackLayout } from './stack-layout';
11
13
  import type {
12
14
  DataPointDate,
13
15
  BaseChartProps,
@@ -152,21 +154,24 @@ const getGroupPadding = ( scale: Record< string, unknown > ): number => {
152
154
  * @param horizontal - Whether the chart is horizontal or vertical.
153
155
  * @param options - The options for the chart.
154
156
  * @param isSeriesRendered - Whether visx mounts a series, i.e. the legend shows it.
157
+ * @param stack - The precomputed stack, when series share one bar.
155
158
  * @return The merged options for the chart.
156
159
  */
157
160
  export function useBarChartOptions(
158
161
  data: SeriesData[],
159
162
  horizontal: boolean,
160
163
  options: BaseChartProps[ 'options' ] = {},
161
- isSeriesRendered: ( series: SeriesData ) => boolean = ALL_RENDERED
164
+ isSeriesRendered: ( series: SeriesData ) => boolean = ALL_RENDERED,
165
+ stack?: StackLayout
162
166
  ) {
163
167
  // Callers reasonably pass an object literal, which is a fresh reference every
164
168
  // render and would defeat every memo below.
165
169
  const stableOptions = useDeepMemo( options );
166
170
  const formatting = useChartFormatting();
171
+ const isStacked = !! stack;
167
172
 
168
- // `labelOverflow` and `tickResolution` are consumed by this hook rather than
169
- // forwarded — visx has an axis prop for neither — and `tickFormat` is merged
173
+ // `labelOverflow`, `tickResolution` and `tickDecimals` are consumed by this hook rather than
174
+ // forwarded — visx has an axis prop for none of them — and `tickFormat` is merged
170
175
  // with the derived default explicitly, so an `undefined` passed by a caller
171
176
  // cannot clobber it through the spread. They are split off the caller's axis
172
177
  // options once, here, and only the rest reaches visx below.
@@ -175,14 +180,22 @@ export function useBarChartOptions(
175
180
  labelOverflow: xLabelOverflow,
176
181
  tickResolution: xTickResolution,
177
182
  tickFormat: xTickFormat,
183
+ tickDecimals: xTickDecimals,
178
184
  ...xAxisOptions
179
185
  } = stableOptions.axis?.x || {};
180
186
  const {
181
187
  labelOverflow: yLabelOverflow,
182
188
  tickResolution: yTickResolution,
183
189
  tickFormat: yTickFormat,
190
+ tickDecimals: yTickDecimals,
184
191
  ...yAxisOptions
185
192
  } = stableOptions.axis?.y || {};
193
+ const [ valueAxisOptions, valueTickDecimals ] = horizontal
194
+ ? [ xAxisOptions, xTickDecimals ]
195
+ : [ yAxisOptions, yTickDecimals ];
196
+ const valueScaleType = isStacked
197
+ ? 'linear'
198
+ : ( horizontal ? stableOptions.xScale : stableOptions.yScale )?.type;
186
199
 
187
200
  return {
188
201
  xLabelOverflow,
@@ -194,8 +207,11 @@ export function useBarChartOptions(
194
207
  // The dates sit on the x axis normally, and on the y axis when the
195
208
  // chart is horizontal, so the hint follows them.
196
209
  tickResolution: horizontal ? yTickResolution : xTickResolution,
210
+ // A caller's own `tickValues` or a log scale turn `tickDecimals` off, domain widening included.
211
+ valueTickDecimals:
212
+ valueAxisOptions.tickValues || valueScaleType === 'log' ? undefined : valueTickDecimals,
197
213
  };
198
- }, [ stableOptions, horizontal ] );
214
+ }, [ stableOptions, horizontal, isStacked ] );
199
215
  const { tickResolution } = axisConfig;
200
216
 
201
217
  const defaultOptions = useMemo( () => {
@@ -270,20 +286,27 @@ export function useBarChartOptions(
270
286
  const hasComparisonSeries = data.some( s => s.options?.type === 'comparison' );
271
287
  // Comparison shadows share the primary bars' scale, so they force zero even when the caller opted out.
272
288
  const includeZero = hasComparisonSeries || valueScaleOptions?.zero !== false;
273
- const domain = valueScaleOptions?.domain
274
- ? null
275
- : getValueScaleDomain( data, includeZero, isSeriesRendered );
276
- const valueScaleDomainOverride: { domain?: [ number, number ] } = domain ? { domain } : {};
289
+ let domain: [ number, number ] | null = null;
290
+ if ( ! valueScaleOptions?.domain ) {
291
+ domain = stack
292
+ ? getStackedValueDomain( stack, axisConfig.valueTickDecimals )
293
+ : getValueScaleDomain( data, includeZero, isSeriesRendered, axisConfig.valueTickDecimals );
294
+ }
295
+ // Every stack starts at zero, which a log scale cannot place.
296
+ const valueScaleOverride: { domain?: [ number, number ]; type?: 'linear' } = {
297
+ ...( domain && { domain } ),
298
+ ...( stack && { type: 'linear' as const } ),
299
+ };
277
300
 
278
301
  const xScale = {
279
302
  ...baseXScale,
280
303
  ...( stableOptions.xScale || {} ),
281
- ...( horizontal ? valueScaleDomainOverride : {} ),
304
+ ...( horizontal ? valueScaleOverride : {} ),
282
305
  };
283
306
  const yScale = {
284
307
  ...baseYScale,
285
308
  ...( stableOptions.yScale || {} ),
286
- ...( ! horizontal ? valueScaleDomainOverride : {} ),
309
+ ...( ! horizontal ? valueScaleOverride : {} ),
287
310
  };
288
311
  const { xLabelOverflow, yLabelOverflow, xTickFormat, yTickFormat, xAxisOptions, yAxisOptions } =
289
312
  axisConfig;
@@ -339,6 +362,7 @@ export function useBarChartOptions(
339
362
  tooltip: {
340
363
  labelFormatter: dateAxisTickFormat || defaultTooltipLabelFormatter,
341
364
  },
365
+ valueTickDecimals: axisConfig.valueTickDecimals,
342
366
  };
343
- }, [ defaultOptions, axisConfig, stableOptions, horizontal, data, isSeriesRendered ] );
367
+ }, [ defaultOptions, axisConfig, stableOptions, horizontal, data, isSeriesRendered, stack ] );
344
368
  }
@@ -1,14 +1,17 @@
1
1
  import { useMemo } from 'react';
2
+ import { categoryKey, isSameCategory } from './category';
2
3
  import { COMPARISON_SLOT, PRIMARY_SLOT, getOverlapPlacements } from './overlap-bars';
4
+ import { getStackLayout } from './stack-layout';
3
5
  import { isBarRendered } from './value-domain';
4
6
  import type { SelectedBar } from './selected-bar-ring';
5
7
  import type { SlotMember } from './slot-tooltip-members';
6
- import type { DataPointDate, SeriesData } from '../../../types';
8
+ import type { StackLayout } from './stack-layout';
9
+ import type { BarChartLayout, DataPointDate, SeriesData } from '../../../types';
7
10
 
8
11
  type SeriesEntry = { series: SeriesData; index: number };
9
12
 
10
- export type OverlapSlots = {
11
- /** The visible series, back to front. */
13
+ export type BarSlots = {
14
+ /** The visible series, back to front; bottom to top in a stack. */
12
15
  entries: SeriesEntry[];
13
16
  keys: string[];
14
17
  /** The slots of a category, in the order the band lays them out. */
@@ -17,7 +20,7 @@ export type OverlapSlots = {
17
20
  thicknessOf: ( key: string ) => number;
18
21
  /** The keyboard stops: every category a slot draws a bar at, category by category. */
19
22
  positions: StopPosition[];
20
- };
23
+ } & ( { kind: 'overlap' } | { kind: 'stacked'; stack: StackLayout } );
21
24
 
22
25
  /** A slot at one category, named by its front-most bar there. */
23
26
  export type StopPosition = {
@@ -30,36 +33,39 @@ export type StopPosition = {
30
33
  export type SelectedSlot = SelectedBar & { members: SlotMember[] };
31
34
 
32
35
  /**
33
- * Whether two data points sit at the same category: the same label, or the same date.
34
- *
35
- * @param point - A data point.
36
- * @param category - The data point naming the category.
37
- * @return Whether `point` is at `category`.
38
- */
39
- export function isSameCategory( point: DataPointDate, category: DataPointDate ): boolean {
40
- // An empty label falls back to the date, as the band axis's accessor does.
41
- return category.label
42
- ? point.label === category.label
43
- : !! category.date && !! point.date && point.date.getTime() === category.date.getTime();
44
- }
45
-
46
- /**
47
- * Lays out the visible series of an overlap layout in slots and layers.
36
+ * Lays out the visible series in slots: overlap layers primaries and comparisons in two, stacked puts every primary in one.
48
37
  *
49
- * @param enabled - Whether the chart draws an overlap layout.
38
+ * @param layout - How the chart places its series in a category.
50
39
  * @param seriesWithVisibility - Every series, with its index and whether it is visible.
51
- * @return The layout, or undefined when the chart is not an overlap layout.
40
+ * @return The slots, or undefined for a grouped layout.
52
41
  */
53
- export function useOverlapSlots(
54
- enabled: boolean,
42
+ export function useBarSlots(
43
+ layout: BarChartLayout,
55
44
  seriesWithVisibility: ( SeriesEntry & { isVisible: boolean } )[]
56
- ): OverlapSlots | undefined {
45
+ ): BarSlots | undefined {
57
46
  return useMemo( () => {
58
- if ( ! enabled ) {
47
+ if ( layout === 'grouped' ) {
59
48
  return undefined;
60
49
  }
61
50
 
62
51
  const visible = seriesWithVisibility.filter( ( { isVisible } ) => isVisible );
52
+
53
+ if ( layout === 'stacked' ) {
54
+ // Bottom to top, so the top segment names each keyboard stop.
55
+ const entries = visible.map( ( { series, index } ) => ( { series, index } ) );
56
+ const slotKeys = [ PRIMARY_SLOT ];
57
+ const slotOf = () => PRIMARY_SLOT;
58
+ return {
59
+ kind: 'stacked',
60
+ stack: getStackLayout( entries.map( ( { series } ) => series ) ),
61
+ entries,
62
+ keys: entries.map( ( { series } ) => series.label ),
63
+ slotKeys,
64
+ slotOf,
65
+ thicknessOf: () => 1,
66
+ positions: getStopPositions( entries, slotKeys, slotOf ),
67
+ };
68
+ }
63
69
  const placements = getOverlapPlacements( visible.map( ( { series } ) => series ) );
64
70
  const placed = visible
65
71
  .map( ( { series, index }, position ) => ( {
@@ -85,6 +91,7 @@ export function useOverlapSlots(
85
91
  ];
86
92
 
87
93
  return {
94
+ kind: 'overlap',
88
95
  entries,
89
96
  keys: entries.map( ( { series } ) => series.label ),
90
97
  slotKeys,
@@ -92,17 +99,7 @@ export function useOverlapSlots(
92
99
  thicknessOf,
93
100
  positions: getStopPositions( entries, slotKeys, slotOf ),
94
101
  };
95
- }, [ enabled, seriesWithVisibility ] );
96
- }
97
-
98
- /**
99
- * The category a data point sits at, as a key two points share when `isSameCategory` holds.
100
- *
101
- * @param point - A data point.
102
- * @return The category key.
103
- */
104
- function categoryKey( point: DataPointDate ): string {
105
- return point.label ? `label:${ point.label }` : `date:${ point.date?.getTime() }`;
102
+ }, [ layout, seriesWithVisibility ] );
106
103
  }
107
104
 
108
105
  /**
@@ -184,12 +181,12 @@ export function membersAt(
184
181
  /**
185
182
  * The slot a keyboard index selects, and every bar at its category.
186
183
  *
187
- * @param slots - The overlap layout.
184
+ * @param slots - The overlap or stacked slots.
188
185
  * @param selectedIndex - The keyboard index into `slots.positions`.
189
186
  * @return The selected slot, or undefined when nothing is selected or drawn there.
190
187
  */
191
188
  export function getSelectedSlot(
192
- slots: OverlapSlots | undefined,
189
+ slots: BarSlots | undefined,
193
190
  selectedIndex: number | undefined
194
191
  ): SelectedSlot | undefined {
195
192
  const position = selectedIndex === undefined ? undefined : slots?.positions[ selectedIndex ];
@@ -209,5 +206,6 @@ export function getSelectedSlot(
209
206
  slotKey,
210
207
  thickness: Math.max( ...slotBars.map( ( { key } ) => slots.thicknessOf( key ) ) ),
211
208
  members,
209
+ ...( slots.kind === 'stacked' && { valueExtent: slots.stack.extentAt( category ) } ),
212
210
  };
213
211
  }
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useMemo } from 'react';
2
- import { isSameCategory } from './use-overlap-slots';
3
- import type { OverlapSlots } from './use-overlap-slots';
2
+ import { isSameCategory } from './category';
3
+ import type { BarSlots } from './use-bar-slots';
4
4
  import type { DataPointDate, SeriesData } from '../../../types';
5
5
 
6
6
  type SeriesEntry = { series: SeriesData; index: number };
@@ -17,34 +17,34 @@ export type BarStops = {
17
17
  };
18
18
 
19
19
  /**
20
- * Lays out the keyboard stops: one per slot and category in an overlap layout, else one per primary bar, series-major within each category.
20
+ * Lays out the keyboard stops: one per slot and category in a slotted layout, else one per primary bar, series-major within each category.
21
21
  *
22
- * @param overlapSlots - The overlap layout, or undefined for a grouped one.
22
+ * @param barSlots - The overlap or stacked slots, or undefined for a grouped layout.
23
23
  * @param primaryEntries - The visible primary series.
24
24
  * @return The stops.
25
25
  */
26
26
  export function useBarStops(
27
- overlapSlots: OverlapSlots | undefined,
27
+ barSlots: BarSlots | undefined,
28
28
  primaryEntries: SeriesEntry[]
29
29
  ): BarStops {
30
30
  const series = useMemo(
31
- () => ( overlapSlots ? [] : primaryEntries.map( entry => entry.series ) ),
32
- [ overlapSlots, primaryEntries ]
31
+ () => ( barSlots ? [] : primaryEntries.map( entry => entry.series ) ),
32
+ [ barSlots, primaryEntries ]
33
33
  );
34
34
 
35
35
  const count =
36
- overlapSlots?.positions.length ??
36
+ barSlots?.positions.length ??
37
37
  Math.max( 0, ...series.map( item => item.data.length ) ) * series.length;
38
38
 
39
39
  const key = useMemo(
40
- () => JSON.stringify( overlapSlots?.keys ?? series.map( item => item.label ) ),
41
- [ overlapSlots, series ]
40
+ () => JSON.stringify( barSlots?.keys ?? series.map( item => item.label ) ),
41
+ [ barSlots, series ]
42
42
  );
43
43
 
44
44
  const barAt = useCallback(
45
45
  ( stop: number ): StopBar | undefined => {
46
- if ( overlapSlots ) {
47
- const position = overlapSlots.positions[ stop ];
46
+ if ( barSlots ) {
47
+ const position = barSlots.positions[ stop ];
48
48
  return (
49
49
  position && {
50
50
  datum: position.datum,
@@ -61,17 +61,17 @@ export function useBarStops(
61
61
  const datum = item?.data[ index ] as DataPointDate | undefined;
62
62
  return item && datum ? { datum, index, key: item.label } : undefined;
63
63
  },
64
- [ overlapSlots, series ]
64
+ [ barSlots, series ]
65
65
  );
66
66
 
67
67
  const stopOf = useCallback(
68
68
  ( seriesKey: string, dataPointIndex: number ): number | undefined => {
69
- if ( overlapSlots ) {
70
- const hovered = overlapSlots.entries.find( entry => entry.series.label === seriesKey )
71
- ?.series.data[ dataPointIndex ] as DataPointDate | undefined;
72
- const slotKey = overlapSlots.slotOf( seriesKey );
69
+ if ( barSlots ) {
70
+ const hovered = barSlots.entries.find( entry => entry.series.label === seriesKey )?.series
71
+ .data[ dataPointIndex ] as DataPointDate | undefined;
72
+ const slotKey = barSlots.slotOf( seriesKey );
73
73
  const stop = hovered
74
- ? overlapSlots.positions.findIndex(
74
+ ? barSlots.positions.findIndex(
75
75
  position => position.slotKey === slotKey && isSameCategory( position.datum, hovered )
76
76
  )
77
77
  : -1;
@@ -80,7 +80,7 @@ export function useBarStops(
80
80
  const seriesIndex = series.findIndex( item => item.label === seriesKey );
81
81
  return seriesIndex >= 0 ? dataPointIndex * series.length + seriesIndex : undefined;
82
82
  },
83
- [ overlapSlots, series ]
83
+ [ barSlots, series ]
84
84
  );
85
85
 
86
86
  return { count, key, barAt, stopOf };
@@ -1,8 +1,9 @@
1
+ import { getTickDecimalsDomain } from '../../private/tick-decimals';
1
2
  import type { EnhancedDataPoint } from '../../../hooks/use-zero-value-display';
2
3
  import type { DataPoint, DataPointDate, SeriesData } from '../../../types';
3
4
 
4
5
  /** Domain for a value axis that has nothing to scale, so its ticks still read 0 to 1. */
5
- const EMPTY_DOMAIN: [ number, number ] = [ 0, 1 ];
6
+ export const EMPTY_DOMAIN: [ number, number ] = [ 0, 1 ];
6
7
 
7
8
  /**
8
9
  * The value a bar is drawn at, preferring `visualValue` so a zero still shows a sliver.
@@ -37,12 +38,14 @@ export const isBarRendered = ( point: DataPoint | DataPointDate | EnhancedDataPo
37
38
  * @param data - Every series handed to the chart.
38
39
  * @param includeZero - Whether the domain must span zero.
39
40
  * @param isSeriesRendered - Whether visx mounts a series, i.e. the legend shows it.
41
+ * @param tickDecimals - The value axis's `tickDecimals`.
40
42
  * @return Value domain, or null when visx should fit its own.
41
43
  */
42
44
  export const getValueScaleDomain = (
43
45
  data: SeriesData[],
44
46
  includeZero: boolean,
45
- isSeriesRendered: ( series: SeriesData ) => boolean
47
+ isSeriesRendered: ( series: SeriesData ) => boolean,
48
+ tickDecimals?: number
46
49
  ): [ number, number ] | null => {
47
50
  let min = Infinity;
48
51
  let max = -Infinity;
@@ -67,14 +70,13 @@ export const getValueScaleDomain = (
67
70
  return [ ...EMPTY_DOMAIN ];
68
71
  }
69
72
 
73
+ let domain: [ number, number ] | null = null;
70
74
  // A flat series fitted by visx collapses to zero height, so it is anchored at zero regardless.
71
75
  if ( min === max ) {
72
- return min === 0 ? [ ...EMPTY_DOMAIN ] : [ Math.min( 0, min ), Math.max( 0, max ) ];
76
+ domain = min === 0 ? [ ...EMPTY_DOMAIN ] : [ Math.min( 0, min ), Math.max( 0, max ) ];
77
+ } else if ( includeZero ) {
78
+ domain = [ Math.min( 0, min ), Math.max( 0, max ) ];
73
79
  }
74
80
 
75
- if ( includeZero ) {
76
- return [ Math.min( 0, min ), Math.max( 0, max ) ];
77
- }
78
-
79
- return null;
81
+ return getTickDecimalsDomain( domain ?? [ min, max ], tickDecimals ) ?? domain;
80
82
  };