package web
// The activity profile (RFC 0013): the core sample — a window of
// check-ins as dots, hour of day or path on Y, one colour per person —
// and the core slice, the whole history folded to one cell per day and
// one row per year. Both are drawn here as inline SVG from the two ops'
// output, so a reader without JavaScript gets the whole picture and
// the controls are links. The canvas that replaces the sample when a
// script runs is a later landing; the SVG stays as its first paint.
import (
"fmt"
"html/template"
"net/http"
"net/url"
"sort"
"strings"
"time"
"craftdesign.group/quarry/internal/ops"
)
// The op rows, as the registry marshals them.
type activityAuthor struct {
Name string `json:"name"`
Checkins int `json:"checkins"`
Files int `json:"files"`
Hue int `json:"hue"`
Shade int `json:"shade"`
}
type activityRow struct {
Day int `json:"day"`
Hour int `json:"hour"`
Author int `json:"author"`
Merge int `json:"merge"`
Files int `json:"files"`
Hash string `json:"hash"`
Branch string `json:"branch"`
}
type activityTouch struct {
Day int `json:"day"`
Path int `json:"path"`
Author int `json:"author"`
Inherited int `json:"inherited"`
}
type activityOut struct {
Since string `json:"since"`
Until string `json:"until"`
Authors []activityAuthor `json:"authors"`
Paths []string `json:"paths"`
Rows []activityRow `json:"rows"`
Touches []activityTouch `json:"touches"`
Window string `json:"window"`
}
type activityDay struct {
N int `json:"n"`
Merges int `json:"merges"`
Top [][]int `json:"top"`
Authors int `json:"authors"`
Path int `json:"path"`
}
type activityDaysOut struct {
First string `json:"first"`
Last string `json:"last"`
Authors []activityAuthor `json:"authors"`
Days map[string]activityDay `json:"days"`
Paths []string `json:"paths"`
Releases map[string]string `json:"releases"`
}
// activityView is what the activity page template draws from.
type activityView struct {
Axis string // "clock" or "tree"
Branch string
User string
Since string
Until string
Window string // "picked" or "given"
// Halved counts how many times the window was cut in half to fit
// the tree SVG under its byte budget (§7). Zero is the normal case.
Halved int
Rows int
Touches int // authored touches — the dots on the tree axis
Sample template.HTML
Slice template.HTML
Legend []activityLegend
Branches []string
// Summary is the marginal the sighted reader takes from the shape,
// as rows for a visually hidden table (§10).
Summary []activitySummary
SummaryLabel string
Years []activityYear // the slice's numbers, for its hidden table
}
type activityYear struct {
Label string
Checkins int
Days int
Merges int
}
type activityLegend struct {
Index int
Name string
Checkins int
Files int
Colour template.CSS // hsl(...) with theme tokens; html/template would reject the var()
URL string
Current bool
}
type activitySummary struct {
Label string
N int
}
// activityByteBudget is the size the tree axis must stay under; past
// it the server halves the window and says so (§7). A variable so a
// test can force the halving without a 25,000-touch fixture.
var activityByteBudget = 1 << 20
// activityColour is a person's colour as a CSS value: hue from the op,
// saturation and lightness from the theme tokens, shade stepping the
// lightness so neighbours on the wheel stay apart (§6).
func activityColour(a activityAuthor) string {
if a.Shade == 0 {
return fmt.Sprintf("hsl(%d var(--dot-s) var(--dot-l))", a.Hue)
}
return fmt.Sprintf("hsl(%d var(--dot-s) calc(var(--dot-l) + %d%%))", a.Hue, a.Shade*8)
}
func activityURL(repo, axis, branch, user string) string {
q := url.Values{}
if axis != "" && axis != "clock" {
q.Set("axis", axis)
}
if branch != "" {
q.Set("branch", branch)
}
if user != "" {
q.Set("user", user)
}
u := "/r/" + url.PathEscape(repo) + "/activity"
if len(q) > 0 {
u += "?" + q.Encode()
}
return u
}
func dayAfter(iso string, n int) string {
t, err := time.Parse("2006-01-02", iso)
if err != nil {
return iso
}
return t.AddDate(0, 0, n).Format("2006-01-02")
}
func daysSpan(since, until string) int {
a, err1 := time.Parse("2006-01-02", since)
b, err2 := time.Parse("2006-01-02", until)
if err1 != nil || err2 != nil || b.Before(a) {
return 1
}
return int(b.Sub(a).Hours()/24) + 1
}
func esc(s string) string { return template.HTMLEscapeString(s) }
// activityHandler serves /r/{repo}/activity.
func activityHandler(r *ops.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
q := req.URL.Query()
axis := q.Get("axis")
if axis == "" {
axis = "clock"
}
if axis != "clock" && axis != "tree" {
fail(r, w, req, fmt.Errorf("axis must be clock or tree: %w", ops.ErrInvalid))
return
}
v := activityView{Axis: axis, Branch: q.Get("branch"), User: q.Get("user")}
since, until := q.Get("since"), q.Get("until")
var act activityOut
fetch := func() error {
out, err := invoke(req.Context(), r, "repo.activity", url.Values{
"repo": {repo}, "branch": {v.Branch}, "user": {v.User},
"since": {since}, "until": {until},
})
if err != nil {
return err
}
act = activityOut{}
remarshal(out, &act)
return nil
}
if err := fetch(); err != nil {
fail(r, w, req, err)
return
}
// The tree axis is one dot per authored file; on a large
// window that is more bytes than a page should be. Halve the
// window until it fits, at most a few times, and say so.
var sample string
for {
sample = coreSampleSVG(repo, axis, act)
if axis != "tree" || len(sample) <= activityByteBudget || v.Halved >= 4 {
break
}
span := daysSpan(act.Since, act.Until)
if span < 2 {
break
}
since, until = dayAfter(act.Since, span/2), act.Until
if err := fetch(); err != nil {
fail(r, w, req, err)
return
}
v.Halved++
}
v.Sample = template.HTML(sample)
v.Since, v.Until, v.Window = act.Since, act.Until, act.Window
v.Rows = len(act.Rows)
for _, t := range act.Touches {
if t.Inherited == 0 {
v.Touches++
}
}
v.Legend = activityLegendOf(repo, axis, v.Branch, v.User, act.Authors)
v.SummaryLabel, v.Summary = activitySummaryOf(axis, act)
if out, err := invoke(req.Context(), r, "repo.activity.days",
url.Values{"repo": {repo}, "branch": {v.Branch}}); err == nil {
var days activityDaysOut
remarshal(out, &days)
svg, years := coreSliceSVG(days, yearSpans(days), false)
v.Slice, v.Years = template.HTML(svg), years
}
if out, err := invoke(req.Context(), r, "branch.list",
url.Values{"repo": {repo}, "limit": {"40"}, "open": {"1"}}); err == nil {
var bl struct {
Branches []branchRow `json:"branches"`
}
remarshal(out, &bl)
for _, b := range bl.Branches {
v.Branches = append(v.Branches, b.Name)
}
}
render(r, w, req, activityPage, pageData{
Title: "Activity", Repo: repo, Activity: &v,
})
}
}
func activityLegendOf(repo, axis, branch, user string, authors []activityAuthor) []activityLegend {
n := len(authors)
if n > 24 {
n = 24
}
out := make([]activityLegend, 0, n)
for i, a := range authors[:n] {
out = append(out, activityLegend{
Index: i, Name: a.Name, Checkins: a.Checkins, Files: a.Files,
Colour: template.CSS(activityColour(a)),
URL: activityURL(repo, axis, branch, a.Name),
Current: a.Name == user,
})
}
return out
}
// activitySummaryOf is the right-hand marginal as numbers: per hour on
// the clock axis, the ten most-touched paths on the tree axis.
func activitySummaryOf(axis string, act activityOut) (string, []activitySummary) {
if axis == "clock" {
var hours [24]int
for _, r := range act.Rows {
if r.Hour >= 0 && r.Hour < 24 {
hours[r.Hour]++
}
}
out := make([]activitySummary, 0, 24)
for h, n := range hours {
out = append(out, activitySummary{Label: fmt.Sprintf("%02d:00", h), N: n})
}
return "check-ins by hour (UTC)", out
}
counts := make(map[int]int)
for _, t := range act.Touches {
if t.Inherited == 0 {
counts[t.Path]++
}
}
idx := make([]int, 0, len(counts))
for p := range counts {
idx = append(idx, p)
}
sort.Slice(idx, func(i, j int) bool {
if counts[idx[i]] != counts[idx[j]] {
return counts[idx[i]] > counts[idx[j]]
}
return idx[i] < idx[j]
})
if len(idx) > 10 {
idx = idx[:10]
}
out := make([]activitySummary, 0, len(idx))
for _, p := range idx {
if p >= 0 && p < len(act.Paths) {
out = append(out, activitySummary{Label: act.Paths[p], N: counts[p]})
}
}
return "most-changed files", out
}
// Layout of the core sample, in viewBox units.
const (
sampleW = 960
sampleLeft = 60 // axis labels
sampleRight = 70 // the per-hour / per-path marginal
sampleTop = 48 // the per-day marginal
sampleBottom = 22 // date labels
sampleHour = 12 // one hour's height on the clock axis
)
// coreSampleSVG draws the window. Dots are grouped by author so the
// colour is set once per person, not once per dot: the group carries
// --c and the stylesheet does the rest. On the clock axis a dot is a
// link to its check-in; on the tree axis a dot is a (check-in, file)
// pair, and inherited touches are not drawn (§3) — the merge is on the
// clock axis, where it happened.
func coreSampleSVG(repo, axis string, act activityOut) string {
span := daysSpan(act.Since, act.Until)
pw := float64(sampleW - sampleLeft - sampleRight)
dx := pw / float64(span)
x := func(day int) float64 { return sampleLeft + (float64(day)+0.5)*dx }
var ph int
rowH := float64(sampleHour)
if axis == "clock" {
ph = 24 * sampleHour
} else {
switch n := len(act.Paths); {
case n <= 40:
rowH = 12
case n <= 200:
rowH = 6
default:
rowH = 3
}
ph = int(rowH * float64(len(act.Paths)))
if ph < sampleHour {
ph = sampleHour
}
}
h := sampleTop + ph + sampleBottom
var b strings.Builder
class := "act act-" + axis
if rowH < 6 {
class += " act-dense"
}
// On the clock axis every dot is a link, and an image may not hold
// focusable things; that chart is a group with a name.
role := "img"
if axis == "clock" {
role = "group"
}
fmt.Fprintf(&b, `<svg class="%s" viewBox="0 0 %d %d" role="%s" aria-label="%s" data-axis="%s" data-rows="%d" data-since="%s" data-until="%s" data-window="%s">`,
class, sampleW, h, role, esc(activitySentence(act)), axis, len(act.Rows), esc(act.Since), esc(act.Until), esc(act.Window))
fmt.Fprintf(&b, `<title>%s</title>`, esc(activitySentence(act)))
// Per-day counts for the top marginal.
perDay := make([]int, span)
if axis == "clock" {
for _, r := range act.Rows {
if r.Day >= 0 && r.Day < span {
perDay[r.Day]++
}
}
} else {
for _, t := range act.Touches {
if t.Inherited == 0 && t.Day >= 0 && t.Day < span {
perDay[t.Day]++
}
}
}
maxDay := 0
for _, n := range perDay {
if n > maxDay {
maxDay = n
}
}
b.WriteString(`<g class="mx" data-marginal="days">`)
if maxDay > 0 {
scale := float64(sampleTop-10) / float64(maxDay)
bw := dx
if bw < 1 {
bw = 1
}
for d, n := range perDay {
if n == 0 {
continue
}
bh := float64(n) * scale
fmt.Fprintf(&b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>`,
sampleLeft+float64(d)*dx, float64(sampleTop-4)-bh, bw, bh)
}
}
b.WriteString(`</g>`)
// Axis labels and the right marginal.
plotTop := float64(sampleTop)
if axis == "clock" {
var perHour [24]int
for _, r := range act.Rows {
if r.Hour >= 0 && r.Hour < 24 {
perHour[r.Hour]++
}
}
maxH := 0
for _, n := range perHour {
if n > maxH {
maxH = n
}
}
b.WriteString(`<g class="ax">`)
for hr := 0; hr < 24; hr += 3 {
fmt.Fprintf(&b, `<text x="%d" y="%.1f">%02d:00</text>`,
sampleLeft-6, plotTop+float64(hr)*sampleHour+9, hr)
}
b.WriteString(`</g><g class="my" data-marginal="hours">`)
if maxH > 0 {
scale := float64(sampleRight-10) / float64(maxH)
for hr, n := range perHour {
if n == 0 {
continue
}
fmt.Fprintf(&b, `<rect x="%d" y="%.1f" width="%.1f" height="%d"/>`,
sampleW-sampleRight+4, plotTop+float64(hr)*sampleHour+1, float64(n)*scale, sampleHour-2)
}
}
b.WriteString(`</g>`)
} else {
perPath := make([]int, len(act.Paths))
for _, t := range act.Touches {
if t.Inherited == 0 && t.Path >= 0 && t.Path < len(perPath) {
perPath[t.Path]++
}
}
maxP := 0
for _, n := range perPath {
if n > maxP {
maxP = n
}
}
// Directory bands (§5): paths arrive sorted so a directory's
// files are contiguous; a run of five or more gets a label.
b.WriteString(`<g class="bands">`)
start, shade := 0, false
for i := 1; i <= len(act.Paths); i++ {
if i < len(act.Paths) && dirOf(act.Paths[i]) == dirOf(act.Paths[start]) {
continue
}
if n := i - start; n >= 5 {
y := plotTop + float64(start)*rowH
if shade {
fmt.Fprintf(&b, `<rect class="band" x="%d" y="%.1f" width="%.0f" height="%.1f"/>`,
sampleLeft, y, pw, float64(n)*rowH)
}
shade = !shade
label := dirOf(act.Paths[start])
if label == "" {
label = "/"
}
fmt.Fprintf(&b, `<text class="band-l" x="%d" y="%.1f"><title>%s</title>%s</text>`,
sampleLeft-6, y+9, esc(label), esc(bandLabel(label)))
}
start = i
}
b.WriteString(`</g><g class="my" data-marginal="paths">`)
if maxP > 0 {
scale := float64(sampleRight-10) / float64(maxP)
for p, n := range perPath {
if n == 0 {
continue
}
fmt.Fprintf(&b, `<rect x="%d" y="%.1f" width="%.1f" height="%.1f"/>`,
sampleW-sampleRight+4, plotTop+float64(p)*rowH, float64(n)*scale, rowH)
}
}
b.WriteString(`</g>`)
}
// Date labels along the bottom: the first of each month that has
// room, or just the ends on a short window.
b.WriteString(`<g class="ax ax-x">`)
yb := float64(sampleTop+ph) + 14
if since, err := time.Parse("2006-01-02", act.Since); err == nil {
// A label is ~60 units wide; pick the month step that fits.
step := 1
for _, st := range []int{1, 2, 3, 6, 12} {
step = st
if float64(span)/30.4/float64(st)*60 <= pw {
break
}
}
for d := 0; d < span; d++ {
t := since.AddDate(0, 0, d)
if t.Day() != 1 || (int(t.Month())-1)%step != 0 {
continue
}
fmt.Fprintf(&b, `<text x="%.1f" y="%.1f">%s</text>`, x(d), yb, t.Format("Jan 2006"))
}
}
b.WriteString(`</g>`)
// The dots, one group per author. The radius is an attribute, not
// only a style: engines without CSS geometry would draw nothing.
radius := "2.4"
if rowH < 6 {
radius = "1.4"
}
byAuthor := make([][]string, len(act.Authors))
if axis == "clock" {
for _, r := range act.Rows {
if r.Author < 0 || r.Author >= len(byAuthor) {
continue
}
cls := "dot"
if r.Merge != 0 {
cls = "dot merge"
}
title := fmt.Sprintf("%s %s %02d:00", act.Authors[r.Author].Name, dayAfter(act.Since, r.Day), r.Hour)
if r.Merge != 0 {
title += " · merge"
} else if r.Files == 1 {
title += " · 1 file"
} else {
title += fmt.Sprintf(" · %d files", r.Files)
}
if r.Branch != "" {
title += " · " + r.Branch
}
byAuthor[r.Author] = append(byAuthor[r.Author], fmt.Sprintf(
`<a href="/r/%s/checkin/%s"><circle class="%s" cx="%.1f" cy="%.1f" r="%s"><title>%s</title></circle></a>`,
url.PathEscape(repo), url.PathEscape(r.Hash), cls, x(r.Day), plotTop+float64(r.Hour)*sampleHour+sampleHour/2, radius, esc(title)))
}
} else {
for _, t := range act.Touches {
if t.Inherited != 0 || t.Author < 0 || t.Author >= len(byAuthor) || t.Path < 0 || t.Path >= len(act.Paths) {
continue
}
byAuthor[t.Author] = append(byAuthor[t.Author], fmt.Sprintf(
`<circle class="dot" cx="%.1f" cy="%.1f" r="%s"><title>%s %s %s</title></circle>`,
x(t.Day), plotTop+(float64(t.Path)+0.5)*rowH, radius,
esc(act.Authors[t.Author].Name), dayAfter(act.Since, t.Day), esc(act.Paths[t.Path])))
}
}
for i, dots := range byAuthor {
if len(dots) == 0 {
continue
}
fmt.Fprintf(&b, `<g class="au" data-author="%d" style="--c:%s">`, i, activityColour(act.Authors[i]))
for _, d := range dots {
b.WriteString(d)
}
b.WriteString(`</g>`)
}
b.WriteString(`</svg>`)
return b.String()
}
// bandLabel keeps the tail of a directory: the margin fits about
// fourteen characters and the head is what the bands above share.
func bandLabel(dir string) string {
const room = 14
if len(dir) <= room {
return dir
}
for i := strings.IndexByte(dir, '/'); i >= 0; i = strings.IndexByte(dir, '/') {
dir = dir[i+1:]
if len(dir)+2 <= room {
return "…/" + dir
}
}
return "…" + dir[len(dir)-room+1:]
}
func dirOf(path string) string {
if i := strings.LastIndexByte(path, '/'); i >= 0 {
return path[:i]
}
return ""
}
// activitySentence is the chart's aria-label: the numbers a sighted
// reader takes from the shape, in one sentence (§10).
func activitySentence(act activityOut) string {
lead := ""
if len(act.Authors) > 0 && len(act.Rows) > 0 {
a := act.Authors[0]
lead = fmt.Sprintf("; %s led with %d%%", a.Name, a.Checkins*100/len(act.Rows))
}
return fmt.Sprintf("%d check-ins by %d people, %s to %s%s",
len(act.Rows), len(act.Authors), act.Since, act.Until, lead)
}
// The slice: one row per span (a year, or the home page's last year),
// 13 units per day, a second-author stripe at 30% share, release
// notches, an ownership bar and three numbers (§7).
const (
sliceCell = 13
sliceRowH = 22
sliceGap = 8
sliceLabelW = 46
sliceBarW = 150
sliceNumW = 70
)
type sliceSpan struct {
Label string
From string // ISO, inclusive
Days int
}
// yearSpans is every calendar year from the first check-in to the
// last, newest first — the slice reads like a core, top is now.
func yearSpans(d activityDaysOut) []sliceSpan {
first, err1 := time.Parse("2006-01-02", d.First)
last, err2 := time.Parse("2006-01-02", d.Last)
if err1 != nil || err2 != nil || last.Before(first) {
return nil
}
var out []sliceSpan
for y := last.Year(); y >= first.Year(); y-- {
days := 365
if y%4 == 0 && (y%100 != 0 || y%400 == 0) {
days = 366
}
out = append(out, sliceSpan{Label: fmt.Sprint(y), From: fmt.Sprintf("%d-01-01", y), Days: days})
}
return out
}
// lastYearSpan is the home page's strip: the 365 days ending at the
// last check-in.
func lastYearSpan(d activityDaysOut) []sliceSpan {
last, err := time.Parse("2006-01-02", d.Last)
if err != nil {
return nil
}
return []sliceSpan{{Label: "", From: last.AddDate(0, 0, -364).Format("2006-01-02"), Days: 365}}
}
// coreSliceSVG draws the slice. With fit false the picture is its
// natural size — 13px cells, scrolling in its box (§7); with fit true
// it stretches to its container, for the home page's strip.
func coreSliceSVG(d activityDaysOut, spans []sliceSpan, fit bool) (string, []activityYear) {
if len(spans) == 0 {
return "", nil
}
var years []activityYear
maxDays := 0
for _, s := range spans {
if s.Days > maxDays {
maxDays = s.Days
}
}
cellsX := float64(sliceLabelW)
if fit {
cellsX = 0
}
barX := cellsX + float64(maxDays*sliceCell) + 16
numX := barX + sliceBarW + 12
w := int(numX) + 3*sliceNumW
h := len(spans)*(sliceRowH+sliceGap) + 6
if fit {
// The strip is cells only, stretched to its box.
w, h = maxDays*sliceCell, sliceRowH
}
colour := func(i int) string {
if i < 0 || i >= len(d.Authors) {
return "var(--muted)"
}
return activityColour(d.Authors[i])
}
name := func(i int) string {
if i < 0 || i >= len(d.Authors) {
return "?"
}
return d.Authors[i].Name
}
var b strings.Builder
active := 0
for _, s := range spans {
for i := 0; i < s.Days; i++ {
if _, ok := d.Days[dayAfter(s.From, i)]; ok {
active++
}
}
}
plural := func(n int, one, many string) string {
if n == 1 {
return one
}
return many
}
label := fmt.Sprintf("%d active %s across %d %s, %s to %s", active, plural(active, "day", "days"),
len(spans), plural(len(spans), "year", "years"), d.First, d.Last)
size := ` preserveAspectRatio="none"`
if !fit {
size = fmt.Sprintf(` width="%d" height="%d"`, w, h)
}
fmt.Fprintf(&b, `<svg class="slice"%s viewBox="0 0 %d %d" role="img" aria-label="%s" data-days="%d" data-spans="%d">`,
size, w, h, esc(label), active, len(spans))
fmt.Fprintf(&b, `<title>%s</title>`, esc(label))
for si, s := range spans {
y := float64(si*(sliceRowH+sliceGap)) + 6
if fit {
y = 0
}
// Group cells by leading author so the colour is set once.
cells := map[int][]string{}
stripes := map[int][]string{}
var notches []string
led := map[int]int{}
checkins, merges, days := 0, 0, 0
for i := 0; i < s.Days; i++ {
date := dayAfter(s.From, i)
day, ok := d.Days[date]
cx := cellsX + float64(i*sliceCell)
if tag, rel := d.Releases[date]; rel {
ny := y - 4
if fit {
ny = y
}
notches = append(notches, fmt.Sprintf(`<rect class="rel" x="%.0f" y="%.0f" width="%d" height="3"><title>%s %s</title></rect>`,
cx, ny, sliceCell, esc(date), esc(tag)))
}
if !ok || day.N == 0 {
continue
}
checkins += day.N
merges += day.Merges
days++
top := -1
if len(day.Top) > 0 && len(day.Top[0]) == 2 {
top = day.Top[0][0]
}
led[top]++
title := fmt.Sprintf("%s · %d check-in", date, day.N)
if day.N != 1 {
title += "s"
}
if day.Merges > 0 {
title += fmt.Sprintf(" (%d merge", day.Merges)
if day.Merges != 1 {
title += "s"
}
title += ")"
}
for k, t := range day.Top {
if len(t) != 2 {
continue
}
if k == 0 {
title += " · "
} else {
title += ", "
}
title += fmt.Sprintf("%s %d", name(t[0]), t[1])
}
if day.Path >= 0 && day.Path < len(d.Paths) {
p := d.Paths[day.Path]
if p == "" {
p = "/"
}
title += " · " + p
}
cells[top] = append(cells[top], fmt.Sprintf(`<rect class="c" x="%.0f" y="%.0f" width="%d" height="%d" data-date="%s"><title>%s</title></rect>`,
cx, y, sliceCell, sliceRowH, esc(date), esc(title)))
if len(day.Top) > 1 && len(day.Top[1]) == 2 && day.Top[1][1]*10 >= day.N*3 {
stripes[day.Top[1][0]] = append(stripes[day.Top[1][0]], fmt.Sprintf(`<rect class="s" x="%.0f" y="%.0f" width="%d" height="6"/>`,
cx, y+float64(sliceRowH-6), sliceCell))
}
}
fmt.Fprintf(&b, `<g class="row" data-span="%s" data-checkins="%d" data-days="%d" data-merges="%d">`, esc(s.Label), checkins, days, merges)
years = append(years, activityYear{Label: s.Label, Checkins: checkins, Days: days, Merges: merges})
if s.Label != "" {
fmt.Fprintf(&b, `<text class="yr" x="%d" y="%.0f">%s</text>`, sliceLabelW-8, y+15, esc(s.Label))
}
for _, i := range authorKeys(cells) {
fmt.Fprintf(&b, `<g class="au" data-author="%d" style="--c:%s">%s</g>`, i, colour(i), strings.Join(cells[i], ""))
}
for _, i := range authorKeys(stripes) {
fmt.Fprintf(&b, `<g class="au" data-author="%d" style="--c:%s">%s</g>`, i, colour(i), strings.Join(stripes[i], ""))
}
b.WriteString(strings.Join(notches, ""))
// The ownership bar: who led how many of the year's days.
if days > 0 && !fit {
fmt.Fprintf(&b, `<g class="own" data-led="%d">`, len(led))
x := barX
for _, i := range sortedByCount(led) {
bw := float64(sliceBarW) * float64(led[i]) / float64(days)
fmt.Fprintf(&b, `<rect class="au" data-author="%d" style="--c:%s" x="%.1f" y="%.0f" width="%.1f" height="%d"><title>%s led %d of %d days</title></rect>`,
i, colour(i), x, y, bw, sliceRowH, esc(name(i)), led[i], days)
x += bw
}
b.WriteString(`</g>`)
}
if !fit {
fmt.Fprintf(&b, `<text class="n" x="%.0f" y="%.0f">%d</text><text class="n" x="%.0f" y="%.0f">%d</text><text class="n" x="%.0f" y="%.0f">%d</text>`,
numX+sliceNumW-6, y+15, checkins,
numX+2*sliceNumW-6, y+15, days,
numX+3*sliceNumW-6, y+15, merges)
}
b.WriteString(`</g>`)
}
b.WriteString(`</svg>`)
return b.String(), years
}
func authorKeys(m map[int][]string) []int {
out := make([]int, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Ints(out)
return out
}
func sortedByCount(m map[int]int) []int {
out := make([]int, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool {
if m[out[i]] != m[out[j]] {
return m[out[i]] > m[out[j]]
}
return out[i] < out[j]
})
return out
}
// homeSlice is the repository home's door: the last year as one row.
func homeSlice(r *ops.Registry, req *http.Request, repo string) template.HTML {
out, err := invoke(req.Context(), r, "repo.activity.days", url.Values{"repo": {repo}})
if err != nil {
return ""
}
var days activityDaysOut
remarshal(out, &days)
svg, _ := coreSliceSVG(days, lastYearSpan(days), true)
return template.HTML(svg)
}
var activityPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{with .Activity}}
{{- /* Two controls, both links (§7): which axis, and which branch.
There is no person control — the legend is the door, and a
chosen person is shown as the current legend entry. */ -}}
<div class="act-controls">
<nav class="seg-box" aria-label="axis">
<a href="{{activityURL $.Repo "clock" .Branch .User}}"{{if eq .Axis "clock"}} class="here" aria-current="true"{{end}}>clock</a>
<a href="{{activityURL $.Repo "tree" .Branch .User}}"{{if eq .Axis "tree"}} class="here" aria-current="true"{{end}}>tree</a>
</nav>
{{if .Branches}}<details class="act-branches"><summary>branch: {{if .Branch}}{{.Branch}}{{else}}all{{end}}</summary>
<ul><li><a href="{{activityURL $.Repo .Axis "" .User}}"{{if not .Branch}} aria-current="true"{{end}}>all branches</a></li>
{{range .Branches}}<li><a href="{{activityURL $.Repo $.Activity.Axis . $.Activity.User}}"{{if eq . $.Activity.Branch}} aria-current="true"{{end}}>{{.}}</a></li>{{end}}</ul></details>{{end}}
{{if .User}}<a class="act-clear" href="{{activityURL $.Repo .Axis .Branch ""}}">everyone</a>{{end}}
</div>
<p class="act-window" data-window="{{.Window}}" data-halved="{{.Halved}}">{{.Since}} to {{.Until}} · {{count .Rows}} {{plural .Rows "check-in" "check-ins"}}{{if eq .Axis "tree"}} · {{count .Touches}} {{plural .Touches "file change" "file changes"}}{{end}}{{if .Halved}} · the window was shortened to keep this page small; the API has the rest{{end}}</p>
<div class="act-sample">{{.Sample}}</div>
{{if .Legend}}<ol class="act-legend">
{{range .Legend}}<li><a href="{{.URL}}" data-author="{{.Index}}"{{if .Current}} aria-current="true"{{end}}><span class="sw" style="--c:{{.Colour}}"></span> {{.Name}} <span class="act-n" title="check-ins">{{count .Checkins}}</span></a></li>
{{end}}</ol>{{end}}
{{if .Summary}}<table class="sr-only" data-summary="{{.Axis}}"><caption>{{.SummaryLabel}}</caption>
{{range .Summary}}<tr><th scope="row">{{.Label}}</th><td>{{.N}}</td></tr>{{end}}</table>{{end}}
{{if .Slice}}<h2 class="act-h">Every year</h2>
<div class="act-slice">{{.Slice}}</div>
<p class="act-key">per year: check-ins · active days · merges</p>
<table class="sr-only" data-summary="years"><caption>per year</caption><tr><th>year</th><th>check-ins</th><th>active days</th><th>merges</th></tr>
{{range .Years}}<tr><th scope="row">{{.Label}}</th><td>{{.Checkins}}</td><td>{{.Days}}</td><td>{{.Merges}}</td></tr>{{end}}</table>{{end}}
{{end}}
{{end}}`))