package dash import ( "fmt" "html" "strings" ) // Chart palette, matching the muted colors in static/css/admin.css. const ( ChartPrimary = "#6b8caf" ChartSuccess = "#5a8a72" ChartInfo = "#6a9aaa" ChartWarning = "#b8995e" ChartDanger = "#a5615c" ) // Y-axis value formats. const ( UnitCount = "" UnitBytes = "bytes" UnitBytesPS = "bps" UnitSeconds = "seconds" UnitPercent = "pct" ) type ChartSeries struct { Name string Color string Data []float64 Area bool } type ChartOptions struct { Unit string // Threshold draws a dashed reference line, e.g. a disk-usage limit. Threshold *float64 // Labels are x-axis tick labels, oldest first. Falls back to relative // sample offsets when empty. Labels []string } // RenderChart draws a multi-series line chart as a self-contained inline SVG, // with no JavaScript. Series must be equal length; shorter ones are left-padded // so all series share the newest sample. func RenderChart(series []ChartSeries, opts ChartOptions) string { const w, h = 560.0, 190.0 const padL, padR, padT, padB = 46.0, 8.0, 10.0, 22.0 plotH := h - padT - padB series = alignSeries(series) n := seriesLen(series) if n < 2 { return fmt.Sprintf(`collecting data…`, w, h, padL, padT+plotH/2, w-padR, padT+plotH/2, w/2, padT+plotH/2-8) } min, max := chartRange(series, opts.Threshold) span := max - min px := func(i int) float64 { return padL + float64(i)*(w-padL-padR)/float64(n-1) } py := func(v float64) float64 { return padT + plotH*(1-(v-min)/span) } var s strings.Builder for g := 0; g <= 4; g++ { v := min + span*float64(g)/4 y := py(v) fmt.Fprintf(&s, ``, padL, y, w-padR, y) fmt.Fprintf(&s, `%s`, padL-6, y+3, html.EscapeString(FormatChartValue(v, opts.Unit))) } for _, i := range []int{0, n / 2, n - 1} { fmt.Fprintf(&s, `%s`, px(i), h-6, html.EscapeString(xLabel(opts, i, n))) } if opts.Threshold != nil { fmt.Fprintf(&s, ``, padL, py(*opts.Threshold), w-padR, py(*opts.Threshold), ChartDanger) } for _, se := range series { var d strings.Builder for i, v := range se.Data { if i == 0 { fmt.Fprintf(&d, "M%.1f %.1f", px(i), py(v)) } else { fmt.Fprintf(&d, " L%.1f %.1f", px(i), py(v)) } } color := se.Color if color == "" { color = ChartPrimary } if se.Area { base := py(min) fmt.Fprintf(&s, ``, d.String(), px(n-1), base, px(0), base, color) } fmt.Fprintf(&s, ``, d.String(), color) } return fmt.Sprintf(`%s`, w, h, s.String()) } // RenderLegend renders series names and colors as Bootstrap-friendly markup. func RenderLegend(series []ChartSeries) string { var b strings.Builder b.WriteString(`
`) for _, se := range series { color := se.Color if color == "" { color = ChartPrimary } fmt.Fprintf(&b, `%s`, html.EscapeString(color), html.EscapeString(se.Name)) } b.WriteString(`
`) return b.String() } // seriesLen returns the sample count shared by all series. func seriesLen(series []ChartSeries) int { n := 0 for _, se := range series { if len(se.Data) > n { n = len(se.Data) } } return n } // alignSeries left-pads shorter series so every series ends on the newest // sample. Series with no data are dropped. func alignSeries(series []ChartSeries) []ChartSeries { n := seriesLen(series) if n == 0 { return nil } out := make([]ChartSeries, 0, len(series)) for _, se := range series { if len(se.Data) == 0 { continue } if len(se.Data) < n { padded := make([]float64, n) copy(padded[n-len(se.Data):], se.Data) se.Data = padded } out = append(out, se) } return out } // chartRange picks the y-axis bounds. It anchors at zero so magnitudes stay // comparable, and never returns a zero span. func chartRange(series []ChartSeries, threshold *float64) (float64, float64) { min, max := 0.0, 0.0 for _, se := range series { for _, v := range se.Data { if v < min { min = v } if v > max { max = v } } } if threshold != nil && *threshold > max { max = *threshold * 1.15 } if max == min { max = min + 1 } return min, max } func xLabel(opts ChartOptions, i, n int) string { if i < len(opts.Labels) { return opts.Labels[i] } if i == n-1 { return "now" } return fmt.Sprintf("-%d", n-1-i) } // FormatChartValue renders an axis value in the given unit. func FormatChartValue(v float64, unit string) string { switch unit { case UnitBytes: return formatChartBytes(v) case UnitBytesPS: return formatChartBytes(v) + "/s" case UnitSeconds: if v < 1 { return fmt.Sprintf("%.0f ms", v*1000) } return fmt.Sprintf("%.2f s", v) case UnitPercent: return fmt.Sprintf("%.0f%%", v) default: switch { case v >= 1e6: return fmt.Sprintf("%.1fM", v/1e6) case v >= 1000: return fmt.Sprintf("%.1fk", v/1000) case v == float64(int64(v)): return fmt.Sprintf("%d", int64(v)) default: return fmt.Sprintf("%.2f", v) } } } func formatChartBytes(v float64) string { const unit = 1024.0 if v < unit { return fmt.Sprintf("%.0f B", v) } div, exp := unit, 0 for v/div >= unit && exp < 5 { div *= unit exp++ } return fmt.Sprintf("%.1f %cB", v/div, "KMGTPE"[exp]) } // LatestValue returns the newest sample, or 0 when there is no data. func LatestValue(data []float64) float64 { if len(data) == 0 { return 0 } return data[len(data)-1] }