| 1 | package report |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "html" |
| 6 | "html/template" |
| 7 | "math" |
| 8 | "strings" |
| 9 | ) |
| 10 | |
| 11 | // Row is one app in a chart: a value, or a reason it has none. |
| 12 | type Row struct { |
| 13 | Language string |
| 14 | Value float64 |
| 15 | Parts []float64 // stacked charts only |
| 16 | Missing string |
| 17 | Suffix string // appended to the value label |
| 18 | } |
| 19 | |
| 20 | // Chart is one horizontal bar chart, apps in fixed report order. |
| 21 | type Chart struct { |
| 22 | Title, Note string |
| 23 | ID string // copy id stem: chart.<ID>.title and chart.<ID>.note |
| 24 | Format func(float64) string |
| 25 | Legend []string // stacked charts only |
| 26 | LegendIDs []string // their copy ids |
| 27 | Rows []Row |
| 28 | } |
| 29 | |
| 30 | const ( |
| 31 | chartW = 680 |
| 32 | labelW = 150 |
| 33 | rowH = 30 |
| 34 | barH = 16 |
| 35 | gap = 2 |
| 36 | topPad = 8 |
| 37 | ) |
| 38 | |
| 39 | // SVG draws the chart. Bars grow from one baseline, 16px thick, with a |
| 40 | // 4px rounded data end; stacked segments are separated by a 2px gap; the |
| 41 | // value sits at the bar's tip in text ink; native <title> tooltips stand |
| 42 | // in for a hover layer, since the page carries no JavaScript. |
| 43 | func (c Chart) SVG() template.HTML { |
| 44 | max, widest := 0.0, 0.0 |
| 45 | for _, r := range c.Rows { |
| 46 | if r.Missing == "" && r.Value > max { |
| 47 | max = r.Value |
| 48 | } |
| 49 | if r.Missing == "" { |
| 50 | widest = math.Max(widest, textWidth(c.Format(r.Value)+r.Suffix)) |
| 51 | } |
| 52 | } |
| 53 | // Bars share what's left after the longest value label, so no label |
| 54 | // runs off the edge. |
| 55 | fullBarW := float64(chartW-labelW) - widest - 12 |
| 56 | h := topPad*2 + rowH*len(c.Rows) |
| 57 | var s strings.Builder |
| 58 | fmt.Fprintf(&s, `<svg class="chart" viewBox="0 0 %d %d" role="img" aria-label="%s">`, chartW, h, html.EscapeString(c.Title)) |
| 59 | fmt.Fprintf(&s, `<line class="axis" x1="%d" x2="%d" y1="%d" y2="%d"/>`, labelW, labelW, topPad, h-topPad) |
| 60 | for i, r := range c.Rows { |
| 61 | y := topPad + i*rowH |
| 62 | mid := y + rowH/2 |
| 63 | fmt.Fprintf(&s, `<text class="label" x="%d" y="%d">%s</text>`, labelW-10, mid+5, html.EscapeString(r.Language)) |
| 64 | if r.Missing != "" { |
| 65 | fmt.Fprintf(&s, `<text class="missing" x="%d" y="%d">%s</text>`, labelW+8, mid+5, html.EscapeString(r.Missing)) |
| 66 | continue |
| 67 | } |
| 68 | w := 0.0 |
| 69 | if max > 0 { |
| 70 | w = r.Value / max * fullBarW |
| 71 | } |
| 72 | by := mid - barH/2 |
| 73 | tip := html.EscapeString(r.Language + ": " + c.Format(r.Value)) |
| 74 | if c.HasParts() && len(r.Parts) > 0 { |
| 75 | x := float64(labelW) |
| 76 | for k, part := range r.Parts { |
| 77 | pw := part / max * fullBarW |
| 78 | if k > 0 { |
| 79 | x += gap |
| 80 | pw -= gap |
| 81 | } |
| 82 | if pw > 0 { |
| 83 | last := k == len(r.Parts)-1 |
| 84 | fmt.Fprintf(&s, `<path class="s%d" d="%s"><title>%s</title></path>`, k+1, barPath(x, float64(by), pw, last), html.EscapeString(fmt.Sprintf("%s: %s %s", r.Language, c.Legend[k], c.Format(part)))) |
| 85 | } |
| 86 | x += math.Max(pw, 0) |
| 87 | } |
| 88 | } else if w > 0 { |
| 89 | fmt.Fprintf(&s, `<path class="s1" d="%s"><title>%s</title></path>`, barPath(labelW, float64(by), w, true), tip) |
| 90 | } |
| 91 | fmt.Fprintf(&s, `<text class="value" x="%.1f" y="%d">%s</text>`, float64(labelW)+w+6, mid+5, html.EscapeString(c.Format(r.Value)+r.Suffix)) |
| 92 | } |
| 93 | s.WriteString(`</svg>`) |
| 94 | return template.HTML(s.String()) |
| 95 | } |
| 96 | |
| 97 | // barPath is a bar from (x,y) of width w: square at the baseline, and a |
| 98 | // 4px rounded data end when roundEnd is set. |
| 99 | func barPath(x, y, w float64, roundEnd bool) string { |
| 100 | r := math.Min(4, w/2) |
| 101 | if !roundEnd { |
| 102 | r = 0 |
| 103 | } |
| 104 | return fmt.Sprintf("M%.1f %.1fH%.1fQ%.1f %.1f %.1f %.1fV%.1fQ%.1f %.1f %.1f %.1fH%.1fZ", |
| 105 | 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) |
| 106 | } |
| 107 | |
| 108 | func (c Chart) HasParts() bool { return len(c.Legend) > 0 } |
| 109 | func (c Chart) Parts() []string { return c.Legend } |
| 110 | |
| 111 | // textWidth estimates the width in pixels of s in the charts' 13px sans, |
| 112 | // generously, so labels sized with it never overflow. |
| 113 | func textWidth(s string) float64 { return 7.6 * float64(len([]rune(s))) } |
| 114 | |