191 lines
5.9 KiB
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
|
|
}
|
|
}
|
|
}
|
|
}
|