@automattic/charts 1.8.0 → 1.9.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 +21 -0
- package/dist/index.cjs +541 -84
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +122 -34
- package/dist/index.d.cts +97 -7
- package/dist/index.d.ts +97 -7
- package/dist/index.js +539 -87
- package/dist/index.js.map +1 -1
- package/package.json +8 -8
- package/src/charts/area-chart/area-chart.module.scss +3 -1
- package/src/charts/area-chart/area-chart.tsx +5 -2
- package/src/charts/bar-chart/bar-chart.module.scss +6 -2
- package/src/charts/bar-chart/bar-chart.tsx +29 -22
- package/src/charts/bar-chart/private/comparison-bars.tsx +7 -0
- package/src/charts/bar-chart/private/use-bar-chart-options.ts +4 -1
- package/src/charts/bar-chart/test/bar-chart.test.tsx +141 -3
- package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +15 -12
- package/src/charts/geo-chart/geo-chart.tsx +6 -1
- package/src/charts/geo-chart/test/geo-chart.test.tsx +11 -1
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +103 -0
- package/src/charts/heatmap-chart/heatmap-chart.tsx +422 -0
- package/src/charts/heatmap-chart/index.ts +4 -0
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +81 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +53 -0
- package/src/charts/heatmap-chart/private/index.ts +5 -0
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +45 -0
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +88 -0
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +301 -0
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +34 -0
- package/src/charts/heatmap-chart/types.ts +42 -0
- package/src/charts/index.ts +1 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +18 -6
- package/src/charts/line-chart/line-chart.module.scss +6 -4
- package/src/charts/line-chart/line-chart.tsx +6 -2
- package/src/charts/line-chart/private/line-chart-annotation.tsx +16 -3
- package/src/charts/private/grid-control/grid-control.module.scss +1 -4
- package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +14 -0
- package/src/charts/private/with-responsive/with-responsive.tsx +12 -0
- package/src/charts/private/x-zoom.module.scss +6 -3
- package/src/components/legend/private/base-legend.module.scss +3 -1
- package/src/components/tooltip/base-tooltip.module.scss +4 -1
- package/src/components/trend-indicator/trend-indicator.module.scss +5 -3
- package/src/hooks/use-xychart-theme.ts +24 -0
- package/src/index.ts +12 -0
- package/src/providers/chart-context/themes.ts +29 -16
- package/src/types.ts +19 -2
- package/src/utils/color-utils.ts +36 -0
- package/src/utils/test/color-utils.test.ts +33 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { formatNumber, formatNumberCompact } from '@automattic/number-formatters';
|
|
2
|
+
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
|
3
|
+
import { __ } from '@wordpress/i18n';
|
|
4
|
+
import clsx from 'clsx';
|
|
5
|
+
import {
|
|
6
|
+
createContext,
|
|
7
|
+
useCallback,
|
|
8
|
+
useContext,
|
|
9
|
+
useEffect,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from 'react';
|
|
14
|
+
import {
|
|
15
|
+
GlobalChartsProvider,
|
|
16
|
+
useChartId,
|
|
17
|
+
useGlobalChartsContext,
|
|
18
|
+
GlobalChartsContext,
|
|
19
|
+
} from '../../providers';
|
|
20
|
+
import { attachSubComponents } from '../../utils';
|
|
21
|
+
import {
|
|
22
|
+
isValidHexColor,
|
|
23
|
+
lightenHexColor,
|
|
24
|
+
normalizeColorToHex,
|
|
25
|
+
prefersLightText,
|
|
26
|
+
} from '../../utils/color-utils';
|
|
27
|
+
import { Center } from '../private/center';
|
|
28
|
+
import { useChartChildren } from '../private/chart-composition';
|
|
29
|
+
import { ChartLayout } from '../private/chart-layout';
|
|
30
|
+
import { SingleChartContext } from '../private/single-chart-context';
|
|
31
|
+
import { withResponsive } from '../private/with-responsive';
|
|
32
|
+
import styles from './heatmap-chart.module.scss';
|
|
33
|
+
import { getValueExtent, getNormalizedValue, HeatmapLegend, isPresent } from './private';
|
|
34
|
+
import type { HeatmapChartProps, HeatmapTooltipData } from './types';
|
|
35
|
+
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
36
|
+
import type { CSSProperties, FC } from 'react';
|
|
37
|
+
|
|
38
|
+
export type HeatmapContextValue = {
|
|
39
|
+
extent: [ number, number ];
|
|
40
|
+
/** The resolved primary color (full intensity); the legend mixes toward it in CSS. */
|
|
41
|
+
primaryColorHex: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const HeatmapContext = createContext< HeatmapContextValue | null >( null );
|
|
45
|
+
|
|
46
|
+
// Mirrors the color-mix floor in heatmap-chart.module.scss (.heatmap-chart__cell--filled):
|
|
47
|
+
// the rendered fill is the primary mixed over the chart background at 0.15 + 0.85 * intensity.
|
|
48
|
+
const CELL_MIX_FLOOR = 0.15;
|
|
49
|
+
|
|
50
|
+
const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
51
|
+
data,
|
|
52
|
+
chartId: providedChartId,
|
|
53
|
+
width = 0,
|
|
54
|
+
height = 0,
|
|
55
|
+
className,
|
|
56
|
+
compact = false,
|
|
57
|
+
showValues,
|
|
58
|
+
rowLabels = [],
|
|
59
|
+
primaryColor,
|
|
60
|
+
gap = 'md',
|
|
61
|
+
withTooltips = false,
|
|
62
|
+
renderTooltip,
|
|
63
|
+
children,
|
|
64
|
+
} ) => {
|
|
65
|
+
const chartId = useChartId( providedChartId );
|
|
66
|
+
const { getElementStyles, theme } = useGlobalChartsContext();
|
|
67
|
+
const { heatmapChart: heatmapChartSettings } = theme;
|
|
68
|
+
const { nonLegendChildren } = useChartChildren( children, 'HeatmapChart' );
|
|
69
|
+
|
|
70
|
+
const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >();
|
|
71
|
+
const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, showTooltip, hideTooltip } =
|
|
72
|
+
useTooltip< HeatmapTooltipData >();
|
|
73
|
+
const { containerRef, containerBounds, TooltipInPortal } = useTooltipInPortal( {
|
|
74
|
+
detectBounds: true,
|
|
75
|
+
scroll: true,
|
|
76
|
+
} );
|
|
77
|
+
// Read from a ref so the keyboard-tooltip effect doesn't depend on containerBounds, which
|
|
78
|
+
// is a new object each render and would loop the effect via showTooltip.
|
|
79
|
+
const containerBoundsRef = useRef( containerBounds );
|
|
80
|
+
containerBoundsRef.current = containerBounds;
|
|
81
|
+
|
|
82
|
+
const { color: primaryColorHex } = getElementStyles( {
|
|
83
|
+
index: 0,
|
|
84
|
+
overrideColor: primaryColor || heatmapChartSettings.primaryColor,
|
|
85
|
+
} );
|
|
86
|
+
|
|
87
|
+
// Pick the in-cell text color from the cell's actual blended fill luminance (not the data
|
|
88
|
+
// value), so light text is only used where it out-contrasts dark text. Falls back to dark
|
|
89
|
+
// text when the primary isn't a resolvable hex (e.g. a bare CSS token).
|
|
90
|
+
const primaryHex = normalizeColorToHex( primaryColorHex );
|
|
91
|
+
const cellHasLightText = ( intensity: number ): boolean =>
|
|
92
|
+
isValidHexColor( primaryHex ) &&
|
|
93
|
+
prefersLightText(
|
|
94
|
+
lightenHexColor( primaryHex, 1 - ( CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity ) )
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const extent = useMemo( () => getValueExtent( data ), [ data ] );
|
|
98
|
+
const heatmapContext = useMemo< HeatmapContextValue >(
|
|
99
|
+
() => ( { extent, primaryColorHex } ),
|
|
100
|
+
[ extent, primaryColorHex ]
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const columns = data.length;
|
|
104
|
+
const rows = Math.max( 0, ...data.map( column => column.data.length ) );
|
|
105
|
+
|
|
106
|
+
const { compactCellGap, compactCellSize } = heatmapChartSettings;
|
|
107
|
+
const drawValues = showValues ?? ! compact;
|
|
108
|
+
|
|
109
|
+
const buildTooltipData = useCallback(
|
|
110
|
+
( columnIndex: number, rowIndex: number ): HeatmapTooltipData => {
|
|
111
|
+
const cell = data[ columnIndex ]?.data[ rowIndex ];
|
|
112
|
+
return {
|
|
113
|
+
value: cell?.value ?? null,
|
|
114
|
+
rowLabel: rowLabels[ rowIndex ],
|
|
115
|
+
columnLabel: data[ columnIndex ]?.label,
|
|
116
|
+
cellLabel: cell?.label,
|
|
117
|
+
row: rowIndex,
|
|
118
|
+
column: columnIndex,
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
[ data, rowLabels ]
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const onChartBlur = useCallback( () => {
|
|
125
|
+
setSelectedIndex( undefined );
|
|
126
|
+
hideTooltip();
|
|
127
|
+
}, [ hideTooltip ] );
|
|
128
|
+
|
|
129
|
+
const onChartKeyDown = useCallback(
|
|
130
|
+
( event: React.KeyboardEvent< HTMLDivElement > ) => {
|
|
131
|
+
if (
|
|
132
|
+
! [ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Escape', 'Tab' ].includes(
|
|
133
|
+
event.key
|
|
134
|
+
)
|
|
135
|
+
) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if ( event.key === 'Tab' || event.key === 'Escape' ) {
|
|
140
|
+
setSelectedIndex( undefined );
|
|
141
|
+
hideTooltip();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
event.preventDefault();
|
|
146
|
+
|
|
147
|
+
if ( selectedIndex === undefined ) {
|
|
148
|
+
setSelectedIndex( 0 );
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
let col = Math.floor( selectedIndex / rows );
|
|
153
|
+
let row = selectedIndex % rows;
|
|
154
|
+
|
|
155
|
+
if ( event.key === 'ArrowRight' ) {
|
|
156
|
+
col = Math.min( col + 1, columns - 1 );
|
|
157
|
+
} else if ( event.key === 'ArrowLeft' ) {
|
|
158
|
+
col = Math.max( col - 1, 0 );
|
|
159
|
+
} else if ( event.key === 'ArrowDown' ) {
|
|
160
|
+
row = Math.min( row + 1, rows - 1 );
|
|
161
|
+
} else if ( event.key === 'ArrowUp' ) {
|
|
162
|
+
row = Math.max( row - 1, 0 );
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
setSelectedIndex( col * rows + row );
|
|
166
|
+
},
|
|
167
|
+
[ rows, columns, selectedIndex, hideTooltip ]
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const handleCellMouseMove = useCallback(
|
|
171
|
+
( event: React.MouseEvent< HTMLDivElement > ) => {
|
|
172
|
+
if ( ! withTooltips ) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const target = event.currentTarget;
|
|
176
|
+
const columnIndex = Number( target.dataset.column );
|
|
177
|
+
const rowIndex = Number( target.dataset.row );
|
|
178
|
+
// Read bounds from the ref (like the keyboard-tooltip effect) so this
|
|
179
|
+
// callback stays stable across renders.
|
|
180
|
+
const bounds = containerBoundsRef.current;
|
|
181
|
+
// TooltipInPortal re-adds containerBounds, so subtract it to land at the cursor.
|
|
182
|
+
showTooltip( {
|
|
183
|
+
tooltipLeft: event.clientX - bounds.left,
|
|
184
|
+
tooltipTop: event.clientY - bounds.top,
|
|
185
|
+
tooltipData: buildTooltipData( columnIndex, rowIndex ),
|
|
186
|
+
} );
|
|
187
|
+
},
|
|
188
|
+
[ withTooltips, showTooltip, buildTooltipData ]
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const handleCellMouseLeave = useCallback( () => {
|
|
192
|
+
// Keyboard selection owns the tooltip; don't let a mouse-out clear it.
|
|
193
|
+
if ( withTooltips && selectedIndex === undefined ) {
|
|
194
|
+
hideTooltip();
|
|
195
|
+
}
|
|
196
|
+
}, [ withTooltips, selectedIndex, hideTooltip ] );
|
|
197
|
+
|
|
198
|
+
// Anchor the tooltip at the selected cell's center on keyboard nav. Cleared on blur/Escape,
|
|
199
|
+
// not here, so a mouse hover (no selection) isn't affected.
|
|
200
|
+
useEffect( () => {
|
|
201
|
+
if ( ! withTooltips || selectedIndex === undefined ) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const col = Math.floor( selectedIndex / rows );
|
|
205
|
+
const row = selectedIndex % rows;
|
|
206
|
+
const cell =
|
|
207
|
+
typeof document !== 'undefined'
|
|
208
|
+
? document.getElementById( `${ chartId }-cell-${ col }-${ row }` )
|
|
209
|
+
: null;
|
|
210
|
+
const rect = cell?.getBoundingClientRect();
|
|
211
|
+
const bounds = containerBoundsRef.current;
|
|
212
|
+
showTooltip( {
|
|
213
|
+
tooltipLeft: rect ? rect.left + rect.width / 2 - bounds.left : 0,
|
|
214
|
+
tooltipTop: rect ? rect.top + rect.height / 2 - bounds.top : 0,
|
|
215
|
+
tooltipData: buildTooltipData( col, row ),
|
|
216
|
+
} );
|
|
217
|
+
}, [ selectedIndex, withTooltips, rows, chartId, buildTooltipData, showTooltip ] );
|
|
218
|
+
|
|
219
|
+
const defaultRenderTooltip = useCallback(
|
|
220
|
+
( info: HeatmapTooltipData ) => (
|
|
221
|
+
<div>
|
|
222
|
+
<strong>
|
|
223
|
+
{ info.cellLabel || `${ info.columnLabel ?? '' } ${ info.rowLabel ?? '' }`.trim() }
|
|
224
|
+
</strong>
|
|
225
|
+
<div>
|
|
226
|
+
{ info.value === null ? __( 'No data', 'jetpack-charts' ) : formatNumber( info.value ) }
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
),
|
|
230
|
+
[]
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
if ( ! columns || ! rows ) {
|
|
234
|
+
return (
|
|
235
|
+
<Center
|
|
236
|
+
className={ clsx( 'heatmap-chart', styles[ 'heatmap-chart' ], className ) }
|
|
237
|
+
style={ { width: width || undefined, height: height || undefined } }
|
|
238
|
+
data-testid="heatmap-chart"
|
|
239
|
+
>
|
|
240
|
+
<span className={ styles[ 'heatmap-chart__empty' ] }>
|
|
241
|
+
{ __( 'No data available', 'jetpack-charts' ) }
|
|
242
|
+
</span>
|
|
243
|
+
</Center>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const trackSize = compact ? 'var(--heatmap-cell-size)' : 'minmax(0, 1fr)';
|
|
248
|
+
const gridStyle: Record< string, string | number > = {
|
|
249
|
+
'--heatmap-primary': primaryColorHex,
|
|
250
|
+
gridTemplateColumns: `auto repeat(${ columns }, ${ trackSize })`,
|
|
251
|
+
gridTemplateRows: `auto repeat(${ rows }, ${ trackSize })`,
|
|
252
|
+
};
|
|
253
|
+
if ( compact ) {
|
|
254
|
+
gridStyle[ '--heatmap-cell-gap' ] = `${ compactCellGap }px`;
|
|
255
|
+
gridStyle[ '--heatmap-cell-size' ] = `${ compactCellSize }px`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const activeDescendant =
|
|
259
|
+
selectedIndex !== undefined
|
|
260
|
+
? `${ chartId }-cell-${ Math.floor( selectedIndex / rows ) }-${ selectedIndex % rows }`
|
|
261
|
+
: undefined;
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<HeatmapContext.Provider value={ heatmapContext }>
|
|
265
|
+
<SingleChartContext.Provider value={ { chartId } }>
|
|
266
|
+
<ChartLayout
|
|
267
|
+
legendPosition="bottom"
|
|
268
|
+
// Legend renders via trailingContent, not the legend slot.
|
|
269
|
+
legendChildren={ [] }
|
|
270
|
+
trailingContent={ nonLegendChildren }
|
|
271
|
+
gap={ gap }
|
|
272
|
+
className={ clsx( 'heatmap-chart', styles[ 'heatmap-chart' ], className ) }
|
|
273
|
+
// Explicit dimensions (the unresponsive export) pin the size; otherwise
|
|
274
|
+
// width/height are unset and the grid fills its container via CSS. The
|
|
275
|
+
// responsive export drops the measured pixels so reflow stays fluid.
|
|
276
|
+
style={ { width: width || undefined, height: height || undefined } }
|
|
277
|
+
data-testid="heatmap-chart"
|
|
278
|
+
data-chart-id={ `heatmap-chart-${ chartId }` }
|
|
279
|
+
>
|
|
280
|
+
<div
|
|
281
|
+
ref={ containerRef }
|
|
282
|
+
role="grid"
|
|
283
|
+
aria-label={ __( 'Heatmap chart', 'jetpack-charts' ) }
|
|
284
|
+
aria-rowcount={ rows }
|
|
285
|
+
aria-colcount={ columns }
|
|
286
|
+
aria-activedescendant={ activeDescendant }
|
|
287
|
+
tabIndex={ 0 }
|
|
288
|
+
onBlur={ onChartBlur }
|
|
289
|
+
onKeyDown={ onChartKeyDown }
|
|
290
|
+
className={ clsx( styles[ 'heatmap-chart__grid' ], {
|
|
291
|
+
[ styles[ 'heatmap-chart__grid--compact' ] ]: compact,
|
|
292
|
+
} ) }
|
|
293
|
+
style={ gridStyle as CSSProperties }
|
|
294
|
+
>
|
|
295
|
+
{ /* Corner gutter + column labels; aria-hidden, since each cell's label carries the text. */ }
|
|
296
|
+
<span aria-hidden="true" />
|
|
297
|
+
{ data.map( ( column, columnIndex ) => (
|
|
298
|
+
<span
|
|
299
|
+
key={ `col-${ columnIndex }` }
|
|
300
|
+
aria-hidden="true"
|
|
301
|
+
className={ styles[ 'heatmap-chart__col-label' ] }
|
|
302
|
+
>
|
|
303
|
+
{ column.label }
|
|
304
|
+
</span>
|
|
305
|
+
) ) }
|
|
306
|
+
|
|
307
|
+
{ Array.from( { length: rows } ).map( ( _row, rowIndex ) => {
|
|
308
|
+
const labelVisible = ! compact || rowIndex % 2 === 0;
|
|
309
|
+
return (
|
|
310
|
+
<div
|
|
311
|
+
key={ `row-${ rowIndex }` }
|
|
312
|
+
role="row"
|
|
313
|
+
aria-rowindex={ rowIndex + 1 }
|
|
314
|
+
className={ styles[ 'heatmap-chart__row' ] }
|
|
315
|
+
>
|
|
316
|
+
<span aria-hidden="true" className={ styles[ 'heatmap-chart__row-label' ] }>
|
|
317
|
+
{ labelVisible ? rowLabels[ rowIndex ] ?? '' : '' }
|
|
318
|
+
</span>
|
|
319
|
+
{ data.map( ( column, columnIndex ) => {
|
|
320
|
+
const cell = column.data[ rowIndex ];
|
|
321
|
+
const value = cell?.value ?? null;
|
|
322
|
+
const present = isPresent( value );
|
|
323
|
+
const normalized = present ? getNormalizedValue( value, extent ) : 0;
|
|
324
|
+
const flatIndex = columnIndex * rows + rowIndex;
|
|
325
|
+
const info = buildTooltipData( columnIndex, rowIndex );
|
|
326
|
+
const accessibleName =
|
|
327
|
+
info.cellLabel ||
|
|
328
|
+
`${ info.columnLabel ?? '' } ${ info.rowLabel ?? '' }`.trim();
|
|
329
|
+
const accessibleLabel = `${ accessibleName }: ${
|
|
330
|
+
info.value === null
|
|
331
|
+
? __( 'No data', 'jetpack-charts' )
|
|
332
|
+
: formatNumber( info.value )
|
|
333
|
+
}`;
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<div
|
|
337
|
+
key={ `cell-${ columnIndex }-${ rowIndex }` }
|
|
338
|
+
id={ `${ chartId }-cell-${ columnIndex }-${ rowIndex }` }
|
|
339
|
+
data-testid="heatmap-cell"
|
|
340
|
+
role="gridcell"
|
|
341
|
+
// Focus stays on the grid (aria-activedescendant); cells are
|
|
342
|
+
// focusable but out of the tab order.
|
|
343
|
+
tabIndex={ -1 }
|
|
344
|
+
aria-colindex={ columnIndex + 1 }
|
|
345
|
+
aria-label={ accessibleLabel }
|
|
346
|
+
data-column={ columnIndex }
|
|
347
|
+
data-row={ rowIndex }
|
|
348
|
+
className={ clsx( styles[ 'heatmap-chart__cell' ], {
|
|
349
|
+
[ styles[ 'heatmap-chart__cell--filled' ] ]: present,
|
|
350
|
+
[ styles[ 'heatmap-chart__cell--strong' ] ]:
|
|
351
|
+
present && cellHasLightText( normalized ),
|
|
352
|
+
[ styles[ 'heatmap-chart__cell--selected' ] ]:
|
|
353
|
+
selectedIndex === flatIndex,
|
|
354
|
+
} ) }
|
|
355
|
+
style={
|
|
356
|
+
present ? ( { '--intensity': normalized } as CSSProperties ) : undefined
|
|
357
|
+
}
|
|
358
|
+
onMouseMove={ handleCellMouseMove }
|
|
359
|
+
onMouseLeave={ handleCellMouseLeave }
|
|
360
|
+
>
|
|
361
|
+
{ drawValues && present && (
|
|
362
|
+
<span className={ styles[ 'heatmap-chart__cell-value' ] }>
|
|
363
|
+
{ /* Compact so large values fit the cell; tooltip + aria-label keep full precision. */ }
|
|
364
|
+
{ formatNumberCompact( value ) }
|
|
365
|
+
</span>
|
|
366
|
+
) }
|
|
367
|
+
</div>
|
|
368
|
+
);
|
|
369
|
+
} ) }
|
|
370
|
+
</div>
|
|
371
|
+
);
|
|
372
|
+
} ) }
|
|
373
|
+
</div>
|
|
374
|
+
{ withTooltips && tooltipOpen && tooltipData && (
|
|
375
|
+
<TooltipInPortal top={ tooltipTop } left={ tooltipLeft }>
|
|
376
|
+
<div role="tooltip" tabIndex={ -1 }>
|
|
377
|
+
{ ( renderTooltip ?? defaultRenderTooltip )( tooltipData ) }
|
|
378
|
+
</div>
|
|
379
|
+
</TooltipInPortal>
|
|
380
|
+
) }
|
|
381
|
+
</ChartLayout>
|
|
382
|
+
</SingleChartContext.Provider>
|
|
383
|
+
</HeatmapContext.Provider>
|
|
384
|
+
);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const HeatmapChartWithProvider: FC< HeatmapChartProps > = props => {
|
|
388
|
+
const existingContext = useContext( GlobalChartsContext );
|
|
389
|
+
if ( existingContext ) {
|
|
390
|
+
return <HeatmapChartInternal { ...props } />;
|
|
391
|
+
}
|
|
392
|
+
return (
|
|
393
|
+
<GlobalChartsProvider>
|
|
394
|
+
<HeatmapChartInternal { ...props } />
|
|
395
|
+
</GlobalChartsProvider>
|
|
396
|
+
);
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
HeatmapChartWithProvider.displayName = 'HeatmapChart';
|
|
400
|
+
|
|
401
|
+
interface HeatmapChartSubComponents {
|
|
402
|
+
Legend: typeof HeatmapLegend;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const HeatmapChart = attachSubComponents( HeatmapChartWithProvider, {
|
|
406
|
+
Legend: HeatmapLegend,
|
|
407
|
+
} ) as FC< HeatmapChartProps > & HeatmapChartSubComponents;
|
|
408
|
+
|
|
409
|
+
// The responsive wrapper already sizes the container; drop its measured pixel
|
|
410
|
+
// width/height so the grid fills that container via CSS and reflows fluidly,
|
|
411
|
+
// instead of pinning to a debounced measurement.
|
|
412
|
+
const HeatmapChartResponsiveInner: FC< HeatmapChartProps > = props => (
|
|
413
|
+
<HeatmapChartWithProvider { ...props } width={ undefined } height={ undefined } />
|
|
414
|
+
);
|
|
415
|
+
HeatmapChartResponsiveInner.displayName = 'HeatmapChart';
|
|
416
|
+
|
|
417
|
+
const HeatmapChartResponsive = attachSubComponents(
|
|
418
|
+
withResponsive< HeatmapChartProps >( HeatmapChartResponsiveInner ),
|
|
419
|
+
{ Legend: HeatmapLegend }
|
|
420
|
+
) as FC< HeatmapChartProps & ResponsiveConfig > & HeatmapChartSubComponents;
|
|
421
|
+
|
|
422
|
+
export { HeatmapChartResponsive as default, HeatmapChart as HeatmapChartUnresponsive };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { default as HeatmapChart, HeatmapChartUnresponsive } from './heatmap-chart';
|
|
2
|
+
export { buildCalendarHeatmapData } from './private';
|
|
3
|
+
export type { CalendarHeatmapResult } from './private';
|
|
4
|
+
export type { HeatmapChartProps, HeatmapColumn, HeatmapCell, HeatmapTooltipData } from './types';
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { addDays, differenceInCalendarWeeks, format, parseISO, startOfWeek } from 'date-fns';
|
|
2
|
+
import type { DataPointDate } from '../../../types';
|
|
3
|
+
import type { HeatmapCell, HeatmapColumn } from '../types';
|
|
4
|
+
|
|
5
|
+
export type CalendarHeatmapResult = {
|
|
6
|
+
data: HeatmapColumn[];
|
|
7
|
+
rowLabels: string[];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Rows that get a weekday label (Mon, Wed, Fri with a Monday week start). */
|
|
11
|
+
const LABELLED_ROWS = [ 0, 2, 4 ];
|
|
12
|
+
|
|
13
|
+
const toDate = ( point: DataPointDate ): Date | null => {
|
|
14
|
+
if ( point.date instanceof Date && ! isNaN( point.date.getTime() ) ) {
|
|
15
|
+
return point.date;
|
|
16
|
+
}
|
|
17
|
+
if ( point.dateString ) {
|
|
18
|
+
const parsed = parseISO( point.dateString );
|
|
19
|
+
if ( ! isNaN( parsed.getTime() ) ) {
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const buildCalendarHeatmapData = (
|
|
27
|
+
series: DataPointDate[],
|
|
28
|
+
options: { weekStartsOn?: 0 | 1 } = {}
|
|
29
|
+
): CalendarHeatmapResult => {
|
|
30
|
+
const weekStartsOn = options.weekStartsOn ?? 1;
|
|
31
|
+
|
|
32
|
+
const entries = series
|
|
33
|
+
.map( point => ( { date: toDate( point ), value: point.value } ) )
|
|
34
|
+
.filter( ( entry ): entry is { date: Date; value: number | null } => entry.date !== null );
|
|
35
|
+
|
|
36
|
+
if ( ! entries.length ) {
|
|
37
|
+
return { data: [], rowLabels: [] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const valueByDay = new Map< string, number | null >();
|
|
41
|
+
let minDate = entries[ 0 ].date;
|
|
42
|
+
let maxDate = entries[ 0 ].date;
|
|
43
|
+
for ( const { date, value } of entries ) {
|
|
44
|
+
valueByDay.set( format( date, 'yyyy-MM-dd' ), value );
|
|
45
|
+
if ( date < minDate ) {
|
|
46
|
+
minDate = date;
|
|
47
|
+
}
|
|
48
|
+
if ( date > maxDate ) {
|
|
49
|
+
maxDate = date;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const gridStart = startOfWeek( minDate, { weekStartsOn } );
|
|
54
|
+
const weekCount = differenceInCalendarWeeks( maxDate, gridStart, { weekStartsOn } ) + 1;
|
|
55
|
+
|
|
56
|
+
const rowLabels = Array.from( { length: 7 }, ( _, row ) =>
|
|
57
|
+
LABELLED_ROWS.includes( row ) ? format( addDays( gridStart, row ), 'EEE' ) : ''
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const data: HeatmapColumn[] = [];
|
|
61
|
+
let previousMonth = -1;
|
|
62
|
+
for ( let week = 0; week < weekCount; week++ ) {
|
|
63
|
+
const columnStart = addDays( gridStart, week * 7 );
|
|
64
|
+
const month = columnStart.getMonth();
|
|
65
|
+
const label = month !== previousMonth ? format( columnStart, 'MMM' ) : '';
|
|
66
|
+
previousMonth = month;
|
|
67
|
+
|
|
68
|
+
const cells: HeatmapCell[] = [];
|
|
69
|
+
for ( let row = 0; row < 7; row++ ) {
|
|
70
|
+
const day = addDays( gridStart, week * 7 + row );
|
|
71
|
+
const key = format( day, 'yyyy-MM-dd' );
|
|
72
|
+
cells.push( {
|
|
73
|
+
label: format( day, 'EEE, MMM d, yyyy' ),
|
|
74
|
+
value: valueByDay.has( key ) ? ( valueByDay.get( key ) as number | null ) : null,
|
|
75
|
+
} );
|
|
76
|
+
}
|
|
77
|
+
data.push( { label, data: cells } );
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { data, rowLabels };
|
|
81
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { __ } from '@wordpress/i18n';
|
|
2
|
+
import { Stack, Text } from '@wordpress/ui';
|
|
3
|
+
import { useContext } from 'react';
|
|
4
|
+
import { useGlobalChartsTheme } from '../../../providers';
|
|
5
|
+
import { HeatmapContext } from '../heatmap-chart';
|
|
6
|
+
import styles from '../heatmap-chart.module.scss';
|
|
7
|
+
import type { CSSProperties, FC } from 'react';
|
|
8
|
+
|
|
9
|
+
export interface HeatmapLegendProps {
|
|
10
|
+
/** Number of swatches in the scale. Default 5. */
|
|
11
|
+
steps?: number;
|
|
12
|
+
lessLabel?: string;
|
|
13
|
+
moreLabel?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel, moreLabel } ) => {
|
|
17
|
+
const context = useContext( HeatmapContext );
|
|
18
|
+
const { legend } = useGlobalChartsTheme();
|
|
19
|
+
if ( ! context ) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const { primaryColorHex } = context;
|
|
23
|
+
const labelStyle = legend.labelStyles;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<Stack direction="row" gap="xs" align="center">
|
|
27
|
+
<Text variant="body-sm" style={ labelStyle }>
|
|
28
|
+
{ lessLabel ?? __( 'Less', 'jetpack-charts' ) }
|
|
29
|
+
</Text>
|
|
30
|
+
<Stack direction="row" gap="xs">
|
|
31
|
+
{ Array.from( { length: steps }, ( _, index ) => {
|
|
32
|
+
const intensity = steps <= 1 ? 1 : index / ( steps - 1 );
|
|
33
|
+
return (
|
|
34
|
+
<span
|
|
35
|
+
key={ index }
|
|
36
|
+
aria-hidden="true"
|
|
37
|
+
className={ styles[ 'heatmap-chart__legend-swatch' ] }
|
|
38
|
+
style={
|
|
39
|
+
{
|
|
40
|
+
'--heatmap-primary': primaryColorHex,
|
|
41
|
+
'--intensity': intensity,
|
|
42
|
+
} as CSSProperties
|
|
43
|
+
}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
} ) }
|
|
47
|
+
</Stack>
|
|
48
|
+
<Text variant="body-sm" style={ labelStyle }>
|
|
49
|
+
{ moreLabel ?? __( 'More', 'jetpack-charts' ) }
|
|
50
|
+
</Text>
|
|
51
|
+
</Stack>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { getValueExtent, getNormalizedValue, isPresent } from './use-heatmap-colors';
|
|
2
|
+
export { buildCalendarHeatmapData } from './build-calendar-data';
|
|
3
|
+
export { HeatmapLegend } from './heatmap-legend';
|
|
4
|
+
export type { CalendarHeatmapResult } from './build-calendar-data';
|
|
5
|
+
export type { HeatmapLegendProps } from './heatmap-legend';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { HeatmapColumn } from '../types';
|
|
2
|
+
|
|
3
|
+
export const isPresent = ( value: number | null | undefined ): value is number =>
|
|
4
|
+
value !== null && value !== undefined && ! isNaN( value );
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get the min and max values from heatmap data, ignoring null/NaN.
|
|
8
|
+
* @param data - The heatmap columns
|
|
9
|
+
* @return Tuple of [min, max] values
|
|
10
|
+
*/
|
|
11
|
+
export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
|
|
12
|
+
let min = Infinity;
|
|
13
|
+
let max = -Infinity;
|
|
14
|
+
for ( const column of data ) {
|
|
15
|
+
for ( const cell of column.data ) {
|
|
16
|
+
if ( ! isPresent( cell.value ) ) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if ( cell.value < min ) {
|
|
20
|
+
min = cell.value;
|
|
21
|
+
}
|
|
22
|
+
if ( cell.value > max ) {
|
|
23
|
+
max = cell.value;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if ( min === Infinity ) {
|
|
28
|
+
return [ 0, 0 ];
|
|
29
|
+
}
|
|
30
|
+
return [ min, max ];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalize a value to 0–1 within the extent. A flat extent (min === max) maps to 1.
|
|
35
|
+
* @param value - The value to normalize
|
|
36
|
+
* @param extent - Tuple of [min, max] values for the normalization range
|
|
37
|
+
* @return Normalized value between 0 and 1
|
|
38
|
+
*/
|
|
39
|
+
export const getNormalizedValue = ( value: number, extent: [ number, number ] ): number => {
|
|
40
|
+
const [ min, max ] = extent;
|
|
41
|
+
if ( min === max ) {
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
return Math.min( 1, Math.max( 0, ( value - min ) / ( max - min ) ) );
|
|
45
|
+
};
|