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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}