import clsx from "clsx"; import { addDays, differenceInCalendarDays } from "date-fns"; import * as React from "react"; import * as R from "remeda"; import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat"; import { useElementSize } from "~/hooks/useElementSize"; import { niceStep } from "~/utils/number"; import styles from "./LineChart.module.css"; const DEFAULT_WIDTH = 672; const DEFAULT_HEIGHT = 170; const MARGIN = { top: 12, bottom: 22, inline: 8 }; const MARGIN_TOP_WITH_PEAK_LABEL = 26; /** Keeps the line's stroke from being clipped at the container's edges */ const SPARKLINE_MARGIN = 2; /** From the plot's bottom edge to the x-axis labels' baseline */ const X_LABEL_OFFSET = MARGIN.bottom - 6; const PEAK_LABEL_CLAMP = 48; const PX_PER_DATE_LABEL = 120; const PX_PER_NUMBER_LABEL = 60; /** Keeps a middle x-axis label from overlapping the first or last one */ const X_LABEL_MIN_GAP_FROM_EDGE = 24; const Y_TICKS_TARGET_COUNT = 3; /** Keeps a tick's label from overlapping the baseline's */ const Y_TICK_MIN_GAP_FROM_BASELINE = 14; /** From a y tick's grid line to its label's baseline, the label sits on top of the line */ const Y_LABEL_OFFSET = -4; const SERIES_COLORS_COUNT = 3; const HIGHLIGHT_COLORS_COUNT = 2; export interface LineChartSeries { /** Shown in the tooltip when the chart has more than one series */ label: React.ReactNode; /** Sorted by `x` ascending */ points: Array<{ x: number; y: number }>; } /** * SVG line chart that draws at the size its container gives it so the labels stay the same size * regardless of it. Height comes from the container, override it via `className`. Needs at least two points. */ export function LineChart({ series, xAxis, formatValue = String, area = false, showPeak = false, highlight, interactive = false, sparkline = false, className, ariaLabel, }: { series: LineChartSeries[]; /** "date": `x` values are timestamps in milliseconds; "number": plain numbers shown with an optional `suffix` */ xAxis: { type: "date" } | { type: "number"; suffix?: string }; /** Formats `y` values for the tooltip and the peak label */ formatValue?: (value: number) => string; /** Gradient fill below the lines */ area?: boolean; /** Marks and labels the highest point of the first series */ showPeak?: boolean; /** Markers at points of interest, e.g. one per build compared */ highlight?: Array<{ x: number; y: number }>; /** Hovering shows the values closest to the pointer */ interactive?: boolean; /** Only the lines, no axes, labels or grid lines, e.g. inline in a table row */ sparkline?: boolean; className?: string; ariaLabel: string; }) { const { formatter: dateLabelFormatter } = useDateTimeFormat({ month: "short", day: "numeric", }); const { formatter: tooltipDateFormatter } = useDateTimeFormat({ weekday: "short", month: "short", day: "numeric", }); const gradientIdPrefix = React.useId(); const { ref: measureRef, size } = useElementSize(); const [hoveredX, setHoveredX] = React.useState(null); const { width, height } = size ?? { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, }; const allPoints = series.flatMap((s) => s.points); const xs = R.unique(allPoints.map((point) => point.x)).sort((a, b) => a - b); const minX = xs[0]; const maxX = xs[xs.length - 1]; const ys = allPoints.map((point) => point.y); const minY = Math.min(...ys); const maxY = Math.max(...ys); const marginTop = sparkline ? SPARKLINE_MARGIN : showPeak ? MARGIN_TOP_WITH_PEAK_LABEL : MARGIN.top; const marginBottom = sparkline ? SPARKLINE_MARGIN : MARGIN.bottom; const innerHeight = height - marginTop - marginBottom; const yRange = maxY - minY || 1; const yPercent = (value: number) => (1 - (value - minY) / yRange) * 100; const yTicks = niceTicks({ min: minY, max: maxY, targetCount: sparkline ? 0 : Y_TICKS_TARGET_COUNT, }).filter( (tick) => ((yPercent(minY) - yPercent(tick.value)) / 100) * innerHeight >= Y_TICK_MIN_GAP_FROM_BASELINE, ); const plotInset = sparkline ? SPARKLINE_MARGIN : MARGIN.inline; const innerWidth = width - plotInset * 2; const xRange = maxX - minX || 1; const xPercent = (value: number) => ((value - minX) / xRange) * 100; const isFarEnoughFromEdges = (value: number) => (xPercent(value) / 100) * innerWidth >= X_LABEL_MIN_GAP_FROM_EDGE && ((100 - xPercent(value)) / 100) * innerWidth >= X_LABEL_MIN_GAP_FROM_EDGE; const formatX = (value: number) => xAxis.type === "date" ? dateLabelFormatter.format(new Date(value)) : `${value}${xAxis.suffix ?? ""}`; // how many fit depends on the width which is only known once measured on the client const middleXLabels = !size || sparkline ? [] : xAxis.type === "date" ? middleDates({ first: new Date(minX), last: new Date(maxX), count: Math.floor(innerWidth / PX_PER_DATE_LABEL) - 1, }).map((date) => ({ value: date.getTime(), label: dateLabelFormatter.format(date), })) : niceTicks({ min: minX, max: maxX, targetCount: Math.floor(innerWidth / PX_PER_NUMBER_LABEL), }) .filter((tick) => isFarEnoughFromEdges(tick.value)) .map((tick) => ({ ...tick, label: `${tick.label}${xAxis.suffix ?? ""}`, })); const peak = showPeak ? R.firstBy(series[0].points, [(point) => point.y, "desc"]) : undefined; const handlePointerMove = (event: React.PointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const pointerPercent = ((event.clientX - rect.left - plotInset) / (rect.width - plotInset * 2)) * 100; setHoveredX( R.firstBy(xs, (x) => Math.abs(xPercent(x) - pointerPercent)) ?? null, ); }; const hoveredPoints = typeof hoveredX === "number" ? series.flatMap((s, seriesIndex) => { const point = s.points.find((p) => p.x === hoveredX); return point ? [{ ...point, seriesIndex, label: s.label }] : []; }) : []; const tooltipAnchor = typeof hoveredX === "number" && hoveredPoints.length > 0 ? { xPercent: xPercent(hoveredX), yPercent: Math.min( ...hoveredPoints.map((point) => yPercent(point.y)), ), } : null; const tooltipAnchorX = tooltipAnchor ? (tooltipAnchor.xPercent / 100) * innerWidth : 0; return (
setHoveredX(null) : undefined} > {/* coordinates are percentages of the plot area so the server render matches the client's at any size */}
{series.map((s, seriesIndex) => { const linePath = s.points .map( (point, index) => `${index === 0 ? "M" : "L"}${xPercent(point.x).toFixed(2)} ${yPercent(point.y).toFixed(2)}`, ) .join(" "); const gradientId = `${gradientIdPrefix}-${seriesIndex}`; return ( {area ? ( <> {/* presentation attributes, not CSS: the image export does not style elements inside defs */} ) : null} ); })} {!sparkline ? ( ) : null} {yTicks.map((tick) => ( {tick.label} ))} {tooltipAnchor ? ( ) : null} {highlight?.map((point, index) => ( ))} {peak ? ( ) : null} {hoveredPoints.map((point) => ( ))} {!sparkline ? ( {formatX(minX)} ) : null} {middleXLabels.map((xLabel) => ( {xLabel.label} ))} {!sparkline ? ( {formatX(maxX)} ) : null} {peak && !tooltipAnchor ? (
{formatValue(peak.y)}
) : null} {tooltipAnchor ? (
{xAxis.type === "date" ? tooltipDateFormatter.format(new Date(hoveredX!)) : formatX(hoveredX!)}
{series.length === 1 ? (
{formatValue(hoveredPoints[0].y)}
) : ( hoveredPoints.map((point) => (
{point.label}
{formatValue(point.y)}
)) )}
) : null}
); } function seriesColorClass(seriesIndex: number) { return styles[`series${seriesIndex % SERIES_COLORS_COUNT}`]; } function middleDates({ first, last, count, }: { first: Date; last: Date; count: number; }) { const daysBetween = differenceInCalendarDays(last, first); const labelsCount = Math.min(count, daysBetween - 1); if (labelsCount <= 0) return []; return R.unique( R.range(1, labelsCount + 1).map((index) => Math.round((daysBetween * index) / (labelsCount + 1)), ), ).map((dayOffset) => addDays(first, dayOffset)); } /** Round-numbered ticks strictly above `min` and up to `max` */ function niceTicks({ min, max, targetCount, }: { min: number; max: number; targetCount: number; }) { const range = max - min; if (range <= 0 || targetCount <= 0) return []; const step = niceStep(range / targetCount); const decimals = Math.max(0, -Math.floor(Math.log10(step))); return R.range(Math.floor(min / step) + 1, Math.floor(max / step) + 1).map( (multiple) => { const value = multiple * step; return { value, label: value.toFixed(decimals) }; }, ); }