diff --git a/projects/js-packages/charts/changelog/add-charts-218-heatmap-chart b/projects/js-packages/charts/changelog/add-charts-218-heatmap-chart new file mode 100644 index 000000000000..a3064d3103be --- /dev/null +++ b/projects/js-packages/charts/changelog/add-charts-218-heatmap-chart @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Charts: add HeatmapChart for matrix and calendar/contribution-style data, with a compact mode and a composition color-scale legend. diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss new file mode 100644 index 000000000000..b9dd58c73756 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss @@ -0,0 +1,103 @@ +.heatmap-chart { + width: 100%; + height: 100%; + min-height: 0; + font-size: var(--wpds-typography-font-size-sm, 12px); +} + +.heatmap-chart__empty { + padding: var(--wpds-dimension-padding-lg, 16px); + color: var(--wpds-color-fg-content-neutral-weak, #707070); +} + +// Track template is set inline because CSS `repeat()` won't take a `var()` +// count. ARIA rows are `display: contents` so their cells join this grid. +.heatmap-chart__grid { + display: grid; + gap: var(--heatmap-cell-gap, var(--wpds-dimension-gap-xs, 4px)); + flex: 1 1 0; + width: 100%; + // Floor so the grid stays usable when the parent has no explicit height. + min-height: 200px; + + &:focus-visible { + outline: + var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px)) solid + var(--wpds-color-stroke-focus-brand, var(--wp-admin-theme-color, #3858e9)); + outline-offset: 2px; + border-radius: var(--wpds-border-radius-sm, 2px); + } +} + +.heatmap-chart__grid--compact { + flex: 0 0 auto; + width: max-content; + height: max-content; + min-height: 0; +} + +.heatmap-chart__row { + display: contents; +} + +.heatmap-chart__col-label, +.heatmap-chart__row-label { + color: var(--wpds-color-fg-content-neutral-weak, #707070); + font-size: var(--wpds-typography-font-size-xs, 11px); + white-space: nowrap; + overflow: visible; +} + +.heatmap-chart__col-label { + align-self: end; + text-align: left; +} + +.heatmap-chart__row-label { + align-self: center; + justify-self: end; + text-align: right; + padding-inline-end: var(--wpds-dimension-padding-xs, 4px); +} + +.heatmap-chart__cell { + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 0; + border-radius: var(--wpds-border-radius-sm, 2px); + // Empty (no-data) default; cells with a value override it below. + background: var(--wpds-color-bg-track-neutral-weak, #f0f0f0); +} + +// Floor the mix at 15% so the lowest value stays visibly tinted, distinct from +// an empty cell. +.heatmap-chart__cell--filled { + background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%), transparent); +} + +.heatmap-chart__cell--selected { + outline: + var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px)) solid + var(--wpds-color-stroke-focus-brand, var(--wp-admin-theme-color, #3858e9)); + outline-offset: calc(-1 * var(--wpds-border-width-focus, var(--wp-admin-border-width-focus, 2px))); +} + +.heatmap-chart__cell-value { + color: var(--wpds-color-fg-content-neutral, #1e1e1e); + font-size: var(--wpds-typography-font-size-md, 13px); +} + +// Light text on fills dark enough to out-contrast dark text (decided in JS from +// the blended fill luminance). +.heatmap-chart__cell--strong .heatmap-chart__cell-value { + color: var(--wpds-color-fg-interactive-neutral-strong, #f0f0f0); +} + +.heatmap-chart__legend-swatch { + width: var(--wpds-dimension-size-3xs, 12px); + height: var(--wpds-dimension-size-3xs, 12px); + border-radius: var(--wpds-border-radius-sm, 2px); + background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%), transparent); +} diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx new file mode 100644 index 000000000000..651ddf1a8a36 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx @@ -0,0 +1,422 @@ +import { formatNumber, formatNumberCompact } from '@automattic/number-formatters'; +import { useTooltip, useTooltipInPortal } from '@visx/tooltip'; +import { __ } from '@wordpress/i18n'; +import clsx from 'clsx'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + GlobalChartsProvider, + useChartId, + useGlobalChartsContext, + GlobalChartsContext, +} from '../../providers'; +import { attachSubComponents } from '../../utils'; +import { + isValidHexColor, + lightenHexColor, + normalizeColorToHex, + prefersLightText, +} from '../../utils/color-utils'; +import { Center } from '../private/center'; +import { useChartChildren } from '../private/chart-composition'; +import { ChartLayout } from '../private/chart-layout'; +import { SingleChartContext } from '../private/single-chart-context'; +import { withResponsive } from '../private/with-responsive'; +import styles from './heatmap-chart.module.scss'; +import { getValueExtent, getNormalizedValue, HeatmapLegend, isPresent } from './private'; +import type { HeatmapChartProps, HeatmapTooltipData } from './types'; +import type { ResponsiveConfig } from '../private/with-responsive'; +import type { CSSProperties, FC } from 'react'; + +export type HeatmapContextValue = { + extent: [ number, number ]; + /** The resolved primary color (full intensity); the legend mixes toward it in CSS. */ + primaryColorHex: string; +}; + +export const HeatmapContext = createContext< HeatmapContextValue | null >( null ); + +// Mirrors the color-mix floor in heatmap-chart.module.scss (.heatmap-chart__cell--filled): +// the rendered fill is the primary mixed over the chart background at 0.15 + 0.85 * intensity. +const CELL_MIX_FLOOR = 0.15; + +const HeatmapChartInternal: FC< HeatmapChartProps > = ( { + data, + chartId: providedChartId, + width = 0, + height = 0, + className, + compact = false, + showValues, + rowLabels = [], + primaryColor, + gap = 'md', + withTooltips = false, + renderTooltip, + children, +} ) => { + const chartId = useChartId( providedChartId ); + const { getElementStyles, theme } = useGlobalChartsContext(); + const { heatmapChart: heatmapChartSettings } = theme; + const { nonLegendChildren } = useChartChildren( children, 'HeatmapChart' ); + + const [ selectedIndex, setSelectedIndex ] = useState< number | undefined >(); + const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, showTooltip, hideTooltip } = + useTooltip< HeatmapTooltipData >(); + const { containerRef, containerBounds, TooltipInPortal } = useTooltipInPortal( { + detectBounds: true, + scroll: true, + } ); + // Read from a ref so the keyboard-tooltip effect doesn't depend on containerBounds, which + // is a new object each render and would loop the effect via showTooltip. + const containerBoundsRef = useRef( containerBounds ); + containerBoundsRef.current = containerBounds; + + const { color: primaryColorHex } = getElementStyles( { + index: 0, + overrideColor: primaryColor || heatmapChartSettings.primaryColor, + } ); + + // Pick the in-cell text color from the cell's actual blended fill luminance (not the data + // value), so light text is only used where it out-contrasts dark text. Falls back to dark + // text when the primary isn't a resolvable hex (e.g. a bare CSS token). + const primaryHex = normalizeColorToHex( primaryColorHex ); + const cellHasLightText = ( intensity: number ): boolean => + isValidHexColor( primaryHex ) && + prefersLightText( + lightenHexColor( primaryHex, 1 - ( CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity ) ) + ); + + const extent = useMemo( () => getValueExtent( data ), [ data ] ); + const heatmapContext = useMemo< HeatmapContextValue >( + () => ( { extent, primaryColorHex } ), + [ extent, primaryColorHex ] + ); + + const columns = data.length; + const rows = Math.max( 0, ...data.map( column => column.data.length ) ); + + const { compactCellGap, compactCellSize } = heatmapChartSettings; + const drawValues = showValues ?? ! compact; + + const buildTooltipData = useCallback( + ( columnIndex: number, rowIndex: number ): HeatmapTooltipData => { + const cell = data[ columnIndex ]?.data[ rowIndex ]; + return { + value: cell?.value ?? null, + rowLabel: rowLabels[ rowIndex ], + columnLabel: data[ columnIndex ]?.label, + cellLabel: cell?.label, + row: rowIndex, + column: columnIndex, + }; + }, + [ data, rowLabels ] + ); + + const onChartBlur = useCallback( () => { + setSelectedIndex( undefined ); + hideTooltip(); + }, [ hideTooltip ] ); + + const onChartKeyDown = useCallback( + ( event: React.KeyboardEvent< HTMLDivElement > ) => { + if ( + ! [ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Escape', 'Tab' ].includes( + event.key + ) + ) { + return; + } + + if ( event.key === 'Tab' || event.key === 'Escape' ) { + setSelectedIndex( undefined ); + hideTooltip(); + return; + } + + event.preventDefault(); + + if ( selectedIndex === undefined ) { + setSelectedIndex( 0 ); + return; + } + + let col = Math.floor( selectedIndex / rows ); + let row = selectedIndex % rows; + + if ( event.key === 'ArrowRight' ) { + col = Math.min( col + 1, columns - 1 ); + } else if ( event.key === 'ArrowLeft' ) { + col = Math.max( col - 1, 0 ); + } else if ( event.key === 'ArrowDown' ) { + row = Math.min( row + 1, rows - 1 ); + } else if ( event.key === 'ArrowUp' ) { + row = Math.max( row - 1, 0 ); + } + + setSelectedIndex( col * rows + row ); + }, + [ rows, columns, selectedIndex, hideTooltip ] + ); + + const handleCellMouseMove = useCallback( + ( event: React.MouseEvent< HTMLDivElement > ) => { + if ( ! withTooltips ) { + return; + } + const target = event.currentTarget; + const columnIndex = Number( target.dataset.column ); + const rowIndex = Number( target.dataset.row ); + // Read bounds from the ref (like the keyboard-tooltip effect) so this + // callback stays stable across renders. + const bounds = containerBoundsRef.current; + // TooltipInPortal re-adds containerBounds, so subtract it to land at the cursor. + showTooltip( { + tooltipLeft: event.clientX - bounds.left, + tooltipTop: event.clientY - bounds.top, + tooltipData: buildTooltipData( columnIndex, rowIndex ), + } ); + }, + [ withTooltips, showTooltip, buildTooltipData ] + ); + + const handleCellMouseLeave = useCallback( () => { + // Keyboard selection owns the tooltip; don't let a mouse-out clear it. + if ( withTooltips && selectedIndex === undefined ) { + hideTooltip(); + } + }, [ withTooltips, selectedIndex, hideTooltip ] ); + + // Anchor the tooltip at the selected cell's center on keyboard nav. Cleared on blur/Escape, + // not here, so a mouse hover (no selection) isn't affected. + useEffect( () => { + if ( ! withTooltips || selectedIndex === undefined ) { + return; + } + const col = Math.floor( selectedIndex / rows ); + const row = selectedIndex % rows; + const cell = + typeof document !== 'undefined' + ? document.getElementById( `${ chartId }-cell-${ col }-${ row }` ) + : null; + const rect = cell?.getBoundingClientRect(); + const bounds = containerBoundsRef.current; + showTooltip( { + tooltipLeft: rect ? rect.left + rect.width / 2 - bounds.left : 0, + tooltipTop: rect ? rect.top + rect.height / 2 - bounds.top : 0, + tooltipData: buildTooltipData( col, row ), + } ); + }, [ selectedIndex, withTooltips, rows, chartId, buildTooltipData, showTooltip ] ); + + const defaultRenderTooltip = useCallback( + ( info: HeatmapTooltipData ) => ( +
+ + { info.cellLabel || `${ info.columnLabel ?? '' } ${ info.rowLabel ?? '' }`.trim() } + +
+ { info.value === null ? __( 'No data', 'jetpack-charts' ) : formatNumber( info.value ) } +
+
+ ), + [] + ); + + if ( ! columns || ! rows ) { + return ( +
+ + { __( 'No data available', 'jetpack-charts' ) } + +
+ ); + } + + const trackSize = compact ? 'var(--heatmap-cell-size)' : 'minmax(0, 1fr)'; + const gridStyle: Record< string, string | number > = { + '--heatmap-primary': primaryColorHex, + gridTemplateColumns: `auto repeat(${ columns }, ${ trackSize })`, + gridTemplateRows: `auto repeat(${ rows }, ${ trackSize })`, + }; + if ( compact ) { + gridStyle[ '--heatmap-cell-gap' ] = `${ compactCellGap }px`; + gridStyle[ '--heatmap-cell-size' ] = `${ compactCellSize }px`; + } + + const activeDescendant = + selectedIndex !== undefined + ? `${ chartId }-cell-${ Math.floor( selectedIndex / rows ) }-${ selectedIndex % rows }` + : undefined; + + return ( + + + +
+ { /* Corner gutter + column labels; aria-hidden, since each cell's label carries the text. */ } +
+ { withTooltips && tooltipOpen && tooltipData && ( + +
+ { ( renderTooltip ?? defaultRenderTooltip )( tooltipData ) } +
+
+ ) } +
+
+
+ ); +}; + +const HeatmapChartWithProvider: FC< HeatmapChartProps > = props => { + const existingContext = useContext( GlobalChartsContext ); + if ( existingContext ) { + return ; + } + return ( + + + + ); +}; + +HeatmapChartWithProvider.displayName = 'HeatmapChart'; + +interface HeatmapChartSubComponents { + Legend: typeof HeatmapLegend; +} + +const HeatmapChart = attachSubComponents( HeatmapChartWithProvider, { + Legend: HeatmapLegend, +} ) as FC< HeatmapChartProps > & HeatmapChartSubComponents; + +// The responsive wrapper already sizes the container; drop its measured pixel +// width/height so the grid fills that container via CSS and reflows fluidly, +// instead of pinning to a debounced measurement. +const HeatmapChartResponsiveInner: FC< HeatmapChartProps > = props => ( + +); +HeatmapChartResponsiveInner.displayName = 'HeatmapChart'; + +const HeatmapChartResponsive = attachSubComponents( + withResponsive< HeatmapChartProps >( HeatmapChartResponsiveInner ), + { Legend: HeatmapLegend } +) as FC< HeatmapChartProps & ResponsiveConfig > & HeatmapChartSubComponents; + +export { HeatmapChartResponsive as default, HeatmapChart as HeatmapChartUnresponsive }; diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/index.ts b/projects/js-packages/charts/src/charts/heatmap-chart/index.ts new file mode 100644 index 000000000000..dd25facaf1c7 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/index.ts @@ -0,0 +1,4 @@ +export { default as HeatmapChart, HeatmapChartUnresponsive } from './heatmap-chart'; +export { buildCalendarHeatmapData } from './private'; +export type { CalendarHeatmapResult } from './private'; +export type { HeatmapChartProps, HeatmapColumn, HeatmapCell, HeatmapTooltipData } from './types'; diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/private/build-calendar-data.ts b/projects/js-packages/charts/src/charts/heatmap-chart/private/build-calendar-data.ts new file mode 100644 index 000000000000..e6045d75e385 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/private/build-calendar-data.ts @@ -0,0 +1,81 @@ +import { addDays, differenceInCalendarWeeks, format, parseISO, startOfWeek } from 'date-fns'; +import type { DataPointDate } from '../../../types'; +import type { HeatmapCell, HeatmapColumn } from '../types'; + +export type CalendarHeatmapResult = { + data: HeatmapColumn[]; + rowLabels: string[]; +}; + +/** Rows that get a weekday label (Mon, Wed, Fri with a Monday week start). */ +const LABELLED_ROWS = [ 0, 2, 4 ]; + +const toDate = ( point: DataPointDate ): Date | null => { + if ( point.date instanceof Date && ! isNaN( point.date.getTime() ) ) { + return point.date; + } + if ( point.dateString ) { + const parsed = parseISO( point.dateString ); + if ( ! isNaN( parsed.getTime() ) ) { + return parsed; + } + } + return null; +}; + +export const buildCalendarHeatmapData = ( + series: DataPointDate[], + options: { weekStartsOn?: 0 | 1 } = {} +): CalendarHeatmapResult => { + const weekStartsOn = options.weekStartsOn ?? 1; + + const entries = series + .map( point => ( { date: toDate( point ), value: point.value } ) ) + .filter( ( entry ): entry is { date: Date; value: number | null } => entry.date !== null ); + + if ( ! entries.length ) { + return { data: [], rowLabels: [] }; + } + + const valueByDay = new Map< string, number | null >(); + let minDate = entries[ 0 ].date; + let maxDate = entries[ 0 ].date; + for ( const { date, value } of entries ) { + valueByDay.set( format( date, 'yyyy-MM-dd' ), value ); + if ( date < minDate ) { + minDate = date; + } + if ( date > maxDate ) { + maxDate = date; + } + } + + const gridStart = startOfWeek( minDate, { weekStartsOn } ); + const weekCount = differenceInCalendarWeeks( maxDate, gridStart, { weekStartsOn } ) + 1; + + const rowLabels = Array.from( { length: 7 }, ( _, row ) => + LABELLED_ROWS.includes( row ) ? format( addDays( gridStart, row ), 'EEE' ) : '' + ); + + const data: HeatmapColumn[] = []; + let previousMonth = -1; + for ( let week = 0; week < weekCount; week++ ) { + const columnStart = addDays( gridStart, week * 7 ); + const month = columnStart.getMonth(); + const label = month !== previousMonth ? format( columnStart, 'MMM' ) : ''; + previousMonth = month; + + const cells: HeatmapCell[] = []; + for ( let row = 0; row < 7; row++ ) { + const day = addDays( gridStart, week * 7 + row ); + const key = format( day, 'yyyy-MM-dd' ); + cells.push( { + label: format( day, 'EEE, MMM d, yyyy' ), + value: valueByDay.has( key ) ? ( valueByDay.get( key ) as number | null ) : null, + } ); + } + data.push( { label, data: cells } ); + } + + return { data, rowLabels }; +}; diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx new file mode 100644 index 000000000000..f634804794e6 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx @@ -0,0 +1,53 @@ +import { __ } from '@wordpress/i18n'; +import { Stack, Text } from '@wordpress/ui'; +import { useContext } from 'react'; +import { useGlobalChartsTheme } from '../../../providers'; +import { HeatmapContext } from '../heatmap-chart'; +import styles from '../heatmap-chart.module.scss'; +import type { CSSProperties, FC } from 'react'; + +export interface HeatmapLegendProps { + /** Number of swatches in the scale. Default 5. */ + steps?: number; + lessLabel?: string; + moreLabel?: string; +} + +export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel, moreLabel } ) => { + const context = useContext( HeatmapContext ); + const { legend } = useGlobalChartsTheme(); + if ( ! context ) { + return null; + } + const { primaryColorHex } = context; + const labelStyle = legend.labelStyles; + + return ( + + + { lessLabel ?? __( 'Less', 'jetpack-charts' ) } + + + { Array.from( { length: steps }, ( _, index ) => { + const intensity = steps <= 1 ? 1 : index / ( steps - 1 ); + return ( + + + { moreLabel ?? __( 'More', 'jetpack-charts' ) } + + + ); +}; diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/private/index.ts b/projects/js-packages/charts/src/charts/heatmap-chart/private/index.ts new file mode 100644 index 000000000000..eb956f77588c --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/private/index.ts @@ -0,0 +1,5 @@ +export { getValueExtent, getNormalizedValue, isPresent } from './use-heatmap-colors'; +export { buildCalendarHeatmapData } from './build-calendar-data'; +export { HeatmapLegend } from './heatmap-legend'; +export type { CalendarHeatmapResult } from './build-calendar-data'; +export type { HeatmapLegendProps } from './heatmap-legend'; diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/private/use-heatmap-colors.ts b/projects/js-packages/charts/src/charts/heatmap-chart/private/use-heatmap-colors.ts new file mode 100644 index 000000000000..1d30b14d3368 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/private/use-heatmap-colors.ts @@ -0,0 +1,45 @@ +import type { HeatmapColumn } from '../types'; + +export const isPresent = ( value: number | null | undefined ): value is number => + value !== null && value !== undefined && ! isNaN( value ); + +/** + * Get the min and max values from heatmap data, ignoring null/NaN. + * @param data - The heatmap columns + * @return Tuple of [min, max] values + */ +export const getValueExtent = ( data: HeatmapColumn[] ): [ number, number ] => { + let min = Infinity; + let max = -Infinity; + for ( const column of data ) { + for ( const cell of column.data ) { + if ( ! isPresent( cell.value ) ) { + continue; + } + if ( cell.value < min ) { + min = cell.value; + } + if ( cell.value > max ) { + max = cell.value; + } + } + } + if ( min === Infinity ) { + return [ 0, 0 ]; + } + return [ min, max ]; +}; + +/** + * Normalize a value to 0–1 within the extent. A flat extent (min === max) maps to 1. + * @param value - The value to normalize + * @param extent - Tuple of [min, max] values for the normalization range + * @return Normalized value between 0 and 1 + */ +export const getNormalizedValue = ( value: number, extent: [ number, number ] ): number => { + const [ min, max ] = extent; + if ( min === max ) { + return 1; + } + return Math.min( 1, Math.max( 0, ( value - min ) / ( max - min ) ) ); +}; diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx new file mode 100644 index 000000000000..1d87b37d43c6 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx @@ -0,0 +1,129 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Heatmap Chart API Reference + +## HeatmapChart + +Main component for rendering a two-dimensional heatmap. Cells are laid out with CSS Grid +and shaded with CSS `color-mix`, so the scale adapts to the chart background. + +**Props:** + +| Prop | Type | Default | Description | +| ---- | ---- | ------- | ----------- | +| `data` | `HeatmapColumn[]` | - | **Required.** Array of columns; each column contains an optional label and an array of cells. | +| `rowLabels` | `string[]` | `[]` | y-axis labels by row index. Empty entries render blank. | +| `compact` | `boolean` | `false` | Compact mode: tighten cell gaps, suppress in-cell values, and thin axis labels. | +| `showValues` | `boolean` | `!compact` | Render the numeric value inside each cell (compact notation for large numbers; the tooltip and aria-label keep full precision). | +| `primaryColor` | `string` | theme / palette | Color the scale interpolates toward at the highest value. Resolution order: this prop > `heatmapChart.primaryColor` > palette `colors[0]`. | +| `withTooltips` | `boolean` | `false` | Show a tooltip on cell hover or keyboard navigation. | +| `renderTooltip` | `(data: HeatmapTooltipData) => ReactNode` | - | Custom tooltip render function. Receives `HeatmapTooltipData`. The default tooltip formats numeric values with `@automattic/number-formatters`. | +| `width` | `number` | - | Fixed width in pixels. When omitted, the chart fills its parent's width. | +| `height` | `number` | - | Fixed height in pixels. When omitted, the chart fills its parent's height. | +| `aspectRatio` | `number` | - | Height as a ratio of width in responsive mode (e.g. `0.4`). Used when `width`/`height` are omitted. | +| `chartId` | `string` | - | Custom chart identifier used in accessibility attributes. | +| `className` | `string` | - | Additional CSS class for the outermost element. | +| `gap` | `GapSize` | `'md'` | Gap between chart layout regions (chart area, legend, children). Uses WordPress design system tokens. | +| `children` | `ReactNode` | - | Composition children, e.g. ``. | + +## HeatmapChart.Legend + +Sub-component that renders a sequential gradient scale from the lightest to the fullest color, annotated with the min and max values present in `data`. + +```jsx + + + +``` + +**Props:** + +| Prop | Type | Default | Description | +| ---- | ---- | ------- | ----------- | +| `steps` | `number` | `5` | Number of color swatches in the scale. | +| `lessLabel` | `string` | `'Less'` | Label shown to the left of the swatches. | +| `moreLabel` | `string` | `'More'` | Label shown to the right of the swatches. | + +## HeatmapColumn Type + +A single column in the heatmap matrix (rendered left→right); its cells render top→bottom. + +```typescript +type HeatmapColumn = { + /** x-axis label for this column. Empty or omitted renders blank. */ + label?: string; + /** Ordered array of cells for this column. */ + data: HeatmapCell[]; +}; +``` + +## HeatmapCell Type + +A single cell in the heatmap. + +```typescript +type HeatmapCell = { + /** Per-cell label used in the tooltip / accessible name. */ + label?: string; + /** Numeric value. Use null to mark an empty / missing cell. */ + value: number | null; +}; +``` + +## HeatmapTooltipData Type + +Data passed to `renderTooltip` when a cell is hovered or selected via keyboard. + +```typescript +type HeatmapTooltipData = { + /** Cell value, or null for an empty cell. */ + value: number | null; + /** Row label from `rowLabels` at this cell's row index. */ + rowLabel?: string; + /** Column label from the column's `label` property. */ + columnLabel?: string; + /** Per-cell label from `HeatmapCell.label`. */ + cellLabel?: string; + /** Zero-based row index of the cell. */ + row: number; + /** Zero-based column index of the cell. */ + column: number; +}; +``` + +## buildCalendarHeatmapData + +Utility function that converts a flat time-series into the `HeatmapColumn[]` / `rowLabels` structure used by `HeatmapChart`, producing a calendar/contribution-style layout. + +**Signature:** + +```typescript +function buildCalendarHeatmapData( + series: DataPointDate[], + options?: { weekStartsOn?: 0 | 1 } +): CalendarHeatmapResult +``` + +**Parameters:** + +| Parameter | Type | Default | Description | +| --------- | ---- | ------- | ----------- | +| `series` | `DataPointDate[]` | - | **Required.** Array of data points with a `date` (or `dateString`) and a numeric `value`. | +| `options.weekStartsOn` | `0 \| 1` | `1` | Day the week starts on: `0` = Sunday, `1` = Monday. | + +**Returns:** `CalendarHeatmapResult` + +## CalendarHeatmapResult Type + +Return value of `buildCalendarHeatmapData`. + +```typescript +type CalendarHeatmapResult = { + /** Columns to pass directly to HeatmapChart's `data` prop. */ + data: HeatmapColumn[]; + /** Row labels to pass directly to HeatmapChart's `rowLabels` prop. */ + rowLabels: string[]; +}; +``` diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx new file mode 100644 index 000000000000..0321c4932213 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx @@ -0,0 +1,251 @@ +import { Meta, Canvas, Source } from '@storybook/addon-docs/blocks'; +import * as HeatmapStories from './index.stories'; + + + +# Heatmap Chart + +Visualize values across a two-dimensional matrix of cells on a sequential color scale. Cells transition from a light tint to the full primary color based on relative value, with `null` cells rendered in a neutral empty-cell color. + + + +## Overview + +The Heatmap Chart renders a CSS Grid of colored cells where color intensity maps to cell value. Shading is done with CSS `color-mix` against the resolved primary color, so the scale adapts to the chart background. It is suitable for activity matrices, time-based frequency data, and any dataset with two categorical axes. + +` } +/> + +## API Reference + +For detailed information about component props, types, and utility functions, see the [Heatmap Chart API Reference](./?path=/docs/js-packages-charts-library-charts-heatmap-chart-api-reference--docs). + +## Basic Usage + +### Default Matrix + +The simplest heatmap requires `data` — an array of `HeatmapColumn` objects, each containing an optional column `label` and a `data` array of `HeatmapCell` objects: + +` } +/> + +### Required Props + +- **`data`**: `HeatmapColumn[]` — array of columns, each with optional `label` and a `data` array of cells + +### Optional Props + +- **`rowLabels`**: y-axis labels by row index. Empty entries render blank. +- **`withTooltips`** (default: `false`): show a tooltip on cell hover +- **`compact`** (default: `false`): tighten spacing and suppress in-cell values for compact widgets +- **`showValues`** (default: `!compact`): render the numeric value inside each cell (compact notation for large numbers; the tooltip keeps full precision) +- **`primaryColor`**: color the scale interpolates toward at the highest value (prop > `heatmapChart.primaryColor` > palette `colors[0]`) +- **`width`** / **`height`**: explicit dimensions in pixels. When omitted the chart fills its parent. +- **`gap`** (default: `'md'`): gap between chart layout regions (chart area, legend, children) +- **`chartId`**: custom identifier for accessibility labelling +- **`className`**: additional CSS class for the outer element +- **`children`**: composition children (e.g. ``) + +## Compact Mode + +Use `compact` for narrow widgets or dashboards where vertical space is limited. The chart renders fixed-size square cells (the theme's `heatmapChart.compactCellSize`, default 11px), tightens cell gaps, hides in-cell values, and thins axis labels. + + + +` } +/> + +## Large Values + +In-cell values use compact notation (e.g. `748.5K`, `1.2M`) so large numbers fit the cell. The tooltip and each cell's accessible label keep the full, precise value. + + + +## Calendar Layout + +Use `buildCalendarHeatmapData` to convert a flat `DataPointDate[]` series into the column/row structure expected by the chart. This produces the GitHub-style contribution graph layout. + + + + + ); + }` } +/> + +`buildCalendarHeatmapData` accepts an optional `options` object: + +- **`weekStartsOn`** (`0 | 1`, default `1`): `0` = Sunday, `1` = Monday + +## Fixed Dimensions + +Pass `width` and `height` to bypass the responsive wrapper and render at an exact pixel size: + + + +` } +/> + +## Composition API — Legend + +Add a legend by placing `` as a child. The legend renders a gradient scale from the lightest to the fullest shade, labelled with the value range. + + + + + + ` } +/> + +## Error / Empty State + +When `data` is empty the chart renders a "No data available" placeholder: + + + +## Tooltips + +Enable `withTooltips` to show a tooltip on cell hover **and** during keyboard navigation (the arrow-selected cell shows its tooltip, like bar and line charts). The default tooltip shows the cell label and the value, formatted with `@automattic/number-formatters`. Supply `renderTooltip` to customize: + + ( +
+ { cellLabel ?? \`\${ columnLabel } \${ rowLabel }\` } +
{ value === null ? 'No data' : value }
+
+ ) } + />` } +/> + +## Theming + +The heatmap resolves its full-intensity color in this order: the `primaryColor` prop wins, then `heatmapChart.primaryColor` from the active theme, then the first palette color (`colors[0]`). The resolved color is fed to CSS `color-mix`, which derives the lighter shades for lower values. + + + + // Or via the theme: + const customTheme: ChartTheme = { + heatmapChart: { primaryColor: '#c0392b' }, + }; + + + + ` } +/> + +Everything else — the cell gap, corner radius, in-cell value size, the empty-cell color and the selection ring — comes straight from WordPress design system tokens (`--wpds-*`) in CSS, so it tracks the active design system theme automatically. There are no per-instance props for these. The `heatmapChart` theme section exposes just the scale color (`primaryColor`, above) and the compact sizing (`compactCellSize`, `compactCellGap`): + + + + ` } +/> + +## Responsive Behavior + +By default the chart fills its parent container's dimensions, reflowing fluidly as the container resizes. The parent must have an explicit height, or you can set `aspectRatio` to derive the height from the available width. When neither is provided, the chart falls back to a minimum height so it stays usable rather than collapsing: + + + + + + // Derive height from width via aspectRatio (height = width × 0.4) + + + // Fixed dimensions + ` } +/> + + + +For more details on responsive behavior, see the [Responsive Design section](./?path=/docs/js-packages-charts-library-introduction--docs#responsive-design) in the introduction. + +## Accessibility + +The heatmap chart is designed for full keyboard and screen reader access: + +- The grid container is a `
` with a localised `aria-label`; each visual row is a `role="row"` and each cell a `role="gridcell"`. +- Each cell has an `aria-label` containing the cell name and value (or "No data"). An `aria-label` is used rather than a native `title` so no duplicate browser tooltip appears. Axis labels are decorative (`aria-hidden`) because the cell label already carries that text. +- **Keyboard navigation**: Tab focuses the chart; arrow keys navigate between cells; the focused cell receives a visible highlight ring, and (when `withTooltips` is set) its tooltip. Escape clears the selection. +- Color alone is never the sole indicator of value — every cell exposes its value via its accessible label, and `showValues` can render numeric labels inside cells. +- **In-cell text contrast**: when values are shown, each cell chooses light or dark text from the luminance of its blended fill rather than the raw value, so the number stays legible across the whole scale and with any primary color. +- The component does not use animations by default, and no `prefers-reduced-motion` opt-out is required. diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx new file mode 100644 index 000000000000..efe26d5e9538 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx @@ -0,0 +1,100 @@ +import { + chartDecorator, + sharedChartArgTypes, + ChartStoryArgs, +} from '../../../stories/chart-decorator'; +import { + heatmapActivityMatrix, + heatmapCalendarSeries, + heatmapLargeValueMatrix, +} from '../../../stories/sample-data'; +import { sharedThemeArgs, themeArgTypes } from '../../../stories/theme-config'; +import { HeatmapChart } from '../index'; +import { buildCalendarHeatmapData } from '../private'; +import type { Meta, StoryObj } from '@storybook/react'; + +type StoryArgs = ChartStoryArgs< React.ComponentProps< typeof HeatmapChart > >; + +const meta: Meta< StoryArgs > = { + title: 'JS Packages/Charts Library/Charts/Heatmap Chart', + component: HeatmapChart, + parameters: { layout: 'centered' }, + decorators: [ chartDecorator ], + argTypes: { + ...sharedChartArgTypes, + ...themeArgTypes, + compact: { control: 'boolean', table: { category: 'Visual Style' } }, + showValues: { control: 'boolean', table: { category: 'Visual Style' } }, + }, +} satisfies Meta< StoryArgs >; + +export default meta; +type Story = StoryObj< StoryArgs >; + +export const Default: Story = { + args: { + ...sharedThemeArgs, + data: heatmapActivityMatrix, + rowLabels: [ 'Mon', '', 'Wed', '', 'Fri', '', '' ], + withTooltips: true, + }, +}; + +export const Compact: Story = { + args: { ...Default.args, compact: true, containerHeight: '160px' }, +}; + +export const LargeValues: Story = { + args: { + ...Default.args, + data: heatmapLargeValueMatrix, + }, +}; + +export const Calendar: StoryObj< StoryArgs & { weekStartsOn: 0 | 1 } > = { + render: ( { weekStartsOn, ...args } ) => { + const { data, rowLabels } = buildCalendarHeatmapData( heatmapCalendarSeries, { + weekStartsOn, + } ); + return ; + }, + args: { ...sharedThemeArgs, withTooltips: true, weekStartsOn: 1 }, + argTypes: { + weekStartsOn: { + control: { type: 'inline-radio', labels: { 0: 'Sunday', 1: 'Monday' } }, + options: [ 1, 0 ], + table: { category: 'Calendar' }, + }, + }, +}; + +export const WithCompositionLegend: Story = { + render: args => ( + + + + ), + args: { ...Default.args }, +}; + +export const FixedDimensions: Story = { + args: { + ...Default.args, + width: 720, + height: 220, + }, +}; + +export const AspectRatio: Story = { + args: { + ...Default.args, + aspectRatio: 0.4, + }, +}; + +export const ErrorStates: Story = { + args: { + ...Default.args, + data: [], + }, +}; diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/test/build-calendar-data.test.ts b/projects/js-packages/charts/src/charts/heatmap-chart/test/build-calendar-data.test.ts new file mode 100644 index 000000000000..cb2788f64bb8 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/test/build-calendar-data.test.ts @@ -0,0 +1,88 @@ +import { buildCalendarHeatmapData } from '../private/build-calendar-data'; +import type { DataPointDate } from '../../../types'; + +const series: DataPointDate[] = [ + { dateString: '2024-01-01', value: 3 }, // Mon + { dateString: '2024-01-03', value: 5 }, // Wed + { dateString: '2024-01-15', value: 2 }, // Mon (3rd week) +]; + +describe( 'buildCalendarHeatmapData', () => { + test( 'returns empty result for empty input', () => { + expect( buildCalendarHeatmapData( [] ) ).toEqual( { data: [], rowLabels: [] } ); + } ); + + test( 'groups days into week columns of 7 rows', () => { + const { data } = buildCalendarHeatmapData( series ); + expect( data ).toHaveLength( 3 ); // weeks containing Jan 1, Jan 8, Jan 15 + data.forEach( column => expect( column.data ).toHaveLength( 7 ) ); + } ); + + test( 'Monday week start places Jan 1 (Mon) in row 0', () => { + const { data, rowLabels } = buildCalendarHeatmapData( series, { weekStartsOn: 1 } ); + expect( data[ 0 ].data[ 0 ].value ).toBe( 3 ); + expect( data[ 0 ].data[ 2 ].value ).toBe( 5 ); // Wed + expect( rowLabels[ 0 ] ).toBe( 'Mon' ); + expect( rowLabels[ 2 ] ).toBe( 'Wed' ); + expect( rowLabels[ 4 ] ).toBe( 'Fri' ); + expect( rowLabels[ 1 ] ).toBe( '' ); + } ); + + test( 'fills missing days with null', () => { + const { data } = buildCalendarHeatmapData( series ); + expect( data[ 0 ].data[ 1 ].value ).toBeNull(); // Tue Jan 2 has no datum + } ); + + test( 'labels only the first column of each month', () => { + const multiMonth: DataPointDate[] = [ + { dateString: '2024-01-29', value: 1 }, + { dateString: '2024-02-05', value: 1 }, + ]; + const { data } = buildCalendarHeatmapData( multiMonth ); + expect( data[ 0 ].label ).toBe( 'Jan' ); + const labels = data.map( c => c.label ).filter( Boolean ); + expect( labels ).toContain( 'Feb' ); + } ); + + test( 'filters out entries with unparseable or missing dates', () => { + const mixed: DataPointDate[] = [ + { dateString: '2024-01-01', value: 3 }, + { dateString: 'not-a-date', value: 9 }, + { date: new Date( NaN ), value: 7 }, + { value: 1 }, // neither date nor dateString + ]; + const { data } = buildCalendarHeatmapData( mixed ); + expect( data ).toHaveLength( 1 ); // only Jan 1 survives -> one week column + const values = data.flatMap( column => column.data.map( cell => cell.value ) ); + expect( values ).toContain( 3 ); + expect( values ).not.toContain( 9 ); + expect( values ).not.toContain( 7 ); + } ); + + test( 'returns empty result when every entry has an invalid date', () => { + const allInvalid: DataPointDate[] = [ + { dateString: 'nope', value: 1 }, + { date: new Date( NaN ), value: 2 }, + ]; + expect( buildCalendarHeatmapData( allInvalid ) ).toEqual( { data: [], rowLabels: [] } ); + } ); + + test( 'duplicate days keep the last value (no aggregation)', () => { + const dupes: DataPointDate[] = [ + { dateString: '2024-01-01', value: 3 }, + { dateString: '2024-01-01', value: 8 }, + ]; + const { data } = buildCalendarHeatmapData( dupes, { weekStartsOn: 1 } ); + expect( data[ 0 ].data[ 0 ].value ).toBe( 8 ); // last write wins, not summed to 11 + } ); + + test( 'Sunday week start shifts rows and labels (Sun/Tue/Thu)', () => { + // gridStart snaps to Sun Dec 31 2023, so Mon Jan 1 lands in row 1. + const { data, rowLabels } = buildCalendarHeatmapData( series, { weekStartsOn: 0 } ); + expect( data[ 0 ].data[ 1 ].value ).toBe( 3 ); // Mon Jan 1 + expect( data[ 0 ].data[ 3 ].value ).toBe( 5 ); // Wed Jan 3 + expect( rowLabels[ 0 ] ).toBe( 'Sun' ); + expect( rowLabels[ 2 ] ).toBe( 'Tue' ); + expect( rowLabels[ 4 ] ).toBe( 'Thu' ); + } ); +} ); diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx new file mode 100644 index 000000000000..aea7a16f5ce2 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx @@ -0,0 +1,301 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { GlobalChartsProvider } from '../../../providers'; +import HeatmapChart, { HeatmapChartUnresponsive } from '../heatmap-chart'; +import type { HeatmapColumn } from '../types'; + +const mockRefCallback = jest.fn(); +jest.mock( '../../../hooks/use-element-size', () => ( { + useElementSize: () => [ mockRefCallback, 500, 300 ], +} ) ); + +const data: HeatmapColumn[] = [ + { label: 'W1', data: [ { value: 1 }, { value: 2 }, { value: null } ] }, + { label: 'W2', data: [ { value: 3 }, { value: 0 }, { value: 4 } ] }, +]; + +const renderChart = ( props = {} ) => + render( + + + + ); + +describe( 'HeatmapChart', () => { + test( 'renders a grid with an accessible label', () => { + renderChart(); + expect( screen.getByRole( 'grid', { name: /heatmap/i } ) ).toBeInTheDocument(); + } ); + + test( 'renders one cell per data point', () => { + renderChart(); + // 2 columns x 3 rows = 6 cells + expect( screen.getAllByTestId( 'heatmap-cell' ) ).toHaveLength( 6 ); + } ); + + test( 'shows an empty-state message for empty data', () => { + renderChart( { data: [] } ); + expect( screen.getByText( /no data available/i ) ).toBeInTheDocument(); + } ); + + test( 'renders column and row labels', () => { + renderChart( { rowLabels: [ 'Mon', '', 'Wed' ] } ); + expect( screen.getAllByText( 'W1' ).length ).toBeGreaterThan( 0 ); + expect( screen.getAllByText( 'Mon' ).length ).toBeGreaterThan( 0 ); + expect( screen.getAllByText( 'Wed' ).length ).toBeGreaterThan( 0 ); + } ); + + test( 'shows in-cell values by default and hides them in compact mode', () => { + const { rerender } = renderChart(); + // value 3 appears in a cell + expect( screen.getAllByText( '3' ).length ).toBeGreaterThan( 0 ); + + rerender( + + + + ); + expect( screen.queryByText( '3' ) ).not.toBeInTheDocument(); + } ); + + test( 'formats large in-cell values compactly', () => { + render( + + + + ); + expect( screen.getByText( /748\.5\s?K/i ) ).toBeInTheDocument(); + } ); + + test( 'gives each cell an accessible name for screen readers', () => { + renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } ); + // The gridcell's accessible name is its aria-label (column + row + value). + expect( screen.getByRole( 'gridcell', { name: 'W1 Mon: 1' } ) ).toBeInTheDocument(); + } ); + + test( 'shows a tooltip on cell hover when withTooltips is set', async () => { + renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } ); + const cell = screen.getAllByTestId( 'heatmap-cell' )[ 0 ]; + await userEvent.setup().hover( cell ); + await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument(); + } ); + + test( 'shows a tooltip on keyboard navigation when withTooltips is set', async () => { + renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } ); + const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); + grid.focus(); + await userEvent.setup().keyboard( '{ArrowDown}' ); + await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument(); + } ); + + test( 'hides the keyboard tooltip on Escape', async () => { + renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } ); + const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); + const user = userEvent.setup(); + grid.focus(); + await user.keyboard( '{ArrowDown}' ); + await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument(); + await user.keyboard( '{Escape}' ); + expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument(); + } ); + + test( 'renders a composition legend with Less/More labels', () => { + render( + + + + + + ); + expect( screen.getByText( /less/i ) ).toBeInTheDocument(); + expect( screen.getByText( /more/i ) ).toBeInTheDocument(); + } ); + + test( 'ArrowDown moves focus within a column, ArrowRight moves to next column', async () => { + renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } ); + const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); + const user = userEvent.setup(); + + // First ArrowDown lands on cell (0,0) + grid.focus(); + await user.keyboard( '{ArrowDown}' ); + expect( grid ).toHaveAttribute( + 'aria-activedescendant', + expect.stringMatching( /-cell-0-0$/ ) + ); + + // Second ArrowDown moves row 0→1 in col 0 + await user.keyboard( '{ArrowDown}' ); + expect( grid ).toHaveAttribute( + 'aria-activedescendant', + expect.stringMatching( /-cell-0-1$/ ) + ); + + // ArrowRight moves to next column, same row (col 0→1, row 1) + await user.keyboard( '{ArrowRight}' ); + expect( grid ).toHaveAttribute( + 'aria-activedescendant', + expect.stringMatching( /-cell-1-1$/ ) + ); + } ); + + test( 'ArrowUp and ArrowLeft move focus in the opposite direction', async () => { + renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } ); + const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); + const user = userEvent.setup(); + + // Navigate to col 1, row 2 (bottom-right of a 2-col × 3-row grid) + // First ArrowDown lands on (0,0); subsequent presses move from there. + grid.focus(); + await user.keyboard( '{ArrowDown}{ArrowDown}{ArrowDown}{ArrowRight}' ); + expect( grid ).toHaveAttribute( + 'aria-activedescendant', + expect.stringMatching( /-cell-1-2$/ ) + ); + + // ArrowUp moves row 2→1 within col 1 + await user.keyboard( '{ArrowUp}' ); + expect( grid ).toHaveAttribute( + 'aria-activedescendant', + expect.stringMatching( /-cell-1-1$/ ) + ); + + // ArrowLeft moves col 1→0, same row + await user.keyboard( '{ArrowLeft}' ); + expect( grid ).toHaveAttribute( + 'aria-activedescendant', + expect.stringMatching( /-cell-0-1$/ ) + ); + } ); + + test( 'ArrowLeft at column 0 and ArrowUp at row 0 clamp (no wrap)', async () => { + renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } ); + const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); + const user = userEvent.setup(); + + // Start at col 0, row 0 (first ArrowDown sets index to row 1; press ArrowUp back to row 0) + grid.focus(); + await user.keyboard( '{ArrowDown}{ArrowUp}' ); + expect( grid ).toHaveAttribute( + 'aria-activedescendant', + expect.stringMatching( /-cell-0-0$/ ) + ); + + // ArrowUp at row 0 stays at row 0 + await user.keyboard( '{ArrowUp}' ); + expect( grid ).toHaveAttribute( + 'aria-activedescendant', + expect.stringMatching( /-cell-0-0$/ ) + ); + + // ArrowLeft at col 0 stays at col 0 + await user.keyboard( '{ArrowLeft}' ); + expect( grid ).toHaveAttribute( + 'aria-activedescendant', + expect.stringMatching( /-cell-0-0$/ ) + ); + } ); + + test( 'Escape clears the selection (aria-activedescendant removed)', async () => { + renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } ); + const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); + const user = userEvent.setup(); + + grid.focus(); + await user.keyboard( '{ArrowDown}' ); + expect( grid ).toHaveAttribute( 'aria-activedescendant' ); + + await user.keyboard( '{Escape}' ); + expect( grid ).not.toHaveAttribute( 'aria-activedescendant' ); + } ); + + test( 'rows contain gridcell children in the ARIA hierarchy', () => { + renderChart(); + const rows = screen.getAllByRole( 'row' ); + expect( rows.length ).toBeGreaterThan( 0 ); + rows.forEach( row => { + expect( within( row ).getAllByRole( 'gridcell' ).length ).toBeGreaterThan( 0 ); + } ); + } ); + + test( 'leaves cell gap and radius to WPDS tokens (no inline overrides)', () => { + renderChart(); + const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); + // Non-compact sets no inline gap/radius — the SCSS falls back to the WPDS tokens. + expect( grid.style.getPropertyValue( '--heatmap-cell-gap' ) ).toBe( '' ); + expect( grid.style.getPropertyValue( '--heatmap-cell-radius' ) ).toBe( '' ); + } ); + + test( 'applies the compact gap inline from the theme compactCellGap', () => { + render( + + + + ); + const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); + expect( grid.style.getPropertyValue( '--heatmap-cell-gap' ) ).toBe( '3px' ); + } ); + + test( 'sizes compact cells to the theme compactCellSize', () => { + render( + + + + ); + const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); + expect( grid.style.getPropertyValue( '--heatmap-cell-size' ) ).toBe( '20px' ); + // Compact track template is built from the fixed cell size. + expect( grid.style.gridTemplateColumns ).toContain( 'var(--heatmap-cell-size)' ); + } ); + + test( 'applies the primaryColor prop as the cell-scale color', () => { + renderChart( { primaryColor: '#abcdef' } ); + const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); + expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#abcdef' ); + } ); + + test( 'resolves primaryColor from the chart theme', () => { + render( + + + + ); + const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); + expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#0a0b0c' ); + } ); + + test( 'falls back to the palette colors[0] when no prop or theme primaryColor is set', () => { + render( + + + + ); + const grid = screen.getByRole( 'grid', { name: /heatmap/i } ); + expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#0a0b0c' ); + } ); + + test( 'the unresponsive export pins explicit width and height', () => { + render( + + + + ); + const chart = screen.getByTestId( 'heatmap-chart' ); + expect( chart ).toHaveStyle( { width: '480px', height: '240px' } ); + } ); + + test( 'the responsive export leaves the chart unpinned so it fills its container', () => { + render( + + + + ); + const chart = screen.getByTestId( 'heatmap-chart' ); + expect( chart ).not.toHaveStyle( { width: '500px' } ); + expect( chart ).not.toHaveStyle( { height: '300px' } ); + } ); +} ); diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts b/projects/js-packages/charts/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts new file mode 100644 index 000000000000..f79f7509a601 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts @@ -0,0 +1,34 @@ +import { getValueExtent, getNormalizedValue } from '../private/use-heatmap-colors'; +import type { HeatmapColumn } from '../types'; + +const data: HeatmapColumn[] = [ + { label: 'A', data: [ { value: 0 }, { value: null }, { value: 10 } ] }, + { label: 'B', data: [ { value: 5 }, { value: 20 }, { value: null } ] }, +]; + +describe( 'getValueExtent', () => { + test( 'returns [min, max] ignoring null/NaN', () => { + expect( getValueExtent( data ) ).toEqual( [ 0, 20 ] ); + } ); + + test( 'returns [0, 0] for all-empty data', () => { + expect( getValueExtent( [ { data: [ { value: null } ] } ] ) ).toEqual( [ 0, 0 ] ); + } ); +} ); + +describe( 'getNormalizedValue', () => { + test( 'returns 0 at min and 1 at max', () => { + expect( getNormalizedValue( 0, [ 0, 20 ] ) ).toBe( 0 ); + expect( getNormalizedValue( 20, [ 0, 20 ] ) ).toBe( 1 ); + } ); + + test( 'returns a clamped value for points inside and outside the extent', () => { + expect( getNormalizedValue( 10, [ 0, 20 ] ) ).toBe( 0.5 ); + expect( getNormalizedValue( 30, [ 0, 20 ] ) ).toBe( 1 ); + expect( getNormalizedValue( -5, [ 0, 20 ] ) ).toBe( 0 ); + } ); + + test( 'returns 1 when min === max', () => { + expect( getNormalizedValue( 7, [ 7, 7 ] ) ).toBe( 1 ); + } ); +} ); diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/types.ts b/projects/js-packages/charts/src/charts/heatmap-chart/types.ts new file mode 100644 index 000000000000..93d600dc5a56 --- /dev/null +++ b/projects/js-packages/charts/src/charts/heatmap-chart/types.ts @@ -0,0 +1,42 @@ +import type { BaseChartProps } from '../../types'; +import type { ReactNode } from 'react'; + +/** A single heatmap cell. `value: null` marks an empty cell. */ +export type HeatmapCell = { + /** Per-cell label used in the tooltip / accessible name. */ + label?: string; + value: number | null; +}; + +/** A heatmap column (rendered left→right); its cells render top→bottom. */ +export type HeatmapColumn = { + /** x-axis label for this column. Empty/omitted renders blank. */ + label?: string; + data: HeatmapCell[]; +}; + +export type HeatmapTooltipData = { + value: number | null; + rowLabel?: string; + columnLabel?: string; + cellLabel?: string; + row: number; + column: number; +}; + +export interface HeatmapChartProps + extends Omit< BaseChartProps< HeatmapColumn[] >, 'showLegend' | 'legend' | 'gridVisibility' > { + /** y-axis labels by row index. Empty entries render blank. */ + rowLabels?: string[]; + /** Compact mode: hide in-cell values, tighten gap, thin axis labels. Default false. */ + compact?: boolean; + /** Render the numeric value inside each cell. Default `! compact`. */ + showValues?: boolean; + /** + * Color the cell scale interpolates toward at the highest value + * (this prop > theme `heatmapChart.primaryColor` > palette `colors[0]`). + */ + primaryColor?: string; + renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode; + children?: ReactNode; +} diff --git a/projects/js-packages/charts/src/charts/index.ts b/projects/js-packages/charts/src/charts/index.ts index 55082d2cfd22..d646c1801ed7 100644 --- a/projects/js-packages/charts/src/charts/index.ts +++ b/projects/js-packages/charts/src/charts/index.ts @@ -1,6 +1,7 @@ export * from './bar-chart'; export * from './bar-list-chart'; export * from './conversion-funnel-chart'; +export * from './heatmap-chart'; export * from './leaderboard-chart'; export * from './line-chart'; export * from './pie-chart'; diff --git a/projects/js-packages/charts/src/charts/private/with-responsive/test/with-responsive.test.tsx b/projects/js-packages/charts/src/charts/private/with-responsive/test/with-responsive.test.tsx index fedf762944f7..d8f3dbec039d 100644 --- a/projects/js-packages/charts/src/charts/private/with-responsive/test/with-responsive.test.tsx +++ b/projects/js-packages/charts/src/charts/private/with-responsive/test/with-responsive.test.tsx @@ -72,6 +72,20 @@ describe( 'withResponsive', () => { const wrapper = screen.getByTestId( 'responsive-wrapper' ); expect( wrapper ).toHaveStyle( { width: '100%', height: 'auto' } ); } ); + + test( 'wrapper expresses aspectRatio in CSS and caps at maxWidth', () => { + render( ); + const wrapper = screen.getByTestId( 'responsive-wrapper' ); + // CSS aspect-ratio is width/height, so 1 / 0.5 = 2. + expect( wrapper ).toHaveStyle( { aspectRatio: '2', maxWidth: '800px' } ); + } ); + + test( 'wrapper omits the maxWidth cap when an explicit width is set', () => { + render( ); + const wrapper = screen.getByTestId( 'responsive-wrapper' ); + expect( wrapper ).toHaveStyle( { aspectRatio: '2', width: '300px' } ); + expect( wrapper ).not.toHaveStyle( { maxWidth: '1200px' } ); + } ); } ); describe( 'configuration', () => { diff --git a/projects/js-packages/charts/src/charts/private/with-responsive/with-responsive.tsx b/projects/js-packages/charts/src/charts/private/with-responsive/with-responsive.tsx index e0ddc4890664..bf8527c53851 100644 --- a/projects/js-packages/charts/src/charts/private/with-responsive/with-responsive.tsx +++ b/projects/js-packages/charts/src/charts/private/with-responsive/with-responsive.tsx @@ -93,6 +93,17 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o const effectiveHeight = measuredHeight || height || 0; const defaultHeight = hasAspectRatio ? 'auto' : '100%'; + // Express the aspect ratio in CSS so the container height tracks its width + // fluidly, rather than snapping to a debounced measured height. Cap the width + // at maxWidth so the CSS-derived height matches the maxWidth-capped content + // (the wrapped chart is sized from the capped `measuredWidth`). + const aspectRatioStyle = + hasAspectRatio && aspectRatio + ? { + aspectRatio: `${ 1 / aspectRatio }`, + maxWidth: width === undefined ? maxWidth : undefined, + } + : null; return (
, 'o style={ { width: width ?? '100%', height: height ?? defaultHeight, + ...aspectRatioStyle, } } > ( { + label: col % 4 === 0 ? `Q${ Math.floor( col / 4 ) + 1 }` : '', + data: Array.from( { length: 7 }, ( _row, row ) => ( { + label: `Col ${ col + 1 }, Row ${ row + 1 }`, + value: ( col * 7 + row ) % 5 === 0 ? null : ( ( col + row ) % 5 ) + 1, + } ) ), + } ) +); + +/** + * Large-value matrix for the heatmap chart (12 columns × 7 rows) + * + * Same shape as the activity matrix but with values up to ~1,000,000, to exercise + * compact in-cell number formatting (e.g. `748.5K`). + * - Category: matrix + * - Data points: 84 + * - Suitable for: HeatmapChart + */ +export const heatmapLargeValueMatrix: HeatmapColumn[] = Array.from( + { length: 12 }, + ( _col, col ) => ( { + label: col % 4 === 0 ? `Q${ Math.floor( col / 4 ) + 1 }` : '', + data: Array.from( { length: 7 }, ( _row, row ) => { + const index = col * 7 + row; + return { + label: `Col ${ col + 1 }, Row ${ row + 1 }`, + value: + index % 9 === 0 ? null : Math.round( Math.abs( Math.sin( index ) ) * 990_000 ) + 1_000, + }; + } ), + } ) +); + +/** + * Daily activity series for the calendar heatmap (120 days from 2024-01-01) + * + * Date/value pairs for building a GitHub-style contribution calendar via + * `buildCalendarHeatmapData`. + * - Category: time-series + * - Data points: 120 + * - Suitable for: HeatmapChart (calendar layout) + */ +export const heatmapCalendarSeries: DataPointDate[] = Array.from( + { length: 120 }, + ( _, index ) => ( { + date: new Date( 2024, 0, 1 + index ), + value: Math.round( Math.abs( Math.sin( index ) ) * 4 ), + } ) +); diff --git a/projects/js-packages/charts/src/types.ts b/projects/js-packages/charts/src/types.ts index 25e1e38a3c4b..0fc9d03e487b 100644 --- a/projects/js-packages/charts/src/types.ts +++ b/projects/js-packages/charts/src/types.ts @@ -413,6 +413,21 @@ export type ChartTheme = { /** Stroke width for the sparkline line */ strokeWidth?: number; }; + /** + * HeatmapChart settings. Cell gap, radius, value size and the selection ring come from + * WPDS tokens in CSS, so only the scale color and the compact sizing live here. + */ + heatmapChart?: { + /** + * Color the cell scale interpolates toward at the highest value (prop > this > + * palette `colors[0]`), fed to CSS `color-mix`. Omit to use the palette color. + */ + primaryColor?: string; + /** Gap in px between cells in compact mode */ + compactCellGap?: number; + /** Fixed square cell size in px for compact mode */ + compactCellSize?: number; + }; }; /** @@ -440,6 +455,8 @@ export type CompleteChartTheme = Required< ChartTheme > & { sparkline: Required< NonNullable< ChartTheme[ 'sparkline' ] > > & { margin: Required< NonNullable< ChartTheme[ 'sparkline' ] >[ 'margin' ] >; }; + heatmapChart: Omit< Required< NonNullable< ChartTheme[ 'heatmapChart' ] > >, 'primaryColor' > & + Pick< NonNullable< ChartTheme[ 'heatmapChart' ] >, 'primaryColor' >; }; export type AxisOptions = { diff --git a/projects/js-packages/charts/src/utils/color-utils.ts b/projects/js-packages/charts/src/utils/color-utils.ts index 5dfdd92b2128..4e75e407ee03 100644 --- a/projects/js-packages/charts/src/utils/color-utils.ts +++ b/projects/js-packages/charts/src/utils/color-utils.ts @@ -232,3 +232,39 @@ export const lightenHexColor = ( hex: string, blend: number ): string => { .toString( 16 ) .padStart( 2, '0' ) }${ newB.toString( 16 ).padStart( 2, '0' ) }`; }; + +/** + * WCAG relative luminance of a hex color (0 = black, 1 = white). + * + * @param hex - Hex color string (e.g., '#98C8DF') + * @return Relative luminance in the range [0, 1] + * @throws {Error} if hex string is malformed + */ +export const relativeLuminance = ( hex: string ): number => { + validateHexColor( hex ); + + const toLinear = ( value: number ): number => { + const channel = value / 255; + return channel <= 0.03928 ? channel / 12.92 : Math.pow( ( channel + 0.055 ) / 1.055, 2.4 ); + }; + + const r = toLinear( parseInt( hex.slice( 1, 3 ), 16 ) ); + const g = toLinear( parseInt( hex.slice( 3, 5 ), 16 ) ); + const b = toLinear( parseInt( hex.slice( 5, 7 ), 16 ) ); + + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; + +/** + * Whether light text reads better than dark text on the given background, using the W3C + * luminance threshold (0.179) that maximizes contrast against black vs white. + * + * @param backgroundHex - Hex background color + * @return true if light text should be used; false (dark text) for malformed colors + */ +export const prefersLightText = ( backgroundHex: string ): boolean => { + if ( ! isValidHexColor( backgroundHex ) ) { + return false; + } + return relativeLuminance( backgroundHex ) <= 0.179; +}; diff --git a/projects/js-packages/charts/src/utils/test/color-utils.test.ts b/projects/js-packages/charts/src/utils/test/color-utils.test.ts index 47e06211bc28..31d8511b4fe5 100644 --- a/projects/js-packages/charts/src/utils/test/color-utils.test.ts +++ b/projects/js-packages/charts/src/utils/test/color-utils.test.ts @@ -8,6 +8,8 @@ import { parseHslString, parseRgbString, normalizeColorToHex, + relativeLuminance, + prefersLightText, } from '../color-utils'; // Helper to convert hex to HSL tuple using d3-color @@ -881,3 +883,34 @@ describe( 'normalizeColorToHex', () => { } ); } ); } ); + +describe( 'relativeLuminance', () => { + it( 'returns 0 for black and 1 for white', () => { + expect( relativeLuminance( '#000000' ) ).toBeCloseTo( 0, 5 ); + expect( relativeLuminance( '#ffffff' ) ).toBeCloseTo( 1, 5 ); + } ); + + it( 'returns a higher luminance for a lighter color', () => { + expect( relativeLuminance( '#98c8df' ) ).toBeGreaterThan( relativeLuminance( '#006dab' ) ); + } ); + + it( 'throws on a malformed hex', () => { + expect( () => relativeLuminance( 'not-a-color' ) ).toThrow(); + } ); +} ); + +describe( 'prefersLightText', () => { + it( 'prefers dark text on light backgrounds', () => { + expect( prefersLightText( '#ffffff' ) ).toBe( false ); + expect( prefersLightText( '#98c8df' ) ).toBe( false ); + } ); + + it( 'prefers light text on dark backgrounds', () => { + expect( prefersLightText( '#000000' ) ).toBe( true ); + expect( prefersLightText( '#006dab' ) ).toBe( true ); + } ); + + it( 'falls back to dark text for malformed colors', () => { + expect( prefersLightText( 'var(--token)' ) ).toBe( false ); + } ); +} );