@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,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 data - The series the chart draws.
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
- data: SeriesData[],
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.
@@ -49,7 +49,7 @@
49
49
  }
50
50
 
51
51
  .label {
52
- padding-left: var(--wpds-dimension-padding-sm);
52
+ padding-inline-start: var(--a8c-charts-dimension-leaderboard-label-inset);
53
53
  }
54
54
 
55
55
  // The bar's 100% height needs a track that fills the row,
@@ -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: LeaderboardEntry
79
- ): entry is LeaderboardEntry & {
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: LeaderboardEntry
86
- ): entry is LeaderboardEntry & {
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: LeaderboardEntry;
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 data ?? [] ) {
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
- }, [ data, withComparison, withOverlayLabel ] );
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
- { data.map( ( entry, rowIndex ) => {
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 { hasOnlyWholeNumbers, WholeNumberTicks } from '../private/whole-number-ticks';
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
- visibleReadingExtent,
445
- options?.yScale?.type === 'log'
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: buildTimeAxisOptions( {
451
- dataSorted,
452
- width,
453
- axisOptions: options?.axis?.x,
454
- scaleDomain: options?.xScale?.domain,
455
- zoomDomain: zoom.domain,
456
- formatting,
457
- // A hidden line is unmounted, so visx scales to the rest.
458
- isSeriesRendered: series => isSeriesVisible( series.label ),
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
- ...options?.axis?.y,
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
- <WholeNumberTicks
698
+ <TickDecimals
670
699
  axis="y"
671
700
  numTicks={ chartOptions.axis.y.numTicks }
672
- enabled={
673
- hasWholeNumberValues &&
674
- ! chartOptions.axis.y.tickValues &&
675
- ! options?.yScale?.domain
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
- </WholeNumberTicks>
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