Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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 "![" + alt + "](data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(b.String())) + ")\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("&", "&amp;", "<", "&lt;", ">", "&gt;").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}