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>
226 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|