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"
"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
}
// clockSampleSVG draws the clock axis. 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 {
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]
})
px := func(day float64) float64 { return stripsLeft + stripsPlotW*day/float64(span) }
place := func(k [2]int) (x, y float64) {
x0, x1 := sb.bounds(k[0])
return px((x0 + x1) / 2), clockTop + float64(k[1])*clockRowH + clockRowH/2
}
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]
x, y := place(k)
m := clockMark{b: k[0], h: k[1], files: c.files, checkins: c.checkins, x: x, y: y, 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)
}
kScale, p95 := markScale(files, clockR95)
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 {
x, y := place(k)
mine = append(mine, clockMark{b: k[0], h: k[1], files: c.files, checkins: c.checkins, x: x, y: y, 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 })
marks := append(all, mine...)
for i := range marks {
marks[i].r, marks[i].capped = markRadius(marks[i].files, kScale, clockCap)
}
var b strings.Builder
bot := float64(clockTop + 24*clockRowH)
band := bot + 26
h := int(band) + stripsNoteBand + clockKeyH + 12
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))
// 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.
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(act.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>`)
// 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) {
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))
}
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},
})
clockKey(&b, band+stripsNoteBand+8, kScale, p95)
b.WriteString(`</svg>`)
return b.String()
}
// 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>`)
}