191 lines
5.7 KiB
Go
191 lines
5.7 KiB
Go
package web
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"munin/internal/stats"
|
|
)
|
|
|
|
// 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 (
|
|
chartLeft = 36 // Platz für die Zahlen der y-Achse
|
|
chartTop = 8 // damit die oberste Zahl nicht abgeschnitten wird
|
|
chartArea = 120 // Höhe der Balkenfläche
|
|
chartBottom = 20 // Platz für die Daten der x-Achse
|
|
chartColumn = 20 // Breite je Tag
|
|
chartBarWidth = 14 // Breite des Balkens darin, der Rest ist Abstand
|
|
)
|
|
|
|
// chart ist ein fertig ausgerechnetes Balkendiagramm.
|
|
type chart struct {
|
|
Title string // für Screenreader (aria-label)
|
|
Width, Height int // viewBox
|
|
Links, Right int // x der Balkenfläche: Anfang und Ende
|
|
Baseline int // y der Nulllinie
|
|
Empty bool // im ganzen Zeitraum nichts — die Seite sagt das statt leerer Achsen
|
|
Grid []chartLine
|
|
Bars []chartBarDay
|
|
Axis []chartLabel
|
|
}
|
|
|
|
// chartLine ist eine waagerechte Hilfslinie mit ihrem Wert.
|
|
type chartLine struct {
|
|
Y int
|
|
Text string
|
|
}
|
|
|
|
// chartBarDay 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 chartBarDay struct {
|
|
X, Width int // Trefferfläche
|
|
Title string
|
|
Parts []chartPart
|
|
}
|
|
|
|
// chartPart ist ein Rechteck eines gestapelten Balkens.
|
|
type chartPart struct {
|
|
X, Y, Width, Height int
|
|
Class string
|
|
}
|
|
|
|
// chartLabel ist ein Datum unter der x-Achse.
|
|
type chartLabel struct {
|
|
X int
|
|
Text string
|
|
}
|
|
|
|
// chartSeries ist eine Teilmenge eines Balkens: welcher Wert, in welcher
|
|
// Farbe, wie heißt er im Tooltip.
|
|
type chartSeries struct {
|
|
value func(stats.Tag) int
|
|
class string
|
|
name string
|
|
}
|
|
|
|
// weekdays für den Tooltip, mit Sonntag = 0 wie time.Weekday.
|
|
var weekdays = [...]string{"So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"}
|
|
|
|
// chartUnit ist das Wort hinter der Summe im Tooltip, in Einzahl und
|
|
// Mehrzahl: "1 Schreibvorgang", "12 Schreibvorgänge".
|
|
type chartUnit struct{ one, more string }
|
|
|
|
// buildChart rechnet ein gestapeltes Balkendiagramm aus: je Tag ein Balken,
|
|
// darin je Reihe ein Teil, von unten nach oben in der Reihenfolge der Reihen.
|
|
func buildChart(title string, unit chartUnit, days []stats.Tag, series ...chartSeries) chart {
|
|
d := chart{
|
|
Title: title,
|
|
Links: chartLeft,
|
|
Right: chartLeft + len(days)*chartColumn,
|
|
Baseline: chartTop + chartArea,
|
|
}
|
|
d.Width = d.Right + 4
|
|
d.Height = d.Baseline + chartBottom
|
|
|
|
total := func(t stats.Tag) int {
|
|
n := 0
|
|
for _, r := range series {
|
|
n += r.value(t)
|
|
}
|
|
return n
|
|
}
|
|
|
|
highest := 0
|
|
for _, t := range days {
|
|
highest = max(highest, total(t))
|
|
}
|
|
d.Empty = highest == 0
|
|
scale := round125(highest)
|
|
|
|
// 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.Grid = append(d.Grid, chartLine{Y: d.Baseline, Text: "0"})
|
|
if scale%2 == 0 {
|
|
d.Grid = append(d.Grid, chartLine{Y: d.Baseline - chartArea/2, Text: fmt.Sprint(scale / 2)})
|
|
}
|
|
d.Grid = append(d.Grid, chartLine{Y: chartTop, Text: fmt.Sprint(scale)})
|
|
|
|
for i, t := range days {
|
|
column := chartLeft + i*chartColumn
|
|
tag := chartBarDay{X: column, Width: chartColumn}
|
|
|
|
// Tooltip: "Mo 13.10.: 12 Schreibvorgänge (Agenten 9, Menschen 3)".
|
|
// Der Name vor der Zahl erspart die Frage nach Einzahl oder Mehrzahl.
|
|
tag.Title = fmt.Sprintf("%s %s: %s", weekdays[t.Date.Weekday()], t.Date.Format("02.01."),
|
|
plural(total(t), unit.one, unit.more))
|
|
if len(series) > 1 && total(t) > 0 {
|
|
tag.Title += " ("
|
|
for j, r := range series {
|
|
if j > 0 {
|
|
tag.Title += ", "
|
|
}
|
|
tag.Title += fmt.Sprintf("%s %d", r.name, r.value(t))
|
|
}
|
|
tag.Title += ")"
|
|
}
|
|
|
|
// Die Teile stapeln: y wächst in SVG nach unten, also von der
|
|
// Nulllinie aus nach oben abziehen.
|
|
top := d.Baseline
|
|
for _, r := range series {
|
|
h := heightOf(r.value(t), scale)
|
|
if h == 0 {
|
|
continue
|
|
}
|
|
top -= h
|
|
tag.Parts = append(tag.Parts, chartPart{
|
|
X: column + (chartColumn-chartBarWidth)/2, Y: top,
|
|
Width: chartBarWidth, Height: h, Class: r.class,
|
|
})
|
|
}
|
|
d.Bars = append(d.Bars, tag)
|
|
|
|
// Ein Datum je Woche, vom heutigen Tag rückwärts gezählt — so steht
|
|
// heute immer unten rechts.
|
|
if (len(days)-1-i)%7 == 0 {
|
|
d.Axis = append(d.Axis, chartLabel{X: column + chartColumn/2, Text: t.Date.Format("02.01.")})
|
|
}
|
|
}
|
|
return d
|
|
}
|
|
|
|
// heightOf 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 heightOf(value, scale int) int {
|
|
if value <= 0 {
|
|
return 0
|
|
}
|
|
return max(1, value*chartArea/scale)
|
|
}
|
|
|
|
// round125 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 round125(n int) int {
|
|
if n <= 1 {
|
|
return 1
|
|
}
|
|
for level := 1; ; level *= 10 {
|
|
for _, f := range []int{1, 2, 5} {
|
|
if f*level >= n {
|
|
return f * level
|
|
}
|
|
}
|
|
}
|
|
}
|