@automattic/charts 2.0.0 → 2.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automattic/charts",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Display charts within Automattic products.",
5
5
  "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
6
6
  "bugs": {
@@ -58,7 +58,7 @@
58
58
  "build": "pnpm run build:prod",
59
59
  "build:prod": "tsdown",
60
60
  "storybook": "cd ../storybook && pnpm run storybook:dev",
61
- "test": "TZ=UTC jest --config=tests/jest.config.cjs",
61
+ "test": "TZ=UTC LC_ALL=en_US.UTF-8 jest --config=tests/jest.config.cjs",
62
62
  "test-coverage": "pnpm run test --coverage",
63
63
  "typecheck": "tsgo --noEmit"
64
64
  },
@@ -27,7 +27,7 @@ import {
27
27
  useGlobalChartsContext,
28
28
  useGlobalChartsTheme,
29
29
  } from '../../providers';
30
- import { attachSubComponents, resolveCssVariable } from '../../utils';
30
+ import { attachSubComponents } from '../../utils';
31
31
  import { renderDefaultTooltip } from '../line-chart';
32
32
  import { useChartChildren } from '../private/chart-composition';
33
33
  import { ChartLayout } from '../private/chart-layout';
@@ -482,10 +482,8 @@ const AreaChartInternal = forwardRef< SingleChartRef, AreaChartProps >(
482
482
  stacked={ stacked }
483
483
  stackOffset={ stackOffset }
484
484
  getElementStyles={ getElementStyles }
485
- strokeColor={
486
- resolveCssVariable( providerTheme.backgroundColor ) ??
487
- providerTheme.backgroundColor
488
- }
485
+ // useXYChartTheme resolved this role inside its memo; reading it back avoids a getComputedStyle on every render.
486
+ strokeColor={ theme.backgroundColor ?? providerTheme.backgroundColor }
489
487
  />
490
488
  </>
491
489
  ) }
