Files
sendou.ink/app/components/LineChart.tsx
2026-09-28 20:03:09 +03:00

453 lines
13 KiB
TypeScript

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<HTMLDivElement>();
const [hoveredX, setHoveredX] = React.useState<number | null>(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<HTMLDivElement>) => {
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 (
<div
ref={measureRef}
className={clsx(styles.container, className, {
[styles.interactive]: interactive,
})}
role="img"
aria-label={ariaLabel}
onPointerMove={interactive ? handlePointerMove : undefined}
onPointerDown={interactive ? handlePointerMove : undefined}
onPointerLeave={interactive ? () => setHoveredX(null) : undefined}
>
{/* coordinates are percentages of the plot area so the server render matches the client's at any size */}
<div
className={styles.plot}
style={{
inset: `${marginTop}px ${plotInset}px ${marginBottom}px`,
}}
>
<svg
className={styles.chart}
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-hidden
>
{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 (
<g key={seriesIndex} className={seriesColorClass(seriesIndex)}>
{area ? (
<>
<defs>
{/* presentation attributes, not CSS: the image export does not style elements inside defs */}
<linearGradient
id={gradientId}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0"
stopColor="currentColor"
stopOpacity={0.3}
/>
<stop
offset="1"
stopColor="currentColor"
stopOpacity={0}
/>
</linearGradient>
</defs>
<path
d={`${linePath} L${xPercent(s.points[s.points.length - 1].x).toFixed(2)} 100 L${xPercent(s.points[0].x).toFixed(2)} 100 Z`}
fill={`url(#${gradientId})`}
/>
</>
) : null}
<path className={styles.line} d={linePath} />
</g>
);
})}
</svg>
<svg className={styles.chart} aria-hidden>
{!sparkline ? (
<line
className={styles.gridLine}
x1="0"
y1="100%"
x2="100%"
y2="100%"
/>
) : null}
{yTicks.map((tick) => (
<g key={tick.label}>
<line
className={clsx(styles.gridLine, styles.gridLineDashed)}
x1="0"
y1={`${yPercent(tick.value)}%`}
x2="100%"
y2={`${yPercent(tick.value)}%`}
/>
<text
className={clsx(styles.label, styles.yLabel)}
x="0"
y={`${yPercent(tick.value)}%`}
dy={Y_LABEL_OFFSET}
>
{tick.label}
</text>
</g>
))}
{tooltipAnchor ? (
<line
className={styles.hoverLine}
x1={`${tooltipAnchor.xPercent}%`}
y1="0"
x2={`${tooltipAnchor.xPercent}%`}
y2="100%"
/>
) : null}
{highlight?.map((point, index) => (
<circle
key={index}
className={clsx(
styles.highlight,
styles[`highlight${index % HIGHLIGHT_COLORS_COUNT}`],
)}
cx={`${xPercent(point.x)}%`}
cy={`${yPercent(point.y)}%`}
r={5}
/>
))}
{peak ? (
<circle
className={clsx(styles.dot, seriesColorClass(0))}
cx={`${xPercent(peak.x)}%`}
cy={`${yPercent(peak.y)}%`}
r={4.5}
/>
) : null}
{hoveredPoints.map((point) => (
<circle
key={point.seriesIndex}
className={clsx(styles.dot, seriesColorClass(point.seriesIndex))}
cx={`${xPercent(point.x)}%`}
cy={`${yPercent(point.y)}%`}
r={4.5}
/>
))}
{!sparkline ? (
<text className={styles.label} x="0" y="100%" dy={X_LABEL_OFFSET}>
{formatX(minX)}
</text>
) : null}
{middleXLabels.map((xLabel) => (
<text
key={xLabel.value}
className={styles.label}
x={`${xPercent(xLabel.value)}%`}
y="100%"
dy={X_LABEL_OFFSET}
textAnchor="middle"
>
{xLabel.label}
</text>
))}
{!sparkline ? (
<text
className={styles.label}
x="100%"
y="100%"
dy={X_LABEL_OFFSET}
textAnchor="end"
>
{formatX(maxX)}
</text>
) : null}
</svg>
{peak && !tooltipAnchor ? (
<div
className={styles.peakLabel}
style={{
left: `clamp(${PEAK_LABEL_CLAMP - plotInset}px, ${xPercent(peak.x)}%, calc(100% + ${plotInset - PEAK_LABEL_CLAMP}px))`,
top: `${yPercent(peak.y)}%`,
}}
>
{formatValue(peak.y)}
</div>
) : null}
{tooltipAnchor ? (
<div
className={styles.tooltip}
style={{
left: `${tooltipAnchor.xPercent}%`,
top: `${tooltipAnchor.yPercent}%`,
// centered on the point but kept inside the chart's edges
translate: `clamp(${-plotInset - tooltipAnchorX}px, -50%, calc(${innerWidth + plotInset - tooltipAnchorX}px - 100%)) calc(-100% - var(--s-3))`,
}}
>
<div className={styles.tooltipHeader}>
{xAxis.type === "date"
? tooltipDateFormatter.format(new Date(hoveredX!))
: formatX(hoveredX!)}
</div>
{series.length === 1 ? (
<div className={styles.tooltipValue}>
{formatValue(hoveredPoints[0].y)}
</div>
) : (
hoveredPoints.map((point) => (
<div key={point.seriesIndex} className={styles.tooltipRow}>
<div
className={clsx(
styles.tooltipDot,
seriesColorClass(point.seriesIndex),
)}
/>
{point.label}
<div className={styles.tooltipRowValue}>
{formatValue(point.y)}
</div>
</div>
))
)}
</div>
) : null}
</div>
</div>
);
}
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) };
},
);
}