package report import ( "fmt" "html" "html/template" "math" "strings" ) // Row is one app in a chart: a value, or a reason it has none. type Row struct { Language string Value float64 Parts []float64 // stacked charts only Missing string Suffix string // appended to the value label } // Chart is one horizontal bar chart, apps in fixed report order. type Chart struct { Title, Note string ID string // copy id stem: chart..title and chart..note Format func(float64) string Legend []string // stacked charts only LegendIDs []string // their copy ids Rows []Row } const ( chartW = 680 labelW = 150 rowH = 30 barH = 16 gap = 2 topPad = 8 ) // SVG draws the chart. Bars grow from one baseline, 16px thick, with a // 4px rounded data end; stacked segments are separated by a 2px gap; the // value sits at the bar's tip in text ink; native tooltips stand // in for a hover layer, since the page carries no JavaScript. func (c Chart) SVG() template.HTML { max, widest := 0.0, 0.0 for _, r := range c.Rows { if r.Missing == "" && r.Value > max { max = r.Value } if r.Missing == "" { widest = math.Max(widest, textWidth(c.Format(r.Value)+r.Suffix)) } } // Bars share what's left after the longest value label, so no label // runs off the edge. fullBarW := float64(chartW-labelW) - widest - 12 h := topPad*2 + rowH*len(c.Rows) var s strings.Builder fmt.Fprintf(&s, `<svg class="chart" viewBox="0 0 %d %d" role="img" aria-label="%s">`, chartW, h, html.EscapeString(c.Title)) fmt.Fprintf(&s, `<line class="axis" x1="%d" x2="%d" y1="%d" y2="%d"/>`, labelW, labelW, topPad, h-topPad) for i, r := range c.Rows { y := topPad + i*rowH mid := y + rowH/2 fmt.Fprintf(&s, `<text class="label" x="%d" y="%d">%s</text>`, labelW-10, mid+5, html.EscapeString(r.Language)) if r.Missing != "" { fmt.Fprintf(&s, `<text class="missing" x="%d" y="%d">%s</text>`, labelW+8, mid+5, html.EscapeString(r.Missing)) continue } w := 0.0 if max > 0 { w = r.Value / max * fullBarW } by := mid - barH/2 tip := html.EscapeString(r.Language + ": " + c.Format(r.Value)) if c.HasParts() && len(r.Parts) > 0 { x := float64(labelW) for k, part := range r.Parts { pw := part / max * fullBarW if k > 0 { x += gap pw -= gap } if pw > 0 { last := k == len(r.Parts)-1 fmt.Fprintf(&s, `<path class="s%d" d="%s"><title>%s`, k+1, barPath(x, float64(by), pw, last), html.EscapeString(fmt.Sprintf("%s: %s %s", r.Language, c.Legend[k], c.Format(part)))) } x += math.Max(pw, 0) } } else if w > 0 { fmt.Fprintf(&s, `%s`, barPath(labelW, float64(by), w, true), tip) } fmt.Fprintf(&s, `%s`, float64(labelW)+w+6, mid+5, html.EscapeString(c.Format(r.Value)+r.Suffix)) } s.WriteString(``) return template.HTML(s.String()) } // barPath is a bar from (x,y) of width w: square at the baseline, and a // 4px rounded data end when roundEnd is set. func barPath(x, y, w float64, roundEnd bool) string { r := math.Min(4, w/2) if !roundEnd { r = 0 } return fmt.Sprintf("M%.1f %.1fH%.1fQ%.1f %.1f %.1f %.1fV%.1fQ%.1f %.1f %.1f %.1fH%.1fZ", x, y, x+w-r, x+w, y, x+w, y+r, y+barH-r, x+w, y+barH, x+w-r, y+barH, x) } func (c Chart) HasParts() bool { return len(c.Legend) > 0 } func (c Chart) Parts() []string { return c.Legend } // textWidth estimates the width in pixels of s in the charts' 13px sans, // generously, so labels sized with it never overflow. func textWidth(s string) float64 { return 7.6 * float64(len([]rune(s))) }