Files
mission-control-v2/frontend/src/components/diagramme/Verlauf.tsx
T
HitonabiandClaude Opus 5.5 dfe0971180 modelle: „Wer nutzt die Modelle“ neu — Kennzahlen, Tabelle je Absender, lesbare Säulendiagramme
Der User: „Viel zu dünn. Und den Graphen kann ja kein Mensch lesen.“ Vorher drei Balken und ein winziges
Stunden-Histogramm ohne lesbare Achsen, halb so breit neben dem Radar.

- Eigene Ansicht views/ModellNutzung.tsx in voller Breite (Radar darunter, ebenfalls volle Breite).
- Kennzahlen: Anfragen mit Schnitt je vollem Tag, heute/gestern, typische Antwort mit „9 von 10 bis“, Fehler
  (ab 1 % bernstein, ab 5 % rot, immer mit Wort dazu, nie nur Farbe).
- Tabelle je Absender: Anfragen mit Anteil-Balken, Antwortzeiten, Rechenzeit mit Anteil (NerdQuiz stellt
  92 % der Anfragen, aber nur 75 % der Rechenzeit), Fehler in Worten („Modell nicht erreichbar (502)“),
  letzte Anfrage. Schmal als Karten mit Beschriftung.
- Neuer Baustein components/diagramme/Saeulen.tsx für „je Tag (7 Tage)“ und „je Stunde (24 h)“: gestapelt je
  Absender, glatte y-Achse, Wochentag/Datum bzw. Uhrzeit an der x-Achse (Stunden ausgedünnt auf 3 bzw. 6 h),
  Legende, Summen auf den Tagessäulen (bei den Stunden nur die höchste), Anzeige per Zeiger, Finger und
  Pfeiltasten, darunter „Werte als Tabelle“. Auf dem Handy steht die Anzeige unter dem Diagramm, sonst
  verdeckte sie das halbe Bild. Säulen höchstens 24 px, 2 px Luft zwischen den Stücken statt Rändern.
- Farben: neue Tokens --reihe-3 (Gelb) und --reihe-weitere (Grau; ab der vierten Farbe fasst „Weitere“
  zusammen). Mit dem Paletten-Prüfer gegen #15181b gemessen: jedes Paar auch bei Rot-Grün-Schwäche getrennt.
  Türkis fiel durch (neben Magenta bei Grünschwäche ΔE 1,6), Violett gleicht der KI-Box-Farbe.
  Die Farbe folgt dem Absender (feste Reihe der Box), nicht dem Rang.
- „Wann welches Modell geladen wurde“ eingeklappt darunter: Anzahl, zuletzt, davor — mit Rolle (Hirn, Coder,
  Bild-Zwilling) statt nur Dateiname.
- useBreite aus Verlauf.tsx nach lib/breite.ts, damit beide Diagramme ihn teilen.
- Ältere Stände der Box ohne die neuen Felder zeigen weiter Anzahlen und Verläufe, Zeiten als „–“ — die
  Homelab-Instanz fragt über den Partner und kann einen anderen Stand haben.

