Files
mission-control-v2/frontend/src/components/diagramme/Saeulen.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

226 lines
10 KiB
TypeScript

import { type KeyboardEvent, type PointerEvent, useState } from "react"
import { cn } from "cn"
import { useBreite } from "@/lib/breite"
import { type Saeule, saeulenPfad, stapeln, zaehlAchse } from "@/lib/diagramm"
export interface SaeulenReihe {
name: string
/** CSS-Farbe der Säulenstücke, z. B. var(--reihe-1). Text trägt sie nie — nur die Marke daneben. */
farbe: string
}
const zahl = new Intl.NumberFormat("de-DE")
/** Breite der Anzeige in px (w-60) — fest, damit sie sich ohne Messen im Diagramm halten lässt. */
const TIPP_BREITE = 240
/** Säulen bleiben schmal; der Rest des Platzes ist Luft. */
const SAEULE_MAX = 24
const SCHRITT: Record<string, (i: number, n: number) => number> = {
ArrowLeft: (i) => Math.max(0, i - 1),
ArrowRight: (i, n) => Math.min(n - 1, i + 1),
Home: () => 0,
End: (_, n) => n - 1,
}
/** Die Werte einer Säule: Zahl vorn, Name dahinter, die Farbe nur als kurzer Strich. */
function Anzeige({ saeule, reihen, summe, className, links }: {
saeule: Saeule
reihen: SaeulenReihe[]
summe: number
className?: string
links?: number
}) {
return (
<div role="status" className={cn("flex flex-col gap-1 rounded-lg border border-linie-stark bg-erhaben px-3 py-2", className)}
style={links === undefined ? undefined : { left: links }}>
<span className="ziffern text-xs text-text-3">{saeule.lang}</span>
{reihen.map((r) => (
<span key={r.name} className="flex items-baseline gap-2 text-sm">
<span aria-hidden className="h-0.5 w-3 shrink-0 self-center rounded-full" style={{ background: r.farbe }} />
<span className="ziffern font-semibold text-foreground">{zahl.format(saeule.werte[r.name] ?? 0)}</span>
<span className="min-w-0 text-text-3">{r.name}</span>
</span>
))}
{reihen.length > 1 && (
<span className="flex items-baseline gap-2 border-t border-linie pt-1 text-sm">
<span className="ziffern font-semibold text-foreground">{zahl.format(summe)}</span>
<span className="text-text-3">zusammen</span>
</span>
)}
</div>
)
}
/**
* Gestapelte Säulen für Zählwerte (Anfragen je Tag, je Stunde): eine Säule je Zeitraum, darin ein Stück je Reihe in
* fester Farbe, 2 px Luft zwischen den Stücken. Zeiger, Finger oder Pfeiltasten wählen eine Säule; die Anzeige nennt
* dann alle Reihen und die Summe. Darunter stehen die Werte zum Aufklappen als Tabelle — die Anzeige ist nie der
* einzige Weg zu einer Zahl.
*/
export function Saeulen({ saeulen, reihen, titel, summen = "alle", hoehe = 200, spalte = "Zeitraum" }: {
saeulen: Saeule[]
/** In Stapelfolge, unten zuerst. */
reihen: SaeulenReihe[]
titel: string
/** Summe über jeder Säule (bei wenigen Säulen) oder nur über der höchsten. */
summen?: "alle" | "hoechste"
hoehe?: number
/** Kopf der ersten Tabellenspalte, z. B. „Tag“ oder „Stunde“. */
spalte?: string
}) {
const [ref, breite] = useBreite()
const [index, setIndex] = useState<number | null>(null)
const n = saeulen.length
// Nur Reihen, die im Zeitraum vorkommen — sonst stünden Farben ohne Säulen in der Legende.
const sichtbar = reihen.filter((r) => saeulen.some((s) => (s.werte[r.name] ?? 0) > 0))
const summenListe = saeulen.map((s) => sichtbar.reduce((a, r) => a + (s.werte[r.name] ?? 0), 0))
const max = Math.max(0, ...summenListe)
const ticks = zaehlAchse(max)
const oben = ticks[ticks.length - 1]
const rand = {
links: Math.max(28, zahl.format(oben).length * 7 + 12),
rechts: 4,
oben: 20,
unten: saeulen.some((s) => s.achse2) ? 34 : 22,
}
const innenB = Math.max(1, breite - rand.links - rand.rechts)
const innenH = hoehe - rand.oben - rand.unten
const band = innenB / Math.max(1, n)
const dicke = Math.min(SAEULE_MAX, Math.max(3, band * 0.72))
const y = (v: number) => rand.oben + innenH - (v / oben) * innenH
const grund = rand.oben + innenH
const mitte = (i: number) => rand.links + (i + 0.5) * band
// Beschriftung ausdünnen, bis sie sich nicht berührt; bei Stunden landet sie so auf 0, 3, 6 … bzw. 0, 6, 12, 18 Uhr.
const noetig = Math.max(1, ...saeulen.map((s) => s.achse.length)) * 6.6 + 8
const jede = [1, 3, 6, 12].find((k) => k * band >= noetig) ?? 24
const hoechste = summenListe.indexOf(max)
const eng = band < 34
function zeigen(e: PointerEvent<SVGRectElement>) {
if (n === 0) return
const r = e.currentTarget.getBoundingClientRect()
setIndex(Math.min(n - 1, Math.max(0, Math.floor(((e.clientX - r.left) / r.width) * n))))
}
function taste(e: KeyboardEvent<SVGSVGElement>) {
const schritt = SCHRITT[e.key]
if (n === 0 || !schritt) return
e.preventDefault()
setIndex((i) => schritt(i ?? n - 1, n))
}
const aktiv = index !== null ? saeulen[index] : undefined
// Ab Tablet-Breite schwebt die Anzeige neben der Säule, auf der Seite mit mehr Platz und immer ganz im Diagramm.
// Auf dem Handy bedeckte sie das halbe Diagramm — dort steht sie nach dem Antippen darunter.
const schwebend = breite >= 360
const tippLinks = index === null ? 0 : Math.max(0, Math.min(breite - TIPP_BREITE,
mitte(index) + 12 + TIPP_BREITE > breite ? mitte(index) - 12 - TIPP_BREITE : mitte(index) + 12))
return (
<div className="flex flex-col gap-2">
{sichtbar.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">
{sichtbar.map((r) => (
<li key={r.name} className="flex items-center gap-2">
<span aria-hidden className="size-2.5 shrink-0 rounded-[2px]" style={{ background: r.farbe }} />
{r.name}
</li>
))}
</ul>
)}
<div ref={ref} className="relative w-full">
<svg
width={breite}
height={hoehe}
viewBox={`0 0 ${breite} ${hoehe}`}
role="img"
aria-label={titel}
tabIndex={0}
onKeyDown={taste}
onFocus={() => setIndex((i) => i ?? (n ? n - 1 : null))}
onBlur={() => setIndex(null)}
className="block h-auto max-w-full outline-none focus-visible:rounded-md focus-visible:ring-2 focus-visible:ring-cyan/60"
>
{aktiv && index !== null && (
<rect x={rand.links + index * band} y={rand.oben - 4} width={band} height={innenH + 4} rx={4} fill="var(--erhaben)" />
)}
{ticks.map((v) => (
<g key={v}>
<line x1={rand.links} x2={breite - rand.rechts} y1={y(v)} y2={y(v)} strokeWidth={1}
stroke={v === 0 ? "var(--linie-stark)" : "var(--linie)"} />
<text x={rand.links - 8} y={y(v) + 4} textAnchor="end" className="fill-text-3 font-mono text-[11px]">
{zahl.format(v)}
</text>
</g>
))}
{saeulen.map((s, i) => {
const x = mitte(i) - dicke / 2
return (
<g key={s.schluessel}>
{stapeln(sichtbar.map((r) => s.werte[r.name] ?? 0), y).filter((t) => t.hoehe > 0).map((t) =>
t.oben ? (
<path key={t.index} d={saeulenPfad(x, t.y, dicke, t.hoehe)} fill={sichtbar[t.index].farbe} />
) : (
<rect key={t.index} x={x} y={t.y} width={dicke} height={t.hoehe} fill={sichtbar[t.index].farbe} />
))}
{summenListe[i] > 0 && (summen === "alle" || i === hoechste) && (
<text x={mitte(i)} y={y(summenListe[i]) - 6} textAnchor="middle"
className={cn("fill-text-2 font-mono", eng ? "text-[10px]" : "text-[11px]")}>
{zahl.format(summenListe[i])}
</text>
)}
{(s.raster ?? i) % jede === 0 && (
<text x={mitte(i)} y={grund + 15} textAnchor="middle" className="fill-text-3 font-mono text-[11px]">
{s.achse}
{s.achse2 && <tspan x={mitte(i)} dy={13} className="text-[10px]">{s.achse2}</tspan>}
</text>
)}
</g>
)
})}
{max === 0 && (
<text x={rand.links + innenB / 2} y={rand.oben + innenH / 2} textAnchor="middle" className="fill-text-3 text-sm">
Keine Anfragen in diesem Zeitraum
</text>
)}
{/* Trefferfläche: das ganze Band einer Säule samt Beschriftung — niemand muss ein schmales Stück treffen. */}
<rect x={rand.links} y={0} width={innenB} height={hoehe} fill="transparent"
onPointerMove={zeigen} onPointerDown={zeigen}
onPointerLeave={(e) => { if (e.pointerType !== "touch") setIndex(null) }} />
</svg>
{aktiv && index !== null && schwebend && (
<Anzeige saeule={aktiv} reihen={sichtbar} summe={summenListe[index]} links={tippLinks}
className="pointer-events-none absolute top-1 z-10 w-60 shadow-lg" />
)}
</div>
{aktiv && index !== null && !schwebend && <Anzeige saeule={aktiv} reihen={sichtbar} summe={summenListe[index]} />}
<details className="text-sm">
<summary className="inline-flex cursor-pointer items-center text-text-3 hover:text-text-2">Werte als Tabelle</summary>
<div className="mt-2 overflow-x-auto">
<table className="w-full border-collapse text-xs">
<thead>
<tr className="text-text-3">
<th scope="col" className="py-1 pr-3 text-left font-normal">{spalte}</th>
{sichtbar.map((r) => (
<th key={r.name} scope="col" className="py-1 pr-3 text-right font-normal [overflow-wrap:anywhere]">{r.name}</th>
))}
{sichtbar.length > 1 && <th scope="col" className="py-1 text-right font-normal">Zusammen</th>}
</tr>
</thead>
<tbody>
{saeulen.map((s, i) => (
<tr key={s.schluessel} className="border-t border-linie">
<th scope="row" className="py-1 pr-3 text-left font-normal text-text-3">{s.lang}</th>
{sichtbar.map((r) => (
<td key={r.name} className="ziffern py-1 pr-3 text-right text-text-2">{zahl.format(s.werte[r.name] ?? 0)}</td>
))}
{sichtbar.length > 1 && <td className="ziffern py-1 text-right text-foreground">{zahl.format(summenListe[i])}</td>}
</tr>
))}
</tbody>
</table>
</div>
</details>
</div>
)
}