Files
Vorrania/web/src/dashboard/charts/Wasserfall.jsx
Scarriffle 26f7ed21d8 Ein- und Auslagerungen als Wasserfall- bzw. Brueckendiagramm
Bisher waren es zwei Linien mit der Anzahl der Vorgaenge. Gemeint war eine
Bruecke: die Bestandslinie, und schwebende gruene/rote Balken, die jeden ihrer
Spruenge erklaeren.

Damit Balken und Linie ueberhaupt zusammen in ein Bild duerfen, zaehlen die
Balken jetzt Mengen in Artikeleinheiten statt Vorgaengen - beides liegt so auf
derselben Achse. Zwei Skalen in einem Diagramm bleiben ausgeschlossen.

Neuer Endpunkt GET /dashboard/flow liefert je Abschnitt Anfangsbestand, Zugang
und Abgang aus einer Abfrage. Getrennt abgefragt koennten die Abschnitte von
timeline und activity um eine Schrittweite auseinanderliegen - dann stuende ein
Balken neben dem Sprung, den er erklaert.

/dashboard/activity bleibt fuer externe Zugriffe erhalten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 12:45:59 +02:00

149 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <div className="chart-line"><div className="empty">Keine Daten vorhanden.</div></div>;
}
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 <rect key={key} x={-balkenB / 2} y={oben} width={balkenB} height={hoch}
rx={Math.min(3, balkenB / 2)} fill={farbe} stroke={flaeche} strokeWidth="1" />;
}
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 (
<div className="chart-line">
<ul className="chart-legend inline">
<li><span className="swatch" style={{ background: gruen }} /><span className="legend-name">Eingelagert</span></li>
<li><span className="swatch" style={{ background: rot }} /><span className="legend-name">Ausgelagert</span></li>
<li><span className="swatch linie" style={{ background: linie }} /><span className="legend-name">Bestand</span></li>
</ul>
<div className="line-plot" ref={plot}>
<svg width={w} height={h} role="img"
aria-label="Brückendiagramm: Bestand mit Ein- und Auslagerungen"
onMouseMove={bewegen} onMouseLeave={() => setIndex(null)}>
{gitter.map((g) => (
<g key={g.wert}>
<line x1={rand.links} x2={w - rand.rechts} y1={g.y} y2={g.y}
stroke={gitterFarbe} strokeWidth="1" />
<text x={rand.links - 8} y={g.y + 4} textAnchor="end"
fontSize="11" fill={textFarbe}>{fmt(g.wert)}</text>
</g>
))}
{achse.map(({ l, i }) => (
<text key={l + i} x={mitte(i)} y={h - 6} textAnchor="middle"
fontSize="11" fill={textFarbe}>{l}</text>
))}
{punkte.map((p, i) => (
<g key={p.label + i} transform={`translate(${mitte(i)} 0)`}>
{p.checked_in > 0 && segment(p.opening, p.spitze, gruen, "ein")}
{p.checked_out > 0 && segment(p.spitze, p.schluss, rot, "aus")}
</g>
))}
{/* Die Bestandslinie liegt über den Balken: Sie ist die Aussage,
die Balken sind die Begründung. */}
<path fill="none" stroke={linie} strokeWidth="2"
strokeLinejoin="round" strokeLinecap="round"
d={punkte.map((p, i) => `${i === 0 ? "M" : "L"} ${mitte(i)} ${y(p.schluss)}`).join(" ")} />
{aktiv && (
<>
<line x1={mitte(index)} x2={mitte(index)} y1={rand.oben} y2={rand.oben + flaecheH}
stroke={gitterFarbe} strokeWidth="1" />
<circle cx={mitte(index)} cy={y(aktiv.schluss)} r="4.5"
fill={linie} stroke={flaeche} strokeWidth="2" />
</>
)}
</svg>
{aktiv && (
<div className="line-tooltip" style={{ left: Math.min(Math.max(mitte(index), 70), w - 70) }}>
<div className="tip-date">{aktiv.label}</div>
<div className="tip-row">
<span className="swatch" style={{ background: gruen }} />
<span className="tip-name">Eingelagert</span><strong>+{fmt(aktiv.checked_in)}</strong>
</div>
<div className="tip-row">
<span className="swatch" style={{ background: rot }} />
<span className="tip-name">Ausgelagert</span><strong>{fmt(aktiv.checked_out)}</strong>
</div>
<div className="tip-row">
<span className="swatch linie" style={{ background: linie }} />
<span className="tip-name">Bestand</span><strong>{fmt(aktiv.schluss)}</strong>
{einheit && <span className="muted small"> {einheit}</span>}
</div>
</div>
)}
</div>
</div>
);
}