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>
This commit is contained in:
Scarriffle
2026-07-23 12:45:59 +02:00
parent d6a717e39c
commit 26f7ed21d8
10 changed files with 336 additions and 32 deletions

View File

@@ -98,6 +98,20 @@ const SKIZZEN = {
</Rahmen>
),
wasserfall: (
<Rahmen>
<line x1="8" y1="36" x2="64" y2="36" stroke="var(--border-strong)" strokeWidth="1" />
{/* Schwebende Balken: jeder beginnt dort, wo der vorige endete. */}
<rect x="12" y="22" width="7" height="8" rx="2" fill="var(--ok)" opacity="0.85" />
<rect x="24" y="22" width="7" height="4" rx="2" fill="var(--danger)" opacity="0.85" />
<rect x="36" y="18" width="7" height="8" rx="2" fill="var(--ok)" opacity="0.85" />
<rect x="48" y="18" width="7" height="6" rx="2" fill="var(--danger)" opacity="0.85" />
<polyline points="8,30 15.5,22 27.5,26 39.5,18 51.5,24 64,24"
fill="none" stroke="var(--accent)" strokeWidth="2"
strokeLinecap="round" strokeLinejoin="round" />
</Rahmen>
),
linien: (
<Rahmen>
<line x1="8" y1="34" x2="64" y2="34" stroke="var(--border-strong)" strokeWidth="1" />

View File

@@ -9,6 +9,7 @@ import {
import Bars from "./charts/Bars";
import Donut from "./charts/Donut";
import Line from "./charts/Line";
import Wasserfall from "./charts/Wasserfall";
import { istDunkel, mitFarben, tokenFarbe } from "./palette";
/**
@@ -369,20 +370,21 @@ function KarteArtikelverlauf({ tage = 90 }) {
);
}
/**
* Ein- und Auslagerungen als Brücke: die Bestandslinie plus die Balken, die
* jeden ihrer Sprünge erklären. Die Mengen kommen deshalb in Artikeleinheiten
* und nicht als Anzahl Vorgänge sonst lägen Balken und Linie auf zwei Skalen.
*/
function KarteBewegungsverlauf({ tage = 30 }) {
const { daten, fehler } = useDaten(() => api.dashboardActivity(tage), [tage]);
const { daten, fehler } = useDaten(() => api.dashboardFlow(tage), [tage]);
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt</div>;
return (
<Line
labels={daten.map((p) => zeitLabel(p.at, tage))}
serien={[
{ name: "Eingelagert", color: tokenFarbe("--ok", "#2e7d55"), werte: daten.map((p) => p.checked_in) },
{ name: "Ausgelagert", color: tokenFarbe("--warn", "#b06f14"), werte: daten.map((p) => p.checked_out) },
]}
einheit="Vorgänge"
<Wasserfall
abschnitte={daten.map((p) => ({ ...p, label: zeitLabel(p.at, tage) }))}
einheit="Einheiten"
/>
);
}
@@ -482,9 +484,9 @@ export const KARTEN = {
komponente: KarteArtikelverlauf,
},
"activity-timeline": {
titel: "Ein- und Auslagerungen", icon: "history", vorschau: "linien",
beschreibung: "Vorgänge je Tag über 30 Tage",
zeitraum: 30, standard: { w: 8, h: 6 }, min: { w: 4, h: 5 },
titel: "Ein- und Auslagerungen", icon: "history", vorschau: "wasserfall",
beschreibung: "Brücke: Bestandslinie mit grünen und roten Balken je Bewegung",
zeitraum: 30, standard: { w: 8, h: 6 }, min: { w: 5, h: 5 },
komponente: KarteBewegungsverlauf,
},
};

View File

@@ -1,26 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useRef, useState } from "react";
import { fmt } from "../../units";
import { tokenFarbe } from "../palette";
/** Misst die tatsächliche Größe, damit das SVG in echten Pixeln zeichnet.
* Ein festes viewBox mit Skalierung würde Linienstärken verzerren.
* Auch die Höhe wird gemessen: Mit fester Höhe passte das Diagramm nicht mehr
* in die Karte, sobald darüber noch eine Auswahl stand es gab einen Rollbalken. */
function useGroesse(ref) {
const [groesse, setGroesse] = useState({ breite: 0, hoehe: 0 });
useEffect(() => {
if (!ref.current) return undefined;
const beobachter = new ResizeObserver(([eintrag]) => {
setGroesse({
breite: eintrag.contentRect.width,
hoehe: eintrag.contentRect.height,
});
});
beobachter.observe(ref.current);
return () => beobachter.disconnect();
}, [ref]);
return groesse;
}
import useGroesse from "./useGroesse";
/**
* Liniendiagramm über die Zeit.

View File

@@ -0,0 +1,148 @@
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>
);
}

View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
/**
* Misst die tatsächliche Größe eines Elements, damit ein SVG in echten Pixeln
* zeichnen kann. Ein festes viewBox mit Skalierung würde Linienstärken und
* Schriftgrößen verzerren.
*
* Auch die Höhe wird gemessen: Mit fester Höhe passt ein Diagramm nicht mehr in
* seine Karte, sobald darüber noch etwas steht es gäbe einen Rollbalken.
*/
export default function useGroesse(ref) {
const [groesse, setGroesse] = useState({ breite: 0, hoehe: 0 });
useEffect(() => {
if (!ref.current) return undefined;
const beobachter = new ResizeObserver(([eintrag]) => {
setGroesse({
breite: eintrag.contentRect.width,
hoehe: eintrag.contentRect.height,
});
});
beobachter.observe(ref.current);
return () => beobachter.disconnect();
}, [ref]);
return groesse;
}