feat: Liniendiagramm mit Nulllinie, runden Schritten und wählbarem Zeitraum

Die Achse richtete sich rein nach den Daten: Beschriftungen wie „-3'065.06" sind
nicht ablesbar, und ohne feste Null sah man die Bewegung, aber nicht, ob man im
Plus oder Minus steht.

- Die Skala schließt die Null immer ein und ist optisch hervorgehoben
- Runde Schritte (1, 2, 2.5, 5 x Zehnerpotenz): 0 / 2'000 / 4'000 … statt
  krummer Werte; nie feiner als ein Rappen, sonst entstünden bei leerem Konto
  Zwischenwerte wie 0,0025
- Vermögensverlauf: Zeitraum als Karten-Option (30 T / 90 T / 6 M / 1 J)

Gilt für jedes Liniendiagramm, nicht nur den Vermögensverlauf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-21 09:59:36 +02:00
co-authored by Claude Opus 4.8
parent 511cf1abe0
commit ec0aa62017
4 changed files with 66 additions and 16 deletions
+16 -3
View File
@@ -63,9 +63,10 @@ function BudgetDonut({ props }) {
return <Donut segmente={data.segments} gesamtLabel={label} fmt={(v) => formatMoney(v, data.currency)} />;
}
function NetWorth() {
function NetWorth({ props }) {
const { t } = useTranslation();
const data = useData(() => api.cardNetWorth(90));
const tage = Number(props?.tage) || 90;
const data = useData(() => api.cardNetWorth(tage));
if (!data) return <Loading />;
const labels = (data.labels || []).map(shortDate);
const serien = [{ name: t("cards.assets"), color: tokenFarbe("--accent", "#3f51b5"), werte: data.values || [] }];
@@ -202,6 +203,17 @@ const AUFSCHLUESSELUNG = {
],
};
// Zeitraum des Vermögensverlaufs.
const ZEITRAUM = {
key: "tage", labelKey: "cards.range", def: "90",
choices: [
{ v: "30", labelKey: "cards.days30" },
{ v: "90", labelKey: "cards.days90" },
{ v: "180", labelKey: "cards.days180" },
{ v: "365", labelKey: "cards.days365" },
],
};
// Liste oder Kreis – dieselben Zahlen, zwei Lesarten: die Liste nennt jeden
// Anteil genau, der Kreis zeigt die Verteilung auf einen Blick.
const DARSTELLUNG = {
@@ -217,7 +229,8 @@ const DARSTELLUNG = {
export const KARTEN = {
"account-balances": { titleKey: "cards.balances", Comp: AccountBalances, w: 4, h: 5, minW: 3, minH: 3 },
"budget-donut": { titleKey: "cards.budgetDonut", Comp: BudgetDonut, w: 4, h: 5, minW: 3, minH: 4, options: [AUFSCHLUESSELUNG] },
"net-worth": { titleKey: "cards.netWorth", Comp: NetWorth, w: 8, h: 4, minW: 4, minH: 3 },
"net-worth": { titleKey: "cards.netWorth", Comp: NetWorth, w: 8, h: 4, minW: 4, minH: 3,
options: [ZEITRAUM] },
"budget-progress": { titleKey: "cards.budgetProgress", Comp: BudgetProgress, w: 4, h: 3, minW: 3, minH: 2, options: [AUFSCHLUESSELUNG] },
"expense-share": { titleKey: "cards.expenseShare", Comp: ExpenseShare, w: 4, h: 5, minW: 3, minH: 3,
options: [{ ...AUFSCHLUESSELUNG, def: "category" }, DARSTELLUNG] },
+38 -11
View File
@@ -2,6 +2,38 @@ import { useRef, useState } from "react";
import { tokenFarbe } from "../palette";
import useGroesse from "./useGroesse";
/**
* Achsenskala mit runden Schritten, die die Null immer einschließt.
*
* Ohne feste Null „schwebt" der Verlauf: Man sieht die Bewegung, aber nicht, ob
* man im Plus oder Minus ist. Und Beschriftungen wie „-3'065.06" sind nicht
* ablesbar – deshalb runde Schritte (1, 2, 2.5, 5 × Zehnerpotenz), sodass 100,
* 200, 300 … oder 1000, 2000, 3000 … dastehen.
*/
function schoeneSkala(min, max, zielTicks = 4) {
let von = Math.min(min, 0);
let bis = Math.max(max, 0);
if (bis === von) bis = von + 1;
const roh = (bis - von) / zielTicks;
const groesse = 10 ** Math.floor(Math.log10(roh));
const rest = roh / groesse;
// Nie feiner als eine Einheit: Die Werte sind ganzzahlige Rappen/Cent, ein
// Schritt von 0,25 Rappen wäre nur Rauschen (z. B. bei leerem Konto).
const schritt = Math.max(
(rest <= 1 ? 1 : rest <= 2 ? 2 : rest <= 2.5 ? 2.5 : rest <= 5 ? 5 : 10) * groesse,
1,
);
von = Math.floor(von / schritt) * schritt;
bis = Math.ceil(bis / schritt) * schritt;
const ticks = [];
// Schrittweise addieren statt multiplizieren – sonst summieren sich
// Fließkomma-Reste zu Werten wie 299.9999999.
for (let i = 0; von + i * schritt <= bis + schritt * 1e-9; i += 1) {
ticks.push(Math.round((von + i * schritt) * 1e6) / 1e6);
}
return { von, bis, ticks };
}
// Liniendiagramm über die Zeit.
// labels: ["01.07.", …] serien: [{ name, color, werte: number[] }]
// fmt: Werteformatierer.
@@ -26,20 +58,14 @@ export default function Line({ labels = [], serien = [], fmt = (v) => String(v),
const flaecheH = Math.max(h - rand.oben - rand.unten, 10);
const alleWerte = serien.flatMap((s) => s.werte);
const maxWert = Math.max(...alleWerte, 1);
const minWert = Math.min(...alleWerte, 0);
const spanne = Math.max(maxWert - minWert, 1);
const skalaMax = maxWert + spanne * 0.1;
const skalaMin = minWert - spanne * 0.05;
const skalaSpanne = Math.max(skalaMax - skalaMin, 1);
const skala = schoeneSkala(Math.min(...alleWerte), Math.max(...alleWerte));
const skalaMin = skala.von;
const skalaSpanne = Math.max(skala.bis - skala.von, 1);
const x = (i) => rand.links + (i / (labels.length - 1)) * flaecheB;
const y = (wert) => rand.oben + flaecheH - ((wert - skalaMin) / skalaSpanne) * flaecheH;
const gitter = [0, 0.5, 1].map((teil) => {
const wert = skalaMin + skalaSpanne * teil;
return { wert, y: y(wert) };
});
const gitter = skala.ticks.map((wert) => ({ wert, y: y(wert), null: wert === 0 }));
const schritt = Math.max(1, Math.round(labels.length / 5));
const achse = labels.map((l, i) => ({ l, i })).filter(({ i }) => i % schritt === 0);
@@ -71,7 +97,8 @@ export default function Line({ labels = [], serien = [], fmt = (v) => String(v),
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" />
<line x1={rand.links} x2={w - rand.rechts} y1={g.y} y2={g.y}
stroke={g.null ? textFarbe : gitterFarbe} strokeWidth={g.null ? 1.5 : 1} />
<text x={rand.links - 8} y={g.y + 4} textAnchor="end" fontSize="11" fill={textFarbe}>{yFmt(g.wert)}</text>
</g>
))}
+6 -1
View File
@@ -137,7 +137,12 @@
"noExpenses": "Noch keine Ausgaben in diesem Monat.",
"display": "Darstellung",
"asList": "Liste",
"asPie": "Kreis"
"asPie": "Kreis",
"range": "Zeitraum",
"days30": "30 T",
"days90": "90 T",
"days180": "6 M",
"days365": "1 J"
},
"server": {
"unreachable": "Server nicht erreichbar.",
+6 -1
View File
@@ -137,7 +137,12 @@
"noExpenses": "No expenses this month yet.",
"display": "Display",
"asList": "List",
"asPie": "Pie"
"asPie": "Pie",
"range": "Range",
"days30": "30 d",
"days90": "90 d",
"days180": "6 m",
"days365": "1 y"
},
"server": {
"unreachable": "Server unreachable.",