Cockpit: Übrige Konten aufklappbar, Balken rücken auseinander
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
1faf10aeda
commit
048bbf3894
8 changed files with 313 additions and 98 deletions
|
|
@ -20,6 +20,14 @@
|
||||||
- Eigenes SVG, keine Diagrammbibliothek. Die Farben stehen als Klasse am Element und
|
- Eigenes SVG, keine Diagrammbibliothek. Die Farben stehen als Klasse am Element und
|
||||||
folgen damit dem hellen wie dem dunklen Thema; jedes Balkenstück trägt ein `<title>`
|
folgen damit dem hellen wie dem dunklen Thema; jedes Balkenstück trägt ein `<title>`
|
||||||
als Tooltip, ohne eine Zeile JavaScript.
|
als Tooltip, ohne eine Zeile JavaScript.
|
||||||
|
- **Übrige Konten (n)** ist ein Link: Er führt eine Ebene tiefer (`/?ebene=N`) und zeigt
|
||||||
|
die nächstkleineren Konten einzeln – beliebig tief, bis der Rest in die Palette passt.
|
||||||
|
Unter der Legende steht der Weg zurück. Jedes Konto erscheint auf genau einer Ebene, und
|
||||||
|
eine zu hohe Ebene fällt auf die letzte zurück (`Vermoegensentwicklung::ebenen()`).
|
||||||
|
- Die Balken nutzen die Kachelbreite aus, indem sie **auseinanderrücken**, nicht indem sie
|
||||||
|
breiter werden: Die Zeichenfläche rechnet waagerecht in Prozent, die Balkenbreite bleibt
|
||||||
|
bei 16 Punkten. Achsenbeschriftung und Zeichenfläche sind dafür zwei SVGs nebeneinander –
|
||||||
|
ein einzelnes könnte von seinen Prozentangaben keine feste Achsenbreite abziehen.
|
||||||
- Die Kachel ist so groß wie ihre Nachbarn: Der Ausgriff über zwei Rasterspalten
|
- Die Kachel ist so groß wie ihre Nachbarn: Der Ausgriff über zwei Rasterspalten
|
||||||
(`.kachel.breit`) und das engere Cockpit-Raster (`.kachelraster.dicht`) entfallen wieder.
|
(`.kachel.breit`) und das engere Cockpit-Raster (`.kachelraster.dicht`) entfallen wieder.
|
||||||
- Die Legende steht unter der Zeichnung statt daneben und in kleinerer Schrift (.74rem);
|
- Die Legende steht unter der Zeichnung statt daneben und in kleinerer Schrift (.74rem);
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,23 @@ zusammengefasst – zwei Konten in derselben Farbe wären in der Legende nicht a
|
||||||
halten. Jede Serie setzt `--farbe`; davon leben der Balken (`fill`) und der Legendenpunkt
|
halten. Jede Serie setzt `--farbe`; davon leben der Balken (`fill`) und der Legendenpunkt
|
||||||
(`background`) gleichermaßen, und beide folgen dem hellen wie dem dunklen Thema.
|
(`background`) gleichermaßen, und beide folgen dem hellen wie dem dunklen Thema.
|
||||||
|
|
||||||
|
### Ebenen: in „Übrige Konten“ hineinsehen
|
||||||
|
|
||||||
|
Der Legendeneintrag **Übrige Konten (n)** ist ein Link. Er führt auf `/?ebene=1`: Dort
|
||||||
|
stehen die nächstelf Konten einzeln, und was dann noch bleibt, sammelt sich erneut. So
|
||||||
|
geht es beliebig tief, bis der Rest in die Palette passt – dann entfällt das Sammelstück,
|
||||||
|
und das ist die letzte Ebene. `Vermoegensentwicklung::ebenen()` rechnet aus, wie viele es
|
||||||
|
sind; eine zu hohe Ebene fällt auf die letzte zurück, statt eine leere Kachel zu zeigen.
|
||||||
|
|
||||||
|
Zurück geht es über die Zeile unter der Legende: *eine Ebene zurück* und, ab der dritten,
|
||||||
|
*zu den größten Konten*.
|
||||||
|
|
||||||
|
Jedes Konto steht auf genau einer Ebene: Was oben schon eine eigene Farbe hatte, ist im
|
||||||
|
Sammelstück nicht mehr enthalten. Sonst wüchse der Rest mit jeder Ebene, statt zu
|
||||||
|
schrumpfen. Weil auf tieferen Ebenen die großen Konten fehlen, passt sich die Achse an –
|
||||||
|
die kleinen Konten werden dadurch überhaupt erst sichtbar. Die Ebene steht in der Adresse
|
||||||
|
und nicht in den Einstellungen: Beim nächsten Aufruf beginnt man wieder oben.
|
||||||
|
|
||||||
### SVG von Hand
|
### SVG von Hand
|
||||||
|
|
||||||
Keine Diagrammbibliothek (siehe `PLAN.md`): `src/Views/cockpit/vermoegensdiagramm.php`
|
Keine Diagrammbibliothek (siehe `PLAN.md`): `src/Views/cockpit/vermoegensdiagramm.php`
|
||||||
|
|
@ -67,11 +84,16 @@ Die Achse rundet über `Vermoegensentwicklung::skala()` auf Schritte von 1, 2, 2
|
||||||
mal einer Zehnerpotenz und schließt die Null immer ein. Jedes Balkenstück trägt ein
|
mal einer Zehnerpotenz und schließt die Null immer ein. Jedes Balkenstück trägt ein
|
||||||
`<title>` mit Konto, Monat und Betrag – das ist der Tooltip, ohne eine Zeile JavaScript.
|
`<title>` mit Konto, Monat und Betrag – das ist der Tooltip, ohne eine Zeile JavaScript.
|
||||||
|
|
||||||
Die Maße stehen in echten Bildpunkten: Ein Balken ist 18 Punkte breit, zwischen zwei
|
Senkrecht rechnet die Zeichnung in Bildpunkten, waagerecht in Prozent: Die Balkenmitte von
|
||||||
Monaten bleiben 3. Damit das auch auf dem Schirm so ankommt, trägt das `<svg>` Breite und
|
Monat *i* liegt bei `(i + 0,5) / 12` der Fläche, der Balken selbst ist feste 16 Punkte breit
|
||||||
Höhe als Attribute und wird **nicht** auf die Kachel gestreckt; `max-width: 100%` lässt es
|
und wird per `transform` um seine halbe Breite nach links gerückt. In einer breiten Kachel
|
||||||
erst mitschrumpfen, wenn die Kachel schmaler wird als die Zeichnung – in der dreispaltigen
|
rücken die Balken damit **auseinander**, statt breiter zu werden.
|
||||||
Kachel also auf gut 90 Prozent.
|
|
||||||
|
Deshalb sind es zwei SVGs nebeneinander (`.diagrammflaeche`): links die Achsenbeschriftung
|
||||||
|
mit fester Breite, rechts die Zeichenfläche. In einem einzigen SVG ginge das nicht –
|
||||||
|
Prozentangaben rechnen immer auf die volle Breite, und ein Attribut kann davon keine feste
|
||||||
|
Achsenbreite abziehen. Ein `viewBox` verbietet sich aus demselben Grund: Es würde alles
|
||||||
|
gleichmäßig strecken, die Balken eingeschlossen.
|
||||||
|
|
||||||
Die Kachel ist eine gewöhnliche Kachel des Cockpit-Rasters, so breit wie ihre Nachbarn:
|
Die Kachel ist eine gewöhnliche Kachel des Cockpit-Rasters, so breit wie ihre Nachbarn:
|
||||||
Ein Ausgriff über zwei Spalten machte sie zum Fremdkörper zwischen den anderen dreien.
|
Ein Ausgriff über zwei Spalten machte sie zum Fremdkörper zwischen den anderen dreien.
|
||||||
|
|
@ -84,6 +106,8 @@ Tooltip und in der Kachel *Kontostände*; in der Legende kosteten sie nur Breite
|
||||||
|
|
||||||
* `tests/13_VermoegensentwicklungTest.php` – Zeitraum, Fortschreibung der Stände,
|
* `tests/13_VermoegensentwicklungTest.php` – Zeitraum, Fortschreibung der Stände,
|
||||||
Umbuchungen, Σ-Kennzeichen, Schalter „nur aktive“, Zusammenfassen jenseits der Palette,
|
Umbuchungen, Σ-Kennzeichen, Schalter „nur aktive“, Zusammenfassen jenseits der Palette,
|
||||||
Skala
|
Ebenen (jedes Konto genau einmal, letzte Ebene ohne Sammelstück, zu tiefe Ebene fällt
|
||||||
|
zurück), Skala
|
||||||
* `tests/e2e/specs/smoke.spec.js` – die Kachel zeichnet zwölf Balken, keine Farbe der
|
* `tests/e2e/specs/smoke.spec.js` – die Kachel zeichnet zwölf Balken, keine Farbe der
|
||||||
Legende kommt doppelt vor, und der Schalter behält seinen Zustand über den Neuaufruf
|
Legende kommt doppelt vor, der Schalter behält seinen Zustand über den Neuaufruf, und
|
||||||
|
*Übrige Konten* führt eine Ebene tiefer und wieder zurück
|
||||||
|
|
|
||||||
|
|
@ -361,23 +361,30 @@ form.inline { display: inline; margin: 0; }
|
||||||
.serie-11 { --farbe: #e07b91; }
|
.serie-11 { --farbe: #e07b91; }
|
||||||
.serie-12 { --farbe: #7b8a99; }
|
.serie-12 { --farbe: #7b8a99; }
|
||||||
|
|
||||||
/* Das SVG bringt seine Maße als Attribute mit und wird nicht auf die Kachel
|
|
||||||
gestreckt: Nur so sind die 18 Punkte Balkenbreite auch 18 Bildpunkte. Erst
|
|
||||||
wenn die Kachel schmaler wird als die Zeichnung, skaliert sie mit – die
|
|
||||||
Legende darunter nimmt dann die volle Kachelbreite. */
|
|
||||||
.diagrammbereich {
|
.diagrammbereich {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
|
||||||
gap: .6rem;
|
gap: .6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Achsenbeschriftung und Zeichenfläche sind zwei SVGs nebeneinander. Die Achse
|
||||||
|
ist fest, die Fläche nimmt den Rest – und weil deren Balken waagerecht in
|
||||||
|
Prozent sitzen, rücken sie in einer breiten Kachel auseinander, statt breiter
|
||||||
|
zu werden. Ein einziges SVG könnte das nicht: Prozentangaben rechnen immer
|
||||||
|
auf die volle Breite, die feste Achse ließe sich nicht abziehen. */
|
||||||
|
.diagrammflaeche {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagrammachse { flex: none; }
|
||||||
|
|
||||||
.diagramm {
|
.diagramm {
|
||||||
max-width: 100%;
|
flex: 1 1 0;
|
||||||
height: auto;
|
min-width: 0;
|
||||||
display: block;
|
display: block;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
flex: 0 1 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagramm rect { fill: var(--farbe); }
|
.diagramm rect { fill: var(--farbe); }
|
||||||
|
|
@ -443,6 +450,22 @@ form.inline { display: inline; margin: 0; }
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* „Übrige Konten“ führt eine Ebene tiefer: Der Eintrag ist ein Link und sieht
|
||||||
|
auch danach aus – sonst probierte ihn niemand aus. */
|
||||||
|
.legendenname.tiefer {
|
||||||
|
color: var(--akzent);
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legendenname.tiefer:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.diagrammpfad {
|
||||||
|
margin: 0;
|
||||||
|
font-size: .74rem;
|
||||||
|
color: var(--text-gedaempft);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Ein/Aus-Schalter ────────────────────────────────────────────────────────
|
/* ── Ein/Aus-Schalter ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Die Bahn mit dem laufenden Knopf, wie man ihn von Mobilgeräten kennt. Ein
|
Die Bahn mit dem laufenden Knopf, wie man ihn von Mobilgeräten kennt. Ein
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,20 @@ class CockpitController extends BaseController
|
||||||
|
|
||||||
$nurAktive = Einstellung::getBool(self::SCHALTER_NUR_AKTIVE, true);
|
$nurAktive = Einstellung::getBool(self::SCHALTER_NUR_AKTIVE, true);
|
||||||
|
|
||||||
|
// Die Ebene steht in der Adresse, nicht in den Einstellungen: Wer in die
|
||||||
|
// „Übrigen Konten“ hineinsieht, will beim nächsten Aufruf wieder oben
|
||||||
|
// anfangen – und kann den Blick trotzdem als Lesezeichen ablegen.
|
||||||
|
$ebene = max(0, (int)$this->query('ebene'));
|
||||||
|
|
||||||
$this->render('cockpit/index.php', [
|
$this->render('cockpit/index.php', [
|
||||||
'titel' => 'Cockpit',
|
'titel' => 'Cockpit',
|
||||||
'uebersicht' => Konto::uebersicht(),
|
'uebersicht' => Konto::uebersicht(),
|
||||||
'vermoegen' => Vermoegensentwicklung::reihe($nurAktive),
|
'vermoegen' => Vermoegensentwicklung::reihe(
|
||||||
|
$nurAktive,
|
||||||
|
Vermoegensentwicklung::MONATE,
|
||||||
|
null,
|
||||||
|
$ebene
|
||||||
|
),
|
||||||
'nurAktive' => $nurAktive,
|
'nurAktive' => $nurAktive,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,17 +24,23 @@ class Vermoegensentwicklung
|
||||||
public const SERIEN = 12;
|
public const SERIEN = 12;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param int $ebene 0 zeigt die größten Konten, jede weitere Ebene die
|
||||||
|
* jeweils nächstkleineren aus „Übrige Konten“.
|
||||||
|
*
|
||||||
* @return array{
|
* @return array{
|
||||||
* monate: list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>,
|
* monate: list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>,
|
||||||
* konten: list<array{id:int, name:string, werte:list<float>}>,
|
* konten: list<array{id:int, name:string, werte:list<float>}>,
|
||||||
* summen: list<float>,
|
* summen: list<float>,
|
||||||
* hatWerte: bool
|
* hatWerte: bool,
|
||||||
|
* ebene: int,
|
||||||
|
* ebenen: int
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public static function reihe(
|
public static function reihe(
|
||||||
bool $nurAktive = true,
|
bool $nurAktive = true,
|
||||||
int $anzahl = self::MONATE,
|
int $anzahl = self::MONATE,
|
||||||
?string $heute = null
|
?string $heute = null,
|
||||||
|
int $ebene = 0
|
||||||
): array {
|
): array {
|
||||||
$monate = self::monate($anzahl, $heute);
|
$monate = self::monate($anzahl, $heute);
|
||||||
$erster = $monate[0];
|
$erster = $monate[0];
|
||||||
|
|
@ -75,8 +81,11 @@ class Vermoegensentwicklung
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ebenen = self::ebenen(count($konten));
|
||||||
|
$ebene = max(0, min($ebene, $ebenen - 1));
|
||||||
|
|
||||||
if (count($konten) > self::SERIEN) {
|
if (count($konten) > self::SERIEN) {
|
||||||
$konten = self::zusammenfassen($konten, count($monate));
|
$konten = self::zusammenfassen($konten, count($monate), $ebene);
|
||||||
}
|
}
|
||||||
|
|
||||||
$hatWerte = false;
|
$hatWerte = false;
|
||||||
|
|
@ -90,18 +99,38 @@ class Vermoegensentwicklung
|
||||||
'konten' => $konten,
|
'konten' => $konten,
|
||||||
'summen' => array_values($summen),
|
'summen' => array_values($summen),
|
||||||
'hatWerte' => $hatWerte || $konten !== [],
|
'hatWerte' => $hatWerte || $konten !== [],
|
||||||
|
'ebene' => $ebene,
|
||||||
|
'ebenen' => $ebenen,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele Ebenen es braucht, um `$konten` Konten einzeln zu zeigen: Jede
|
||||||
|
* Ebene zeigt so viele Konten, wie Farbtöne da sind – eine weniger, wenn
|
||||||
|
* noch ein Stück „Übrige Konten“ dazukommt.
|
||||||
|
*/
|
||||||
|
public static function ebenen(int $konten): int
|
||||||
|
{
|
||||||
|
if ($konten <= self::SERIEN) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1 + (int)ceil(($konten - self::SERIEN) / (self::SERIEN - 1));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mehr Konten als Farbtöne: Die größten behalten ihre eigene Farbe, der Rest
|
* Mehr Konten als Farbtöne: Die größten behalten ihre eigene Farbe, der Rest
|
||||||
* wird zu einem Balkenstück zusammengefasst. Sonst bekämen zwei Konten
|
* wird zu einem Balkenstück zusammengefasst. Sonst bekämen zwei Konten
|
||||||
* denselben Ton – und wer die Legende liest, ordnete falsch zu.
|
* denselben Ton – und wer die Legende liest, ordnete falsch zu.
|
||||||
*
|
*
|
||||||
|
* `$ebene` blättert in diesem Rest weiter: Ebene 1 überspringt die größten
|
||||||
|
* Konten und zeigt die nächstkleineren einzeln, Ebene 2 die darauf folgenden
|
||||||
|
* und so fort. Passt der Rest in die Palette, entfällt das Sammelstück.
|
||||||
|
*
|
||||||
* @param list<array{id:int, name:string, werte:list<float>}> $konten
|
* @param list<array{id:int, name:string, werte:list<float>}> $konten
|
||||||
* @return list<array{id:int, name:string, werte:list<float>}>
|
* @return list<array{id:int, name:string, werte:list<float>}>
|
||||||
*/
|
*/
|
||||||
private static function zusammenfassen(array $konten, int $spalten): array
|
private static function zusammenfassen(array $konten, int $spalten, int $ebene = 0): array
|
||||||
{
|
{
|
||||||
$groesse = [];
|
$groesse = [];
|
||||||
foreach ($konten as $nummer => $konto) {
|
foreach ($konten as $nummer => $konto) {
|
||||||
|
|
@ -109,9 +138,15 @@ class Vermoegensentwicklung
|
||||||
}
|
}
|
||||||
arsort($groesse);
|
arsort($groesse);
|
||||||
|
|
||||||
|
$rangfolge = array_slice(array_keys($groesse), $ebene * (self::SERIEN - 1));
|
||||||
|
|
||||||
|
// Passt der Rest vollständig in die Palette, braucht es kein Sammelstück
|
||||||
|
// mehr – dann ist das die letzte Ebene.
|
||||||
|
$sammelt = count($rangfolge) > self::SERIEN;
|
||||||
|
|
||||||
// Die größten behalten, aber in ihrer ursprünglichen Reihenfolge – der
|
// Die größten behalten, aber in ihrer ursprünglichen Reihenfolge – der
|
||||||
// Stapel soll nicht bei jedem Aufruf anders sortiert sein.
|
// Stapel soll nicht bei jedem Aufruf anders sortiert sein.
|
||||||
$eigene = array_slice(array_keys($groesse), 0, self::SERIEN - 1);
|
$eigene = $sammelt ? array_slice($rangfolge, 0, self::SERIEN - 1) : $rangfolge;
|
||||||
sort($eigene);
|
sort($eigene);
|
||||||
|
|
||||||
$rest = array_fill(0, $spalten, 0.0);
|
$rest = array_fill(0, $spalten, 0.0);
|
||||||
|
|
@ -124,17 +159,25 @@ class Vermoegensentwicklung
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Was auf einer früheren Ebene stand, ist hier gar nicht mehr dabei –
|
||||||
|
// sonst wüchse das Sammelstück mit jeder Ebene statt zu schrumpfen.
|
||||||
|
if (!in_array($nummer, $rangfolge, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($konto['werte'] as $spalte => $wert) {
|
foreach ($konto['werte'] as $spalte => $wert) {
|
||||||
$rest[$spalte] += $wert;
|
$rest[$spalte] += $wert;
|
||||||
}
|
}
|
||||||
$restKonten++;
|
$restKonten++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($restKonten > 0) {
|
||||||
$ergebnis[] = [
|
$ergebnis[] = [
|
||||||
'id' => 0,
|
'id' => 0,
|
||||||
'name' => 'Übrige Konten (' . $restKonten . ')',
|
'name' => 'Übrige Konten (' . $restKonten . ')',
|
||||||
'werte' => array_map(static fn(float $wert): float => round($wert, 2), $rest),
|
'werte' => array_map(static fn(float $wert): float => round($wert, 2), $rest),
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return $ergebnis;
|
return $ergebnis;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,17 @@ use Finanzmanager\View;
|
||||||
* monate: list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>,
|
* monate: list<array{jahr:int, monat:int, bis:string, kuerzel:string, jahrKurz:string}>,
|
||||||
* konten: list<array{id:int, name:string, werte:list<float>}>,
|
* konten: list<array{id:int, name:string, werte:list<float>}>,
|
||||||
* summen: list<float>,
|
* summen: list<float>,
|
||||||
* hatWerte: bool
|
* hatWerte: bool,
|
||||||
|
* ebene: int,
|
||||||
|
* ebenen: int
|
||||||
* } $reihe
|
* } $reihe
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$monate = $reihe['monate'];
|
$monate = $reihe['monate'];
|
||||||
$konten = $reihe['konten'];
|
$konten = $reihe['konten'];
|
||||||
$anzahl = count($monate);
|
$anzahl = count($monate);
|
||||||
|
$ebene = $reihe['ebene'];
|
||||||
|
$ebenen = $reihe['ebenen'];
|
||||||
|
|
||||||
// Die Achse muss den gestapelten Positiv- und Negativteil jedes Balkens fassen.
|
// Die Achse muss den gestapelten Positiv- und Negativteil jedes Balkens fassen.
|
||||||
// Die Nettosumme liegt immer dazwischen und braucht keine eigene Grenze.
|
// Die Nettosumme liegt immer dazwischen und braucht keine eigene Grenze.
|
||||||
|
|
@ -44,29 +48,25 @@ foreach ($monate as $nummer => $monat) {
|
||||||
|
|
||||||
$skala = Vermoegensentwicklung::skala($grenzwerte);
|
$skala = Vermoegensentwicklung::skala($grenzwerte);
|
||||||
|
|
||||||
// Maße in echten Bildpunkten: Das SVG trägt Breite und Höhe als Attribute und
|
// Senkrecht in echten Bildpunkten, waagerecht in Prozent: Die Zeichenfläche
|
||||||
// wird deshalb nicht auf die Kachel gestreckt. Ein Balken ist 18 Punkte breit,
|
// nimmt die Breite, die die Kachel hergibt, und die Balken rücken darin
|
||||||
// zwischen zwei Monaten bleiben 3 – erst unterhalb der eigenen Breite skaliert
|
// auseinander, statt breiter zu werden. Die Achsenbeschriftung sitzt in einem
|
||||||
// die Zeichnung mit (max-width in app.css).
|
// eigenen, festen SVG daneben – sonst müsste die Prozentrechnung ihre Breite
|
||||||
$balken = 18;
|
// abziehen, und das kann ein SVG-Attribut nicht.
|
||||||
$luecke = 3;
|
$balken = 16;
|
||||||
$links = 52;
|
$achse = 56;
|
||||||
$rechts = 6;
|
|
||||||
$oben = 10;
|
$oben = 10;
|
||||||
$unten = 32;
|
|
||||||
|
|
||||||
$spalte = $balken + $luecke;
|
|
||||||
$plotB = $spalte * $anzahl;
|
|
||||||
$plotH = 150;
|
$plotH = 150;
|
||||||
$breite = $links + $plotB + $rechts;
|
$unten = 32;
|
||||||
$hoehe = $oben + $plotH + $unten;
|
$hoehe = $oben + $plotH + $unten;
|
||||||
|
|
||||||
$y = static function (float $wert) use ($skala, $oben, $plotH): float {
|
$y = static function (float $wert) use ($skala, $oben, $plotH): float {
|
||||||
return round($oben + $plotH * ($skala['max'] - $wert) / ($skala['max'] - $skala['min']), 2);
|
return round($oben + $plotH * ($skala['max'] - $wert) / ($skala['max'] - $skala['min']), 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
$mitte = static function (int $nummer) use ($links, $spalte): float {
|
/** Die Mitte einer Monatsspalte, als Anteil der Zeichenfläche. */
|
||||||
return round($links + ($nummer + 0.5) * $spalte, 2);
|
$mitte = static function (int $nummer) use ($anzahl): string {
|
||||||
|
return round(($nummer + 0.5) / $anzahl * 100, 3) . '%';
|
||||||
};
|
};
|
||||||
|
|
||||||
$achsenzahl = static function (float $wert): string {
|
$achsenzahl = static function (float $wert): string {
|
||||||
|
|
@ -82,28 +82,36 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz']
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="diagrammbereich">
|
<div class="diagrammbereich">
|
||||||
<svg class="diagramm" width="<?= $breite ?>" height="<?= $hoehe ?>"
|
<div class="diagrammflaeche">
|
||||||
viewBox="0 0 <?= $breite ?> <?= $hoehe ?>" role="img"
|
<svg class="diagrammachse" width="<?= $achse ?>" height="<?= $hoehe ?>" aria-hidden="true">
|
||||||
preserveAspectRatio="xMidYMid meet"
|
<?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?>
|
||||||
|
<text class="diagramm-achse" x="<?= $achse - 8 ?>" y="<?= $y($wert) + 4 ?>"
|
||||||
|
text-anchor="end"><?= View::h($achsenzahl($wert)) ?></text>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<?php /* Ohne viewBox: Die Koordinaten bleiben Bildpunkte, nur die
|
||||||
|
Prozentangaben wachsen mit der Fläche. */ ?>
|
||||||
|
<svg class="diagramm" height="<?= $hoehe ?>" role="img"
|
||||||
aria-label="Kontostände je Monatsende, <?= View::h($zeitraum) ?>">
|
aria-label="Kontostände je Monatsende, <?= View::h($zeitraum) ?>">
|
||||||
<title>Vermögensentwicklung <?= View::h($zeitraum) ?></title>
|
<title>Vermögensentwicklung <?= View::h($zeitraum) ?></title>
|
||||||
|
|
||||||
<?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?>
|
<?php for ($wert = $skala['min']; $wert <= $skala['max'] + 0.001; $wert += $skala['schritt']): ?>
|
||||||
<line class="diagramm-gitter" x1="<?= $links ?>" x2="<?= $breite - $rechts ?>"
|
<line class="diagramm-gitter" x1="0" x2="100%"
|
||||||
y1="<?= $y($wert) ?>" y2="<?= $y($wert) ?>"></line>
|
y1="<?= $y($wert) ?>" y2="<?= $y($wert) ?>"></line>
|
||||||
<text class="diagramm-achse" x="<?= $links - 8 ?>" y="<?= $y($wert) + 4 ?>"
|
|
||||||
text-anchor="end"><?= View::h($achsenzahl($wert)) ?></text>
|
|
||||||
<?php endfor; ?>
|
<?php endfor; ?>
|
||||||
|
|
||||||
<line class="diagramm-nulllinie" x1="<?= $links ?>" x2="<?= $breite - $rechts ?>"
|
<line class="diagramm-nulllinie" x1="0" x2="100%"
|
||||||
y1="<?= $y(0.0) ?>" y2="<?= $y(0.0) ?>"></line>
|
y1="<?= $y(0.0) ?>" y2="<?= $y(0.0) ?>"></line>
|
||||||
|
|
||||||
<?php foreach ($monate as $nummer => $monat): ?>
|
<?php foreach ($monate as $nummer => $monat): ?>
|
||||||
<?php
|
<?php
|
||||||
$x = $mitte($nummer) - $balken / 2;
|
|
||||||
$oberkante = 0.0;
|
$oberkante = 0.0;
|
||||||
$unterkante = 0.0;
|
$unterkante = 0.0;
|
||||||
?>
|
?>
|
||||||
|
<?php /* Die Balkenmitte liegt auf dem Prozentwert; die halbe
|
||||||
|
Balkenbreite nach links holt die Verschiebung. */ ?>
|
||||||
|
<g transform="translate(-<?= $balken / 2 ?> 0)">
|
||||||
<?php foreach ($konten as $index => $konto): ?>
|
<?php foreach ($konten as $index => $konto): ?>
|
||||||
<?php
|
<?php
|
||||||
$wert = $konto['werte'][$nummer];
|
$wert = $konto['werte'][$nummer];
|
||||||
|
|
@ -121,12 +129,13 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz']
|
||||||
$unterkante = $von;
|
$unterkante = $von;
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<rect class="<?= $serie($index) ?>" x="<?= round($x, 2) ?>" y="<?= $y($bis) ?>"
|
<rect class="<?= $serie($index) ?>" x="<?= $mitte($nummer) ?>" y="<?= $y($bis) ?>"
|
||||||
width="<?= round($balken, 2) ?>" height="<?= round($y($von) - $y($bis), 2) ?>">
|
width="<?= $balken ?>" height="<?= round($y($von) - $y($bis), 2) ?>">
|
||||||
<title><?= View::h($konto['name'] . ' – ' . $monat['kuerzel'] . ' ' . $monat['jahr']
|
<title><?= View::h($konto['name'] . ' – ' . $monat['kuerzel'] . ' ' . $monat['jahr']
|
||||||
. ': ' . View::betrag($wert)) ?></title>
|
. ': ' . View::betrag($wert)) ?></title>
|
||||||
</rect>
|
</rect>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
|
</g>
|
||||||
|
|
||||||
<text class="diagramm-beschriftung" x="<?= $mitte($nummer) ?>" y="<?= $hoehe - 18 ?>"
|
<text class="diagramm-beschriftung" x="<?= $mitte($nummer) ?>" y="<?= $hoehe - 18 ?>"
|
||||||
text-anchor="middle"><?= View::h($monat['kuerzel']) ?></text>
|
text-anchor="middle"><?= View::h($monat['kuerzel']) ?></text>
|
||||||
|
|
@ -135,7 +144,8 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz']
|
||||||
text-anchor="middle"><?= View::h($monat['jahrKurz']) ?></text>
|
text-anchor="middle"><?= View::h($monat['jahrKurz']) ?></text>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</svg>
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul class="diagrammlegende">
|
<ul class="diagrammlegende">
|
||||||
<?php foreach ($konten as $index => $konto): ?>
|
<?php foreach ($konten as $index => $konto): ?>
|
||||||
|
|
@ -143,8 +153,23 @@ $zeitraum = $monate[0]['kuerzel'] . ' ' . $monate[0]['jahrKurz']
|
||||||
der Kachel „Kontostände“ – in der Legende kosteten sie nur Breite. */ ?>
|
der Kachel „Kontostände“ – in der Legende kosteten sie nur Breite. */ ?>
|
||||||
<li>
|
<li>
|
||||||
<span class="legendenfarbe <?= $serie($index) ?>" aria-hidden="true"></span>
|
<span class="legendenfarbe <?= $serie($index) ?>" aria-hidden="true"></span>
|
||||||
|
<?php if ($konto['id'] === 0): ?>
|
||||||
|
<a class="legendenname tiefer" href="/?ebene=<?= $ebene + 1 ?>"
|
||||||
|
title="Diese Konten einzeln zeigen"><?= View::h($konto['name']) ?></a>
|
||||||
|
<?php else: ?>
|
||||||
<span class="legendenname"><?= View::h($konto['name']) ?></span>
|
<span class="legendenname"><?= View::h($konto['name']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<?php if ($ebene > 0): ?>
|
||||||
|
<p class="diagrammpfad">
|
||||||
|
Ebene <?= $ebene + 1 ?> von <?= $ebenen ?> ·
|
||||||
|
<a href="/?ebene=<?= $ebene - 1 ?>">← eine Ebene zurück</a>
|
||||||
|
<?php if ($ebene > 1): ?>
|
||||||
|
· <a href="/">zu den größten Konten</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ class VermoegensentwicklungTest extends TestCase
|
||||||
$this->summenkennzeichenFiltertNicht();
|
$this->summenkennzeichenFiltertNicht();
|
||||||
$this->schalterFiltertStillgelegteKonten();
|
$this->schalterFiltertStillgelegteKonten();
|
||||||
$this->mehrKontenAlsFarbenWerdenZusammengefasst();
|
$this->mehrKontenAlsFarbenWerdenZusammengefasst();
|
||||||
|
$this->ebenenBlaetternDurchDieUebrigenKonten();
|
||||||
$this->skalaRundetAufLesbareSchritte();
|
$this->skalaRundetAufLesbareSchritte();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,9 +55,9 @@ class VermoegensentwicklungTest extends TestCase
|
||||||
$this->kategorie = Kategorie::findenOderAnlegenNachPfad('600 - Vermögenstest', 'ausgabe');
|
$this->kategorie = Kategorie::findenOderAnlegenNachPfad('600 - Vermögenstest', 'ausgabe');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function reihe(bool $nurAktive = true): array
|
private function reihe(bool $nurAktive = true, int $ebene = 0): array
|
||||||
{
|
{
|
||||||
return Vermoegensentwicklung::reihe($nurAktive, 12, self::HEUTE);
|
return Vermoegensentwicklung::reihe($nurAktive, 12, self::HEUTE, $ebene);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string,mixed>|null */
|
/** @return array<string,mixed>|null */
|
||||||
|
|
@ -238,6 +239,63 @@ class VermoegensentwicklungTest extends TestCase
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Übrige Konten“ ist keine Sackgasse: Jede Ebene zeigt die nächstkleineren
|
||||||
|
* Konten einzeln, bis der Rest in die Palette passt.
|
||||||
|
*/
|
||||||
|
private function ebenenBlaetternDurchDieUebrigenKonten(): void
|
||||||
|
{
|
||||||
|
$this->assertEquals(1, Vermoegensentwicklung::ebenen(12), 'Was in die Palette passt, hat eine Ebene');
|
||||||
|
$this->assertEquals(2, Vermoegensentwicklung::ebenen(13), 'Ein Konto zu viel macht die zweite Ebene auf');
|
||||||
|
$this->assertEquals(2, Vermoegensentwicklung::ebenen(23), 'Elf eigene und zwölf übrige: noch zwei Ebenen');
|
||||||
|
$this->assertEquals(3, Vermoegensentwicklung::ebenen(24), 'Eines mehr, und es braucht eine dritte');
|
||||||
|
|
||||||
|
// Aus dem vorigen Test stehen mehr Konten da, als die Palette Töne hat.
|
||||||
|
$erste = $this->reihe(true, 0);
|
||||||
|
$zweite = $this->reihe(true, 1);
|
||||||
|
|
||||||
|
$this->assertGreaterThan(1, $erste['ebenen'], 'Der Testbestand füllt mehr als eine Ebene');
|
||||||
|
$this->assertEquals(0, $erste['ebene'], 'Ohne Angabe steht man auf der ersten Ebene');
|
||||||
|
$this->assertEquals(1, $zweite['ebene'], 'Die zweite Ebene ist erreichbar');
|
||||||
|
|
||||||
|
$ids = static fn(array $reihe): array => array_map(
|
||||||
|
static fn(array $konto): int => $konto['id'],
|
||||||
|
$reihe['konten']
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assert(in_array(0, $ids($erste), true), 'Die erste Ebene sammelt den Rest ein');
|
||||||
|
$this->assertEquals(
|
||||||
|
[],
|
||||||
|
array_intersect(array_diff($ids($erste), [0]), $ids($zweite)),
|
||||||
|
'Kein Konto steht auf zwei Ebenen'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Die letzte Ebene braucht kein Sammelstück mehr – sonst käme man nie an.
|
||||||
|
$letzte = $this->reihe(true, $erste['ebenen'] - 1);
|
||||||
|
$this->assert(!in_array(0, $ids($letzte), true), 'Auf der letzten Ebene stehen nur noch echte Konten');
|
||||||
|
|
||||||
|
// Zusammen ergeben alle Ebenen wieder den ganzen Bestand.
|
||||||
|
$gesehen = [];
|
||||||
|
for ($ebene = 0; $ebene < $erste['ebenen']; $ebene++) {
|
||||||
|
$gesehen = array_merge($gesehen, array_diff($ids($this->reihe(true, $ebene)), [0]));
|
||||||
|
}
|
||||||
|
$this->assertEquals(
|
||||||
|
count($gesehen),
|
||||||
|
count(array_unique($gesehen)),
|
||||||
|
'Über alle Ebenen hinweg taucht kein Konto doppelt auf'
|
||||||
|
);
|
||||||
|
$this->assertGreaterThan(
|
||||||
|
Vermoegensentwicklung::SERIEN,
|
||||||
|
count($gesehen),
|
||||||
|
'Und in Summe sind es mehr Konten, als eine Ebene fasst'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Eine Ebene jenseits der letzten gibt es nicht: Sie fällt darauf zurück.
|
||||||
|
$zuTief = $this->reihe(true, 99);
|
||||||
|
$this->assertEquals($erste['ebenen'] - 1, $zuTief['ebene'], 'Zu tief geraten landet man auf der letzten');
|
||||||
|
$this->assertEquals($ids($letzte), $ids($zuTief), 'Mit denselben Konten');
|
||||||
|
}
|
||||||
|
|
||||||
private function skalaRundetAufLesbareSchritte(): void
|
private function skalaRundetAufLesbareSchritte(): void
|
||||||
{
|
{
|
||||||
$skala = Vermoegensentwicklung::skala([0.0, 1234.0, 4870.0]);
|
$skala = Vermoegensentwicklung::skala([0.0, 1234.0, 4870.0]);
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,30 @@ test.describe('Grundgerüst', () => {
|
||||||
expect(new Set(farben).size).toBe(farben.length)
|
expect(new Set(farben).size).toBe(farben.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('„Übrige Konten“ führt eine Ebene tiefer und wieder zurück', async ({ page }) => {
|
||||||
|
await anmelden(page)
|
||||||
|
|
||||||
|
const kachel = page.locator('.kachel', { hasText: 'Vermögensentwicklung' })
|
||||||
|
const sammel = kachel.locator('.legendenname.tiefer')
|
||||||
|
|
||||||
|
// Der Testbestand hat mehr Konten als Farbtöne; sonst gäbe es nichts
|
||||||
|
// aufzuklappen und der Test hätte hier nichts zu prüfen.
|
||||||
|
await expect(sammel).toHaveText(/Übrige Konten \(\d+\)/)
|
||||||
|
|
||||||
|
const oben = await kachel.locator('.legendenname').allTextContents()
|
||||||
|
await sammel.click()
|
||||||
|
await expect(page).toHaveURL(/ebene=1/)
|
||||||
|
await expect(kachel.locator('.diagrammpfad')).toContainText('Ebene 2 von')
|
||||||
|
|
||||||
|
// Eine Ebene tiefer stehen andere Konten in der Legende.
|
||||||
|
const unten = await kachel.locator('.legendenname').allTextContents()
|
||||||
|
expect(unten.filter(name => oben.includes(name) && !name.startsWith('Übrige'))).toHaveLength(0)
|
||||||
|
|
||||||
|
await kachel.locator('.diagrammpfad a').first().click()
|
||||||
|
await expect(page).toHaveURL(/ebene=0/)
|
||||||
|
expect(await kachel.locator('.legendenname').allTextContents()).toEqual(oben)
|
||||||
|
})
|
||||||
|
|
||||||
test('Schalter „nur aktive“ behält seinen Zustand', async ({ page }) => {
|
test('Schalter „nur aktive“ behält seinen Zustand', async ({ page }) => {
|
||||||
await anmelden(page)
|
await anmelden(page)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue