@automattic/charts 1.10.0 → 1.10.2
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 +5917 -5783
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +44 -3
- package/dist/index.d.cts +66 -13
- package/dist/index.d.ts +67 -14
- package/dist/index.js +1159 -1015
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/charts/heatmap-chart/heatmap-chart.module.scss +27 -4
- package/src/charts/heatmap-chart/heatmap-chart.tsx +115 -49
- package/src/charts/heatmap-chart/private/build-calendar-data.ts +39 -4
- package/src/charts/heatmap-chart/private/heatmap-context.ts +10 -0
- package/src/charts/heatmap-chart/private/heatmap-legend.tsx +3 -2
- package/src/charts/heatmap-chart/private/index.ts +2 -0
- package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +6 -2
- package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +57 -1
- package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +91 -3
- package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +4 -0
- package/src/charts/heatmap-chart/types.ts +28 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +12 -0
- package/src/charts/leaderboard-chart/leaderboard-chart.tsx +65 -37
- package/src/charts/leaderboard-chart/test/leaderboard-chart.test.tsx +46 -6
- package/src/charts/pie-semi-circle-chart/test/pie-semi-circle-chart.test.tsx +0 -6
- package/src/charts/private/with-responsive/test/with-responsive.test.tsx +229 -18
- package/src/charts/private/with-responsive/with-responsive.module.scss +20 -1
- package/src/charts/private/with-responsive/with-responsive.tsx +114 -65
- package/src/hooks/test/use-chart-margin.test.tsx +8 -5
- package/src/hooks/use-chart-margin.tsx +11 -1
- package/src/providers/chart-context/global-charts-provider.tsx +7 -0
- package/src/providers/chart-context/test/chart-context.test.tsx +40 -0
- package/src/providers/chart-context/types.ts +3 -0
- package/src/types.ts +9 -6
- package/src/utils/color-utils.ts +28 -0
- package/src/utils/test/color-utils.test.ts +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automattic/charts",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.2",
|
|
4
4
|
"description": "Display charts within Automattic products.",
|
|
5
5
|
"homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/js-packages/charts/#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"typecheck": "tsgo --noEmit"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"@automattic/number-formatters": "^1.2.
|
|
66
|
+
"@automattic/number-formatters": "^1.2.7",
|
|
67
67
|
"@babel/runtime": "7.29.7",
|
|
68
68
|
"@react-spring/web": "10.1.1",
|
|
69
69
|
"@visx/annotation": "^4.0.0",
|
|
@@ -107,11 +107,11 @@
|
|
|
107
107
|
"@types/jest": "^30.0.0",
|
|
108
108
|
"@types/react": "18.3.28",
|
|
109
109
|
"@types/react-dom": "18.3.7",
|
|
110
|
-
"@typescript/native-preview": "7.0.0-dev.
|
|
110
|
+
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
|
111
111
|
"@visx/glyph": "4.0.0",
|
|
112
|
-
"@wordpress/components": "
|
|
113
|
-
"@wordpress/element": "8.
|
|
114
|
-
"@wordpress/private-apis": "1.
|
|
112
|
+
"@wordpress/components": "37.0.0",
|
|
113
|
+
"@wordpress/element": "8.3.0",
|
|
114
|
+
"@wordpress/private-apis": "1.51.0",
|
|
115
115
|
"babel-jest": "30.4.1",
|
|
116
116
|
"identity-obj-proxy": "^3.0.0",
|
|
117
117
|
"jest": "30.4.2",
|
|
@@ -122,7 +122,7 @@
|
|
|
122
122
|
"sass-embedded": "1.97.3",
|
|
123
123
|
"storybook": "10.4.6",
|
|
124
124
|
"tsdown": "0.22.2",
|
|
125
|
-
"typescript": "
|
|
125
|
+
"typescript": "6.0.3"
|
|
126
126
|
},
|
|
127
127
|
"peerDependencies": {
|
|
128
128
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
font-size: var(--wpds-typography-font-size-sm, 12px);
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
// Height-capped grids are content-sized vertically, so the wrapper hugs them
|
|
9
|
+
// instead of stretching — the consumer decides how to place it in leftover
|
|
10
|
+
// space. Width-only caps keep the normal full-height layout.
|
|
11
|
+
.heatmap-chart--height-capped {
|
|
12
|
+
height: max-content;
|
|
13
|
+
}
|
|
14
|
+
|
|
8
15
|
.heatmap-chart__empty {
|
|
9
16
|
padding: var(--wpds-dimension-padding-lg, 16px);
|
|
10
17
|
color: var(--wpds-color-foreground-content-neutral-weak, #707070);
|
|
@@ -17,8 +24,8 @@
|
|
|
17
24
|
gap: var(--heatmap-cell-gap, var(--wpds-dimension-gap-xs, 4px));
|
|
18
25
|
flex: 1 1 0;
|
|
19
26
|
width: 100%;
|
|
20
|
-
//
|
|
21
|
-
min-height:
|
|
27
|
+
// Override flex's auto minimum so short widgets do not scroll.
|
|
28
|
+
min-height: 0;
|
|
22
29
|
|
|
23
30
|
&:focus-visible {
|
|
24
31
|
outline:
|
|
@@ -36,6 +43,15 @@
|
|
|
36
43
|
min-height: 0;
|
|
37
44
|
}
|
|
38
45
|
|
|
46
|
+
// A cell-height cap makes the row tracks content-bound, so the grid must not
|
|
47
|
+
// stretch: leftover container height would otherwise be absorbed by the auto
|
|
48
|
+
// label row. A width-only cap does not change vertical flex sizing.
|
|
49
|
+
.heatmap-chart__grid--height-capped {
|
|
50
|
+
flex: 0 0 auto;
|
|
51
|
+
height: max-content;
|
|
52
|
+
min-height: 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
39
55
|
.heatmap-chart__row {
|
|
40
56
|
display: contents;
|
|
41
57
|
}
|
|
@@ -71,10 +87,17 @@
|
|
|
71
87
|
background: var(--wpds-color-background-track-neutral-weak, #f0f0f0);
|
|
72
88
|
}
|
|
73
89
|
|
|
90
|
+
// An empty grid slot (calendar ragged edges): keeps its track but paints and
|
|
91
|
+
// captures nothing. `visibility` (not `display`) so the slot still sizes.
|
|
92
|
+
.heatmap-chart__cell--hidden {
|
|
93
|
+
visibility: hidden;
|
|
94
|
+
pointer-events: none;
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
// Floor the mix at 15% so the lowest value stays visibly tinted, distinct from
|
|
75
98
|
// an empty cell.
|
|
76
99
|
.heatmap-chart__cell--filled {
|
|
77
|
-
background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%),
|
|
100
|
+
background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%), var(--heatmap-bg, #fff));
|
|
78
101
|
}
|
|
79
102
|
|
|
80
103
|
.heatmap-chart__cell--selected {
|
|
@@ -99,5 +122,5 @@
|
|
|
99
122
|
width: var(--wpds-dimension-size-3xs, 12px);
|
|
100
123
|
height: var(--wpds-dimension-size-3xs, 12px);
|
|
101
124
|
border-radius: var(--wpds-border-radius-sm, 2px);
|
|
102
|
-
background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%),
|
|
125
|
+
background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%), var(--heatmap-bg, #fff));
|
|
103
126
|
}
|
|
@@ -2,15 +2,7 @@ import { formatNumber, formatNumberCompact } from '@automattic/number-formatters
|
|
|
2
2
|
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
|
|
3
3
|
import { __ } from '@wordpress/i18n';
|
|
4
4
|
import clsx from 'clsx';
|
|
5
|
-
import {
|
|
6
|
-
createContext,
|
|
7
|
-
useCallback,
|
|
8
|
-
useContext,
|
|
9
|
-
useEffect,
|
|
10
|
-
useMemo,
|
|
11
|
-
useRef,
|
|
12
|
-
useState,
|
|
13
|
-
} from 'react';
|
|
5
|
+
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
14
6
|
import {
|
|
15
7
|
GlobalChartsProvider,
|
|
16
8
|
useChartId,
|
|
@@ -20,7 +12,7 @@ import {
|
|
|
20
12
|
import { attachSubComponents } from '../../utils';
|
|
21
13
|
import {
|
|
22
14
|
isValidHexColor,
|
|
23
|
-
|
|
15
|
+
mixHexColors,
|
|
24
16
|
normalizeColorToHex,
|
|
25
17
|
prefersLightText,
|
|
26
18
|
} from '../../utils/color-utils';
|
|
@@ -30,19 +22,18 @@ import { ChartLayout } from '../private/chart-layout';
|
|
|
30
22
|
import { SingleChartContext } from '../private/single-chart-context';
|
|
31
23
|
import { withResponsive } from '../private/with-responsive';
|
|
32
24
|
import styles from './heatmap-chart.module.scss';
|
|
33
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
getValueExtent,
|
|
27
|
+
getNormalizedValue,
|
|
28
|
+
HeatmapContext,
|
|
29
|
+
HeatmapLegend,
|
|
30
|
+
isPresent,
|
|
31
|
+
} from './private';
|
|
32
|
+
import type { HeatmapContextValue } from './private';
|
|
34
33
|
import type { HeatmapChartProps, HeatmapTooltipData } from './types';
|
|
35
34
|
import type { ResponsiveConfig } from '../private/with-responsive';
|
|
36
35
|
import type { CSSProperties, FC } from 'react';
|
|
37
36
|
|
|
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
37
|
// Mirrors the color-mix floor in heatmap-chart.module.scss (.heatmap-chart__cell--filled):
|
|
47
38
|
// the rendered fill is the primary mixed over the chart background at 0.15 + 0.85 * intensity.
|
|
48
39
|
const CELL_MIX_FLOOR = 0.15;
|
|
@@ -55,6 +46,10 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
55
46
|
className,
|
|
56
47
|
compact = false,
|
|
57
48
|
showValues,
|
|
49
|
+
maxCellWidth,
|
|
50
|
+
maxCellHeight,
|
|
51
|
+
minCellWidth,
|
|
52
|
+
minCellHeight,
|
|
58
53
|
rowLabels = [],
|
|
59
54
|
primaryColor,
|
|
60
55
|
gap = 'md',
|
|
@@ -63,7 +58,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
63
58
|
children,
|
|
64
59
|
} ) => {
|
|
65
60
|
const chartId = useChartId( providedChartId );
|
|
66
|
-
const { getElementStyles, theme } = useGlobalChartsContext();
|
|
61
|
+
const { getElementStyles, resolveThemeColor, theme } = useGlobalChartsContext();
|
|
67
62
|
const { heatmapChart: heatmapChartSettings } = theme;
|
|
68
63
|
const { nonLegendChildren } = useChartChildren( children, 'HeatmapChart' );
|
|
69
64
|
|
|
@@ -84,14 +79,22 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
84
79
|
overrideColor: primaryColor || heatmapChartSettings.primaryColor,
|
|
85
80
|
} );
|
|
86
81
|
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
82
|
+
// Resolve the background in the provider's theme scope so the blended-fill text
|
|
83
|
+
// color tracks a themed (e.g. dark) background.
|
|
84
|
+
const chartBackgroundHex = resolveThemeColor( theme.backgroundColor );
|
|
85
|
+
|
|
86
|
+
// Choose text color from the blended fill, not the raw value.
|
|
87
|
+
// If either color cannot resolve to hex, keep dark text.
|
|
90
88
|
const primaryHex = normalizeColorToHex( primaryColorHex );
|
|
91
89
|
const cellHasLightText = ( intensity: number ): boolean =>
|
|
92
90
|
isValidHexColor( primaryHex ) &&
|
|
91
|
+
isValidHexColor( chartBackgroundHex ) &&
|
|
93
92
|
prefersLightText(
|
|
94
|
-
|
|
93
|
+
mixHexColors(
|
|
94
|
+
primaryHex,
|
|
95
|
+
chartBackgroundHex,
|
|
96
|
+
1 - ( CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity )
|
|
97
|
+
)
|
|
95
98
|
);
|
|
96
99
|
|
|
97
100
|
const extent = useMemo( () => getValueExtent( data ), [ data ] );
|
|
@@ -126,6 +129,11 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
126
129
|
hideTooltip();
|
|
127
130
|
}, [ hideTooltip ] );
|
|
128
131
|
|
|
132
|
+
const isCellHidden = useCallback(
|
|
133
|
+
( col: number, row: number ) => data[ col ]?.data[ row ]?.hidden === true,
|
|
134
|
+
[ data ]
|
|
135
|
+
);
|
|
136
|
+
|
|
129
137
|
const onChartKeyDown = useCallback(
|
|
130
138
|
( event: React.KeyboardEvent< HTMLDivElement > ) => {
|
|
131
139
|
if (
|
|
@@ -145,26 +153,46 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
145
153
|
event.preventDefault();
|
|
146
154
|
|
|
147
155
|
if ( selectedIndex === undefined ) {
|
|
148
|
-
|
|
156
|
+
// Start at the first navigable cell (a calendar's leading edge
|
|
157
|
+
// slots may be hidden).
|
|
158
|
+
for ( let index = 0; index < columns * rows; index++ ) {
|
|
159
|
+
if ( ! isCellHidden( Math.floor( index / rows ), index % rows ) ) {
|
|
160
|
+
setSelectedIndex( index );
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
149
164
|
return;
|
|
150
165
|
}
|
|
151
166
|
|
|
152
|
-
let
|
|
153
|
-
let
|
|
154
|
-
|
|
167
|
+
let stepCol = 0;
|
|
168
|
+
let stepRow = 0;
|
|
155
169
|
if ( event.key === 'ArrowRight' ) {
|
|
156
|
-
|
|
170
|
+
stepCol = 1;
|
|
157
171
|
} else if ( event.key === 'ArrowLeft' ) {
|
|
158
|
-
|
|
172
|
+
stepCol = -1;
|
|
159
173
|
} else if ( event.key === 'ArrowDown' ) {
|
|
160
|
-
|
|
174
|
+
stepRow = 1;
|
|
161
175
|
} else if ( event.key === 'ArrowUp' ) {
|
|
162
|
-
|
|
176
|
+
stepRow = -1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Step past hidden slots to the next navigable cell in the pressed
|
|
180
|
+
// direction; when only hidden slots (or the edge) remain that way,
|
|
181
|
+
// the selection stays put.
|
|
182
|
+
let col = Math.floor( selectedIndex / rows );
|
|
183
|
+
let row = selectedIndex % rows;
|
|
184
|
+
do {
|
|
185
|
+
col += stepCol;
|
|
186
|
+
row += stepRow;
|
|
187
|
+
} while ( col >= 0 && col < columns && row >= 0 && row < rows && isCellHidden( col, row ) );
|
|
188
|
+
|
|
189
|
+
if ( col < 0 || col >= columns || row < 0 || row >= rows ) {
|
|
190
|
+
return;
|
|
163
191
|
}
|
|
164
192
|
|
|
165
193
|
setSelectedIndex( col * rows + row );
|
|
166
194
|
},
|
|
167
|
-
[ rows, columns, selectedIndex, hideTooltip ]
|
|
195
|
+
[ rows, columns, selectedIndex, hideTooltip, isCellHidden ]
|
|
168
196
|
);
|
|
169
197
|
|
|
170
198
|
const handleCellMouseMove = useCallback(
|
|
@@ -244,11 +272,21 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
244
272
|
);
|
|
245
273
|
}
|
|
246
274
|
|
|
247
|
-
|
|
275
|
+
// Non-compact tracks split the container by default; a max cap makes them
|
|
276
|
+
// stop growing there instead, so sparse ranges keep sensible cell sizes,
|
|
277
|
+
// and a min floor makes the grid overflow (for a scrollable wrapper)
|
|
278
|
+
// rather than crushing cells on long ranges.
|
|
279
|
+
const columnTrack = compact
|
|
280
|
+
? 'var(--heatmap-cell-size)'
|
|
281
|
+
: `minmax(${ minCellWidth ?? 0 }px, ${ maxCellWidth ? `${ maxCellWidth }px` : '1fr' })`;
|
|
282
|
+
const rowTrack = compact
|
|
283
|
+
? 'var(--heatmap-cell-size)'
|
|
284
|
+
: `minmax(${ minCellHeight ?? 0 }px, ${ maxCellHeight ? `${ maxCellHeight }px` : '1fr' })`;
|
|
248
285
|
const gridStyle: Record< string, string | number > = {
|
|
249
286
|
'--heatmap-primary': primaryColorHex,
|
|
250
|
-
|
|
251
|
-
|
|
287
|
+
'--heatmap-bg': theme.backgroundColor,
|
|
288
|
+
gridTemplateColumns: `auto repeat(${ columns }, ${ columnTrack })`,
|
|
289
|
+
gridTemplateRows: `auto repeat(${ rows }, ${ rowTrack })`,
|
|
252
290
|
};
|
|
253
291
|
if ( compact ) {
|
|
254
292
|
gridStyle[ '--heatmap-cell-gap' ] = `${ compactCellGap }px`;
|
|
@@ -260,6 +298,12 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
260
298
|
? `${ chartId }-cell-${ Math.floor( selectedIndex / rows ) }-${ selectedIndex % rows }`
|
|
261
299
|
: undefined;
|
|
262
300
|
|
|
301
|
+
// A capped row track makes the chart content-sized vertically: neither the
|
|
302
|
+
// wrapper nor the grid stretches, or the leftover container height would
|
|
303
|
+
// land in the auto label row. A width-only cap must keep the normal vertical
|
|
304
|
+
// flex sizing, so it does not opt into this class.
|
|
305
|
+
const heightCapped = ! compact && Boolean( maxCellHeight );
|
|
306
|
+
|
|
263
307
|
return (
|
|
264
308
|
<HeatmapContext.Provider value={ heatmapContext }>
|
|
265
309
|
<SingleChartContext.Provider value={ { chartId } }>
|
|
@@ -269,7 +313,9 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
269
313
|
legendChildren={ [] }
|
|
270
314
|
trailingContent={ nonLegendChildren }
|
|
271
315
|
gap={ gap }
|
|
272
|
-
className={ clsx( 'heatmap-chart', styles[ 'heatmap-chart' ], className
|
|
316
|
+
className={ clsx( 'heatmap-chart', styles[ 'heatmap-chart' ], className, {
|
|
317
|
+
[ styles[ 'heatmap-chart--height-capped' ] ]: heightCapped,
|
|
318
|
+
} ) }
|
|
273
319
|
// Explicit dimensions (the unresponsive export) pin the size; otherwise
|
|
274
320
|
// width/height are unset and the grid fills its container via CSS. The
|
|
275
321
|
// responsive export drops the measured pixels so reflow stays fluid.
|
|
@@ -289,20 +335,22 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
289
335
|
onKeyDown={ onChartKeyDown }
|
|
290
336
|
className={ clsx( styles[ 'heatmap-chart__grid' ], {
|
|
291
337
|
[ styles[ 'heatmap-chart__grid--compact' ] ]: compact,
|
|
338
|
+
[ styles[ 'heatmap-chart__grid--height-capped' ] ]: heightCapped,
|
|
292
339
|
} ) }
|
|
293
340
|
style={ gridStyle as CSSProperties }
|
|
294
341
|
>
|
|
295
|
-
{ /*
|
|
296
|
-
<
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
342
|
+
{ /* Header row preserves the grid structure; cell aria-labels include this text. */ }
|
|
343
|
+
<div role="row" aria-hidden="true" className={ styles[ 'heatmap-chart__row' ] }>
|
|
344
|
+
<span />
|
|
345
|
+
{ data.map( ( column, columnIndex ) => (
|
|
346
|
+
<span
|
|
347
|
+
key={ `col-${ columnIndex }` }
|
|
348
|
+
className={ styles[ 'heatmap-chart__col-label' ] }
|
|
349
|
+
>
|
|
350
|
+
{ column.label }
|
|
351
|
+
</span>
|
|
352
|
+
) ) }
|
|
353
|
+
</div>
|
|
306
354
|
|
|
307
355
|
{ Array.from( { length: rows } ).map( ( _row, rowIndex ) => {
|
|
308
356
|
const labelVisible = ! compact || rowIndex % 2 === 0;
|
|
@@ -318,6 +366,24 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
318
366
|
</span>
|
|
319
367
|
{ data.map( ( column, columnIndex ) => {
|
|
320
368
|
const cell = column.data[ rowIndex ];
|
|
369
|
+
|
|
370
|
+
// A hidden cell keeps its grid slot (so the rest of the
|
|
371
|
+
// column doesn't shift) but paints nothing and takes no
|
|
372
|
+
// interaction — a calendar's ragged edges.
|
|
373
|
+
if ( cell?.hidden ) {
|
|
374
|
+
return (
|
|
375
|
+
<div
|
|
376
|
+
key={ `cell-${ columnIndex }-${ rowIndex }` }
|
|
377
|
+
data-testid="heatmap-cell-hidden"
|
|
378
|
+
aria-hidden="true"
|
|
379
|
+
className={ clsx(
|
|
380
|
+
styles[ 'heatmap-chart__cell' ],
|
|
381
|
+
styles[ 'heatmap-chart__cell--hidden' ]
|
|
382
|
+
) }
|
|
383
|
+
/>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
321
387
|
const value = cell?.value ?? null;
|
|
322
388
|
const present = isPresent( value );
|
|
323
389
|
const normalized = present ? getNormalizedValue( value, extent ) : 0;
|
|
@@ -360,7 +426,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
|
|
|
360
426
|
>
|
|
361
427
|
{ drawValues && present && (
|
|
362
428
|
<span className={ styles[ 'heatmap-chart__cell-value' ] }>
|
|
363
|
-
{ /* Compact
|
|
429
|
+
{ /* Compact display; tooltip and aria-label keep full precision. */ }
|
|
364
430
|
{ formatNumberCompact( value ) }
|
|
365
431
|
</span>
|
|
366
432
|
) }
|
|
@@ -25,9 +25,19 @@ const toDate = ( point: DataPointDate ): Date | null => {
|
|
|
25
25
|
|
|
26
26
|
export const buildCalendarHeatmapData = (
|
|
27
27
|
series: DataPointDate[],
|
|
28
|
-
options: {
|
|
28
|
+
options: {
|
|
29
|
+
weekStartsOn?: 0 | 1;
|
|
30
|
+
/**
|
|
31
|
+
* Mark the days completing the first/last week outside the series'
|
|
32
|
+
* date span as hidden cells (empty grid slots) instead of blank
|
|
33
|
+
* cells, giving the calendar ragged edges. Days inside the span stay
|
|
34
|
+
* blank cells even when the series has no entry for them.
|
|
35
|
+
*/
|
|
36
|
+
hideOutOfRangeDays?: boolean;
|
|
37
|
+
} = {}
|
|
29
38
|
): CalendarHeatmapResult => {
|
|
30
39
|
const weekStartsOn = options.weekStartsOn ?? 1;
|
|
40
|
+
const hideOutOfRangeDays = options.hideOutOfRangeDays ?? true;
|
|
31
41
|
|
|
32
42
|
const entries = series
|
|
33
43
|
.map( point => ( { date: toDate( point ), value: point.value } ) )
|
|
@@ -53,26 +63,51 @@ export const buildCalendarHeatmapData = (
|
|
|
53
63
|
const gridStart = startOfWeek( minDate, { weekStartsOn } );
|
|
54
64
|
const weekCount = differenceInCalendarWeeks( maxDate, gridStart, { weekStartsOn } ) + 1;
|
|
55
65
|
|
|
66
|
+
// Day-key bounds for the ragged-edge option: calendar-day comparison, so
|
|
67
|
+
// entries carrying a time of day can't shift the span.
|
|
68
|
+
const minDayKey = format( minDate, 'yyyy-MM-dd' );
|
|
69
|
+
const maxDayKey = format( maxDate, 'yyyy-MM-dd' );
|
|
70
|
+
|
|
56
71
|
const rowLabels = Array.from( { length: 7 }, ( _, row ) =>
|
|
57
72
|
LABELLED_ROWS.includes( row ) ? format( addDays( gridStart, row ), 'EEE' ) : ''
|
|
58
73
|
);
|
|
59
74
|
|
|
75
|
+
// Hide short partial first-month labels when a later month follows; compact
|
|
76
|
+
// cells make adjacent labels collide. Keep the label for single-month ranges.
|
|
77
|
+
const MIN_FIRST_MONTH_WEEKS = 2;
|
|
78
|
+
const firstMonth = gridStart.getMonth();
|
|
79
|
+
let firstMonthWeeks = 0;
|
|
80
|
+
while (
|
|
81
|
+
firstMonthWeeks < weekCount &&
|
|
82
|
+
addDays( gridStart, firstMonthWeeks * 7 ).getMonth() === firstMonth
|
|
83
|
+
) {
|
|
84
|
+
firstMonthWeeks++;
|
|
85
|
+
}
|
|
86
|
+
const spansLaterMonth = firstMonthWeeks < weekCount;
|
|
87
|
+
const showFirstMonthLabel = ! spansLaterMonth || firstMonthWeeks >= MIN_FIRST_MONTH_WEEKS;
|
|
88
|
+
|
|
60
89
|
const data: HeatmapColumn[] = [];
|
|
61
90
|
let previousMonth = -1;
|
|
62
91
|
for ( let week = 0; week < weekCount; week++ ) {
|
|
63
92
|
const columnStart = addDays( gridStart, week * 7 );
|
|
64
93
|
const month = columnStart.getMonth();
|
|
65
|
-
const
|
|
94
|
+
const isNewMonth = month !== previousMonth;
|
|
95
|
+
const label =
|
|
96
|
+
isNewMonth && ( week !== 0 || showFirstMonthLabel ) ? format( columnStart, 'MMM' ) : '';
|
|
66
97
|
previousMonth = month;
|
|
67
98
|
|
|
68
99
|
const cells: HeatmapCell[] = [];
|
|
69
100
|
for ( let row = 0; row < 7; row++ ) {
|
|
70
101
|
const day = addDays( gridStart, week * 7 + row );
|
|
71
102
|
const key = format( day, 'yyyy-MM-dd' );
|
|
72
|
-
|
|
103
|
+
const cell: HeatmapCell = {
|
|
73
104
|
label: format( day, 'EEE, MMM d, yyyy' ),
|
|
74
105
|
value: valueByDay.has( key ) ? ( valueByDay.get( key ) as number | null ) : null,
|
|
75
|
-
}
|
|
106
|
+
};
|
|
107
|
+
if ( hideOutOfRangeDays && ( key < minDayKey || key > maxDayKey ) ) {
|
|
108
|
+
cell.hidden = true;
|
|
109
|
+
}
|
|
110
|
+
cells.push( cell );
|
|
76
111
|
}
|
|
77
112
|
data.push( { label, data: cells } );
|
|
78
113
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { createContext } from 'react';
|
|
2
|
+
|
|
3
|
+
export type HeatmapContextValue = {
|
|
4
|
+
extent: [ number, number ];
|
|
5
|
+
/** The resolved primary color (full intensity); the legend mixes toward it in CSS. */
|
|
6
|
+
primaryColorHex: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Shared by the chart and legend without importing back from `heatmap-chart.tsx`. */
|
|
10
|
+
export const HeatmapContext = createContext< HeatmapContextValue | null >( null );
|
|
@@ -2,8 +2,8 @@ import { __ } from '@wordpress/i18n';
|
|
|
2
2
|
import { Stack, Text } from '@wordpress/ui';
|
|
3
3
|
import { useContext } from 'react';
|
|
4
4
|
import { useGlobalChartsTheme } from '../../../providers';
|
|
5
|
-
import { HeatmapContext } from '../heatmap-chart';
|
|
6
5
|
import styles from '../heatmap-chart.module.scss';
|
|
6
|
+
import { HeatmapContext } from './heatmap-context';
|
|
7
7
|
import type { CSSProperties, FC } from 'react';
|
|
8
8
|
|
|
9
9
|
export interface HeatmapLegendProps {
|
|
@@ -15,7 +15,7 @@ export interface HeatmapLegendProps {
|
|
|
15
15
|
|
|
16
16
|
export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel, moreLabel } ) => {
|
|
17
17
|
const context = useContext( HeatmapContext );
|
|
18
|
-
const { legend } = useGlobalChartsTheme();
|
|
18
|
+
const { legend, backgroundColor } = useGlobalChartsTheme();
|
|
19
19
|
if ( ! context ) {
|
|
20
20
|
return null;
|
|
21
21
|
}
|
|
@@ -38,6 +38,7 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel,
|
|
|
38
38
|
style={
|
|
39
39
|
{
|
|
40
40
|
'--heatmap-primary': primaryColorHex,
|
|
41
|
+
'--heatmap-bg': backgroundColor,
|
|
41
42
|
'--intensity': intensity,
|
|
42
43
|
} as CSSProperties
|
|
43
44
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { getValueExtent, getNormalizedValue, isPresent } from './use-heatmap-colors';
|
|
2
2
|
export { buildCalendarHeatmapData } from './build-calendar-data';
|
|
3
3
|
export { HeatmapLegend } from './heatmap-legend';
|
|
4
|
+
export { HeatmapContext } from './heatmap-context';
|
|
4
5
|
export type { CalendarHeatmapResult } from './build-calendar-data';
|
|
5
6
|
export type { HeatmapLegendProps } from './heatmap-legend';
|
|
7
|
+
export type { HeatmapContextValue } from './heatmap-context';
|
|
@@ -31,7 +31,11 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
|
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* Normalize a value to 0–1 within the extent. A flat extent (min === max)
|
|
34
|
+
* Normalize a value to 0–1 within the extent. A flat extent (min === max)
|
|
35
|
+
* maps to 1 — every cell is equally the "highest" — except an all-zero
|
|
36
|
+
* extent, which maps to 0 so a no-activity grid renders at the scale's
|
|
37
|
+
* bottom instead of full intensity.
|
|
38
|
+
*
|
|
35
39
|
* @param value - The value to normalize
|
|
36
40
|
* @param extent - Tuple of [min, max] values for the normalization range
|
|
37
41
|
* @return Normalized value between 0 and 1
|
|
@@ -39,7 +43,7 @@ export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => {
|
|
|
39
43
|
export const getNormalizedValue = ( value: number, extent: [ number, number ] ): number => {
|
|
40
44
|
const [ min, max ] = extent;
|
|
41
45
|
if ( min === max ) {
|
|
42
|
-
return 1;
|
|
46
|
+
return max === 0 ? 0 : 1;
|
|
43
47
|
}
|
|
44
48
|
return Math.min( 1, Math.max( 0, ( value - min ) / ( max - min ) ) );
|
|
45
49
|
};
|
|
@@ -33,9 +33,43 @@ describe( 'buildCalendarHeatmapData', () => {
|
|
|
33
33
|
expect( data[ 0 ].data[ 1 ].value ).toBeNull(); // Tue Jan 2 has no datum
|
|
34
34
|
} );
|
|
35
35
|
|
|
36
|
+
test( 'hides the week-completion days outside the span by default', () => {
|
|
37
|
+
const midWeek: DataPointDate[] = [
|
|
38
|
+
{ dateString: '2024-01-03', value: 5 }, // Wed
|
|
39
|
+
{ dateString: '2024-01-09', value: 2 }, // Tue (2nd week)
|
|
40
|
+
];
|
|
41
|
+
const { data } = buildCalendarHeatmapData( midWeek, {
|
|
42
|
+
weekStartsOn: 1,
|
|
43
|
+
} );
|
|
44
|
+
|
|
45
|
+
// Mon Jan 1 and Tue Jan 2 precede the span — hidden; Wed Jan 3 opens it.
|
|
46
|
+
expect( data[ 0 ].data[ 0 ].hidden ).toBe( true );
|
|
47
|
+
expect( data[ 0 ].data[ 1 ].hidden ).toBe( true );
|
|
48
|
+
expect( data[ 0 ].data[ 2 ].hidden ).toBeUndefined();
|
|
49
|
+
|
|
50
|
+
// Thu Jan 4 is inside the span with no entry — a blank cell, not hidden.
|
|
51
|
+
expect( data[ 0 ].data[ 3 ].hidden ).toBeUndefined();
|
|
52
|
+
expect( data[ 0 ].data[ 3 ].value ).toBeNull();
|
|
53
|
+
|
|
54
|
+
// Tue Jan 9 closes the span; Wed Jan 10 onward is hidden.
|
|
55
|
+
expect( data[ 1 ].data[ 1 ].hidden ).toBeUndefined();
|
|
56
|
+
expect( data[ 1 ].data[ 2 ].hidden ).toBe( true );
|
|
57
|
+
expect( data[ 1 ].data[ 6 ].hidden ).toBe( true );
|
|
58
|
+
} );
|
|
59
|
+
|
|
60
|
+
test( 'out-of-span days stay blank cells when hideOutOfRangeDays is false', () => {
|
|
61
|
+
const midWeek: DataPointDate[] = [ { dateString: '2024-01-03', value: 5 } ]; // Wed
|
|
62
|
+
const { data } = buildCalendarHeatmapData( midWeek, {
|
|
63
|
+
weekStartsOn: 1,
|
|
64
|
+
hideOutOfRangeDays: false,
|
|
65
|
+
} );
|
|
66
|
+
expect( data[ 0 ].data[ 0 ].hidden ).toBeUndefined();
|
|
67
|
+
expect( data[ 0 ].data[ 0 ].value ).toBeNull();
|
|
68
|
+
} );
|
|
69
|
+
|
|
36
70
|
test( 'labels only the first column of each month', () => {
|
|
37
71
|
const multiMonth: DataPointDate[] = [
|
|
38
|
-
{ dateString: '2024-01-
|
|
72
|
+
{ dateString: '2024-01-01', value: 1 },
|
|
39
73
|
{ dateString: '2024-02-05', value: 1 },
|
|
40
74
|
];
|
|
41
75
|
const { data } = buildCalendarHeatmapData( multiMonth );
|
|
@@ -44,6 +78,28 @@ describe( 'buildCalendarHeatmapData', () => {
|
|
|
44
78
|
expect( labels ).toContain( 'Feb' );
|
|
45
79
|
} );
|
|
46
80
|
|
|
81
|
+
test( 'suppresses a partial first month label so it cannot collide with the next', () => {
|
|
82
|
+
// Jan 29 is the only January column; February should be the first visible label.
|
|
83
|
+
const partialFirstMonth: DataPointDate[] = [
|
|
84
|
+
{ dateString: '2024-01-29', value: 1 },
|
|
85
|
+
{ dateString: '2024-02-05', value: 1 },
|
|
86
|
+
];
|
|
87
|
+
const { data } = buildCalendarHeatmapData( partialFirstMonth, { weekStartsOn: 1 } );
|
|
88
|
+
expect( data[ 0 ].label ).toBe( '' );
|
|
89
|
+
expect( data.map( c => c.label ).filter( Boolean )[ 0 ] ).toBe( 'Feb' );
|
|
90
|
+
} );
|
|
91
|
+
|
|
92
|
+
test( 'keeps the first month label when the range never reaches a second month', () => {
|
|
93
|
+
// Single-month ranges keep their lone month label.
|
|
94
|
+
const singleMonth: DataPointDate[] = [
|
|
95
|
+
{ dateString: '2024-01-01', value: 1 },
|
|
96
|
+
{ dateString: '2024-01-03', value: 2 },
|
|
97
|
+
];
|
|
98
|
+
const { data } = buildCalendarHeatmapData( singleMonth, { weekStartsOn: 1 } );
|
|
99
|
+
expect( data ).toHaveLength( 1 );
|
|
100
|
+
expect( data[ 0 ].label ).toBe( 'Jan' );
|
|
101
|
+
} );
|
|
102
|
+
|
|
47
103
|
test( 'filters out entries with unparseable or missing dates', () => {
|
|
48
104
|
const mixed: DataPointDate[] = [
|
|
49
105
|
{ dateString: '2024-01-01', value: 3 },
|