Repository Public
1"use client";23import { useEffect, useMemo, useRef, useState } from "react";4import type { HistoryPoint } from "@/lib/live-types";5import type { Locale } from "@/lib/i18n";6import { fmtClock, fmtPct } from "./format";78export type Series = { key: "cpu" | "mem" | "gpu"; label: string; color: string };910const niceMax = (v: number) => [10, 20, 40, 60, 100].find((m) => v <= m * 0.92) ?? 100;1112/**13 * Utilisation over time: one shared percentage axis, 2px lines, hairline grid, legend, crosshair with a tooltip,14 * and a table view of the same data.15 */16export function LineChart({ points, series, locale, longWindow, alt, tableLabel, timeLabel, height = 240 }: { points: HistoryPoint[]; series: Series[]; locale: Locale; longWindow: boolean; alt: string; tableLabel: string; timeLabel: string; height?: number }) {17 const wrap = useRef<HTMLDivElement>(null);18 const [width, setWidth] = useState(0);19 const [hover, setHover] = useState<number | null>(null);2021 useEffect(() => {22 const el = wrap.current;23 if (!el) return;24 const ro = new ResizeObserver(([e]) => setWidth(Math.round(e.contentRect.width)));25 ro.observe(el);26 return () => ro.disconnect();27 }, []);2829 const shown = useMemo(() => series.filter((s) => points.some((p) => p[s.key] !== null)), [series, points]);30 const m = { l: 38, r: 14, t: 10, b: 24 };31 const iw = Math.max(10, width - m.l - m.r);32 const ih = height - m.t - m.b;33 const t0 = points[0]?.t ?? 0;34 const t1 = points.at(-1)?.t ?? 1;35 const yMax = niceMax(Math.max(1, ...points.flatMap((p) => shown.map((s) => p[s.key] ?? 0))));36 const x = (t: number) => m.l + (t1 === t0 ? 0 : ((t - t0) / (t1 - t0)) * iw);37 const y = (v: number) => m.t + ih - (v / yMax) * ih;38 const yTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => f * yMax);39 const xTickCount = width < 480 ? 3 : 5;40 const xTicks = Array.from({ length: xTickCount }, (_, i) => t0 + ((t1 - t0) * i) / (xTickCount - 1));4142 const path = (key: Series["key"]) => {43 let d = "", pen = false;44 for (const p of points) {45 const v = p[key];46 if (v === null) { pen = false; continue; }47 d += `${pen ? "L" : "M"}${x(p.t).toFixed(1)},${y(v).toFixed(1)}`;48 pen = true;49 }50 return d;51 };5253 const onMove = (clientX: number) => {54 const el = wrap.current;55 if (!el || points.length === 0) return;56 const px = clientX - el.getBoundingClientRect().left;57 const t = t0 + ((px - m.l) / iw) * (t1 - t0);58 let best = 0;59 for (let i = 1; i < points.length; i++) if (Math.abs(points[i].t - t) < Math.abs(points[best].t - t)) best = i;60 setHover(best);61 };6263 const hp = hover !== null ? points[hover] : null;64 const tipLeft = hp ? Math.min(Math.max(x(hp.t) + 12, 4), Math.max(4, width - 164)) : 0;65 const step = Math.max(1, Math.ceil(points.length / 24));66 const rows = points.filter((_, i) => i % step === 0 || i === points.length - 1);6768 return (69 <div>70 <ul className="flex flex-wrap gap-x-5 gap-y-1.5 text-[0.8125rem] text-body" aria-hidden="true">71 {shown.map((s) => (72 <li key={s.key} className="flex items-center gap-2">73 <span className="h-0.5 w-4 rounded-full" style={{ background: s.color }} />74 {s.label}75 {points.length ? <span className="num font-mono text-fg">{fmtPct(points.at(-1)![s.key] ?? 0, locale, 1)}</span> : null}76 </li>77 ))}78 </ul>79 <div ref={wrap} className="relative mt-3 touch-pan-y" style={{ height }} onPointerMove={(e) => onMove(e.clientX)} onPointerDown={(e) => onMove(e.clientX)} onPointerLeave={() => setHover(null)}>80 {width > 0 ? (81 <svg width={width} height={height} role="img" aria-label={alt} className="block">82 {yTicks.map((v) => (83 <g key={v}>84 <line x1={m.l} x2={width - m.r} y1={y(v)} y2={y(v)} stroke="var(--color-viz-grid)" strokeWidth="1" />85 <text x={m.l - 8} y={y(v) + 4} textAnchor="end" fontSize="11" fill="var(--color-mute)" fontFamily="var(--font-mono)">{Math.round(v)}</text>86 </g>87 ))}88 {xTicks.map((t, i) => (89 <text key={i} x={x(t)} y={height - 6} textAnchor={i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle"} fontSize="11" fill="var(--color-mute)" fontFamily="var(--font-mono)">{fmtClock(t, locale, longWindow)}</text>90 ))}91 {shown.map((s) => <path key={s.key} d={path(s.key)} fill="none" stroke={s.color} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />)}92 {hp ? (93 <g>94 <line x1={x(hp.t)} x2={x(hp.t)} y1={m.t} y2={m.t + ih} stroke="var(--color-line-strong)" strokeWidth="1" />95 {shown.map((s) => (hp[s.key] !== null ? <circle key={s.key} cx={x(hp.t)} cy={y(hp[s.key]!)} r="4.5" fill={s.color} stroke="var(--color-surface)" strokeWidth="2" /> : null))}96 </g>97 ) : null}98 </svg>99 ) : null}100 {hp ? (101 <div className="pointer-events-none absolute top-1 z-10 w-40 rounded-xl border border-line bg-surface p-3 text-[0.8125rem] shadow-lift" style={{ left: tipLeft }}>102 <p className="num font-mono text-[0.6875rem] text-mute">{fmtClock(hp.t, locale, longWindow)}</p>103 {shown.map((s) => (104 <p key={s.key} className="mt-1.5 flex items-center justify-between gap-3">105 <span className="flex items-center gap-2 text-body"><span className="h-2 w-2 rounded-full" style={{ background: s.color }} />{s.label}</span>106 <span className="num font-mono text-fg">{hp[s.key] !== null ? fmtPct(hp[s.key]!, locale, 1) : "—"}</span>107 </p>108 ))}109 </div>110 ) : null}111 </div>112 <details className="mt-3 text-[0.8125rem]">113 <summary className="cursor-pointer text-mute hover:text-fg">{tableLabel}</summary>114 <div className="mt-3 max-h-64 overflow-auto rounded-xl border border-line" tabIndex={0} role="region" aria-label={tableLabel}>115 <table className="num w-full text-left font-mono text-[0.75rem]">116 <thead className="sticky top-0 bg-surface-2 text-mute">117 <tr><th scope="col" className="px-3 py-2 font-normal">{timeLabel}</th>{shown.map((s) => <th key={s.key} scope="col" className="px-3 py-2 text-right font-normal">{s.label}</th>)}</tr>118 </thead>119 <tbody>120 {rows.map((p) => (121 <tr key={p.t} className="border-t border-line">122 <th scope="row" className="px-3 py-1.5 font-normal text-body">{fmtClock(p.t, locale, longWindow)}</th>123 {shown.map((s) => <td key={s.key} className="px-3 py-1.5 text-right">{p[s.key] !== null ? fmtPct(p[s.key]!, locale, 1) : "—"}</td>)}124 </tr>125 ))}126 </tbody>127 </table>128 </div>129 </details>130 </div>131 );132}133