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
// The widths of the two cards, in viewBox units: the grid is four
// small cards across, so the small card is a quarter of the picture.
peopleBigW = 528
peopleSmallW = 240
)
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
// compact is the everyone card drawn at the small cards' size, for a
// screen too narrow for the wide one.
compact 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)))
// One SVG per card, in a grid the stylesheet reflows: four across on a
// wide screen, one or two on a phone, each at its own size.
var b strings.Builder
marks := 0
card := func(cls, name string, w, h int, draw func(*strings.Builder) int) {
fmt.Fprintf(&b, `<svg class="act act-people pc %s" viewBox="0 0 %d %d" role="group" aria-label="%s"%s>`, cls, w, h, esc(name), userAttr(user))
var body strings.Builder
n := draw(&body)
if !strings.Contains(cls, "pc-compact") {
marks += n
}
b.WriteString(body.String())
b.WriteString(`</svg>`)
}
everyone := peopleHead{kind: "everyone", name: "everyone", link: activityURL(repo, "clock", "", win)}
card("pc-every", "everyone", peopleBigW, peopleBigH+3, func(w *strings.Builder) int {
return peopleDrawCard(w, cards[0], 0, 0, peopleBigCell, k, everyone)
})
// A phone cannot hold the wide card at a size anyone can read: it gets
// the same card at the small cards' size, and the stylesheet shows one.
compact := everyone
compact.compact = true
card("pc-every pc-compact", "everyone", peopleSmallW, peopleSmallH, func(w *strings.Builder) int {
return peopleDrawCard(w, cards[0], 0, 0, peopleCell, k, compact)
})
if len(order) > nTop {
others := len(order) - nTop
card("pc-others", plural(others, "other"), peopleSmallW, peopleSmallH, func(w *strings.Builder) int {
return peopleDrawCard(w, cards[1], 0, 0, peopleCell, k,
peopleHead{kind: "others", name: plural(others, "other"), people: others})
})
}
for i, a := range order[:nTop] {
au := act.Authors[a]
cls := "pc-person"
if i == 0 {
cls += " pc-first"
}
card(cls, au.Name, peopleSmallW, peopleSmallH, func(w *strings.Builder) int {
return peopleDrawCard(w, cards[i+2], 0, 0, 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))
return fmt.Sprintf(`<div class="act-cards" 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">%s</div>`,
label, len(act.Rows), marks, userAttr(user), esc(act.Since), esc(act.Until), esc(act.Window), 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" && !hd.compact
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.compact {
b.WriteString(` data-variant="compact"`)
}
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]) + "…"
}