Skip to content
quarry Artifact [0b625975d6]
Login

Artifact [0b625975d6]

Artifact 0b625975d651905e5ae0ffcf7b3225d06a210cea13cab6ee41138bd213f652ff:


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