@automattic/charts 3.1.1 → 4.0.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 +41 -0
- package/TOKENS.md +80 -46
- package/dist/index.cjs +1224 -662
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +72 -23
- package/dist/index.d.cts +230 -162
- package/dist/index.d.ts +230 -162
- package/dist/index.js +1224 -663
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/charts/area-chart/area-chart.tsx +39 -25
- package/src/charts/area-chart/test/area-chart.test.tsx +4 -33
- package/src/charts/area-chart/test/tick-placement.test.tsx +55 -0
- package/src/charts/area-chart/test/tooltip-resolution-branches.test.tsx +86 -0
- package/src/charts/area-chart/test/tooltip-resolution.test.tsx +69 -0
- package/src/charts/area-chart/types.ts +4 -7
- package/src/charts/bar-chart/bar-chart.tsx +38 -11
- package/src/charts/bar-chart/private/test/use-bar-chart-options-formatting.test.tsx +69 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +27 -15
- package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
- package/src/charts/bar-chart/test/bar-chart.test.tsx +79 -0
- package/src/charts/bar-list-chart/bar-list-chart.tsx +45 -2
- package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +33 -23
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +138 -182
- package/src/charts/conversion-funnel-chart/test/conversion-funnel-chart.test.tsx +46 -0
- package/src/charts/geo-chart/geo-chart.module.scss +1 -0
- package/src/charts/geo-chart/geo-chart.tsx +5 -10
- package/src/charts/geo-chart/test/geo-chart.test.tsx +2 -2
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +21 -16
- package/src/charts/heatmap-chart/heatmap-chart.tsx +45 -28
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +3 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +11 -4
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +51 -12
- package/src/charts/heatmap-chart/types.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-fitted-row-count.ts +2 -2
- package/src/charts/leaderboard-chart/hooks/use-leaderboard-legend-items.ts +3 -5
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +31 -19
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +27 -17
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +14 -0
- package/src/charts/leaderboard-chart/test/use-leaderboard-legend-items.test.tsx +2 -9
- package/src/charts/line-chart/line-chart.module.scss +10 -18
- package/src/charts/line-chart/line-chart.tsx +109 -25
- package/src/charts/line-chart/private/line-chart-annotation.tsx +18 -19
- package/src/charts/line-chart/test/default-tooltip-formatting.test.tsx +74 -0
- package/src/charts/line-chart/test/line-chart.test.tsx +74 -3
- package/src/charts/line-chart/test/tick-placement.test.tsx +84 -0
- package/src/charts/line-chart/test/tooltip-resolution.test.tsx +140 -0
- package/src/charts/line-chart/types.ts +4 -1
- package/src/charts/pie-chart/pie-chart.module.scss +13 -1
- package/src/charts/pie-chart/pie-chart.tsx +30 -40
- package/src/charts/pie-chart/test/pie-chart.test.tsx +56 -0
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.module.scss +8 -3
- package/src/charts/pie-semi-circle-chart/pie-semi-circle-chart.tsx +23 -37
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +50 -0
- package/src/charts/private/chart-layout/chart-layout.module.scss +3 -2
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/test/axis-formatting.test.tsx +85 -0
- package/src/charts/private/test/time-axis-formatting.test.ts +109 -0
- package/src/charts/private/test/time-axis-options.test.ts +267 -0
- package/src/charts/private/test/time-axis-tick-invariants.test.ts +153 -0
- package/src/charts/private/test/time-axis-tick-values.test.ts +280 -0
- package/src/charts/private/test/time-axis.test.ts +9 -69
- package/src/charts/private/time-axis-options.ts +106 -0
- package/src/charts/private/time-axis.ts +318 -136
- package/src/charts/private/x-zoom/x-zoom.module.scss +3 -3
- package/src/charts/private/xy-plot/xy-plot.module.scss +8 -0
- package/src/charts/sparkline/sparkline.tsx +9 -3
- package/src/charts/sparkline/test/sparkline.test.tsx +29 -56
- package/src/charts/sparkline/types.ts +2 -2
- package/src/components/legend/private/base-legend.module.scss +9 -5
- package/src/components/tooltip/accessible-tooltip.tsx +38 -10
- package/src/components/tooltip/base-tooltip.module.scss +3 -3
- package/src/components/tooltip/private/bounded-tooltip.tsx +172 -0
- package/src/components/tooltip/test/accessible-tooltip.test.tsx +67 -0
- package/src/components/tooltip/test/bounded-tooltip.test.tsx +129 -0
- package/src/components/tooltip/test/xy-chart-tooltip.test.tsx +279 -0
- package/src/components/tooltip/xy-chart-tooltip.tsx +288 -0
- package/src/components/trend-indicator/trend-indicator.module.scss +2 -2
- package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
- package/src/hooks/test/use-xychart-theme.test.tsx +63 -5
- package/src/hooks/use-xychart-theme.ts +33 -23
- package/src/index.ts +4 -0
- package/src/providers/chart-context/global-charts-provider.tsx +64 -54
- package/src/providers/chart-context/hooks/use-chart-formatting.ts +22 -0
- package/src/providers/chart-context/index.ts +1 -0
- package/src/providers/chart-context/private/catalog-pointers.ts +42 -0
- package/src/providers/chart-context/private/series-palette.ts +35 -0
- package/src/providers/chart-context/test/catalog-pointers.test.ts +61 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +177 -792
- package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +7 -18
- package/src/providers/chart-context/themes.ts +7 -47
- package/src/providers/chart-context/types.ts +3 -0
- package/src/styles/chart-scope.scss +63 -58
- package/src/styles/test/chart-scope.test.ts +25 -40
- package/src/test-utils/series-fixtures.ts +31 -0
- package/src/types.ts +103 -73
- package/src/utils/bucket-info.ts +87 -0
- package/src/utils/color-utils.ts +0 -27
- package/src/utils/date-formatting.ts +174 -0
- package/src/utils/get-styles.ts +7 -1
- package/src/utils/index.ts +3 -0
- package/src/utils/test/bucket-info.test.ts +185 -0
- package/src/utils/test/color-utils.test.ts +1 -48
- package/src/utils/test/date-formatting.test.ts +89 -0
- package/src/utils/test/get-styles.test.ts +10 -7
- package/src/utils/test/merge-themes.test.ts +18 -46
- package/src/visx/types.ts +29 -2
- package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +0 -28
- package/src/providers/chart-context/private/theme-override-vars.ts +0 -87
- package/src/providers/chart-context/private/with-catalog-pointers.ts +0 -50
- package/src/providers/chart-context/test/sparse-theme.test.ts +0 -35
- package/src/providers/chart-context/test/theme-override-vars-guard.test.tsx +0 -29
- package/src/providers/chart-context/test/theme-override-vars.test.ts +0 -118
- package/src/providers/chart-context/test/with-catalog-pointers.test.ts +0 -47
- package/src/styles/test/wpds-fallbacks.test.ts +0 -131
|
@@ -21,21 +21,21 @@
|
|
|
21
21
|
&__annotation-label-popover {
|
|
22
22
|
background: var(--a8c-charts-color-surface);
|
|
23
23
|
color: var(--a8c-charts-color-label);
|
|
24
|
-
padding: var(--wpds-dimension-padding-sm
|
|
24
|
+
padding: var(--wpds-dimension-padding-sm);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
&__tooltip-date {
|
|
28
|
-
font-weight: var(--wpds-typography-font-weight-emphasis
|
|
29
|
-
padding-bottom: var(--wpds-dimension-padding-md
|
|
28
|
+
font-weight: var(--wpds-typography-font-weight-emphasis);
|
|
29
|
+
padding-bottom: var(--wpds-dimension-padding-md);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
&__tooltip-row {
|
|
33
|
-
padding: var(--wpds-dimension-padding-xs
|
|
33
|
+
padding: var(--wpds-dimension-padding-xs) 0;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
&__tooltip-label {
|
|
37
|
-
font-weight: var(--wpds-typography-font-weight-emphasis
|
|
38
|
-
padding-right: var(--wpds-dimension-padding-lg
|
|
37
|
+
font-weight: var(--wpds-typography-font-weight-emphasis);
|
|
38
|
+
padding-right: var(--wpds-dimension-padding-lg);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
&__annotations-overlay {
|
|
@@ -64,14 +64,14 @@
|
|
|
64
64
|
&__annotation-label-popover {
|
|
65
65
|
min-width: 125px;
|
|
66
66
|
border: none;
|
|
67
|
-
border-radius: var(--wpds-border-radius-md
|
|
68
|
-
font-size: var(--wpds-typography-font-size-md
|
|
67
|
+
border-radius: var(--wpds-border-radius-md);
|
|
68
|
+
font-size: var(--wpds-typography-font-size-md);
|
|
69
69
|
box-shadow: var(--a8c-charts-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
|
|
70
70
|
position: fixed;
|
|
71
71
|
// Without margin set, the popover has margin:auto which upsets the
|
|
72
72
|
// positioning relative to the trigger button. A gap token is appropriate
|
|
73
73
|
// because it creates a gap to the trigger button.
|
|
74
|
-
margin: var(--wpds-dimension-gap-sm
|
|
74
|
+
margin: var(--wpds-dimension-gap-sm);
|
|
75
75
|
visibility: hidden;
|
|
76
76
|
|
|
77
77
|
&--visible {
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
&__annotation-label-popover-content {
|
|
88
|
-
padding: var(--wpds-dimension-padding-sm
|
|
88
|
+
padding: var(--wpds-dimension-padding-sm);
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
&__annotation-label-popover-close-button {
|
|
@@ -101,14 +101,6 @@
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
:global(.visx-tooltip-glyph) {
|
|
105
|
-
|
|
106
|
-
svg {
|
|
107
|
-
width: 10px;
|
|
108
|
-
height: 10px;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
104
|
@keyframes rise {
|
|
113
105
|
|
|
114
106
|
from {
|
|
@@ -24,25 +24,36 @@ import {
|
|
|
24
24
|
import {
|
|
25
25
|
GlobalChartsProvider,
|
|
26
26
|
GlobalChartsContext,
|
|
27
|
+
useChartFormatting,
|
|
27
28
|
useChartId,
|
|
28
29
|
useChartRegistration,
|
|
29
30
|
useGlobalChartsContext,
|
|
30
|
-
useGlobalChartsTheme,
|
|
31
31
|
} from '../../providers';
|
|
32
32
|
import { useDefaultHiddenSeries } from '../../providers/chart-context/hooks/use-default-hidden-series';
|
|
33
33
|
import { attachSubComponents } from '../../utils';
|
|
34
|
+
import { getBucketInfo } from '../../utils/bucket-info';
|
|
35
|
+
import { createDateFormatter } from '../../utils/date-formatting';
|
|
34
36
|
import { useChartChildren } from '../private/chart-composition';
|
|
35
37
|
import { ChartInstanceContext, type ChartInstanceRef } from '../private/chart-instance-context';
|
|
36
38
|
import { ChartLayout } from '../private/chart-layout';
|
|
37
39
|
import { DefaultGlyph } from '../private/default-glyph';
|
|
38
40
|
import { getAllHiddenMessage, SvgEmptyState } from '../private/svg-empty-state';
|
|
39
|
-
import { getCurveType
|
|
41
|
+
import { getCurveType } from '../private/time-axis';
|
|
42
|
+
import { buildTimeAxisOptions } from '../private/time-axis-options';
|
|
40
43
|
import { withResponsive } from '../private/with-responsive';
|
|
41
44
|
import { useXZoom, ZoomResetButton, ZoomSelectionRect, ZoomClip } from '../private/x-zoom';
|
|
45
|
+
import plotStyles from '../private/xy-plot/xy-plot.module.scss';
|
|
42
46
|
import styles from './line-chart.module.scss';
|
|
43
47
|
import { LineChartAnnotation, LineChartAnnotationsOverlay, LineChartGlyph } from './private';
|
|
44
48
|
import type { RenderLineGlyphProps, LineChartProps, TooltipDatum } from './types';
|
|
45
|
-
import type {
|
|
49
|
+
import type {
|
|
50
|
+
BucketInfo,
|
|
51
|
+
DataPoint,
|
|
52
|
+
DataPointDate,
|
|
53
|
+
SeriesData,
|
|
54
|
+
Optional,
|
|
55
|
+
TickResolution,
|
|
56
|
+
} from '../../types';
|
|
46
57
|
import type { RenderTooltipParams } from '../../visx/types';
|
|
47
58
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
48
59
|
import type { TickFormatter } from '@visx/axis';
|
|
@@ -58,16 +69,52 @@ const toNumber = ( val?: number | string | null ): number | undefined => {
|
|
|
58
69
|
return isNaN( num ) ? undefined : num;
|
|
59
70
|
};
|
|
60
71
|
|
|
72
|
+
// No date-part options, which is what `Intl` defaults to a numeric date for —
|
|
73
|
+
// the heading the tooltip printed with `toLocaleDateString()` before it could
|
|
74
|
+
// be told a locale and a zone.
|
|
75
|
+
const TOOLTIP_DATE: Intl.DateTimeFormatOptions = {};
|
|
76
|
+
|
|
77
|
+
// Only the hour case adds to the plain date heading. A coarser bucket keeps the
|
|
78
|
+
// day: 'month' covers any spacing from 28 days up, so dropping it would give two
|
|
79
|
+
// distinct points one heading.
|
|
80
|
+
const TOOLTIP_FORMAT_BY_RESOLUTION: Record<
|
|
81
|
+
Exclude< TickResolution, 'week' >,
|
|
82
|
+
Intl.DateTimeFormatOptions
|
|
83
|
+
> = {
|
|
84
|
+
hour: { year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric' },
|
|
85
|
+
day: TOOLTIP_DATE,
|
|
86
|
+
month: TOOLTIP_DATE,
|
|
87
|
+
year: TOOLTIP_DATE,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// A component rather than a call, because `renderDefaultTooltip` is a plain
|
|
91
|
+
// function a consumer may pass around: the context has to be read where the
|
|
92
|
+
// heading renders, not where the tooltip is built.
|
|
93
|
+
const TooltipDate: FC< { date?: Date; displayResolution: Exclude< TickResolution, 'week' > } > = ( {
|
|
94
|
+
date,
|
|
95
|
+
displayResolution,
|
|
96
|
+
} ) => {
|
|
97
|
+
const formatting = useChartFormatting();
|
|
98
|
+
const format = useMemo(
|
|
99
|
+
() => createDateFormatter( TOOLTIP_FORMAT_BY_RESOLUTION[ displayResolution ], formatting ),
|
|
100
|
+
[ displayResolution, formatting ]
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return <>{ date ? format( date ) : null }</>;
|
|
104
|
+
};
|
|
105
|
+
|
|
61
106
|
/**
|
|
62
107
|
* Default visx-tooltip render that prints the hovered date as a heading and
|
|
63
108
|
* one row per visible series (label + formatted value), sorted descending by
|
|
64
109
|
* value. Reused by AreaChart, which has the same multi-series shape.
|
|
65
110
|
*
|
|
66
|
-
* @param params - visx `RenderTooltipParams< DataPointDate
|
|
111
|
+
* @param params - visx `RenderTooltipParams< DataPointDate >`, plus the chart's optional `bucketInfo`.
|
|
67
112
|
* @return Tooltip JSX, or `null` when no datum is hovered.
|
|
68
113
|
*/
|
|
69
|
-
export const renderDefaultTooltip = (
|
|
70
|
-
|
|
114
|
+
export const renderDefaultTooltip = (
|
|
115
|
+
params: RenderTooltipParams< DataPointDate > & { bucketInfo?: BucketInfo }
|
|
116
|
+
) => {
|
|
117
|
+
const { tooltipData, bucketInfo } = params;
|
|
71
118
|
const nearestDatum = tooltipData?.nearestDatum?.datum;
|
|
72
119
|
if ( ! nearestDatum ) return null;
|
|
73
120
|
|
|
@@ -81,7 +128,10 @@ export const renderDefaultTooltip = ( params: RenderTooltipParams< DataPointDate
|
|
|
81
128
|
return (
|
|
82
129
|
<div className={ styles[ 'line-chart__tooltip' ] }>
|
|
83
130
|
<div className={ styles[ 'line-chart__tooltip-date' ] }>
|
|
84
|
-
|
|
131
|
+
<TooltipDate
|
|
132
|
+
date={ nearestDatum.date }
|
|
133
|
+
displayResolution={ bucketInfo?.displayResolution ?? 'day' }
|
|
134
|
+
/>
|
|
85
135
|
</div>
|
|
86
136
|
{ tooltipPoints.map( point => (
|
|
87
137
|
<Stack
|
|
@@ -179,6 +229,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
179
229
|
onPointerUp = undefined,
|
|
180
230
|
onPointerMove = undefined,
|
|
181
231
|
onPointerOut = undefined,
|
|
232
|
+
onDatumActivate = undefined,
|
|
182
233
|
zoomable = false,
|
|
183
234
|
rescaleYOnVisibilityChange = true,
|
|
184
235
|
defaultHiddenSeries,
|
|
@@ -193,10 +244,11 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
193
244
|
const legendShape = legend.shape ?? 'line';
|
|
194
245
|
const legendPosition = legend.position ?? 'bottom';
|
|
195
246
|
|
|
196
|
-
const
|
|
247
|
+
const formatting = useChartFormatting();
|
|
197
248
|
const theme = useXYChartTheme( data );
|
|
198
|
-
//
|
|
199
|
-
|
|
249
|
+
// A gradient stop reads its color as a string, so it has to be resolved. Read back what
|
|
250
|
+
// `useXYChartTheme` already resolved rather than paying another getComputedStyle per render.
|
|
251
|
+
const resolvedBackgroundColor = theme.backgroundColor;
|
|
200
252
|
const chartId = useChartId( providedChartId );
|
|
201
253
|
const hiddenSeries = useDefaultHiddenSeries( chartId, defaultHiddenSeries );
|
|
202
254
|
const isSeriesVisible = useCallback(
|
|
@@ -280,7 +332,20 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
280
332
|
return min < max ? [ min, max ] : undefined;
|
|
281
333
|
}, [ rescaleYOnVisibilityChange, dataSorted ] );
|
|
282
334
|
|
|
283
|
-
//
|
|
335
|
+
// Keyboard navigation steps through x positions, and the grouped tooltip
|
|
336
|
+
// reads every series at that position; the first series names the point.
|
|
337
|
+
const activateSelectedPoint = useCallback(
|
|
338
|
+
( index: number ) => {
|
|
339
|
+
const series = dataSorted[ 0 ];
|
|
340
|
+
const datum = series?.data[ index ];
|
|
341
|
+
|
|
342
|
+
if ( series && datum ) {
|
|
343
|
+
onDatumActivate?.( { datum, index, key: series.label } );
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
[ dataSorted, onDatumActivate ]
|
|
347
|
+
);
|
|
348
|
+
|
|
284
349
|
const { tooltipRef, onChartFocus, onChartBlur, onChartKeyDown } = useKeyboardNavigation( {
|
|
285
350
|
selectedIndex,
|
|
286
351
|
setSelectedIndex,
|
|
@@ -288,21 +353,22 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
288
353
|
setIsNavigating,
|
|
289
354
|
chartRef,
|
|
290
355
|
totalPoints: dataSorted[ 0 ]?.data.length || 0,
|
|
356
|
+
onActivate: activateSelectedPoint,
|
|
291
357
|
} );
|
|
292
358
|
|
|
293
359
|
const chartOptions = useMemo( () => {
|
|
294
|
-
const { tickResolution, tickFormat, ...xAxisOptions } = options?.axis?.x ?? {};
|
|
295
|
-
const formatter = tickFormat || getFormatter( dataSorted, tickResolution );
|
|
296
|
-
|
|
297
360
|
return {
|
|
298
361
|
axis: {
|
|
299
|
-
x: {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
362
|
+
x: buildTimeAxisOptions( {
|
|
363
|
+
dataSorted,
|
|
364
|
+
width,
|
|
365
|
+
axisOptions: options?.axis?.x,
|
|
366
|
+
scaleDomain: options?.xScale?.domain,
|
|
367
|
+
zoomDomain: zoom.domain,
|
|
368
|
+
formatting,
|
|
369
|
+
// A hidden line is unmounted, so visx scales to the rest.
|
|
370
|
+
isSeriesRendered: series => isSeriesVisible( series.label ),
|
|
371
|
+
} ),
|
|
306
372
|
y: {
|
|
307
373
|
orientation: 'left' as const,
|
|
308
374
|
numTicks: 4,
|
|
@@ -324,7 +390,18 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
324
390
|
...options?.yScale,
|
|
325
391
|
},
|
|
326
392
|
};
|
|
327
|
-
}, [ options, dataSorted, width, zoom.domain, stableYDomain ] );
|
|
393
|
+
}, [ options, dataSorted, width, zoom.domain, stableYDomain, formatting, isSeriesVisible ] );
|
|
394
|
+
|
|
395
|
+
// Classified from the rendered series, like the axis above: a hidden
|
|
396
|
+
// hourly line must not leave the tooltip naming an hour the axis dropped.
|
|
397
|
+
const bucketInfo = useMemo(
|
|
398
|
+
() =>
|
|
399
|
+
getBucketInfo(
|
|
400
|
+
dataSorted.filter( series => isSeriesVisible( series.label ) ),
|
|
401
|
+
options?.axis?.x?.tickResolution
|
|
402
|
+
),
|
|
403
|
+
[ dataSorted, isSeriesVisible, options?.axis?.x?.tickResolution ]
|
|
404
|
+
);
|
|
328
405
|
|
|
329
406
|
const tooltipRenderGlyph = useMemo( () => {
|
|
330
407
|
return ( props: GlyphProps< DataPointDate > ) => {
|
|
@@ -396,7 +473,14 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
396
473
|
yAccessor: ( d: DataPointDate ) => d?.value,
|
|
397
474
|
};
|
|
398
475
|
|
|
399
|
-
//
|
|
476
|
+
// Augments every renderTooltip call with the chart's bucket classification,
|
|
477
|
+
// default or custom, so a heading keyed on it can't disagree with the axis.
|
|
478
|
+
const tooltipRenderer = useMemo(
|
|
479
|
+
() => ( params: RenderTooltipParams< DataPointDate > ) =>
|
|
480
|
+
renderTooltip( { ...params, bucketInfo } ),
|
|
481
|
+
[ renderTooltip, bucketInfo ]
|
|
482
|
+
);
|
|
483
|
+
|
|
400
484
|
if ( error ) {
|
|
401
485
|
return <div className={ clsx( 'line-chart', styles[ 'line-chart' ] ) }>{ error }</div>;
|
|
402
486
|
}
|
|
@@ -457,7 +541,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
457
541
|
onBlur={ onChartBlur }
|
|
458
542
|
>
|
|
459
543
|
{ chartHeight > 0 && (
|
|
460
|
-
<div ref={ chartRef }
|
|
544
|
+
<div ref={ chartRef } className={ plotStyles[ 'xy-plot' ] }>
|
|
461
545
|
{ zoomable && zoom.domain && <ZoomResetButton onClick={ zoom.reset } /> }
|
|
462
546
|
<XYChart
|
|
463
547
|
theme={ theme }
|
|
@@ -594,7 +678,7 @@ const LineChartInternal = forwardRef< ChartInstanceRef, LineChartProps >(
|
|
|
594
678
|
snapTooltipToDatumX
|
|
595
679
|
snapTooltipToDatumY
|
|
596
680
|
showSeriesGlyphs
|
|
597
|
-
renderTooltip={
|
|
681
|
+
renderTooltip={ tooltipRenderer }
|
|
598
682
|
renderGlyph={ tooltipRenderGlyph }
|
|
599
683
|
glyphStyle={ glyphStyle }
|
|
600
684
|
showVerticalCrosshair={ withTooltipCrosshairs?.showVertical }
|
|
@@ -9,11 +9,13 @@ import {
|
|
|
9
9
|
import { DataContext } from '@visx/xychart';
|
|
10
10
|
import merge from 'deepmerge';
|
|
11
11
|
import { useContext, useRef, useEffect, useState, useMemo } from 'react';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { useGlobalChartsTheme } from '../../../providers';
|
|
13
|
+
import { ANNOTATION_POINTERS } from '../../../providers/chart-context/private/catalog-pointers';
|
|
14
|
+
import { isSafari } from '../../../utils';
|
|
14
15
|
import LineChartAnnotationLabelWithPopover, {
|
|
15
16
|
POPOVER_BUTTON_SIZE,
|
|
16
17
|
} from './line-chart-annotation-label-popover';
|
|
18
|
+
import type { AnnotationStyles } from '../../../types';
|
|
17
19
|
import type { LineChartAnnotationProps } from '../types';
|
|
18
20
|
import type { LabelProps } from '@visx/annotation';
|
|
19
21
|
import type { TextProps } from '@visx/text';
|
|
@@ -152,17 +154,22 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
|
|
|
152
154
|
renderLabelPopover,
|
|
153
155
|
} ) => {
|
|
154
156
|
const providerTheme = useGlobalChartsTheme();
|
|
155
|
-
const scopeElement = useChartScopeElement();
|
|
156
157
|
const { xScale, yScale } = useContext( DataContext ) || {};
|
|
157
158
|
const labelRef = useRef< SVGGElement >( null );
|
|
158
159
|
const [ height, setHeight ] = useState< number | null >( null );
|
|
159
160
|
|
|
160
|
-
// Deep merge
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
161
|
+
// Deep merge to preserve nested object properties. Pointers first, so a per-datum color wins.
|
|
162
|
+
// Memoized because the result is spread into the visx annotation parts: a fresh object every
|
|
163
|
+
// render would give each of them new props whether or not a style changed.
|
|
164
|
+
const styles = useMemo(
|
|
165
|
+
() =>
|
|
166
|
+
merge.all( [
|
|
167
|
+
ANNOTATION_POINTERS,
|
|
168
|
+
providerTheme.annotationStyles ?? {},
|
|
169
|
+
datumStyles ?? {},
|
|
170
|
+
] ) as AnnotationStyles,
|
|
171
|
+
[ providerTheme.annotationStyles, datumStyles ]
|
|
172
|
+
);
|
|
166
173
|
|
|
167
174
|
// Measure the label height once after initial render
|
|
168
175
|
useEffect( () => {
|
|
@@ -269,14 +276,8 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
|
|
|
269
276
|
return (
|
|
270
277
|
<g data-testid={ testId }>
|
|
271
278
|
<Annotation x={ x } y={ y } dx={ dx } dy={ dy }>
|
|
272
|
-
<Connector { ...styles?.connector }
|
|
273
|
-
{ subjectType === 'circle' &&
|
|
274
|
-
<CircleSubject
|
|
275
|
-
{ ...styles?.circleSubject }
|
|
276
|
-
fill={ resolveColor( styles?.circleSubject?.fill ) }
|
|
277
|
-
stroke={ resolveColor( styles?.circleSubject?.stroke ) }
|
|
278
|
-
/>
|
|
279
|
-
) }
|
|
279
|
+
<Connector { ...styles?.connector } />
|
|
280
|
+
{ subjectType === 'circle' && <CircleSubject { ...styles?.circleSubject } /> }
|
|
280
281
|
{ subjectType === 'line-vertical' && (
|
|
281
282
|
<LineSubject
|
|
282
283
|
min={ yMax }
|
|
@@ -312,8 +313,6 @@ const LineChartAnnotation: FC< LineChartAnnotationProps > = ( {
|
|
|
312
313
|
title={ title }
|
|
313
314
|
subtitle={ subtitle }
|
|
314
315
|
{ ...styles?.label }
|
|
315
|
-
anchorLineStroke={ resolveColor( styles?.label?.anchorLineStroke ) }
|
|
316
|
-
backgroundFill={ resolveColor( styles?.label?.backgroundFill ) }
|
|
317
316
|
{ ...labelPosition }
|
|
318
317
|
horizontalAnchor={ getHorizontalAnchor( subjectType, isFlippedHorizontally ) }
|
|
319
318
|
verticalAnchor={ getVerticalAnchor(
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment <rootDir>/tests/environment-los-angeles.mjs
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
import userEvent from '@testing-library/user-event';
|
|
6
|
+
import { GlobalChartsProvider } from '../../../providers';
|
|
7
|
+
import LineChart, { renderDefaultTooltip } from '../line-chart';
|
|
8
|
+
import type { DataPointDate } from '../../../types';
|
|
9
|
+
import type { RenderTooltipParams } from '../../../visx/types';
|
|
10
|
+
|
|
11
|
+
const mockRefCallback = jest.fn();
|
|
12
|
+
jest.mock( '../../../hooks/use-element-size', () => ( {
|
|
13
|
+
useElementSize: () => [ mockRefCallback, 500, 300 ],
|
|
14
|
+
} ) );
|
|
15
|
+
|
|
16
|
+
// The instant below is Aug 2 in Los Angeles and Aug 3 in Tokyo, so a tooltip
|
|
17
|
+
// that reads the runtime zone prints a different day from the axis.
|
|
18
|
+
|
|
19
|
+
const date = new Date( '2026-08-02T15:30:00Z' );
|
|
20
|
+
|
|
21
|
+
const params = {
|
|
22
|
+
tooltipData: {
|
|
23
|
+
nearestDatum: { key: 'Series A', datum: { date, value: 10 }, index: 0, distance: 0 },
|
|
24
|
+
datumByKey: { 'Series A': { key: 'Series A', datum: { date, value: 10 }, index: 0 } },
|
|
25
|
+
},
|
|
26
|
+
} as unknown as RenderTooltipParams< DataPointDate >;
|
|
27
|
+
|
|
28
|
+
describe( 'renderDefaultTooltip date heading', () => {
|
|
29
|
+
it( 'uses the locale and time zone from the provider', () => {
|
|
30
|
+
render(
|
|
31
|
+
<GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
|
|
32
|
+
{ renderDefaultTooltip( params ) }
|
|
33
|
+
</GlobalChartsProvider>
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
expect( screen.getByText( '3.8.2026' ) ).toBeInTheDocument();
|
|
37
|
+
} );
|
|
38
|
+
|
|
39
|
+
it( 'falls back to the runtime locale and zone when the provider supplies none', () => {
|
|
40
|
+
render( <GlobalChartsProvider>{ renderDefaultTooltip( params ) }</GlobalChartsProvider> );
|
|
41
|
+
|
|
42
|
+
expect( screen.getByText( '8/2/2026' ) ).toBeInTheDocument();
|
|
43
|
+
} );
|
|
44
|
+
|
|
45
|
+
// The tooltip renders through a portal, so this is the only check that the
|
|
46
|
+
// provider's context actually reaches it inside a real chart.
|
|
47
|
+
it( 'reaches the tooltip a mounted chart opens', async () => {
|
|
48
|
+
const user = userEvent.setup();
|
|
49
|
+
render(
|
|
50
|
+
<GlobalChartsProvider locale="de-DE" timeZone="Asia/Tokyo">
|
|
51
|
+
<LineChart
|
|
52
|
+
width={ 500 }
|
|
53
|
+
height={ 300 }
|
|
54
|
+
withGradientFill={ false }
|
|
55
|
+
data={ [
|
|
56
|
+
{
|
|
57
|
+
label: 'Series A',
|
|
58
|
+
data: [
|
|
59
|
+
{ date, value: 10 },
|
|
60
|
+
{ date: new Date( date.getTime() + 24 * 60 * 60 * 1000 ), value: 20 },
|
|
61
|
+
],
|
|
62
|
+
options: {},
|
|
63
|
+
},
|
|
64
|
+
] }
|
|
65
|
+
/>
|
|
66
|
+
</GlobalChartsProvider>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
70
|
+
await user.keyboard( '{ArrowRight}' );
|
|
71
|
+
|
|
72
|
+
expect( screen.getByTestId( 'chart-tooltip-0' ) ).toHaveTextContent( '3.8.2026' );
|
|
73
|
+
} );
|
|
74
|
+
} );
|
|
@@ -445,10 +445,11 @@ describe( 'LineChart', () => {
|
|
|
445
445
|
data: [
|
|
446
446
|
{
|
|
447
447
|
label: 'Series A',
|
|
448
|
-
//
|
|
448
|
+
// 23h apart (not 24, and not both midnight): spacing inference would
|
|
449
|
+
// read this as daily buckets, and the two hours format distinctly.
|
|
449
450
|
data: [
|
|
450
451
|
{ date: new Date( '2024-01-01T00:00:00' ), value: 10 },
|
|
451
|
-
{ date: new Date( '2024-01-
|
|
452
|
+
{ date: new Date( '2024-01-01T23:00:00' ), value: 20 },
|
|
452
453
|
],
|
|
453
454
|
},
|
|
454
455
|
],
|
|
@@ -458,6 +459,27 @@ describe( 'LineChart', () => {
|
|
|
458
459
|
expect( ticks.length ).toBeGreaterThan( 1 );
|
|
459
460
|
} );
|
|
460
461
|
|
|
462
|
+
// Exactly 24 hours apart: bare hours would print "12 AM" twice and leave the
|
|
463
|
+
// second day unnamed, so the span reads as multi-day and dates its ticks.
|
|
464
|
+
test( 'names both days when two sub-daily points sit a full day apart', () => {
|
|
465
|
+
renderWithTheme( {
|
|
466
|
+
width: 800,
|
|
467
|
+
options: { axis: { x: { tickResolution: 'hour' } } },
|
|
468
|
+
data: [
|
|
469
|
+
{
|
|
470
|
+
label: 'Series A',
|
|
471
|
+
data: [
|
|
472
|
+
{ date: new Date( '2024-01-01T00:00:00' ), value: 10 },
|
|
473
|
+
{ date: new Date( '2024-01-02T00:00:00' ), value: 20 },
|
|
474
|
+
],
|
|
475
|
+
},
|
|
476
|
+
],
|
|
477
|
+
} );
|
|
478
|
+
|
|
479
|
+
expect( screen.getByText( 'Jan 1' ) ).toBeInTheDocument();
|
|
480
|
+
expect( screen.getByText( 'Jan 2' ) ).toBeInTheDocument();
|
|
481
|
+
} );
|
|
482
|
+
|
|
461
483
|
test( 'renders ticks in short date format.', () => {
|
|
462
484
|
renderWithTheme( {
|
|
463
485
|
width: 800,
|
|
@@ -599,6 +621,8 @@ describe( 'LineChart', () => {
|
|
|
599
621
|
expect( ticks.length ).toBeLessThan( 6 ); // Not much space
|
|
600
622
|
} );
|
|
601
623
|
|
|
624
|
+
// One repeated instant carries no spacing, so the axis falls back to
|
|
625
|
+
// calendar dates rather than reading a zero gap as hourly buckets.
|
|
602
626
|
test( 'renders only one tick when all ticks are the same', () => {
|
|
603
627
|
renderWithTheme( {
|
|
604
628
|
width: 800,
|
|
@@ -614,7 +638,7 @@ describe( 'LineChart', () => {
|
|
|
614
638
|
],
|
|
615
639
|
} );
|
|
616
640
|
|
|
617
|
-
const ticks = screen.getAllByText(
|
|
641
|
+
const ticks = screen.getAllByText( /^[A-Z][a-z]{2} \d{1,2}$/ );
|
|
618
642
|
expect( ticks ).toHaveLength( 1 );
|
|
619
643
|
} );
|
|
620
644
|
} );
|
|
@@ -1153,6 +1177,53 @@ describe( 'LineChart', () => {
|
|
|
1153
1177
|
} );
|
|
1154
1178
|
} );
|
|
1155
1179
|
|
|
1180
|
+
describe( 'Activation', () => {
|
|
1181
|
+
const SERIES_A = {
|
|
1182
|
+
label: 'Series A',
|
|
1183
|
+
data: [
|
|
1184
|
+
{ date: new Date( '2024-01-01' ), value: 10 },
|
|
1185
|
+
{ date: new Date( '2024-01-02' ), value: 20 },
|
|
1186
|
+
],
|
|
1187
|
+
options: {},
|
|
1188
|
+
};
|
|
1189
|
+
const SERIES_B = {
|
|
1190
|
+
label: 'Series B',
|
|
1191
|
+
data: [
|
|
1192
|
+
{ date: new Date( '2024-01-01' ), value: 15 },
|
|
1193
|
+
{ date: new Date( '2024-01-02' ), value: 25 },
|
|
1194
|
+
],
|
|
1195
|
+
options: {},
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
// Navigation steps through x positions; the first series names the point.
|
|
1199
|
+
test( 'Enter hands the selected point to onDatumActivate', async () => {
|
|
1200
|
+
const user = userEvent.setup();
|
|
1201
|
+
const onDatumActivate = jest.fn();
|
|
1202
|
+
renderWithTheme( { data: [ SERIES_A, SERIES_B ], onDatumActivate } );
|
|
1203
|
+
|
|
1204
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
1205
|
+
await user.keyboard( '{ArrowRight}{ArrowRight}{Enter}' );
|
|
1206
|
+
|
|
1207
|
+
expect( onDatumActivate ).toHaveBeenCalledTimes( 1 );
|
|
1208
|
+
expect( onDatumActivate ).toHaveBeenCalledWith( {
|
|
1209
|
+
datum: SERIES_A.data[ 1 ],
|
|
1210
|
+
index: 1,
|
|
1211
|
+
key: 'Series A',
|
|
1212
|
+
} );
|
|
1213
|
+
} );
|
|
1214
|
+
|
|
1215
|
+
test( 'Enter with no point selected activates nothing', async () => {
|
|
1216
|
+
const user = userEvent.setup();
|
|
1217
|
+
const onDatumActivate = jest.fn();
|
|
1218
|
+
renderWithTheme( { data: [ SERIES_A ], onDatumActivate } );
|
|
1219
|
+
|
|
1220
|
+
screen.getByRole( 'grid', { name: /line chart/i } ).focus();
|
|
1221
|
+
await user.keyboard( '{Enter}' );
|
|
1222
|
+
|
|
1223
|
+
expect( onDatumActivate ).not.toHaveBeenCalled();
|
|
1224
|
+
} );
|
|
1225
|
+
} );
|
|
1226
|
+
|
|
1156
1227
|
describe( 'Arrow Key Navigation', () => {
|
|
1157
1228
|
test( 'right arrow key navigates to next data point', async () => {
|
|
1158
1229
|
const user = userEvent.setup();
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment <rootDir>/tests/environment-los-angeles.mjs
|
|
3
|
+
*/
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
import { GlobalChartsProvider } from '../../../providers';
|
|
6
|
+
import LineChart from '../line-chart';
|
|
7
|
+
|
|
8
|
+
const mockRefCallback = jest.fn();
|
|
9
|
+
jest.mock( '../../../hooks/use-element-size', () => ( {
|
|
10
|
+
useElementSize: () => [ mockRefCallback, 500, 300 ],
|
|
11
|
+
} ) );
|
|
12
|
+
|
|
13
|
+
// Each instant below is one day in Los Angeles and the next in Tokyo.
|
|
14
|
+
const data = [
|
|
15
|
+
{
|
|
16
|
+
label: 'Series A',
|
|
17
|
+
data: Array.from( { length: 8 }, ( _, index ) => ( {
|
|
18
|
+
date: new Date( new Date( '2026-08-02T15:30:00Z' ).getTime() + index * 86400000 ),
|
|
19
|
+
value: index,
|
|
20
|
+
} ) ),
|
|
21
|
+
options: {},
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// A long series and a short one, so hiding the long one leaves visx a much
|
|
26
|
+
// narrower domain than the whole dataset would give.
|
|
27
|
+
const monthly = ( label: string, count: number ) => ( {
|
|
28
|
+
label,
|
|
29
|
+
data: Array.from( { length: count }, ( _, index ) => ( {
|
|
30
|
+
date: new Date( Date.UTC( 2026, index, 1, 12 ) ),
|
|
31
|
+
value: index,
|
|
32
|
+
} ) ),
|
|
33
|
+
options: {},
|
|
34
|
+
} );
|
|
35
|
+
|
|
36
|
+
describe( 'line chart tick placement', () => {
|
|
37
|
+
it( 'places one tick per host zone calendar day, with no gap', () => {
|
|
38
|
+
render(
|
|
39
|
+
<GlobalChartsProvider locale="en-US" timeZone="Asia/Tokyo">
|
|
40
|
+
<LineChart width={ 500 } height={ 300 } withGradientFill={ false } data={ data } />
|
|
41
|
+
</GlobalChartsProvider>
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const ticks = screen.getAllByText( /^[A-Z][a-z]{2} \d{1,2}$/ );
|
|
45
|
+
expect( ticks.map( tick => tick.textContent ) ).toEqual( [
|
|
46
|
+
'Aug 3',
|
|
47
|
+
'Aug 4',
|
|
48
|
+
'Aug 5',
|
|
49
|
+
'Aug 6',
|
|
50
|
+
'Aug 7',
|
|
51
|
+
'Aug 8',
|
|
52
|
+
'Aug 9',
|
|
53
|
+
'Aug 10',
|
|
54
|
+
] );
|
|
55
|
+
} );
|
|
56
|
+
|
|
57
|
+
it( 'keeps every tick inside the axis when a series is hidden', () => {
|
|
58
|
+
const { container } = render(
|
|
59
|
+
<GlobalChartsProvider locale="en-US" timeZone="Asia/Tokyo">
|
|
60
|
+
<LineChart
|
|
61
|
+
width={ 500 }
|
|
62
|
+
height={ 300 }
|
|
63
|
+
withGradientFill={ false }
|
|
64
|
+
data={ [ monthly( 'Long', 12 ), monthly( 'Short', 3 ) ] }
|
|
65
|
+
defaultHiddenSeries={ [ 'Long' ] }
|
|
66
|
+
/>
|
|
67
|
+
</GlobalChartsProvider>
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// The x axis renders before the y axis, and neither carries an orientation class.
|
|
71
|
+
/* eslint-disable testing-library/no-container, testing-library/no-node-access -- A tick's position is an SVG attribute no query reaches. */
|
|
72
|
+
const xAxis = container.querySelectorAll( '.visx-axis' )[ 0 ];
|
|
73
|
+
const offsets = Array.from( xAxis.querySelectorAll( '.visx-axis-tick text' ) ).map( tick =>
|
|
74
|
+
Number( tick.getAttribute( 'x' ) )
|
|
75
|
+
);
|
|
76
|
+
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
|
77
|
+
|
|
78
|
+
expect( offsets.length ).toBeGreaterThan( 1 );
|
|
79
|
+
for ( const offset of offsets ) {
|
|
80
|
+
expect( offset ).toBeGreaterThanOrEqual( 0 );
|
|
81
|
+
expect( offset ).toBeLessThanOrEqual( 500 );
|
|
82
|
+
}
|
|
83
|
+
} );
|
|
84
|
+
} );
|