package adminconsole import ( "fmt" "html/template" "strings" "time" ) // ChartPoint is one move-number sample of the move-duration chart: the min, mean and // max think time (seconds) the account took on its Ordinal-th move across its games. type ChartPoint struct { Ordinal int Min float64 Max float64 Avg float64 } // FormatDuration renders a think-time in seconds as a compact human string // ("45s", "3m", "1h5m"), for the user-list columns and the chart's Y labels. func FormatDuration(secs float64) string { d := time.Duration(secs * float64(time.Second)) switch { case d < time.Minute: return fmt.Sprintf("%ds", int(d.Seconds()+0.5)) case d < time.Hour: return fmt.Sprintf("%dm", int(d.Minutes()+0.5)) default: h := int(d.Hours()) if m := int(d.Minutes()) - h*60; m > 0 { return fmt.Sprintf("%dh%dm", h, m) } return fmt.Sprintf("%dh", h) } } // MoveDurationChart renders the per-move-number think-time chart as a self-contained, // script-free inline SVG with three series (min, mean, max). The coordinates and // labels are all derived from numeric data, so the result is safe template.HTML. // An empty series renders nothing. func MoveDurationChart(points []ChartPoint) template.HTML { if len(points) == 0 { return "" } const ( w, h = 640, 240 padL = 46 padR = 12 padT = 10 padB = 28 ) maxOrd := points[len(points)-1].Ordinal if maxOrd < 1 { maxOrd = 1 } var maxY float64 for _, p := range points { maxY = max(maxY, p.Max) } if maxY <= 0 { maxY = 1 } xOf := func(ord int) float64 { if maxOrd == 1 { return padL } return padL + (float64(ord-1)/float64(maxOrd-1))*(w-padL-padR) } yOf := func(v float64) float64 { return padT + (1-v/maxY)*(h-padT-padB) } line := func(get func(ChartPoint) float64) string { pts := make([]string, len(points)) for i, p := range points { pts[i] = fmt.Sprintf("%.1f,%.1f", xOf(p.Ordinal), yOf(get(p))) } return strings.Join(pts, " ") } var b strings.Builder fmt.Fprintf(&b, ``, w, h) fmt.Fprintf(&b, ``, padL, padT, padL, float64(h-padB)) fmt.Fprintf(&b, ``, padL, float64(h-padB), w-padR, float64(h-padB)) for _, frac := range []float64{0, 0.5, 1} { v := maxY * frac y := yOf(v) fmt.Fprintf(&b, ``, padL, y, w-padR, y) fmt.Fprintf(&b, `%s`, padL-5, y+3, FormatDuration(v)) } for _, ord := range xTicks(maxOrd) { fmt.Fprintf(&b, `%d`, xOf(ord), h-padB+15, ord) } fmt.Fprintf(&b, ``, line(func(p ChartPoint) float64 { return p.Max })) fmt.Fprintf(&b, ``, line(func(p ChartPoint) float64 { return p.Avg })) fmt.Fprintf(&b, ``, line(func(p ChartPoint) float64 { return p.Min })) b.WriteString(``) return template.HTML(b.String()) } // xTicks returns up to three distinct ordinal labels for the chart's X axis. func xTicks(maxOrd int) []int { if maxOrd <= 2 { out := make([]int, 0, maxOrd) for i := 1; i <= maxOrd; i++ { out = append(out, i) } return out } return []int{1, (maxOrd + 1) / 2, maxOrd} }