import { useRef, useState } from "react"; import { fmt } from "../../units"; import { tokenFarbe } from "../palette"; import useGroesse from "./useGroesse"; /** * Wasserfall- bzw. Brückendiagramm. * * Die Linie zeigt den Bestand, die Balken erklären jeden Sprung darin: grün nach * oben für Einlagerungen, rot nach unten für Auslagerungen. Ein Balken beginnt * dort, wo der vorherige Bestand stand – deshalb „Brücke“. * * Bewusst nur EINE Werteachse. Balken und Linie sind hier beide Artikeleinheiten, * nur deshalb dürfen sie überhaupt zusammen in ein Bild. Wären es Vorgänge gegen * Mengen, gehörten sie in zwei Karten. * * abschnitte: [{ label, opening, checked_in, checked_out }] */ export default function Wasserfall({ abschnitte = [], einheit = "" }) { const plot = useRef(null); const { breite, hoehe } = useGroesse(plot); const [index, setIndex] = useState(null); if (abschnitte.length < 2) { return
Keine Daten vorhanden.
; } const gruen = tokenFarbe("--ok", "#2e7d55"); const rot = tokenFarbe("--danger", "#c0392b"); const linie = tokenFarbe("--accent", "#2a78d6"); const gitterFarbe = tokenFarbe("--border", "#ddd"); const textFarbe = tokenFarbe("--muted", "#888"); const flaeche = tokenFarbe("--surface", "#fff"); // Ein Abschnitt läuft von opening über die Zwischenspitze zum Endbestand. const punkte = abschnitte.map((a) => { const spitze = a.opening + a.checked_in; return { ...a, spitze, schluss: spitze - a.checked_out }; }); const rand = { oben: 12, rechts: 12, unten: 24, links: 44 }; const w = Math.max(breite, 240); const h = Math.max(hoehe, 120); const flaecheB = Math.max(w - rand.links - rand.rechts, 10); const flaecheH = Math.max(h - rand.oben - rand.unten, 10); // Von null aus: Bei Mengen ist eine abgeschnittene Achse irreführend. const maxWert = Math.max(...punkte.map((p) => Math.max(p.opening, p.spitze, p.schluss)), 1); const skalaMax = maxWert * 1.1; const schlitz = flaecheB / punkte.length; const mitte = (i) => rand.links + schlitz * (i + 0.5); const y = (wert) => rand.oben + flaecheH - (wert / skalaMax) * flaecheH; const balkenB = Math.max(Math.min(schlitz - 2, 18), 1); const gitter = [0, 0.5, 1].map((teil) => ({ wert: skalaMax * teil, y: y(skalaMax * teil) })); const schritt = Math.max(1, Math.round(punkte.length / 5)); const achse = punkte.map((p, i) => ({ l: p.label, i })).filter(({ i }) => i % schritt === 0); /** Segment eines Balkens. Unter zwei Pixeln Höhe bliebe es unsichtbar – * dann lieber eine dünne Marke als gar nichts. */ function segment(von, bis, farbe, key) { const oben = Math.min(y(von), y(bis)); const hoch = Math.max(Math.abs(y(von) - y(bis)), 2); return ; } function bewegen(event) { const box = event.currentTarget.getBoundingClientRect(); const rel = event.clientX - box.left - rand.links; setIndex(Math.min(Math.max(Math.floor(rel / schlitz), 0), punkte.length - 1)); } const aktiv = index != null ? punkte[index] : null; return (
setIndex(null)}> {gitter.map((g) => ( {fmt(g.wert)} ))} {achse.map(({ l, i }) => ( {l} ))} {punkte.map((p, i) => ( {p.checked_in > 0 && segment(p.opening, p.spitze, gruen, "ein")} {p.checked_out > 0 && segment(p.spitze, p.schluss, rot, "aus")} ))} {/* Die Bestandslinie liegt über den Balken: Sie ist die Aussage, die Balken sind die Begründung. */} `${i === 0 ? "M" : "L"} ${mitte(i)} ${y(p.schluss)}`).join(" ")} /> {aktiv && ( <> )} {aktiv && (
{aktiv.label}
Eingelagert+{fmt(aktiv.checked_in)}
Ausgelagert−{fmt(aktiv.checked_out)}
Bestand{fmt(aktiv.schluss)} {einheit && {einheit}}
)}
); }