@automattic/charts 4.0.0 → 4.1.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 +18 -0
- package/SECURITY.md +1 -0
- package/TOKENS.md +1 -1
- package/dist/index.cjs +548 -199
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +21 -0
- package/dist/index.d.cts +86 -72
- package/dist/index.d.ts +86 -72
- package/dist/index.js +549 -201
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +31 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +44 -9
- package/src/charts/heatmap-chart/index.ts +2 -1
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +126 -92
- package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
- package/src/charts/heatmap-chart/private/index.ts +1 -1
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +3 -0
- package/src/charts/heatmap-chart/test/civil-day.test.ts +70 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -0
- package/src/charts/heatmap-chart/test/use-calendar-heatmap-data.test.tsx +118 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +8 -0
- package/src/charts/heatmap-chart/test/zoned-calendar-data.test.ts +248 -0
- package/src/charts/heatmap-chart/types.ts +6 -0
- package/src/charts/heatmap-chart/use-calendar-heatmap-data.ts +42 -0
- package/src/charts/leaderboard-chart/types.ts +2 -2
- package/src/charts/line-chart/line-chart.tsx +39 -6
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +147 -38
- package/src/charts/line-chart/test/zoned-date-string.test.tsx +37 -0
- package/src/charts/line-chart/types.ts +13 -2
- package/src/charts/pie-chart/test/pie-chart.test.tsx +3 -12
- package/src/components/tooltip/accessible-tooltip.tsx +8 -2
- package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +28 -1
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +100 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +51 -13
- package/src/hooks/test/use-chart-data-transform-zone.test.tsx +36 -0
- package/src/hooks/test/use-chart-margin.test.tsx +173 -3
- package/src/hooks/use-chart-data-transform.ts +10 -3
- package/src/hooks/use-chart-margin.tsx +54 -9
- package/src/index.ts +4 -0
- package/src/test-utils/zoned-parsing-suite.ts +90 -0
- package/src/types.ts +2 -2
- package/src/utils/date-formatting.ts +1 -18
- package/src/utils/date-parsing.ts +103 -52
- package/src/utils/get-edge-tick-widths.ts +33 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/test/date-parsing.auckland.test.ts +6 -0
- package/src/utils/test/date-parsing.santiago.test.ts +6 -0
- package/src/utils/test/date-parsing.test.ts +16 -1
- package/src/utils/test/get-edge-tick-widths.test.ts +55 -0
- package/src/utils/warn-once.ts +23 -0
- package/src/visx/types.ts +27 -3
|
@@ -9,6 +9,13 @@ jest.mock( '../../utils/get-longest-tick-width', () => ( {
|
|
|
9
9
|
getLongestTickWidth: ( ...args: unknown[] ) => mockGetLongestTickWidth( ...args ),
|
|
10
10
|
} ) );
|
|
11
11
|
|
|
12
|
+
// jsdom has no getComputedTextLength, so the real measurement always returns null.
|
|
13
|
+
const mockGetEdgeTickWidths = jest.fn();
|
|
14
|
+
jest.mock( '../../utils/get-edge-tick-widths', () => ( {
|
|
15
|
+
...jest.requireActual( '../../utils/get-edge-tick-widths' ),
|
|
16
|
+
getEdgeTickWidths: ( ...args: unknown[] ) => mockGetEdgeTickWidths( ...args ),
|
|
17
|
+
} ) );
|
|
18
|
+
|
|
12
19
|
describe( 'useChartMargin', () => {
|
|
13
20
|
const baseTheme = {
|
|
14
21
|
axisStyles: {
|
|
@@ -43,6 +50,8 @@ describe( 'useChartMargin', () => {
|
|
|
43
50
|
beforeEach( () => {
|
|
44
51
|
mockGetLongestTickWidth.mockReset();
|
|
45
52
|
mockGetLongestTickWidth.mockReturnValue( 40 );
|
|
53
|
+
mockGetEdgeTickWidths.mockReset();
|
|
54
|
+
mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 0 } );
|
|
46
55
|
} );
|
|
47
56
|
|
|
48
57
|
it( 'calculates left margin for left y axis', () => {
|
|
@@ -62,7 +71,7 @@ describe( 'useChartMargin', () => {
|
|
|
62
71
|
expect( mockGetLongestTickWidth ).toHaveBeenCalledWith(
|
|
63
72
|
expect.any( Array ),
|
|
64
73
|
options.axis.y.tickFormat,
|
|
65
|
-
|
|
74
|
+
{ fontSize: '12px' }
|
|
66
75
|
);
|
|
67
76
|
// 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset
|
|
68
77
|
expect( result.current.left ).toBe( 51 );
|
|
@@ -85,7 +94,7 @@ describe( 'useChartMargin', () => {
|
|
|
85
94
|
expect( mockGetLongestTickWidth ).toHaveBeenCalledWith(
|
|
86
95
|
expect.any( Array ),
|
|
87
96
|
options.axis.y.tickFormat,
|
|
88
|
-
|
|
97
|
+
{ fontSize: '12px' }
|
|
89
98
|
);
|
|
90
99
|
// 40 label width + 8 tick length + ceil(11 * 0.25) label dx offset
|
|
91
100
|
expect( result.current.right ).toBe( 51 );
|
|
@@ -108,7 +117,7 @@ describe( 'useChartMargin', () => {
|
|
|
108
117
|
expect( mockGetLongestTickWidth ).toHaveBeenCalledWith(
|
|
109
118
|
[ 0, 1000 ],
|
|
110
119
|
options.axis.y.tickFormat,
|
|
111
|
-
|
|
120
|
+
{ fontSize: '12px' }
|
|
112
121
|
);
|
|
113
122
|
} );
|
|
114
123
|
|
|
@@ -185,6 +194,167 @@ describe( 'useChartMargin', () => {
|
|
|
185
194
|
expect( result.current.bottom ).toBe( 25 );
|
|
186
195
|
} );
|
|
187
196
|
|
|
197
|
+
describe( 'x-axis edge tick labels', () => {
|
|
198
|
+
const tickFormat = ( value: number ) => new Date( value ).toDateString();
|
|
199
|
+
const tickValues = [ 1, 2, 3 ];
|
|
200
|
+
const datedXOptions = ( xOverrides = {} ) => ( {
|
|
201
|
+
...optionsBase,
|
|
202
|
+
axis: { ...optionsBase.axis, x: { tickValues, tickFormat, ...xOverrides } },
|
|
203
|
+
} );
|
|
204
|
+
|
|
205
|
+
it( 'reserves half of the last label on the right', () => {
|
|
206
|
+
mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 60 } );
|
|
207
|
+
|
|
208
|
+
const { result } = renderHook( () =>
|
|
209
|
+
useChartMargin( 300, datedXOptions(), data, baseTheme )
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
expect( result.current.right ).toBe( 30 );
|
|
213
|
+
} );
|
|
214
|
+
|
|
215
|
+
it( 'keeps the default right margin when the last label fits inside it', () => {
|
|
216
|
+
mockGetEdgeTickWidths.mockReturnValue( { first: 0, last: 30 } );
|
|
217
|
+
|
|
218
|
+
const { result } = renderHook( () =>
|
|
219
|
+
useChartMargin( 300, datedXOptions(), data, baseTheme )
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
expect( result.current.right ).toBe( 20 );
|
|
223
|
+
} );
|
|
224
|
+
|
|
225
|
+
it( 'widens the left margin past the y-axis reservation when the first label needs it', () => {
|
|
226
|
+
mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 0 } );
|
|
227
|
+
|
|
228
|
+
const { result } = renderHook( () =>
|
|
229
|
+
useChartMargin( 300, datedXOptions(), data, baseTheme )
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// 60 for the label's overhanging half, over the 51 the y-axis ticks need.
|
|
233
|
+
expect( result.current.left ).toBe( 60 );
|
|
234
|
+
} );
|
|
235
|
+
|
|
236
|
+
it( 'measures the axis tick values with the x tick label style', () => {
|
|
237
|
+
const theme = {
|
|
238
|
+
...baseTheme,
|
|
239
|
+
axisStyles: {
|
|
240
|
+
...baseTheme.axisStyles,
|
|
241
|
+
x: {
|
|
242
|
+
bottom: { tickLabel: { fontSize: 11 }, tickLength: 8 } as unknown as never,
|
|
243
|
+
top: {} as unknown as never,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
} as XYChartTheme;
|
|
247
|
+
|
|
248
|
+
renderHook( () => useChartMargin( 300, datedXOptions(), data, theme ) );
|
|
249
|
+
|
|
250
|
+
expect( mockGetEdgeTickWidths ).toHaveBeenCalledWith( tickValues, tickFormat, {
|
|
251
|
+
fontSize: '11px',
|
|
252
|
+
} );
|
|
253
|
+
} );
|
|
254
|
+
|
|
255
|
+
it( 'falls back to the raw tick label style when its font size is a relative unit', () => {
|
|
256
|
+
const theme = {
|
|
257
|
+
...baseTheme,
|
|
258
|
+
axisStyles: {
|
|
259
|
+
...baseTheme.axisStyles,
|
|
260
|
+
x: {
|
|
261
|
+
bottom: { tickLabel: { fontSize: '0.875rem' }, tickLength: 8 } as unknown as never,
|
|
262
|
+
top: {} as unknown as never,
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
} as XYChartTheme;
|
|
266
|
+
|
|
267
|
+
renderHook( () => useChartMargin( 300, datedXOptions(), data, theme ) );
|
|
268
|
+
|
|
269
|
+
expect( mockGetEdgeTickWidths ).toHaveBeenCalledWith( tickValues, tickFormat, {
|
|
270
|
+
fontSize: '0.875rem',
|
|
271
|
+
} );
|
|
272
|
+
} );
|
|
273
|
+
|
|
274
|
+
it( 'reserves the edge labels on a top x axis too', () => {
|
|
275
|
+
mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 60 } );
|
|
276
|
+
|
|
277
|
+
const { result } = renderHook( () =>
|
|
278
|
+
useChartMargin( 300, datedXOptions( { orientation: 'top' } ), data, baseTheme )
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect( result.current.right ).toBe( 30 );
|
|
282
|
+
expect( result.current.left ).toBe( 60 );
|
|
283
|
+
} );
|
|
284
|
+
|
|
285
|
+
it( 'reserves nothing for a hidden x axis', () => {
|
|
286
|
+
mockGetEdgeTickWidths.mockReturnValue( { first: 120, last: 60 } );
|
|
287
|
+
|
|
288
|
+
const { result } = renderHook( () =>
|
|
289
|
+
useChartMargin( 300, datedXOptions( { display: false } ), data, baseTheme )
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
expect( mockGetEdgeTickWidths ).not.toHaveBeenCalled();
|
|
293
|
+
expect( result.current.right ).toBe( 20 );
|
|
294
|
+
expect( result.current.left ).toBe( 51 );
|
|
295
|
+
} );
|
|
296
|
+
} );
|
|
297
|
+
|
|
298
|
+
describe( 'real measurement', () => {
|
|
299
|
+
// Everything else here mocks the measurer out; this block runs it for real,
|
|
300
|
+
// so that a width that never reaches the margin would fail something.
|
|
301
|
+
const actual = jest.requireActual( '../../utils/get-edge-tick-widths' );
|
|
302
|
+
type Measurable = { getComputedTextLength?: () => number };
|
|
303
|
+
|
|
304
|
+
afterEach( () => {
|
|
305
|
+
delete ( window.SVGElement.prototype as Measurable ).getComputedTextLength;
|
|
306
|
+
} );
|
|
307
|
+
|
|
308
|
+
it( 'turns a measured edge label into a reserved margin', () => {
|
|
309
|
+
// jsdom ships no getComputedTextLength, so @visx/text cannot measure at all.
|
|
310
|
+
( window.SVGElement.prototype as Measurable ).getComputedTextLength = function (
|
|
311
|
+
this: SVGElement
|
|
312
|
+
) {
|
|
313
|
+
return ( this.textContent ?? '' ).length * 8;
|
|
314
|
+
};
|
|
315
|
+
mockGetEdgeTickWidths.mockImplementation( actual.getEdgeTickWidths );
|
|
316
|
+
|
|
317
|
+
const options = {
|
|
318
|
+
...optionsBase,
|
|
319
|
+
axis: {
|
|
320
|
+
...optionsBase.axis,
|
|
321
|
+
x: {
|
|
322
|
+
tickValues: [ 1, 2 ],
|
|
323
|
+
tickFormat: ( _value: number, index: number ) =>
|
|
324
|
+
index === 0 ? 'AA' : 'MEASURED-LAST',
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const { result } = renderHook( () => useChartMargin( 300, options, data, baseTheme ) );
|
|
330
|
+
|
|
331
|
+
// 'MEASURED-LAST' is 13 characters, so 104px wide, and half of it is reserved.
|
|
332
|
+
expect( result.current.right ).toBe( 52 );
|
|
333
|
+
} );
|
|
334
|
+
} );
|
|
335
|
+
|
|
336
|
+
describe( 'y axis gutter', () => {
|
|
337
|
+
it( 'measures a caller-pinned domain rather than the data range', () => {
|
|
338
|
+
const options = { ...optionsBase, yScale: { domain: [ 0, 1 ] as [ number, number ] } };
|
|
339
|
+
|
|
340
|
+
renderHook( () => useChartMargin( 300, options, data, baseTheme ) );
|
|
341
|
+
|
|
342
|
+
const ticks = mockGetLongestTickWidth.mock.calls[ 0 ][ 0 ] as number[];
|
|
343
|
+
expect( Math.max( ...ticks ) ).toBeLessThanOrEqual( 1 );
|
|
344
|
+
} );
|
|
345
|
+
|
|
346
|
+
it( 'reserves no gutter for a hidden y axis', () => {
|
|
347
|
+
const options = {
|
|
348
|
+
...optionsBase,
|
|
349
|
+
axis: { ...optionsBase.axis, y: { ...optionsBase.axis.y, display: false } },
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const { result } = renderHook( () => useChartMargin( 300, options, data, baseTheme ) );
|
|
353
|
+
|
|
354
|
+
expect( result.current.left ).toBe( 20 );
|
|
355
|
+
} );
|
|
356
|
+
} );
|
|
357
|
+
|
|
188
358
|
describe( 'horizontal y ticks', () => {
|
|
189
359
|
const horizontalOptions = ( tickFormat: ( value: string | number ) => string ) => ( {
|
|
190
360
|
...optionsBase,
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { useMemo } from 'react';
|
|
2
|
+
// Imported from the module rather than the `providers` barrel: that barrel pulls
|
|
3
|
+
// `use-chart-registration`, which imports this file's own barrel back.
|
|
4
|
+
import { useChartFormatting } from '../providers/chart-context/hooks/use-chart-formatting';
|
|
2
5
|
import { parseAsLocalDate } from '../utils';
|
|
3
6
|
import type { SeriesData } from '../types';
|
|
4
7
|
|
|
@@ -7,7 +10,7 @@ import type { SeriesData } from '../types';
|
|
|
7
10
|
*
|
|
8
11
|
* This hook extracts the common data transformation logic used in both line-chart
|
|
9
12
|
* and bar-chart components. It:
|
|
10
|
-
* 1. Parses date strings into Date objects
|
|
13
|
+
* 1. Parses date strings into Date objects, dated in the provider's time zone
|
|
11
14
|
* 2. Sorts data points by date when date properties are present
|
|
12
15
|
* 3. Returns the original data unchanged when no date properties are found
|
|
13
16
|
*
|
|
@@ -15,6 +18,10 @@ import type { SeriesData } from '../types';
|
|
|
15
18
|
* @return {SeriesData[]} The transformed and sorted data
|
|
16
19
|
*/
|
|
17
20
|
export const useChartDataTransform = ( data: SeriesData[] ) => {
|
|
21
|
+
// A naive `dateString` names no zone, so without this it would be read in the
|
|
22
|
+
// viewer's and then relabeled in the host's, and the two never cancel.
|
|
23
|
+
const { timeZone } = useChartFormatting();
|
|
24
|
+
|
|
18
25
|
return useMemo( () => {
|
|
19
26
|
// Check if the first data point has date or dateString properties
|
|
20
27
|
const firstPoint = data?.[ 0 ]?.data?.[ 0 ];
|
|
@@ -35,7 +42,7 @@ export const useChartDataTransform = ( data: SeriesData[] ) => {
|
|
|
35
42
|
if ( 'date' in point && point.date ) {
|
|
36
43
|
date = point.date;
|
|
37
44
|
} else if ( 'dateString' in point && point.dateString ) {
|
|
38
|
-
date = parseAsLocalDate( point.dateString );
|
|
45
|
+
date = parseAsLocalDate( point.dateString, timeZone );
|
|
39
46
|
}
|
|
40
47
|
|
|
41
48
|
return {
|
|
@@ -48,5 +55,5 @@ export const useChartDataTransform = ( data: SeriesData[] ) => {
|
|
|
48
55
|
return a.date.getTime() - b.date.getTime();
|
|
49
56
|
} ),
|
|
50
57
|
} ) );
|
|
51
|
-
}, [ data ] );
|
|
58
|
+
}, [ data, timeZone ] );
|
|
52
59
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createScale, getTicks } from '@visx/scale';
|
|
2
2
|
import { useMemo } from 'react';
|
|
3
|
-
import { getLongestTickWidth, resolveFontSize } from '../utils';
|
|
3
|
+
import { getEdgeTickWidths, getLongestTickWidth, resolveFontSize } from '../utils';
|
|
4
4
|
import type { BaseChartProps, DataPointDate, SeriesData } from '../types';
|
|
5
5
|
import type { XYChartTheme } from '@visx/xychart';
|
|
6
6
|
|
|
@@ -50,6 +50,33 @@ const DEFAULT_TICK_LENGTH = 8;
|
|
|
50
50
|
*/
|
|
51
51
|
const DEFAULT_Y_TICK_WIDTH = 40;
|
|
52
52
|
|
|
53
|
+
type LabelStyle = { fontSize?: number | string; letterSpacing?: number | string };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Copy a label style with its lengths spelled out in px.
|
|
57
|
+
*
|
|
58
|
+
* `getStringWidth` applies the style through CSSOM. Blink resolves a bare number
|
|
59
|
+
* on an SVG `<text>`, but that is its own leniency rather than the CSS rule, and
|
|
60
|
+
* `buildChartTheme` hands us bare numbers.
|
|
61
|
+
*
|
|
62
|
+
* @param style - Raw label style from the theme.
|
|
63
|
+
* @return The same style with px-qualified lengths.
|
|
64
|
+
*/
|
|
65
|
+
const toMeasurableStyle = < T extends LabelStyle >( style?: T ) => {
|
|
66
|
+
if ( ! style ) {
|
|
67
|
+
return style;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const fontSize = resolveFontSize( style.fontSize );
|
|
71
|
+
const { letterSpacing } = style;
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
...style,
|
|
75
|
+
...( fontSize === undefined ? {} : { fontSize: `${ fontSize }px` } ),
|
|
76
|
+
...( typeof letterSpacing === 'number' ? { letterSpacing: `${ letterSpacing }px` } : {} ),
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
|
|
53
80
|
const getXAxisLabelMetrics = ( theme: XYChartTheme, orientation: 'top' | 'bottom' ) => {
|
|
54
81
|
const xAxisStyles =
|
|
55
82
|
orientation === 'top' ? theme.axisStyles?.x?.top : theme.axisStyles?.x?.bottom;
|
|
@@ -61,7 +88,7 @@ const getXAxisLabelMetrics = ( theme: XYChartTheme, orientation: 'top' | 'bottom
|
|
|
61
88
|
|
|
62
89
|
const tickLength = xAxisStyles?.tickLength ?? DEFAULT_TICK_LENGTH;
|
|
63
90
|
|
|
64
|
-
return { fontSize, tickLength };
|
|
91
|
+
return { fontSize, tickLength, tickLabelStyle: toMeasurableStyle( xAxisStyles?.tickLabel ) };
|
|
65
92
|
};
|
|
66
93
|
|
|
67
94
|
export const useChartMargin = (
|
|
@@ -88,7 +115,9 @@ export const useChartMargin = (
|
|
|
88
115
|
const maxY = Math.max( ...allDataPoints.map( d => d.value ) );
|
|
89
116
|
const yScale = createScale( {
|
|
90
117
|
...options.yScale,
|
|
91
|
-
domain
|
|
118
|
+
// A pinned domain is what the axis actually renders, so measure those
|
|
119
|
+
// ticks; the data's range would size the gutter for narrower labels.
|
|
120
|
+
domain: options.yScale?.domain ?? [ minY, maxY ],
|
|
92
121
|
range: [ height, 0 ],
|
|
93
122
|
} );
|
|
94
123
|
|
|
@@ -111,7 +140,7 @@ export const useChartMargin = (
|
|
|
111
140
|
const yTickWidth = getLongestTickWidth(
|
|
112
141
|
yTicks,
|
|
113
142
|
options.axis?.y?.tickFormat,
|
|
114
|
-
yAxisStyles.axisLabel
|
|
143
|
+
toMeasurableStyle( yAxisStyles.axisLabel )
|
|
115
144
|
);
|
|
116
145
|
// visx's default axis theme pushes y-axis tick labels a further 0.25em
|
|
117
146
|
// away from the axis (dx of -0.25em on the left, 0.25em on the right), so
|
|
@@ -125,17 +154,20 @@ export const useChartMargin = (
|
|
|
125
154
|
( yAxisStyles?.tickLength ?? 0 ) +
|
|
126
155
|
Math.ceil( yTickLabelFontSize * 0.25 );
|
|
127
156
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
157
|
+
// A hidden y axis reserves nothing; its gutter belongs to the plot area.
|
|
158
|
+
if ( options.axis?.y?.display !== false ) {
|
|
159
|
+
if ( yAxisOrientation === 'right' ) {
|
|
160
|
+
defaultMargin.right = yMarginValue;
|
|
161
|
+
} else {
|
|
162
|
+
defaultMargin.left = yMarginValue;
|
|
163
|
+
}
|
|
132
164
|
}
|
|
133
165
|
|
|
134
166
|
// Dynamically compute X-axis margin (bottom by default, or top if orientation is 'top').
|
|
135
167
|
// This mirrors Y-axis behavior where margin is based on label size and tick length,
|
|
136
168
|
// but keeps the padding minimal so consumers can control container spacing themselves.
|
|
137
169
|
const xOrientation = options.axis?.x?.orientation === 'top' ? 'top' : 'bottom';
|
|
138
|
-
const { fontSize, tickLength } = getXAxisLabelMetrics( theme, xOrientation );
|
|
170
|
+
const { fontSize, tickLength, tickLabelStyle } = getXAxisLabelMetrics( theme, xOrientation );
|
|
139
171
|
const computedXMargin = fontSize + tickLength;
|
|
140
172
|
|
|
141
173
|
if ( xOrientation === 'top' ) {
|
|
@@ -145,6 +177,19 @@ export const useChartMargin = (
|
|
|
145
177
|
defaultMargin.bottom = Math.max( defaultMargin.bottom, computedXMargin );
|
|
146
178
|
}
|
|
147
179
|
|
|
180
|
+
// An X-axis label is centered on its tick, so the ones at either end of the
|
|
181
|
+
// scale hang half their width outside the plot area and clip at the SVG edge.
|
|
182
|
+
if ( options.axis?.x?.display !== false ) {
|
|
183
|
+
const { first, last } = getEdgeTickWidths(
|
|
184
|
+
options.axis?.x?.tickValues ?? [],
|
|
185
|
+
options.axis?.x?.tickFormat,
|
|
186
|
+
tickLabelStyle
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
defaultMargin.left = Math.max( defaultMargin.left, Math.ceil( first / 2 ) );
|
|
190
|
+
defaultMargin.right = Math.max( defaultMargin.right, Math.ceil( last / 2 ) );
|
|
191
|
+
}
|
|
192
|
+
|
|
148
193
|
return defaultMargin;
|
|
149
194
|
}, [ options, theme, yTicks ] );
|
|
150
195
|
};
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
HeatmapChart,
|
|
11
11
|
HeatmapChartUnresponsive,
|
|
12
12
|
buildCalendarHeatmapData,
|
|
13
|
+
useCalendarHeatmapData,
|
|
13
14
|
} from './charts/heatmap-chart';
|
|
14
15
|
export { LeaderboardChart, LeaderboardChartUnresponsive } from './charts/leaderboard-chart';
|
|
15
16
|
export { LineChart, LineChartUnresponsive } from './charts/line-chart';
|
|
@@ -113,6 +114,7 @@ export type {
|
|
|
113
114
|
HeatmapColumn,
|
|
114
115
|
HeatmapCell,
|
|
115
116
|
HeatmapTooltipData,
|
|
117
|
+
CalendarHeatmapOptions,
|
|
116
118
|
CalendarHeatmapResult,
|
|
117
119
|
} from './charts/heatmap-chart';
|
|
118
120
|
export type { LeaderboardChartProps } from './charts/leaderboard-chart';
|
|
@@ -138,3 +140,5 @@ export {
|
|
|
138
140
|
export * from './utils/color-utils';
|
|
139
141
|
export type { MetricValueType } from './utils';
|
|
140
142
|
export type { BucketInfo } from './types';
|
|
143
|
+
|
|
144
|
+
export type { CrosshairStyle } from './visx/types';
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { parseAsLocalDate } from '../utils/date-parsing';
|
|
2
|
+
|
|
3
|
+
// `@wordpress/jest-console` registers the matcher but ships no types for it.
|
|
4
|
+
declare global {
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-namespace
|
|
6
|
+
namespace jest {
|
|
7
|
+
interface Matchers< R > {
|
|
8
|
+
toHaveWarnedWith( ...args: unknown[] ): R;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Every case asserts an absolute instant, so running the suite from more than one
|
|
14
|
+
// worker zone is what proves the result does not depend on the viewer's.
|
|
15
|
+
const CASES: Array< [ string, string, string ] > = [
|
|
16
|
+
// The shape the Stats payload gives for a day bucket.
|
|
17
|
+
[ '2026-08-02', 'Asia/Tokyo', '2026-08-01T15:00:00.000Z' ],
|
|
18
|
+
[ '2026-08-02 09:30:00', 'Asia/Tokyo', '2026-08-02T00:30:00.000Z' ],
|
|
19
|
+
[ '2026-08-02T09:30:00', 'Asia/Tokyo', '2026-08-02T00:30:00.000Z' ],
|
|
20
|
+
// Santiago springs forward at midnight, deleting this reading entirely.
|
|
21
|
+
[ '2026-09-06', 'America/Santiago', '2026-09-06T04:00:00.000Z' ],
|
|
22
|
+
// Berlin's gap runs the other way, its offset growing rather than shrinking, which is
|
|
23
|
+
// the shape most of Europe and North America has. Only this case tells the smaller of
|
|
24
|
+
// the two candidate offsets apart from the later probe.
|
|
25
|
+
[ '2026-03-29 02:30:00', 'Europe/Berlin', '2026-03-29T01:30:00.000Z' ],
|
|
26
|
+
// And Santiago falls back at midnight, so this reading happens twice.
|
|
27
|
+
[ '2026-04-04 23:00:00', 'America/Santiago', '2026-04-05T02:00:00.000Z' ],
|
|
28
|
+
// Which of the two an overlap resolves to follows the sign of the offset, so a zone
|
|
29
|
+
// east of UTC settles on the other one. Auckland is where this PR's own stories sit.
|
|
30
|
+
[ '2026-04-05 02:30:00', 'Pacific/Auckland', '2026-04-04T14:30:00.000Z' ],
|
|
31
|
+
// Hours past a fall-back, where the first offset probe lands on the wrong side
|
|
32
|
+
// of the transition and only the second one is right.
|
|
33
|
+
[ '2026-11-01 08:00:00', 'America/Los_Angeles', '2026-11-01T16:00:00.000Z' ],
|
|
34
|
+
// Nothing about this reading is remarkable in Tokyo; it is the worker zones that make it
|
|
35
|
+
// worth asserting, since Santiago deletes this local midnight and used to drag the
|
|
36
|
+
// answer an hour with it.
|
|
37
|
+
[ '2026-09-06', 'Asia/Tokyo', '2026-09-05T15:00:00.000Z' ],
|
|
38
|
+
// date-fns reads each field as one to N digits, so an unpadded string must land on the
|
|
39
|
+
// same instant as its padded twin. A one-digit fraction is 5ms to date-fns, not 500.
|
|
40
|
+
[ '2026-8-2', 'Asia/Tokyo', '2026-08-01T15:00:00.000Z' ],
|
|
41
|
+
[ '2026-08-02 9:30', 'Asia/Tokyo', '2026-08-02T00:30:00.000Z' ],
|
|
42
|
+
[ '2026-08-02T9:30:5.5', 'Asia/Tokyo', '2026-08-02T00:30:05.005Z' ],
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// A string carrying its own offset is already an instant, so the zone must not move it.
|
|
46
|
+
const INSTANT_CASES: Array< [ string, string ] > = [
|
|
47
|
+
[ '2026-08-02T00:00:00Z', '2026-08-02T00:00:00.000Z' ],
|
|
48
|
+
[ '2026-08-02T00:00:00+05:00', '2026-08-01T19:00:00.000Z' ],
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The zone-aware half of `parseAsLocalDate`, run from whatever zone the caller's
|
|
53
|
+
* environment pins.
|
|
54
|
+
*/
|
|
55
|
+
export const describeZonedParsing = () => {
|
|
56
|
+
describe( 'parseAsLocalDate with a time zone', () => {
|
|
57
|
+
test.each( CASES )( 'reads %s in %s', ( dateString, timeZone, expected ) => {
|
|
58
|
+
expect( parseAsLocalDate( dateString, timeZone ).toISOString() ).toBe( expected );
|
|
59
|
+
} );
|
|
60
|
+
|
|
61
|
+
test.each( INSTANT_CASES )( 'leaves %s where it is', ( dateString, expected ) => {
|
|
62
|
+
expect( parseAsLocalDate( dateString, 'Asia/Tokyo' ).toISOString() ).toBe( expected );
|
|
63
|
+
} );
|
|
64
|
+
|
|
65
|
+
it( 'falls back to the runtime zone where Intl rejects the zone, and says so', () => {
|
|
66
|
+
const fallback = parseAsLocalDate( '2026-08-02', 'Not/AZone' );
|
|
67
|
+
|
|
68
|
+
expect( fallback.getTime() ).toBe( parseAsLocalDate( '2026-08-02' ).getTime() );
|
|
69
|
+
expect( console ).toHaveWarnedWith(
|
|
70
|
+
'[Charts] timeZone "Not/AZone" is not a zone Intl accepts, so dates are read in the browser\'s zone. Pass an IANA name or a UTC offset such as "+05:30".'
|
|
71
|
+
);
|
|
72
|
+
} );
|
|
73
|
+
|
|
74
|
+
it( 'keeps a year below 100 out of the 1900s', () => {
|
|
75
|
+
expect( parseAsLocalDate( '0099-08-02', 'Asia/Tokyo' ).getUTCFullYear() ).toBe( 99 );
|
|
76
|
+
expect( parseAsLocalDate( '99-08-02', 'Asia/Tokyo' ).getUTCFullYear() ).toBe( 99 );
|
|
77
|
+
} );
|
|
78
|
+
|
|
79
|
+
it( 'reads a naive string in the runtime zone when no zone is supplied', () => {
|
|
80
|
+
const local = parseAsLocalDate( '2026-08-02' );
|
|
81
|
+
|
|
82
|
+
expect( [
|
|
83
|
+
local.getFullYear(),
|
|
84
|
+
local.getMonth(),
|
|
85
|
+
local.getDate(),
|
|
86
|
+
local.getHours(),
|
|
87
|
+
] ).toEqual( [ 2026, 7, 2, 0 ] );
|
|
88
|
+
} );
|
|
89
|
+
} );
|
|
90
|
+
};
|
package/src/types.ts
CHANGED
|
@@ -478,8 +478,8 @@ export type BucketInfo = {
|
|
|
478
478
|
* `timeZone` re-dates the instants the host supplies, so a value that is really a
|
|
479
479
|
* calendar day shifts under it: `new Date( '2026-08-02' )` is UTC midnight and
|
|
480
480
|
* labels as Aug 1 in `America/Los_Angeles`. Supply true instants alongside a
|
|
481
|
-
* `timeZone`, or set only `locale` for day-bucketed data. `
|
|
482
|
-
*
|
|
481
|
+
* `timeZone`, or set only `locale` for day-bucketed data. `buildCalendarHeatmapData`
|
|
482
|
+
* is the exception: it reads a bare `yyyy-MM-dd` as the day it names.
|
|
483
483
|
*/
|
|
484
484
|
export type ChartFormatting = {
|
|
485
485
|
/** BCP-47 language tag, e.g. `de-DE`. Defaults to the runtime's locale. */
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
+
import { warnOnce } from './warn-once';
|
|
1
2
|
import type { ChartFormatting } from '../types';
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
* `process.env.NODE_ENV` is replaced by the bundler at build time. Declare a
|
|
5
|
-
* minimal `process` locally so this file type-checks as source under `jetpack:src`.
|
|
6
|
-
*/
|
|
7
|
-
declare const process: { env: Record< string, string | undefined > };
|
|
8
|
-
|
|
9
4
|
// One `Intl.DateTimeFormat` per locale, zone and options triple. The axis formats
|
|
10
5
|
// every bucket in the domain, and rebuilding the formatter is what costs; the tick
|
|
11
6
|
// formats themselves are rebuilt on every resize, so a per-closure cache is not
|
|
@@ -27,18 +22,6 @@ const getFormatter = (
|
|
|
27
22
|
return formatter;
|
|
28
23
|
};
|
|
29
24
|
|
|
30
|
-
const warned = new Set< string >();
|
|
31
|
-
|
|
32
|
-
const warnOnce = ( key: string, message: string ): void => {
|
|
33
|
-
if ( warned.has( key ) || process.env.NODE_ENV === 'production' ) {
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
warned.add( key );
|
|
38
|
-
// eslint-disable-next-line no-console
|
|
39
|
-
console.warn( `[Charts] ${ message }` );
|
|
40
|
-
};
|
|
41
|
-
|
|
42
25
|
const isUsable = ( formatting: ChartFormatting ): boolean => {
|
|
43
26
|
try {
|
|
44
27
|
// Constructing is the check: `Intl` throws on a tag or zone it cannot use.
|