Skip to content
quarry Artifact [f1a39ee4c7]
Login

Artifact [f1a39ee4c7]

Artifact f1a39ee4c7763478b63d5c1f8ee31ce5fa7a5acc6c6f4591c593edf3e94b4da9:


package web

// The people axis of the activity page: one punch card per person. A
// card is a weekday (rows, Monday first) by hour (columns, UTC) grid with
// a mark in every slot that has check-ins, sized by the files changed
// there. Everyone comes first as the baseline, then the twelve people
// with the most check-ins in the window, then the rest summed into one
// card. Every figure is folded here from the rows the clock axis already
// has — no op, no index change.

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

const (
	peopleTop      = 12 // people with a card of their own
	peopleCols     = 4  // cards to a row
	peopleBigCell  = 20 // the everyone card's cell, in viewBox units
	peopleCell     = 9  // every other card's cell
	peopleSparkMax = 120
	// peopleMaxR is the radius the 95th percentile of the everyone card's
	// slots is drawn at, and everything else follows the same square-root
	// scale. It is the largest a small card's cell holds comfortably: a
	// person's slot never has more files than everyone's, so on the small
	// cards only the true outliers go off the scale.
	peopleMaxR = 5
	// Heights of the everyone row and of a row of person cards.
	peopleBigH   = 245
	peopleSmallH = 156
)

var peopleDays = [7]string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}

// peopleCard is one card's figures: per slot (weekday*24+hour) check-ins
// and files, and check-ins per stretch of weeks for the line under it.
type peopleCard struct {
	checkins [168]int
	files    [168]int
	weeks    []int
	n, f     int
}

// peopleHead is what a card says about whose it is.
type peopleHead struct {
	kind   string // everyone, person or others
	author int    // person only
	name   string
	link   string // the name's door, if it has one
	colour string
	people int // others only: how many people the card sums
	chosen bool
}

func (c *peopleCard) add(slot, week, files int) {
	c.checkins[slot]++
	c.files[slot] += files
	c.weeks[week]++
	c.n++
	c.f += files
}

// peopleSampleSVG draws the people axis. user is the person singled out,
// or -1: their card is marked and the other people's marks grey, the
// same convention the other axes use. win is the window control's value,
// carried into each name's link to that person's clock view.
func peopleSampleSVG(repo, win string, act activityOut, user int) string {
	since, err := time.Parse("2006-01-02", act.Since)
	if err != nil {
		since = time.Time{}
	}
	span := daysSpan(act.Since, act.Until)
	nweeks := (span + 6) / 7

	// Who gets a card: by check-ins in the window, ties by list order.
	perAuthor := make([]int, len(act.Authors))
	for _, r := range act.Rows {
		if r.Author >= 0 && r.Author < len(perAuthor) {
			perAuthor[r.Author]++
		}
	}
	order := make([]int, 0, len(act.Authors))
	for i, n := range perAuthor {
		if n > 0 {
			order = append(order, i)
		}
	}
	sort.SliceStable(order, func(i, j int) bool { return perAuthor[order[i]] > perAuthor[order[j]] })
	nTop := min(len(order), peopleTop)
	cardOfAuthor := map[int]int{} // author -> index into cards
	for k, a := range order[:nTop] {
		cardOfAuthor[a] = k + 2
	}
	// cards: 0 everyone, 1 the rest, 2.. the top people in order.
	cards := make([]*peopleCard, 2+nTop)
	for i := range cards {
		cards[i] = &peopleCard{weeks: make([]int, max(nweeks, 1))}
	}
	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
		}
		dow := (int(since.AddDate(0, 0, r.Day).Weekday()) + 6) % 7
		slot, week := dow*24+r.Hour, r.Day/7
		cards[0].add(slot, week, r.Files)
		if k, ok := cardOfAuthor[r.Author]; ok {
			cards[k].add(slot, week, r.Files)
		} else {
			cards[1].add(slot, week, r.Files)
		}
	}

	// One scale for every card, from the everyone card's slots.
	sorted := make([]int, 168)
	copy(sorted, cards[0].files[:])
	sort.Ints(sorted)
	k := peopleMaxR / math.Sqrt(float64(max(sorted[168*95/100], 1)))

	var b strings.Builder
	var body strings.Builder
	marks := peopleDrawCard(&body, cards[0], 0, 0, peopleBigCell, k,
		peopleHead{kind: "everyone", name: "everyone", link: activityURL(repo, "clock", "", win)})
	if len(order) > nTop {
		others := len(order) - nTop
		marks += peopleDrawCard(&body, cards[1], float64(sampleW/peopleCols*3), 0, peopleCell, k,
			peopleHead{kind: "others", name: plural(others, "other"), people: others})
	}
	h := peopleBigH
	if nTop > 0 {
		h = peopleBigH + 3 + (nTop+peopleCols-1)/peopleCols*peopleSmallH - 11
		for i, a := range order[:nTop] {
			au := act.Authors[a]
			marks += peopleDrawCard(&body, cards[i+2], float64(i%peopleCols*(sampleW/peopleCols)),
				float64(peopleBigH+3+i/peopleCols*peopleSmallH), peopleCell, k,
				peopleHead{kind: "person", author: a, name: au.Name, colour: activityColour(au),
					link: activityURL(repo, "clock", au.Name, win), chosen: a == user})
		}
	}

	label := esc(activitySentence(act, user))
	fmt.Fprintf(&b, `<svg class="act act-people" viewBox="0 0 %d %d" role="group" aria-label="%s" data-axis="people" data-rows="%d" data-bucket="1" data-marks="%d"%s data-since="%s" data-until="%s" data-window="%s">`,
		sampleW, h, label, len(act.Rows), marks, userAttr(user), esc(act.Since), esc(act.Until), esc(act.Window))
	fmt.Fprintf(&b, `<title>%s</title>`, label)
	b.WriteString(body.String())
	b.WriteString(`</svg>`)
	return b.String()
}

