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:
@@ -38,6 +38,7 @@ from ..schemas import (
|
|||||||
DashboardLayoutOut,
|
DashboardLayoutOut,
|
||||||
DashboardStats,
|
DashboardStats,
|
||||||
ExpirySplit,
|
ExpirySplit,
|
||||||
|
FlowPoint,
|
||||||
TimelinePoint,
|
TimelinePoint,
|
||||||
)
|
)
|
||||||
from ..services.conversion import article_unit
|
from ..services.conversion import article_unit
|
||||||
@@ -388,6 +389,82 @@ def timeline(
|
|||||||
return punkte
|
return punkte
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/flow", response_model=list[FlowPoint])
|
||||||
|
def flow(
|
||||||
|
days: int = 30,
|
||||||
|
product_id: int | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> list[FlowPoint]:
|
||||||
|
"""Wasserfall: Anfangsbestand je Abschnitt plus Zu- und Abgang.
|
||||||
|
|
||||||
|
Balken und Bestandslinie kommen bewusst aus *einer* Abfrage. Getrennt
|
||||||
|
abgefragt könnten die Abschnitte der beiden Antworten um eine Schrittweite
|
||||||
|
auseinanderliegen – dann stünde ein Balken neben dem Sprung, den er erklärt.
|
||||||
|
|
||||||
|
Anders als :func:`activity` zählt das hier keine Vorgänge, sondern Mengen in
|
||||||
|
Artikeleinheiten: Nur so liegen Balken und Linie auf derselben Achse.
|
||||||
|
"""
|
||||||
|
days = max(1, min(days, 730))
|
||||||
|
jetzt, schritt, anzahl = _eimer(days)
|
||||||
|
|
||||||
|
produkte = db.query(Product)
|
||||||
|
if product_id is not None:
|
||||||
|
produkte = produkte.filter(Product.id == product_id)
|
||||||
|
produkte = produkte.all()
|
||||||
|
if not produkte:
|
||||||
|
return []
|
||||||
|
|
||||||
|
faktoren = {p.id: (article_unit(p)[0] or 1.0) for p in produkte}
|
||||||
|
bestand = {p.id: current_stock(db, p.id) for p in produkte}
|
||||||
|
|
||||||
|
beginn = jetzt - schritt * anzahl
|
||||||
|
bewegungen = db.query(Movement).filter(Movement.created_at >= beginn)
|
||||||
|
if product_id is not None:
|
||||||
|
bewegungen = bewegungen.filter(Movement.product_id == product_id)
|
||||||
|
|
||||||
|
sekunden = schritt.total_seconds()
|
||||||
|
delta: dict[int, dict[int, float]] = defaultdict(lambda: defaultdict(float))
|
||||||
|
ein: dict[int, float] = defaultdict(float)
|
||||||
|
aus: dict[int, float] = defaultdict(float)
|
||||||
|
for m in bewegungen.all():
|
||||||
|
if m.product_id not in faktoren:
|
||||||
|
continue
|
||||||
|
index = int((jetzt - _utc(m.created_at)).total_seconds() // sekunden)
|
||||||
|
if not 0 <= index < anzahl:
|
||||||
|
continue
|
||||||
|
menge = _signiert(m)
|
||||||
|
delta[index][m.product_id] += menge
|
||||||
|
# Korrekturen tragen ihr Vorzeichen bereits – deshalb nach Vorzeichen
|
||||||
|
# einsortieren und nicht nach Bewegungsart.
|
||||||
|
einheiten = menge / faktoren[m.product_id]
|
||||||
|
if einheiten >= 0:
|
||||||
|
ein[index] += einheiten
|
||||||
|
else:
|
||||||
|
aus[index] -= einheiten
|
||||||
|
|
||||||
|
def summe() -> float:
|
||||||
|
return round(sum(bestand[pid] / faktoren[pid] for pid in bestand), 3)
|
||||||
|
|
||||||
|
# Rückwärts durch die Abschnitte: Der Endbestand des Abschnitts ist bekannt,
|
||||||
|
# der Anfangsbestand ergibt sich daraus, dass die Bewegungen herausfallen.
|
||||||
|
punkte: list[FlowPoint] = []
|
||||||
|
for i in range(anzahl):
|
||||||
|
for pid, wert in delta.get(i, {}).items():
|
||||||
|
bestand[pid] = bestand.get(pid, 0.0) - wert
|
||||||
|
punkte.append(
|
||||||
|
FlowPoint(
|
||||||
|
at=jetzt - schritt * i,
|
||||||
|
opening=summe(),
|
||||||
|
checked_in=round(ein.get(i, 0.0), 3),
|
||||||
|
checked_out=round(aus.get(i, 0.0), 3),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
punkte.reverse()
|
||||||
|
return punkte
|
||||||
|
|
||||||
|
|
||||||
@router.get("/activity", response_model=list[ActivityPoint])
|
@router.get("/activity", response_model=list[ActivityPoint])
|
||||||
def activity(
|
def activity(
|
||||||
days: int = 30,
|
days: int = 30,
|
||||||
|
|||||||
@@ -441,3 +441,17 @@ class ActivityPoint(BaseModel):
|
|||||||
at: datetime
|
at: datetime
|
||||||
checked_in: int
|
checked_in: int
|
||||||
checked_out: int
|
checked_out: int
|
||||||
|
|
||||||
|
|
||||||
|
class FlowPoint(BaseModel):
|
||||||
|
"""Ein Abschnitt eines Wasserfalls: Anfangsbestand, Zu- und Abgang.
|
||||||
|
|
||||||
|
Alle Werte sind Artikeleinheiten und liegen damit auf derselben Skala wie
|
||||||
|
die Bestandslinie – nur so darf beides in ein Diagramm.
|
||||||
|
``opening + checked_in - checked_out`` ergibt den Endbestand.
|
||||||
|
"""
|
||||||
|
|
||||||
|
at: datetime
|
||||||
|
opening: float
|
||||||
|
checked_in: float
|
||||||
|
checked_out: float
|
||||||
|
|||||||
@@ -134,6 +134,44 @@ def test_timeline_kann_auf_ein_produkt_eingegrenzt_werden(db, rice, user):
|
|||||||
assert nur_reis[-1].article_units == 2
|
assert nur_reis[-1].article_units == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_flow_liefert_eine_geschlossene_bruecke(db, rice, user):
|
||||||
|
"""Anfangsbestand + Zugang - Abgang muss den Endbestand ergeben."""
|
||||||
|
check_in(db, rice, 5, "package", None, None, user)
|
||||||
|
check_out(db, rice, 2, "package", user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
abschnitte = dashboard.flow(days=3, product_id=None, db=db, _=user)
|
||||||
|
juengster = abschnitte[-1]
|
||||||
|
assert juengster.opening == 0 # vorher war das Lager leer
|
||||||
|
assert juengster.checked_in == 5 # Artikeleinheiten, nicht Vorgaenge
|
||||||
|
assert juengster.checked_out == 2
|
||||||
|
assert juengster.opening + juengster.checked_in - juengster.checked_out == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_flow_schliesst_ohne_luecke_an_den_vorherigen_abschnitt_an(db, rice, user):
|
||||||
|
"""Der Endbestand eines Abschnitts ist der Anfangsbestand des naechsten –
|
||||||
|
sonst haette die Bruecke einen Sprung, den kein Balken erklaert."""
|
||||||
|
check_in(db, rice, 4, "package", None, None, user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
abschnitte = dashboard.flow(days=7, product_id=None, db=db, _=user)
|
||||||
|
for vorher, nachher in zip(abschnitte, abschnitte[1:]):
|
||||||
|
schluss = vorher.opening + vorher.checked_in - vorher.checked_out
|
||||||
|
assert round(schluss, 3) == round(nachher.opening, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flow_kann_auf_ein_produkt_eingegrenzt_werden(db, rice, user):
|
||||||
|
anderes = Product(name="Zucker", base_unit=BaseUnit.gram, package_size=1000)
|
||||||
|
db.add(anderes)
|
||||||
|
db.commit()
|
||||||
|
check_in(db, rice, 2, "package", None, None, user)
|
||||||
|
check_in(db, anderes, 5, "package", None, None, user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
nur_reis = dashboard.flow(days=2, product_id=rice.id, db=db, _=user)
|
||||||
|
assert nur_reis[-1].checked_in == 2
|
||||||
|
|
||||||
|
|
||||||
def test_activity_zaehlt_ein_und_auslagerungen(db, rice, user):
|
def test_activity_zaehlt_ein_und_auslagerungen(db, rice, user):
|
||||||
check_in(db, rice, 2, "package", None, None, user)
|
check_in(db, rice, 2, "package", None, None, user)
|
||||||
check_in(db, rice, 1, "package", None, None, user)
|
check_in(db, rice, 1, "package", None, None, user)
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ export const api = {
|
|||||||
dashboardTimeline: (days, productId) =>
|
dashboardTimeline: (days, productId) =>
|
||||||
request(`/dashboard/timeline?days=${days}${productId ? `&product_id=${productId}` : ""}`),
|
request(`/dashboard/timeline?days=${days}${productId ? `&product_id=${productId}` : ""}`),
|
||||||
dashboardActivity: (days) => request(`/dashboard/activity?days=${days}`),
|
dashboardActivity: (days) => request(`/dashboard/activity?days=${days}`),
|
||||||
|
dashboardFlow: (days, productId) =>
|
||||||
|
request(`/dashboard/flow?days=${days}${productId ? `&product_id=${productId}` : ""}`),
|
||||||
|
|
||||||
// Stammdaten
|
// Stammdaten
|
||||||
listLocations: () => request("/locations"),
|
listLocations: () => request("/locations"),
|
||||||
|
|||||||
@@ -98,6 +98,20 @@ const SKIZZEN = {
|
|||||||
</Rahmen>
|
</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: (
|
linien: (
|
||||||
<Rahmen>
|
<Rahmen>
|
||||||
<line x1="8" y1="34" x2="64" y2="34" stroke="var(--border-strong)" strokeWidth="1" />
|
<line x1="8" y1="34" x2="64" y2="34" stroke="var(--border-strong)" strokeWidth="1" />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import Bars from "./charts/Bars";
|
import Bars from "./charts/Bars";
|
||||||
import Donut from "./charts/Donut";
|
import Donut from "./charts/Donut";
|
||||||
import Line from "./charts/Line";
|
import Line from "./charts/Line";
|
||||||
|
import Wasserfall from "./charts/Wasserfall";
|
||||||
import { istDunkel, mitFarben, tokenFarbe } from "./palette";
|
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 }) {
|
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 (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
|
||||||
if (!daten) return <div className="empty">Lädt…</div>;
|
if (!daten) return <div className="empty">Lädt…</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Line
|
<Wasserfall
|
||||||
labels={daten.map((p) => zeitLabel(p.at, tage))}
|
abschnitte={daten.map((p) => ({ ...p, label: zeitLabel(p.at, tage) }))}
|
||||||
serien={[
|
einheit="Einheiten"
|
||||||
{ 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"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -482,9 +484,9 @@ export const KARTEN = {
|
|||||||
komponente: KarteArtikelverlauf,
|
komponente: KarteArtikelverlauf,
|
||||||
},
|
},
|
||||||
"activity-timeline": {
|
"activity-timeline": {
|
||||||
titel: "Ein- und Auslagerungen", icon: "history", vorschau: "linien",
|
titel: "Ein- und Auslagerungen", icon: "history", vorschau: "wasserfall",
|
||||||
beschreibung: "Vorgänge je Tag über 30 Tage",
|
beschreibung: "Brücke: Bestandslinie mit grünen und roten Balken je Bewegung",
|
||||||
zeitraum: 30, standard: { w: 8, h: 6 }, min: { w: 4, h: 5 },
|
zeitraum: 30, standard: { w: 8, h: 6 }, min: { w: 5, h: 5 },
|
||||||
komponente: KarteBewegungsverlauf,
|
komponente: KarteBewegungsverlauf,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,26 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { fmt } from "../../units";
|
import { fmt } from "../../units";
|
||||||
import { tokenFarbe } from "../palette";
|
import { tokenFarbe } from "../palette";
|
||||||
|
import useGroesse from "./useGroesse";
|
||||||
/** 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liniendiagramm über die Zeit.
|
* Liniendiagramm über die Zeit.
|
||||||
|
|||||||
148
web/src/dashboard/charts/Wasserfall.jsx
Normal file
148
web/src/dashboard/charts/Wasserfall.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
web/src/dashboard/charts/useGroesse.js
Normal file
25
web/src/dashboard/charts/useGroesse.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -603,6 +603,9 @@ select.zeitraum:hover { color: var(--text); }
|
|||||||
.chart-legend .legend-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.chart-legend .legend-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.chart-legend .legend-value { color: var(--muted); font-size: 0.78rem; white-space: nowrap; }
|
.chart-legend .legend-value { color: var(--muted); font-size: 0.78rem; white-space: nowrap; }
|
||||||
.swatch { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
|
.swatch { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
|
||||||
|
/* Eine Serie, die als Linie gezeichnet wird, traegt auch in der Legende einen
|
||||||
|
Strich – sonst sieht sie aus wie eine weitere Flaeche. */
|
||||||
|
.swatch.linie { height: 3px; border-radius: 2px; width: 12px; }
|
||||||
|
|
||||||
.chart-bars { display: flex; flex-direction: column; gap: 7px; }
|
.chart-bars { display: flex; flex-direction: column; gap: 7px; }
|
||||||
.bar-row { display: grid; grid-template-columns: minmax(80px, 26%) 1fr auto; align-items: center; gap: var(--sp-3); }
|
.bar-row { display: grid; grid-template-columns: minmax(80px, 26%) 1fr auto; align-items: center; gap: var(--sp-3); }
|
||||||
|
|||||||
Reference in New Issue
Block a user