Skip to content
quarry Artifact [97411916b0]
Login

Artifact [97411916b0]

Artifact 97411916b0c718a2455f043b319da62cd4f85ea7b610a7d38f6f4505921670e7:


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
	stripsStep      = 9   // between marks of one bucket, outward from the line
	stripsRing      = 2.2 // the second ring's distance from a capped mark
	stripsNoteBand  = 58  // 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
}

// 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) {
	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)

	checkins := make([]int, len(act.Authors))
	for _, r := range act.Rows {
		if r.Author >= 0 && r.Author < len(act.Authors) && r.Day >= 0 && r.Day < span {
			checkins[r.Author]++
		}
	}

	// 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)
	}
	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, each a fixed colour by
	// rank, and whoever is chosen, who takes the first when outside them.
	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)]
	rankOf := map[int]int{}
	for rank, a := range by {
		rankOf[a] = rank
	}
	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 = 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)
		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 + 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="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},
	})

	stripsKey(&b, bot+stripsNoteBand+8, kScale, p95)
	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, pad, plotBias = 6.5, 11.0, 3.0, 3.0, 100.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 ri, 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
					if ri == 0 {
						cost = plotBias // the band under the plot is the quieter place
					}
					lo := sort.Search(len(obst), func(i int) bool { return obst[i].y >= by0-maxR-pad-2 })
					for _, o := range obst[lo:] {
						if o.y > by1+maxR+pad+2 {
							break
						}
						// A note keeps clear of a mark with room to spare; touching
						// one costs more than any distance a free place can be.
						nx, ny := math.Max(bx0-pad, math.Min(o.x, bx1+pad)), math.Max(by0-pad, math.Min(o.y, by1+pad))
						if (nx-o.x)*(nx-o.x)+(ny-o.y)*(ny-o.y) < o.r*o.r {
							cost += 1e4
						}
					}
					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 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, 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>`)
}