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 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 (
{saeule.lang} {reihen.map((r) => ( {zahl.format(saeule.werte[r.name] ?? 0)} {r.name} ))} {reihen.length > 1 && ( {zahl.format(summe)} zusammen )}
) } /** * 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(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) { 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) { 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 (
{sichtbar.length > 1 && (
    {sichtbar.map((r) => (
  • {r.name}
  • ))}
)}
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 && ( )} {ticks.map((v) => ( {zahl.format(v)} ))} {saeulen.map((s, i) => { const x = mitte(i) - dicke / 2 return ( {stapeln(sichtbar.map((r) => s.werte[r.name] ?? 0), y).filter((t) => t.hoehe > 0).map((t) => t.oben ? ( ) : ( ))} {summenListe[i] > 0 && (summen === "alle" || i === hoechste) && ( {zahl.format(summenListe[i])} )} {(s.raster ?? i) % jede === 0 && ( {s.achse} {s.achse2 && {s.achse2}} )} ) })} {max === 0 && ( Keine Anfragen in diesem Zeitraum )} {/* Trefferfläche: das ganze Band einer Säule samt Beschriftung — niemand muss ein schmales Stück treffen. */} { if (e.pointerType !== "touch") setIndex(null) }} /> {aktiv && index !== null && schwebend && ( )}
{aktiv && index !== null && !schwebend && }
Werte als Tabelle
{sichtbar.map((r) => ( ))} {sichtbar.length > 1 && } {saeulen.map((s, i) => ( {sichtbar.map((r) => ( ))} {sichtbar.length > 1 && } ))}
{spalte}{r.name}Zusammen
{s.lang}{zahl.format(s.werte[r.name] ?? 0)}{zahl.format(summenListe[i])}
) }