munin/internal/web/diagramm.go

191 lines
5.9 KiB
Go

package web
import (
"fmt"
"munin/internal/statistik"
)
// Balkendiagramme für die Systemseite (AUSBAUSTUFEN.md 5.16).
//
// # Warum selbst gezeichnet?
//
// Eine Diagramm-Bibliothek wäre JavaScript, das erst geladen werden und unter
// der Content-Security-Policy laufen muss — für 30 Balken. Stattdessen rechnet
// der Server die Rechtecke aus, und das Template schreibt sie als SVG direkt
// in die Seite. Der Browser zeichnet SVG wie HTML, ohne ein Skript.
//
// Die Farben kommen aus style.css über Klassen (keine style-Attribute — die
// verbietet die CSP), damit das dunkle Farbschema von selbst passt. Fährt man
// mit der Maus über einen Tag, zeigt der Browser dessen <title> an.
//
// Alle Maße sind ganze Zahlen im Koordinatensystem der viewBox. Das SVG
// skaliert mit der Breite der Seite; die Zahlen sind also keine Pixel.
// Maße des Diagramms in viewBox-Einheiten.
const (
diagrammLinks = 36 // Platz für die Zahlen der y-Achse
diagrammOben = 8 // damit die oberste Zahl nicht abgeschnitten wird
diagrammFlaeche = 120 // Höhe der Balkenfläche
diagrammUnten = 20 // Platz für die Daten der x-Achse
diagrammSpalte = 20 // Breite je Tag
diagrammBalken = 14 // Breite des Balkens darin, der Rest ist Abstand
)
// diagramm ist ein fertig ausgerechnetes Balkendiagramm.
type diagramm struct {
Titel string // für Screenreader (aria-label)
Breite, Hoehe int // viewBox
Links, Rechts int // x der Balkenfläche: Anfang und Ende
Grund int // y der Nulllinie
Leer bool // im ganzen Zeitraum nichts — die Seite sagt das statt leerer Achsen
Gitter []diagrammLinie
Balken []diagrammBalkenTag
Achse []diagrammBeschriftung
}
// diagrammLinie ist eine waagerechte Hilfslinie mit ihrem Wert.
type diagrammLinie struct {
Y int
Text string
}
// diagrammBalkenTag ist ein Tag: eine unsichtbare Trefferfläche über die
// ganze Höhe (damit der Tooltip auch bei kleinen Balken erscheint) und die
// gestapelten Teile.
type diagrammBalkenTag struct {
X, Breite int // Trefferfläche
Titel string
Teile []diagrammTeil
}
// diagrammTeil ist ein Rechteck eines gestapelten Balkens.
type diagrammTeil struct {
X, Y, Breite, Hoehe int
Klasse string
}
// diagrammBeschriftung ist ein Datum unter der x-Achse.
type diagrammBeschriftung struct {
X int
Text string
}
// diagrammReihe ist eine Teilmenge eines Balkens: welcher Wert, in welcher
// Farbe, wie heißt er im Tooltip.
type diagrammReihe struct {
wert func(statistik.Tag) int
klasse string
name string
}
// wochentage für den Tooltip, mit Sonntag = 0 wie time.Weekday.
var wochentage = [...]string{"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"}
// diagrammEinheit ist das Wort hinter der Summe im Tooltip, in Einzahl und
// Mehrzahl: "1 Schreibvorgang", "12 Schreibvorgänge".
type diagrammEinheit struct{ eins, mehr string }
// baueDiagramm rechnet ein gestapeltes Balkendiagramm aus: je Tag ein Balken,
// darin je Reihe ein Teil, von unten nach oben in der Reihenfolge der Reihen.
func baueDiagramm(titel string, einheit diagrammEinheit, tage []statistik.Tag, reihen ...diagrammReihe) diagramm {
d := diagramm{
Titel: titel,
Links: diagrammLinks,
Rechts: diagrammLinks + len(tage)*diagrammSpalte,
Grund: diagrammOben + diagrammFlaeche,
}
d.Breite = d.Rechts + 4
d.Hoehe = d.Grund + diagrammUnten
summe := func(t statistik.Tag) int {
n := 0
for _, r := range reihen {
n += r.wert(t)
}
return n
}
hoechster := 0
for _, t := range tage {
hoechster = max(hoechster, summe(t))
}
d.Leer = hoechster == 0
skala := runde125(hoechster)
// Hilfslinien bei der Hälfte und ganz oben. Die Hälfte nur, wenn sie
// eine ganze Zahl ist — "2,5 Suchen" wäre seltsam.
d.Gitter = append(d.Gitter, diagrammLinie{Y: d.Grund, Text: "0"})
if skala%2 == 0 {
d.Gitter = append(d.Gitter, diagrammLinie{Y: d.Grund - diagrammFlaeche/2, Text: fmt.Sprint(skala / 2)})
}
d.Gitter = append(d.Gitter, diagrammLinie{Y: diagrammOben, Text: fmt.Sprint(skala)})
for i, t := range tage {
spalte := diagrammLinks + i*diagrammSpalte
tag := diagrammBalkenTag{X: spalte, Breite: diagrammSpalte}
// Tooltip: "Mo 13.10.: 12 Schreibvorgänge (Agenten 9, Menschen 3)".
// Der Name vor der Zahl erspart die Frage nach Einzahl oder Mehrzahl.
tag.Titel = fmt.Sprintf("%s %s: %s", wochentage[t.Datum.Weekday()], t.Datum.Format("02.01."),
plural(summe(t), einheit.eins, einheit.mehr))
if len(reihen) > 1 && summe(t) > 0 {
tag.Titel += " ("
for j, r := range reihen {
if j > 0 {
tag.Titel += ", "
}
tag.Titel += fmt.Sprintf("%s %d", r.name, r.wert(t))
}
tag.Titel += ")"
}
// Die Teile stapeln: y wächst in SVG nach unten, also von der
// Nulllinie aus nach oben abziehen.
oben := d.Grund
for _, r := range reihen {
h := hoeheVon(r.wert(t), skala)
if h == 0 {
continue
}
oben -= h
tag.Teile = append(tag.Teile, diagrammTeil{
X: spalte + (diagrammSpalte-diagrammBalken)/2, Y: oben,
Breite: diagrammBalken, Hoehe: h, Klasse: r.klasse,
})
}
d.Balken = append(d.Balken, tag)
// Ein Datum je Woche, vom heutigen Tag rückwärts gezählt — so steht
// heute immer unten rechts.
if (len(tage)-1-i)%7 == 0 {
d.Achse = append(d.Achse, diagrammBeschriftung{X: spalte + diagrammSpalte/2, Text: t.Datum.Format("02.01.")})
}
}
return d
}
// hoeheVon rechnet einen Wert in die Höhe seines Balkens um. Ein Wert über
// null bekommt mindestens eine Einheit, sonst verschwände ein einzelner
// Schreibvorgang neben einem Tag mit 500.
func hoeheVon(wert, skala int) int {
if wert <= 0 {
return 0
}
return max(1, wert*diagrammFlaeche/skala)
}
// runde125 rundet auf die nächste "glatte" Zahl auf: 1, 2, 5, 10, 20, 50, …
// So endet die y-Achse bei einem Wert, den man gut lesen kann, statt bei 37.
func runde125(n int) int {
if n <= 1 {
return 1
}
for stufe := 1; ; stufe *= 10 {
for _, f := range []int{1, 2, 5} {
if f*stufe >= n {
return f * stufe
}
}
}
}