@automattic/charts 4.0.0 → 4.1.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/CHANGELOG.md +27 -0
- package/SECURITY.md +1 -0
- package/TOKENS.md +7 -5
- package/dist/index.cjs +614 -259
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +30 -0
- package/dist/index.d.cts +88 -74
- package/dist/index.d.ts +88 -74
- package/dist/index.js +615 -261
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- package/src/charts/area-chart/area-chart.tsx +2 -1
- package/src/charts/area-chart/test/area-chart.test.tsx +31 -0
- package/src/charts/heatmap-chart/build-calendar-data.ts +189 -0
- 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 +10 -3
- package/src/charts/heatmap-chart/private/civil-day.ts +174 -0
- package/src/charts/heatmap-chart/private/index.ts +0 -2
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +5 -1
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +4 -1
- 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 +35 -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 +41 -7
- package/src/charts/line-chart/test/crosshair-style-types.test.ts +31 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +178 -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 +40 -12
- package/src/components/tooltip/private/bounded-tooltip.tsx +71 -16
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +60 -1
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +102 -6
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +122 -3
- package/src/components/tooltip/xy-chart-tooltip.tsx +53 -14
- 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/styles/chart-scope.scss +11 -3
- package/src/styles/test/chart-scope.test.ts +35 -1
- 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
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +0 -179
|
@@ -2,12 +2,38 @@ import { Tooltip, defaultStyles } from '@visx/tooltip';
|
|
|
2
2
|
import { DataContext, TooltipContext } from '@visx/xychart';
|
|
3
3
|
import { useCallback, useContext, useState } from 'react';
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
|
+
import { isValidHexColor } from '../../utils';
|
|
5
6
|
import { BoundedTooltip } from './private/bounded-tooltip';
|
|
6
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
CrosshairStyle,
|
|
9
|
+
RenderTooltipGlyphProps,
|
|
10
|
+
XyChartTooltipProps,
|
|
11
|
+
} from '../../visx/types';
|
|
7
12
|
import type { TooltipContextType } from '@visx/xychart';
|
|
8
13
|
import type { CSSProperties, ReactNode } from 'react';
|
|
9
14
|
|
|
10
15
|
const CROSSHAIR_STROKE_WIDTH = 1.5;
|
|
16
|
+
const CROSSHAIR_PAINT_PROPERTIES = new Set( [
|
|
17
|
+
'stroke',
|
|
18
|
+
'strokeWidth',
|
|
19
|
+
'strokeOpacity',
|
|
20
|
+
'strokeDasharray',
|
|
21
|
+
'strokeLinecap',
|
|
22
|
+
'opacity',
|
|
23
|
+
] );
|
|
24
|
+
|
|
25
|
+
const crosshairPaintProps = ( props: CrosshairStyle = {} ) => {
|
|
26
|
+
const pickPaint = ( value: object ) =>
|
|
27
|
+
Object.fromEntries(
|
|
28
|
+
Object.entries( value ).filter( ( [ key ] ) => CROSSHAIR_PAINT_PROPERTIES.has( key ) )
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
...pickPaint( props ),
|
|
33
|
+
...( props.className === undefined ? {} : { className: props.className } ),
|
|
34
|
+
...( props.style === undefined ? {} : { style: pickPaint( props.style ) } ),
|
|
35
|
+
};
|
|
36
|
+
};
|
|
11
37
|
const DEFAULT_GLYPH_RADIUS = 4;
|
|
12
38
|
const FALLBACK_COLOR = '#222';
|
|
13
39
|
// Above the chart's own overlays (the zoom reset button sits at 2). The chart
|
|
@@ -83,7 +109,9 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
83
109
|
verticalCrosshairStyle,
|
|
84
110
|
horizontalCrosshairStyle,
|
|
85
111
|
detectBounds = true,
|
|
112
|
+
tooltipPlacement = 'auto',
|
|
86
113
|
zIndex = DEFAULT_TOOLTIP_Z_INDEX,
|
|
114
|
+
style,
|
|
87
115
|
...rest
|
|
88
116
|
}: XyChartTooltipContentProps< Datum > ) => {
|
|
89
117
|
const tooltipProps = Object.fromEntries(
|
|
@@ -125,9 +153,12 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
125
153
|
const nearestDatum = tooltipContext.tooltipData?.nearestDatum;
|
|
126
154
|
let { tooltipLeft, tooltipTop } = tooltipContext;
|
|
127
155
|
|
|
128
|
-
if (
|
|
156
|
+
if (
|
|
157
|
+
nearestDatum &&
|
|
158
|
+
( snapTooltipToDatumX || snapTooltipToDatumY || tooltipPlacement === 'below-axis' )
|
|
159
|
+
) {
|
|
129
160
|
const { left, top } = getDatumLeftTop( nearestDatum.key, nearestDatum.datum );
|
|
130
|
-
if ( snapTooltipToDatumX && isValidNumber( left ) ) {
|
|
161
|
+
if ( ( snapTooltipToDatumX || tooltipPlacement === 'below-axis' ) && isValidNumber( left ) ) {
|
|
131
162
|
tooltipLeft = left;
|
|
132
163
|
}
|
|
133
164
|
if ( snapTooltipToDatumY && isValidNumber( top ) ) {
|
|
@@ -183,15 +214,17 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
183
214
|
const marginTop = margin?.top ?? 0;
|
|
184
215
|
const marginLeft = margin?.left ?? 0;
|
|
185
216
|
|
|
186
|
-
const TooltipComponent =
|
|
217
|
+
const TooltipComponent =
|
|
218
|
+
detectBounds || tooltipPlacement === 'below-axis' ? BoundedTooltip : Tooltip;
|
|
187
219
|
const boxStyle: CSSProperties = {
|
|
188
220
|
...defaultStyles,
|
|
189
221
|
zIndex,
|
|
190
|
-
|
|
222
|
+
backgroundColor: theme?.backgroundColor ?? 'white',
|
|
191
223
|
boxShadow: `0 1px 2px ${
|
|
192
|
-
theme?.htmlLabel?.color ? `${ theme.htmlLabel.color }55` : '#22222255'
|
|
224
|
+
isValidHexColor( theme?.htmlLabel?.color ) ? `${ theme.htmlLabel.color }55` : '#22222255'
|
|
193
225
|
}`,
|
|
194
226
|
...theme?.htmlLabel,
|
|
227
|
+
...style,
|
|
195
228
|
};
|
|
196
229
|
|
|
197
230
|
return (
|
|
@@ -201,26 +234,26 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
201
234
|
<line
|
|
202
235
|
className="visx-crosshair visx-crosshair-vertical"
|
|
203
236
|
data-testid="xy-chart-tooltip-crosshair-vertical"
|
|
237
|
+
stroke={ crosshairStroke }
|
|
238
|
+
strokeWidth={ CROSSHAIR_STROKE_WIDTH }
|
|
239
|
+
{ ...crosshairPaintProps( verticalCrosshairStyle ) }
|
|
204
240
|
x1={ tooltipLeft }
|
|
205
241
|
x2={ tooltipLeft }
|
|
206
242
|
y1={ marginTop }
|
|
207
243
|
y2={ marginTop + innerHeight }
|
|
208
|
-
stroke={ crosshairStroke }
|
|
209
|
-
strokeWidth={ CROSSHAIR_STROKE_WIDTH }
|
|
210
|
-
{ ...verticalCrosshairStyle }
|
|
211
244
|
/>
|
|
212
245
|
) }
|
|
213
246
|
{ showHorizontalCrosshair && isValidNumber( tooltipTop ) && (
|
|
214
247
|
<line
|
|
215
248
|
className="visx-crosshair visx-crosshair-horizontal"
|
|
216
249
|
data-testid="xy-chart-tooltip-crosshair-horizontal"
|
|
250
|
+
stroke={ crosshairStroke }
|
|
251
|
+
strokeWidth={ CROSSHAIR_STROKE_WIDTH }
|
|
252
|
+
{ ...crosshairPaintProps( horizontalCrosshairStyle ) }
|
|
217
253
|
x1={ marginLeft }
|
|
218
254
|
x2={ marginLeft + innerWidth }
|
|
219
255
|
y1={ tooltipTop }
|
|
220
256
|
y2={ tooltipTop }
|
|
221
|
-
stroke={ crosshairStroke }
|
|
222
|
-
strokeWidth={ CROSSHAIR_STROKE_WIDTH }
|
|
223
|
-
{ ...horizontalCrosshairStyle }
|
|
224
257
|
/>
|
|
225
258
|
) }
|
|
226
259
|
{ glyphs }
|
|
@@ -229,10 +262,15 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
229
262
|
createPortal(
|
|
230
263
|
<TooltipComponent
|
|
231
264
|
left={ tooltipLeft }
|
|
232
|
-
top={
|
|
265
|
+
top={
|
|
266
|
+
tooltipPlacement === 'below-axis'
|
|
267
|
+
? marginTop + innerHeight + ( margin?.bottom ?? 0 )
|
|
268
|
+
: tooltipTop
|
|
269
|
+
}
|
|
233
270
|
style={ boxStyle }
|
|
234
271
|
applyPositionStyle
|
|
235
272
|
{ ...tooltipProps }
|
|
273
|
+
{ ...( tooltipPlacement === 'below-axis' && { placement: tooltipPlacement } ) }
|
|
236
274
|
>
|
|
237
275
|
{ tooltipContent }
|
|
238
276
|
</TooltipComponent>,
|
|
@@ -259,7 +297,8 @@ const XyChartTooltipContent = < Datum extends object >( {
|
|
|
259
297
|
* isolation keeps the box's `zIndex` from competing with page chrome. The box
|
|
260
298
|
* flips and clamps to stay inside the nearest ancestor that clips its overflow
|
|
261
299
|
* (or the viewport), so it may extend past the chart wrapper but is never cut
|
|
262
|
-
* off unless that ancestor is smaller than the box itself.
|
|
300
|
+
* off unless that ancestor is smaller than the box itself. Below-axis placement
|
|
301
|
+
* pins it beneath the x-axis and applies horizontal bounds only.
|
|
263
302
|
*
|
|
264
303
|
* @param props - visx's `Tooltip` options. `scroll`, `debounce` and `resizeObserverPolyfill` are accepted and ignored.
|
|
265
304
|
* @return An anchor in the SVG, plus the overlay and the tooltip box while the tooltip is open.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { GlobalChartsProvider } from '../../providers';
|
|
3
|
+
import { useChartDataTransform } from '../use-chart-data-transform';
|
|
4
|
+
import type { SeriesData } from '../../types';
|
|
5
|
+
|
|
6
|
+
// One reference, reused across both renders, so only the zone can invalidate the memo.
|
|
7
|
+
const data: SeriesData[] = [
|
|
8
|
+
{ label: 'Series A', data: [ { dateString: '2026-08-02', value: 1 } ] },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const Probe = () => {
|
|
12
|
+
const [ series ] = useChartDataTransform( data );
|
|
13
|
+
const point = series.data[ 0 ] as { date?: Date };
|
|
14
|
+
|
|
15
|
+
return <span data-testid="instant">{ point.date?.toISOString() }</span>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe( 'useChartDataTransform', () => {
|
|
19
|
+
it( 're-dates its points when only the provider time zone changes', () => {
|
|
20
|
+
const { rerender } = render(
|
|
21
|
+
<GlobalChartsProvider timeZone="Asia/Tokyo">
|
|
22
|
+
<Probe />
|
|
23
|
+
</GlobalChartsProvider>
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect( screen.getByTestId( 'instant' ) ).toHaveTextContent( '2026-08-01T15:00:00.000Z' );
|
|
27
|
+
|
|
28
|
+
rerender(
|
|
29
|
+
<GlobalChartsProvider timeZone="Pacific/Auckland">
|
|
30
|
+
<Probe />
|
|
31
|
+
</GlobalChartsProvider>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
expect( screen.getByTestId( 'instant' ) ).toHaveTextContent( '2026-08-01T12:00:00.000Z' );
|
|
35
|
+
} );
|
|
36
|
+
} );
|
|
@@ -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';
|
|
@@ -42,9 +42,7 @@
|
|
|
42
42
|
// The literal is load-bearing: a `fill` reading a fallback-less var() on an
|
|
43
43
|
// undeclared property drops the declaration and paints black instead.
|
|
44
44
|
--a8c-charts-color-label-background: transparent;
|
|
45
|
-
//
|
|
46
|
-
// derived from it, so setting the broad role moves every label at once while
|
|
47
|
-
// setting this one moves only the axis.
|
|
45
|
+
// See TOKENS.md's "Broad roles and narrow ones" for label inheritance.
|
|
48
46
|
--a8c-charts-color-label-axis: var(--a8c-charts-color-label);
|
|
49
47
|
--a8c-charts-color-annotation: var(--wpds-color-foreground-content-neutral);
|
|
50
48
|
--a8c-charts-color-trend-up: var(--wpds-color-foreground-content-success-weak);
|
|
@@ -86,3 +84,13 @@
|
|
|
86
84
|
--a8c-charts-dimension-leaderboard-row-gap: var(--wpds-dimension-gap-md);
|
|
87
85
|
--a8c-charts-dimension-leaderboard-column-gap: var(--wpds-dimension-gap-xs);
|
|
88
86
|
}
|
|
87
|
+
|
|
88
|
+
@media (forced-colors: active) {
|
|
89
|
+
|
|
90
|
+
:where(.a8c-charts-scope) {
|
|
91
|
+
--a8c-charts-color-label-axis: CanvasText;
|
|
92
|
+
--a8c-charts-color-grid: GrayText;
|
|
93
|
+
--a8c-charts-color-axis-x: GrayText;
|
|
94
|
+
--a8c-charts-color-tick-x: GrayText;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { compileString } from 'sass-embedded';
|
|
3
4
|
|
|
4
5
|
const stylesheet = readFileSync( join( __dirname, '..', 'chart-scope.scss' ), 'utf8' );
|
|
5
6
|
const tokensDoc = readFileSync( join( __dirname, '..', '..', '..', 'TOKENS.md' ), 'utf8' );
|
|
@@ -19,7 +20,7 @@ type Entry = {
|
|
|
19
20
|
* @return Every declared `--a8c-charts-*` property, keyed by name.
|
|
20
21
|
*/
|
|
21
22
|
function parseStylesheet(): Map< string, Entry > {
|
|
22
|
-
const body = stylesheet.slice( stylesheet.indexOf( '{' ) + 1, stylesheet.
|
|
23
|
+
const body = stylesheet.slice( stylesheet.indexOf( '{' ) + 1, stylesheet.indexOf( '}' ) );
|
|
23
24
|
const entries = new Map< string, Entry >();
|
|
24
25
|
|
|
25
26
|
for ( const declaration of body
|
|
@@ -112,6 +113,39 @@ const declared = parseStylesheet();
|
|
|
112
113
|
const documented = parseTokensDoc();
|
|
113
114
|
|
|
114
115
|
describe( 'chart scope catalog', () => {
|
|
116
|
+
it( 'uses system colors for axis text and visible lines only under forced colors', () => {
|
|
117
|
+
const style = document.createElement( 'style' );
|
|
118
|
+
style.textContent = compileString( stylesheet ).css;
|
|
119
|
+
document.head.appendChild( style );
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const mediaRules = Array.from( style.sheet!.cssRules ).filter(
|
|
123
|
+
( rule ): rule is CSSMediaRule => rule.type === CSSRule.MEDIA_RULE
|
|
124
|
+
);
|
|
125
|
+
expect( mediaRules ).toHaveLength( 1 );
|
|
126
|
+
expect( mediaRules[ 0 ].conditionText ).toBe( '(forced-colors: active)' );
|
|
127
|
+
expect( mediaRules[ 0 ].cssRules ).toHaveLength( 1 );
|
|
128
|
+
|
|
129
|
+
const rule = mediaRules[ 0 ].cssRules[ 0 ] as CSSStyleRule;
|
|
130
|
+
expect( rule.selectorText ).toBe( ':where(.a8c-charts-scope)' );
|
|
131
|
+
expect(
|
|
132
|
+
Object.fromEntries(
|
|
133
|
+
Array.from( rule.style ).map( property => [
|
|
134
|
+
property,
|
|
135
|
+
rule.style.getPropertyValue( property ).trim().toLowerCase(),
|
|
136
|
+
] )
|
|
137
|
+
)
|
|
138
|
+
).toEqual( {
|
|
139
|
+
'--a8c-charts-color-label-axis': 'canvastext',
|
|
140
|
+
'--a8c-charts-color-grid': 'graytext',
|
|
141
|
+
'--a8c-charts-color-axis-x': 'graytext',
|
|
142
|
+
'--a8c-charts-color-tick-x': 'graytext',
|
|
143
|
+
} );
|
|
144
|
+
} finally {
|
|
145
|
+
style.remove();
|
|
146
|
+
}
|
|
147
|
+
} );
|
|
148
|
+
|
|
115
149
|
it( 'documents every declared role, and declares every documented one', () => {
|
|
116
150
|
expect( [ ...documented.keys() ].sort() ).toEqual( [ ...declared.keys() ].sort() );
|
|
117
151
|
} );
|