frontend/dist ist bewusst nicht gebaut (kommt beim Zusammenführen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:26:45 +02:00

175 lines
7.7 KiB
TypeScript

import { type KeyboardEvent, type PointerEvent, useState } from "react"
import { useBreite } from "@/lib/breite"
import { flaechenPfad, linienPfad } from "@/lib/diagramm"
import { type Einheit, obergrenze, wertText, zeitText } from "@/lib/messung"
import type { Messpunkt } from "@/lib/typen"
export interface Reihe {
name: string
punkte: Messpunkt[]
/** CSS-Farbe der Linie, z. B. var(--reihe-1). Text trägt sie nie — nur die Marke daneben. */
farbe: string
}
const RAND = { rechts: 12, oben: 10, unten: 24 }
/**
* Zeitverlauf einer oder zweier Messreihen: dünne Linien, bei einer Reihe eine zarte Fläche, haarfeines Raster.
* Ein Fadenkreuz folgt Zeiger oder Pfeiltasten und zeigt alle Reihen am selben Zeitpunkt. Darunter liegen die
* Werte zum Aufklappen als Tabelle — der Tooltip ist nie der einzige Weg zu einer Zahl.
*/
export function Verlauf({ reihen, einheit, langerZeitraum, hoehe = 180, titel }: {
reihen: Reihe[]
einheit: Einheit
/** 7 Tage: Zeitachse mit Wochentag. */
langerZeitraum: boolean
hoehe?: number
titel: string
}) {
const [ref, breite] = useBreite()
const [index, setIndex] = useState<number | null>(null)
const basis = reihen[0]?.punkte ?? []
const n = basis.length
const max = obergrenze(reihen.map((r) => r.punkte), einheit)
const ticksY = einheit === "prozent" ? [0, 25, 50, 75, 100] : [0, max / 2, max]
// Der linke Rand richtet sich nach der längsten Beschriftung („500 KB/s“ braucht mehr als „75 %“).
const links = Math.max(40, Math.max(...ticksY.map((v) => wertText(v, einheit).length)) * 7 + 12)
const RAND_L = { ...RAND, links }
const innenB = breite - RAND_L.links - RAND.rechts
const innenH = hoehe - RAND.oben - RAND.unten
const t0 = basis[0]?.[0] ?? 0
const t1 = basis[n - 1]?.[0] ?? 1
const x = (t: number) => RAND_L.links + (t1 > t0 ? ((t - t0) / (t1 - t0)) * innenB : 0)
const y = (v: number) => RAND.oben + innenH - (Math.min(v, max) / (max || 1)) * innenH
const grund = RAND.oben + innenH
const ticksX = n > 1 ? [0, 0.25, 0.5, 0.75, 1].map((f) => basis[Math.round(f * (n - 1))][0]) : []
const hatWerte = reihen.some((r) => r.punkte.some(([, v]) => v !== null))
function zeigen(e: PointerEvent<SVGRectElement>) {
if (n === 0) return
const r = e.currentTarget.getBoundingClientRect()
const anteil = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
setIndex(Math.round(anteil * (n - 1)))
}
function taste(e: KeyboardEvent<SVGSVGElement>) {
if (n === 0) return
if (e.key === "ArrowLeft") setIndex((i) => Math.max(0, (i ?? n - 1) - 1))
else if (e.key === "ArrowRight") setIndex((i) => Math.min(n - 1, (i ?? n - 1) + 1))
else return
e.preventDefault()
}
const aktiv = index !== null && basis[index] ? basis[index][0] : null
const tipLinks = aktiv !== null ? x(aktiv) : 0
const letzte = basis.slice(-12)
return (
<div className="flex flex-col gap-2">
{reihen.length > 1 && (
<ul className="m-0 flex list-none flex-wrap gap-x-4 gap-y-1 p-0 text-sm text-text-2" aria-label="Legende">
{reihen.map((r) => (
<li key={r.name} className="flex items-center gap-2">
<span aria-hidden className="h-0.5 w-4 rounded-full" style={{ background: r.farbe }} />
{r.name}
</li>
))}
</ul>
)}
<div ref={ref} className="relative w-full">
<svg
width={breite}
height={hoehe}
role="img"
aria-label={titel}
tabIndex={0}
onKeyDown={taste}
onFocus={() => setIndex((i) => i ?? (n ? n - 1 : null))}
onBlur={() => setIndex(null)}
className="block max-w-full outline-none focus-visible:rounded-md focus-visible:ring-2 focus-visible:ring-cyan/60"
>
{ticksY.map((v) => (
<g key={v}>
<line x1={RAND_L.links} x2={breite - RAND.rechts} y1={y(v)} y2={y(v)} stroke="var(--linie)" strokeWidth={1} />
<text x={RAND_L.links - 8} y={y(v) + 4} textAnchor="end" className="fill-text-3 font-mono text-[11px]">
{wertText(v, einheit)}
</text>
</g>
))}
{ticksX.map((t, i) => (
<text key={t} x={x(t)} y={hoehe - 6} className="fill-text-3 font-mono text-[11px]"
textAnchor={i === 0 ? "start" : i === ticksX.length - 1 ? "end" : "middle"}>
{zeitText(t, langerZeitraum)}
</text>
))}
{hatWerte ? (
reihen.map((r) => (
<g key={r.name}>
{reihen.length === 1 && <path d={flaechenPfad(r.punkte, x, y, grund)} fill={r.farbe} opacity={0.1} />}
<path d={linienPfad(r.punkte, x, y)} fill="none" stroke={r.farbe} strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" />
</g>
))
) : (
<text x={RAND_L.links + innenB / 2} y={RAND.oben + innenH / 2} textAnchor="middle" className="fill-text-3 text-sm">
Noch keine Messwerte in diesem Zeitraum
</text>
)}
{aktiv !== null && (
<g aria-hidden>
<line x1={x(aktiv)} x2={x(aktiv)} y1={RAND.oben} y2={grund} stroke="var(--text-3)" strokeWidth={1} />
{reihen.map((r) => {
const v = r.punkte[index!]?.[1]
return v === null || v === undefined ? null : (
<circle key={r.name} cx={x(aktiv)} cy={y(v)} r={4} fill={r.farbe} stroke="var(--panel)" strokeWidth={2} />
)
})}
</g>
)}
{/* Trefferfläche: die ganze Zeichenfläche — niemand muss eine 2-px-Linie treffen. */}
<rect x={RAND_L.links} y={RAND.oben} width={innenB} height={innenH} fill="transparent"
onPointerMove={zeigen} onPointerLeave={() => setIndex(null)} />
</svg>
{aktiv !== null && (
<div
role="status"
className="pointer-events-none absolute top-1 z-10 flex min-w-36 flex-col gap-1 rounded-lg border border-linie-stark bg-erhaben px-3 py-2 shadow-lg"
style={tipLinks > breite / 2 ? { right: breite - tipLinks + 10 } : { left: tipLinks + 10 }}
>
<span className="ziffern text-xs text-text-3">{zeitText(aktiv, true)}</span>
{reihen.map((r) => (
<span key={r.name} className="flex items-center gap-2 text-sm">
<span aria-hidden className="h-0.5 w-3 rounded-full" style={{ background: r.farbe }} />
<span className="font-semibold text-foreground">{wertText(r.punkte[index!]?.[1], einheit)}</span>
{reihen.length > 1 && <span className="text-text-3">{r.name}</span>}
</span>
))}
</div>
)}
</div>
<details className="group text-sm">
<summary className="inline-flex cursor-pointer items-center text-text-3 hover:text-text-2">Werte als Tabelle</summary>
<table className="ziffern mt-2 w-full max-w-md border-collapse text-xs">
<thead>
<tr className="text-left text-text-3">
<th className="py-1 pr-4 font-normal">Zeit</th>
{reihen.map((r) => <th key={r.name} className="py-1 pr-4 font-normal">{r.name}</th>)}
</tr>
</thead>
<tbody>
{letzte.map(([t], i) => (
<tr key={t} className="border-t border-linie">
<td className="py-1 pr-4 text-text-3">{zeitText(t, langerZeitraum)}</td>
{reihen.map((r) => (
<td key={r.name} className="py-1 pr-4 text-text-2">
{wertText(r.punkte[n - letzte.length + i]?.[1], einheit)}
</td>
))}
</tr>
))}
</tbody>
</table>
</details>
</div>
)
}