From c7d5254cb191dd812617d64e2213aa33d43c8d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Do=20Rosario?= Date: Thu, 10 Sep 2026 11:33:01 +0200 Subject: [PATCH 01/12] improvement(charts): a Heatmap composed from existing primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One row per entity, one column per time slot, each cell coloured by its value and describing itself on hover or focus. It is assembled from Box, Tooltip, Text and ChartLegend rather than from a charting library: the grid is CSS, so it costs no Recharts instance and scales to a few hundred cells without one. The component owns everything it draws — title, row labels, grid, x-axis and legend — so an app hands it data and a colour scale rather than a composition. Two scales, and they type the data with them. `discrete` — the default — takes a colorSet and mounts its own ChartLegendWrapper, so the legend both colours the cells and filters the grid on click; omitting the colorSet reads a wrapper the caller put above instead, which is how several charts come to share one legend. `continuous` ramps one colour by opacity and stands beside a HeatmapGradientScale handed the same max the cells were ramped against, so the numbers printed by the scale cannot drift from what the grid shows. The grid is driven by `columns`, not by each row's cells. A row shorter than the axis has to leave the rest of its line empty, and CSS auto-placement does the opposite — it pulls the next row's label out of the gutter and skews everything below it. A `null` cell holds its slot. Three smaller decisions worth knowing. Colours are resolved once per distinct value rather than once per cell, because a dense grid asks `getColor` the same four questions four hundred times and each miss warns. Cell opacity is rounded to two decimals, which is past the eye's resolution and stops a dense grid from minting a styled class per cell. And the prop is `scale`, not `colorScale`: Storybook infers a colour picker for any prop whose name matches /color/i. The stories keep the data and a useStatusScale() that says what OK, WARNING, CRITICAL and NONE are worth in the theme's colours — that much is app domain, and the component has no business knowing those statuses. --- .../charts/heatmap/Heatmap.test.tsx | 260 +++++++++++ src/lib/components/charts/heatmap/Heatmap.tsx | 426 ++++++++++++++++++ .../charts/heatmap/Heatmap.utils.test.ts | 46 ++ .../charts/heatmap/Heatmap.utils.ts | 48 ++ .../charts/heatmap/HeatmapGradientScale.tsx | 70 +++ src/lib/components/charts/index.ts | 14 + src/lib/next.ts | 11 + stories/Heatmap/heatmap.stories.tsx | 358 +++++++++++++++ 8 files changed, 1233 insertions(+) create mode 100644 src/lib/components/charts/heatmap/Heatmap.test.tsx create mode 100644 src/lib/components/charts/heatmap/Heatmap.tsx create mode 100644 src/lib/components/charts/heatmap/Heatmap.utils.test.ts create mode 100644 src/lib/components/charts/heatmap/Heatmap.utils.ts create mode 100644 src/lib/components/charts/heatmap/HeatmapGradientScale.tsx create mode 100644 stories/Heatmap/heatmap.stories.tsx diff --git a/src/lib/components/charts/heatmap/Heatmap.test.tsx b/src/lib/components/charts/heatmap/Heatmap.test.tsx new file mode 100644 index 0000000000..19af3d1e0b --- /dev/null +++ b/src/lib/components/charts/heatmap/Heatmap.test.tsx @@ -0,0 +1,260 @@ +import React from 'react'; +import { act, cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { getWrapper } from '../../../testUtils'; +import { ChartLegendWrapper } from '../legend/ChartLegendWrapper'; +import { Heatmap, HeatmapRow } from './Heatmap'; +import { DIMMED_CELL_OPACITY } from './Heatmap.utils'; + +const columns = [ + new Date('2026-08-25T10:00:00Z'), + new Date('2026-08-25T10:05:00Z'), + new Date('2026-08-25T10:10:00Z'), +]; + +const colorSet = { + OK: 'green', + WARNING: 'orange', +}; + +const statusRows: HeatmapRow[] = [ + { label: 'Prometheus', cells: ['OK', 'WARNING', 'OK'] }, + { label: 'Grafana', cells: ['OK', 'OK', 'OK'] }, +]; + +const renderStatusHeatmap = ( + props: Partial> = {}, +) => { + const { Wrapper } = getWrapper(); + + return render( + , + { wrapper: Wrapper }, + ); +}; + +describe('Heatmap', () => { + describe('title and legend', () => { + it('should render its own title and legend, from the colorSet it was given', () => { + renderStatusHeatmap(); + + expect( + screen.getByText('Monitoring Services Status'), + ).toBeInTheDocument(); + expect(screen.getByText('Service Status')).toBeInTheDocument(); + expect(screen.getByLabelText('OK selected')).toBeInTheDocument(); + expect(screen.getByLabelText('WARNING selected')).toBeInTheDocument(); + }); + + it('should order the legend items the scale asked for', () => { + renderStatusHeatmap({ + scale: { + colorSet, + sortOrder: (a, b) => b.localeCompare(a), + }, + }); + + const items = screen + .getAllByLabelText(/selected$/) + .map((item) => item.textContent); + expect(items).toEqual(['WARNING', 'OK']); + }); + + it('should relabel the legend items through labelMap', () => { + renderStatusHeatmap({ + scale: { colorSet, labelMap: { OK: 'Healthy' } }, + }); + + expect(screen.getByText('Healthy')).toBeInTheDocument(); + }); + + it('should drop the legend when the scale is stated elsewhere', () => { + renderStatusHeatmap({ showLegend: false }); + + expect(screen.queryByText('Service Status')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('OK selected')).not.toBeInTheDocument(); + // the grid itself is untouched + expect(screen.getAllByRole('img')).toHaveLength(6); + }); + + it('should read a ChartLegendWrapper the caller owns when given no colorSet', () => { + const { Wrapper } = getWrapper(); + + render( + + + , + { wrapper: Wrapper }, + ); + + expect(screen.getByLabelText('Prometheus WARNING')).toHaveStyle( + 'background-color: rgb(255, 165, 0)', + ); + }); + }); + + describe('discrete scale', () => { + it('should render one cell per column and per row, and the row labels', () => { + renderStatusHeatmap(); + + expect(screen.getByText('Prometheus')).toBeInTheDocument(); + expect(screen.getByText('Grafana')).toBeInTheDocument(); + expect(screen.getAllByRole('img')).toHaveLength(6); + expect(screen.getByLabelText('Prometheus WARNING')).toBeInTheDocument(); + }); + + it('should color a cell with the color the legend holds for its value', () => { + renderStatusHeatmap(); + + expect(screen.getByLabelText('Prometheus WARNING')).toHaveStyle( + 'background-color: rgb(255, 165, 0)', + ); + expect(screen.getAllByLabelText('Grafana OK')[0]).toHaveStyle( + 'background-color: rgb(0, 128, 0)', + ); + }); + + it('should register its values, so a colorSet function is told what to color', () => { + const colorSetFn = jest.fn(() => colorSet); + renderStatusHeatmap({ scale: { colorSet: colorSetFn } }); + + expect(colorSetFn).toHaveBeenCalledWith(['OK', 'WARNING']); + }); + + it('should dim the cells of the values the legend filtered out', () => { + renderStatusHeatmap(); + + // clicking a legend item selects it alone + userEvent.click(screen.getByText('OK')); + + expect(screen.getByLabelText('Prometheus WARNING')).toHaveStyle( + `opacity: ${DIMMED_CELL_OPACITY}`, + ); + expect(screen.getAllByLabelText('Grafana OK')[0]).toHaveStyle( + 'opacity: 1', + ); + }); + }); + + describe('empty cells', () => { + it('should leave a slot empty rather than shift the rest of the grid', () => { + renderStatusHeatmap({ + rows: [ + // a row shorter than the axis, and a hole in the middle of one + { label: 'Short', cells: ['OK'] }, + { label: 'Holed', cells: ['OK', null, 'OK'] }, + ], + }); + + expect(screen.getAllByRole('img')).toHaveLength(3); + expect(screen.getAllByLabelText('Short OK')).toHaveLength(1); + expect(screen.getAllByLabelText('Holed OK')).toHaveLength(2); + }); + }); + + describe('continuous scale', () => { + const numericRows: HeatmapRow[] = [ + { label: 'cpu', cells: [0, 50, 100] }, + ]; + + const renderNumericHeatmap = ( + scale: Partial<{ max: number; minOpacity: number }> = {}, + props = {}, + ) => { + const { Wrapper } = getWrapper(); + + return render( + , + { wrapper: Wrapper }, + ); + }; + + it('should ramp the opacity from the floor at 0 to 1 at the max', () => { + renderNumericHeatmap({ minOpacity: 0.2 }); + + expect(screen.getByLabelText('cpu 0')).toHaveStyle('opacity: 0.2'); + expect(screen.getByLabelText('cpu 50')).toHaveStyle('opacity: 0.6'); + expect(screen.getByLabelText('cpu 100')).toHaveStyle('opacity: 1'); + expect(screen.getByLabelText('cpu 100')).toHaveStyle( + 'background-color: rgb(10,173,166)', + ); + }); + + it('should ramp against a pinned max rather than the data', () => { + renderNumericHeatmap({ max: 200, minOpacity: 0 }); + + expect(screen.getByLabelText('cpu 100')).toHaveStyle('opacity: 0.5'); + }); + + it('should state the domain it ramped against beside the grid', () => { + renderNumericHeatmap(); + + expect(screen.getByText('%')).toBeInTheDocument(); + expect(screen.getByText('100')).toBeInTheDocument(); + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('should spell the value out through formatValue', () => { + renderNumericHeatmap( + {}, + { formatValue: (value: number) => `${value} %` }, + ); + + expect(screen.getByLabelText('cpu 100 %')).toBeInTheDocument(); + }); + }); + + describe('tooltip', () => { + it('should describe the cell on focus, so it is reachable from the keyboard', () => { + renderStatusHeatmap(); + + act(() => screen.getByLabelText('Prometheus WARNING').focus()); + + const overlay = document.querySelector('.sc-tooltip-overlay'); + expect(overlay).not.toBeNull(); + expect(overlay).toHaveTextContent('Prometheus'); + expect(overlay).toHaveTextContent('WARNING'); + }); + + it('should let the caller replace the tooltip content', () => { + renderStatusHeatmap({ + renderTooltip: ({ row, columnIndex }) => + `${row.label} at column ${columnIndex}`, + }); + + act(() => screen.getByLabelText('Prometheus WARNING').focus()); + + expect(document.querySelector('.sc-tooltip-overlay')).toHaveTextContent( + 'Prometheus at column 1', + ); + }); + }); + + describe('x-axis', () => { + it('should thin the ticks out with labelEvery', () => { + const tickCount = () => + screen.getAllByText(/^\d{2}:\d{2}$/, { exact: false }).length; + + renderStatusHeatmap(); + expect(tickCount()).toBe(3); + + cleanup(); + + renderStatusHeatmap({ labelEvery: 2 }); + expect(tickCount()).toBe(2); + }); + }); +}); diff --git a/src/lib/components/charts/heatmap/Heatmap.tsx b/src/lib/components/charts/heatmap/Heatmap.tsx new file mode 100644 index 0000000000..e32f72f548 --- /dev/null +++ b/src/lib/components/charts/heatmap/Heatmap.tsx @@ -0,0 +1,426 @@ +import React, { ReactNode, useCallback, useEffect, useMemo } from 'react'; +import styled from 'styled-components'; +import { Box } from '../../box/Box'; +import { spacing, Stack } from '../../../spacing'; +import { Text } from '../../text/Text.component'; +import { Tooltip } from '../../tooltip/Tooltip.component'; +import { FormattedDateTime } from '../../date/FormattedDateTime'; +import { ChartLegend } from '../legend/ChartLegend'; +import { + ChartLegendWrapper, + ChartLegendWrapperProps, + useChartId, + useChartLegend, +} from '../legend/ChartLegendWrapper'; +import { HeatmapGradientScale } from './HeatmapGradientScale'; +import { + DEFAULT_MIN_OPACITY, + DIMMED_CELL_OPACITY, + getHeatmapMaxValue, + getRampOpacity, +} from './Heatmap.utils'; + +/** One line of the grid: a label in the gutter, then one cell per column. */ +export type HeatmapRow = { + label: string; + /** + * Read positionally against `columns`: cell `i` sits under column `i`. `null` + * — and a row shorter than `columns` — leaves that slot empty instead of + * shifting the rest of the grid. + */ + cells: (T | null)[]; +}; + +/** What the tooltip and the value formatter are handed for one cell. */ +export type HeatmapCell = { + row: HeatmapRow; + column: Date; + columnIndex: number; + value: T; +}; + +/** + * Discrete values — a status, a state, any small set of names. The legend is + * the single source of truth: it colors the cells, and clicking an item filters + * the grid. + */ +export type HeatmapDiscreteScale = { + type?: 'discrete'; + /** + * The color of each value. Omit it to read a `ChartLegendWrapper` the caller + * put above instead — which is how several charts come to share one legend. + */ + colorSet?: ChartLegendWrapperProps['colorSet']; + sortOrder?: ChartLegendWrapperProps['sortOrder']; + /** Display labels for the legend items, when a value is not its own label. */ + labelMap?: ChartLegendWrapperProps['labelMap']; +}; + +/** Continuous values — one color, ramped by opacity from `minOpacity` to 1. */ +export type HeatmapContinuousScale = { + type: 'continuous'; + /** + * The color to ramp, as an RGB triple: `theme.statusHealthyRGB` and its + * siblings are exactly that, `'10,173,166'`. + */ + colorRGB: string; + /** Value mapped to full opacity. Defaults to the largest value in `rows`. */ + max?: number; + /** Opacity of the value 0, so the low end stays visible. Defaults to 0.1. */ + minOpacity?: number; +}; + +type HeatmapBaseProps = { + rows: HeatmapRow[]; + /** The x-axis. It defines the columns: a row is padded or truncated to fit. */ + columns: Date[]; + /** Heading above the grid. */ + title?: ReactNode; + /** Heading above the legend — what the colors mean, or the unit they ramp. */ + legendTitle?: ReactNode; + /** + * Hide the legend, for a heatmap whose scale is stated elsewhere: several + * grids under one shared legend, a ramp beside its own + * `HeatmapGradientScale`. + */ + showLegend?: boolean; + /** Show one x-axis tick every N columns, to keep a dense axis legible. */ + labelEvery?: number; + cellHeight?: string; + cellGap?: string; + /** The row label gutter. Labels truncate rather than widen it. */ + labelWidth?: string; + /** How a column is spelled out on the x-axis. Defaults to the time of day. */ + formatColumnTick?: (column: Date) => ReactNode; + /** How a value is spelled out, in the default tooltip and in `aria-label`. */ + formatValue?: (value: T) => string; + /** Replaces the default tooltip — row label, column date-time, value. */ + renderTooltip?: (cell: HeatmapCell) => ReactNode; +}; + +export type DiscreteHeatmapProps = HeatmapBaseProps & { + scale?: HeatmapDiscreteScale; +}; + +export type ContinuousHeatmapProps = HeatmapBaseProps & { + scale: HeatmapContinuousScale; +}; + +export type HeatmapProps = DiscreteHeatmapProps | ContinuousHeatmapProps; + +const Cell = styled.div<{ + $color: string; + $opacity: number; + $height: string; +}>` + height: ${({ $height }) => $height}; + border-radius: ${spacing.f2}; + background-color: ${({ $color }) => $color}; + opacity: ${({ $opacity }) => $opacity}; + transition: opacity 0.15s ease; + cursor: pointer; + + /* outline, not border: it paints outside the box so nothing is re-laid out */ + &:hover, + &:focus-visible { + outline: ${spacing.f2} solid ${({ theme }) => theme.selectedActive}; + outline-offset: ${spacing.f1}; + } +`; + +const defaultTooltip = ( + { row, column, value }: HeatmapCell, + formatValue: (value: T) => string, +) => ( + + + {row.label} + + + + + {formatValue(value)} + +); + +/** Title above, grid and legend side by side — the frame both scales share. */ +const HeatmapFrame = ({ + title, + legend, + children, +}: { + title?: ReactNode; + legend?: ReactNode; + children: ReactNode; +}) => ( + + {title !== undefined && ( + + {title} + + )} + + {children} + {legend} + + +); + +const LegendColumn = ({ title }: { title?: ReactNode }) => ( + + {title !== undefined && ( + + {title} + + )} + + +); + +type HeatmapGridProps = HeatmapBaseProps & { + /** How one value is painted. The only thing the two scales disagree on. */ + appearanceOf: (value: T) => { color: string; opacity: number }; +}; + +const HeatmapGrid = ({ + rows, + columns, + appearanceOf, + labelEvery = 1, + cellHeight = spacing.f20, + cellGap = spacing.f4, + labelWidth = '7rem', + formatColumnTick = (column) => ( + + ), + formatValue = (value) => String(value), + renderTooltip, +}: HeatmapGridProps) => ( + + {rows.map((row, rowIndex) => ( + + + + {row.label} + + + + {/* driven by the columns, not by the cells: that is what keeps a short + row from pulling the next row's label out of the gutter */} + {columns.map((column, columnIndex) => { + const value = row.cells[columnIndex] ?? null; + const key = `${row.label}-${rowIndex}-${columnIndex}`; + + if (value === null) { + return ; + } + + const cell = { row, column, columnIndex, value }; + const { color, opacity } = appearanceOf(value); + + return ( + + + + ); + })} + + ))} + + {/* x-axis: an empty gutter cell, then one slot per column */} + + {columns.map((column, columnIndex) => ( + + {columnIndex % labelEvery === 0 && ( + + {formatColumnTick(column)} + + )} + + ))} + +); + +const DiscreteHeatmap = ({ + scale: _scale, + title, + legendTitle, + showLegend = true, + ...gridProps +}: DiscreteHeatmapProps) => { + const { rows } = gridProps; + const chartId = useChartId(); + const { getColor, isSelected, register } = useChartLegend(); + + /** + * Keyed on the *content* of the series, not on the identity of `rows`: a + * caller rebuilding its rows on every render — every story here does — must + * not re-register, since registering re-renders the wrapper above us. + */ + const seriesKey = useMemo( + () => + JSON.stringify( + Array.from( + new Set( + rows.flatMap((row) => + row.cells.filter((cell): cell is string => cell !== null), + ), + ), + ).sort(), + ), + [rows], + ); + const seriesNames = useMemo( + () => JSON.parse(seriesKey) as string[], + [seriesKey], + ); + + useEffect(() => { + register(chartId, seriesNames); + }, [chartId, register, seriesNames]); + + /** + * Resolved once per distinct value, not once per cell: a dense grid asks the + * same four questions hundreds of times, and `getColor` warns each time it + * has no answer — which it does on the first render of a `colorSet` function, + * before our registration above has reached it. + */ + const colorOfValue = useMemo( + () => new Map(seriesNames.map((name) => [name, getColor(name)])), + [seriesNames, getColor], + ); + + const appearanceOf = useCallback( + (value: string) => ({ + color: colorOfValue.get(value) ?? 'transparent', + opacity: isSelected(value) ? 1 : DIMMED_CELL_OPACITY, + }), + [colorOfValue, isSelected], + ); + + return ( + : undefined} + > + + + ); +}; + +const ContinuousHeatmap = ({ + scale, + title, + legendTitle, + showLegend = true, + ...gridProps +}: ContinuousHeatmapProps) => { + const { colorRGB, minOpacity = DEFAULT_MIN_OPACITY } = scale; + const max = scale.max ?? getHeatmapMaxValue(gridProps.rows); + + const appearanceOf = useCallback( + (value: number) => ({ + color: `rgb(${colorRGB})`, + opacity: getRampOpacity(value, max, minOpacity), + }), + [colorRGB, max, minOpacity], + ); + + return ( + + ) : undefined + } + > + + + ); +}; + +/** + * A grid of one metric read over time: one row per entity, one column per time + * slot, each cell colored by its value and describing itself on hover or focus. + * Title, grid, x-axis and legend all belong to the component. + * + * Two scales, and they type the data with them. `discrete` — the default — + * colors named values through a legend that also filters the grid; + * `continuous` ramps one color by opacity beside a gradient scale. + * + * ```tsx + * + * ``` + */ +export const Heatmap = (props: HeatmapProps) => { + /** + * One implementation per scale rather than one branch inside it, because the + * discrete scale reads the legend context — a hook, so it cannot be called + * conditionally. The casts are the one thing TypeScript will not do for us: + * it narrows on a top-level discriminant, not on `scale.type` one level down. + */ + if (props.scale?.type === 'continuous') { + return ; + } + + const { scale, ...discreteProps } = props as DiscreteHeatmapProps; + + // no colorSet: a ChartLegendWrapper the caller owns is holding the colors + if (!scale?.colorSet) { + return ; + } + + return ( + + + + ); +}; diff --git a/src/lib/components/charts/heatmap/Heatmap.utils.test.ts b/src/lib/components/charts/heatmap/Heatmap.utils.test.ts new file mode 100644 index 0000000000..c8c12bcc90 --- /dev/null +++ b/src/lib/components/charts/heatmap/Heatmap.utils.test.ts @@ -0,0 +1,46 @@ +import { + getHeatmapMaxValue, + getRampOpacity, + DEFAULT_MIN_OPACITY, +} from './Heatmap.utils'; + +describe('getHeatmapMaxValue', () => { + it('should return the largest cell of the whole grid', () => { + expect( + getHeatmapMaxValue([{ cells: [1, 9, 3] }, { cells: [4, 12, 0] }]), + ).toBe(12); + }); + + it('should ignore the empty cells', () => { + expect(getHeatmapMaxValue([{ cells: [null, 5, null] }])).toBe(5); + }); + + it('should return 0 when there is no data at all', () => { + expect(getHeatmapMaxValue([])).toBe(0); + expect(getHeatmapMaxValue([{ cells: [] }, { cells: [null] }])).toBe(0); + }); +}); + +describe('getRampOpacity', () => { + it('should give the floor to the value 0 and full opacity to the max', () => { + expect(getRampOpacity(0, 100, 0.1)).toBe(0.1); + expect(getRampOpacity(100, 100, 0.1)).toBe(1); + }); + + it('should interpolate between the floor and full opacity', () => { + expect(getRampOpacity(50, 100, 0.2)).toBe(0.6); + }); + + it('should round, so a dense grid does not mint a class per cell', () => { + expect(getRampOpacity(1, 3, 0)).toBe(0.33); + }); + + it('should clamp values outside the domain instead of overshooting', () => { + expect(getRampOpacity(150, 100, 0.1)).toBe(1); + expect(getRampOpacity(-20, 100, 0.1)).toBe(0.1); + }); + + it('should fall back to the floor on a degenerate domain', () => { + expect(getRampOpacity(0, 0, DEFAULT_MIN_OPACITY)).toBe(DEFAULT_MIN_OPACITY); + }); +}); diff --git a/src/lib/components/charts/heatmap/Heatmap.utils.ts b/src/lib/components/charts/heatmap/Heatmap.utils.ts new file mode 100644 index 0000000000..3879cd3dcc --- /dev/null +++ b/src/lib/components/charts/heatmap/Heatmap.utils.ts @@ -0,0 +1,48 @@ +/** Opacity of a cell whose series has been filtered out through the legend. */ +export const DIMMED_CELL_OPACITY = 0.15; + +/** + * Opacity given to the value 0 on a continuous scale, so the low end of the + * ramp stays visible instead of dissolving into the background. + */ +export const DEFAULT_MIN_OPACITY = 0.1; + +/** + * Largest value in the grid, ignoring the empty cells — 0 when there is none. + * A continuous heatmap uses it as the top of its ramp unless the caller pins + * `max` itself, which is what a fixed domain (a percentage, a quota) wants. + */ +export const getHeatmapMaxValue = ( + rows: { cells: (number | null)[] }[], +): number => + rows.reduce( + (max, row) => + row.cells.reduce( + (rowMax, cell) => (cell === null ? rowMax : Math.max(rowMax, cell)), + max, + ), + 0, + ); + +/** + * Where `value` sits on the ramp, as an opacity between `minOpacity` and 1. + * Values outside [0, max] are clamped, so an outlier — or a value the caller + * pinned a smaller `max` than — cannot push a cell past full opacity. + * + * Rounded to two decimals, which is past the eye's resolution and bounds the + * number of distinct values: a dense grid then generates a hundred styled + * classes at worst, not one per cell. + */ +export const getRampOpacity = ( + value: number, + max: number, + minOpacity: number, +): number => { + // Every value is 0, or the domain is degenerate: the ramp has nothing to say. + if (max <= 0) { + return minOpacity; + } + + const ratio = Math.min(Math.max(value / max, 0), 1); + return Math.round((minOpacity + (1 - minOpacity) * ratio) * 100) / 100; +}; diff --git a/src/lib/components/charts/heatmap/HeatmapGradientScale.tsx b/src/lib/components/charts/heatmap/HeatmapGradientScale.tsx new file mode 100644 index 0000000000..5b84eae723 --- /dev/null +++ b/src/lib/components/charts/heatmap/HeatmapGradientScale.tsx @@ -0,0 +1,70 @@ +import { ReactNode } from 'react'; +import { Box } from '../../box/Box'; +import { spacing, Stack } from '../../../spacing'; +import { Text } from '../../text/Text.component'; +import { DEFAULT_MIN_OPACITY } from './Heatmap.utils'; + +export type HeatmapGradientScaleProps = { + /** The ramped color, same RGB triple the heatmap's continuous scale got. */ + colorRGB: string; + /** Top of the domain — `getHeatmapMaxValue(rows)` when the data sets it. */ + max: number; + min?: number; + minOpacity?: number; + /** What is being measured: a unit, a metric name. */ + label?: ReactNode; + height?: string; + formatValue?: (value: number) => ReactNode; +}; + +/** + * The legend of a continuous `Heatmap`: `ChartLegend` enumerates discrete + * series, a ramp has no items to enumerate — only its two ends. + * + * Placed by the caller, like `ChartLegend`, so the same component serves a + * heatmap standing beside its scale and one sharing a scale with its siblings. + * It takes the domain rather than the data: the numbers it prints have to be + * the ones the grid ramped against, so they come from the same place. + */ +export const HeatmapGradientScale = ({ + colorRGB, + max, + min = 0, + minOpacity = DEFAULT_MIN_OPACITY, + label, + height = '6rem', + formatValue = (value) => String(value), +}: HeatmapGradientScaleProps) => ( + + {label !== undefined && ( + + {label} + + )} + + + {/* the same height as the bar, so the two ends line up with the ramp + rather than collapsing to the height of the two labels */} + + + {formatValue(max)} + + + {formatValue(min)} + + + + +); diff --git a/src/lib/components/charts/index.ts b/src/lib/components/charts/index.ts index 98dfb63965..1c77d17eee 100644 --- a/src/lib/components/charts/index.ts +++ b/src/lib/components/charts/index.ts @@ -20,6 +20,20 @@ export type { Alert } from './globalhealthbar/GlobalHealthBar.hooks'; export { Sparkline } from './sparkline/Sparkline'; +export { Heatmap } from './heatmap/Heatmap'; +export type { + HeatmapProps, + DiscreteHeatmapProps, + ContinuousHeatmapProps, + HeatmapRow, + HeatmapCell, + HeatmapDiscreteScale, + HeatmapContinuousScale, +} from './heatmap/Heatmap'; +export { HeatmapGradientScale } from './heatmap/HeatmapGradientScale'; +export type { HeatmapGradientScaleProps } from './heatmap/HeatmapGradientScale'; +export { getHeatmapMaxValue } from './heatmap/Heatmap.utils'; + // Legend export { ChartLegend } from './legend/ChartLegend'; export { diff --git a/src/lib/next.ts b/src/lib/next.ts index 66bf71f97b..45f4213d31 100644 --- a/src/lib/next.ts +++ b/src/lib/next.ts @@ -30,6 +30,9 @@ export { LineTimeSerieChart, GlobalHealthBar, Sparkline, + Heatmap, + HeatmapGradientScale, + getHeatmapMaxValue, ChartLegend, ChartLegendWrapper, useChartId, @@ -48,6 +51,14 @@ export type { LineChartProps, Serie, GlobalHealthProps, + HeatmapProps, + DiscreteHeatmapProps, + ContinuousHeatmapProps, + HeatmapRow, + HeatmapCell, + HeatmapDiscreteScale, + HeatmapContinuousScale, + HeatmapGradientScaleProps, Alert, UnitRange, TimeType, diff --git a/stories/Heatmap/heatmap.stories.tsx b/stories/Heatmap/heatmap.stories.tsx new file mode 100644 index 0000000000..920004f286 --- /dev/null +++ b/stories/Heatmap/heatmap.stories.tsx @@ -0,0 +1,358 @@ +import { Meta, StoryObj } from '@storybook/react-webpack5'; +import { useTheme } from 'styled-components'; +import { + Box, + Heatmap, + HeatmapDiscreteScale, + HeatmapRow, +} from '../../src/lib/next'; +import { CoreUITheme } from '../../src/lib/style/theme'; + +/* -------------------------------------------------------------------------- */ +/* DATA */ +/* -------------------------------------------------------------------------- */ + +type CellStatus = 'OK' | 'WARNING' | 'CRITICAL' | 'NONE'; + +const STATUS_ORDER: CellStatus[] = ['OK', 'WARNING', 'CRITICAL', 'NONE']; + +const MONITORING_SERVICES = [ + 'Alertmanager', + 'Grafana', + 'Prometheus', + 'Supervisor', + 'Thanos', +]; + +const FIVE_MINUTES = 5 * 60 * 1000; +const ONE_HOUR = 60 * 60 * 1000; +const ONE_DAY = 24 * ONE_HOUR; + +const buildTimeSlots = (start: Date, count: number, step: number): Date[] => + Array.from({ length: count }, (_, i) => new Date(start.getTime() + i * step)); + +/** Deterministic pseudo-random so the stories stay stable between renders. */ +const noise = (a: number, b: number) => (a * 73 + b * 151 + a * b * 17) % 100; + +const buildStatusRows = ( + labels: string[], + columnCount: number, + /** Index from which the whole column is reported as NONE (no data yet). */ + noDataFrom = columnCount, +): HeatmapRow[] => + labels.map((label, rowIndex) => ({ + label, + cells: Array.from({ length: columnCount }, (_, colIndex) => { + if (colIndex >= noDataFrom) return 'NONE' as CellStatus; + const value = noise(rowIndex + 1, colIndex + 1); + if (value < 7) return 'CRITICAL' as CellStatus; + if (value < 22) return 'WARNING' as CellStatus; + return 'OK' as CellStatus; + }), + })); + +/** + * The one thing an app brings to a discrete heatmap: what its values mean, in + * its own colors and its own order. + */ +const useStatusScale = (): HeatmapDiscreteScale => { + const theme = useTheme() as CoreUITheme; + + return { + colorSet: { + OK: theme.statusHealthy, + WARNING: theme.statusWarning, + CRITICAL: theme.statusCritical, + NONE: theme.textSecondary, + }, + sortOrder: (a, b) => + STATUS_ORDER.indexOf(a as CellStatus) - + STATUS_ORDER.indexOf(b as CellStatus), + }; +}; + +/* -------------------------------------------------------------------------- */ +/* STORIES */ +/* -------------------------------------------------------------------------- */ + +/** Presentational props, shared by every story so a control means the same thing. */ +type LayoutArgs = { + cellHeight: number; + cellGap: number; + labelEvery: number; + labelWidth: string; +}; + +/** Stories whose grid is typed in by hand. The data is the source of truth. */ +type DataArgs = LayoutArgs & { rows: HeatmapRow[] }; + +/** Stories whose grid is generated, because hand-editing 400 cells is not a thing. */ +type GeneratedArgs = LayoutArgs & { + /** Rows — one per monitored entity: a bucket, a service, a node. */ + entities: number; + /** Columns — one per time slot on the x-axis. */ + columns: number; + /** Trailing columns reported as NONE: the "collection has not caught up" tail. */ + noDataColumns: number; +}; + +const layoutArgTypes = { + cellHeight: { + control: { type: 'range' as const, min: 4, max: 48, step: 1 }, + description: 'Cell height in px', + }, + cellGap: { + control: { type: 'range' as const, min: 0, max: 16, step: 1 }, + description: + 'Gap between cells in px. At 0 the grid reads as a continuous timeline', + }, + labelEvery: { + control: { type: 'range' as const, min: 1, max: 12, step: 1 }, + description: 'Show one column label every N columns', + }, + labelWidth: { + control: 'text' as const, + description: 'Row label gutter. Labels truncate rather than widen it', + }, +}; + +const layoutArgs: LayoutArgs = { + cellHeight: 20, + cellGap: 4, + labelEvery: 1, + labelWidth: '7rem', +}; + +const layoutProps = (args: LayoutArgs) => ({ + labelEvery: args.labelEvery, + labelWidth: args.labelWidth, + cellHeight: `${args.cellHeight}px`, + cellGap: `${args.cellGap}px`, +}); + +const meta: Meta = { + title: 'Components/Data Display/Charts/Heatmap', + component: Heatmap, +}; +export default meta; + +const HOUR_START = new Date('2026-08-25T10:00:00Z'); +const DAY_START = new Date('2026-08-25T00:00:00Z'); + +/** Row labels for the generated stories, so the count control is honest at any N. */ +const entityLabels = (count: number) => + Array.from( + { length: count }, + (_, index) => + MONITORING_SERVICES[index] ?? + `storage-node-${index - MONITORING_SERVICES.length + 1}`, + ); + +/** + * The interactive one: edit the grid itself. + * + * `rows` is a real control — add a row, rename one, or change any cell to OK, + * WARNING, CRITICAL or NONE and the grid follows. The x-axis is derived from the + * longest row, so adding cells adds columns; a row with fewer cells leaves the + * rest of its line empty rather than shifting anything. + */ +export const Playground: StoryObj = { + argTypes: { + ...layoutArgTypes, + rows: { + control: 'object', + description: + 'One entry per row: { label, cells }. A cell is OK | WARNING | CRITICAL | NONE', + }, + }, + args: { + ...layoutArgs, + rows: [ + { label: 'Alertmanager', cells: ['OK', 'OK', 'WARNING', 'OK', 'NONE'] }, + { label: 'Grafana', cells: ['OK', 'OK', 'OK', 'OK', 'NONE'] }, + { + label: 'Prometheus', + cells: ['WARNING', 'CRITICAL', 'CRITICAL', 'OK', 'NONE'], + }, + { label: 'Supervisor', cells: ['OK', 'OK', 'OK', 'OK', 'NONE'] }, + { label: 'Thanos', cells: ['OK', 'WARNING', 'OK', 'OK', 'NONE'] }, + ], + }, + render: (args) => { + const scale = useStatusScale(); + const columnCount = Math.max( + 1, + ...args.rows.map((row) => row.cells.length), + ); + + return ( + + + + ); + }, +}; + +/** + * 1:1 with the reference screenshot: 5 services, 4 columns, the last one has no + * data yet. + */ +export const ScreenshotEquivalent: StoryObj = { + argTypes: layoutArgTypes, + args: layoutArgs, + render: (args) => { + const scale = useStatusScale(); + + return ( + + ({ + label, + cells: ['OK', 'OK', 'OK', 'NONE'] as CellStatus[], + }))} + columns={buildTimeSlots( + new Date('2026-08-25T10:30:00Z'), + 4, + FIVE_MINUTES, + )} + {...layoutProps(args)} + /> + + ); + }, +}; + +/** Realistic generated mix over one hour, 5-minute slots, label every 15 minutes. */ +export const ServiceStatusOverOneHour: StoryObj = { + argTypes: layoutArgTypes, + args: { ...layoutArgs, labelEvery: 3 }, + render: (args) => { + const scale = useStatusScale(); + + return ( + + + + ); + }, +}; + +/** + * Dense grid, generated: entities against time slots. Push the gap to 0 and the + * grid reads as a continuous timeline; the label frequency is what keeps the + * x-axis legible. + */ +export const DenseGrid: StoryObj = { + argTypes: { + ...layoutArgTypes, + entities: { + control: { type: 'range', min: 1, max: 24, step: 1 }, + description: 'Rows — one per monitored entity', + }, + columns: { + control: { type: 'range', min: 2, max: 96, step: 1 }, + description: 'Columns — one per time slot', + }, + noDataColumns: { control: { type: 'range', min: 0, max: 12, step: 1 } }, + }, + args: { + ...layoutArgs, + entities: 8, + columns: 48, + noDataColumns: 3, + cellHeight: 16, + cellGap: 1, + labelEvery: 6, + labelWidth: '9rem', + }, + render: (args) => { + const scale = useStatusScale(); + + return ( + + + + ); + }, +}; + +/** Continuous values instead of statuses: opacity ramp + gradient scale. */ +export const NumericValues: StoryObj< + Omit & { minOpacity: number } +> = { + argTypes: { + ...layoutArgTypes, + entities: { control: { type: 'range', min: 1, max: 24, step: 1 } }, + columns: { control: { type: 'range', min: 2, max: 48, step: 1 } }, + minOpacity: { + control: { type: 'range', min: 0, max: 0.6, step: 0.05 }, + description: 'Opacity floor, so the low values stay visible', + }, + }, + args: { + ...layoutArgs, + entities: 6, + columns: 24, + labelEvery: 3, + labelWidth: '9rem', + minOpacity: 0.1, + }, + render: (args) => { + const theme = useTheme() as CoreUITheme; + + return ( + + ({ + label, + cells: Array.from({ length: args.columns }, (_, colIndex) => + Math.round(noise(rowIndex + 3, colIndex + 5)), + ), + }))} + columns={buildTimeSlots(DAY_START, args.columns, ONE_HOUR)} + formatValue={(value: number) => `${value} %`} + {...layoutProps(args)} + /> + + ); + }, +}; From b346c0e3e968eb09a398359b1212559a40a2dc5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Do=20Rosario?= Date: Fri, 11 Sep 2026 10:16:51 +0200 Subject: [PATCH 02/12] improvement(charts): Heatmap stories for colour sets beyond a health status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stories only ever showed OK / WARNING / CRITICAL / NONE in the theme's status tokens, which reads as the set of values a Heatmap supports rather than as one example of them. Three stories say otherwise. CustomColorSet takes five backup outcomes and colours them half from the chart palette and half from the theme's own tokens, because colorSet takes any CSS colour and there is no reason to pick them all from one place. NonStatusValues drops the theme entirely: four workload profiles in four hand-written hex values, nothing about health in them. LabelledValues puts bare response codes in the cells, then hands labelMap to the legend and formatValue to the tooltip and aria-label, so the data keeps its codes and the reader gets sentences; its sortOrder compares them as numbers. Generalising the data helper is what made the three cheap. buildCategoryRows fills a grid over any value set, weighting each value at half the frequency of the one before it — an even wash hides which colour means what, which is the only thing these stories are about. inDeclaredOrder replaces the indexOf-with-cast comparator that useStatusScale had inline. --- stories/Heatmap/heatmap.stories.tsx | 213 +++++++++++++++++++++++++++- 1 file changed, 209 insertions(+), 4 deletions(-) diff --git a/stories/Heatmap/heatmap.stories.tsx b/stories/Heatmap/heatmap.stories.tsx index 920004f286..e27edb3985 100644 --- a/stories/Heatmap/heatmap.stories.tsx +++ b/stories/Heatmap/heatmap.stories.tsx @@ -6,7 +6,13 @@ import { HeatmapDiscreteScale, HeatmapRow, } from '../../src/lib/next'; -import { CoreUITheme } from '../../src/lib/style/theme'; +import { FormattedDateTime } from '../../src/lib/components/date/FormattedDateTime'; +import { + CoreUITheme, + lineColor1, + lineColor2, + lineColor3, +} from '../../src/lib/style/theme'; /* -------------------------------------------------------------------------- */ /* DATA */ @@ -51,6 +57,89 @@ const buildStatusRows = ( }), })); +/* -- Value sets the colorSet stories are built on -------------------------- */ + +const BACKUP_OUTCOMES = [ + 'Full', + 'Incremental', + 'Snapshot', + 'Skipped', + 'Failed', +] as const; + +const BACKUP_POLICIES = [ + 'vault-01 nightly', + 'vault-02 nightly', + 'archive weekly', + 'metadata hourly', + 'config hourly', +]; + +const WORKLOAD_PROFILES = [ + 'Read-heavy', + 'Write-heavy', + 'Mixed', + 'Idle', +] as const; + +const BUCKETS = [ + 'ingest-raw', + 'media-thumbnails', + 'analytics-exports', + 'backup-archive', + 'user-uploads', + 'logs-audit', +]; + +/** Codes, not sentences: the legend spells them out through `labelMap`. */ +const RESPONSE_CODES = ['200', '206', '403', '500'] as const; + +const S3_ENDPOINTS = [ + 'GET /objects', + 'PUT /objects', + 'POST /multipart', + 'DELETE /objects', + 'GET /buckets', +]; + +/** Picks from `values` by geometric share, given a 0-99 draw. */ +const pickWeighted = (values: readonly T[], draw: number): T => { + let remaining = draw; + + for (let index = 0; index < values.length - 1; index++) { + const share = 100 / 2 ** (index + 1); + if (remaining < share) return values[index]; + remaining -= share; + } + + return values[values.length - 1]; +}; + +/** + * Rows over any value set: the generated stories differ in what their values + * are called, not in how the grid is filled. + * + * Each value is half as frequent as the one before it, so a generated grid has + * a dominant value and a rare tail — an even wash would hide which color means + * what, which is the only thing these stories are about. + */ +const buildCategoryRows = ( + labels: string[], + columnCount: number, + /** Values from most to least frequent. */ + values: readonly T[], +): HeatmapRow[] => + labels.map((label, rowIndex) => ({ + label, + cells: Array.from({ length: columnCount }, (_, colIndex) => + pickWeighted(values, noise(rowIndex + 2, colIndex + 3)), + ), + })); + +/** `sortOrder` that keeps the legend in the order the values are declared. */ +const inDeclaredOrder = (values: readonly string[]) => (a: string, b: string) => + values.indexOf(a) - values.indexOf(b); + /** * The one thing an app brings to a discrete heatmap: what its values mean, in * its own colors and its own order. @@ -65,9 +154,7 @@ const useStatusScale = (): HeatmapDiscreteScale => { CRITICAL: theme.statusCritical, NONE: theme.textSecondary, }, - sortOrder: (a, b) => - STATUS_ORDER.indexOf(a as CellStatus) - - STATUS_ORDER.indexOf(b as CellStatus), + sortOrder: inDeclaredOrder(STATUS_ORDER), }; }; @@ -356,3 +443,121 @@ export const NumericValues: StoryObj< ); }, }; + +/** + * The colors are the caller's, and so are the values. Five backup outcomes, + * none of them a health status, painted from the chart palette and the theme's + * own tokens side by side — `colorSet` takes any CSS color, wherever it comes + * from. + * + * `sortOrder` is what keeps the legend in pipeline order rather than + * alphabetical, so the rare outcomes stay at the bottom where they are looked + * for. + */ +export const CustomColorSet: StoryObj = { + argTypes: layoutArgTypes, + args: { ...layoutArgs, labelEvery: 2, labelWidth: '9rem' }, + render: (args) => { + const theme = useTheme() as CoreUITheme; + + return ( + + ( + + )} + {...layoutProps(args)} + /> + + ); + }, +}; + +/** + * Discrete does not mean three states of health. Here the values are workload + * profiles in four hand-picked hex colors that belong to no theme at all, and + * the grid behaves exactly the same: click *Write-heavy* in the legend and + * every other slot dims, leaving the write bursts alone on the timeline. + */ +export const NonStatusValues: StoryObj = { + argTypes: layoutArgTypes, + args: { ...layoutArgs, labelEvery: 3, labelWidth: '10rem', cellGap: 2 }, + render: (args) => ( + + + + ), +}; + +/** + * When the values in the data are not what a reader should see: the cells hold + * bare response codes, `labelMap` spells them out in the legend, `formatValue` + * does the same for the tooltip and the cell's `aria-label`, and `sortOrder` + * compares them as the numbers they are rather than as the strings they arrive + * as. + */ +export const LabelledValues: StoryObj = { + argTypes: layoutArgTypes, + args: { ...layoutArgs, labelEvery: 3, labelWidth: '9rem' }, + render: (args) => { + const theme = useTheme() as CoreUITheme; + const labelMap = { + '200': '200 OK', + '206': '206 Partial Content', + '403': '403 Forbidden', + '500': '500 Internal Error', + }; + + return ( + + Number(a) - Number(b), + }} + rows={buildCategoryRows(S3_ENDPOINTS, 12, RESPONSE_CODES)} + columns={buildTimeSlots(HOUR_START, 12, ONE_HOUR)} + formatValue={(value) => labelMap[value] ?? value} + {...layoutProps(args)} + /> + + ); + }, +}; From 893a887ce7632ba9416f7142e31eba13a93ea3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Do=20Rosario?= Date: Mon, 14 Sep 2026 14:55:34 +0200 Subject: [PATCH 03/12] improvement(charts): address the Heatmap review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cell now says how long it lasts. It only ever gave the instant its column opened, so reading it meant measuring it against the next tick to find out whether the square covered five minutes, an hour or a day. The duration is read off the axis — a column lasts until the next one starts, the last reuses the gap before it — so an irregular axis is followed rather than assumed away, and no new prop is needed. The end repeats the date whenever the slot changes day: without that, a nightly slot read "31 Aug 23:00 to 00:00" and a daily one "31 Aug 00:00 to 00:00", the same instant twice as far as the reader could tell. getDateDaysDiff cannot answer that question, being an elapsed duration where this is a calendar one, hence isSameCalendarDay. labelWidth promised that labels truncate rather than widen the gutter, and nothing in the CSS made them. They overflowed onto the grid instead. The gutter clips now, with min-width because a grid item defaults to the width of its content and would otherwise refuse to shrink into its track, and carries the full label in a title so the truncation loses nothing. Cells no longer show a pointer cursor. Nothing in the grid is clickable and the hand promised an action that does not exist. Colour sets stop crossing. Status tokens are for states of health; everything else — job outcomes, workload profiles — takes the series colours, because a value painted statusHealthy claims to be healthy and a backup type is not. One story had four hex values written into it, which belong to no theme and follow none. Worth knowing: the series colours are module constants, identical in all four themes, so they are the design system's categorical palette rather than a theme-reactive one. The axis speaks the date guideline. It printed "09-02", which is 2 September or 9 February depending on the reader; day-month-abbreviated gives "02 Sep". The component's own default tick is still time-of-day and has no notion of its axis granularity, so a daily axis without an explicit formatColumnTick would print midnight fourteen times. Stories stop shouting: OK / WARNING / CRITICAL / NONE became Ok / Warning / Critical / No data, since stories are what people copy. Periods leave the titles, a range being something a global selector owns rather than a chart. A story crossing midnight puts on the record that the axis says nothing when the day changes. And Numeric Values gained a toggle between a ramp pinned to 100 and one topped by the data, with the sample load lowered to around 60% so the difference is visible — two grids whose ramps top out at their own maxima are not comparable. The guideline page is a first version, and deliberately silent on the continuous scale while its fate is still being discussed. It states the limitation the component cannot design away: colour is the only channel carrying information, the tooltip is what saves it, and that is a reason to keep such components rare rather than a pattern to copy. --- .../charts/heatmap/Heatmap.test.tsx | 58 +++++ src/lib/components/charts/heatmap/Heatmap.tsx | 194 ++++++++++------ .../charts/heatmap/Heatmap.utils.test.ts | 35 +++ .../charts/heatmap/Heatmap.utils.ts | 36 +++ stories/Heatmap/heatmap.guideline.mdx | 119 ++++++++++ stories/Heatmap/heatmap.stories.tsx | 216 ++++++++++++------ 6 files changed, 511 insertions(+), 147 deletions(-) create mode 100644 stories/Heatmap/heatmap.guideline.mdx diff --git a/src/lib/components/charts/heatmap/Heatmap.test.tsx b/src/lib/components/charts/heatmap/Heatmap.test.tsx index 19af3d1e0b..186dff9a07 100644 --- a/src/lib/components/charts/heatmap/Heatmap.test.tsx +++ b/src/lib/components/charts/heatmap/Heatmap.test.tsx @@ -229,6 +229,64 @@ describe('Heatmap', () => { expect(overlay).toHaveTextContent('WARNING'); }); + it('should name the whole slot, not the instant the column opens', () => { + renderStatusHeatmap(); + + act(() => screen.getByLabelText('Prometheus WARNING').focus()); + + // the axis is five-minute slots, and the cell has to say so on its own + expect(document.querySelector('.sc-tooltip-overlay')).toHaveTextContent( + '25 Aug 10:05 to 10:10', + ); + }); + + it('should repeat the date when the slot runs into the next day', () => { + renderStatusHeatmap({ + columns: [ + new Date('2026-08-31T23:00:00Z'), + new Date('2026-09-01T00:00:00Z'), + ], + rows: [{ label: 'Prometheus', cells: ['OK', 'OK'] }], + }); + + act(() => screen.getAllByLabelText('Prometheus OK')[0].focus()); + + // without the date the slot would read "31 Aug 23:00 to 00:00" + expect(document.querySelector('.sc-tooltip-overlay')).toHaveTextContent( + '31 Aug 23:00 to 01 Sep 00:00', + ); + }); + + it('should name both days of a slot a whole day long', () => { + renderStatusHeatmap({ + columns: [ + new Date('2026-08-31T00:00:00Z'), + new Date('2026-09-01T00:00:00Z'), + ], + rows: [{ label: 'Prometheus', cells: ['OK', 'OK'] }], + }); + + act(() => screen.getAllByLabelText('Prometheus OK')[0].focus()); + + // the end is midnight too, so only the date tells the two apart + expect(document.querySelector('.sc-tooltip-overlay')).toHaveTextContent( + '31 Aug 00:00 to 01 Sep 00:00', + ); + }); + + it('should fall back to the start alone when the axis has one column', () => { + renderStatusHeatmap({ + columns: [columns[0]], + rows: [{ label: 'Prometheus', cells: ['OK'] }], + }); + + act(() => screen.getByLabelText('Prometheus OK').focus()); + + const overlay = document.querySelector('.sc-tooltip-overlay'); + expect(overlay).toHaveTextContent('25 Aug 10:00'); + expect(overlay).not.toHaveTextContent('to'); + }); + it('should let the caller replace the tooltip content', () => { renderStatusHeatmap({ renderTooltip: ({ row, columnIndex }) => diff --git a/src/lib/components/charts/heatmap/Heatmap.tsx b/src/lib/components/charts/heatmap/Heatmap.tsx index e32f72f548..891efb44f3 100644 --- a/src/lib/components/charts/heatmap/Heatmap.tsx +++ b/src/lib/components/charts/heatmap/Heatmap.tsx @@ -16,8 +16,10 @@ import { HeatmapGradientScale } from './HeatmapGradientScale'; import { DEFAULT_MIN_OPACITY, DIMMED_CELL_OPACITY, + getColumnEnds, getHeatmapMaxValue, getRampOpacity, + isSameCalendarDay, } from './Heatmap.utils'; /** One line of the grid: a label in the gutter, then one cell per column. */ @@ -34,7 +36,14 @@ export type HeatmapRow = { /** What the tooltip and the value formatter are handed for one cell. */ export type HeatmapCell = { row: HeatmapRow; + /** When the slot opens — the column it sits under. */ column: Date; + /** + * When the slot closes: the next column's start, or one axis step past the + * last column. Equal to `column` on a single-column axis, which has no step + * to read a duration from. + */ + columnEnd: Date; columnIndex: number; value: T; }; @@ -118,7 +127,6 @@ const Cell = styled.div<{ background-color: ${({ $color }) => $color}; opacity: ${({ $opacity }) => $opacity}; transition: opacity 0.15s ease; - cursor: pointer; /* outline, not border: it paints outside the box so nothing is re-laid out */ &:hover, @@ -128,16 +136,49 @@ const Cell = styled.div<{ } `; +/** + * The row label gutter. `labelWidth` fixes the track, so the label has to clip + * rather than widen it — `min-width` because a grid item defaults to the width + * of its content and would otherwise refuse to shrink into the track. + */ +const RowLabel = styled(Box)` + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +`; + const defaultTooltip = ( - { row, column, value }: HeatmapCell, + { row, column, columnEnd, value }: HeatmapCell, formatValue: (value: T) => string, ) => ( {row.label} + {/* the whole slot, not the instant it opens: a cell read on its own says + nothing about whether it covers five minutes, an hour or a day */} - + + {columnEnd.getTime() > column.getTime() && ( + <> + {' to '} + {/* the end repeats the date whenever the slot changes day, or a + nightly slot reads as "31 Aug 23:00 to 00:00" and a daily one as + "31 Aug 00:00 to 00:00" — the same instant twice, apparently */} + {isSameCalendarDay(column, columnEnd) ? ( + + ) : ( + + )} + + )} {formatValue(value)} @@ -200,76 +241,87 @@ const HeatmapGrid = ({ ), formatValue = (value) => String(value), renderTooltip, -}: HeatmapGridProps) => ( - - {rows.map((row, rowIndex) => ( - - - - {row.label} - - +}: HeatmapGridProps) => { + // read off the axis once, not once per cell + const columnEnds = getColumnEnds(columns); - {/* driven by the columns, not by the cells: that is what keeps a short + return ( + + {rows.map((row, rowIndex) => ( + + + + {row.label} + + + + {/* driven by the columns, not by the cells: that is what keeps a short row from pulling the next row's label out of the gutter */} - {columns.map((column, columnIndex) => { - const value = row.cells[columnIndex] ?? null; - const key = `${row.label}-${rowIndex}-${columnIndex}`; - - if (value === null) { - return ; - } - - const cell = { row, column, columnIndex, value }; - const { color, opacity } = appearanceOf(value); - - return ( - - - - ); - })} - - ))} - - {/* x-axis: an empty gutter cell, then one slot per column */} - - {columns.map((column, columnIndex) => ( - - {columnIndex % labelEvery === 0 && ( - - {formatColumnTick(column)} - - )} - - ))} - -); + {columns.map((column, columnIndex) => { + const value = row.cells[columnIndex] ?? null; + const key = `${row.label}-${rowIndex}-${columnIndex}`; + + if (value === null) { + return ; + } + + const cell = { + row, + column, + columnEnd: columnEnds[columnIndex], + columnIndex, + value, + }; + const { color, opacity } = appearanceOf(value); + + return ( + + + + ); + })} + + ))} + + {/* x-axis: an empty gutter cell, then one slot per column */} + + {columns.map((column, columnIndex) => ( + + {columnIndex % labelEvery === 0 && ( + + {formatColumnTick(column)} + + )} + + ))} + + ); +}; const DiscreteHeatmap = ({ scale: _scale, diff --git a/src/lib/components/charts/heatmap/Heatmap.utils.test.ts b/src/lib/components/charts/heatmap/Heatmap.utils.test.ts index c8c12bcc90..24f9cda7ca 100644 --- a/src/lib/components/charts/heatmap/Heatmap.utils.test.ts +++ b/src/lib/components/charts/heatmap/Heatmap.utils.test.ts @@ -1,4 +1,5 @@ import { + getColumnEnds, getHeatmapMaxValue, getRampOpacity, DEFAULT_MIN_OPACITY, @@ -44,3 +45,37 @@ describe('getRampOpacity', () => { expect(getRampOpacity(0, 0, DEFAULT_MIN_OPACITY)).toBe(DEFAULT_MIN_OPACITY); }); }); + +describe('getColumnEnds', () => { + const at = (time: string) => new Date(`2026-08-25T${time}:00Z`); + + it('should end every column where the next one starts', () => { + expect(getColumnEnds([at('10:00'), at('10:05'), at('10:10')])).toEqual([ + at('10:05'), + at('10:10'), + at('10:15'), + ]); + }); + + it('should give the last column the gap that came before it', () => { + const [, , last] = getColumnEnds([at('10:00'), at('11:00'), at('12:00')]); + + expect(last).toEqual(at('13:00')); + }); + + it('should follow an irregular axis rather than assume a fixed step', () => { + expect(getColumnEnds([at('10:00'), at('10:05'), at('11:05')])).toEqual([ + at('10:05'), + at('11:05'), + at('12:05'), + ]); + }); + + it('should leave a single column without a duration to invent one from', () => { + expect(getColumnEnds([at('10:00')])).toEqual([at('10:00')]); + }); + + it('should hold on an empty axis', () => { + expect(getColumnEnds([])).toEqual([]); + }); +}); diff --git a/src/lib/components/charts/heatmap/Heatmap.utils.ts b/src/lib/components/charts/heatmap/Heatmap.utils.ts index 3879cd3dcc..a0947ac9c1 100644 --- a/src/lib/components/charts/heatmap/Heatmap.utils.ts +++ b/src/lib/components/charts/heatmap/Heatmap.utils.ts @@ -46,3 +46,39 @@ export const getRampOpacity = ( const ratio = Math.min(Math.max(value / max, 0), 1); return Math.round((minOpacity + (1 - minOpacity) * ratio) * 100) / 100; }; + +/** + * The end of every column's slot, read off the axis: a column lasts until the + * next one starts. That is what lets the tooltip name the slot — "03:30 to + * 04:00" — rather than the instant it opens, which on its own says nothing + * about whether a cell covers five minutes or a day. + * + * The last column has no next one, so it reuses the gap before it. A single + * column has no gap at all, and gets an end equal to its start rather than an + * invented duration — the tooltip reads that back as "no slot to show". + */ +export const getColumnEnds = (columns: Date[]): Date[] => + columns.map((column, index) => { + const next = columns[index + 1]; + if (next) { + return next; + } + + const previous = columns[index - 1]; + return new Date( + column.getTime() + (previous ? column.getTime() - previous.getTime() : 0), + ); + }); + +/** + * Whether two instants land on the same calendar day, in the reader's own time + * zone — the one the axis and the tooltip are already printed in. + * + * Not an elapsed-time question, which is why `getDateDaysDiff` cannot answer + * it: 23:00 and 00:00 are an hour apart and two different days, and it is the + * day the tooltip has to name. + */ +export const isSameCalendarDay = (a: Date, b: Date): boolean => + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate(); diff --git a/stories/Heatmap/heatmap.guideline.mdx b/stories/Heatmap/heatmap.guideline.mdx new file mode 100644 index 0000000000..005b9e5dac --- /dev/null +++ b/stories/Heatmap/heatmap.guideline.mdx @@ -0,0 +1,119 @@ +import { Meta, Canvas } from '@storybook/addon-docs/blocks'; +import * as HeatmapStories from './heatmap.stories'; + + + + + +# Heatmap + +A heatmap reads one metric across two axes at once: one row per entity, one column per time slot, +each cell coloured by its value. It answers "which of these, and when" in a single glance — which +service was degraded overnight, which node has been quiet all week. + + + +## When to use it + +Use a heatmap when the pattern matters more than the number: a shape across many entities and many +slots, where the reader is looking for a cluster, a gap or an outlier rather than a value. + +Use something else when: + +- **The exact values matter.** A table states them; a heatmap only implies them through colour, and + the reader has to hover every cell to recover what the table showed outright. +- **There is one entity, or a handful.** A line chart shows the trend, the magnitude and the + direction of change, all of which a row of coloured squares throws away. +- **There are two or three slots.** A grid that small is a status list wearing a grid's clothes. +- **The reader needs to act on a row.** Cells are not actionable and never will be — see below. + +## Accessibility: colour is the only channel + +This is the component's central limitation, and it has to be stated rather than discovered. + +In a heatmap, colour carries the information on its own. There is no shape, no label and no +position that distinguishes one value from another, so a reader who does not separate the hues does +not read the grid. The tooltip is what makes the component usable at all: every cell is focusable, +carries an `aria-label`, and opens its tooltip on focus as well as on hover, so the grid is +traversable from the keyboard and by a screen reader. + +That is an acceptable trade for this component, because a heatmap without colour is not a heatmap. +It is not an acceptable trade in general: + +- Never make a heatmap the **only** place a piece of information appears. Anything a user must act + on belongs somewhere a reader can find it without separating hues — a table, a list, an alert. +- Do not treat this pattern as a precedent. A new component that leans on colour alone needs its + own justification, not this page as cover. +- Keep the value set small. Four or five values is the ceiling at which distinct hues stay distinct; + past that, colours start to be told apart by their neighbours rather than on their own. + +## Colour sets + +Which palette applies follows from what the values mean, and the two cases do not mix. + +### Status values + +When the values are states of health, take the theme's status tokens — `statusHealthy`, +`statusWarning`, `statusCritical` — and a theme neutral for the absence of data. They are the only +colours that change with the theme, so a status grid stays legible in light and dark without +anything else being done. + +### Categorical values + +When the values are categories — job types, workload profiles, regions, response codes — take the +series colours (`lineColor1`…`lineColor8`, or `lineTimeSeriesColorRange`). Two rules: + +- **Never borrow the status palette for a category.** A value painted `statusHealthy` claims to be + healthy. A backup type is not healthy, and the reader who has learnt that colour elsewhere in the + product will read it that way here. +- **Never hardcode a hex.** A colour written into a page belongs to no theme and follows none. + + + +Whichever set applies, order the legend the way the values mean something — the pipeline's order, +the severity order, the numeric order — through `scale.sortOrder`. Alphabetical is the default and +is almost never what the reader is looking for. Where the stored value is not what a reader should +see — a raw code, an enum — `scale.labelMap` names it in the legend and `formatValue` does the same +for the tooltip and the `aria-label`. + +## Time + +**The axis** carries the start of each slot, thinned out by `labelEvery` so a dense grid stays +legible. Time of day is the default. Where a slot is a day or longer, pass `formatColumnTick` with +the `day-month-abbreviated` format — `25 Aug`, per the date guideline. Never a numeric month: +`09-02` is 2 September to half the product's users and 9 February to the other half. + +The axis says nothing when the day changes. A tick rolls from `23:00` to `00:00` and that is all — +the date is one hover away, and spelling it out on the axis costs more room than the change is +worth. + + + +**The tooltip** carries the whole slot, not the instant it opens: `25 Aug 03:30 to 04:00`. A cell +read on its own says nothing about whether it covers five minutes, an hour or a day, and the reader +should not have to measure it against the next tick. The component reads the duration off the axis, +so this is the default and needs nothing from the caller. + +## Empty and no-data states + +Two different absences, and they must not look alike. + +**No cell.** A `null` cell — or a row shorter than the axis — leaves its slot blank. Use it when the +entity did not exist in that slot: a node added on Tuesday has nothing to say about Monday. The grid +is driven by `columns` rather than by each row's cells, so a short row leaves the rest of its line +empty instead of shifting everything below it. + +**Collected nothing.** When the entity existed and the value is unknown — collection has not caught +up, the exporter was down — that is a value like any other. Give it a name in the `colorSet` and a +theme neutral, so it appears in the legend, can be filtered, and reads as "we do not know" rather +than as a hole in the grid. + + + +An entirely empty grid is not the component's business. A heatmap with no rows renders its axis and +nothing else; the surrounding page is what should say why. diff --git a/stories/Heatmap/heatmap.stories.tsx b/stories/Heatmap/heatmap.stories.tsx index e27edb3985..97a5f817d9 100644 --- a/stories/Heatmap/heatmap.stories.tsx +++ b/stories/Heatmap/heatmap.stories.tsx @@ -12,15 +12,18 @@ import { lineColor1, lineColor2, lineColor3, + lineColor4, + lineColor6, + lineColor7, } from '../../src/lib/style/theme'; /* -------------------------------------------------------------------------- */ /* DATA */ /* -------------------------------------------------------------------------- */ -type CellStatus = 'OK' | 'WARNING' | 'CRITICAL' | 'NONE'; +type CellStatus = 'Ok' | 'Warning' | 'Critical' | 'No data'; -const STATUS_ORDER: CellStatus[] = ['OK', 'WARNING', 'CRITICAL', 'NONE']; +const STATUS_ORDER: CellStatus[] = ['Ok', 'Warning', 'Critical', 'No data']; const MONITORING_SERVICES = [ 'Alertmanager', @@ -43,17 +46,17 @@ const noise = (a: number, b: number) => (a * 73 + b * 151 + a * b * 17) % 100; const buildStatusRows = ( labels: string[], columnCount: number, - /** Index from which the whole column is reported as NONE (no data yet). */ + /** Index from which the whole column is reported as 'No data' (not collected yet). */ noDataFrom = columnCount, ): HeatmapRow[] => labels.map((label, rowIndex) => ({ label, cells: Array.from({ length: columnCount }, (_, colIndex) => { - if (colIndex >= noDataFrom) return 'NONE' as CellStatus; + if (colIndex >= noDataFrom) return 'No data' as CellStatus; const value = noise(rowIndex + 1, colIndex + 1); - if (value < 7) return 'CRITICAL' as CellStatus; - if (value < 22) return 'WARNING' as CellStatus; - return 'OK' as CellStatus; + if (value < 7) return 'Critical' as CellStatus; + if (value < 22) return 'Warning' as CellStatus; + return 'Ok' as CellStatus; }), })); @@ -149,10 +152,10 @@ const useStatusScale = (): HeatmapDiscreteScale => { return { colorSet: { - OK: theme.statusHealthy, - WARNING: theme.statusWarning, - CRITICAL: theme.statusCritical, - NONE: theme.textSecondary, + Ok: theme.statusHealthy, + Warning: theme.statusWarning, + Critical: theme.statusCritical, + 'No data': theme.textSecondary, }, sortOrder: inDeclaredOrder(STATUS_ORDER), }; @@ -179,7 +182,7 @@ type GeneratedArgs = LayoutArgs & { entities: number; /** Columns — one per time slot on the x-axis. */ columns: number; - /** Trailing columns reported as NONE: the "collection has not caught up" tail. */ + /** Trailing columns reported as 'No data': the "collection has not caught up" tail. */ noDataColumns: number; }; @@ -238,8 +241,8 @@ const entityLabels = (count: number) => /** * The interactive one: edit the grid itself. * - * `rows` is a real control — add a row, rename one, or change any cell to OK, - * WARNING, CRITICAL or NONE and the grid follows. The x-axis is derived from the + * `rows` is a real control — add a row, rename one, or change any cell to Ok, + * Warning, Critical or 'No data' and the grid follows. The x-axis is derived from the * longest row, so adding cells adds columns; a row with fewer cells leaves the * rest of its line empty rather than shifting anything. */ @@ -249,20 +252,23 @@ export const Playground: StoryObj = { rows: { control: 'object', description: - 'One entry per row: { label, cells }. A cell is OK | WARNING | CRITICAL | NONE', + "One entry per row: { label, cells }. A cell is Ok | Warning | Critical | 'No data'", }, }, args: { ...layoutArgs, rows: [ - { label: 'Alertmanager', cells: ['OK', 'OK', 'WARNING', 'OK', 'NONE'] }, - { label: 'Grafana', cells: ['OK', 'OK', 'OK', 'OK', 'NONE'] }, + { + label: 'Alertmanager', + cells: ['Ok', 'Ok', 'Warning', 'Ok', 'No data'], + }, + { label: 'Grafana', cells: ['Ok', 'Ok', 'Ok', 'Ok', 'No data'] }, { label: 'Prometheus', - cells: ['WARNING', 'CRITICAL', 'CRITICAL', 'OK', 'NONE'], + cells: ['Warning', 'Critical', 'Critical', 'Ok', 'No data'], }, - { label: 'Supervisor', cells: ['OK', 'OK', 'OK', 'OK', 'NONE'] }, - { label: 'Thanos', cells: ['OK', 'WARNING', 'OK', 'OK', 'NONE'] }, + { label: 'Supervisor', cells: ['Ok', 'Ok', 'Ok', 'Ok', 'No data'] }, + { label: 'Thanos', cells: ['Ok', 'Warning', 'Ok', 'Ok', 'No data'] }, ], }, render: (args) => { @@ -305,7 +311,7 @@ export const ScreenshotEquivalent: StoryObj = { scale={scale} rows={MONITORING_SERVICES.map((label) => ({ label, - cells: ['OK', 'OK', 'OK', 'NONE'] as CellStatus[], + cells: ['Ok', 'Ok', 'Ok', 'No data'] as CellStatus[], }))} columns={buildTimeSlots( new Date('2026-08-25T10:30:00Z'), @@ -329,7 +335,7 @@ export const ServiceStatusOverOneHour: StoryObj = { return ( = { return ( = { }, }; -/** Continuous values instead of statuses: opacity ramp + gradient scale. */ +/** + * Continuous values instead of statuses: opacity ramp + gradient scale. + * + * `pinnedToHundred` is what the top of the ramp is worth. This grid peaks + * around 60 %, so leaving `max` to the data burns the whole ramp on the range + * the data happens to occupy and the busiest node reads as fully saturated — + * true of this chart, and a lie next to another one whose peak is 20 %. Pinning + * `max: 100` spends the ramp on the scale the unit actually has, so two grids + * side by side mean the same thing. Flip the control and watch both the cells + * and the number at the top of the gradient move. + */ export const NumericValues: StoryObj< - Omit & { minOpacity: number } + Omit & { + minOpacity: number; + pinnedToHundred: boolean; + } > = { argTypes: { ...layoutArgTypes, @@ -407,6 +426,11 @@ export const NumericValues: StoryObj< control: { type: 'range', min: 0, max: 0.6, step: 0.05 }, description: 'Opacity floor, so the low values stay visible', }, + pinnedToHundred: { + control: 'boolean', + description: + 'Top of the ramp: 100 % (comparable between charts) or the largest value in the data', + }, }, args: { ...layoutArgs, @@ -415,6 +439,7 @@ export const NumericValues: StoryObj< labelEvery: 3, labelWidth: '9rem', minOpacity: 0.1, + pinnedToHundred: true, }, render: (args) => { const theme = useTheme() as CoreUITheme; @@ -422,17 +447,21 @@ export const NumericValues: StoryObj< return ( ({ label, + // a load that peaks around 60 %, so pinning the top to 100 is + // visible rather than a change of one percent cells: Array.from({ length: args.columns }, (_, colIndex) => - Math.round(noise(rowIndex + 3, colIndex + 5)), + Math.round(noise(rowIndex + 3, colIndex + 5) * 0.6), ), }))} columns={buildTimeSlots(DAY_START, args.columns, ONE_HOUR)} @@ -446,9 +475,10 @@ export const NumericValues: StoryObj< /** * The colors are the caller's, and so are the values. Five backup outcomes, - * none of them a health status, painted from the chart palette and the theme's - * own tokens side by side — `colorSet` takes any CSS color, wherever it comes - * from. + * none of them a health status, and so none of them painted from the status + * tokens: a categorical scale takes the theme's series colors, and leaves + * `statusHealthy` and `statusCritical` to mean health where health is what is + * being shown. * * `sortOrder` is what keeps the legend in pipeline order rather than * alphabetical, so the rare outcomes stay at the bottom where they are looked @@ -457,29 +487,62 @@ export const NumericValues: StoryObj< export const CustomColorSet: StoryObj = { argTypes: layoutArgTypes, args: { ...layoutArgs, labelEvery: 2, labelWidth: '9rem' }, + render: (args) => ( + + ( + + )} + {...layoutProps(args)} + /> + + ), +}; + +/** + * Discrete does not mean three states of health. Here the values are workload + * profiles, colored from the series palette because that is what a categorical + * scale is for, and the grid behaves exactly the same: click *Write-heavy* in + * the legend and every other slot dims, leaving the write bursts alone on the + * timeline. + */ +export const NonStatusValues: StoryObj = { + argTypes: layoutArgTypes, + args: { ...layoutArgs, labelEvery: 3, labelWidth: '10rem', cellGap: 2 }, render: (args) => { const theme = useTheme() as CoreUITheme; return ( - + ( - - )} + rows={buildCategoryRows(BUCKETS, 24, WORKLOAD_PROFILES)} + columns={buildTimeSlots(DAY_START, 24, ONE_HOUR)} {...layoutProps(args)} /> @@ -487,37 +550,6 @@ export const CustomColorSet: StoryObj = { }, }; -/** - * Discrete does not mean three states of health. Here the values are workload - * profiles in four hand-picked hex colors that belong to no theme at all, and - * the grid behaves exactly the same: click *Write-heavy* in the legend and - * every other slot dims, leaving the write bursts alone on the timeline. - */ -export const NonStatusValues: StoryObj = { - argTypes: layoutArgTypes, - args: { ...layoutArgs, labelEvery: 3, labelWidth: '10rem', cellGap: 2 }, - render: (args) => ( - - - - ), -}; - /** * When the values in the data are not what a reader should see: the cells hold * bare response codes, `labelMap` spells them out in the legend, `formatValue` @@ -540,7 +572,7 @@ export const LabelledValues: StoryObj = { return ( = { ); }, }; + +/** + * The axis crosses midnight, which no other story does. The tick rolls from + * 23:00 to 00:00 and says nothing else about the day changing — the date is one + * hover away in the tooltip, and a date on the axis would cost more room than + * the change is worth. That is a decision rather than an oversight, which is + * why it has a story. + */ +export const AcrossMidnight: StoryObj = { + argTypes: layoutArgTypes, + args: { ...layoutArgs, labelEvery: 1 }, + render: (args) => { + const scale = useStatusScale(); + + return ( + + + + ); + }, +}; From 7267bcf15d2a6f54a548aa7502278c5b8391366b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Do=20Rosario?= Date: Mon, 14 Sep 2026 15:03:31 +0200 Subject: [PATCH 04/12] improvement(charts): response codes take the series palette too The Labelled Values story still painted 200 with statusHealthy, 403 with statusWarning and 500 with statusCritical, which is the crossing the rest of the review already removed everywhere else. A response code looks like a health status and is not one. A 403 is the server working exactly as asked; a 500 in one endpoint's row says nothing about whether the product is degraded. Painting them with the status tokens makes every red cell in the product claim the same thing whether it holds or not, and that claim is the whole value of those three colours where they do apply. Four series colours instead, none of them crimson, so the reading order of the legend comes from sortOrder comparing the codes as numbers rather than from a hue quietly ranking them. --- stories/Heatmap/heatmap.stories.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/stories/Heatmap/heatmap.stories.tsx b/stories/Heatmap/heatmap.stories.tsx index 97a5f817d9..bd077777ef 100644 --- a/stories/Heatmap/heatmap.stories.tsx +++ b/stories/Heatmap/heatmap.stories.tsx @@ -556,12 +556,16 @@ export const NonStatusValues: StoryObj = { * does the same for the tooltip and the cell's `aria-label`, and `sortOrder` * compares them as the numbers they are rather than as the strings they arrive * as. + * + * The colors are the series palette, not the status tokens. A response code + * looks like a health status and is not one — a 403 is the server working + * correctly — and painting 500 with `statusCritical` would have every red cell + * in the product mean the same thing whether it does or not. */ export const LabelledValues: StoryObj = { argTypes: layoutArgTypes, args: { ...layoutArgs, labelEvery: 3, labelWidth: '9rem' }, render: (args) => { - const theme = useTheme() as CoreUITheme; const labelMap = { '200': '200 OK', '206': '206 Partial Content', @@ -576,10 +580,10 @@ export const LabelledValues: StoryObj = { legendTitle="HTTP status" scale={{ colorSet: { - '200': theme.statusHealthy, + '200': lineColor3, '206': lineColor1, - '403': theme.statusWarning, - '500': theme.statusCritical, + '403': lineColor2, + '500': lineColor7, }, labelMap, sortOrder: (a, b) => Number(a) - Number(b), From 1542adbc0606454859ca769d1872ff5999207f27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Do=20Rosario?= Date: Tue, 15 Sep 2026 10:55:18 +0200 Subject: [PATCH 05/12] improvement(charts): drop the Heatmap continuous scale, derive the axis tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The continuous scale goes. No product needs it today, and a published prop nobody exercises still has to be documented, tested and supported. The day intensity is actually needed, the use case will say whether it wants a ramp or a few steps, which is a better place to decide it from than this one. Out with it: HeatmapGradientScale, HeatmapContinuousScale, ContinuousHeatmapProps, getHeatmapMaxValue, getRampOpacity, DEFAULT_MIN_OPACITY, the Numeric Values story and the exports of all of it. Two open questions close with it — pinning the top of a percentage ramp to 100, and the gradient bar starting at minOpacity while its bottom label said min, so it showed shades no cell would ever use. What is left is simpler than what it replaced. HeatmapProps stops being a union, so Heatmap has neither a branch nor the casts that branch needed, and the value type narrows from string | number to string — a heatmap value is a name, and HeatmapRow still lets a caller say which names. formatValue keeps its test: it was only ever covered through the continuous scale, and it is a discrete prop too. The x-axis now works out its own tick format. It was hardcoded to the time of day and ignored the step of the axis, so a daily grid printed "00:00" over every one of its columns unless the caller passed formatColumnTick. The slot duration is already known from getColumnEnds, so the default reads it: time of day below a day, the abbreviated date from a day up. The date guideline then lives in the component rather than in every caller, which is why the guideline page no longer states it. That guideline page is Cuervino's rewrite, kept whole. It adds the two things mine was missing. First, what a cell hides: it is an aggregate, worst-value-wins and average and last-value produce three different grids from the same data, and the reader cannot tell which rule made the picture — so the page has to ask for it to be stated. Second, the name, since a heatmap in UX research is clicks or gaze painted over a screenshot, an unrelated object that shares the word. Titles go to sentence case throughout, because examples are what people copy and the rest of the charts in that file already were. --- .../charts/heatmap/Heatmap.test.tsx | 85 ++++------ src/lib/components/charts/heatmap/Heatmap.tsx | 142 +++++----------- .../charts/heatmap/Heatmap.utils.test.ts | 94 +++++------ .../charts/heatmap/Heatmap.utils.ts | 58 ++----- .../charts/heatmap/HeatmapGradientScale.tsx | 70 -------- src/lib/components/charts/index.ts | 5 - src/lib/next.ts | 5 - stories/Heatmap/heatmap.guideline.mdx | 159 ++++++++++-------- stories/Heatmap/heatmap.stories.tsx | 90 +--------- 9 files changed, 229 insertions(+), 479 deletions(-) delete mode 100644 src/lib/components/charts/heatmap/HeatmapGradientScale.tsx diff --git a/src/lib/components/charts/heatmap/Heatmap.test.tsx b/src/lib/components/charts/heatmap/Heatmap.test.tsx index 186dff9a07..b9f353707a 100644 --- a/src/lib/components/charts/heatmap/Heatmap.test.tsx +++ b/src/lib/components/charts/heatmap/Heatmap.test.tsx @@ -159,61 +159,15 @@ describe('Heatmap', () => { }); }); - describe('continuous scale', () => { - const numericRows: HeatmapRow[] = [ - { label: 'cpu', cells: [0, 50, 100] }, - ]; - - const renderNumericHeatmap = ( - scale: Partial<{ max: number; minOpacity: number }> = {}, - props = {}, - ) => { - const { Wrapper } = getWrapper(); - - return render( - , - { wrapper: Wrapper }, - ); - }; - - it('should ramp the opacity from the floor at 0 to 1 at the max', () => { - renderNumericHeatmap({ minOpacity: 0.2 }); - - expect(screen.getByLabelText('cpu 0')).toHaveStyle('opacity: 0.2'); - expect(screen.getByLabelText('cpu 50')).toHaveStyle('opacity: 0.6'); - expect(screen.getByLabelText('cpu 100')).toHaveStyle('opacity: 1'); - expect(screen.getByLabelText('cpu 100')).toHaveStyle( - 'background-color: rgb(10,173,166)', - ); - }); - - it('should ramp against a pinned max rather than the data', () => { - renderNumericHeatmap({ max: 200, minOpacity: 0 }); - - expect(screen.getByLabelText('cpu 100')).toHaveStyle('opacity: 0.5'); - }); - - it('should state the domain it ramped against beside the grid', () => { - renderNumericHeatmap(); - - expect(screen.getByText('%')).toBeInTheDocument(); - expect(screen.getByText('100')).toBeInTheDocument(); - expect(screen.getByText('0')).toBeInTheDocument(); - }); - - it('should spell the value out through formatValue', () => { - renderNumericHeatmap( - {}, - { formatValue: (value: number) => `${value} %` }, - ); + describe('formatValue', () => { + it('should spell the value out in the aria-label', () => { + renderStatusHeatmap({ + formatValue: (value: string) => `status ${value.toLowerCase()}`, + }); - expect(screen.getByLabelText('cpu 100 %')).toBeInTheDocument(); + expect( + screen.getByLabelText('Prometheus status warning'), + ).toBeInTheDocument(); }); }); @@ -302,6 +256,29 @@ describe('Heatmap', () => { }); describe('x-axis', () => { + it('should label a daily axis by date rather than by time of day', () => { + renderStatusHeatmap({ + columns: [ + new Date('2026-08-25T00:00:00Z'), + new Date('2026-08-26T00:00:00Z'), + new Date('2026-08-27T00:00:00Z'), + ], + }); + + // by time of day the three columns would all read "00:00" + expect(screen.getByText('25 Aug')).toBeInTheDocument(); + expect(screen.getByText('26 Aug')).toBeInTheDocument(); + expect(screen.getByText('27 Aug')).toBeInTheDocument(); + }); + + it('should let the caller override the default tick', () => { + renderStatusHeatmap({ + formatColumnTick: (column: Date) => `slot ${column.getUTCMinutes()}`, + }); + + expect(screen.getByText('slot 5')).toBeInTheDocument(); + }); + it('should thin the ticks out with labelEvery', () => { const tickCount = () => screen.getAllByText(/^\d{2}:\d{2}$/, { exact: false }).length; diff --git a/src/lib/components/charts/heatmap/Heatmap.tsx b/src/lib/components/charts/heatmap/Heatmap.tsx index 891efb44f3..a6c182f131 100644 --- a/src/lib/components/charts/heatmap/Heatmap.tsx +++ b/src/lib/components/charts/heatmap/Heatmap.tsx @@ -12,18 +12,15 @@ import { useChartId, useChartLegend, } from '../legend/ChartLegendWrapper'; -import { HeatmapGradientScale } from './HeatmapGradientScale'; import { - DEFAULT_MIN_OPACITY, DIMMED_CELL_OPACITY, getColumnEnds, - getHeatmapMaxValue, - getRampOpacity, + isDailyOrLongerSlot, isSameCalendarDay, } from './Heatmap.utils'; /** One line of the grid: a label in the gutter, then one cell per column. */ -export type HeatmapRow = { +export type HeatmapRow = { label: string; /** * Read positionally against `columns`: cell `i` sits under column `i`. `null` @@ -34,7 +31,7 @@ export type HeatmapRow = { }; /** What the tooltip and the value formatter are handed for one cell. */ -export type HeatmapCell = { +export type HeatmapCell = { row: HeatmapRow; /** When the slot opens — the column it sits under. */ column: Date; @@ -65,32 +62,17 @@ export type HeatmapDiscreteScale = { labelMap?: ChartLegendWrapperProps['labelMap']; }; -/** Continuous values — one color, ramped by opacity from `minOpacity` to 1. */ -export type HeatmapContinuousScale = { - type: 'continuous'; - /** - * The color to ramp, as an RGB triple: `theme.statusHealthyRGB` and its - * siblings are exactly that, `'10,173,166'`. - */ - colorRGB: string; - /** Value mapped to full opacity. Defaults to the largest value in `rows`. */ - max?: number; - /** Opacity of the value 0, so the low end stays visible. Defaults to 0.1. */ - minOpacity?: number; -}; - -type HeatmapBaseProps = { +type HeatmapBaseProps = { rows: HeatmapRow[]; /** The x-axis. It defines the columns: a row is padded or truncated to fit. */ columns: Date[]; /** Heading above the grid. */ title?: ReactNode; - /** Heading above the legend — what the colors mean, or the unit they ramp. */ + /** Heading above the legend — what the colors mean. */ legendTitle?: ReactNode; /** * Hide the legend, for a heatmap whose scale is stated elsewhere: several - * grids under one shared legend, a ramp beside its own - * `HeatmapGradientScale`. + * grids standing under one shared legend. */ showLegend?: boolean; /** Show one x-axis tick every N columns, to keep a dense axis legible. */ @@ -99,7 +81,11 @@ type HeatmapBaseProps = { cellGap?: string; /** The row label gutter. Labels truncate rather than widen it. */ labelWidth?: string; - /** How a column is spelled out on the x-axis. Defaults to the time of day. */ + /** + * How a column is spelled out on the x-axis. The default reads the slot + * duration off the axis: the time of day below a day, the abbreviated date + * from a day up. + */ formatColumnTick?: (column: Date) => ReactNode; /** How a value is spelled out, in the default tooltip and in `aria-label`. */ formatValue?: (value: T) => string; @@ -111,11 +97,7 @@ export type DiscreteHeatmapProps = HeatmapBaseProps & { scale?: HeatmapDiscreteScale; }; -export type ContinuousHeatmapProps = HeatmapBaseProps & { - scale: HeatmapContinuousScale; -}; - -export type HeatmapProps = DiscreteHeatmapProps | ContinuousHeatmapProps; +export type HeatmapProps = DiscreteHeatmapProps; const Cell = styled.div<{ $color: string; @@ -148,7 +130,7 @@ const RowLabel = styled(Box)` text-overflow: ellipsis; `; -const defaultTooltip = ( +const defaultTooltip = ( { row, column, columnEnd, value }: HeatmapCell, formatValue: (value: T) => string, ) => ( @@ -223,12 +205,12 @@ const LegendColumn = ({ title }: { title?: ReactNode }) => ( ); -type HeatmapGridProps = HeatmapBaseProps & { +type HeatmapGridProps = HeatmapBaseProps & { /** How one value is painted. The only thing the two scales disagree on. */ appearanceOf: (value: T) => { color: string; opacity: number }; }; -const HeatmapGrid = ({ +const HeatmapGrid = ({ rows, columns, appearanceOf, @@ -236,9 +218,7 @@ const HeatmapGrid = ({ cellHeight = spacing.f20, cellGap = spacing.f4, labelWidth = '7rem', - formatColumnTick = (column) => ( - - ), + formatColumnTick, formatValue = (value) => String(value), renderTooltip, }: HeatmapGridProps) => { @@ -314,7 +294,20 @@ const HeatmapGrid = ({ {columnIndex % labelEvery === 0 && ( - {formatColumnTick(column)} + {formatColumnTick ? ( + formatColumnTick(column) + ) : ( + /* the axis says what it is about: a daily grid labelled by the + time of day prints "00:00" over every one of its columns */ + + )} )} @@ -390,80 +383,29 @@ const DiscreteHeatmap = ({ ); }; -const ContinuousHeatmap = ({ - scale, - title, - legendTitle, - showLegend = true, - ...gridProps -}: ContinuousHeatmapProps) => { - const { colorRGB, minOpacity = DEFAULT_MIN_OPACITY } = scale; - const max = scale.max ?? getHeatmapMaxValue(gridProps.rows); - - const appearanceOf = useCallback( - (value: number) => ({ - color: `rgb(${colorRGB})`, - opacity: getRampOpacity(value, max, minOpacity), - }), - [colorRGB, max, minOpacity], - ); - - return ( - - ) : undefined - } - > - - - ); -}; - /** - * A grid of one metric read over time: one row per entity, one column per time - * slot, each cell colored by its value and describing itself on hover or focus. - * Title, grid, x-axis and legend all belong to the component. + * A grid of one metric read across two dimensions: one row per entity, one + * column per slot, each cell colored by its value and describing itself on + * hover or focus. Title, grid, x-axis and legend all belong to the component. * - * Two scales, and they type the data with them. `discrete` — the default — - * colors named values through a legend that also filters the grid; - * `continuous` ramps one color by opacity beside a gradient scale. + * The values are names — a status, a state, any small set — and the legend is + * where they get their color and their meaning. Clicking a legend item filters + * the grid. * * ```tsx * * ``` */ -export const Heatmap = (props: HeatmapProps) => { - /** - * One implementation per scale rather than one branch inside it, because the - * discrete scale reads the legend context — a hook, so it cannot be called - * conditionally. The casts are the one thing TypeScript will not do for us: - * it narrows on a top-level discriminant, not on `scale.type` one level down. - */ - if (props.scale?.type === 'continuous') { - return ; - } - - const { scale, ...discreteProps } = props as DiscreteHeatmapProps; - +export const Heatmap = ({ scale, ...gridProps }: HeatmapProps) => { // no colorSet: a ChartLegendWrapper the caller owns is holding the colors if (!scale?.colorSet) { - return ; + return ; } return ( @@ -472,7 +414,7 @@ export const Heatmap = (props: HeatmapProps) => { sortOrder={scale.sortOrder} labelMap={scale.labelMap} > - + ); }; diff --git a/src/lib/components/charts/heatmap/Heatmap.utils.test.ts b/src/lib/components/charts/heatmap/Heatmap.utils.test.ts index 24f9cda7ca..c51f76949e 100644 --- a/src/lib/components/charts/heatmap/Heatmap.utils.test.ts +++ b/src/lib/components/charts/heatmap/Heatmap.utils.test.ts @@ -1,50 +1,4 @@ -import { - getColumnEnds, - getHeatmapMaxValue, - getRampOpacity, - DEFAULT_MIN_OPACITY, -} from './Heatmap.utils'; - -describe('getHeatmapMaxValue', () => { - it('should return the largest cell of the whole grid', () => { - expect( - getHeatmapMaxValue([{ cells: [1, 9, 3] }, { cells: [4, 12, 0] }]), - ).toBe(12); - }); - - it('should ignore the empty cells', () => { - expect(getHeatmapMaxValue([{ cells: [null, 5, null] }])).toBe(5); - }); - - it('should return 0 when there is no data at all', () => { - expect(getHeatmapMaxValue([])).toBe(0); - expect(getHeatmapMaxValue([{ cells: [] }, { cells: [null] }])).toBe(0); - }); -}); - -describe('getRampOpacity', () => { - it('should give the floor to the value 0 and full opacity to the max', () => { - expect(getRampOpacity(0, 100, 0.1)).toBe(0.1); - expect(getRampOpacity(100, 100, 0.1)).toBe(1); - }); - - it('should interpolate between the floor and full opacity', () => { - expect(getRampOpacity(50, 100, 0.2)).toBe(0.6); - }); - - it('should round, so a dense grid does not mint a class per cell', () => { - expect(getRampOpacity(1, 3, 0)).toBe(0.33); - }); - - it('should clamp values outside the domain instead of overshooting', () => { - expect(getRampOpacity(150, 100, 0.1)).toBe(1); - expect(getRampOpacity(-20, 100, 0.1)).toBe(0.1); - }); - - it('should fall back to the floor on a degenerate domain', () => { - expect(getRampOpacity(0, 0, DEFAULT_MIN_OPACITY)).toBe(DEFAULT_MIN_OPACITY); - }); -}); +import { getColumnEnds, isDailyOrLongerSlot } from './Heatmap.utils'; describe('getColumnEnds', () => { const at = (time: string) => new Date(`2026-08-25T${time}:00Z`); @@ -79,3 +33,49 @@ describe('getColumnEnds', () => { expect(getColumnEnds([])).toEqual([]); }); }); + +describe('isDailyOrLongerSlot', () => { + const at = (iso: string) => new Date(iso); + + it('should call a slot shorter than a day a time-of-day slot', () => { + expect( + isDailyOrLongerSlot( + at('2026-08-25T10:00:00Z'), + at('2026-08-25T10:05:00Z'), + ), + ).toBe(false); + expect( + isDailyOrLongerSlot( + at('2026-08-25T00:00:00Z'), + at('2026-08-25T23:00:00Z'), + ), + ).toBe(false); + }); + + it('should call a slot of exactly a day a dated one', () => { + expect( + isDailyOrLongerSlot( + at('2026-08-25T00:00:00Z'), + at('2026-08-26T00:00:00Z'), + ), + ).toBe(true); + }); + + it('should call anything longer a dated one', () => { + expect( + isDailyOrLongerSlot( + at('2026-08-25T00:00:00Z'), + at('2026-09-01T00:00:00Z'), + ), + ).toBe(true); + }); + + it('should not call a slot with no duration a dated one', () => { + expect( + isDailyOrLongerSlot( + at('2026-08-25T10:00:00Z'), + at('2026-08-25T10:00:00Z'), + ), + ).toBe(false); + }); +}); diff --git a/src/lib/components/charts/heatmap/Heatmap.utils.ts b/src/lib/components/charts/heatmap/Heatmap.utils.ts index a0947ac9c1..20186939eb 100644 --- a/src/lib/components/charts/heatmap/Heatmap.utils.ts +++ b/src/lib/components/charts/heatmap/Heatmap.utils.ts @@ -1,52 +1,6 @@ /** Opacity of a cell whose series has been filtered out through the legend. */ export const DIMMED_CELL_OPACITY = 0.15; -/** - * Opacity given to the value 0 on a continuous scale, so the low end of the - * ramp stays visible instead of dissolving into the background. - */ -export const DEFAULT_MIN_OPACITY = 0.1; - -/** - * Largest value in the grid, ignoring the empty cells — 0 when there is none. - * A continuous heatmap uses it as the top of its ramp unless the caller pins - * `max` itself, which is what a fixed domain (a percentage, a quota) wants. - */ -export const getHeatmapMaxValue = ( - rows: { cells: (number | null)[] }[], -): number => - rows.reduce( - (max, row) => - row.cells.reduce( - (rowMax, cell) => (cell === null ? rowMax : Math.max(rowMax, cell)), - max, - ), - 0, - ); - -/** - * Where `value` sits on the ramp, as an opacity between `minOpacity` and 1. - * Values outside [0, max] are clamped, so an outlier — or a value the caller - * pinned a smaller `max` than — cannot push a cell past full opacity. - * - * Rounded to two decimals, which is past the eye's resolution and bounds the - * number of distinct values: a dense grid then generates a hundred styled - * classes at worst, not one per cell. - */ -export const getRampOpacity = ( - value: number, - max: number, - minOpacity: number, -): number => { - // Every value is 0, or the domain is degenerate: the ramp has nothing to say. - if (max <= 0) { - return minOpacity; - } - - const ratio = Math.min(Math.max(value / max, 0), 1); - return Math.round((minOpacity + (1 - minOpacity) * ratio) * 100) / 100; -}; - /** * The end of every column's slot, read off the axis: a column lasts until the * next one starts. That is what lets the tooltip name the slot — "03:30 to @@ -82,3 +36,15 @@ export const isSameCalendarDay = (a: Date, b: Date): boolean => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); + +/** One day, the threshold at which an axis stops being about the time of day. */ +const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; + +/** + * Whether a slot covers a day or more, which is what decides how the x-axis + * spells a column out: below a day the time of day is what tells two columns + * apart, from a day up it is the date, and a daily axis labelled by time reads + * as "00:00" repeated all the way across. + */ +export const isDailyOrLongerSlot = (start: Date, end: Date): boolean => + end.getTime() - start.getTime() >= ONE_DAY_IN_MS; diff --git a/src/lib/components/charts/heatmap/HeatmapGradientScale.tsx b/src/lib/components/charts/heatmap/HeatmapGradientScale.tsx deleted file mode 100644 index 5b84eae723..0000000000 --- a/src/lib/components/charts/heatmap/HeatmapGradientScale.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { ReactNode } from 'react'; -import { Box } from '../../box/Box'; -import { spacing, Stack } from '../../../spacing'; -import { Text } from '../../text/Text.component'; -import { DEFAULT_MIN_OPACITY } from './Heatmap.utils'; - -export type HeatmapGradientScaleProps = { - /** The ramped color, same RGB triple the heatmap's continuous scale got. */ - colorRGB: string; - /** Top of the domain — `getHeatmapMaxValue(rows)` when the data sets it. */ - max: number; - min?: number; - minOpacity?: number; - /** What is being measured: a unit, a metric name. */ - label?: ReactNode; - height?: string; - formatValue?: (value: number) => ReactNode; -}; - -/** - * The legend of a continuous `Heatmap`: `ChartLegend` enumerates discrete - * series, a ramp has no items to enumerate — only its two ends. - * - * Placed by the caller, like `ChartLegend`, so the same component serves a - * heatmap standing beside its scale and one sharing a scale with its siblings. - * It takes the domain rather than the data: the numbers it prints have to be - * the ones the grid ramped against, so they come from the same place. - */ -export const HeatmapGradientScale = ({ - colorRGB, - max, - min = 0, - minOpacity = DEFAULT_MIN_OPACITY, - label, - height = '6rem', - formatValue = (value) => String(value), -}: HeatmapGradientScaleProps) => ( - - {label !== undefined && ( - - {label} - - )} - - - {/* the same height as the bar, so the two ends line up with the ramp - rather than collapsing to the height of the two labels */} - - - {formatValue(max)} - - - {formatValue(min)} - - - - -); diff --git a/src/lib/components/charts/index.ts b/src/lib/components/charts/index.ts index 1c77d17eee..6dcdafacb8 100644 --- a/src/lib/components/charts/index.ts +++ b/src/lib/components/charts/index.ts @@ -24,15 +24,10 @@ export { Heatmap } from './heatmap/Heatmap'; export type { HeatmapProps, DiscreteHeatmapProps, - ContinuousHeatmapProps, HeatmapRow, HeatmapCell, HeatmapDiscreteScale, - HeatmapContinuousScale, } from './heatmap/Heatmap'; -export { HeatmapGradientScale } from './heatmap/HeatmapGradientScale'; -export type { HeatmapGradientScaleProps } from './heatmap/HeatmapGradientScale'; -export { getHeatmapMaxValue } from './heatmap/Heatmap.utils'; // Legend export { ChartLegend } from './legend/ChartLegend'; diff --git a/src/lib/next.ts b/src/lib/next.ts index 45f4213d31..7fd9330c29 100644 --- a/src/lib/next.ts +++ b/src/lib/next.ts @@ -31,8 +31,6 @@ export { GlobalHealthBar, Sparkline, Heatmap, - HeatmapGradientScale, - getHeatmapMaxValue, ChartLegend, ChartLegendWrapper, useChartId, @@ -53,12 +51,9 @@ export type { GlobalHealthProps, HeatmapProps, DiscreteHeatmapProps, - ContinuousHeatmapProps, HeatmapRow, HeatmapCell, HeatmapDiscreteScale, - HeatmapContinuousScale, - HeatmapGradientScaleProps, Alert, UnitRange, TimeType, diff --git a/stories/Heatmap/heatmap.guideline.mdx b/stories/Heatmap/heatmap.guideline.mdx index 005b9e5dac..3a02528bcb 100644 --- a/stories/Heatmap/heatmap.guideline.mdx +++ b/stories/Heatmap/heatmap.guideline.mdx @@ -10,110 +10,127 @@ import * as HeatmapStories from './heatmap.stories'; .sbdocs-content .sbdocs-preview { margin-bottom: 0.5rem; } `} -# Heatmap +# Heatmap chart -A heatmap reads one metric across two axes at once: one row per entity, one column per time slot, -each cell coloured by its value. It answers "which of these, and when" in a single glance — which -service was degraded overnight, which node has been quiet all week. +A heatmap chart is a matrix. One row per entity, one column per slice of a +second dimension, and a colour at each crossing standing for the value there. + +That second dimension is time in most cases, and status history is the case this +component was built for: one column per time slot, the colour saying how each +entity was doing then. Time is not a requirement. Any second dimension with few +enough values works. +A note on the name: in UX research, a heatmap is a different object, clicks, +scrolls or gaze painted over a screenshot of a page. Both are called heatmaps +and they have nothing to do with each other. Here it should be understood as +"heatmap chart". + ## When to use it -Use a heatmap when the pattern matters more than the number: a shape across many entities and many -slots, where the reader is looking for a cluster, a gap or an outlier rather than a value. +Use it when the pattern matters more than the number, and when there are enough +rows and enough columns **for a pattern to exist**. Use something else when: -- **The exact values matter.** A table states them; a heatmap only implies them through colour, and - the reader has to hover every cell to recover what the table showed outright. -- **There is one entity, or a handful.** A line chart shows the trend, the magnitude and the - direction of change, all of which a row of coloured squares throws away. -- **There are two or three slots.** A grid that small is a status list wearing a grid's clothes. -- **The reader needs to act on a row.** Cells are not actionable and never will be — see below. +- The exact values matter. A table states them, a heatmap only implies them + through colour. +- There is one entity, or a handful. A line chart shows the trend and the + magnitude, which a row of coloured squares throws away. +- There are two or three columns. That is a status list, not a grid. -## Accessibility: colour is the only channel +## Every cell is an aggregate -This is the component's central limitation, and it has to be stated rather than discovered. +A cell covers a slice and says one thing about it, so something was dropped on +the way. The rule that dropped it decides what the reader sees, and the same +data under two rules produces two different grids. -In a heatmap, colour carries the information on its own. There is no shape, no label and no -position that distinguishes one value from another, so a reader who does not separate the hues does -not read the grid. The tooltip is what makes the component usable at all: every cell is focusable, -carries an `aria-label`, and opens its tooltip on focus as well as on hover, so the grid is -traversable from the keyboard and by a screen reader. +- **Worst value wins.** One minute of outage colours the whole hour. Nothing is + missed, but a brief incident and a sustained one look alike. +- **Average.** Brief incidents disappear into the slots around them. +- **Last value.** The grid shows the state at the end of each slot and says + nothing about what happened inside it. -That is an acceptable trade for this component, because a heatmap without colour is not a heatmap. -It is not an acceptable trade in general: +Worst value wins is the usual choice for status history, because missing an +outage is worse than overstating one. It is still a choice, and one the reader +cannot recover from the picture. Say which rule applies, in the legend title or +beside the chart. -- Never make a heatmap the **only** place a piece of information appears. Anything a user must act - on belongs somewhere a reader can find it without separating hues — a table, a list, an alert. -- Do not treat this pattern as a precedent. A new component that leans on colour alone needs its - own justification, not this page as cover. -- Keep the value set small. Four or five values is the ceiling at which distinct hues stay distinct; - past that, colours start to be told apart by their neighbours rather than on their own. +The aggregation happens before the component. `Heatmap` paints the values it is +given and never computes them, so this is a decision for the page, not a prop. -## Colour sets +Slot width is part of the same decision. Narrower slots aggregate less and show +more, up to the point where the grid stops being readable. Anything that lets a +user change the window has to re-aggregate, and the answer on screen changes +with it. -Which palette applies follows from what the values mean, and the two cases do not mix. +## Accessibility: colour is the only channel -### Status values +Colour carries the value on its own. No shape, no label and no position tells +one cell from another, so the grid stays closed to a reader who does not +separate the hues. The tooltip is what makes it usable: every cell takes focus, +carries an `aria-label`, and opens its tooltip on focus as well as on hover, so +the grid can be read from the keyboard and by a screen reader. -When the values are states of health, take the theme's status tokens — `statusHealthy`, -`statusWarning`, `statusCritical` — and a theme neutral for the absence of data. They are the only -colours that change with the theme, so a status grid stays legible in light and dark without -anything else being done. +That trade is acceptable here, because a heatmap chart without colour is not a +heatmap chart. It is not acceptable as a habit: -### Categorical values +- Pick colours that stay apart from one another, including for the most common + colour vision deficiencies. Two neighbouring hues in the same set are the + first thing to check. +- Keep the value set small. Four or five values is the ceiling at which distinct + hues stay distinct. +- Do not treat this page as a precedent for the next component that leans on + colour alone. -When the values are categories — job types, workload profiles, regions, response codes — take the -series colours (`lineColor1`…`lineColor8`, or `lineTimeSeriesColorRange`). Two rules: +## Colour sets -- **Never borrow the status palette for a category.** A value painted `statusHealthy` claims to be - healthy. A backup type is not healthy, and the reader who has learnt that colour elsewhere in the - product will read it that way here. -- **Never hardcode a hex.** A colour written into a page belongs to no theme and follows none. +Which palette applies follows from what the values mean, and the two cases do +not mix. + +**Status values**, meaning states of health, take the theme status tokens +(`statusHealthy`, `statusWarning`, `statusCritical`) and a theme neutral for the +absence of data. They follow the theme, so a status grid holds in light and +dark with nothing else to do. + +**Categorical values**, meaning anything else, take the series colours +(`lineColor1` to `lineColor8`, or `lineTimeSeriesColorRange`). -Whichever set applies, order the legend the way the values mean something — the pipeline's order, -the severity order, the numeric order — through `scale.sortOrder`. Alphabetical is the default and -is almost never what the reader is looking for. Where the stored value is not what a reader should -see — a raw code, an enum — `scale.labelMap` names it in the legend and `formatValue` does the same -for the tooltip and the `aria-label`. +Two rules hold in both cases. Never borrow the status palette for a category: a +value painted `statusHealthy` claims to be healthy, and a reader who learnt that +colour elsewhere in the product will read it the same way here. Avoid hardcoded +hex values, they belong to no theme and follow none. + +Order the legend the way the values mean something, through `scale.sortOrder`. +Alphabetical is the default and is rarely what the reader expects. Where the +stored value is not what should be shown, `scale.labelMap` renames it in the +legend and `formatValue` does the same for the tooltip and the `aria-label`. ## Time -**The axis** carries the start of each slot, thinned out by `labelEvery` so a dense grid stays -legible. Time of day is the default. Where a slot is a day or longer, pass `formatColumnTick` with -the `day-month-abbreviated` format — `25 Aug`, per the date guideline. Never a numeric month: -`09-02` is 2 September to half the product's users and 9 February to the other half. +The axis carries the start of each slot, thinned out by `labelEvery` so a dense +axis stays legible. -The axis says nothing when the day changes. A tick rolls from `23:00` to `00:00` and that is all — -the date is one hover away, and spelling it out on the axis costs more room than the change is -worth. +The axis does not mark the change of day. A slot that opens a new day carries +the same kind of label as the others, and the date is only in the tooltip. -**The tooltip** carries the whole slot, not the instant it opens: `25 Aug 03:30 to 04:00`. A cell -read on its own says nothing about whether it covers five minutes, an hour or a day, and the reader -should not have to measure it against the next tick. The component reads the duration off the axis, -so this is the default and needs nothing from the caller. +## Empty and no data -## Empty and no-data states +Two cases have to be distinguished: a slot where the entity had nothing to +report, and a slot where a value exists but was not collected. -Two different absences, and they must not look alike. +**No cell.** A `null` cell, or a row shorter than the axis, leaves its slot +empty. It applies when the entity did not exist during that slot. The grid +follows `columns` rather than the length of each row, so a short row keeps its +place and the rows below it are not shifted. -**No cell.** A `null` cell — or a row shorter than the axis — leaves its slot blank. Use it when the -entity did not exist in that slot: a node added on Tuesday has nothing to say about Monday. The grid -is driven by `columns` rather than by each row's cells, so a short row leaves the rest of its line -empty instead of shifting everything below it. - -**Collected nothing.** When the entity existed and the value is unknown — collection has not caught -up, the exporter was down — that is a value like any other. Give it a name in the `colorSet` and a -theme neutral, so it appears in the legend, can be filtered, and reads as "we do not know" rather -than as a hole in the grid. +**No value collected.** When the entity existed but its value is unknown, it is +treated as a value of its own. Declare it in the `colorSet` with a theme +neutral, so that it appears in the legend and can be filtered like the others. - -An entirely empty grid is not the component's business. A heatmap with no rows renders its axis and -nothing else; the surrounding page is what should say why. diff --git a/stories/Heatmap/heatmap.stories.tsx b/stories/Heatmap/heatmap.stories.tsx index bd077777ef..4156857c76 100644 --- a/stories/Heatmap/heatmap.stories.tsx +++ b/stories/Heatmap/heatmap.stories.tsx @@ -281,8 +281,8 @@ export const Playground: StoryObj = { return ( = { return ( ({ label, @@ -335,8 +335,8 @@ export const ServiceStatusOverOneHour: StoryObj = { return ( = { = { }, }; -/** - * Continuous values instead of statuses: opacity ramp + gradient scale. - * - * `pinnedToHundred` is what the top of the ramp is worth. This grid peaks - * around 60 %, so leaving `max` to the data burns the whole ramp on the range - * the data happens to occupy and the busiest node reads as fully saturated — - * true of this chart, and a lie next to another one whose peak is 20 %. Pinning - * `max: 100` spends the ramp on the scale the unit actually has, so two grids - * side by side mean the same thing. Flip the control and watch both the cells - * and the number at the top of the gradient move. - */ -export const NumericValues: StoryObj< - Omit & { - minOpacity: number; - pinnedToHundred: boolean; - } -> = { - argTypes: { - ...layoutArgTypes, - entities: { control: { type: 'range', min: 1, max: 24, step: 1 } }, - columns: { control: { type: 'range', min: 2, max: 48, step: 1 } }, - minOpacity: { - control: { type: 'range', min: 0, max: 0.6, step: 0.05 }, - description: 'Opacity floor, so the low values stay visible', - }, - pinnedToHundred: { - control: 'boolean', - description: - 'Top of the ramp: 100 % (comparable between charts) or the largest value in the data', - }, - }, - args: { - ...layoutArgs, - entities: 6, - columns: 24, - labelEvery: 3, - labelWidth: '9rem', - minOpacity: 0.1, - pinnedToHundred: true, - }, - render: (args) => { - const theme = useTheme() as CoreUITheme; - - return ( - - ({ - label, - // a load that peaks around 60 %, so pinning the top to 100 is - // visible rather than a change of one percent - cells: Array.from({ length: args.columns }, (_, colIndex) => - Math.round(noise(rowIndex + 3, colIndex + 5) * 0.6), - ), - }))} - columns={buildTimeSlots(DAY_START, args.columns, ONE_HOUR)} - formatValue={(value: number) => `${value} %`} - {...layoutProps(args)} - /> - - ); - }, -}; - /** * The colors are the caller's, and so are the values. Five backup outcomes, * none of them a health status, and so none of them painted from the status @@ -614,8 +542,8 @@ export const AcrossMidnight: StoryObj = { return ( Date: Tue, 15 Sep 2026 16:48:53 +0200 Subject: [PATCH 06/12] improvement(charts): tighten the Heatmap props, and let a long axis scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things Jean-Marc asked for. labelWidth was typed string and interpolated into grid-template-columns, where a length CSS cannot parse does not fail alone — it invalidates the whole declaration, repeat() included, and the grid collapses. It is a CSS length now, and it no longer goes near the template: the gutter cell carries its own width, so a bad value costs one label. cellHeight, cellGap and cellMinWidth take the same type, and each says what it expects and what it defaults to. sortOrder and labelMap are read only alongside colorSet — without one there is no legend of ours to order, and the ChartLegendWrapper the caller put above owns it. That was true and unwritten; it is written on both props now. A cell announced itself as its row and its value, leaving a screen reader to count columns to find out when it happened. It carries its slot too, from the same formatSlot the tooltip uses, so the two cannot drift. And the grid says it is one: role="grid", rows, gridcells — the empty ones included, or the columns stop lining up for anyone stepping through them. Then the part that was not asked for as such. Columns share the width available, so a long axis in a narrow frame kept dividing until the cells were slivers a couple of pixels wide: present, aligned, and saying nothing. cellMinWidth is a floor, 12px by default, and past it the grid scrolls sideways instead. Scrolling is what made the rest of the layout move. The labels sit outside the scrolling area rather than pinned inside it, which is worth spelling out because pinning is the obvious thing to try and it costs three problems: something opaque to paint, therefore a background the component has to be told about, therefore a hover ring bleeding past the box that paints it. Outside, they simply do not move, and the scrollbar covers the tiles alone rather than promising that the labels move too. Rows became subgrid to keep the two columns level — the scroller takes the rows it is placed in rather than sizing its own — and overflow-y is hidden, because a horizontal scrollbar shortens the box by just enough to summon a vertical one. The row labels lost role="rowheader" on the way out of the grid: a rowheader has to be owned by the row holding its cells. Each row carries an aria-label instead, each cell still names its row, and the visible column is aria-hidden so none of it is announced twice. --- .../charts/heatmap/Heatmap.test.tsx | 110 ++++-- src/lib/components/charts/heatmap/Heatmap.tsx | 326 +++++++++++------- .../charts/heatmap/Heatmap.utils.test.ts | 34 +- .../charts/heatmap/Heatmap.utils.ts | 59 +++- src/lib/components/charts/index.ts | 1 + src/lib/next.ts | 1 + stories/Heatmap/heatmap.stories.tsx | 118 +++++-- 7 files changed, 443 insertions(+), 206 deletions(-) diff --git a/src/lib/components/charts/heatmap/Heatmap.test.tsx b/src/lib/components/charts/heatmap/Heatmap.test.tsx index b9f353707a..7103f0ac2d 100644 --- a/src/lib/components/charts/heatmap/Heatmap.test.tsx +++ b/src/lib/components/charts/heatmap/Heatmap.test.tsx @@ -81,7 +81,7 @@ describe('Heatmap', () => { expect(screen.queryByText('Service Status')).not.toBeInTheDocument(); expect(screen.queryByLabelText('OK selected')).not.toBeInTheDocument(); // the grid itself is untouched - expect(screen.getAllByRole('img')).toHaveLength(6); + expect(screen.getAllByRole('gridcell')).toHaveLength(6); }); it('should read a ChartLegendWrapper the caller owns when given no colorSet', () => { @@ -94,9 +94,9 @@ describe('Heatmap', () => { { wrapper: Wrapper }, ); - expect(screen.getByLabelText('Prometheus WARNING')).toHaveStyle( - 'background-color: rgb(255, 165, 0)', - ); + expect( + screen.getByLabelText('Prometheus, 25 Aug 10:05 to 10:10, WARNING'), + ).toHaveStyle('background-color: rgb(255, 165, 0)'); }); }); @@ -106,19 +106,21 @@ describe('Heatmap', () => { expect(screen.getByText('Prometheus')).toBeInTheDocument(); expect(screen.getByText('Grafana')).toBeInTheDocument(); - expect(screen.getAllByRole('img')).toHaveLength(6); - expect(screen.getByLabelText('Prometheus WARNING')).toBeInTheDocument(); + expect(screen.getAllByRole('gridcell')).toHaveLength(6); + expect( + screen.getByLabelText('Prometheus, 25 Aug 10:05 to 10:10, WARNING'), + ).toBeInTheDocument(); }); it('should color a cell with the color the legend holds for its value', () => { renderStatusHeatmap(); - expect(screen.getByLabelText('Prometheus WARNING')).toHaveStyle( - 'background-color: rgb(255, 165, 0)', - ); - expect(screen.getAllByLabelText('Grafana OK')[0]).toHaveStyle( - 'background-color: rgb(0, 128, 0)', - ); + expect( + screen.getByLabelText('Prometheus, 25 Aug 10:05 to 10:10, WARNING'), + ).toHaveStyle('background-color: rgb(255, 165, 0)'); + expect( + screen.getByLabelText('Grafana, 25 Aug 10:00 to 10:05, OK'), + ).toHaveStyle('background-color: rgb(0, 128, 0)'); }); it('should register its values, so a colorSet function is told what to color', () => { @@ -134,12 +136,12 @@ describe('Heatmap', () => { // clicking a legend item selects it alone userEvent.click(screen.getByText('OK')); - expect(screen.getByLabelText('Prometheus WARNING')).toHaveStyle( - `opacity: ${DIMMED_CELL_OPACITY}`, - ); - expect(screen.getAllByLabelText('Grafana OK')[0]).toHaveStyle( - 'opacity: 1', - ); + expect( + screen.getByLabelText('Prometheus, 25 Aug 10:05 to 10:10, WARNING'), + ).toHaveStyle(`opacity: ${DIMMED_CELL_OPACITY}`); + expect( + screen.getByLabelText('Grafana, 25 Aug 10:00 to 10:05, OK'), + ).toHaveStyle('opacity: 1'); }); }); @@ -153,9 +155,11 @@ describe('Heatmap', () => { ], }); - expect(screen.getAllByRole('img')).toHaveLength(3); - expect(screen.getAllByLabelText('Short OK')).toHaveLength(1); - expect(screen.getAllByLabelText('Holed OK')).toHaveLength(2); + // every slot is a cell of its row, painted or not, so the columns stay + // aligned for anyone stepping through them + expect(screen.getAllByRole('gridcell')).toHaveLength(6); + expect(screen.getAllByLabelText(/^Short, /)).toHaveLength(1); + expect(screen.getAllByLabelText(/^Holed, /)).toHaveLength(2); }); }); @@ -166,7 +170,37 @@ describe('Heatmap', () => { }); expect( - screen.getByLabelText('Prometheus status warning'), + screen.getByLabelText( + 'Prometheus, 25 Aug 10:05 to 10:10, status warning', + ), + ).toBeInTheDocument(); + }); + }); + + describe('assistive structure', () => { + it('should expose the grid as named rows of cells', () => { + renderStatusHeatmap(); + + expect(screen.getByRole('grid')).toBeInTheDocument(); + // two rows of data, plus the x-axis + expect(screen.getAllByRole('row')).toHaveLength(3); + // the visible label column stands outside the grid, so the row carries + // the name rather than a screen reader hearing it from both + expect( + screen.getByRole('row', { name: 'Prometheus' }), + ).toBeInTheDocument(); + expect(screen.getAllByRole('columnheader')).toHaveLength(columns.length); + }); + + it('should announce a cell with its row, its slot and its value', () => { + renderStatusHeatmap(); + + // the slot is the part a screen reader cannot get from anywhere else: + // without it the columns have to be counted to find out when this was + expect( + screen.getByRole('gridcell', { + name: 'Prometheus, 25 Aug 10:05 to 10:10, WARNING', + }), ).toBeInTheDocument(); }); }); @@ -175,7 +209,11 @@ describe('Heatmap', () => { it('should describe the cell on focus, so it is reachable from the keyboard', () => { renderStatusHeatmap(); - act(() => screen.getByLabelText('Prometheus WARNING').focus()); + act(() => + screen + .getByLabelText('Prometheus, 25 Aug 10:05 to 10:10, WARNING') + .focus(), + ); const overlay = document.querySelector('.sc-tooltip-overlay'); expect(overlay).not.toBeNull(); @@ -186,7 +224,11 @@ describe('Heatmap', () => { it('should name the whole slot, not the instant the column opens', () => { renderStatusHeatmap(); - act(() => screen.getByLabelText('Prometheus WARNING').focus()); + act(() => + screen + .getByLabelText('Prometheus, 25 Aug 10:05 to 10:10, WARNING') + .focus(), + ); // the axis is five-minute slots, and the cell has to say so on its own expect(document.querySelector('.sc-tooltip-overlay')).toHaveTextContent( @@ -203,7 +245,11 @@ describe('Heatmap', () => { rows: [{ label: 'Prometheus', cells: ['OK', 'OK'] }], }); - act(() => screen.getAllByLabelText('Prometheus OK')[0].focus()); + act(() => + screen + .getByLabelText('Prometheus, 31 Aug 23:00 to 01 Sep 00:00, OK') + .focus(), + ); // without the date the slot would read "31 Aug 23:00 to 00:00" expect(document.querySelector('.sc-tooltip-overlay')).toHaveTextContent( @@ -220,7 +266,11 @@ describe('Heatmap', () => { rows: [{ label: 'Prometheus', cells: ['OK', 'OK'] }], }); - act(() => screen.getAllByLabelText('Prometheus OK')[0].focus()); + act(() => + screen + .getByLabelText('Prometheus, 31 Aug 00:00 to 01 Sep 00:00, OK') + .focus(), + ); // the end is midnight too, so only the date tells the two apart expect(document.querySelector('.sc-tooltip-overlay')).toHaveTextContent( @@ -234,7 +284,7 @@ describe('Heatmap', () => { rows: [{ label: 'Prometheus', cells: ['OK'] }], }); - act(() => screen.getByLabelText('Prometheus OK').focus()); + act(() => screen.getByLabelText('Prometheus, 25 Aug 10:00, OK').focus()); const overlay = document.querySelector('.sc-tooltip-overlay'); expect(overlay).toHaveTextContent('25 Aug 10:00'); @@ -247,7 +297,11 @@ describe('Heatmap', () => { `${row.label} at column ${columnIndex}`, }); - act(() => screen.getByLabelText('Prometheus WARNING').focus()); + act(() => + screen + .getByLabelText('Prometheus, 25 Aug 10:05 to 10:10, WARNING') + .focus(), + ); expect(document.querySelector('.sc-tooltip-overlay')).toHaveTextContent( 'Prometheus at column 1', diff --git a/src/lib/components/charts/heatmap/Heatmap.tsx b/src/lib/components/charts/heatmap/Heatmap.tsx index a6c182f131..34c13d1305 100644 --- a/src/lib/components/charts/heatmap/Heatmap.tsx +++ b/src/lib/components/charts/heatmap/Heatmap.tsx @@ -1,11 +1,13 @@ import React, { ReactNode, useCallback, useEffect, useMemo } from 'react'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { Box } from '../../box/Box'; import { spacing, Stack } from '../../../spacing'; import { Text } from '../../text/Text.component'; +import { ConstrainedText } from '../../constrainedtext/Constrainedtext.component'; import { Tooltip } from '../../tooltip/Tooltip.component'; import { FormattedDateTime } from '../../date/FormattedDateTime'; import { ChartLegend } from '../legend/ChartLegend'; +import { CoreUITheme } from '../../../style/theme'; import { ChartLegendWrapper, ChartLegendWrapperProps, @@ -14,9 +16,9 @@ import { } from '../legend/ChartLegendWrapper'; import { DIMMED_CELL_OPACITY, + formatSlot, getColumnEnds, isDailyOrLongerSlot, - isSameCalendarDay, } from './Heatmap.utils'; /** One line of the grid: a label in the gutter, then one cell per column. */ @@ -37,19 +39,14 @@ export type HeatmapCell = { column: Date; /** * When the slot closes: the next column's start, or one axis step past the - * last column. Equal to `column` on a single-column axis, which has no step - * to read a duration from. + * last. Equal to `column` on a single-column axis, which has no step to read. */ columnEnd: Date; columnIndex: number; value: T; }; -/** - * Discrete values — a status, a state, any small set of names. The legend is - * the single source of truth: it colors the cells, and clicking an item filters - * the grid. - */ +/** Discrete values — a status, a state, any small set of names. */ export type HeatmapDiscreteScale = { type?: 'discrete'; /** @@ -57,11 +54,25 @@ export type HeatmapDiscreteScale = { * put above instead — which is how several charts come to share one legend. */ colorSet?: ChartLegendWrapperProps['colorSet']; + /** + * Order of the legend items. Read only alongside `colorSet`: without one the + * `ChartLegendWrapper` the caller put above owns the ordering, and this is + * ignored. + */ sortOrder?: ChartLegendWrapperProps['sortOrder']; - /** Display labels for the legend items, when a value is not its own label. */ + /** + * Display labels for the legend items, when a value is not its own label. + * Read only alongside `colorSet`, for the same reason as `sortOrder`. + */ labelMap?: ChartLegendWrapperProps['labelMap']; }; +/** + * A CSS length, rather than any string: these are interpolated into a grid + * template, where a value CSS cannot parse drops the whole declaration. + */ +export type HeatmapLength = `${number}${'px' | 'rem' | 'em' | '%'}`; + type HeatmapBaseProps = { rows: HeatmapRow[]; /** The x-axis. It defines the columns: a row is padded or truncated to fit. */ @@ -77,10 +88,26 @@ type HeatmapBaseProps = { showLegend?: boolean; /** Show one x-axis tick every N columns, to keep a dense axis legible. */ labelEvery?: number; - cellHeight?: string; - cellGap?: string; - /** The row label gutter. Labels truncate rather than widen it. */ - labelWidth?: string; + /** Height of one cell. Defaults to `20px`. */ + cellHeight?: HeatmapLength; + /** + * Space between cells, both ways. Defaults to `4px`; at `0px` the grid reads + * as a continuous timeline rather than as a row of squares. + */ + cellGap?: HeatmapLength; + /** + * Width of the row label gutter. Defaults to `7rem`. Labels truncate rather + * than widen it, so a gutter too narrow costs the end of a label and never + * the alignment of the grid. + */ + labelWidth?: HeatmapLength; + /** + * Smallest a cell may become. Defaults to `12px`. Columns share the width + * available, so without a floor a long axis on a narrow screen divides into + * slivers. At the floor the grid scrolls horizontally instead; `0px` removes + * it and the grid always fits its container. + */ + cellMinWidth?: HeatmapLength; /** * How a column is spelled out on the x-axis. The default reads the slot * duration off the axis: the time of day below a day, the abbreviated date @@ -99,6 +126,9 @@ export type DiscreteHeatmapProps = HeatmapBaseProps & { export type HeatmapProps = DiscreteHeatmapProps; +const FOCUS_RING_OFFSET = spacing.f1; +const FOCUS_RING_WIDTH = spacing.f2; + const Cell = styled.div<{ $color: string; $opacity: number; @@ -113,21 +143,32 @@ const Cell = styled.div<{ /* outline, not border: it paints outside the box so nothing is re-laid out */ &:hover, &:focus-visible { - outline: ${spacing.f2} solid ${({ theme }) => theme.selectedActive}; - outline-offset: ${spacing.f1}; + outline: ${FOCUS_RING_WIDTH} solid ${({ theme }) => theme.selectedActive}; + outline-offset: ${FOCUS_RING_OFFSET}; } `; /** - * The row label gutter. `labelWidth` fixes the track, so the label has to clip - * rather than widen it — `min-width` because a grid item defaults to the width - * of its content and would otherwise refuse to shrink into the track. + * A row's label, outside the scroller so it holds still while the tiles move. + * It carries its own width rather than handing it to `grid-template-columns`, + * so a bad length costs one label instead of the whole template. `min-width` + * because a grid item otherwise refuses to shrink into its track. */ -const RowLabel = styled(Box)` +const GutterCell = styled(Box)` + box-sizing: border-box; min-width: 0; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; +`; + +/** + * A row of the grid, for assistive technology and for layout at once: `subgrid` + * makes it a real box spanning every column while its tracks stay the + * scroller's, so columns line up across rows without a template of their own. + */ +const GridRow = styled.div` + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + align-items: center; `; const defaultTooltip = ( @@ -141,26 +182,7 @@ const defaultTooltip = ( {/* the whole slot, not the instant it opens: a cell read on its own says nothing about whether it covers five minutes, an hour or a day */} - - {columnEnd.getTime() > column.getTime() && ( - <> - {' to '} - {/* the end repeats the date whenever the slot changes day, or a - nightly slot reads as "31 Aug 23:00 to 00:00" and a daily one as - "31 Aug 00:00 to 00:00" — the same instant twice, apparently */} - {isSameCalendarDay(column, columnEnd) ? ( - - ) : ( - - )} - - )} + {formatSlot(column, columnEnd)} {formatValue(value)} @@ -215,103 +237,156 @@ const HeatmapGrid = ({ columns, appearanceOf, labelEvery = 1, - cellHeight = spacing.f20, - cellGap = spacing.f4, + /* `spacing` is not declared `as const`, so its members are `string` and have + to be told they are the lengths they visibly are */ + cellHeight = spacing.f20 as HeatmapLength, + cellGap = spacing.f4 as HeatmapLength, labelWidth = '7rem', + cellMinWidth = spacing.f12 as HeatmapLength, formatColumnTick, formatValue = (value) => String(value), renderTooltip, }: HeatmapGridProps) => { // read off the axis once, not once per cell const columnEnds = getColumnEnds(columns); + const columnCount = Math.max(columns.length, 1); return ( + /* Two columns: the labels, then everything that scrolls. Keeping the labels + out of the scroller is what makes the scrollbar cover the tiles alone, + and saves painting anything opaque for the tiles to pass under. The + trailing row is the scrollbar's own room, which it otherwise takes from + the x-axis. */ {rows.map((row, rowIndex) => ( - - - - {row.label} - - - - {/* driven by the columns, not by the cells: that is what keeps a short - row from pulling the next row's label out of the gutter */} - {columns.map((column, columnIndex) => { - const value = row.cells[columnIndex] ?? null; - const key = `${row.label}-${rowIndex}-${columnIndex}`; + + ))} - if (value === null) { - return ; - } + {/* `subgrid` keeps the two columns level: the scroller takes the rows it + is placed in rather than sizing its own, so a label cannot drift from + the tiles it names. `overflow-y: hidden` because a horizontal + scrollbar shortens the box enough to summon a vertical one. */} + + {rows.map((row, rowIndex) => ( + + {/* driven by the columns, not by the cells: a short row must not + pull the next one out of line */} + {columns.map((column, columnIndex) => { + const value = row.cells[columnIndex] ?? null; + const key = `${row.label}-${rowIndex}-${columnIndex}`; - const cell = { - row, - column, - columnEnd: columnEnds[columnIndex], - columnIndex, - value, - }; - const { color, opacity } = appearanceOf(value); + // still a cell of the row, so the columns keep lining up + if (value === null) { + return ; + } - return ( - - - - ); - })} - - ))} + const cell = { + row, + column, + columnEnd: columnEnds[columnIndex], + columnIndex, + value, + }; + const { color, opacity } = appearanceOf(value); - {/* x-axis: an empty gutter cell, then one slot per column */} - - {columns.map((column, columnIndex) => ( - - {columnIndex % labelEvery === 0 && ( - - {formatColumnTick ? ( - formatColumnTick(column) - ) : ( - /* the axis says what it is about: a daily grid labelled by the - time of day prints "00:00" over every one of its columns */ - + > + + + ); + })} + + ))} + + + {columns.map((column, columnIndex) => ( + + {columnIndex % labelEvery === 0 && ( + + {formatColumnTick ? ( + formatColumnTick(column) + ) : ( + /* a daily axis labelled by time of day prints "00:00" over + every column */ + + )} + )} - - )} - - ))} + + ))} + + ); }; @@ -328,9 +403,9 @@ const DiscreteHeatmap = ({ const { getColor, isSelected, register } = useChartLegend(); /** - * Keyed on the *content* of the series, not on the identity of `rows`: a - * caller rebuilding its rows on every render — every story here does — must - * not re-register, since registering re-renders the wrapper above us. + * Keyed on the *content* of the series, not the identity of `rows`: a caller + * rebuilding its rows every render must not re-register, since registering + * re-renders the wrapper above us. */ const seriesKey = useMemo( () => @@ -356,9 +431,7 @@ const DiscreteHeatmap = ({ /** * Resolved once per distinct value, not once per cell: a dense grid asks the - * same four questions hundreds of times, and `getColor` warns each time it - * has no answer — which it does on the first render of a `colorSet` function, - * before our registration above has reached it. + * same four questions hundreds of times, and `getColor` warns on each miss. */ const colorOfValue = useMemo( () => new Map(seriesNames.map((name) => [name, getColor(name)])), @@ -388,9 +461,8 @@ const DiscreteHeatmap = ({ * column per slot, each cell colored by its value and describing itself on * hover or focus. Title, grid, x-axis and legend all belong to the component. * - * The values are names — a status, a state, any small set — and the legend is - * where they get their color and their meaning. Clicking a legend item filters - * the grid. + * Values are names, and the legend is where they get their color; clicking one + * filters the grid. * * ```tsx * { const at = (time: string) => new Date(`2026-08-25T${time}:00Z`); @@ -79,3 +83,31 @@ describe('isDailyOrLongerSlot', () => { ).toBe(false); }); }); + +describe('formatSlot', () => { + const at = (iso: string) => new Date(iso); + + it('should name the slot from its start to its end', () => { + expect( + formatSlot(at('2026-08-25T10:05:00Z'), at('2026-08-25T10:10:00Z')), + ).toBe('25 Aug 10:05 to 10:10'); + }); + + it('should repeat the date when the slot runs into the next day', () => { + expect( + formatSlot(at('2026-08-31T23:00:00Z'), at('2026-09-01T00:00:00Z')), + ).toBe('31 Aug 23:00 to 01 Sep 00:00'); + }); + + it('should keep a whole-day slot from reading as one instant twice', () => { + expect( + formatSlot(at('2026-08-31T00:00:00Z'), at('2026-09-01T00:00:00Z')), + ).toBe('31 Aug 00:00 to 01 Sep 00:00'); + }); + + it('should give the start alone when the slot has no duration', () => { + expect( + formatSlot(at('2026-08-25T10:00:00Z'), at('2026-08-25T10:00:00Z')), + ).toBe('25 Aug 10:00'); + }); +}); diff --git a/src/lib/components/charts/heatmap/Heatmap.utils.ts b/src/lib/components/charts/heatmap/Heatmap.utils.ts index 20186939eb..09b2eacd4a 100644 --- a/src/lib/components/charts/heatmap/Heatmap.utils.ts +++ b/src/lib/components/charts/heatmap/Heatmap.utils.ts @@ -1,15 +1,15 @@ +import { + DAY_MONTH_ABBREVIATED_HOUR_MINUTE, + TIME_FORMATER, +} from '../../date/FormattedDateTime'; + /** Opacity of a cell whose series has been filtered out through the legend. */ export const DIMMED_CELL_OPACITY = 0.15; /** * The end of every column's slot, read off the axis: a column lasts until the - * next one starts. That is what lets the tooltip name the slot — "03:30 to - * 04:00" — rather than the instant it opens, which on its own says nothing - * about whether a cell covers five minutes or a day. - * - * The last column has no next one, so it reuses the gap before it. A single - * column has no gap at all, and gets an end equal to its start rather than an - * invented duration — the tooltip reads that back as "no slot to show". + * next one starts. The last reuses the gap before it; a single column has no + * gap to read one from, so its end is its start. */ export const getColumnEnds = (columns: Date[]): Date[] => columns.map((column, index) => { @@ -25,12 +25,9 @@ export const getColumnEnds = (columns: Date[]): Date[] => }); /** - * Whether two instants land on the same calendar day, in the reader's own time - * zone — the one the axis and the tooltip are already printed in. - * - * Not an elapsed-time question, which is why `getDateDaysDiff` cannot answer - * it: 23:00 and 00:00 are an hour apart and two different days, and it is the - * day the tooltip has to name. + * Whether two instants land on the same calendar day, in the reader's time + * zone. Not an elapsed-time question, which is why `getDateDaysDiff` cannot + * answer it: 23:00 and 00:00 are an hour apart and two different days. */ export const isSameCalendarDay = (a: Date, b: Date): boolean => a.getFullYear() === b.getFullYear() && @@ -41,10 +38,38 @@ export const isSameCalendarDay = (a: Date, b: Date): boolean => const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; /** - * Whether a slot covers a day or more, which is what decides how the x-axis - * spells a column out: below a day the time of day is what tells two columns - * apart, from a day up it is the date, and a daily axis labelled by time reads - * as "00:00" repeated all the way across. + * Whether a slot covers a day or more, which decides how the x-axis spells a + * column out: a daily axis labelled by time of day reads "00:00" all the way + * across. */ export const isDailyOrLongerSlot = (start: Date, end: Date): boolean => end.getTime() - start.getTime() >= ONE_DAY_IN_MS; + +/** The locale comma is not wanted between the date and the time it precedes. */ +const dayMonthHourMinute = (value: Date): string => + DAY_MONTH_ABBREVIATED_HOUR_MINUTE.format(value) + .replace(',', '') + .replace(/Sept/g, 'Sep'); + +/** + * A slot as one sentence — "25 Aug 10:05 to 10:10". The end repeats the date + * when the slot changes day, or a nightly one would read "23:00 to 00:00": the + * same instant twice, as far as the reader can tell. A slot with no duration is + * its start alone. + * + * The tooltip and the cell's `aria-label` both come from here, so they cannot + * drift apart. + */ +export const formatSlot = (start: Date, end: Date): string => { + const from = dayMonthHourMinute(start); + + if (end.getTime() <= start.getTime()) { + return from; + } + + return `${from} to ${ + isSameCalendarDay(start, end) + ? TIME_FORMATER.format(end) + : dayMonthHourMinute(end) + }`; +}; diff --git a/src/lib/components/charts/index.ts b/src/lib/components/charts/index.ts index 6dcdafacb8..7698ffa3f9 100644 --- a/src/lib/components/charts/index.ts +++ b/src/lib/components/charts/index.ts @@ -27,6 +27,7 @@ export type { HeatmapRow, HeatmapCell, HeatmapDiscreteScale, + HeatmapLength, } from './heatmap/Heatmap'; // Legend diff --git a/src/lib/next.ts b/src/lib/next.ts index 7fd9330c29..a7992855f3 100644 --- a/src/lib/next.ts +++ b/src/lib/next.ts @@ -54,6 +54,7 @@ export type { HeatmapRow, HeatmapCell, HeatmapDiscreteScale, + HeatmapLength, Alert, UnitRange, TimeType, diff --git a/stories/Heatmap/heatmap.stories.tsx b/stories/Heatmap/heatmap.stories.tsx index 4156857c76..bfd85791b0 100644 --- a/stories/Heatmap/heatmap.stories.tsx +++ b/stories/Heatmap/heatmap.stories.tsx @@ -4,6 +4,8 @@ import { Box, Heatmap, HeatmapDiscreteScale, + HeatmapLength, + HeatmapProps, HeatmapRow, } from '../../src/lib/next'; import { FormattedDateTime } from '../../src/lib/components/date/FormattedDateTime'; @@ -119,12 +121,9 @@ const pickWeighted = (values: readonly T[], draw: number): T => { }; /** - * Rows over any value set: the generated stories differ in what their values - * are called, not in how the grid is filled. - * - * Each value is half as frequent as the one before it, so a generated grid has - * a dominant value and a rare tail — an even wash would hide which color means - * what, which is the only thing these stories are about. + * Rows over any value set. Each value is half as frequent as the one before it, + * so a grid has a dominant value and a rare tail rather than an even wash that + * would hide which color means what. */ const buildCategoryRows = ( labels: string[], @@ -170,7 +169,7 @@ type LayoutArgs = { cellHeight: number; cellGap: number; labelEvery: number; - labelWidth: string; + labelWidth: HeatmapLength; }; /** Stories whose grid is typed in by hand. The data is the source of truth. */ @@ -213,7 +212,14 @@ const layoutArgs: LayoutArgs = { labelWidth: '7rem', }; -const layoutProps = (args: LayoutArgs) => ({ +/* the return type is what tells the two templates below they are the lengths + `Heatmap` asks for, rather than the plain strings TS would infer */ +const layoutProps = ( + args: LayoutArgs, +): Pick< + HeatmapProps, + 'labelEvery' | 'labelWidth' | 'cellHeight' | 'cellGap' +> => ({ labelEvery: args.labelEvery, labelWidth: args.labelWidth, cellHeight: `${args.cellHeight}px`, @@ -403,14 +409,10 @@ export const DenseGrid: StoryObj = { /** * The colors are the caller's, and so are the values. Five backup outcomes, - * none of them a health status, and so none of them painted from the status - * tokens: a categorical scale takes the theme's series colors, and leaves - * `statusHealthy` and `statusCritical` to mean health where health is what is - * being shown. + * none of them a health status, so none of them painted from the status tokens: + * a categorical scale takes the theme's series colors instead. * - * `sortOrder` is what keeps the legend in pipeline order rather than - * alphabetical, so the rare outcomes stay at the bottom where they are looked - * for. + * `sortOrder` keeps the legend in pipeline order rather than alphabetical. */ export const CustomColorSet: StoryObj = { argTypes: layoutArgTypes, @@ -442,11 +444,9 @@ export const CustomColorSet: StoryObj = { }; /** - * Discrete does not mean three states of health. Here the values are workload - * profiles, colored from the series palette because that is what a categorical - * scale is for, and the grid behaves exactly the same: click *Write-heavy* in - * the legend and every other slot dims, leaving the write bursts alone on the - * timeline. + * Discrete does not mean three states of health: here the values are workload + * profiles, and the grid behaves the same. Click *Write-heavy* in the legend + * and every other slot dims, leaving the write bursts alone on the timeline. */ export const NonStatusValues: StoryObj = { argTypes: layoutArgTypes, @@ -479,16 +479,13 @@ export const NonStatusValues: StoryObj = { }; /** - * When the values in the data are not what a reader should see: the cells hold - * bare response codes, `labelMap` spells them out in the legend, `formatValue` - * does the same for the tooltip and the cell's `aria-label`, and `sortOrder` - * compares them as the numbers they are rather than as the strings they arrive - * as. + * When the stored value is not what a reader should see: the cells hold bare + * response codes, `labelMap` spells them out in the legend, `formatValue` does + * the same for the tooltip and the `aria-label`, and `sortOrder` compares them + * as numbers. * - * The colors are the series palette, not the status tokens. A response code - * looks like a health status and is not one — a 403 is the server working - * correctly — and painting 500 with `statusCritical` would have every red cell - * in the product mean the same thing whether it does or not. + * Series colors, not status tokens: a response code looks like a health status + * and is not one — a 403 is the server working correctly. */ export const LabelledValues: StoryObj = { argTypes: layoutArgTypes, @@ -528,10 +525,8 @@ export const LabelledValues: StoryObj = { /** * The axis crosses midnight, which no other story does. The tick rolls from - * 23:00 to 00:00 and says nothing else about the day changing — the date is one - * hover away in the tooltip, and a date on the axis would cost more room than - * the change is worth. That is a decision rather than an oversight, which is - * why it has a story. + * 23:00 to 00:00 and marks the day change no further — the date is one hover + * away in the tooltip. A decision rather than an oversight, hence the story. */ export const AcrossMidnight: StoryObj = { argTypes: layoutArgTypes, @@ -557,3 +552,60 @@ export const AcrossMidnight: StoryObj = { ); }, }; + +/** + * A long axis on a narrow screen. Columns share whatever width there is, so + * without a floor a day of five-minute slots divides itself into slivers. + * + * `cellMinWidth` is that floor, `12px` by default, and the slider starts there: + * what loads is what a caller gets for free. Below it the grid scrolls sideways + * instead; drag the slider to `0` to remove the floor and get the slivers back. + * The row labels stay put, outside the scrolling area, so the scrollbar covers + * the tiles alone. Widen `frameWidth` and it goes away — the floor only bites + * while there is not enough room. + */ +export const HorizontalScroll: StoryObj< + LayoutArgs & { cellMinWidth: number; columns: number; frameWidth: string } +> = { + argTypes: { + ...layoutArgTypes, + cellMinWidth: { + control: { type: 'range', min: 0, max: 64, step: 1 }, + description: + "Smallest a cell may become, in px. Starts at the component's own default of 12; at 0 there is no floor, the grid always fits and never scrolls", + }, + columns: { + control: { type: 'range', min: 12, max: 288, step: 12 }, + description: 'Columns — one per five-minute slot', + }, + frameWidth: { + control: 'text', + description: 'Width of the surrounding frame, to stand in for the screen', + }, + }, + args: { + ...layoutArgs, + cellMinWidth: 16, + columns: 144, + frameWidth: '48rem', + cellGap: 2, + labelEvery: 12, + }, + render: (args) => { + const scale = useStatusScale(); + + return ( + + + + ); + }, +}; From df821038373ddd258cc205ff54dbc4aa531af8b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Do=20Rosario?= Date: Tue, 15 Sep 2026 16:49:05 +0200 Subject: [PATCH 07/12] improvement(charts): the Heatmap guideline covers how many slots fit The page already asks the reader to decide how wide a slot is, and to say which rule aggregated it. It said nothing about how many of those slots the frame can hold, which is the decision that comes next and the one the component now has an opinion about. It sits under the aggregation section rather than beside the time axis, because it is the same question: slot width decides what a cell hides, the floor decides how much of the grid you can see without scrolling for it. Raising the floor to give each slot more room trades one way of losing the pattern for another. --- stories/Heatmap/heatmap.guideline.mdx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/stories/Heatmap/heatmap.guideline.mdx b/stories/Heatmap/heatmap.guideline.mdx index 3a02528bcb..ad1f015b40 100644 --- a/stories/Heatmap/heatmap.guideline.mdx +++ b/stories/Heatmap/heatmap.guideline.mdx @@ -65,6 +65,16 @@ more, up to the point where the grid stops being readable. Anything that lets a user change the window has to re-aggregate, and the answer on screen changes with it. +How many slots fit is not that decision. Columns share the width available, so +a long axis in a narrow frame would divide itself into slivers; `cellMinWidth` +stops it, and past that floor the grid scrolls sideways rather than shrinking +further. The row labels stay outside the scrolling area. Raise the floor to give +each slot more room, but the reader then has to scroll to see the window they +asked for, which is a worse way to lose the pattern than showing all of it +smaller. + + + ## Accessibility: colour is the only channel Colour carries the value on its own. No shape, no label and no position tells From 93c1e407876662c2753af08920fd1dc0d9c42ae2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Do=20Rosario?= Date: Tue, 22 Sep 2026 15:33:38 +0200 Subject: [PATCH 08/12] improvement(charts): the Heatmap gives up its labels and legend before its cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A narrowing frame used to take it out of the cells alone: the label gutter held a fixed `labelWidth` and the legend held its column, so the tiles were the only thing left to squeeze, and they reached `cellMinWidth` and scrolled while both neighbours still had room to give. Reverse the order, cheapest first. The row labels truncate — they keep the whole name in a tooltip, so nothing is lost. The legend then drops under the grid and turns horizontal, which costs nothing at all. Only then do the cells narrow, and past their floor the grid scrolls as before. `labelWidth` becomes a cap rather than a width, so the gutter also stops leaving dead space beside short labels. Two lengths carry the policy: `LABEL_MIN_WIDTH`, how far the labels give way, and `PREFERRED_CELL_WIDTH`, the cell width they give way to protect — measured against `cellMinWidth` instead, the cells would always reach the floor first and the gutter would only yield once scrolling had started. Which way the legend went is measured rather than predicted: the break depends on how many columns the axis has, so there is no breakpoint to write. Reading it back cannot oscillate, since going under only makes the legend wider. Also fixes two things the responsive work made visible: an x-axis tick wrapped to two lines inside its single-column cell, and the title was a size and a weight above the `ChartTitle` that `ChartHeader` gives every other chart. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/components/charts/heatmap/Heatmap.tsx | 185 ++++++++++++------ stories/Heatmap/heatmap.guideline.mdx | 7 + stories/Heatmap/heatmap.stories.tsx | 82 +++++++- 3 files changed, 217 insertions(+), 57 deletions(-) diff --git a/src/lib/components/charts/heatmap/Heatmap.tsx b/src/lib/components/charts/heatmap/Heatmap.tsx index 34c13d1305..75b101f2e9 100644 --- a/src/lib/components/charts/heatmap/Heatmap.tsx +++ b/src/lib/components/charts/heatmap/Heatmap.tsx @@ -1,5 +1,13 @@ -import React, { ReactNode, useCallback, useEffect, useMemo } from 'react'; -import styled, { css } from 'styled-components'; +import { + ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import styled from 'styled-components'; import { Box } from '../../box/Box'; import { spacing, Stack } from '../../../spacing'; import { Text } from '../../text/Text.component'; @@ -7,7 +15,6 @@ import { ConstrainedText } from '../../constrainedtext/Constrainedtext.component import { Tooltip } from '../../tooltip/Tooltip.component'; import { FormattedDateTime } from '../../date/FormattedDateTime'; import { ChartLegend } from '../legend/ChartLegend'; -import { CoreUITheme } from '../../../style/theme'; import { ChartLegendWrapper, ChartLegendWrapperProps, @@ -96,9 +103,9 @@ type HeatmapBaseProps = { */ cellGap?: HeatmapLength; /** - * Width of the row label gutter. Defaults to `7rem`. Labels truncate rather - * than widen it, so a gutter too narrow costs the end of a label and never - * the alignment of the grid. + * Cap on the row label gutter. Defaults to `7rem`. The gutter sizes to the + * longest label it holds and only a label past this truncates, so a cap too + * low costs the end of a label and never the alignment of the grid. */ labelWidth?: HeatmapLength; /** @@ -126,6 +133,18 @@ export type DiscreteHeatmapProps = HeatmapBaseProps & { export type HeatmapProps = DiscreteHeatmapProps; +/** + * Narrowest the label gutter goes before the cells give up width instead. An + * ellipsized label keeps its tooltip; a narrowed cell only gets harder to hit. + */ +const LABEL_MIN_WIDTH = '5rem'; + +/** + * The cell width the labels give way to protect. Deliberately above + * `cellMinWidth`, the floor a cell scrolls at, so the gutter yields first. + */ +const PREFERRED_CELL_WIDTH = '20px'; + const FOCUS_RING_OFFSET = spacing.f1; const FOCUS_RING_WIDTH = spacing.f2; @@ -150,19 +169,29 @@ const Cell = styled.div<{ /** * A row's label, outside the scroller so it holds still while the tiles move. - * It carries its own width rather than handing it to `grid-template-columns`, - * so a bad length costs one label instead of the whole template. `min-width` - * because a grid item otherwise refuses to shrink into its track. + * + * `min-width` because a grid item otherwise refuses to shrink into its track, + * and `overflow: hidden` to make it a scroll container: its min-content is then + * zero, where the label's own `nowrap` would have been the gutter's floor. */ const GutterCell = styled(Box)` box-sizing: border-box; min-width: 0; + overflow: hidden; +`; + +/** + * One x-axis tick. It overflows its single-column cell either side rather than + * wrapping, and `min-width: 0` keeps it from widening the track it is centred in. + */ +const AxisTick = styled(Box)` + min-width: 0; + white-space: nowrap; `; /** * A row of the grid, for assistive technology and for layout at once: `subgrid` - * makes it a real box spanning every column while its tracks stay the - * scroller's, so columns line up across rows without a template of their own. + * makes it a real box spanning every column while its tracks stay the scroller's. */ const GridRow = styled.div` display: grid; @@ -179,8 +208,7 @@ const defaultTooltip = ( {row.label} - {/* the whole slot, not the instant it opens: a cell read on its own says - nothing about whether it covers five minutes, an hour or a day */} + {/* the slot, not its opening instant: a cell alone says nothing of its span */} {formatSlot(column, columnEnd)} @@ -188,30 +216,75 @@ const defaultTooltip = ( ); -/** Title above, grid and legend side by side — the frame both scales share. */ +/** + * Title above, grid and legend side by side. + * + * The row wraps, so the legend drops under the grid rather than squeezing it; + * the grid's flex basis is what decides the break. The legend is handed the + * direction it ended up in, since the break depends on how many columns the axis + * has and there is no breakpoint to write. Measuring cannot oscillate: going + * under only makes the legend wider, which can only keep it under. + */ const HeatmapFrame = ({ title, legend, children, }: { title?: ReactNode; - legend?: ReactNode; + legend?: (direction: 'horizontal' | 'vertical') => ReactNode; children: ReactNode; -}) => ( - - {title !== undefined && ( - - {title} - - )} - - {children} - {legend} - - -); +}) => { + const row = useRef(null); + const [isLegendBelow, setIsLegendBelow] = useState(false); + + const measure = useCallback(() => { + const element = row.current; + if (!element) return; + const [grid, slot] = Array.from(element.children) as HTMLElement[]; + if (slot) setIsLegendBelow(slot.offsetTop > grid.offsetTop); + }, []); + + // A longer axis re-decides the break without the row ever changing size. + useLayoutEffect(measure); + + // And on resize, which no render reports. + useEffect(() => { + const element = row.current; + if (!element) return; + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, [measure]); + + // Beside the grid the gap parts two columns; under it, it only leads a line. + const legendGap = isLegendBelow ? spacing.f4 : spacing.f16; + + return ( + + {/* the variant `ChartHeader` gives every other chart, so none shouts */} + {title !== undefined && {title}} + + {children} + {legend?.(isLegendBelow ? 'horizontal' : 'vertical')} + + + ); +}; -const LegendColumn = ({ title }: { title?: ReactNode }) => ( +const LegendColumn = ({ + title, + direction, +}: { + title?: ReactNode; + direction: 'horizontal' | 'vertical'; +}) => ( + // Only the items turn: the heading stays above, so two lines under, not five. {title !== undefined && ( @@ -220,7 +293,7 @@ const LegendColumn = ({ title }: { title?: ReactNode }) => ( )} @@ -237,8 +310,7 @@ const HeatmapGrid = ({ columns, appearanceOf, labelEvery = 1, - /* `spacing` is not declared `as const`, so its members are `string` and have - to be told they are the lengths they visibly are */ + /* `spacing` is not `as const`, so its members need telling they are lengths */ cellHeight = spacing.f20 as HeatmapLength, cellGap = spacing.f4 as HeatmapLength, labelWidth = '7rem', @@ -250,20 +322,24 @@ const HeatmapGrid = ({ // read off the axis once, not once per cell const columnEnds = getColumnEnds(columns); const columnCount = Math.max(columns.length, 1); + // The width the grid is worth defending: every column readable, plus the gaps. + const roomyGrid = `${columnCount} * ${PREFERRED_CELL_WIDTH} + ${ + columnCount - 1 + } * ${cellGap}`; + // Below this the cells give up room, after the gutter has reached its floor. + const roomyWidth = `calc(${LABEL_MIN_WIDTH} + ${roomyGrid})`; return ( - /* Two columns: the labels, then everything that scrolls. Keeping the labels - out of the scroller is what makes the scrollbar cover the tiles alone, - and saves painting anything opaque for the tiles to pass under. The - trailing row is the scrollbar's own room, which it otherwise takes from - the x-axis. */ + /* Labels outside the scroller, so the scrollbar covers the tiles alone. */ {rows.map((row, rowIndex) => ( @@ -273,12 +349,11 @@ const HeatmapGrid = ({ key={`${row.label}-${rowIndex}`} gridColumn={1} gridRow={rowIndex + 1} - width={labelWidth} + maxWidth={labelWidth} textAlign="right" pr={spacing.f8} > - {/* ellipsizes, and shows the whole label in a tooltip only when the - ellipsis actually took something away */} + {/* tooltip only when the ellipsis actually took something away */} {row.label}} @@ -286,10 +361,7 @@ const HeatmapGrid = ({ ))} - {/* `subgrid` keeps the two columns level: the scroller takes the rows it - is placed in rather than sizing its own, so a label cannot drift from - the tiles it names. `overflow-y: hidden` because a horizontal - scrollbar shortens the box enough to summon a vertical one. */} + {/* `subgrid` keeps the columns level; y scrolls on the x bar's height alone */} ({ aria-label={row.label} key={`${row.label}-${rowIndex}`} > - {/* driven by the columns, not by the cells: a short row must not - pull the next one out of line */} + {/* driven by the columns: a short row must not pull the next one out */} {columns.map((column, columnIndex) => { const value = row.cells[columnIndex] ?? null; const key = `${row.label}-${rowIndex}-${columnIndex}`; @@ -344,8 +415,7 @@ const HeatmapGrid = ({ $height={cellHeight} tabIndex={0} role="gridcell" - /* the same three things the tooltip shows: without the - slot, a screen reader has to count columns to place it */ + /* without the slot, a screen reader counts columns to place it */ aria-label={`${row.label}, ${formatSlot( column, columnEnds[columnIndex], @@ -359,7 +429,7 @@ const HeatmapGrid = ({ {columns.map((column, columnIndex) => ( - ({ {formatColumnTick ? ( formatColumnTick(column) ) : ( - /* a daily axis labelled by time of day prints "00:00" over - every column */ + /* a daily axis by time of day prints "00:00" over every column */ ({ )} )} - + ))} @@ -449,7 +518,13 @@ const DiscreteHeatmap = ({ return ( : undefined} + legend={ + showLegend + ? (direction) => ( + + ) + : undefined + } > diff --git a/stories/Heatmap/heatmap.guideline.mdx b/stories/Heatmap/heatmap.guideline.mdx index ad1f015b40..4e55154cd5 100644 --- a/stories/Heatmap/heatmap.guideline.mdx +++ b/stories/Heatmap/heatmap.guideline.mdx @@ -73,6 +73,13 @@ each slot more room, but the reader then has to scroll to see the window they asked for, which is a worse way to lose the pattern than showing all of it smaller. +The cells are the last thing to give way, because they are the only part a +reader cannot get back. A narrowing frame first truncates the row labels, which +keep the whole name in a tooltip; then drops the legend under the grid, which +loses nothing at all; and only then narrows the cells towards `cellMinWidth`. +`labelWidth` is the cap on the first of those, not a fixed gutter — the labels +take the width they need up to it, and no more. + ## Accessibility: colour is the only channel diff --git a/stories/Heatmap/heatmap.stories.tsx b/stories/Heatmap/heatmap.stories.tsx index bfd85791b0..b526019468 100644 --- a/stories/Heatmap/heatmap.stories.tsx +++ b/stories/Heatmap/heatmap.stories.tsx @@ -35,6 +35,18 @@ const MONITORING_SERVICES = [ 'Thanos', ]; +/** Deliberately long: ResponsiveOrder is about watching the labels give way. */ +const CONNECTOR_SERVICES = [ + 'IAM & STS (vault)', + 'Lifecycle service', + 'Replication (CRR) service', + 'S3 frontend', + 'S3 metadata', + 'S3 service (cloudserver)', + 'RING connector (sproxyd)', + 'UTAPI v1', +]; + const FIVE_MINUTES = 5 * 60 * 1000; const ONE_HOUR = 60 * 60 * 1000; const ONE_DAY = 24 * ONE_HOUR; @@ -212,8 +224,7 @@ const layoutArgs: LayoutArgs = { labelWidth: '7rem', }; -/* the return type is what tells the two templates below they are the lengths - `Heatmap` asks for, rather than the plain strings TS would infer */ +/* the return type is what types the templates below as lengths, not strings */ const layoutProps = ( args: LayoutArgs, ): Pick< @@ -609,3 +620,70 @@ export const HorizontalScroll: StoryObj< ); }, }; + +/** + * Drag the frame's bottom-right corner and watch what gives way, in order. + * + * The cells go last, because they are the only part a reader cannot get back. + * Wide, everything is comfortable. Narrow the frame and the row labels truncate + * first — they keep the whole name in a tooltip, so nothing is lost. Next the + * legend drops under the grid and turns horizontal, which costs nothing at all. + * Only then do the cells narrow, and past `cellMinWidth` the grid scrolls. + * + * `columns` moves every one of those thresholds, which is why they are not + * breakpoints: a longer axis wants more room, so it gives up the labels and the + * legend sooner. Set `labelWidth` shorter than the longest label to see that it + * caps the gutter rather than fixing it — the labels never take more than they + * need. + */ +export const ResponsiveOrder: StoryObj< + LayoutArgs & { columns: number; cellMinWidth: number } +> = { + argTypes: { + ...layoutArgTypes, + columns: { + control: { type: 'range', min: 8, max: 96, step: 4 }, + description: 'Columns — one per hourly slot', + }, + cellMinWidth: { + control: { type: 'range', min: 0, max: 48, step: 1 }, + description: + "Smallest a cell may become, in px. The component's own default is 12", + }, + }, + args: { + ...layoutArgs, + columns: 24, + cellMinWidth: 12, + labelWidth: '15rem', + labelEvery: 3, + }, + render: (args) => { + const scale = useStatusScale(); + + return ( +
+ +
+ ); + }, +}; From 6bf5d263937932a9924104222c6085889e40383f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Do=20Rosario?= Date: Wed, 23 Sep 2026 11:09:11 +0200 Subject: [PATCH 09/12] improvement(charts): a Heatmap row owns its cells again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tooltip does not wrap its children transparently: it puts a container and a reference div around them. The role sat on the Cell, two levels under, so what each row actually owned was an anonymous div and the gridcells were buried beneath it — the one arrangement ARIA does not allow, since a row's required owned elements are its cells. The role moves to a wrapper above the Tooltip. That wrapper is the grid item now, which is why it is display: grid — TooltipContainer is inline-block, and behind an ordinary block it would shrink to fit a cell that has no width of its own, collapsing every tile to nothing. The gap that let this through is worth as much as the fix. The suite counted six gridcells and found them by name, and both stayed true with the cells buried, because neither says anything about who owns them. There is a test for the ownership itself now; it fails on the old shape, with the three nulls of the intervening divs. Empty cells were always correct, having no tooltip to be wrapped in — which is how the grid came to be right for its holes and wrong for every painted cell. --- .../charts/heatmap/Heatmap.test.tsx | 12 +++++ src/lib/components/charts/heatmap/Heatmap.tsx | 44 +++++++++---------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/lib/components/charts/heatmap/Heatmap.test.tsx b/src/lib/components/charts/heatmap/Heatmap.test.tsx index 7103f0ac2d..1bd98b6420 100644 --- a/src/lib/components/charts/heatmap/Heatmap.test.tsx +++ b/src/lib/components/charts/heatmap/Heatmap.test.tsx @@ -192,6 +192,18 @@ describe('Heatmap', () => { expect(screen.getAllByRole('columnheader')).toHaveLength(columns.length); }); + it('should have each row own its cells rather than a wrapper above them', () => { + renderStatusHeatmap(); + + const [firstRow] = screen.getAllByRole('row'); + + expect( + Array.from(firstRow.children).map((child) => + child.getAttribute('role'), + ), + ).toEqual(Array(columns.length).fill('gridcell')); + }); + it('should announce a cell with its row, its slot and its value', () => { renderStatusHeatmap(); diff --git a/src/lib/components/charts/heatmap/Heatmap.tsx b/src/lib/components/charts/heatmap/Heatmap.tsx index 75b101f2e9..f8243ab824 100644 --- a/src/lib/components/charts/heatmap/Heatmap.tsx +++ b/src/lib/components/charts/heatmap/Heatmap.tsx @@ -400,28 +400,28 @@ const HeatmapGrid = ({ const { color, opacity } = appearanceOf(value); return ( - - - + + + + + ); })} From 38d1e40da34fa7012207736b8ecce8bae4374c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Do=20Rosario?= Date: Wed, 23 Sep 2026 11:09:36 +0200 Subject: [PATCH 10/12] improvement(charts): the scrolling grid keeps room for a cell's focus ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cell paints its hover and focus ring outside its own box, and the scroller clips at its padding box, so the ring on any cell along an edge was cut — top and bottom by the hidden vertical overflow, the leading and trailing columns by the horizontal one. Padding gives it that room, and gives the outermost tiles a little air from the gutter and the frame besides. --- src/lib/components/charts/heatmap/Heatmap.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/components/charts/heatmap/Heatmap.tsx b/src/lib/components/charts/heatmap/Heatmap.tsx index f8243ab824..d59f11a678 100644 --- a/src/lib/components/charts/heatmap/Heatmap.tsx +++ b/src/lib/components/charts/heatmap/Heatmap.tsx @@ -364,6 +364,7 @@ const HeatmapGrid = ({ {/* `subgrid` keeps the columns level; y scrolls on the x bar's height alone */} Date: Wed, 23 Sep 2026 12:48:52 +0200 Subject: [PATCH 11/12] improvement(charts): the Heatmap guideline states all three sort orders The page named sortOrder and described only the default, which left a reader to find the other two in the type. Documenting the status shorthand honestly turned out to be the whole point. 'status' does not sort. It returns ['Success', 'Warning', 'Failed'] filtered by what the grid happens to hold, so it is a whitelist: any value named otherwise keeps its colour on the cells and loses its legend entry, which leaves it neither readable nor filterable on a grid whose own guideline says two sections earlier that colour is the only channel. That catches the absence value as well, and a status history always has one, since collection has gaps. So the shorthand is not "for grids that spell their values that way", it is for charts with exactly three outcomes and no fourth. Heatmaps take the comparator. --- stories/Heatmap/heatmap.guideline.mdx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/stories/Heatmap/heatmap.guideline.mdx b/stories/Heatmap/heatmap.guideline.mdx index 4e55154cd5..0bdd57ca37 100644 --- a/stories/Heatmap/heatmap.guideline.mdx +++ b/stories/Heatmap/heatmap.guideline.mdx @@ -122,9 +122,23 @@ colour elsewhere in the product will read it the same way here. Avoid hardcoded hex values, they belong to no theme and follow none. Order the legend the way the values mean something, through `scale.sortOrder`. -Alphabetical is the default and is rarely what the reader expects. Where the -stored value is not what should be shown, `scale.labelMap` renames it in the -legend and `formatValue` does the same for the tooltip and the `aria-label`. +It takes three forms: + +- **`'alphabetical'`**, the default. It is rarely the order a reader is looking + for: severity, or the order a pipeline runs in, almost never sorts by name. +- **`'status'`**, a shorthand for `Success`, `Warning`, `Failed`, in that order. + It keeps only those three, and a value named anything else still paints its + cells but loses its legend entry — neither readable nor filterable, the worst + outcome on a grid where colour is the only channel. Note that this catches the + absence value too, which a status history always carries, so the shorthand + rarely survives contact with a real grid. +- **A comparator**, `(a, b) => number`, for everything else. This is the usual + answer — severity order, pipeline order, numeric order for values that are + numbers wearing strings. + +Where the stored value is not what should be shown, `scale.labelMap` renames it +in the legend and `formatValue` does the same for the tooltip and the +`aria-label`. ## Time From 314c02369b8bfb3798a36e79f912bae6ffc43d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Do=20Rosario?= Date: Wed, 23 Sep 2026 18:18:49 +0200 Subject: [PATCH 12/12] improvement(charts): the sortOrder warning moves to the prop that carries it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guideline had grown a paragraph where it wanted a list item: three sort orders, one of them arguing its case at four times the length of the other two. The page states what 'status' keeps and what it leaves out, and stops there. The argument belongs on the prop, where a caller meets it while choosing rather than while reading a design page, so sortOrder's own doc now carries the three forms and what the shorthand really does: a whitelist, not a sort, and a value it drops keeps painting its cells — left coloured, unexplained and impossible to filter. One thing worth stating there that was not written anywhere: a comparator is handed the colorSet keys, not the values behind them. With labelMap in play it is easy to assume you are ordering what the legend displays, when you are ordering what the cells hold. --- src/lib/components/charts/heatmap/Heatmap.tsx | 18 +++++++++++++++--- stories/Heatmap/heatmap.guideline.mdx | 6 +----- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/lib/components/charts/heatmap/Heatmap.tsx b/src/lib/components/charts/heatmap/Heatmap.tsx index d59f11a678..9c429da712 100644 --- a/src/lib/components/charts/heatmap/Heatmap.tsx +++ b/src/lib/components/charts/heatmap/Heatmap.tsx @@ -62,9 +62,21 @@ export type HeatmapDiscreteScale = { */ colorSet?: ChartLegendWrapperProps['colorSet']; /** - * Order of the legend items. Read only alongside `colorSet`: without one the - * `ChartLegendWrapper` the caller put above owns the ordering, and this is - * ignored. + * Order of the legend items, and with them the order the values are read in. + * Read only alongside `colorSet`: without one the `ChartLegendWrapper` the + * caller put above owns the ordering, and this is ignored. + * + * `'alphabetical'` sorts by name, and is the default. + * + * `'status'` is a whitelist rather than a sort. It keeps `Success`, `Warning` + * and `Failed`, in that order, and drops every other value from the legend — + * the absence value with them. A dropped value still paints its cells, so it + * is left coloured, unexplained and impossible to filter. Since a status + * history normally carries an absence value, expect to need a comparator. + * + * A comparator, `(a, b) => number`, is handed the `colorSet` keys, not the + * values behind them: it compares what the cells hold, which is what + * `labelMap` renames for display rather than replaces. */ sortOrder?: ChartLegendWrapperProps['sortOrder']; /** diff --git a/stories/Heatmap/heatmap.guideline.mdx b/stories/Heatmap/heatmap.guideline.mdx index 0bdd57ca37..58ee6b7939 100644 --- a/stories/Heatmap/heatmap.guideline.mdx +++ b/stories/Heatmap/heatmap.guideline.mdx @@ -127,11 +127,7 @@ It takes three forms: - **`'alphabetical'`**, the default. It is rarely the order a reader is looking for: severity, or the order a pipeline runs in, almost never sorts by name. - **`'status'`**, a shorthand for `Success`, `Warning`, `Failed`, in that order. - It keeps only those three, and a value named anything else still paints its - cells but loses its legend entry — neither readable nor filterable, the worst - outcome on a grid where colour is the only channel. Note that this catches the - absence value too, which a status history always carries, so the shorthand - rarely survives contact with a real grid. + Any other value is left out of the legend, including the absence value. - **A comparator**, `(a, b) => number`, for everything else. This is the usual answer — severity order, pipeline order, numeric order for values that are numbers wearing strings.