package web
// The tree axis of the activity page: directory strips. A lane is a
// directory (foldColumns picks at most sixteen, opening the busiest as
// far as they fit), time runs left to right, and a mark is one person's
// work in one lane in one time bucket: a circle whose area is the files
// they changed there, placed at their usual hour of that bucket. Every
// figure is folded here from the rows and touches the clock axis already
// has — no op, no index change — and the picture is bounded by
// lanes x buckets x marks, so nothing is coarsened to fit a byte budget.
import (
"fmt"
"html/template"
"math"
"sort"
"strings"
)
const (
stripsTop = 40 // the month labels
stripH = 46 // one strip
stripsMaxLanes = 16
// A bucket draws its six largest marks and folds the rest into one.
stripsPerBucket = 6
// stripsCap is the largest radius a mark takes: past it the mark is
// drawn hollow with a second ring, so it cannot leave its strip nor
// hide what is under it.
stripsCap = 0.42 * stripH
// stripsR95 is the radius the 95th percentile of this repository's
// marks is drawn at; every other mark follows the same square-root
// scale.
stripsR95 = 0.20 * stripH
// stripsJitter is the most a mark moves sideways so that people in
// one bucket do not sit on one another.
stripsJitter = 16
// The bucket's files are weighted up to this many each when the
// strip's centre of gravity is found, so that one import does not
// drag the line to wherever it was committed.
stripsWeightCap = 400
stripsStep = 9 // between marks of one bucket, outward from the line
stripsKeyH = 88
)
// stripItem is one person's work in one lane and bucket.
type stripItem struct {
author, files int
}
// stripMark is a circle to draw.
type stripMark struct {
lane, b, author, files, people int
x, y, r float64
slot int
capped bool
tip string
}
// stripsSampleSVG draws the tree axis. user is the person singled out,
// or -1. It also returns the lanes as summary rows, files changed in
// each, for the page's hidden table.
func stripsSampleSVG(act activityOut, user int) (string, []activitySummary) {
p := stripsDraw(act, user)
return p.svg, p.lanes
}
// stripsOut is everything the strips write: the picture, the lanes as
// summary rows, the lane labels again for a phone to hold in view, the
// ranked people as a legend a phone can read, and the drawn key.
type stripsOut struct {
svg string
lanes []activitySummary
labels string
ranks string
key string
}
// stripsSample is the tree axis as the page uses it: the picture with the
// phone's lane labels beside it, the ranked legend, and the drawn key.
func stripsSample(act activityOut, user int) (sample, key, ranks template.HTML, lanes []activitySummary) {
p := stripsDraw(act, user)
return template.HTML(`<div class="act-sticky">` + p.labels + p.svg + `</div>`), template.HTML(p.key), template.HTML(p.ranks), p.lanes
}
// stripsDraw draws the tree axis.
func stripsDraw(act activityOut, user int) stripsOut {
span := daysSpan(act.Since, act.Until)
sb := newStripBuckets(act.Since, span)
names, colOf, _ := foldPlan(act, stripsMaxLanes)
if len(act.Paths) == 0 {
names = nil
}
nl := len(names)
// Authored touches, counted per lane, bucket and person.
type cell struct{ lane, b, author int }
cells := map[cell]int{}
laneFiles := make([]int, nl)
lanePeople := make([]map[int]bool, nl)
for i := range lanePeople {
lanePeople[i] = map[int]bool{}
}
for _, t := range act.Touches {
if t.Inherited != 0 || t.Author < 0 || t.Author >= len(act.Authors) ||
t.Path < 0 || t.Path >= len(colOf) || t.Day < 0 || t.Day >= span {
continue
}
l := colOf[t.Path]
cells[cell{l, sb.index(t.Day), t.Author}]++
laneFiles[l]++
lanePeople[l][t.Author] = true
}
// A lane nobody authored a file in has nothing to draw: it would be
// an empty strip, and the lanes that remain keep their order.
remap := make([]int, nl)
var kept []string
var keptFiles []int
var keptPeople []map[int]bool
for l, n := range names {
remap[l] = -1
if laneFiles[l] > 0 {
remap[l] = len(kept)
kept = append(kept, n)
keptFiles = append(keptFiles, laneFiles[l])
keptPeople = append(keptPeople, lanePeople[l])
}
}
names, laneFiles, lanePeople, nl = kept, keptFiles, keptPeople, len(kept)
keys := make([]cell, 0, len(cells))
counts := make(map[cell]int, len(cells))
for k, n := range cells {
k.lane = remap[k.lane]
keys = append(keys, k)
counts[k] = n
}
sort.Slice(keys, func(i, j int) bool {
a, b := keys[i], keys[j]
if a.lane != b.lane {
return a.lane < b.lane
}
if a.b != b.b {
return a.b < b.b
}
return a.author < b.author
})
type lb struct{ lane, b int }
lists := map[lb][]stripItem{}
var order []lb
var all []int
for _, k := range keys {
g := lb{k.lane, k.b}
if _, ok := lists[g]; !ok {
order = append(order, g)
}
n := counts[k]
lists[g] = append(lists[g], stripItem{author: k.author, files: n})
all = append(all, n)
}
kScale, p95 := markScale(all, stripsR95)
// Who is in colour: the busiest by check-ins, each a fixed colour by
// rank, and whoever is chosen, who takes the first when outside them.
by, rankOf := activityRanks(act, span)
colourOf := func(a int) (rank int, ok bool) {
if rank, ok = rankOf[a]; ok {
return rank, true
}
return 0, a == user && a >= 0
}
laneTop := func(l int) float64 { return stripsTop + float64(l)*stripH }
// The marks: six per bucket and a remainder, and who leads each lane.
// A bucket's marks sit on the strip's line, the largest on it and the
// rest stepping outward, above and below by turns; the remainder is
// last and outermost.
leads := make([]map[int]int, nl)
for i := range leads {
leads[i] = map[int]int{}
}
var marks []stripMark
for _, g := range order {
items := lists[g]
sort.SliceStable(items, func(i, j int) bool { return items[i].files > items[j].files })
x0, x1 := sb.bounds(g.b)
cx := stripsLeft + stripsPlotW*(x0+x1)/2/float64(span)
bw := stripsPlotW * (x1 - x0) / float64(span)
for _, it := range items {
leads[g.lane][it.author] += it.files
}
keep := items[:min(len(items), stripsPerBucket)]
for slot, it := range keep {
jit := math.Min(0.25*bw, stripsJitter) * float64((it.author*5)%7-3) / 3
marks = append(marks, stripMark{lane: g.lane, b: g.b, author: it.author, files: it.files, slot: slot,
x: cx + jit,
tip: fmt.Sprintf("%s · %s · %s · %s changed", act.Authors[it.author].Name,
names[g.lane], sb.label(g.b), peopleCount(it.files, "file"))})
}
if rest := items[len(keep):]; len(rest) > 0 {
rf := 0
for _, it := range rest {
rf += it.files
}
marks = append(marks, stripMark{lane: g.lane, b: g.b, author: -1, files: rf, people: len(rest), slot: len(keep),
x: cx,
tip: fmt.Sprintf("%s · %s · %s · %s changed", stripPeople(len(rest), "more "), names[g.lane],
sb.label(g.b), peopleCount(rf, "file"))})
}
}
sort.SliceStable(marks, func(i, j int) bool { return marks[i].files > marks[j].files })
for i := range marks {
m := &marks[i]
m.r, m.capped = markRadius(m.files, kScale, stripsCap)
extra := 0.7
if m.capped {
extra += stripsRing
}
// A mark never leaves its strip: its centre gives way, not its size.
top := laneTop(m.lane)
off := float64((m.slot+1)/2) * stripsStep
if m.slot%2 == 1 {
off = -off
}
m.y = math.Max(top+m.r+extra, math.Min(top+stripH/2+off, top+stripH-m.r-extra))
}
var b strings.Builder
label := esc(activitySentence(act, user))
h := stripsTop + nl*stripH + stripsNoteBand
fmt.Fprintf(&b, `<svg class="act act-strips" viewBox="0 0 %d %d" role="group" aria-label="%s" data-axis="tree" data-rows="%d" data-bucket="1" data-grain="%s" data-lanes="%d" data-marks="%d"%s data-since="%s" data-until="%s" data-window="%s">`,
stripsW, h, label, len(act.Rows), sb.grain, nl, len(marks), userAttr(user), esc(act.Since), esc(act.Until), esc(act.Window))
fmt.Fprintf(&b, `<title>%s</title>`, label)
bot := float64(stripsTop + nl*stripH)
px := func(day float64) float64 { return stripsLeft + stripsPlotW*day/float64(span) }
// Month ticks along the top: every month on a short window, January
// and July on a long one.
b.WriteString(`<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="%d"/>`, x, stripsTop-up, x, stripsTop)
if mt.major {
if nl > 0 {
fmt.Fprintf(&b, `<line class="h" x1="%.1f" y1="%d" x2="%.1f" y2="%.1f"/>`, x, stripsTop, x, bot)
}
fmt.Fprintf(&b, `<text class="tk" x="%.1f" y="%.1f">%s</text>`, x+3, float64(stripsTop)-12, mt.label)
}
}
b.WriteString(`</g>`)
// The lanes: a name, what is in it, a rule above and a tick at the
// middle of the day.
// The labels are drawn a second time into a strip of their own, which a
// phone holds in view while the plot scrolls under it.
var lab strings.Builder
b.WriteString(`<g class="lanes">`)
for l, name := range names {
top := laneTop(l)
if l%2 == 1 {
fmt.Fprintf(&b, `<rect class="band" x="0" y="%.1f" width="%d" height="%d"/>`, top, stripsRight, stripH)
}
// On a phone the plot takes the full width and each lane's name
// is a caption at the top left of its band, held in view while
// the plot scrolls, on a faint backing the marks pass under.
cap := stripClip(name, 28)
files := peopleCount(laneFiles[l], "file")
fmt.Fprintf(&lab, `<rect class="pill" x="0" y="%.1f" width="%.0f" height="14" rx="2"/>`, top+1, float64(len([]rune(cap))+len(files)+3)*5.9+10)
fmt.Fprintf(&lab, `<text class="sc" x="5" y="%.1f">%s<tspan class="ct"> · %s</tspan></text>`, top+12, esc(cap), files)
fmt.Fprintf(&b, `<g class="lane" data-lane="%d" data-dir="%s" data-files="%d" data-people="%d">`, l, esc(name), laneFiles[l], len(lanePeople[l]))
fmt.Fprintf(&b, `<line class="h2" x1="0" y1="%.1f" x2="%d" y2="%.1f"/>`, top, stripsRight, top)
fmt.Fprintf(&b, `<text class="sc" x="%d" y="%.1f" text-anchor="end">%s</text>`, stripsLeft-12, top+20, esc(stripClip(name, 22)))
fmt.Fprintf(&b, `<text class="tk" x="%d" y="%.1f" text-anchor="end">%s · %s</text>`, stripsLeft-12, top+33,
peopleCount(laneFiles[l], "file"), stripPeople(len(lanePeople[l]), ""))
fmt.Fprintf(&b, `<line class="base" x1="%d" y1="%.1f" x2="%d" y2="%.1f"/>`, stripsLeft-4, top+stripH/2, stripsRight, top+stripH/2)
b.WriteString(`</g>`)
}
if nl > 0 {
fmt.Fprintf(&b, `<line class="h2" x1="0" y1="%.1f" x2="%d" y2="%.1f"/>`, bot, stripsRight, bot)
}
b.WriteString(`</g>`)
// The marks, largest first so the small ones sit on top.
b.WriteString(`<g class="marks">`)
for _, m := range marks {
cls := "dot nr"
extra := ""
switch rank, ok := colourOf(m.author); {
case m.author < 0:
cls = "dot rest"
case ok:
cls = "dot c"
extra = fmt.Sprintf(` data-c="%d"`, rank)
if r, top := rankOf[m.author]; top {
extra += fmt.Sprintf(` data-rank="%d"`, r)
}
}
if m.capped {
cls += " hol"
}
if m.author >= 0 && m.author == user {
cls += " mine"
}
people := ""
if m.people > 0 {
people = fmt.Sprintf(` data-people="%d"`, m.people)
}
fmt.Fprintf(&b, `<circle class="%s" data-lane="%d" data-b="%d" data-author="%d" data-files="%d" cx="%.1f" cy="%.1f" r="%.1f"%s%s><title>%s</title></circle>`,
cls, m.lane, m.b, m.author, m.files, m.x, m.y, m.r, people, extra, esc(m.tip))
if m.capped {
ring := ""
if _, ok := colourOf(m.author); ok && m.author >= 0 {
r, _ := colourOf(m.author)
ring = fmt.Sprintf(` data-c="%d"`, r)
}
fmt.Fprintf(&b, `<circle class="cap" cx="%.1f" cy="%.1f" r="%.1f"%s/>`, m.x, m.y, m.r+stripsRing, ring)
}
}
b.WriteString(`</g>`)
// Who leads where: a fine curve from each of the five to every lane
// where they are first or second by files, gathering on their name.
type tie struct {
author, lane, rank int
y float64
}
var ties []tie
for l := 0; l < nl; l++ {
rk := make([]int, 0, len(leads[l]))
for a := range leads[l] {
rk = append(rk, a)
}
sort.Slice(rk, func(i, j int) bool {
if leads[l][rk[i]] != leads[l][rk[j]] {
return leads[l][rk[i]] > leads[l][rk[j]]
}
return rk[i] < rk[j]
})
for rank, a := range rk[:min(len(rk), 2)] {
if _, ok := rankOf[a]; ok {
ties = append(ties, tie{a, l, rank, laneTop(l) + stripH/2})
}
}
}
type person struct {
author, first, second int
mean float64
}
var five []person
for _, a := range by {
p := person{author: a}
n := 0
for _, t := range ties {
if t.author != a {
continue
}
if t.rank == 0 {
p.first++
} else {
p.second++
}
p.mean += t.y
n++
}
if n > 0 {
p.mean /= float64(n)
five = append(five, p)
}
}
sort.SliceStable(five, func(i, j int) bool { return five[i].mean < five[j].mean })
b.WriteString(`<g class="leaders">`)
nx := float64(stripsRight + 92)
for q, p := range five {
ny := stripsTop + (float64(q)+0.5)*float64(nl*stripH)/float64(len(five))
mine := ""
if p.author == user {
mine = " mine"
}
colour := rankOf[p.author]
for _, t := range ties {
if t.author != p.author {
continue
}
fmt.Fprintf(&b, `<path class="fl%s" data-author="%d" data-c="%d" d="M%d %.1fC%d %.1f %.1f %.1f %.1f %.1f"/>`,
mine, p.author, colour, stripsRight+7, t.y, stripsRight+60, t.y, nx-56, ny-3, nx-5, ny-3)
fmt.Fprintf(&b, `<circle class="fm%s" data-author="%d" data-c="%d" cx="%d" cy="%.1f" r="1.7"/>`,
mine, p.author, colour, stripsRight+5, t.y)
}
fmt.Fprintf(&b, `<g class="lead%s" data-person="%d" data-first="%d" data-second="%d" data-rank="%d" data-c="%d">`,
mine, p.author, p.first, p.second, colour, colour)
fmt.Fprintf(&b, `<circle class="sw" cx="%.1f" cy="%.1f" r="3.5"/>`, nx+3, ny-3.5)
fmt.Fprintf(&b, `<text class="tn" x="%.1f" y="%.1f">%s</text>`, nx+11, ny, esc(peopleClip(act.Authors[p.author].Name, 16)))
fmt.Fprintf(&b, `<text class="tk" x="%.1f" y="%.1f">%s</text>`, nx+11, ny+12, stripLeadText(p.first, p.second))
b.WriteString(`</g>`)
}
if len(five) > 0 {
fmt.Fprintf(&b, `<text class="pk" x="%.1f" y="%d">who leads where</text>`, nx, stripsTop-12)
}
b.WriteString(`</g>`)
// Annotations: the largest marks of single people, one per person
// and lane, each placed where it covers the fewest marks.
var notes []stripNote
seen := map[[2]int]bool{}
for _, m := range marks {
if len(notes) == 3 {
break
}
if m.author < 0 || seen[[2]int{m.lane, m.author}] {
continue
}
seen[[2]int{m.lane, m.author}] = true
notes = append(notes, stripNote{x: m.x, y: m.y, r: m.r, text: fmt.Sprintf("%s, %s in %s, %s",
act.Authors[m.author].Name, peopleCount(m.files, "file"), stripClip(names[m.lane], 26), sb.label(m.b))})
}
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, stripsTop + 2, bot - 2},
{stripsLeft, stripsRight, bot + 6, bot + stripsNoteBand},
})
b.WriteString(`</svg>`)
rows := make([]activitySummary, nl)
for l, n := range names {
rows[l] = activitySummary{Label: n, N: laneFiles[l]}
}
// The ranked five as a legend: where the right edge's names scroll out
// of view, this is what says whose colour is whose.
var rk strings.Builder
if len(by) > 0 {
rk.WriteString(`<ol class="act-ranks" data-legend="ranks">`)
for _, a := range by {
p := person{author: a}
for _, f := range five {
if f.author == a {
p = f
}
}
r := rankOf[a]
mine := ""
if a == user {
mine = " mine"
}
fmt.Fprintf(&rk, `<li class="rk%s" data-rank="%d" data-c="%d" data-person="%d" data-first="%d" data-second="%d"><span class="sw"></span> <b>%s</b> <span class="act-n">%s</span></li>`,
mine, r, r, a, p.first, p.second, esc(act.Authors[a].Name), stripLeadText(p.first, p.second))
}
rk.WriteString(`</ol>`)
}
// The labels under the picture, as a list a phone can read whole: in the
// picture they sit beside their marks, out of the visible width.
if len(notes) > 0 {
rk.WriteString(`<ul class="act-notes" data-legend="notes">`)
for _, n := range notes {
fmt.Fprintf(&rk, `<li>%s</li>`, esc(n.text))
}
rk.WriteString(`</ul>`)
}
labels := fmt.Sprintf(`<svg class="act-lanes" aria-hidden="true" viewBox="0 0 %d %d" width="%d" height="%d" style="margin-bottom:-%dpx">%s</svg>`,
stripsHeldW, h, stripsHeldW, h, h, lab.String())
key := keyPair("strips", stripsKeyH+12, func(b *strings.Builder, y float64) { stripsKey(b, y, kScale, p95) }, kScale, p95, stripsCap, []keyEntry{
{"kd c", []string{"one of the five busiest,", "by colour"}},
{"kd nr", []string{"anyone else"}},
{"kd rest", []string{"the rest of a bucket,", "folded"}},
{"ring", []string{"off the scale: hollow,", "a second ring"}},
{"step", []string{"largest on the line,", "others step outward"}},
})
return stripsOut{svg: b.String(), lanes: rows, labels: labels, ranks: rk.String(), key: key}
}
// stripPeople counts people, with an optional word between the number
// and the noun.
func stripPeople(n int, adj string) string {
if n == 1 {
return "1 " + adj + "person"
}
return fmt.Sprintf("%d %speople", n, adj)
}
func stripLeadText(first, second int) string {
switch {
case first > 0 && second > 0:
return fmt.Sprintf("first in %d · second in %d", first, second)
case first > 0:
return fmt.Sprintf("first in %d", first)
case second > 0:
return fmt.Sprintf("second in %d", second)
}
return "not first or second in any lane"
}
// stripClip shortens a lane's name from the left, where directories
// repeat, keeping the end that tells lanes apart.
func stripClip(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return "…" + string(r[len(r)-n+1:])
}
// stripsKey draws how to read the strips: the size scale (the same one
// the marks use, so a key circle is a real mark) and one example of each
// kind of mark.
func stripsKey(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, stripsCap, 2)
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", "one of the five busiest, by colour")
glyph(c1, r1+17, "kd nr", "anyone else")
glyph(c1, r1+34, "kd rest", "the rest of that bucket, folded")
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="base" x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/><circle class="kd nr" cx="%.1f" cy="%.1f" r="3.6"/><circle class="kd nr" cx="%.1f" cy="%.1f" r="2.4"/>`,
c2, r1+14, c2+14, r1+14, c2+7, r1+14, c2+11, r1+8)
fmt.Fprintf(b, `<text class="pk" x="%.1f" y="%.1f">within a bucket the largest sits on the line,</text>`, c2+22, r1+18)
fmt.Fprintf(b, `<text class="pk" x="%.1f" y="%.1f">smaller ones step outward</text>`, c2+22, r1+31)
b.WriteString(`</g>`)
}