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 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 } } } }