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"
"math"
"sort"
"strings"
"time"
)
const (
stripsW = 1210
stripsLeft = 172 // the lane labels
stripsPlotW = 700
stripsRight = stripsLeft + stripsPlotW // where the leader curves start
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
stripsColoured = 5 // people who get their own colour
// 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
stripsRing = 2.2 // the second ring's distance from a capped mark
stripsNoteBand = 44 // room under the plot for annotations
stripsKeyH = 88
)
// stripGrainOf is the width of a time bucket for a window of days: the
// finest that keeps the strips from emptying out on a short window and
// from becoming a smear on a long one.
func stripGrainOf(days int) string {
switch {
case days <= 60:
return "2day"
case days <= 200:
return "week"
case days <= 420:
return "2week"
}
return "month"
}
// stripBuckets cuts a window into time buckets. Weeks start on Monday
// and months on the first, whatever day the window opens on, so the
// first and last bucket may be clipped to the window.
type stripBuckets struct {
grain string
start time.Time
span int
starts []int // day offsets from the window's start; the last is the end
}
func newStripBuckets(since string, span int) stripBuckets {
t, err := time.Parse("2006-01-02", since)
if err != nil {
t = time.Time{}
}
span = max(span, 1)
sb := stripBuckets{grain: stripGrainOf(span), start: t, span: span}
dow := (int(t.Weekday()) + 6) % 7
switch sb.grain {
case "2day":
for d := 0; d < span; d += 2 {
sb.starts = append(sb.starts, d)
}
case "week", "2week":
step := 7
if sb.grain == "2week" {
step = 14
}
for d := -dow; d < span; d += step {
sb.starts = append(sb.starts, d)
}
default:
first := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
for m := 0; ; m++ {
d := int(first.AddDate(0, m, 0).Sub(t).Hours() / 24)
if d >= span {
break
}
sb.starts = append(sb.starts, d)
}
}
sb.starts = append(sb.starts, max(span, sb.starts[len(sb.starts)-1]+1))
return sb
}
func (sb stripBuckets) count() int { return len(sb.starts) - 1 }
// index is the bucket a day offset falls in.
func (sb stripBuckets) index(day int) int {
i := sort.Search(len(sb.starts)-1, func(i int) bool { return sb.starts[i+1] > day })
return min(i, sb.count()-1)
}
// bounds is the bucket's extent in days, clipped to the window.
func (sb stripBuckets) bounds(i int) (from, to float64) {
return float64(max(sb.starts[i], 0)), float64(min(sb.starts[i+1], sb.span))
}
// label names a bucket the way a reader would, by its first day.
func (sb stripBuckets) label(i int) string {
d := sb.start.AddDate(0, 0, sb.starts[i])
switch sb.grain {
case "2day":
return "2 days from " + d.Format("2 Jan 2006")
case "week":
return "week of " + d.Format("2 Jan 2006")
case "2week":
return "fortnight of " + d.Format("2 Jan 2006")
}
return d.Format("Jan 2006")
}
// stripItem is one person's work in one lane and bucket.
type stripItem struct {
author, files int
med float64 // usual hour, rotated to start at the quietest
}
// stripMark is a circle to draw.
type stripMark struct {
lane, b, author, files, people int
x, y, r float64
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) {
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)
// The repository's quietest hour opens the day; a person's usual
// hour in a bucket is the median of their check-ins' rotated hours.
var hourN [24]int
for _, r := range act.Rows {
if r.Hour >= 0 && r.Hour < 24 {
hourN[r.Hour]++
}
}
h0 := 0
for h := range hourN {
if hourN[h] < hourN[h0] {
h0 = h
}
}
type ab struct{ author, b int }
hours := map[ab][]int{}
checkins := make([]int, len(act.Authors))
for _, r := range act.Rows {
if r.Author < 0 || r.Author >= len(act.Authors) || r.Hour < 0 || r.Hour > 23 || r.Day < 0 || r.Day >= span {
continue
}
checkins[r.Author]++
k := ab{r.Author, sb.index(r.Day)}
hours[k] = append(hours[k], (r.Hour-h0+24)%24)
}
medOf := func(author, b int) float64 {
v := hours[ab{author, b}]
if len(v) == 0 {
return 12
}
sort.Ints(v)
if n := len(v); n%2 == 1 {
return float64(v[n/2])
} else {
return float64(v[n/2-1]+v[n/2]) / 2
}
}
// 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, med: medOf(k.author, k.b)})
all = append(all, n)
}
sort.Ints(all)
p95 := 1
if len(all) > 0 {
p95 = max(all[min(len(all)-1, len(all)*95/100)], 1)
}
kScale := stripsR95 / math.Sqrt(float64(p95))
// Who is in colour: the busiest by check-ins, and whoever is chosen.
by := make([]int, 0, len(act.Authors))
for a, n := range checkins {
if n > 0 {
by = append(by, a)
}
}
sort.SliceStable(by, func(i, j int) bool { return checkins[by[i]] > checkins[by[j]] })
by = by[:min(len(by), stripsColoured)]
coloured := map[int]bool{}
for _, a := range by {
coloured[a] = true
}
if user >= 0 {
coloured[user] = true
}
laneTop := func(l int) float64 { return stripsTop + float64(l)*stripH }
yIn := func(l int, med float64) float64 {
return laneTop(l) + 5 + (med+0.5)/24*(stripH-10)
}
hh := func(med float64) string { return fmt.Sprintf("%02d:00", (int(med)+h0)%24) }
// The marks: six per bucket and a remainder, the centre of gravity
// of every bucket, and who leads each lane.
type lead struct{ files, sy float64 }
leads := make([]map[int]*lead, nl)
for i := range leads {
leads[i] = map[int]*lead{}
}
cg := make([][][2]float64, nl)
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)
var sw, sy float64
for _, it := range items {
w := math.Min(float64(it.files), stripsWeightCap)
sw += w
sy += w * it.med
ld := leads[g.lane][it.author]
if ld == nil {
ld = &lead{}
leads[g.lane][it.author] = ld
}
ld.files += float64(it.files)
ld.sy += float64(it.files) * it.med
}
cg[g.lane] = append(cg[g.lane], [2]float64{cx, sy / sw})
keep := items[:min(len(items), stripsPerBucket)]
for _, 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,
x: cx + jit, y: yIn(g.lane, it.med),
tip: fmt.Sprintf("%s · %s · %s · %s changed · usually %s UTC", act.Authors[it.author].Name,
names[g.lane], sb.label(g.b), peopleCount(it.files, "file"), hh(it.med))})
}
if rest := items[len(keep):]; len(rest) > 0 {
var rf, ry float64
for _, it := range rest {
rf += float64(it.files)
ry += float64(it.files) * it.med
}
marks = append(marks, stripMark{lane: g.lane, b: g.b, author: -1, files: int(rf), people: len(rest),
x: cx, y: yIn(g.lane, ry/rf),
tip: fmt.Sprintf("%s · %s · %s · %s changed", stripPeople(len(rest), "more "), names[g.lane],
sb.label(g.b), peopleCount(int(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 = math.Max(kScale*math.Sqrt(float64(m.files)), 1)
extra := 0.7
if m.r > stripsCap {
m.r, m.capped = stripsCap, true
extra += stripsRing
}
// A mark never leaves its strip: its centre gives way, not its size.
top := laneTop(m.lane)
m.y = math.Max(top+m.r+extra, math.Min(m.y, top+stripH-m.r-extra))
}
var b strings.Builder
label := esc(activitySentence(act, user))
h := stripsTop + nl*stripH + stripsNoteBand + stripsKeyH + 12
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.
since, _ := time.Parse("2006-01-02", act.Since)
months := 0
for m := time.Date(since.Year(), since.Month(), 1, 0, 0, 0, 0, time.UTC); m.Before(since.AddDate(0, 0, span)); m = m.AddDate(0, 1, 0) {
months++
}
b.WriteString(`<g class="months">`)
for m, i := time.Date(since.Year(), since.Month(), 1, 0, 0, 0, 0, time.UTC), 0; i < months; m, i = m.AddDate(0, 1, 0), i+1 {
x := px(math.Max(0, m.Sub(since).Hours()/24))
major := months <= 8 || m.Month() == time.January || m.Month() == time.July
up := 4.0
if major {
up = 9
}
fmt.Fprintf(&b, `<line class="ax" x1="%.1f" y1="%.1f" x2="%.1f" y2="%d"/>`, x, stripsTop-up, x, stripsTop)
if 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, m.Format("Jan 2006"))
}
}
b.WriteString(`</g>`)
// The lanes: a name, what is in it, a rule above and a tick at the
// middle of the day.
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)
}
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="ax" x1="%d" y1="%.1f" x2="%d" y2="%.1f"/>`, stripsLeft-4, top+stripH/2, stripsLeft, 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"
style := ""
switch {
case m.author < 0:
cls = "dot rest"
case coloured[m.author]:
cls = "dot c"
style = fmt.Sprintf(` style="--c:%s"`, activityColour(act.Authors[m.author]))
}
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, style, esc(m.tip))
if m.capped {
fmt.Fprintf(&b, `<circle class="cap" cx="%.1f" cy="%.1f" r="%.1f"%s/>`, m.x, m.y, m.r+stripsRing, style)
}
}
b.WriteString(`</g>`)
// The centre of gravity of each strip over time: a three-bucket
// running mean of where in the day its work was done.
b.WriteString(`<g class="gravity">`)
for l, line := range cg {
if len(line) < 2 {
continue
}
pts := make([][2]float64, len(line))
for i, p := range line {
a, c := line[max(0, i-1)][1], line[min(len(line)-1, i+1)][1]
pts[i] = [2]float64{p[0], yIn(l, (a+p[1]+c)/3)}
}
fmt.Fprintf(&b, `<path class="cg" d="%s"/>`, smoothPath(pts))
}
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]].files != leads[l][rk[j]].files {
return leads[l][rk[i]].files > leads[l][rk[j]].files
}
return rk[i] < rk[j]
})
for rank, a := range rk[:min(len(rk), 2)] {
if g := leads[l][a]; coloured[a] {
ties = append(ties, tie{a, l, rank, yIn(l, g.sy/g.files)})
}
}
}
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 := activityColour(act.Authors[p.author])
for _, t := range ties {
if t.author != p.author {
continue
}
fmt.Fprintf(&b, `<path class="fl%s" data-author="%d" style="--c:%s" 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" style="--c:%s" 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" style="--c:%s">`,
mine, p.author, p.first, p.second, 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}
}
placeStripNotes(&b, notes, obst, []stripRegion{
{stripsLeft + 2, stripsRight - 2, stripsTop + 2, bot - 2},
{stripsLeft, stripsRight, bot + 6, bot + stripsNoteBand},
})
stripsKey(&b, bot+stripsNoteBand+8, kScale, p95, h0)
b.WriteString(`</svg>`)
rows := make([]activitySummary, nl)
for l, n := range names {
rows[l] = activitySummary{Label: n, N: laneFiles[l]}
}
return b.String(), rows
}
// 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)
}
return fmt.Sprintf("second in %d", second)
}
// 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:])
}
// smoothPath is a line through the points, rounded at each corner.
func smoothPath(pts [][2]float64) string {
var b strings.Builder
fmt.Fprintf(&b, "M%.1f %.1f", pts[0][0], pts[0][1])
for i := 1; i < len(pts)-1; i++ {
mx, my := (pts[i][0]+pts[i+1][0])/2, (pts[i][1]+pts[i+1][1])/2
fmt.Fprintf(&b, "Q%.1f %.1f %.1f %.1f", pts[i][0], pts[i][1], mx, my)
}
e := pts[len(pts)-1]
fmt.Fprintf(&b, "L%.1f %.1f", e[0], e[1])
return b.String()
}
// stripNote is an annotation to place, or (with no text) an obstacle:
// a circle the note must keep off.
type stripNote struct {
x, y, r float64
text string
}
type stripRegion struct{ x0, x1, y0, y1 float64 }
// spot is a candidate place for a note: its cost, the text's origin, its
// box, and the point of the box nearest the mark it names.
type spot struct {
cost, tx, ty float64
bx0, bx1, by0, by1 float64
px, py float64
}
// placeStripNotes writes each note where it covers the fewest marks, in
// the given regions, and a fine line ties it back to its mark.
func placeStripNotes(b *strings.Builder, notes, obst []stripNote, regions []stripRegion) {
const charW, boxUp, boxDown = 6.5, 11.0, 3.0
sort.SliceStable(obst, func(i, j int) bool { return obst[i].y < obst[j].y })
maxR := 0.0
for _, o := range obst {
maxR = math.Max(maxR, o.r)
}
type box struct{ x0, x1, y0, y1 float64 }
var placed []box
for _, nt := range notes {
w := float64(len([]rune(nt.text)))*charW + 4
var best *spot
for _, rg := range regions {
for ty := rg.y0 + boxUp; ty <= rg.y1-boxDown; ty += 6 {
for tx := rg.x0; tx+w <= rg.x1; tx += 12 {
bx0, bx1, by0, by1 := tx, tx+w, ty-boxUp, ty+boxDown
cost := 0.0
lo := sort.Search(len(obst), func(i int) bool { return obst[i].y >= by0-maxR-2 })
for _, o := range obst[lo:] {
if o.y > by1+maxR+2 {
break
}
nx, ny := math.Max(bx0, math.Min(o.x, bx1)), math.Max(by0, math.Min(o.y, by1))
if (nx-o.x)*(nx-o.x)+(ny-o.y)*(ny-o.y) < (o.r+1.5)*(o.r+1.5) {
cost += 2 + 8*math.Min(1, o.r/4)
}
}
for _, o := range placed {
if bx0 < o.x1+4 && bx1 > o.x0-4 && by0 < o.y1+2 && by1 > o.y0-2 {
cost += 1e6
}
}
px, py := math.Max(bx0, math.Min(nt.x, bx1)), math.Max(by0, math.Min(nt.y, by1))
cost += math.Hypot(px-nt.x, py-nt.y) * 0.04
if best == nil || cost < best.cost {
best = &spot{cost, tx, ty, bx0, bx1, by0, by1, px, py}
}
}
}
}
if best == nil {
continue
}
placed = append(placed, box{best.bx0, best.bx1, best.by0, best.by1})
dx, dy := best.px-nt.x, best.py-nt.y
dist, rr := math.Hypot(dx, dy), nt.r+3
if dist > rr+4 {
fmt.Fprintf(b, `<path class="ld" d="M%.1f %.1fL%.1f %.1f"/>`,
nt.x+dx/dist*rr, nt.y+dy/dist*rr, best.px-dx/dist*2, best.py-dy/dist*2)
}
fmt.Fprintf(b, `<text class="an" x="%.1f" y="%.1f">%s</text>`, best.tx, best.ty, esc(nt.text))
}
}
// stripNice rounds to 1, 2 or 5 times a power of ten, for the size key.
func stripNice(v float64) int {
v = math.Max(1, v)
n := math.Pow(10, math.Floor(math.Log10(v)))
m := v / n
best := 1.0
for _, c := range []float64{1, 2, 5, 10} {
if math.Abs(c-m) < math.Abs(best-m) {
best = c
}
}
return max(1, int(math.Round(best*n)))
}
// 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, h0 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)
var vals []int
for _, v := range []float64{float64(p95) / 20, float64(p95) / 4, float64(p95)} {
if n := stripNice(v); len(vals) == 0 || n > vals[len(vals)-1] {
vals = append(vals, n)
}
}
cx, base := 2.0, y+58
for _, v := range vals {
r := math.Min(stripsCap, math.Max(k*math.Sqrt(float64(v)), 1))
cx += r
fmt.Fprintf(b, `<circle class="rg" cx="%.1f" cy="%.1f" r="%.1f"/>`, cx, base-r, r)
fmt.Fprintf(b, `<text class="tk" x="%.1f" y="%.1f" text-anchor="middle">%s</text>`, cx, base+11, commas(v))
cx += r + 16
}
fmt.Fprintf(b, `<text class="tk" x="%.1f" y="%.1f">files changed</text>`, cx-2, base-3)
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 people")
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, `<path class="cg" d="M%.1f %.1fQ%.1f %.1f %.1f %.1fL%.1f %.1f"/>`, c2, r1+17, c2+4, r1+12, c2+8, r1+17, c2+12, r1+13)
fmt.Fprintf(b, `<text class="pk" x="%.1f" y="%.1f">centre of gravity, three-bucket mean</text>`, c2+18, r1+18)
fmt.Fprintf(b, `<text class="pk" x="%.1f" y="%.1f">Height in a strip: that person's usual hour in the bucket,</text>`, c2, r1+36)
fmt.Fprintf(b, `<text class="pk" x="%.1f" y="%.1f">top edge %02d:00 UTC, the quietest hour.</text>`, c2, r1+49, h0)
b.WriteString(`</g>`)
}