package web import ( "fmt" "html/template" "math" "sort" "strings" "time" "gitea.mynodes.xyz/ilker/llmgw/internal/promq" ) // vizColours is the categorical palette, in the order it is handed out. // // Fixed order, never cycled. A fifth line does not get a generated hue — it is // folded into "other" by foldSeries below, because a chart whose colours repeat // is one where two lines mean the same thing and neither can be read. var vizColours = []string{"--viz-1", "--viz-2", "--viz-3", "--viz-4"} // otherColour is the muted grey the tail is drawn in, so it reads as a total // rather than as a fifth thing with a name. const otherColour = "--muted" const ( tsW = 720.0 tsH = 150.0 tsPadL = 44.0 tsPadB = 18.0 tsPadT = 8.0 tsGridRows = 3 ) // TimeSeries is a chart of several lines over one clock. type TimeSeries struct { SVG template.HTML Legend []LegendEntry // Empty is true when there was nothing to draw, so the page can say why // rather than showing a frame with no lines in it. Empty bool Peak string } // LegendEntry names one line and the colour it was drawn in. // // A legend is always present for two lines or more: colour alone is not an // encoding anybody can rely on, and a reader who cannot tell two lines apart // has a picture rather than a chart. type LegendEntry struct { Name string Colour string Last string } // LineChart draws several series against one axis. // // One axis, always. Two measures of different scale belong in two charts: a // second y-axis lets any two lines be made to cross wherever the author likes, // and the reader has no way to know it happened. // // Ranked by peak, which is what a gauge asks: the model that hit the ceiling is // the one worth a colour. For a counter, see LineChartTotals. func LineChart(series []promq.Series, unit string, maxSeries int) TimeSeries { return english.LineChart(series, unit, maxSeries) } // LineChart is the chart with its axis, peak and legend figures written the // way this language writes numbers. func (l *Lang) LineChart(series []promq.Series, unit string, maxSeries int) TimeSeries { return l.lineChart(series, unit, maxSeries, promq.Series.Max, promq.Series.Last) } // LineChartTotals is LineChart ranked by the sum over the window. // // Which is the right question of a counter. "Who used the most" is answered by // the total, and ranking by peak would give a colour to the team that made one // large burst over the team that ran steadily all week — the opposite of what a // usage report is read for. // // Its legend gives each line's total too. The latest slice, which is what a // gauge's legend shows, is usually a quiet one for a counter: a legend reading // "0 · 0 · 0" under a table of hundreds said nothing true. func LineChartTotals(series []promq.Series, unit string, maxSeries int) TimeSeries { return english.LineChartTotals(series, unit, maxSeries) } // LineChartTotals is LineChartTotals in this language. func (l *Lang) LineChartTotals(series []promq.Series, unit string, maxSeries int) TimeSeries { return l.lineChart(series, unit, maxSeries, promq.Series.Sum, promq.Series.Sum) } func (l *Lang) lineChart(series []promq.Series, unit string, maxSeries int, weight, legend func(promq.Series) float64, ) TimeSeries { if maxSeries <= 0 || maxSeries > len(vizColours) { maxSeries = len(vizColours) } series = foldSeries(series, maxSeries, weight) if len(series) == 0 { return TimeSeries{Empty: true} } minT, maxT, maxV := bounds(series) if maxT.Equal(minT) { // One instant is not a line. Reported as empty rather than drawn as a // dot in the corner, which reads as a broken chart. return TimeSeries{Empty: true} } // A flat zero is a real answer — nothing is happening — and drawing it // along the bottom says so, where an autoscaled empty frame would not. if maxV <= 0 { maxV = 1 } maxV = niceCeiling(maxV) var b strings.Builder fmt.Fprintf(&b, ``, tsW, tsH) l.writeGrid(&b, maxV, unit) out := TimeSeries{Peak: l.formatValue(maxV, unit)} span := maxT.Sub(minT).Seconds() for i, s := range series { colour, name := otherColour, l.T("chart.other") if s.Name != otherLabel { colour, name = vizColours[i%len(vizColours)], s.Name } writeLine(&b, s, minT, span, maxV, colour) out.Legend = append(out.Legend, LegendEntry{ Name: name, Colour: colour, Last: l.formatValue(legend(s), unit), }) } b.WriteString(``) out.SVG = template.HTML(b.String()) //nolint:gosec // numbers and CSS vars only return out } const otherLabel = "other" // foldSeries keeps the busiest lines and sums the rest into one. // // Rather than dropping the tail, which would make a chart that does not add up, // or colouring it, which needs hues the palette does not have. The fold is // labelled so nobody reads it as a single model. // // What "busiest" means is the caller's: a gauge is ranked by its peak and a // counter by its total, and the two disagree exactly where it matters. func foldSeries(in []promq.Series, keep int, weight func(promq.Series) float64) []promq.Series { clean := make([]promq.Series, 0, len(in)) for _, s := range in { if len(s.Points) > 0 { clean = append(clean, s) } } if len(clean) <= keep { return clean } sort.SliceStable(clean, func(i, j int) bool { return weight(clean[i]) > weight(clean[j]) }) rest := clean[keep-1:] folded := promq.Series{Name: otherLabel} // Summed by timestamp, because the tail's lines do not necessarily share // sample times and adding them by index would shift points sideways. at := map[int64]float64{} for _, s := range rest { for _, p := range s.Points { at[p.At.Unix()] += p.Value } } keys := make([]int64, 0, len(at)) for k := range at { keys = append(keys, k) } sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) for _, k := range keys { folded.Points = append(folded.Points, promq.Point{At: time.Unix(k, 0), Value: at[k]}) } return append(append([]promq.Series{}, clean[:keep-1]...), folded) } func bounds(series []promq.Series) (minT, maxT time.Time, maxV float64) { for _, s := range series { for _, p := range s.Points { if minT.IsZero() || p.At.Before(minT) { minT = p.At } if p.At.After(maxT) { maxT = p.At } if p.Value > maxV { maxV = p.Value } } } return minT, maxT, maxV } // writeGrid draws the horizontal rules and their labels. // // Recessive on purpose: the grid is there to be read off, not looked at, and a // grid as dark as the data competes with it. func (l *Lang) writeGrid(b *strings.Builder, maxV float64, unit string) { plotH := tsH - tsPadB - tsPadT for i := 0; i <= tsGridRows; i++ { y := tsPadT + plotH*float64(i)/tsGridRows v := maxV * float64(tsGridRows-i) / tsGridRows fmt.Fprintf(b, ``, tsPadL, y, tsW, y) fmt.Fprintf(b, `%s`, tsPadL-6, y+3, escapeText(l.formatValue(v, unit))) } } func writeLine(b *strings.Builder, s promq.Series, minT time.Time, span, maxV float64, colour string, ) { plotW := tsW - tsPadL plotH := tsH - tsPadB - tsPadT fmt.Fprintf(b, ``) } func clamp01(v float64) float64 { if math.IsNaN(v) || v < 0 { return 0 } if v > 1 { return 1 } return v } // niceCeiling rounds an axis maximum up to something a person would choose. // // An axis topping out at 0.8347 makes every gridline a number nobody can hold // in their head, and the shape of the data is what the chart is for. func niceCeiling(v float64) float64 { if v <= 0 { return 1 } mag := math.Pow(10, math.Floor(math.Log10(v))) for _, step := range []float64{1, 2, 2.5, 5, 10} { if v <= step*mag { return step * mag } } return 10 * mag } func formatValue(v float64, unit string) string { return english.formatValue(v, unit) } // formatValue is a figure on an axis or in a legend, in this language's // separators. The percent sign stays after the number, as it is on every // other page of the portal, so a figure reads the same wherever it is. func (l *Lang) formatValue(v float64, unit string) string { switch unit { case "%": // A decimal below ten, where whole percents would print a quiet // machine's axis as 0%, 0%, 0%. if v != 0 && v*100 < 10 { return l.Num(v*100, 1) + "%" } return l.Num(v*100, 0) + "%" case "s": if v < 1 { return l.T("chart.axis_ms", l.Num(v*1000, 0)) } return l.T("chart.axis_s", l.Num(v, 1)) case "bytes": // Binary, like every other size on these pages. compact's own // suffixes are for counts, and on bytes read as "20.0BB". return strings.Replace(l.Bytes(int64(v)), " ", "", 1) default: return l.Compact(v) } }