Skip to content
quarry Artifact [85b717e5ab]
Login

Artifact [85b717e5ab]

Artifact 85b717e5ab013bf82238d430b65fc6e7c11d418c4a5ca77c5c7a5d39f1453172:


package web

// What the activity axes draw with in common: the time buckets, who is
// in colour, the size scale and its second ring, the month ticks, the
// size key and the placer that sets a label where it covers no mark.

import (
	"fmt"
	"math"
	"sort"
	"strings"
	"time"
)

const (
	stripsW        = 1210
	stripsLeft     = 172 // the lane labels
	stripsPlotW    = 700
	stripsRight    = stripsLeft + stripsPlotW // where the leader curves start
	stripsColoured = 5                        // people who get their own colour
	stripsRing     = 2.2                      // the second ring's distance from a capped mark
	stripsNoteBand = 58                       // room under the plot for annotations
	// stripsHeldW is the width of the strip of lane captions a phone holds
	// in view over the plot.
	stripsHeldW = 340
)

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

// short names a bucket in a few characters, for a margin: the month, or
// the first day.
func (sb stripBuckets) short(i int) string {
	d := sb.start.AddDate(0, 0, sb.starts[i])
	if sb.grain == "month" {
		return d.Format("Jan 06")
	}
	return d.Format("2 Jan")
}

// 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")
}

// activityRanks are the five people with the most check-ins in the
// window, busiest first, and each one's rank: the fixed colour they are
// drawn in on every axis that colours people.
func activityRanks(act activityOut, span int) (by []int, rankOf map[int]int) {
	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]++
		}
	}
	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
	}
	return by, rankOf
}

// markScale is the radius constant for one picture: the 95th percentile
// of its marks (their file counts, sorted in place) is drawn at r95 and
// every other mark follows the same square-root scale.
func markScale(files []int, r95 float64) (k float64, p95 int) {
	sort.Ints(files)
	p95 = 1
	if len(files) > 0 {
		p95 = max(files[min(len(files)-1, len(files)*95/100)], 1)
	}
	return r95 / math.Sqrt(float64(p95)), p95
}

// markRadius is a mark's radius, its area following its files, and
// whether it went over the cap, which is drawn hollow with a second ring.
func markRadius(files int, k, capR float64) (r float64, capped bool) {
	r = math.Max(k*math.Sqrt(float64(files)), 1)
	if r > capR {
		return capR, true
	}
	return r, false
}

// monthTick is a month's start along the window, in days from its first.
type monthTick struct {
	day   float64
	label string
	major bool
}

// monthTicks are the month starts across a window: every month on a
// short one, January and July on a long one.
func monthTicks(since string, span int) []monthTick {
	t, _ := time.Parse("2006-01-02", since)
	first := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
	months := 0
	for m := first; m.Before(t.AddDate(0, 0, span)); m = m.AddDate(0, 1, 0) {
		months++
	}
	out := make([]monthTick, 0, months)
	for m, i := first, 0; i < months; m, i = m.AddDate(0, 1, 0), i+1 {
		out = append(out, monthTick{
			day:   math.Max(0, m.Sub(t).Hours()/24),
			label: m.Format("Jan 2006"),
			major: months <= 8 || m.Month() == time.January || m.Month() == time.July,
		})
	}
	return out
}

// sizeKey draws the size scale of a figure: circles of a few round file
// counts, drawn at the radius a mark of that many files takes, so a key
// circle is a real mark.
func sizeKey(b *strings.Builder, y, k float64, p95 int, capR, x0 float64) {
	sizeKeyAt(b, y+58, k, p95, capR, x0)
}

// sizeKeyVals are the round file counts a key draws.
func sizeKeyVals(p95 int) []int {
	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)
		}
	}
	return vals
}

// sizeKeyAt draws the size circles standing on a baseline.
func sizeKeyAt(b *strings.Builder, base, k float64, p95 int, capR, x0 float64) {
	cx := x0
	for _, v := range sizeKeyVals(p95) {
		r := math.Min(capR, 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))
		// A long label gets the room it needs before the next circle.
		cx += math.Max(r+16, float64(len(commas(v)))*2.6+4)
	}
	fmt.Fprintf(b, `<text class="tk" x="%.1f" y="%.1f">files changed</text>`, cx-2, base-3)
}

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

// keyEntry is one line of the drawn key: a sample mark and what it says.
type keyEntry struct {
	kind  string // a mark class ("kd c", "kd nr", "kd rest"), or "ring", "step" or "dt"
	lines []string
}

// keyPair is the drawn key of a picture, twice: laid out wide, exactly as
// it was drawn inside the picture, and in one narrow column that fits a
// phone. The stylesheet shows one of them. wide draws the wide layout at
// the given y; its box is stripsW by wideH.
func keyPair(kind string, wideH int, wide func(b *strings.Builder, y float64), k float64, p95 int, capR float64, entries []keyEntry) string {
	var b strings.Builder
	fmt.Fprintf(&b, `<svg class="act act-%s act-keysvg act-key-wide" viewBox="0 0 %d %d" aria-hidden="true">`, kind, stripsW, wideH)
	wide(&b, 8)
	b.WriteString(`</svg>`)

	// Narrow: the size circles on one row, then the entries in two
	// columns, with no heading (the details' summary says it) and no band
	// above or below.
	const w, col, textX = 360, 180, 18.0
	var body strings.Builder
	body.WriteString(`<g class="key">`)
	top := 0.0
	for _, v := range sizeKeyVals(p95) {
		top = math.Max(top, 2*math.Min(capR, math.Max(k*math.Sqrt(float64(v)), 1)))
	}
	base := top + 3
	sizeKeyAt(&body, base, k, p95, capR, 8)
	y := base + 11 + 14
	for i := 0; i < len(entries); i += 2 {
		rowH := 0.0
		for j := i; j < i+2 && j < len(entries); j++ {
			e, x := entries[j], float64((j-i)*col)
			my := y + 7
			switch e.kind {
			case "ring":
				fmt.Fprintf(&body, `<circle class="kd nr" cx="%.1f" cy="%.1f" r="3.6"/><circle class="cap kd" cx="%.1f" cy="%.1f" r="5.8"/>`, x+8, my, x+8, my)
			case "step":
				fmt.Fprintf(&body, `<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"/>`, x, my, x+16, my, x+5, my, x+11, my-5)
			case "dt":
				fmt.Fprintf(&body, `<line class="dt kd" x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/>`, x, my, x+16, my)
			default:
				fmt.Fprintf(&body, `<circle class="%s" cx="%.1f" cy="%.1f" r="4"/>`, e.kind, x+8, my)
			}
			ly := y + 11
			for _, l := range e.lines {
				fmt.Fprintf(&body, `<text class="pk" x="%.1f" y="%.1f">%s</text>`, x+textX, ly, esc(l))
				ly += 13
			}
			rowH = math.Max(rowH, ly-y)
		}
		y += rowH + 5
	}
	body.WriteString(`</g>`)
	fmt.Fprintf(&b, `<svg class="act act-%s act-keysvg act-key-narrow" viewBox="0 0 %d %.0f" aria-hidden="true">%s</svg>`, kind, w, y, body.String())
	return b.String()
}