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..1bd98b6420 --- /dev/null +++ b/src/lib/components/charts/heatmap/Heatmap.test.tsx @@ -0,0 +1,361 @@ +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('gridcell')).toHaveLength(6); + }); + + it('should read a ChartLegendWrapper the caller owns when given no colorSet', () => { + const { Wrapper } = getWrapper(); + + render( + + + , + { wrapper: Wrapper }, + ); + + expect( + screen.getByLabelText('Prometheus, 25 Aug 10:05 to 10:10, 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('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, 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', () => { + 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, 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'); + }); + }); + + 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'] }, + ], + }); + + // 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); + }); + }); + + describe('formatValue', () => { + it('should spell the value out in the aria-label', () => { + renderStatusHeatmap({ + formatValue: (value: string) => `status ${value.toLowerCase()}`, + }); + + expect( + 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 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(); + + // 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(); + }); + }); + + describe('tooltip', () => { + it('should describe the cell on focus, so it is reachable from the keyboard', () => { + renderStatusHeatmap(); + + act(() => + screen + .getByLabelText('Prometheus, 25 Aug 10:05 to 10:10, WARNING') + .focus(), + ); + + const overlay = document.querySelector('.sc-tooltip-overlay'); + expect(overlay).not.toBeNull(); + expect(overlay).toHaveTextContent('Prometheus'); + expect(overlay).toHaveTextContent('WARNING'); + }); + + it('should name the whole slot, not the instant the column opens', () => { + renderStatusHeatmap(); + + 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( + '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 + .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( + '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 + .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( + '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, 25 Aug 10:00, 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 }) => + `${row.label} at column ${columnIndex}`, + }); + + act(() => + screen + .getByLabelText('Prometheus, 25 Aug 10:05 to 10:10, WARNING') + .focus(), + ); + + expect(document.querySelector('.sc-tooltip-overlay')).toHaveTextContent( + 'Prometheus at column 1', + ); + }); + }); + + 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; + + 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..9c429da712 --- /dev/null +++ b/src/lib/components/charts/heatmap/Heatmap.tsx @@ -0,0 +1,580 @@ +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'; +import { ConstrainedText } from '../../constrainedtext/Constrainedtext.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 { + DIMMED_CELL_OPACITY, + formatSlot, + getColumnEnds, + isDailyOrLongerSlot, +} 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; + /** 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. 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. */ +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']; + /** + * 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']; + /** + * 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. */ + columns: Date[]; + /** Heading above the grid. */ + title?: ReactNode; + /** Heading above the legend — what the colors mean. */ + legendTitle?: ReactNode; + /** + * Hide the legend, for a heatmap whose scale is stated elsewhere: several + * grids standing under one shared legend. + */ + showLegend?: boolean; + /** Show one x-axis tick every N columns, to keep a dense axis legible. */ + labelEvery?: number; + /** 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; + /** + * 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; + /** + * 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 + * 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; + /** Replaces the default tooltip — row label, column date-time, value. */ + renderTooltip?: (cell: HeatmapCell) => ReactNode; +}; + +export type DiscreteHeatmapProps = HeatmapBaseProps & { + scale?: HeatmapDiscreteScale; +}; + +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; + +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; + + /* outline, not border: it paints outside the box so nothing is re-laid out */ + &:hover, + &:focus-visible { + outline: ${FOCUS_RING_WIDTH} solid ${({ theme }) => theme.selectedActive}; + outline-offset: ${FOCUS_RING_OFFSET}; + } +`; + +/** + * A row's label, outside the scroller so it holds still while the tiles move. + * + * `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. + */ +const GridRow = styled.div` + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + align-items: center; +`; + +const defaultTooltip = ( + { row, column, columnEnd, value }: HeatmapCell, + formatValue: (value: T) => string, +) => ( + + + {row.label} + + {/* the slot, not its opening instant: a cell alone says nothing of its span */} + + {formatSlot(column, columnEnd)} + + {formatValue(value)} + +); + +/** + * 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?: (direction: 'horizontal' | 'vertical') => ReactNode; + children: ReactNode; +}) => { + 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, + direction, +}: { + title?: ReactNode; + direction: 'horizontal' | 'vertical'; +}) => ( + // Only the items turn: the heading stays above, so two lines under, not five. + + {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, + /* `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', + 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); + // 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 ( + /* Labels outside the scroller, so the scrollbar covers the tiles alone. */ + + {rows.map((row, rowIndex) => ( + + ))} + + {/* `subgrid` keeps the columns level; y scrolls on the x bar's height alone */} + + {rows.map((row, rowIndex) => ( + + {/* 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}`; + + // still a cell of the row, so the columns keep lining up + if (value === null) { + return ; + } + + const cell = { + row, + column, + columnEnd: columnEnds[columnIndex], + columnIndex, + value, + }; + const { color, opacity } = appearanceOf(value); + + return ( + + + + + + ); + })} + + ))} + + + {columns.map((column, columnIndex) => ( + + {columnIndex % labelEvery === 0 && ( + + {formatColumnTick ? ( + formatColumnTick(column) + ) : ( + /* a daily axis by time of day prints "00:00" over every 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 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( + () => + 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 on each miss. + */ + 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 + } + > + + + ); +}; + +/** + * 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. + * + * Values are names, and the legend is where they get their color; clicking one + * filters the grid. + * + * ```tsx + * + * ``` + */ +export const Heatmap = ({ scale, ...gridProps }: HeatmapProps) => { + // 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..0dd17b389b --- /dev/null +++ b/src/lib/components/charts/heatmap/Heatmap.utils.test.ts @@ -0,0 +1,113 @@ +import { + formatSlot, + getColumnEnds, + isDailyOrLongerSlot, +} from './Heatmap.utils'; + +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([]); + }); +}); + +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); + }); +}); + +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 new file mode 100644 index 0000000000..09b2eacd4a --- /dev/null +++ b/src/lib/components/charts/heatmap/Heatmap.utils.ts @@ -0,0 +1,75 @@ +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. 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) => { + 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 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() && + 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 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 98dfb63965..7698ffa3f9 100644 --- a/src/lib/components/charts/index.ts +++ b/src/lib/components/charts/index.ts @@ -20,6 +20,16 @@ export type { Alert } from './globalhealthbar/GlobalHealthBar.hooks'; export { Sparkline } from './sparkline/Sparkline'; +export { Heatmap } from './heatmap/Heatmap'; +export type { + HeatmapProps, + DiscreteHeatmapProps, + HeatmapRow, + HeatmapCell, + HeatmapDiscreteScale, + HeatmapLength, +} from './heatmap/Heatmap'; + // Legend export { ChartLegend } from './legend/ChartLegend'; export { diff --git a/src/lib/next.ts b/src/lib/next.ts index 66bf71f97b..a7992855f3 100644 --- a/src/lib/next.ts +++ b/src/lib/next.ts @@ -30,6 +30,7 @@ export { LineTimeSerieChart, GlobalHealthBar, Sparkline, + Heatmap, ChartLegend, ChartLegendWrapper, useChartId, @@ -48,6 +49,12 @@ export type { LineChartProps, Serie, GlobalHealthProps, + HeatmapProps, + DiscreteHeatmapProps, + HeatmapRow, + HeatmapCell, + HeatmapDiscreteScale, + HeatmapLength, Alert, UnitRange, TimeType, diff --git a/stories/Heatmap/heatmap.guideline.mdx b/stories/Heatmap/heatmap.guideline.mdx new file mode 100644 index 0000000000..58ee6b7939 --- /dev/null +++ b/stories/Heatmap/heatmap.guideline.mdx @@ -0,0 +1,163 @@ +import { Meta, Canvas } from '@storybook/addon-docs/blocks'; +import * as HeatmapStories from './heatmap.stories'; + + + + + +# Heatmap chart + +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 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. +- 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. + +## Every cell is an aggregate + +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. + +- **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. + +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. + +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. + +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. + +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. + +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 + +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. + +That trade is acceptable here, because a heatmap chart without colour is not a +heatmap chart. It is not acceptable as a habit: + +- 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. + +## Colour sets + +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`). + + + +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`. +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. + 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. + +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 +axis stays legible. + +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. + + + +## Empty and no data + +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. + +**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 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. + + diff --git a/stories/Heatmap/heatmap.stories.tsx b/stories/Heatmap/heatmap.stories.tsx new file mode 100644 index 0000000000..b526019468 --- /dev/null +++ b/stories/Heatmap/heatmap.stories.tsx @@ -0,0 +1,689 @@ +import { Meta, StoryObj } from '@storybook/react-webpack5'; +import { useTheme } from 'styled-components'; +import { + Box, + Heatmap, + HeatmapDiscreteScale, + HeatmapLength, + HeatmapProps, + HeatmapRow, +} from '../../src/lib/next'; +import { FormattedDateTime } from '../../src/lib/components/date/FormattedDateTime'; +import { + CoreUITheme, + lineColor1, + lineColor2, + lineColor3, + lineColor4, + lineColor6, + lineColor7, +} from '../../src/lib/style/theme'; + +/* -------------------------------------------------------------------------- */ +/* DATA */ +/* -------------------------------------------------------------------------- */ + +type CellStatus = 'Ok' | 'Warning' | 'Critical' | 'No data'; + +const STATUS_ORDER: CellStatus[] = ['Ok', 'Warning', 'Critical', 'No data']; + +const MONITORING_SERVICES = [ + 'Alertmanager', + 'Grafana', + 'Prometheus', + 'Supervisor', + '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; + +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 'No data' (not collected yet). */ + noDataFrom = columnCount, +): HeatmapRow[] => + labels.map((label, rowIndex) => ({ + label, + cells: Array.from({ length: columnCount }, (_, colIndex) => { + 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; + }), + })); + +/* -- 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. 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[], + 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. + */ +const useStatusScale = (): HeatmapDiscreteScale => { + const theme = useTheme() as CoreUITheme; + + return { + colorSet: { + Ok: theme.statusHealthy, + Warning: theme.statusWarning, + Critical: theme.statusCritical, + 'No data': theme.textSecondary, + }, + sortOrder: inDeclaredOrder(STATUS_ORDER), + }; +}; + +/* -------------------------------------------------------------------------- */ +/* STORIES */ +/* -------------------------------------------------------------------------- */ + +/** Presentational props, shared by every story so a control means the same thing. */ +type LayoutArgs = { + cellHeight: number; + cellGap: number; + labelEvery: number; + labelWidth: HeatmapLength; +}; + +/** 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 'No data': 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', +}; + +/* the return type is what types the templates below as lengths, not strings */ +const layoutProps = ( + args: LayoutArgs, +): Pick< + HeatmapProps, + 'labelEvery' | 'labelWidth' | 'cellHeight' | 'cellGap' +> => ({ + 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 '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. + */ +export const Playground: StoryObj = { + argTypes: { + ...layoutArgTypes, + rows: { + control: 'object', + description: + "One entry per row: { label, cells }. A cell is Ok | Warning | Critical | 'No data'", + }, + }, + args: { + ...layoutArgs, + rows: [ + { + 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', 'No data'], + }, + { label: 'Supervisor', cells: ['Ok', 'Ok', 'Ok', 'Ok', 'No data'] }, + { label: 'Thanos', cells: ['Ok', 'Warning', 'Ok', 'Ok', 'No data'] }, + ], + }, + 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', 'No data'] 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 ( + + + + ); + }, +}; + +/** + * The colors are the caller's, and so are the values. Five backup outcomes, + * 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` keeps the legend in pipeline order rather than alphabetical. + */ +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, 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, + args: { ...layoutArgs, labelEvery: 3, labelWidth: '10rem', cellGap: 2 }, + render: (args) => { + const theme = useTheme() as CoreUITheme; + + return ( + + + + ); + }, +}; + +/** + * 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. + * + * 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, + args: { ...layoutArgs, labelEvery: 3, labelWidth: '9rem' }, + render: (args) => { + 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)} + /> + + ); + }, +}; + +/** + * The axis crosses midnight, which no other story does. The tick rolls from + * 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, + args: { ...layoutArgs, labelEvery: 1 }, + render: (args) => { + const scale = useStatusScale(); + + return ( + + + + ); + }, +}; + +/** + * 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 ( + + + + ); + }, +}; + +/** + * 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 ( +
+ +
+ ); + }, +};