@@ -147,7 +147,23 @@ const BarChartInternal: FC< BarChartProps > = ( {
147
147
  [ legendCollapseGroups ]
148
148
  );
149
149
  const legendItems = useChartLegendItems( dataSorted, legendOptions );
150
- const chartOptions = useBarChartOptions( dataWithVisibleZeros, horizontal, options );
150
+
151
+ const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
152
+
153
+ // A hidden series is unmounted, so it is not on the band scale either — the
154
+ // axis has to choose its tick values from what is left.
155
+ const isSeriesRendered = useCallback(
156
+ ( series: SeriesData ) =>
157
+ ! chartId || ! legendInteractive || isSeriesVisible( chartId, series.label ),
158
+ [ chartId, legendInteractive, isSeriesVisible ]
159
+ );
160
+
161
+ const chartOptions = useBarChartOptions(
162
+ dataWithVisibleZeros,
163
+ horizontal,
164
+ options,
165
+ isSeriesRendered
166
+ );
151
167
  const defaultMargin = useChartMargin( height, chartOptions, dataSorted, theme, horizontal );
152
168
  const chartRef = useRef< HTMLDivElement >( null );
153
169
 
@@ -182,23 +198,16 @@ const BarChartInternal: FC< BarChartProps > = ( {
182
198
  totalPoints,
183
199
  } );
184
200
 
185
- const { getElementStyles, isSeriesVisible } = useGlobalChartsContext();
186
-
187
201
  // Add visibility information to series when using interactive legends
188
- const seriesWithVisibility = useMemo( () => {
189
- if ( ! chartId || ! legendInteractive ) {
190
- return dataWithVisibleZeros.map( ( series, index ) => ( {
202
+ const seriesWithVisibility = useMemo(
203
+ () =>
204
+ dataWithVisibleZeros.map( ( series, index ) => ( {
191
205
  series,
192
206
  index,
193
- isVisible: true,
194
- } ) );
195
- }
196
- return dataWithVisibleZeros.map( ( series, index ) => ( {
197
- series,
198
- index,
199
- isVisible: isSeriesVisible( chartId, series.label ),
200
- } ) );
201
- }, [ dataWithVisibleZeros, chartId, isSeriesVisible, legendInteractive ] );
207
+ isVisible: isSeriesRendered( series ),
208
+ } ) ),
209
+ [ dataWithVisibleZeros, isSeriesRendered ]
210
+ );
202
211
 
203
212
  // Check if all series are hidden
204
213
  const allSeriesHidden = useMemo( () => {
@@ -1,21 +1,127 @@
1
1
  import { formatNumberCompact } from '@automattic/number-formatters';
2
+ import { __, sprintf } from '@wordpress/i18n';
2
3
  import { useMemo } from 'react';
4
+ import { useDeepMemo } from '../../../hooks';
5
+ import { getBandTickValues, getBucketResolution, getFormatter } from '../../private/time-axis';
3
6
  import { TruncatedXTickComponent, TruncatedYTickComponent } from './truncated-tick-component';
4
7
  import type { EnhancedDataPoint } from '../../../hooks/use-zero-value-display';
5
- import type { DataPointDate, BaseChartProps, SeriesData } from '../../../types';
8
+ import type { DataPointDate, BaseChartProps, SeriesData, TickResolution } from '../../../types';
6
9
  import type { TickFormatter } from '@visx/axis';
7
10
 
8
11
  /** Outer padding of the category band scale (space at the chart edges). */
9
12
  export const BASE_BAND_PADDING = 0.2;
10
13
  /** Inner padding of the category band scale (the base gap between ticks). */
11
14
  export const BASE_BAND_PADDING_INNER = 0.1;
15
+ /** Ticks each axis carries unless the caller asks for a different count. */
16
+ const DEFAULT_NUM_TICKS = 4;
12
17
 
13
- const formatDateTick = ( timestamp: number ) => {
14
- const date = new Date( timestamp );
15
- return date.toLocaleDateString( undefined, {
16
- month: 'short',
17
- day: 'numeric',
18
- } );
18
+ // A stable reference for callers that hide no series, so the memos below hold.
19
+ const ALL_RENDERED = () => true;
20
+
21
+ // The axis abbreviates to fit a tick; a tooltip has room to spell the same
22
+ // bucket out in full.
23
+ const TOOLTIP_FORMAT_BY_RESOLUTION: Record<
24
+ Exclude< TickResolution, 'week' >,
25
+ Intl.DateTimeFormatOptions
26
+ > = {
27
+ hour: { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', hour12: true },
28
+ day: { year: 'numeric', month: 'long', day: 'numeric' },
29
+ month: { year: 'numeric', month: 'long' },
30
+ year: { year: 'numeric' },
31
+ };
32
+
33
+ /**
34
+ * Labels one bar's bucket, at the bucket's own granularity — finer than the
35
+ * ticks whenever the overall time span coarsens the axis.
36
+ *
37
+ * @param data - Date-based series, already parsed and sorted by `useChartDataTransform`.
38
+ * @param tickResolution - Caller-declared bucket resolution, when known.
39
+ * @return Tooltip label formatter.
40
+ */
41
+ const getTooltipFormatter = ( data: SeriesData[], tickResolution?: TickResolution ) => {
42
+ // Only a declared 'week' reaches this branch: seven-day spacing is
43
+ // indistinguishable from sparse daily data, so inference reports 'day'.
44
+ if ( tickResolution === 'week' ) {
45
+ return ( timestamp: number ) =>
46
+ sprintf(
47
+ /* translators: %s is the first day of the week the bar covers. */
48
+ __( 'Week of %s', 'jetpack-charts' ),
49
+ new Date( timestamp ).toLocaleDateString( undefined, {
50
+ year: 'numeric',
51
+ month: 'long',
52
+ day: 'numeric',
53
+ } )
54
+ );
55
+ }
56
+
57
+ // Fall back to the day format rather than `undefined` options, which would
58
+ // print a full locale date-time for an unrecognised `tickResolution`.
59
+ const format =
60
+ TOOLTIP_FORMAT_BY_RESOLUTION[ getBucketResolution( data, tickResolution ) ] ??
61
+ TOOLTIP_FORMAT_BY_RESOLUTION.day;
62
+
63
+ return ( timestamp: number ) => new Date( timestamp ).toLocaleString( undefined, format );
64
+ };
65
+
66
+ const identity = ( label: string ) => label;
67
+
68
+ // `hasLabels` only samples the first point of the first series, so a labelled
69
+ // bucket can still reach a formatter that was chosen for timestamps. It arrives
70
+ // as its own string; only a dated bucket has a timestamp to format.
71
+ const byBucket =
72
+ ( format: ( timestamp: number ) => string ) => ( bucket: Date | string | number ) =>
73
+ typeof bucket === 'string' ? bucket : format( Number( bucket ) );
74
+
75
+ // Shared with the domain below so both read a bucket the same way.
76
+ const bucketAccessor = ( d: DataPointDate ) => d?.label || d?.date;
77
+
78
+ /**
79
+ * The buckets the band axis can put a tick on.
80
+ *
81
+ * Built from the accessor visx is given, over the same series in the same order,
82
+ * so that a tick value is a key the scale actually holds; `scaleBand` returns
83
+ * `undefined` for one it does not, which parks the tick at the origin. Null
84
+ * rather than a partial list when any bucket is labelled instead of dated.
85
+ *
86
+ * @param data - Every series handed to the chart.
87
+ * @param isSeriesRendered - Whether visx mounts a series, i.e. the legend shows it.
88
+ * @return Bucket dates in axis order, or null if the axis cannot be ticked by date.
89
+ */
90
+ const getBandDomain = (
91
+ data: SeriesData[],
92
+ isSeriesRendered: ( series: SeriesData ) => boolean
93
+ ): Date[] | null => {
94
+ // visx keys its data registry on the series label and reads it back with
95
+ // `Object.keys`, so a plain object reproduces both of the things that follow
96
+ // from that: two series sharing a label collapse to the later one, and an
97
+ // integer-like label sorts ahead of the rest whatever order it arrived in.
98
+ const registry: Record< string, SeriesData > = {};
99
+
100
+ for ( const series of data ) {
101
+ // Comparison series register nothing with visx — `comparison-bars.tsx` draws
102
+ // onto the scales the primary series established — and a hidden series is
103
+ // unmounted, so neither contributes a key to the band scale.
104
+ if ( series.options?.type === 'comparison' || ! isSeriesRendered( series ) ) {
105
+ continue;
106
+ }
107
+ registry[ series.label ] = series;
108
+ }
109
+
110
+ const byTimestamp = new Map< number, Date >();
111
+
112
+ for ( const key of Object.keys( registry ) ) {
113
+ for ( const point of registry[ key ].data ) {
114
+ const bucket = bucketAccessor( point as DataPointDate );
115
+ if ( ! ( bucket instanceof Date ) ) {
116
+ return null;
117
+ }
118
+ if ( ! byTimestamp.has( bucket.getTime() ) ) {
119
+ byTimestamp.set( bucket.getTime(), bucket );
120
+ }
121
+ }
122
+ }
123
+
124
+ return byTimestamp.size ? [ ...byTimestamp.values() ] : null;
19
125
  };
20
126
 
21
127
  /**
@@ -31,16 +137,49 @@ const getGroupPadding = ( scale: Record< string, unknown > ): number => {
31
137
  /**
32
138
  * Returns the merged options for the bar chart, including axis and scale configuration based on the orientation.
33
139
  *
34
- * @param data - The data to be displayed in the chart.
35
- * @param horizontal - Whether the chart is horizontal or vertical.
36
- * @param options - The options for the chart.
140
+ * @param data - The data to be displayed in the chart.
141
+ * @param horizontal - Whether the chart is horizontal or vertical.
142
+ * @param options - The options for the chart.
143
+ * @param isSeriesRendered - Whether visx mounts a series, i.e. the legend shows it.
37
144
  * @return The merged options for the chart.
38
145
  */
39
146
  export function useBarChartOptions(
40
147
  data: SeriesData[],
41
148
  horizontal: boolean,
42
- options: BaseChartProps[ 'options' ] = {}
149
+ options: BaseChartProps[ 'options' ] = {},
150
+ isSeriesRendered: ( series: SeriesData ) => boolean = ALL_RENDERED
43
151
  ) {
152
+ // Callers reasonably pass an object literal, which is a fresh reference every
153
+ // render and would defeat every memo below.
154
+ const stableOptions = useDeepMemo( options );
155
+
156
+ // `labelOverflow` and `tickResolution` are consumed by this hook rather than
157
+ // forwarded — visx has an axis prop for neither — so they are split off the
158
+ // caller's axis options once, here, and only the rest reaches visx below.
159
+ const axisConfig = useMemo( () => {
160
+ const {
161
+ labelOverflow: xLabelOverflow,
162
+ tickResolution: xTickResolution,
163
+ ...xAxisOptions
164
+ } = stableOptions.axis?.x || {};
165
+ const {
166
+ labelOverflow: yLabelOverflow,
167
+ tickResolution: yTickResolution,
168
+ ...yAxisOptions
169
+ } = stableOptions.axis?.y || {};
170
+
171
+ return {
172
+ xLabelOverflow,
173
+ yLabelOverflow,
174
+ xAxisOptions,
175
+ yAxisOptions,
176
+ // The dates sit on the x axis normally, and on the y axis when the
177
+ // chart is horizontal, so the hint follows them.
178
+ tickResolution: horizontal ? yTickResolution : xTickResolution,
179
+ };
180
+ }, [ stableOptions, horizontal ] );
181
+ const { tickResolution } = axisConfig;
182
+
44
183
  const defaultOptions = useMemo( () => {
45
184
  const bandScale = {
46
185
  type: 'band' as const,
@@ -53,12 +192,19 @@ export function useBarChartOptions(
53
192
  zero: false,
54
193
  };
55
194
 
56
- const labelFormatter = data?.[ 0 ]?.data?.[ 0 ]?.label
57
- ? ( label: string ) => label
58
- : formatDateTick;
195
+ // Ticks on a date-based series share the line/area charts' time-axis
196
+ // formatter, which narrows with the overall span as well as the bucket
197
+ // size; the tooltip stays at the bucket's own granularity.
198
+ const hasLabels = Boolean( data?.[ 0 ]?.data?.[ 0 ]?.label );
199
+ const timeTickFormatter = hasLabels ? null : getFormatter( data, tickResolution );
200
+ const labelFormatter = timeTickFormatter ? byBucket( timeTickFormatter ) : identity;
201
+ const tooltipDatumFormatter = hasLabels
202
+ ? labelFormatter
203
+ : byBucket( getTooltipFormatter( data, tickResolution ) );
59
204
  const valueFormatter = formatNumberCompact as TickFormatter< unknown >;
60
205
 
61
- const labelAccessor = ( d: DataPointDate ) => d?.label || d?.date;
206
+ const bandDomain = timeTickFormatter ? getBandDomain( data, isSeriesRendered ) : null;
207
+
62
208
  const valueAccessor = ( d: DataPointDate | EnhancedDataPoint ) => {
63
209
  // Use visualValue for bar rendering if available (for zero values), otherwise use value
64
210
  const enhancedPoint = d as EnhancedDataPoint;
@@ -66,11 +212,16 @@ export function useBarChartOptions(
66
212
  };
67
213
 
68
214
  return {
215
+ timeAxis: bandDomain &&
216
+ timeTickFormatter && {
217
+ domain: bandDomain,
218
+ tickFormatter: timeTickFormatter,
219
+ },
69
220
  vertical: {
70
221
  xTickFormat: labelFormatter,
71
222
  yTickFormat: valueFormatter,
72
- tooltipLabelFormatter: labelFormatter,
73
- xAccessor: labelAccessor,
223
+ tooltipLabelFormatter: tooltipDatumFormatter,
224
+ xAccessor: bucketAccessor,
74
225
  yAccessor: valueAccessor,
75
226
  gridVisibility: 'x',
76
227
  xScale: bandScale,
@@ -79,15 +230,15 @@ export function useBarChartOptions(
79
230
  horizontal: {
80
231
  xTickFormat: valueFormatter,
81
232
  yTickFormat: labelFormatter,
82
- tooltipLabelFormatter: labelFormatter,
233
+ tooltipLabelFormatter: tooltipDatumFormatter,
83
234
  xAccessor: valueAccessor,
84
- yAccessor: labelAccessor,
235
+ yAccessor: bucketAccessor,
85
236
  gridVisibility: 'y',
86
237
  xScale: linearScale,
87
238
  yScale: bandScale,
88
239
  },
89
240
  };
90
- }, [ data ] );
241
+ }, [ data, tickResolution, isSeriesRendered ] );
91
242
 
92
243
  return useMemo( () => {
93
244
  const orientationKey = horizontal ? 'horizontal' : 'vertical';
@@ -109,7 +260,7 @@ export function useBarChartOptions(
109
260
  const hasComparisonSeries = data.some( s => s.options?.type === 'comparison' );
110
261
  if ( hasComparisonSeries ) {
111
262
  const valueAxisIsY = ! horizontal;
112
- const userDomain = valueAxisIsY ? options.yScale?.domain : options.xScale?.domain;
263
+ const userDomain = valueAxisIsY ? stableOptions.yScale?.domain : stableOptions.xScale?.domain;
113
264
  if ( ! userDomain ) {
114
265
  const allValues: number[] = [];
115
266
  data.forEach( series => {
@@ -135,20 +286,34 @@ export function useBarChartOptions(
135
286
 
136
287
  const xScale = {
137
288
  ...baseXScale,
138
- ...( options.xScale || {} ),
289
+ ...( stableOptions.xScale || {} ),
139
290
  ...( horizontal ? valueScaleDomainOverride : {} ),
140
291
  };
141
292
  const yScale = {
142
293
  ...baseYScale,
143
- ...( options.yScale || {} ),
294
+ ...( stableOptions.yScale || {} ),
144
295
  ...( ! horizontal ? valueScaleDomainOverride : {} ),
145
296
  };
297
+ const { xLabelOverflow, yLabelOverflow, xAxisOptions, yAxisOptions } = axisConfig;
146
298
  const providedToolTipLabelFormatter = horizontal
147
- ? options.axis?.y?.tickFormat
148
- : options.axis?.x?.tickFormat;
299
+ ? yAxisOptions.tickFormat
300
+ : xAxisOptions.tickFormat;
149
301
 
150
- const { labelOverflow: xLabelOverflow, ...xAxisOptions } = options.axis?.x || {};
151
- const { labelOverflow: yLabelOverflow, ...yAxisOptions } = options.axis?.y || {};
302
+ // A band scale has no ticks of its own for visx to ask for, so it samples
303
+ // the domain by index and can miss the tick that dates the day or names the
304
+ // year. Pick the values here instead. Only for our own formatter — a
305
+ // caller's `tickFormat` may not be the one these values were chosen for.
306
+ const { timeAxis } = defaultOptions;
307
+ const dateAxisOptions = horizontal ? yAxisOptions : xAxisOptions;
308
+ const bandTickValues =
309
+ timeAxis && ! dateAxisOptions.tickFormat
310
+ ? getBandTickValues(
311
+ timeAxis.domain,
312
+ timeAxis.tickFormatter,
313
+ dateAxisOptions.numTicks ?? DEFAULT_NUM_TICKS
314
+ )
315
+ : null;
316
+ const dateAxisTickValues = bandTickValues ? { tickValues: bandTickValues } : {};
152
317
 
153
318
  return {
154
319
  gridVisibility,
@@ -161,15 +326,17 @@ export function useBarChartOptions(
161
326
  axis: {
162
327
  x: {
163
328
  orientation: 'bottom' as const,
164
- numTicks: 4,
329
+ numTicks: DEFAULT_NUM_TICKS,
165
330
  tickFormat: xTickFormat,
331
+ ...( horizontal ? {} : dateAxisTickValues ),
166
332
  ...( xLabelOverflow === 'ellipsis' ? { tickComponent: TruncatedXTickComponent } : {} ),
167
333
  ...xAxisOptions,
168
334
  },
169
335
  y: {
170
336
  orientation: 'left' as const,
171
- numTicks: 4,
337
+ numTicks: DEFAULT_NUM_TICKS,
172
338
  tickFormat: yTickFormat,
339
+ ...( horizontal ? dateAxisTickValues : {} ),
173
340
  ...( yLabelOverflow === 'ellipsis' ? { tickComponent: TruncatedYTickComponent } : {} ),
174
341
  ...yAxisOptions,
175
342
  },
@@ -181,5 +348,5 @@ export function useBarChartOptions(
181
348
  labelFormatter: providedToolTipLabelFormatter || defaultTooltipLabelFormatter,
182
349
  },
183
350
  };
184
- }, [ defaultOptions, options, horizontal, data ] );
351
+ }, [ defaultOptions, axisConfig, stableOptions, horizontal, data ] );
185
352
  }