@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/index.cjs +541 -84
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +122 -34
  5. package/dist/index.d.cts +97 -7
  6. package/dist/index.d.ts +97 -7
  7. package/dist/index.js +539 -87
  8. package/dist/index.js.map +1 -1
  9. package/package.json +8 -8
  10. package/src/charts/area-chart/area-chart.module.scss +3 -1
  11. package/src/charts/area-chart/area-chart.tsx +5 -2
  12. package/src/charts/bar-chart/bar-chart.module.scss +6 -2
  13. package/src/charts/bar-chart/bar-chart.tsx +29 -22
  14. package/src/charts/bar-chart/private/comparison-bars.tsx +7 -0
  15. package/src/charts/bar-chart/private/use-bar-chart-options.ts +4 -1
  16. package/src/charts/bar-chart/test/bar-chart.test.tsx +141 -3
  17. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +15 -12
  18. package/src/charts/geo-chart/geo-chart.tsx +6 -1
  19. package/src/charts/geo-chart/test/geo-chart.test.tsx +11 -1
  20. package/src/charts/heatmap-chart/heatmap-chart.module.scss +103 -0
  21. package/src/charts/heatmap-chart/heatmap-chart.tsx +422 -0
  22. package/src/charts/heatmap-chart/index.ts +4 -0
  23. package/src/charts/heatmap-chart/private/build-calendar-data.ts +81 -0
  24. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +53 -0
  25. package/src/charts/heatmap-chart/private/index.ts +5 -0
  26. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +45 -0
  27. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +88 -0
  28. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +301 -0
  29. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +34 -0
  30. package/src/charts/heatmap-chart/types.ts +42 -0
  31. package/src/charts/index.ts +1 -0
  32. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +18 -6
  33. package/src/charts/line-chart/line-chart.module.scss +6 -4
  34. package/src/charts/line-chart/line-chart.tsx +6 -2
  35. package/src/charts/line-chart/private/line-chart-annotation.tsx +16 -3
  36. package/src/charts/private/grid-control/grid-control.module.scss +1 -4
  37. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  38. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +14 -0
  39. package/src/charts/private/with-responsive/with-responsive.tsx +12 -0
  40. package/src/charts/private/x-zoom.module.scss +6 -3
  41. package/src/components/legend/private/base-legend.module.scss +3 -1
  42. package/src/components/tooltip/base-tooltip.module.scss +4 -1
  43. package/src/components/trend-indicator/trend-indicator.module.scss +5 -3
  44. package/src/hooks/use-xychart-theme.ts +24 -0
  45. package/src/index.ts +12 -0
  46. package/src/providers/chart-context/themes.ts +29 -16
  47. package/src/types.ts +19 -2
  48. package/src/utils/color-utils.ts +36 -0
  49. 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
+ };