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:
@@ -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] },
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user