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>
149 lines
6.4 KiB
JavaScript
149 lines
6.4 KiB
JavaScript
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>
|
||
);
|
||
}
|