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