// peopleDrawCard writes one card and returns how many marks it drew.
func peopleDrawCard(b *strings.Builder, c *peopleCard, x, y, cell, k float64, hd peopleHead) int {
	big := hd.kind == "everyone"
	lx := x + 14
	gy := y + 32
	if big {
		lx = x + 34
	}
	gw := 24 * cell
	capR := cell * 0.6
	if big {
		capR = cell * 0.5
	}

	pk, we := 0, 0
	for d := 0; d < 168; d++ {
		if d >= 120 {
			we += c.checkins[d]
		}
		if c.checkins[d] > c.checkins[pk] {
			pk = d
		}
	}
	cls := "card"
	if hd.chosen {
		cls = "card mine"
	}
	fmt.Fprintf(b, `<g class="%s" data-card="%s"`, cls, hd.kind)
	if hd.kind == "person" {
		fmt.Fprintf(b, ` data-author="%d"`, hd.author)
	}
	fmt.Fprintf(b, ` data-checkins="%d" data-files="%d"`, c.n, c.f)
	if hd.kind == "others" {
		fmt.Fprintf(b, ` data-people="%d"`, hd.people)
	}
	if c.n > 0 {
		fmt.Fprintf(b, ` data-peak-d="%d" data-peak-h="%d" data-weekend="%d"`, pk/24, pk%24, 100*we/c.n)
	}
	if hd.colour != "" {
		fmt.Fprintf(b, ` style="--c:%s"`, hd.colour)
	}
	b.WriteString(`>`)

	// Header: the name (a door to that person's clock view) and, under
	// it, the card's totals.
	title := esc(peopleClip(hd.name, 28))
	ny := y + 12
	if hd.link != "" {
		fmt.Fprintf(b, `<a href="%s"><text class="tn" x="%.0f" y="%.0f">%s</text></a>`, esc(hd.link), x, ny, title)
	} else {
		fmt.Fprintf(b, `<text class="tn" x="%.0f" y="%.0f">%s</text>`, x, ny, title)
	}
	fmt.Fprintf(b, `<text class="ct" x="%.0f" y="%.0f">%s · %s</text>`, x, y+25,
		peopleCount(c.n, "check-in"), peopleCount(c.f, "file"))

	// The grid: a hairline per weekday, hour ticks every six.
	b.WriteString(`<g class="grid">`)
	for d := 0; d < 7; d++ {
		cy := gy + float64(d)*cell + cell/2
		fmt.Fprintf(b, `<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/>`, lx, cy, lx+gw, cy)
		lab := peopleDays[d]
		if !big {
			lab = lab[:1]
		}
		gap := 5.0
		if big {
			gap = 8
		}
		fmt.Fprintf(b, `<text class="tk" x="%.1f" y="%.1f" text-anchor="end">%s</text>`, lx-gap, cy+3, lab)
	}
	for h := 0; h <= 24; h += 6 {
		tx := lx + float64(h)*cell
		fmt.Fprintf(b, `<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/>`, tx, gy+7*cell, tx, gy+7*cell+3)
		off := 0.0
		if h < 24 {
			off = cell / 2
		}
		fmt.Fprintf(b, `<text class="tk" x="%.1f" y="%.1f" text-anchor="middle">%02d</text>`, tx+off, gy+7*cell+11, h)
	}
	b.WriteString(`</g>`)

	// The marks. The radius is an attribute as well as a shape: engines
	// without CSS geometry would draw nothing.
	b.WriteString(`<g class="marks">`)
	marks := 0
	for d := 0; d < 168; d++ {
		if c.checkins[d] == 0 {
			continue
		}
		dow, hr := d/24, d%24
		cx := lx + float64(hr)*cell + cell/2
		cy := gy + float64(dow)*cell + cell/2
		r := k * math.Sqrt(float64(c.files[d]))
		over := r > capR
		if over {
			r = capR
		}
		minR, dcls := 1.4, "dot"
		if dow >= 5 {
			minR, dcls = 1.6, "dot wk"
		}
		r = math.Max(r, minR)
		tip := fmt.Sprintf("%s %02d:00 · %s · %s", peopleDays[dow], hr, peopleCount(c.files[d], "file"), peopleCount(c.checkins[d], "check-in"))
		fmt.Fprintf(b, `<circle class="%s" data-d="%d" data-h="%d" cx="%.1f" cy="%.1f" r="%.1f"><title>%s</title></circle>`,
			dcls, dow, hr, cx, cy, r, tip)
		marks++
		if over {
			fmt.Fprintf(b, `<circle class="cap" cx="%.1f" cy="%.1f" r="%.1f"/>`, cx, cy, r+2.2)
		}
		if d == pk {
			fmt.Fprintf(b, `<circle class="peak" cx="%.1f" cy="%.1f" r="%.1f"/>`, cx, cy, math.Max(r+2.4, cell*0.42))
		}
	}
	b.WriteString(`</g>`)

	// Under the grid: check-ins per week, each card to its own peak, and
	// the one line of numbers.
	spark := 18.0
	if big {
		spark = 30
	}
	st := gy + 7*cell + 14
	if big {
		st = gy + 7*cell + 17
	}
	pts := sparkPoints(c.weeks, peopleSparkMax)
	peak := 1
	for _, n := range pts {
		peak = max(peak, n)
	}
	fmt.Fprintf(b, `<line class="base" x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f"/>`, lx, st+spark+0.5, lx+gw, st+spark+0.5)
	var path strings.Builder
	for i, n := range pts {
		px := lx + (float64(i)+0.5)*gw/float64(len(pts))
		py := st + spark - float64(n)/float64(peak)*(spark-1)
		op := "L"
		if i == 0 {
			op = "M"
		}
		fmt.Fprintf(&path, "%s%.1f %.1f", op, px, py)
	}
	if len(pts) == 1 { // a line needs two ends
		fmt.Fprintf(&path, "L%.1f %.1f", lx+gw/2+0.1, st+spark-float64(pts[0])/float64(peak)*(spark-1))
	}
	fmt.Fprintf(b, `<path class="spark" d="%s"/>`, path.String())
	if c.n > 0 {
		fmt.Fprintf(b, `<text class="pk" x="%.1f" y="%.1f">peak %s %02d:00 · %d%% at weekends</text>`,
			lx, st+spark+13, peopleDays[pk/24], pk%24, 100*we/c.n)
	}
	b.WriteString(`</g>`)
	return marks
}

// peopleCount is a number with thousands commas and its unit.
func peopleCount(n int, unit string) string {
	if n == 1 {
		return "1 " + unit
	}
	return commas(n) + " " + unit + "s"
}

// sparkPoints folds a week-by-week series to at most limit points by
// summing neighbouring weeks, so a forty-year window draws a line the
// same size as a quarter's.
func sparkPoints(weeks []int, limit int) []int {
	if len(weeks) <= limit {
		return weeks
	}
	g := (len(weeks) + limit - 1) / limit
	out := make([]int, 0, limit)
	for i := 0; i < len(weeks); i += g {
		sum := 0
		for _, n := range weeks[i:min(i+g, len(weeks))] {
			sum += n
		}
		out = append(out, sum)
	}
	return out
}

// peopleClip shortens a name to n characters, with an ellipsis.
func peopleClip(s string, n int) string {
	r := []rune(s)
	if len(r) <= n {
		return s
	}
	return string(r[:n-1]) + "…"
}