package web
// The clock axis of the activity page: time across in buckets, the hour
// of day (UTC, 00 at the top) down in 24 rows. A mark is one hour of one
// bucket, a circle whose area is the files changed by the check-ins in
// it; it is in the colour of one of the five busiest people when that
// person made more than half of it. Every figure is folded here from the
// rows the activity op already returns, and the picture is bounded by
// buckets x 24, so nothing is coarsened to fit a byte budget.
import (
"fmt"
"html/template"
"math"
"net/url"
"sort"
"strings"
)
const (
clockRowH = 14 // one hour
clockTop = 70 // the check-ins-per-bucket curve, and a little breath
clockCurveH = 34 // the tallest point of either curve
// clockR95 is the radius the 95th percentile of this repository's
// marks is drawn at, and clockCap the largest radius any mark takes:
// past it a mark is hollow with a second ring, so it stays readable
// against its neighbours in the rows above and below it.
clockR95 = 0.4 * clockRowH
clockCap = 0.75 * clockRowH
clockKeyH = 96 // the key under the picture
)
// clockCell is one hour of one bucket: what was changed in it and how
// many check-ins did it, in all and by each person.
type clockCell struct {
files, checkins int
af, ac map[int]int
}
// clockMark is a circle to draw.
type clockMark struct {
b, h, files, checkins int
x, y, r float64
capped bool
rank int // the rank of the person it is coloured for, -1 for an outline
mine bool
tip string
}
// clockFold is the clock axis folded from the rows, before anything is
// drawn: the same marks serve the wide picture and the tall one.
type clockFold struct {
act activityOut
user int
span int
sb stripBuckets
rankOf map[int]int
all []clockMark // everyone's, largest first
marks []clockMark // everyone's, then the chosen person's own
unit float64 // the radius constant for a 95th-percentile radius of 1
p95 int
perBucket []int
perHour [24]int
quiet int
}
// clockSampleSVG draws the clock axis wide: time across, the hour down.
// user is the person singled out, or -1: their own work is drawn as marks
// of its own on top of everyone's, which go grey.
func clockSampleSVG(repo string, act activityOut, user int) string {
return foldClock(act, user).wide(repo)
}
// clockSample is the axis as the page uses it: the wide picture and the
// tall one, of which the stylesheet shows one, and the drawn key.
func clockSample(repo string, act activityOut, user int) (sample, key template.HTML) {
f := foldClock(act, user)
return template.HTML(`<div class="act-orient" data-orient="wide">` + f.wide(repo) + `</div>` +
`<div class="act-orient" data-orient="tall">` + f.tall(repo) + `</div>`), template.HTML(f.key())
}
func foldClock(act activityOut, user int) *clockFold {
span := daysSpan(act.Since, act.Until)
sb := newStripBuckets(act.Since, span)
nb := sb.count()
_, rankOf := activityRanks(act, span)
cells := map[[2]int]*clockCell{}
own := map[[2]int]*clockCell{}
perBucket := make([]int, nb)
var perHour [24]int
for _, r := range act.Rows {
if r.Author < 0 || r.Author >= len(act.Authors) || r.Day < 0 || r.Day >= span || r.Hour < 0 || r.Hour > 23 {
continue
}
b := sb.index(r.Day)
perBucket[b]++
perHour[r.Hour]++
k := [2]int{b, r.Hour}
c := cells[k]
if c == nil {
c = &clockCell{af: map[int]int{}, ac: map[int]int{}}
cells[k] = c
}
c.files += r.Files
c.checkins++
c.af[r.Author] += r.Files
c.ac[r.Author]++
if r.Author == user {
o := own[k]
if o == nil {
o = &clockCell{}
own[k] = o
}
o.files += r.Files
o.checkins++
}
}
quiet := 0
for h := range perHour {
if perHour[h] < perHour[quiet] {
quiet = h
}
}
name := func(rank int) string {
for a, r := range rankOf {
if r == rank {
return act.Authors[a].Name
}
}
return ""
}
// The five who are in colour: the one a mark is mostly the work of.
majority := func(c *clockCell) int {
total, by := c.files, c.af
if total == 0 {
total, by = c.checkins, c.ac
}
for a, n := range by {
if rank, ok := rankOf[a]; ok && 2*n > total {
return rank
}
}
return -1
}
keys := make([][2]int, 0, len(cells))
for k := range cells {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i][0] != keys[j][0] {
return keys[i][0] < keys[j][0]
}
return keys[i][1] < keys[j][1]
})
hh := func(h int) string { return fmt.Sprintf("%02d:00", h) }
var all []clockMark
files := make([]int, 0, len(keys))
for _, k := range keys {
c := cells[k]
m := clockMark{b: k[0], h: k[1], files: c.files, checkins: c.checkins, rank: majority(c)}
m.tip = fmt.Sprintf("%s · %s UTC · %s in %s", sb.label(k[0]), hh(k[1]), peopleCount(c.files, "file"), peopleCount(c.checkins, "check-in"))
if m.rank >= 0 {
m.tip += " · mostly " + name(m.rank)
}
all = append(all, m)
files = append(files, c.files)
}
unit, p95 := markScale(files, 1)
sort.SliceStable(all, func(i, j int) bool { return all[i].files > all[j].files })
// The chosen person's own work, drawn over everyone's.
var mine []clockMark
myRank := -1
if r, ok := rankOf[user]; ok {
myRank = r
}
for _, k := range keys {
if c := own[k]; c != nil {
mine = append(mine, clockMark{b: k[0], h: k[1], files: c.files, checkins: c.checkins, rank: myRank, mine: true,
tip: fmt.Sprintf("%s · %s · %s UTC · %s in %s", act.Authors[user].Name, sb.label(k[0]), hh(k[1]),
peopleCount(c.files, "file"), peopleCount(c.checkins, "check-in"))})
}
}
sort.SliceStable(mine, func(i, j int) bool { return mine[i].files > mine[j].files })
return &clockFold{act: act, user: user, span: span, sb: sb, rankOf: rankOf, all: all, marks: append(all[:len(all):len(all)], mine...),
unit: unit, p95: p95, perBucket: perBucket, perHour: perHour, quiet: quiet}
}
// place sets every mark's centre and radius for a layout: where a bucket
// and an hour fall, the radius a 95th-percentile mark takes, and the cap.
func (f *clockFold) place(at func(b, h int) (x, y float64), r95, capR float64) (marks, all []clockMark) {
marks = make([]clockMark, len(f.marks))
copy(marks, f.marks)
k := r95 * f.unit
for i := range marks {
m := &marks[i]
m.x, m.y = at(m.b, m.h)
m.r, m.capped = markRadius(m.files, k, capR)
}
return marks, marks[:len(f.all)]
}
// wide is the clock picture with time across and the hour down.
func (f *clockFold) wide(repo string) string {
act, user, span, sb := f.act, f.user, f.span, f.sb
nb, perBucket, perHour, quiet := sb.count(), f.perBucket, f.perHour, f.quiet
px := func(day float64) float64 { return stripsLeft + stripsPlotW*day/float64(span) }
marks, all := f.place(func(b, h int) (float64, float64) {
x0, x1 := sb.bounds(b)
return px((x0 + x1) / 2), clockTop + float64(h)*clockRowH + clockRowH/2
}, clockR95, clockCap)
hh := func(h int) string { return fmt.Sprintf("%02d:00", h) }
var b strings.Builder
bot := float64(clockTop + 24*clockRowH)
band := bot + 26
h := int(band) + stripsNoteBand
label := esc(activitySentence(act, user))
fmt.Fprintf(&b, `<svg class="act act-clock" viewBox="0 0 %d %d" role="group" aria-label="%s" data-axis="clock" data-rows="%d" data-grain="%s" data-marks="%d"%s data-since="%s" data-until="%s" data-window="%s">`,
stripsW, h, label, len(act.Rows), sb.grain, len(marks), userAttr(user), esc(act.Since), esc(act.Until), esc(act.Window))
fmt.Fprintf(&b, `<title>%s</title>`, label)
// The hours down the left, a rule every six, and the months along
// the bottom.
b.WriteString(`<g class="hours">`)
for hr := 0; hr <= 24; hr += 6 {
fmt.Fprintf(&b, `<line class="h2" x1="%d" y1="%.1f" x2="%d" y2="%.1f"/>`, stripsLeft, clockTop+float64(hr)*clockRowH, stripsRight, clockTop+float64(hr)*clockRowH)
}
for hr := 0; hr < 24; hr += 3 {
fmt.Fprintf(&b, `<text class="tk" x="%d" y="%.1f" text-anchor="end">%s</text>`, stripsLeft-12, clockTop+float64(hr)*clockRowH+clockRowH/2+3, hh(hr))
}
fmt.Fprintf(&b, `<text class="tk" x="%d" y="%d" text-anchor="end">UTC</text>`, stripsLeft-12, clockTop-8)
b.WriteString(`</g><g class="months">`)
for _, mt := range monthTicks(act.Since, span) {
x := px(mt.day)
up := 4.0
if mt.major {
up = 9
}
fmt.Fprintf(&b, `<line class="ax" x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/>`, x, bot, x, bot+up)
if mt.major {
fmt.Fprintf(&b, `<line class="h" x1="%.1f" y1="%d" x2="%.1f" y2="%.1f"/>`, x, clockTop, x, bot)
fmt.Fprintf(&b, `<text class="tk" x="%.1f" y="%.1f">%s</text>`, x+3, bot+19, mt.label)
}
}
b.WriteString(`</g>`)
// The one reference line: the repository's quietest hour.
qy := clockTop + float64(quiet)*clockRowH + clockRowH/2
fmt.Fprintf(&b, `<line class="dt" data-quiet="%d" x1="%d" y1="%.1f" x2="%d" y2="%.1f"/>`, quiet, stripsLeft, qy, stripsRight+80, qy)
fmt.Fprintf(&b, `<text class="pk" x="%d" y="%.1f">quietest %s</text>`, stripsRight+86, qy+3.5, hh(quiet))
clockMarksSVG(&b, repo, act.Since, sb, marks)
// The margins: check-ins per bucket along the top, and the
// project's day, check-ins by hour, down the right edge.
curve := func(kind, text string, pts [][2]float64, tx, ty float64) { curveGroup(&b, kind, text, pts, tx, ty) }
top := make([][2]float64, nb)
peak := max(maxOf(perBucket), 1)
for i := range top {
x0, x1 := sb.bounds(i)
top[i] = [2]float64{px((x0 + x1) / 2), clockTop - 12 - float64(perBucket[i])/float64(peak)*clockCurveH}
}
fmt.Fprintf(&b, `<line class="h2" x1="%d" y1="%d" x2="%d" y2="%d"/>`, stripsLeft, clockTop-12, stripsRight, clockTop-12)
curve("buckets", fmt.Sprintf("check-ins per bucket, peak %s", commas(peak)), top, stripsLeft, clockTop-52)
side := make([][2]float64, 24)
hpeak := max(maxOf(perHour[:]), 1)
for hr := range side {
side[hr] = [2]float64{stripsRight + 12 + float64(perHour[hr])/float64(hpeak)*62, clockTop + float64(hr)*clockRowH + clockRowH/2}
}
fmt.Fprintf(&b, `<line class="h2" x1="%d" y1="%d" x2="%d" y2="%.1f"/>`, stripsRight+11, clockTop, stripsRight+11, bot)
curve("hours", "check-ins by hour", side, stripsRight+12, clockTop-8)
// Labels: the largest marks, one to a stretch of time, each placed
// where it covers no mark, in the band below the plot when it can.
var notes []stripNote
for _, m := range all {
if len(notes) == 3 {
break
}
near := false
for _, n := range notes {
near = near || math.Abs(n.x-m.x) < 3*stripsPlotW/float64(nb)
}
if near {
continue
}
notes = append(notes, stripNote{x: m.x, y: m.y, r: m.r, text: fmt.Sprintf("%s, %s UTC: %s", sb.label(m.b), hh(m.h), peopleCount(m.files, "file"))})
}
obst := make([]stripNote, len(marks))
for i, m := range marks {
obst[i] = stripNote{x: m.x, y: m.y, r: m.r}
if m.capped {
obst[i].r += stripsRing
}
}
placeStripNotes(&b, notes, obst, []stripRegion{
{stripsLeft + 2, stripsRight - 2, clockTop + 2, bot - 2},
{stripsLeft, stripsRight, band, band + stripsNoteBand},
})
b.WriteString(`</svg>`)
return b.String()
}
// key is the drawn key of the clock, wide and narrow.
func (f *clockFold) key() string {
k := clockR95 * f.unit
return keyPair("clock", clockKeyH+12, func(b *strings.Builder, y float64) { clockKey(b, y, k, f.p95) }, k, f.p95, clockCap, []keyEntry{
{"kd c", []string{"mostly one of the", "five busiest, by colour"}},
{"kd nr", []string{"anyone else, or no one", "in the majority"}},
{"ring", []string{"off the scale: hollow,", "a second ring"}},
{"dt", []string{"the repository's", "quietest hour"}},
})
}
// clockMarksSVG writes the marks, largest first so the small ones sit on
// top, each a door to the timeline at the end of its bucket: the timeline
// takes a date as c= and has no range of its own.
func clockMarksSVG(b *strings.Builder, repo, since string, sb stripBuckets, marks []clockMark) {
b.WriteString(`<g class="marks">`)
for _, m := range marks {
cls := "dot nr"
extra := ""
if m.rank >= 0 || m.mine {
cls = "dot c"
extra = fmt.Sprintf(` data-c="%d"`, max(m.rank, 0))
if m.rank >= 0 {
extra += fmt.Sprintf(` data-rank="%d"`, m.rank)
}
}
if m.capped {
cls += " hol"
}
if m.mine {
cls += " mine"
}
_, to := sb.bounds(m.b)
href := "/r/" + url.PathEscape(repo) + "/timeline?c=" + dayAfter(since, int(to)-1)
fmt.Fprintf(b, `<a href="%s"><circle class="%s" data-b="%d" data-h="%d" data-files="%d" data-checkins="%d" cx="%.1f" cy="%.1f" r="%.1f"%s><title>%s</title></circle></a>`,
href, cls, m.b, m.h, m.files, m.checkins, m.x, m.y, m.r, extra, esc(m.tip))
if m.capped {
ring := ""
if m.rank >= 0 || m.mine {
ring = fmt.Sprintf(` data-c="%d"`, max(m.rank, 0))
}
fmt.Fprintf(b, `<circle class="cap" cx="%.1f" cy="%.1f" r="%.1f"%s/>`, m.x, m.y, m.r+stripsRing, ring)
}
}
b.WriteString(`</g>`)
}
// curvePath is a margin's curve: a line through one point per value.
func curveGroup(b *strings.Builder, kind, text string, pts [][2]float64, tx, ty float64) {
var d strings.Builder
for i, p := range pts {
op := "L"
if i == 0 {
op = "M"
}
fmt.Fprintf(&d, "%s%.1f %.1f", op, p[0], p[1])
}
fmt.Fprintf(b, `<g class="margin" data-margin="%s" data-points="%d"><path class="sp" d="%s"/><text class="pk" x="%.1f" y="%.1f">%s</text></g>`,
kind, len(pts), d.String(), tx, ty, esc(text))
}
// clockKey draws how to read the clock: the size scale and one example
// of each kind of mark.
func clockKey(b *strings.Builder, y, k float64, p95 int) {
b.WriteString(`<g class="key">`)
fmt.Fprintf(b, `<text class="kt" x="0" y="%.1f">How to read it</text>`, y)
fmt.Fprintf(b, `<line class="ax" x1="0" y1="%.1f" x2="%d" y2="%.1f"/>`, y+6, stripsRight, y+6)
sizeKey(b, y, k, p95, clockCap, 16)
c1, c2, r1 := 250.0, 500.0, y+28
glyph := func(x, gy float64, cls, text string) {
fmt.Fprintf(b, `<circle class="%s" cx="%.1f" cy="%.1f" r="4"/>`, cls, x+6, gy-3)
fmt.Fprintf(b, `<text class="pk" x="%.1f" y="%.1f">%s</text>`, x+18, gy, text)
}
glyph(c1, r1, "kd c", "mostly one of the five busiest, by colour")
glyph(c1, r1+17, "kd nr", "anyone else, or no one in the majority")
fmt.Fprintf(b, `<circle class="kd nr" cx="%.1f" cy="%.1f" r="3.6"/><circle class="cap kd" cx="%.1f" cy="%.1f" r="5.8"/>`, c2+6, r1-3, c2+6, r1-3)
fmt.Fprintf(b, `<text class="pk" x="%.1f" y="%.1f">off the scale: hollow, a second ring</text>`, c2+18, r1)
fmt.Fprintf(b, `<line class="dt kd" x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/>`, c2, r1+14, c2+13, r1+14)
fmt.Fprintf(b, `<text class="pk" x="%.1f" y="%.1f">the repository's quietest hour</text>`, c2+18, r1+17)
b.WriteString(`</g>`)
}
// The tall clock, for a phone: the 24 hours run across, which fit the
// width, and time runs down the page in the same buckets, newest at the
// top. The same marks, colours, cap rule and labels as the wide one.
const (
tallW = 400
tallLeft = 52 // the bucket labels
tallCol = 13.5 // one hour
tallRowH = 20 // one bucket
tallTop = 70 // the profile curve and the hour labels
tallR95 = 0.45 * tallCol
tallCap = 0.75 * tallCol
tallPlot = 24 * tallCol
)
func (f *clockFold) tall(repo string) string {
act, user, sb := f.act, f.user, f.sb
nb := sb.count()
rowTop := func(b int) float64 { return tallTop + float64(nb-1-b)*tallRowH }
marks, all := f.place(func(b, h int) (float64, float64) {
return tallLeft + (float64(h)+0.5)*tallCol, rowTop(b) + tallRowH/2
}, tallR95, tallCap)
hh := func(h int) string { return fmt.Sprintf("%02d:00", h) }
bot := float64(tallTop + nb*tallRowH)
band := bot + 24
h := int(band) + 3*18 + 6
var b strings.Builder
label := esc(activitySentence(act, user))
fmt.Fprintf(&b, `<svg class="act act-clock act-tall" viewBox="0 0 %d %d" role="group" aria-label="%s" data-axis="clock" data-rows="%d" data-grain="%s" data-marks="%d"%s data-since="%s" data-until="%s" data-window="%s">`,
tallW, h, label, len(act.Rows), sb.grain, len(marks), userAttr(user), esc(act.Since), esc(act.Until), esc(act.Window))
fmt.Fprintf(&b, `<title>%s</title>`, label)
// The buckets down the left, newest first, with a wash under every
// other one; the hours across the top.
b.WriteString(`<g class="hours">`)
step := (nb + 39) / 40
for i := 0; i < nb; i++ {
if i%2 == 1 {
fmt.Fprintf(&b, `<rect class="band" x="%d" y="%.1f" width="%.1f" height="%d"/>`, tallLeft, rowTop(i), tallPlot, tallRowH)
}
if (nb-1-i)%step == 0 {
fmt.Fprintf(&b, `<text class="tk tl" x="%d" y="%.1f" text-anchor="end">%s</text>`, tallLeft-6, rowTop(i)+tallRowH/2+4, esc(sb.short(i)))
}
}
for hr := 0; hr <= 24; hr += 6 {
fmt.Fprintf(&b, `<line class="h2" x1="%.1f" y1="%d" x2="%.1f" y2="%.1f"/>`, tallLeft+float64(hr)*tallCol, tallTop, tallLeft+float64(hr)*tallCol, bot)
}
for hr := 0; hr < 24; hr += 3 {
fmt.Fprintf(&b, `<text class="tk tl" x="%.1f" y="%d" text-anchor="middle">%02d</text>`, tallLeft+(float64(hr)+0.5)*tallCol, tallTop-6, hr)
}
fmt.Fprintf(&b, `<text class="tk tl" x="%d" y="%d" text-anchor="end">UTC</text>`, tallLeft-6, tallTop-6)
b.WriteString(`</g>`)
// The quietest hour, a dotted column.
qx := tallLeft + (float64(f.quiet)+0.5)*tallCol
fmt.Fprintf(&b, `<line class="dt" data-quiet="%d" x1="%.1f" y1="%d" x2="%.1f" y2="%.1f"/>`, f.quiet, qx, tallTop, qx, bot)
fmt.Fprintf(&b, `<text class="pk" x="%d" y="%.1f">quietest %s</text>`, tallLeft, bot+14, hh(f.quiet))
clockMarksSVG(&b, repo, act.Since, sb, marks)
// The margins: the project's day, check-ins by hour, along the top,
// and check-ins per bucket down the right edge.
hours := make([][2]float64, 24)
hpeak := max(maxOf(f.perHour[:]), 1)
for hr := range hours {
hours[hr] = [2]float64{tallLeft + (float64(hr)+0.5)*tallCol, tallTop - 22 - float64(f.perHour[hr])/float64(hpeak)*26}
}
curveGroup(&b, "hours", "check-ins by hour", hours, tallLeft, 12)
buckets := make([][2]float64, nb)
bpeak := max(maxOf(f.perBucket), 1)
for i := range buckets {
buckets[i] = [2]float64{tallLeft + tallPlot + 6 + float64(f.perBucket[i])/float64(bpeak)*(tallW-tallLeft-tallPlot-8), rowTop(i) + tallRowH/2}
}
curveGroup(&b, "buckets", "", buckets, 0, 0)
var notes []stripNote
for _, m := range all {
if len(notes) == 3 {
break
}
near := false
for _, n := range notes {
near = near || math.Abs(n.y-m.y) < 3*tallRowH
}
if near {
continue
}
notes = append(notes, stripNote{x: m.x, y: m.y, r: m.r, text: fmt.Sprintf("%s, %s UTC: %s", sb.label(m.b), hh(m.h), peopleCount(m.files, "file"))})
}
obst := make([]stripNote, len(marks))
for i, m := range marks {
obst[i] = stripNote{x: m.x, y: m.y, r: m.r}
if m.capped {
obst[i].r += stripsRing
}
}
placeStripNotes(&b, notes, obst, []stripRegion{{2, tallW - 24, band, band + 3*18}})
b.WriteString(`</svg>`)
return b.String()
}