, * konten: list}>, * summen: list, * hatWerte: bool, * ebene: int, * ebenen: int * } */ public static function reihe( bool $nurAktive = true, int $anzahl = self::MONATE, ?string $heute = null, int $ebene = 0 ): array { $monate = self::monate($anzahl, $heute); $erster = $monate[0]; $letzter = $monate[count($monate) - 1]; // Alles vor dem Zeitraum steckt im Anfangsstand; innerhalb zählt Monat // für Monat dazu. So genügen zwei Abfragen statt einer je Stichtag. $vorlauf = date('Y-m-d', strtotime($erster['bis'] . ' first day of this month -1 day')); $anfang = Buchung::saldenJeKonto($vorlauf); $bewegungen = Buchung::bewegungenJeKontoUndMonat( date('Y-m-01', strtotime($erster['bis'])), $letzter['bis'] ); $konten = []; $summen = array_fill(0, count($monate), 0.0); foreach (Konto::alle($nurAktive) as $konto) { $id = (int)$konto['id']; $stand = (float)$konto['anfangssaldo'] + ($anfang[$id] ?? 0.0); $werte = []; $bewegt = false; foreach ($monate as $nummer => $monat) { $schluessel = sprintf('%04d-%02d', $monat['jahr'], $monat['monat']); $stand += $bewegungen[$id][$schluessel] ?? 0.0; $werte[] = round($stand, 2); $summen[$nummer] += $stand; $bewegt = $bewegt || round($stand, 2) !== 0.0; } // Konten, die im ganzen Zeitraum auf null standen, blähen nur die // Legende auf – sie zeichnen ohnehin keinen Balken. if ($bewegt) { $konten[] = ['id' => $id, 'name' => (string)$konto['name'], 'werte' => $werte]; } } $ebenen = self::ebenen(count($konten)); $ebene = max(0, min($ebene, $ebenen - 1)); if (count($konten) > self::SERIEN) { $konten = self::zusammenfassen($konten, count($monate), $ebene); } $hatWerte = false; foreach ($summen as $nummer => $summe) { $summen[$nummer] = round($summe, 2); $hatWerte = $hatWerte || $summen[$nummer] !== 0.0; } return [ 'monate' => $monate, 'konten' => $konten, 'summen' => array_values($summen), '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 * wird zu einem Balkenstück zusammengefasst. Sonst bekämen zwei Konten * 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}> $konten * @return list}> */ private static function zusammenfassen(array $konten, int $spalten, int $ebene = 0): array { $groesse = []; foreach ($konten as $nummer => $konto) { $groesse[$nummer] = max(array_map('abs', $konto['werte'])); } 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 // Stapel soll nicht bei jedem Aufruf anders sortiert sein. $eigene = $sammelt ? array_slice($rangfolge, 0, self::SERIEN - 1) : $rangfolge; sort($eigene); $rest = array_fill(0, $spalten, 0.0); $restKonten = 0; $ergebnis = []; foreach ($konten as $nummer => $konto) { if (in_array($nummer, $eigene, true)) { $ergebnis[] = $konto; 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) { $rest[$spalte] += $wert; } $restKonten++; } if ($restKonten > 0) { $ergebnis[] = [ 'id' => 0, 'name' => 'Übrige Konten (' . $restKonten . ')', 'werte' => array_map(static fn(float $wert): float => round($wert, 2), $rest), ]; } return $ergebnis; } /** * Die letzten `$anzahl` Monate bis einschließlich des laufenden, jeweils mit * dem Monatsletzten als Stichtag. * * @return list */ public static function monate(int $anzahl = self::MONATE, ?string $heute = null): array { $heute = $heute ?? date('Y-m-d'); $start = strtotime(substr($heute, 0, 8) . '01'); $monate = []; for ($rueckwaerts = $anzahl - 1; $rueckwaerts >= 0; $rueckwaerts--) { $zeit = strtotime('-' . $rueckwaerts . ' month', $start); $jahr = (int)date('Y', $zeit); $nr = (int)date('n', $zeit); $monate[] = [ 'jahr' => $jahr, 'monat' => $nr, 'bis' => date('Y-m-t', $zeit), 'kuerzel' => Budgetplaner::MONATSKUERZEL[$nr], 'jahrKurz' => date('y', $zeit), ]; } return $monate; } /** * Skala für die Achse: Nulllinie inbegriffen, Schritte in 1/2/2,5/5 mal * Zehnerpotenz – die Beschriftung soll lesbare Zahlen zeigen, nicht die * krummen Maxima der Konten. * * @param list $werte * @return array{min: float, max: float, schritt: float} */ public static function skala(array $werte): array { $max = max(0.0, ...($werte === [] ? [0.0] : $werte)); $min = min(0.0, ...($werte === [] ? [0.0] : $werte)); if ($max - $min < 0.01) { return ['min' => 0.0, 'max' => 1.0, 'schritt' => 1.0]; } $schritt = self::schritt(($max - $min) / 4); return [ 'min' => floor($min / $schritt) * $schritt, 'max' => ceil($max / $schritt) * $schritt, 'schritt' => $schritt, ]; } private static function schritt(float $roh): float { $potenz = 10 ** floor(log10(max($roh, 0.01))); foreach ([1, 2, 2.5, 5, 10] as $faktor) { if ($roh <= $faktor * $potenz) { return $faktor * $potenz; } } return 10 * $potenz; } }