From ec0aa620174e973f877e1b1346319010d2d8c8f1 Mon Sep 17 00:00:00 2001 From: Guido Date: Fri, 21 Aug 2026 09:59:36 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Liniendiagramm=20mit=20Nulllinie,=20run?= =?UTF-8?q?den=20Schritten=20und=20w=C3=A4hlbarem=20Zeitraum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/dashboard/cards.jsx | 19 ++++++++++-- web/src/dashboard/charts/Line.jsx | 49 ++++++++++++++++++++++++------- web/src/locales/de.json | 7 ++++- web/src/locales/en.json | 7 ++++- 4 files changed, 66 insertions(+), 16 deletions(-) diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index ed37934..54d0e37 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -63,9 +63,10 @@ function BudgetDonut({ props }) { return 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 ; 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] }, diff --git a/web/src/dashboard/charts/Line.jsx b/web/src/dashboard/charts/Line.jsx index 49a9158..48e7f38 100644 --- a/web/src/dashboard/charts/Line.jsx +++ b/web/src/dashboard/charts/Line.jsx @@ -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) => ( - + {yFmt(g.wert)} ))} diff --git a/web/src/locales/de.json b/web/src/locales/de.json index 92a2c66..2ed8677 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -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.", diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 56818b1..b9d45f5 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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.",