SPB Git

Repository Public

LineChart.tsx 6.7 KB 133 lines tsx

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
Files main
Menu

Theme