Merge branch 'worktree-agent-a7ad50f83f2d4236b' into wartung/helfer-nutzung-dienste
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react"
|
||||
import type { Saeule } from "@/lib/diagramm"
|
||||
import { Saeulen } from "./Saeulen"
|
||||
|
||||
const REIHEN = [{ name: "A", farbe: "var(--reihe-1)" }, { name: "B", farbe: "var(--reihe-2)" }, { name: "C", farbe: "var(--reihe-3)" }]
|
||||
|
||||
const stunden = Array.from({ length: 24 }, (_, i): Saeule => {
|
||||
const h = (15 + i) % 24
|
||||
return {
|
||||
schluessel: String(i), achse: `${String(h).padStart(2, "0")}:00`, raster: h, lang: `${h} Uhr`,
|
||||
werte: h === 3 ? { A: 139 } : { A: 5, B: i % 2 },
|
||||
}
|
||||
})
|
||||
|
||||
/** Der Behälter meldet eine feste Breite, wie ein Handy (jsdom kennt keinen ResizeObserver). */
|
||||
function breiteVortaeuschen(px: number) {
|
||||
vi.stubGlobal("ResizeObserver", class {
|
||||
constructor(private melden: (e: { contentRect: { width: number } }[]) => void) {}
|
||||
observe() { this.melden([{ contentRect: { width: px } }]) }
|
||||
disconnect() {}
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
describe("Saeulen", () => {
|
||||
it("dünnt die Stundenbeschriftung aus und nennt nur die höchste Summe", () => {
|
||||
const { container } = render(<Saeulen saeulen={stunden} reihen={REIHEN} titel="Je Stunde" summen="hoechste" />)
|
||||
const texte = [...container.querySelectorAll("svg text")].map((t) => t.textContent)
|
||||
expect(texte).toEqual(expect.arrayContaining(["15:00", "18:00", "00:00", "03:00", "139"]))
|
||||
expect(texte).not.toContain("16:00")
|
||||
expect(texte.filter((t) => t === "6")).toHaveLength(0) // die übrigen Summen stehen nur in Anzeige und Tabelle
|
||||
})
|
||||
|
||||
it("zeigt in der Legende nur Reihen mit Werten", () => {
|
||||
render(<Saeulen saeulen={stunden} reihen={REIHEN} titel="Je Stunde" />)
|
||||
expect(within(screen.getByRole("list", { name: "Legende" })).getAllByRole("listitem").map((l) => l.textContent))
|
||||
.toEqual(["A", "B"])
|
||||
})
|
||||
|
||||
it("stellt die Anzeige auf dem Handy unter das Diagramm statt darüber", () => {
|
||||
breiteVortaeuschen(300)
|
||||
render(<Saeulen saeulen={stunden} reihen={REIHEN} titel="Je Stunde" />)
|
||||
const bild = screen.getByRole("img", { name: "Je Stunde" })
|
||||
expect(bild).toHaveAttribute("width", "300")
|
||||
fireEvent.focus(bild)
|
||||
const anzeige = screen.getByRole("status")
|
||||
expect(anzeige).not.toHaveClass("absolute")
|
||||
expect(anzeige).toHaveTextContent("14 Uhr")
|
||||
expect(bild.compareDocumentPosition(anzeige) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
it("sagt es, wenn es nichts zu zeigen gibt", () => {
|
||||
render(<Saeulen saeulen={[{ schluessel: "x", achse: "Sa", lang: "Samstag", werte: {} }]} reihen={REIHEN} titel="Leer" />)
|
||||
expect(screen.getByText("Keine Anfragen in diesem Zeitraum")).toBeInTheDocument()
|
||||
expect(screen.queryByRole("list", { name: "Legende" })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type KeyboardEvent, type PointerEvent, useEffect, useRef, useState } from "react"
|
||||
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"
|
||||
@@ -12,20 +13,6 @@ export interface Reihe {
|
||||
|
||||
const RAND = { rechts: 12, oben: 10, unten: 24 }
|
||||
|
||||
/** Breite des Behälters; ohne ResizeObserver (Tests) bleibt es beim Standard. */
|
||||
function useBreite(standard = 640) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [breite, setBreite] = useState(standard)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || typeof ResizeObserver === "undefined") return
|
||||
const beobachter = new ResizeObserver(([e]) => setBreite(Math.max(240, Math.round(e.contentRect.width))))
|
||||
beobachter.observe(el)
|
||||
return () => beobachter.disconnect()
|
||||
}, [])
|
||||
return [ref, breite] as const
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
Reference in New Issue
Block a user