Statistik: tägliche SVG-Diagramme statt CSS-Balken
Der Server rendert einmal täglich (0:01 Uhr deutscher Zeit) Liniendiagramme für Aufrufe, Netze und Beiträge pro Tag sowie gestapelte Säulen (Threads/ Antworten) für den Ø pro Wochentag und pro Tageszeit, je schmal (600) und breit (1200), ausgeliefert über /api/stats/charts. Hovern zeigt den Datenpunkt in einer Infozeile und markiert ihn mit einer senkrechten Linie. Die Statistik-Seite wird auf breiten Bildschirmen bis 1200 px breit. "Netze nach Betreiber" entfällt; neu ist die Kennzahl Ø Beiträge/Tag der letzten 30 Tage. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
047ad392d9
commit
07a2b64eff
@@ -0,0 +1,566 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
_ "time/tzdata" // Alpine-Image hat keine Zonendaten
|
||||
)
|
||||
|
||||
// Diagramme der Statistik-Seite. Sie werden einmal am Tag (kurz nach
|
||||
// Mitternacht, deutsche Zeit) als fertiges SVG gerendert und im Speicher
|
||||
// gehalten; ein Seitenaufruf liefert nur noch die Strings aus. Das SVG nutzt
|
||||
// ausschließlich Präsentationsattribute (stroke, fill ...) und kein style-
|
||||
// Attribut -> inline eingebettet von der strikten CSP nicht betroffen, und die
|
||||
// Schrift erbt es von der Seite.
|
||||
//
|
||||
// Der laufende Tag fehlt bewusst: er wäre bis zum nächsten Rendern immer nur
|
||||
// ein halber Tag und würde die Kurven am rechten Rand abknicken lassen.
|
||||
|
||||
var berlin = mustLoadLocation("Europe/Berlin")
|
||||
|
||||
func mustLoadLocation(name string) *time.Location {
|
||||
loc, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// StatsCharts ist die Antwort von /api/stats/charts. Jedes Diagramm gibt es
|
||||
// schmal (Handy, schmale Fenster) und breit (Widescreen); welches sichtbar ist,
|
||||
// entscheidet das CSS. So bleibt die Schrift in beiden Fällen etwa 1:1.
|
||||
type StatsCharts struct {
|
||||
Generated int64 `json:"generated"`
|
||||
Charts map[string]ChartPair `json:"charts"`
|
||||
}
|
||||
|
||||
type ChartPair struct {
|
||||
Narrow string `json:"narrow"`
|
||||
Wide string `json:"wide"`
|
||||
}
|
||||
|
||||
// both rendert ein Diagramm in beiden Größen.
|
||||
func both(render func(sz size) string) ChartPair {
|
||||
return ChartPair{Narrow: render(narrow), Wide: render(wide)}
|
||||
}
|
||||
|
||||
var (
|
||||
chartMu sync.RWMutex
|
||||
chartCache StatsCharts
|
||||
chartStop = make(chan struct{})
|
||||
chartDone = make(chan struct{})
|
||||
)
|
||||
|
||||
// startChartWorker rendert sofort und danach jede Nacht neu. Schlägt das
|
||||
// Rendern fehl, bleibt der alte Stand stehen und es gibt nach 15 Minuten einen
|
||||
// neuen Versuch.
|
||||
func startChartWorker() {
|
||||
go func() {
|
||||
defer close(chartDone)
|
||||
for {
|
||||
wait := 15 * time.Minute
|
||||
if err := renderCharts(time.Now()); err != nil {
|
||||
log.Printf("statistik-diagramme: %v", err)
|
||||
} else {
|
||||
wait = time.Until(nextChartRun(time.Now()))
|
||||
}
|
||||
select {
|
||||
case <-chartStop:
|
||||
return
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// stopChartWorker wartet ein laufendes Rendern ab (vor db.Close rufen).
|
||||
func stopChartWorker() {
|
||||
close(chartStop)
|
||||
<-chartDone
|
||||
}
|
||||
|
||||
// nextChartRun ist eine Minute nach der nächsten Mitternacht in Berlin.
|
||||
func nextChartRun(now time.Time) time.Time {
|
||||
y, m, d := now.In(berlin).Date()
|
||||
return time.Date(y, m, d+1, 0, 1, 0, 0, berlin)
|
||||
}
|
||||
|
||||
func renderCharts(now time.Time) error {
|
||||
impr, err := collectImpressionStats(now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ent, err := collectEntryStats(now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
set := StatsCharts{
|
||||
Generated: now.Unix(),
|
||||
Charts: map[string]ChartPair{
|
||||
"impressions": both(func(sz size) string {
|
||||
return lineChart(sz, "Aufrufe pro Tag", impr.days, impr.hits, "Aufrufe")
|
||||
}),
|
||||
"networks": both(func(sz size) string {
|
||||
return lineChart(sz, "Netze pro Tag", impr.days, impr.networks, "Netze")
|
||||
}),
|
||||
"entries": both(func(sz size) string {
|
||||
return lineChart(sz, "Beiträge pro Tag", ent.days, ent.perDay, "Beiträge")
|
||||
}),
|
||||
"weekday": both(func(sz size) string {
|
||||
return stackedBars(sz, "Beiträge pro Wochentag", weekdayNames[:], weekdayLong[:], ent.weekday)
|
||||
}),
|
||||
"hour": both(func(sz size) string {
|
||||
return stackedBars(sz, "Beiträge pro Tageszeit", hourLabels(), hourRanges(), ent.hour)
|
||||
}),
|
||||
},
|
||||
}
|
||||
chartMu.Lock()
|
||||
chartCache = set
|
||||
chartMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleStatsCharts liefert die fertigen SVGs. Ist noch nichts gerendert
|
||||
// (Start läuft noch, Tests ohne Worker), wird einmalig direkt gerendert.
|
||||
func handleStatsCharts(w http.ResponseWriter, r *http.Request) error {
|
||||
chartMu.RLock()
|
||||
set := chartCache
|
||||
chartMu.RUnlock()
|
||||
if set.Generated == 0 {
|
||||
if err := renderCharts(time.Now()); err != nil {
|
||||
return Internal(err)
|
||||
}
|
||||
chartMu.RLock()
|
||||
set = chartCache
|
||||
chartMu.RUnlock()
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=600")
|
||||
writeJSON(w, http.StatusOK, set)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Daten ---
|
||||
|
||||
type impressionStats struct {
|
||||
days []time.Time // lückenlos, ohne heute
|
||||
hits, networks []float64
|
||||
}
|
||||
|
||||
// collectImpressionStats summiert Aufrufe und verschiedene Netze je Tag. Die
|
||||
// Impression-Tage sind UTC-Daten (siehe recordImpression).
|
||||
func collectImpressionStats(now time.Time) (impressionStats, error) {
|
||||
var s impressionStats
|
||||
today := civilDate(now.UTC())
|
||||
rows, err := db.Query(
|
||||
`SELECT day, SUM(hits)::bigint, COUNT(DISTINCT anon_ip)
|
||||
FROM impression
|
||||
WHERE day < $1
|
||||
GROUP BY day`, today.Format(time.DateOnly))
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
defer rows.Close()
|
||||
type counts struct{ hits, networks int64 }
|
||||
byDay := map[time.Time]counts{}
|
||||
var first time.Time
|
||||
for rows.Next() {
|
||||
var day string
|
||||
var c counts
|
||||
if err := rows.Scan(&day, &c.hits, &c.networks); err != nil {
|
||||
return s, err
|
||||
}
|
||||
d, err := time.Parse(time.DateOnly, day)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
byDay[d] = c
|
||||
if first.IsZero() || d.Before(first) {
|
||||
first = d
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return s, err
|
||||
}
|
||||
if first.IsZero() {
|
||||
return s, nil
|
||||
}
|
||||
s.days = dayRange(first, today)
|
||||
for _, d := range s.days {
|
||||
c := byDay[d]
|
||||
s.hits = append(s.hits, float64(c.hits))
|
||||
s.networks = append(s.networks, float64(c.networks))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Reihenfolge der Stapel: Threads unten, Antworten darüber.
|
||||
var postKinds = [2]string{"Threads", "Antworten"}
|
||||
|
||||
type entryStats struct {
|
||||
days []time.Time // erster Beitragstag bis gestern (deutsche Zeit)
|
||||
perDay []float64
|
||||
weekday [][2]float64 // Mo..So, Ø Beiträge je solchem Tag
|
||||
hour [][2]float64 // 0..23 Uhr, Ø Beiträge je Tag
|
||||
}
|
||||
|
||||
// collectEntryStats zählt alle Beiträge einschließlich gelöschter (created_at
|
||||
// bleibt beim Löschen erhalten). Tage, Wochentage und Stunden gelten in
|
||||
// deutscher Zeit. Wochentag und Stunde sind Durchschnitte: Summe geteilt durch
|
||||
// die Zahl der Montage, Dienstage ... bzw. aller Tage im Zeitraum.
|
||||
func collectEntryStats(now time.Time) (entryStats, error) {
|
||||
var s entryStats
|
||||
today := civilDate(now.In(berlin))
|
||||
midnight := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, berlin)
|
||||
rows, err := db.Query(
|
||||
`SELECT created_at, reply_to <> 0 FROM entry WHERE created_at > 0 AND created_at < $1`,
|
||||
midnight.Unix())
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
defer rows.Close()
|
||||
perDay := map[time.Time]float64{}
|
||||
var weekdaySum [7][2]float64
|
||||
var hourSum [24][2]float64
|
||||
var first time.Time
|
||||
for rows.Next() {
|
||||
var created int64
|
||||
var reply bool
|
||||
if err := rows.Scan(&created, &reply); err != nil {
|
||||
return s, err
|
||||
}
|
||||
t := time.Unix(created, 0).In(berlin)
|
||||
d := civilDate(t)
|
||||
kind := 0
|
||||
if reply {
|
||||
kind = 1
|
||||
}
|
||||
perDay[d]++
|
||||
weekdaySum[isoWeekday(d)][kind]++
|
||||
hourSum[t.Hour()][kind]++
|
||||
if first.IsZero() || d.Before(first) {
|
||||
first = d
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.weekday = make([][2]float64, 7)
|
||||
s.hour = make([][2]float64, 24)
|
||||
if first.IsZero() {
|
||||
return s, nil
|
||||
}
|
||||
s.days = dayRange(first, today)
|
||||
var weekdayCount [7]float64
|
||||
for _, d := range s.days {
|
||||
s.perDay = append(s.perDay, perDay[d])
|
||||
weekdayCount[isoWeekday(d)]++
|
||||
}
|
||||
for i := range s.weekday {
|
||||
for k := range postKinds {
|
||||
if weekdayCount[i] > 0 {
|
||||
s.weekday[i][k] = weekdaySum[i][k] / weekdayCount[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
n := float64(len(s.days))
|
||||
for i := range s.hour {
|
||||
for k := range postKinds {
|
||||
s.hour[i][k] = hourSum[i][k] / n
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// civilDate ist das Kalenderdatum von t (in dessen Zone) als UTC-Mitternacht.
|
||||
// Gerechnet wird mit diesen Werten, damit Sommerzeitwechsel keine 23- oder
|
||||
// 25-Stunden-Tage in die Tagesfolge bringen.
|
||||
func civilDate(t time.Time) time.Time {
|
||||
y, m, d := t.Date()
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
// dayRange liefert alle Tage von first bis ausschließlich end.
|
||||
func dayRange(first, end time.Time) []time.Time {
|
||||
var days []time.Time
|
||||
for d := first; d.Before(end); d = d.AddDate(0, 0, 1) {
|
||||
days = append(days, d)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
// isoWeekday: Montag = 0 ... Sonntag = 6.
|
||||
func isoWeekday(d time.Time) int {
|
||||
return (int(d.Weekday()) + 6) % 7
|
||||
}
|
||||
|
||||
var weekdayNames = [7]string{"Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"}
|
||||
|
||||
var weekdayLong = [7]string{"Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"}
|
||||
|
||||
// hourRanges: "9–10 Uhr" für die Infozeile.
|
||||
func hourRanges() []string {
|
||||
l := make([]string, 24)
|
||||
for i := range l {
|
||||
l[i] = fmt.Sprintf("%d–%d Uhr", i, i+1)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func hourLabels() []string {
|
||||
l := make([]string, 24)
|
||||
for i := range l {
|
||||
l[i] = strconv.Itoa(i)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// --- SVG ---
|
||||
|
||||
// Maße im viewBox-Raum. narrow entspricht der schmalen Inhaltsspalte (600 px),
|
||||
// wide der breiten Statistik-Seite (1200 px); bis dorthin wird 1:1 gezeichnet,
|
||||
// darunter skaliert das SVG mit.
|
||||
type size struct{ w, h int }
|
||||
|
||||
var (
|
||||
narrow = size{600, 240}
|
||||
wide = size{1200, 300}
|
||||
)
|
||||
|
||||
const (
|
||||
padLeft = 44
|
||||
padRight = 12
|
||||
padTop = 12
|
||||
padBottom = 26
|
||||
fontSize = 13
|
||||
)
|
||||
|
||||
var (
|
||||
colorGrid = "#ddd"
|
||||
colorStack = [2]string{"#444", "#aaa"}
|
||||
)
|
||||
|
||||
type svg struct{ strings.Builder }
|
||||
|
||||
func (b *svg) f(format string, a ...any) { fmt.Fprintf(b, format, a...) }
|
||||
|
||||
func svgOpen(b *svg, sz size, label string) {
|
||||
b.f(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" role="img" aria-label="%s" font-size="%d" fill="black">`,
|
||||
sz.w, sz.h, html.EscapeString(label), fontSize)
|
||||
}
|
||||
|
||||
func emptyChart(sz size, label string) string {
|
||||
var b svg
|
||||
svgOpen(&b, sz, label)
|
||||
b.f(`<text x="%d" y="%d" text-anchor="middle" fill="gray">Noch keine Daten.</text></svg>`, sz.w/2, sz.h/2)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// niceScale wählt eine runde Schrittweite (1, 2, 5 × 10^k) für etwa vier
|
||||
// Gitterlinien und die darauf aufgerundete Obergrenze.
|
||||
func niceScale(max float64) (step, top float64) {
|
||||
if max <= 0 {
|
||||
max = 1
|
||||
}
|
||||
raw := max / 4
|
||||
mag := math.Pow(10, math.Floor(math.Log10(raw)))
|
||||
step = 10 * mag
|
||||
for _, m := range []float64{1, 2, 5} {
|
||||
if m*mag >= raw {
|
||||
step = m * mag
|
||||
break
|
||||
}
|
||||
}
|
||||
return step, math.Ceil(max/step-1e-9) * step
|
||||
}
|
||||
|
||||
// fmtNum schreibt eine Zahl mit höchstens zwei Nachkommastellen und
|
||||
// deutschem Komma.
|
||||
func fmtNum(v float64) string {
|
||||
return strings.Replace(strconv.FormatFloat(math.Round(v*100)/100, 'f', -1, 64), ".", ",", 1)
|
||||
}
|
||||
|
||||
// yAxis zeichnet Gitterlinien mit Beschriftung und liefert die Abbildung
|
||||
// Wert -> y-Koordinate.
|
||||
func yAxis(b *svg, sz size, max float64, top int) func(float64) float64 {
|
||||
step, maxV := niceScale(max)
|
||||
bottom := float64(sz.h - padBottom)
|
||||
y := func(v float64) float64 { return bottom - v/maxV*(bottom-float64(top)) }
|
||||
for v := 0.0; v <= maxV+step/2; v += step {
|
||||
yy := y(v)
|
||||
color := colorGrid
|
||||
if v == 0 {
|
||||
color = "black"
|
||||
}
|
||||
b.f(`<line x1="%d" y1="%.1f" x2="%d" y2="%.1f" stroke="%s"/>`, padLeft, yy, sz.w-padRight, yy, color)
|
||||
b.f(`<text x="%d" y="%.1f" text-anchor="end">%s</text>`, padLeft-6, yy+4, fmtNum(v))
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
func maxOf(vals []float64) float64 {
|
||||
m := 0.0
|
||||
for _, v := range vals {
|
||||
m = math.Max(m, v)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// lineChart zeichnet eine Tagesreihe als Linie. Alle Tage seit Beginn werden
|
||||
// auf die volle Breite gestaucht; unsichtbare Streifen je Tag tragen den
|
||||
// Tooltip (<title>), denselben Text in data-tip für die Infozeile unter dem
|
||||
// Diagramm (StatsPage.tsx) und eine senkrechte Markierung.
|
||||
func lineChart(sz size, label string, days []time.Time, vals []float64, unit string) string {
|
||||
if len(days) == 0 {
|
||||
return emptyChart(sz, label)
|
||||
}
|
||||
var b svg
|
||||
svgOpen(&b, sz, label)
|
||||
y := yAxis(&b, sz, maxOf(vals), padTop)
|
||||
plotW := float64(sz.w - padLeft - padRight)
|
||||
n := len(days)
|
||||
x := func(i int) float64 {
|
||||
if n == 1 {
|
||||
return padLeft + plotW/2
|
||||
}
|
||||
return padLeft + float64(i)*plotW/float64(n-1)
|
||||
}
|
||||
|
||||
bottom := sz.h - padBottom
|
||||
for _, i := range dateTicks(days) {
|
||||
xx := x(i)
|
||||
b.f(`<line x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="black"/>`, xx, bottom, xx, bottom+4)
|
||||
tx := math.Min(math.Max(xx, padLeft+16), float64(sz.w-padRight-16))
|
||||
b.f(`<text x="%.1f" y="%d" text-anchor="middle">%s</text>`, tx, bottom+18, dateTickLabel(days[i]))
|
||||
}
|
||||
|
||||
var pts strings.Builder
|
||||
for i, v := range vals {
|
||||
fmt.Fprintf(&pts, "%.1f,%.1f ", x(i), y(v))
|
||||
}
|
||||
if n == 1 {
|
||||
b.f(`<circle cx="%.1f" cy="%.1f" r="3"/>`, x(0), y(vals[0]))
|
||||
} else {
|
||||
b.f(`<polyline points="%s" fill="none" stroke="black" stroke-width="1.5" stroke-linejoin="round"/>`,
|
||||
strings.TrimSpace(pts.String()))
|
||||
}
|
||||
|
||||
slot := plotW / float64(max(n-1, 1))
|
||||
for i, d := range days {
|
||||
tip := fmt.Sprintf("%s, %s: %s %s", weekdayNames[isoWeekday(d)], d.Format("02.01.2006"), fmtNum(vals[i]), unit)
|
||||
// Die Markierungslinie ist versteckt; das CSS zeigt sie beim Hovern
|
||||
// über den Streifen (.chart g:hover .mark).
|
||||
b.f(`<g data-tip="%s"><title>%s</title><rect x="%.1f" y="%d" width="%.1f" height="%d" fill="transparent"/>`,
|
||||
tip, tip, x(i)-slot/2, padTop, slot, bottom-padTop)
|
||||
b.f(`<line class="mark" x1="%.1f" y1="%d" x2="%.1f" y2="%d" stroke="black" visibility="hidden" pointer-events="none"/></g>`,
|
||||
x(i), padTop, x(i), bottom)
|
||||
}
|
||||
b.f(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var monthNames = [12]string{"Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"}
|
||||
|
||||
// dateTicks wählt die beschrifteten Tage: Monatsanfänge, bei langen Reihen
|
||||
// nur jedes Quartal, Halbjahr oder Jahr; bei Reihen unter zwei Monaten die
|
||||
// Montage. Höchstens acht Beschriftungen.
|
||||
func dateTicks(days []time.Time) []int {
|
||||
var months []int
|
||||
for i, d := range days {
|
||||
if d.Day() == 1 {
|
||||
months = append(months, i)
|
||||
}
|
||||
}
|
||||
if len(months) >= 2 {
|
||||
for _, step := range []int{1, 3, 6, 12} {
|
||||
var picked []int
|
||||
for _, i := range months {
|
||||
if (int(days[i].Month())-1)%step == 0 {
|
||||
picked = append(picked, i)
|
||||
}
|
||||
}
|
||||
if len(picked) <= 8 || step == 12 {
|
||||
return picked
|
||||
}
|
||||
}
|
||||
}
|
||||
var mondays []int
|
||||
for i, d := range days {
|
||||
if d.Weekday() == time.Monday {
|
||||
mondays = append(mondays, i)
|
||||
}
|
||||
}
|
||||
for len(mondays) > 8 {
|
||||
var half []int
|
||||
for j := 0; j < len(mondays); j += 2 {
|
||||
half = append(half, mondays[j])
|
||||
}
|
||||
mondays = half
|
||||
}
|
||||
return mondays
|
||||
}
|
||||
|
||||
// dateTickLabel: "02.10." bei Wochen-Ticks, sonst Monatsname; der Januar
|
||||
// zeigt stattdessen die Jahreszahl.
|
||||
func dateTickLabel(d time.Time) string {
|
||||
if d.Day() != 1 {
|
||||
return d.Format("02.01.")
|
||||
}
|
||||
if d.Month() == time.January {
|
||||
return strconv.Itoa(d.Year())
|
||||
}
|
||||
return monthNames[d.Month()-1]
|
||||
}
|
||||
|
||||
// stackedBars zeichnet Ø Threads (unten) und Antworten (oben) je Kategorie
|
||||
// als gestapelte Säulen mit Legende.
|
||||
func stackedBars(sz size, label string, labels, tipLabels []string, vals [][2]float64) string {
|
||||
totals := make([]float64, len(vals))
|
||||
for i, v := range vals {
|
||||
totals[i] = v[0] + v[1]
|
||||
}
|
||||
if maxOf(totals) == 0 {
|
||||
return emptyChart(sz, label)
|
||||
}
|
||||
var b svg
|
||||
svgOpen(&b, sz, label)
|
||||
top := padTop + 20
|
||||
x0 := padLeft + 4
|
||||
for k, name := range postKinds {
|
||||
b.f(`<rect x="%d" y="%d" width="11" height="11" fill="%s"/>`, x0, padTop-2, colorStack[k])
|
||||
b.f(`<text x="%d" y="%d">%s</text>`, x0+16, padTop+8, name)
|
||||
x0 += 16 + 9*len(name) + 16
|
||||
}
|
||||
y := yAxis(&b, sz, maxOf(totals), top)
|
||||
|
||||
bottom := float64(sz.h - padBottom)
|
||||
slot := float64(sz.w-padLeft-padRight) / float64(len(vals))
|
||||
barW := slot * 0.7
|
||||
for i, v := range vals {
|
||||
x := padLeft + float64(i)*slot + (slot-barW)/2
|
||||
tip := html.EscapeString(fmt.Sprintf("%s: Ø %s Beiträge pro Tag (%s Threads, %s Antworten)",
|
||||
tipLabels[i], fmtNum(totals[i]), fmtNum(v[0]), fmtNum(v[1])))
|
||||
b.f(`<g data-tip="%s"><title>%s</title>`, tip, tip)
|
||||
// Unsichtbare volle Säule, damit auch kurze Balken gut zu treffen sind.
|
||||
b.f(`<rect x="%.1f" y="%d" width="%.1f" height="%.1f" fill="transparent"/>`,
|
||||
padLeft+float64(i)*slot, top, slot, bottom-float64(top))
|
||||
base := 0.0
|
||||
for k := range postKinds {
|
||||
if v[k] > 0 {
|
||||
b.f(`<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"/>`,
|
||||
x, y(base+v[k]), barW, y(base)-y(base+v[k]), colorStack[k])
|
||||
}
|
||||
base += v[k]
|
||||
}
|
||||
b.f(`</g>`)
|
||||
b.f(`<text x="%.1f" y="%.0f" text-anchor="middle">%s</text>`, x+barW/2, bottom+18, html.EscapeString(labels[i]))
|
||||
}
|
||||
b.f(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
Reference in New Issue
Block a user