charts.gno
12.85 Kb · 407 lines
1package home
2
3import (
4 "encoding/base64"
5 "strconv"
6 "strings"
7)
8
9// Two charts, merged pull requests and reviews per month, each stacked by
10// repository. A realm cannot read GitHub, so each chart's numbers live in a
11// section, "activity" and "reviewactivity", and scripts/activity.py prints the
12// Set commands that refresh them. A section reads, lines split by ";":
13//
14// title merged pull requests
15// subtitle per month, on public gno.land repositories
16// series gnolang/gno,gnodaokit,Others,Peer Dev
17// scale Peer Dev,10,commits
18// outline gnolang/gno pull requests opened
19// updated 2026-09-24
20// 2025-07 4 0 1 48 | 3
21//
22// Each month row carries one count per series. A scaled series counts another
23// unit: its bars are drawn at one step per 10, and it stays out of the totals.
24// The count after "|" draws a striped bar in the first series' colour beside
25// the stack.
26
27const (
28 activitySlot = "activity"
29 reviewActivitySlot = "reviewactivity"
30 chartWidth = 720
31 chartX = 24
32 maxBar = 90
33 scaledFill = "#fbbf24"
34 othersFill = "#94a3b8"
35)
36
37var (
38 monthNames = []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
39 // A repository keeps its colour in both charts; one not listed here takes
40 // a spare colour picked from its name.
41 repoFills = map[string]string{
42 "gnolang/gno": "#3ddc97",
43 "gno-onboarding-bot": "#7aa2ff",
44 "gnodaokit": "#c084fc",
45 "awesome-gno": "#f472b6",
46 "gnolove": "#2dd4bf",
47 }
48 spareFills = []string{"#fb923c", "#a3e635", "#38bdf8", "#e879f9"}
49)
50
51type month struct {
52 label string // "Jul", or "Jan 26" where a year starts
53 counts []int
54 outline int
55}
56
57type activity struct {
58 noun string
59 subtitle string
60 series []string
61 scaled string // the series counted in another unit, or ""
62 scaleDiv int
63 scaleUnit string
64 outline string // the striped bar's legend, or ""
65 updated string // "24 Sep 2026", the day the numbers were counted
66 months []month
67}
68
69// parseActivity reads a chart section; a malformed line is skipped.
70func parseActivity(spec string) activity {
71 var a activity
72 for _, line := range strings.Split(strings.ReplaceAll(spec, "\n", ";"), ";") {
73 line = strings.TrimSpace(line)
74 switch {
75 case strings.HasPrefix(line, "title "):
76 a.noun = strings.TrimSpace(line[6:])
77 case strings.HasPrefix(line, "subtitle "):
78 a.subtitle = strings.TrimSpace(line[9:])
79 case strings.HasPrefix(line, "series "):
80 for _, s := range strings.Split(line[7:], ",") {
81 a.series = append(a.series, strings.TrimSpace(s))
82 }
83 case strings.HasPrefix(line, "scale "):
84 parts := strings.Split(line[6:], ",")
85 if len(parts) != 3 {
86 continue
87 }
88 div, err := strconv.Atoi(strings.TrimSpace(parts[1]))
89 if err != nil || div < 1 {
90 continue
91 }
92 a.scaled, a.scaleDiv, a.scaleUnit = strings.TrimSpace(parts[0]), div, strings.TrimSpace(parts[2])
93 case strings.HasPrefix(line, "outline "):
94 a.outline = strings.TrimSpace(line[8:])
95 case strings.HasPrefix(line, "updated "):
96 a.updated = formatDay(strings.TrimSpace(line[8:]))
97 default:
98 if m, ok := parseMonth(line, len(a.series), len(a.months) == 0); ok {
99 a.months = append(a.months, m)
100 }
101 }
102 }
103 return a
104}
105
106func parseMonth(line string, width int, first bool) (month, bool) {
107 fields := strings.Fields(line)
108 if len(fields) < 1 || len(fields[0]) != 7 || fields[0][4] != '-' {
109 return month{}, false
110 }
111 m, err := strconv.Atoi(fields[0][5:])
112 if err != nil || m < 1 || m > 12 {
113 return month{}, false
114 }
115 out := month{label: monthNames[m-1]}
116 if m == 1 || first {
117 out.label += " " + fields[0][2:4]
118 }
119 counts := fields[1:]
120 if n := len(counts); n >= 2 && counts[n-2] == "|" {
121 v, err := strconv.Atoi(counts[n-1])
122 if err != nil || v < 0 {
123 return month{}, false
124 }
125 out.outline, counts = v, counts[:n-2]
126 }
127 if len(counts) != width || width == 0 {
128 return month{}, false
129 }
130 for _, f := range counts {
131 n, err := strconv.Atoi(f)
132 if err != nil || n < 0 {
133 return month{}, false
134 }
135 out.counts = append(out.counts, n)
136 }
137 return out, true
138}
139
140// formatDay turns 2026-09-24 into "24 Sep 2026", or "" when it is not a date.
141func formatDay(s string) string {
142 if len(s) != 10 || s[4] != '-' || s[7] != '-' {
143 return ""
144 }
145 m, err1 := strconv.Atoi(s[5:7])
146 d, err2 := strconv.Atoi(s[8:])
147 if err1 != nil || err2 != nil || m < 1 || m > 12 || d < 1 || d > 31 {
148 return ""
149 }
150 return strconv.Itoa(d) + " " + monthNames[m-1] + " " + s[:4]
151}
152
153func (a activity) fill(i int) string {
154 name := a.series[i]
155 switch name {
156 case "Others":
157 return othersFill
158 case a.scaled:
159 return scaledFill
160 }
161 if f, ok := repoFills[name]; ok {
162 return f
163 }
164 sum := 0
165 for j := 0; j < len(name); j++ {
166 sum += int(name[j])
167 }
168 return spareFills[sum%len(spareFills)]
169}
170
171// drawn returns how many steps series i takes in the bar, rounding a scaled
172// count up so a quiet month still shows.
173func (a activity) drawn(i, n int) int {
174 if a.series[i] == a.scaled {
175 return (n + a.scaleDiv - 1) / a.scaleDiv
176 }
177 return n
178}
179
180// renderChart draws a chart section as a markdown image, or "" when it holds
181// no month.
182func renderChart(slot string) string {
183 a := parseActivity(Get(slot))
184 if len(a.months) == 0 || a.noun == "" {
185 return ""
186 }
187 totals := make([]int, len(a.series))
188 peak, sum, outlined := 1, 0, 0
189 for _, m := range a.months {
190 outlined += m.outline
191 if m.outline > peak {
192 peak = m.outline
193 }
194 steps := 0
195 for i, n := range m.counts {
196 totals[i] += n
197 steps += a.drawn(i, n)
198 if a.series[i] != a.scaled {
199 sum += n
200 }
201 }
202 if steps > peak {
203 peak = steps
204 }
205 }
206
207 // Legend entries flow left to right and wrap, so the chart sits below them.
208 var b, items strings.Builder
209 x, y := chartX, 96
210 for i, s := range a.series {
211 label := s + " " + strconv.Itoa(totals[i])
212 if s == a.scaled {
213 label += " " + a.scaleUnit + ", drawn 1 per " + strconv.Itoa(a.scaleDiv)
214 }
215 w := 24 + len(label)*7
216 if x+w > chartWidth-chartX {
217 x, y = chartX, y+22
218 }
219 rect(&items, x, y-10, 12, 12, 0, a.fill(i))
220 text(&items, x+18, y, "s", "#ffffff", label)
221 x += w + 12
222 }
223 if a.outline != "" {
224 label := a.outline + " " + strconv.Itoa(outlined)
225 w := 24 + len(label)*7
226 if x+w > chartWidth-chartX {
227 x, y = chartX, y+22
228 }
229 rect(&items, x, y-10, 12, 12, 0, "url(#o)")
230 text(&items, x+18, y, "s", "#ffffff", label)
231 }
232 baseline := y + 30 + maxBar
233 height := baseline + 34
234
235 sh, sw := strconv.Itoa(height), strconv.Itoa(chartWidth)
236 b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="` + sw + `" height="` + sh + `" viewBox="0 0 ` + sw + ` ` + sh + `">`)
237 b.WriteString("<style>.h{font:700 26px sans-serif}.s{font:13px sans-serif}" +
238 ".n{font:600 11px sans-serif;text-anchor:middle}.m{font:11px sans-serif;text-anchor:middle}" +
239 ".u{font:12px sans-serif;text-anchor:end}</style>")
240 b.WriteString(`<defs><pattern id="o" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">` +
241 `<rect width="2" height="5" fill="` + repoFills["gnolang/gno"] + `"/></pattern></defs>`)
242 rect(&b, 0, 0, chartWidth, height, 12, "#10231c")
243 text(&b, chartX, 42, "h", "#ffffff", strconv.Itoa(sum)+" "+a.noun)
244 if a.subtitle != "" {
245 text(&b, chartX, 66, "s", "#9fc9b4", a.subtitle)
246 }
247 if a.updated != "" {
248 text(&b, chartWidth-chartX, 42, "u", "#9fc9b4", "updated "+a.updated)
249 }
250 b.WriteString(items.String())
251
252 step := (chartWidth - 2*chartX) / len(a.months)
253 barW := step * 2 / 3
254 stripeW := 0
255 if a.outline != "" {
256 // The striped bar and the stack share the month's slot, opened first,
257 // at the same width and touching.
258 barW = step * 2 / 5
259 stripeW = barW
260 }
261 for i, m := range a.months {
262 sx := chartX + i*step + (step-barW-stripeW)/2
263 mid := sx + (barW+stripeW)/2
264 bx := sx
265 if stripeW > 0 {
266 bx = sx + stripeW
267 if m.outline > 0 {
268 h := m.outline * maxBar / peak
269 if h == 0 {
270 h = 1
271 }
272 rect(&b, sx, baseline-h, stripeW, h, 0, "url(#o)")
273 }
274 }
275 top, total := baseline, 0
276 for j, n := range m.counts {
277 d := a.drawn(j, n)
278 h := d * maxBar / peak
279 if d > 0 && h == 0 {
280 h = 1
281 }
282 if h == 0 {
283 continue
284 }
285 top -= h
286 if a.series[j] != a.scaled {
287 total += n
288 }
289 rect(&b, bx, top, barW, h, 0, a.fill(j))
290 }
291 if top == baseline {
292 rect(&b, bx, baseline-2, barW, 2, 0, "#2c4a3e")
293 } else if total > 0 {
294 text(&b, bx+barW/2, top-5, "n", "#ffffff", strconv.Itoa(total))
295 }
296 text(&b, mid, baseline+18, "m", "#9fc9b4", m.label)
297 }
298 b.WriteString("</svg>")
299 alt := strings.ToUpper(a.noun[:1]) + a.noun[1:] + " per month, by repository"
300 return ")) + ")\n"
301}
302
303// The chart is written straight into one builder: p/nt/svg formats each
304// element with ufmt and appends it to a growing string, which cost this page
305// close to a billion gas.
306
307func rect(b *strings.Builder, x, y, w, h, r int, fill string) {
308 b.WriteString(`<rect x="` + strconv.Itoa(x) + `" y="` + strconv.Itoa(y) + `" width="` + strconv.Itoa(w) +
309 `" height="` + strconv.Itoa(h) + `" rx="` + strconv.Itoa(r) + `" fill="` + fill + `"/>`)
310}
311
312// text writes s as SVG text; callers pass repository names and numbers, so it
313// escapes the three characters that could break the markup.
314func text(b *strings.Builder, x, y int, class, fill, s string) {
315 s = strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(s)
316 b.WriteString(`<text x="` + strconv.Itoa(x) + `" y="` + strconv.Itoa(y) + `" class="` + class + `" fill="` + fill + `">` + s + `</text>`)
317}
318
319// The area chart is a ring of one number per area, from the "areas" section,
320// which reads, lines split by ";":
321//
322// title pull requests by area
323// subtitle merged into gnolang/gno
324// updated 2026-09-24
325// slice 26 Docs
326// slice 17 GnoVM
327
328const areasSlot = "areas"
329
330var areaFills = []string{"#3ddc97", "#7aa2ff", "#c084fc", "#f472b6", "#2dd4bf", "#fb923c"}
331
332type slice struct {
333 label string
334 count int
335}
336
337func parseAreas(spec string) (noun, subtitle, updated string, slices []slice) {
338 for _, line := range strings.Split(strings.ReplaceAll(spec, "\n", ";"), ";") {
339 line = strings.TrimSpace(line)
340 switch {
341 case strings.HasPrefix(line, "title "):
342 noun = strings.TrimSpace(line[6:])
343 case strings.HasPrefix(line, "subtitle "):
344 subtitle = strings.TrimSpace(line[9:])
345 case strings.HasPrefix(line, "updated "):
346 updated = formatDay(strings.TrimSpace(line[8:]))
347 case strings.HasPrefix(line, "slice "):
348 n, label, ok := strings.Cut(strings.TrimSpace(line[6:]), " ")
349 c, err := strconv.Atoi(n)
350 if ok && err == nil && c > 0 && strings.TrimSpace(label) != "" {
351 slices = append(slices, slice{strings.TrimSpace(label), c})
352 }
353 }
354 }
355 return noun, subtitle, updated, slices
356}
357
358// renderAreas draws the "areas" section as a ring, or "" when it holds no slice.
359func renderAreas() string {
360 noun, subtitle, updated, slices := parseAreas(Get(areasSlot))
361 if len(slices) == 0 || noun == "" {
362 return ""
363 }
364 total := 0
365 for _, s := range slices {
366 total += s.count
367 }
368 const height, cx, cy, r = 300, 140, 190, 80
369 var b strings.Builder
370 b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="720" height="300" viewBox="0 0 720 300">`)
371 b.WriteString("<style>.h{font:700 26px sans-serif}.s{font:13px sans-serif}.t{font:700 34px sans-serif;text-anchor:middle}" +
372 ".c{font:13px sans-serif;text-anchor:middle}.l{font:15px sans-serif}.p{font:13px sans-serif;text-anchor:end}" +
373 ".u{font:12px sans-serif;text-anchor:end}</style>")
374 rect(&b, 0, 0, chartWidth, height, 12, "#10231c")
375 text(&b, chartX, 42, "h", "#ffffff", strings.ToUpper(noun[:1])+noun[1:])
376 if subtitle != "" {
377 text(&b, chartX, 66, "s", "#9fc9b4", subtitle)
378 }
379 if updated != "" {
380 text(&b, chartWidth-chartX, 42, "u", "#9fc9b4", "updated "+updated)
381 }
382 // Each slice is a circle stroke whose dash covers its share of a ring
383 // measured as 1000 units, rotated so the first slice starts at the top.
384 start := 0
385 for i, s := range slices {
386 share := s.count * 1000 / total
387 if i == len(slices)-1 {
388 share = 1000 - start
389 }
390 b.WriteString(`<circle cx="` + strconv.Itoa(cx) + `" cy="` + strconv.Itoa(cy) + `" r="` + strconv.Itoa(r) +
391 `" fill="none" stroke="` + areaFills[i%len(areaFills)] + `" stroke-width="34" pathLength="1000"` +
392 ` stroke-dasharray="` + strconv.Itoa(share) + ` 1000" stroke-dashoffset="` + strconv.Itoa(-start) +
393 `" transform="rotate(-90 ` + strconv.Itoa(cx) + ` ` + strconv.Itoa(cy) + `)"/>`)
394 start += share
395 }
396 text(&b, cx, cy+8, "t", "#ffffff", strconv.Itoa(total))
397 text(&b, cx, cy+28, "c", "#9fc9b4", "merged")
398 for i, s := range slices {
399 y := 130 + i*30
400 rect(&b, 280, y-12, 14, 14, 3, areaFills[i%len(areaFills)])
401 text(&b, 304, y, "l", "#ffffff", s.label)
402 text(&b, chartWidth-chartX-60, y, "p", "#ffffff", strconv.Itoa(s.count))
403 text(&b, chartWidth-chartX, y, "p", "#9fc9b4", strconv.Itoa(s.count*100/total)+"%")
404 }
405 b.WriteString("</svg>")
406 return "![" + mdText(strings.ToUpper(noun[:1])+noun[1:]+", "+subtitle) + "](data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(b.String())) + ")\n"
407}