package web
// The activity profile (RFC 0013): the core sample — a window of
// check-ins as dots, hour of day or path on Y, one colour per person —
// and the core slice, the whole history folded to one cell per day and
// one row per year. Both are drawn here as inline SVG from the two ops'
// output, so a reader without JavaScript gets the whole picture and
// the controls are links. The canvas that replaces the sample when a
// script runs is a later landing; the SVG stays as its first paint.
import (
"craftdesign.group/quarry/internal/idn"
"fmt"
"html/template"
"math"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"craftdesign.group/quarry/internal/ops"
)
// The op rows, as the registry marshals them.
type activityAuthor struct {
Name string `json:"name"`
Checkins int `json:"checkins"`
Files int `json:"files"`
Hue int `json:"hue"`
Chroma int `json:"chroma"`
Shade int `json:"shade"`
}
type activityRow struct {
Day int `json:"day"`
Hour int `json:"hour"`
Author int `json:"author"`
Merge int `json:"merge"`
Files int `json:"files"`
Hash string `json:"hash"`
Branch string `json:"branch"`
}
type activityTouch struct {
Day int `json:"day"`
Path int `json:"path"`
Author int `json:"author"`
Inherited int `json:"inherited"`
}
type activityOut struct {
Since string `json:"since"`
Until string `json:"until"`
Authors []activityAuthor `json:"authors"`
Paths []string `json:"paths"`
Rows []activityRow `json:"rows"`
Touches []activityTouch `json:"touches"`
Window string `json:"window"`
}
type activityDay struct {
N int `json:"n"`
Merges int `json:"merges"`
Top [][]int `json:"top"`
Authors int `json:"authors"`
Path int `json:"path"`
}
type activityDaysOut struct {
First string `json:"first"`
Last string `json:"last"`
Authors []activityAuthor `json:"authors"`
Days map[string]activityDay `json:"days"`
Paths []string `json:"paths"`
Releases map[string]string `json:"releases"`
}
// activityView is what the activity page template draws from.
type activityView struct {
Axis string // "clock", "tree" (directory strips) or "people"
// User is the person singled out, and UserIdx their place in the
// op's author list (-1 when nobody is chosen or the name is not in
// the window). The filter never subtracts: everyone's rows come
// back and the rest are drawn grey (§7).
User string
UserIdx int
Win string // the window control: "recent" or "all"
Since string
Until string
Window string // the op's own word: "picked" or "given"
Rows int
Touches int // authored touches — the files the strips count
Grain string // the time bucket of the strips and the clock: 2day, week, 2week or month
Sample template.HTML
Key template.HTML // the drawn key, wide and narrow, for the time axes
Ranks template.HTML // the strips' ranked people, as a legend
Slice template.HTML
// The legend under the picture is the chooser (§7): everyone in
// the window, the chosen one marked, and a way back to everyone.
Legend []activityLegend
// LegendRest is the legend past the first few, which a narrow screen
// folds away.
LegendRest []activityLegend
ClearURL string
// Summary is the marginal the sighted reader takes from the shape,
// as rows for a visually hidden table (§10).
Summary []activitySummary
SummaryLabel string
Years []activityYear // the slice's numbers, for its hidden table
YearsTotal int // years in the repository's history
AllYears bool // ?years=all: every year, not the last sliceYears
}
type activityYear struct {
Label string
Checkins int
Days int
Merges int
Lead string // who led the most days
LeadPct int
}
// activityLegend is one entry of the legend: the person's identicon, a
// name and their check-ins in the window, the whole entry a link.
type activityLegend struct {
Index int
Name string
Checkins int
URL string
Current bool
}
type activitySummary struct {
Label string
N int
}
// activityLegendMax is how many people the legend names.
const activityLegendMax = 24
// activityLegendHead is how many people a narrow screen shows before
// folding the rest of the legend away.
const activityLegendHead = 8
// activityColour is a person's colour as a CSS value: the identicon's
// hue and chroma, lightness from the theme token, shade stepping the
// lightness so neighbours on the wheel stay apart (§6).
func activityColour(a activityAuthor) string {
if a.Shade == 0 {
return idn.Fill(a.Name, "var(--idn-l)")
}
return idn.Fill(a.Name, fmt.Sprintf("calc(var(--idn-l) + %d%%)", a.Shade*8))
}
// activityURL is a link to the activity page. There are three states
// and no more: which axis, who is singled out, and which window. A link
// carrying none of them is the bare page.
func activityURL(repo, axis, user, window string) string {
q := url.Values{}
if axis != "" && axis != "clock" {
q.Set("axis", axis)
}
if user != "" {
q.Set("user", user)
}
if window == "all" {
q.Set("window", "all")
}
u := "/r/" + url.PathEscape(repo) + "/activity"
if len(q) > 0 {
u += "?" + q.Encode()
}
return u
}
func dayAfter(iso string, n int) string {
t, err := time.Parse("2006-01-02", iso)
if err != nil {
return iso
}
return t.AddDate(0, 0, n).Format("2006-01-02")
}
func daysSpan(since, until string) int {
a, err1 := time.Parse("2006-01-02", since)
b, err2 := time.Parse("2006-01-02", until)
if err1 != nil || err2 != nil || b.Before(a) {
return 1
}
return int(b.Sub(a).Hours()/24) + 1
}
func esc(s string) string { return template.HTMLEscapeString(s) }
// activityHandler serves /r/{repo}/activity.
func activityHandler(r *ops.Registry) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
repo := req.PathValue("repo")
q := req.URL.Query()
axis := q.Get("axis")
if axis == "" {
axis = "clock"
}
if axis != "clock" && axis != "tree" && axis != "people" {
fail(r, w, req, fmt.Errorf("axis must be clock, tree or people: %w", ops.ErrInvalid))
return
}
win := "recent"
if q.Get("window") == "all" {
win = "all"
}
v := activityView{Axis: axis, User: q.Get("user"), UserIdx: -1, Win: win}
since, until := q.Get("since"), q.Get("until")
// The whole history folded to days: the year slice draws from
// it, and the "all" window takes its ends from it.
var days activityDaysOut
haveDays := false
loadDays := func() {
if haveDays {
return
}
out, err := invoke(req.Context(), r, "repo.activity.days", url.Values{"repo": {repo}})
if err != nil {
return
}
remarshal(out, &days)
haveDays = true
}
if win == "all" {
loadDays()
if days.First != "" {
since, until = days.First, days.Last
}
}
// No user, no branch: the person filter singles out, it does
// not subtract, so everyone's rows have to come back (§7).
var act activityOut
out, err := invoke(req.Context(), r, "repo.activity", url.Values{
"repo": {repo}, "since": {since}, "until": {until},
})
if err != nil {
fail(r, w, req, err)
return
}
remarshal(out, &act)
for i, a := range act.Authors {
if a.Name == v.User {
v.UserIdx = i
break
}
}
var treeLanes []activitySummary
switch axis {
case "people":
// A bounded picture: at most fourteen cards of 168 slots.
v.Sample = template.HTML(peopleSampleSVG(repo, win, act, v.UserIdx))
case "tree":
// Bounded too: at most sixteen lanes of sixty buckets of seven
// marks, whatever the repository.
v.Sample, v.Key, v.Ranks, treeLanes = stripsSample(act, v.UserIdx)
v.Grain = stripGrainOf(daysSpan(act.Since, act.Until))
default:
// Bounded as well: a mark for each hour of each bucket.
v.Sample, v.Key = clockSample(repo, act, v.UserIdx)
v.Grain = stripGrainOf(daysSpan(act.Since, act.Until))
}
v.Since, v.Until, v.Window = act.Since, act.Until, act.Window
v.Rows = len(act.Rows)
for _, t := range act.Touches {
if t.Inherited == 0 {
v.Touches++
}
}
// On the people axis the cards are the legend and the chooser.
if axis != "people" {
v.Legend = activityLegendOf(repo, axis, win, v.User, act.Authors)
if len(v.Legend) > activityLegendHead {
v.Legend, v.LegendRest = v.Legend[:activityLegendHead], v.Legend[activityLegendHead:]
}
}
v.ClearURL = activityURL(repo, axis, "", win)
if axis == "tree" {
v.SummaryLabel, v.Summary = "files changed by directory", treeLanes
} else {
v.SummaryLabel, v.Summary = activitySummaryOf(axis, act)
}
loadDays()
if haveDays {
// The days op has its own author list, so the chosen
// person is resolved against it, not against the window's.
sliceUser := -1
for i, a := range days.Authors {
if a.Name == v.User {
sliceUser = i
break
}
}
spans := yearSpans(days)
v.YearsTotal = len(spans)
if q.Get("years") == "all" {
v.AllYears = true
} else if len(spans) > sliceYears {
spans = spans[:sliceYears]
}
svg, years := coreSliceSVG(days, spans, false, sliceUser)
v.Slice, v.Years = template.HTML(svg), years
}
// ?frag=1 is the same page asked for as a fragment: the view
// region alone, as one Datastar patch into #act-view. The
// slice lives in that region too, so a filter click re-cuts
// the year-by-year with the rest (§7).
if q.Get("frag") != "" {
var buf strings.Builder
if err := hooksOf(req.Context()).template(activityPage).ExecuteTemplate(&buf, "act-view", pageData{Repo: repo, Activity: &v}); err != nil {
http.Error(w, "view render failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-store")
fmt.Fprint(w, "event: datastar-patch-elements\ndata: mode inner\ndata: selector #act-view\n")
for _, line := range strings.Split(buf.String(), "\n") {
fmt.Fprintf(w, "data: elements %s\n", line)
}
fmt.Fprint(w, "\n")
return
}
render(r, w, req, activityPage, pageData{
Title: "Activity", Repo: repo, Activity: &v,
})
}
}
// activityLegendOf is the legend under the picture, which is also the
// chooser (§7): everyone in the window, most check-ins first. Everyone
// stays named whoever is chosen — the legend is the door, not a report
// of what survived a filter.
func activityLegendOf(repo, axis, win, user string, authors []activityAuthor) []activityLegend {
n := len(authors)
if n > activityLegendMax {
n = activityLegendMax
}
out := make([]activityLegend, 0, n)
for i, a := range authors[:n] {
out = append(out, activityLegend{
Index: i, Name: a.Name, Checkins: a.Checkins,
URL: activityURL(repo, axis, a.Name, win),
Current: a.Name == user,
})
}
return out
}
// activitySummaryOf is the right-hand marginal as numbers: per hour on
// the clock axis, the busiest people on the people axis. The tree
// axis's comes from its own drawing, files changed by lane.
func activitySummaryOf(axis string, act activityOut) (string, []activitySummary) {
if axis == "people" {
per := make([]int, len(act.Authors))
for _, r := range act.Rows {
if r.Author >= 0 && r.Author < len(per) {
per[r.Author]++
}
}
idx := make([]int, 0, len(per))
for i, n := range per {
if n > 0 {
idx = append(idx, i)
}
}
sort.SliceStable(idx, func(i, j int) bool { return per[idx[i]] > per[idx[j]] })
if len(idx) > peopleTop {
idx = idx[:peopleTop]
}
out := make([]activitySummary, 0, len(idx))
for _, i := range idx {
out = append(out, activitySummary{Label: act.Authors[i].Name, N: per[i]})
}
return "check-ins by person", out
}
var hours [24]int
for _, r := range act.Rows {
if r.Hour >= 0 && r.Hour < 24 {
hours[r.Hour]++
}
}
out := make([]activitySummary, 0, 24)
for h, n := range hours {
out = append(out, activitySummary{Label: fmt.Sprintf("%02d:00", h), N: n})
}
return "check-ins by hour (UTC)", out
}
// Layout of the core sample, in viewBox units.
const (
sampleW = 960
sampleLeft = 12 // the hour ticks / date rail, rules only — no labels
sampleRight = 70 // the per-hour / per-day marginal
sampleTop = 48 // the per-day marginal
sampleBottom = 22 // date labels
sampleHour = 12 // one hour's height on the clock axis
)
// userAttr and colourAttr are the two optional attributes the filter
// adds: which person is singled out, and their colour on a group.
func userAttr(user int) string {
if user < 0 {
return ""
}
return fmt.Sprintf(` data-user="%d"`, user)
}
func maxOf(v []int) int {
m := 0
for _, n := range v {
if n > m {
m = n
}
}
return m
}
// activitySentence is the chart's aria-label: the numbers a sighted
// reader takes from the shape, in one sentence (§10). Under a filter it
// says who is in colour, because that is what the picture now shows.
func activitySentence(act activityOut, user int) string {
lead := ""
if len(act.Authors) > 0 && len(act.Rows) > 0 {
a := act.Authors[0]
lead = fmt.Sprintf("; %s led with %d%%", a.Name, a.Checkins*100/len(act.Rows))
}
who := ""
if user >= 0 && user < len(act.Authors) {
who = fmt.Sprintf("; %s is in colour, everyone else grey", act.Authors[user].Name)
}
return fmt.Sprintf("%d check-ins by %d people, %s to %s%s%s",
len(act.Rows), len(act.Authors), act.Since, act.Until, lead, who)
}
// The slice: one block per span (a calendar year, or the home page's
// last year), laid out the way GitHub taught everyone to read a year —
// a column per week, a row per weekday — so eight years stack on a
// page without scrolling. A cell is the day's leading author's colour
// at the day's volume, with a second-author stripe at 30% share and a
// release notch; the block's right column carries the ownership bar
// and the year's numbers (§7).
const (
sliceCell = 11
sliceStep = 13 // cell plus gap
sliceTop = 16 // month labels above the grid
sliceGap = 14 // between blocks
sliceLabelW = 46
sliceBarW = 150
// Wide enough for the longest numbers line, which now opens with
// the year: "2020 · 12,345 check-ins · 366 active days".
sliceRightW = 262
sliceStripH = 22 // the home page's one-row strip
)
// sliceYears is how many blocks the activity page shows unless asked
// for all of them.
const sliceYears = 8
type sliceSpan struct {
Label string
From string // ISO, inclusive
Days int
}
// yearSpans is every calendar year from the first check-in to the
// last, newest first — the slice reads like a core, top is now.
func yearSpans(d activityDaysOut) []sliceSpan {
first, err1 := time.Parse("2006-01-02", d.First)
last, err2 := time.Parse("2006-01-02", d.Last)
if err1 != nil || err2 != nil || last.Before(first) {
return nil
}
var out []sliceSpan
for y := last.Year(); y >= first.Year(); y-- {
days := 365
if y%4 == 0 && (y%100 != 0 || y%400 == 0) {
days = 366
}
out = append(out, sliceSpan{Label: fmt.Sprint(y), From: fmt.Sprintf("%d-01-01", y), Days: days})
}
return out
}
// lastYearSpan is the home page's block: the 365 days ending at the
// last check-in.
func lastYearSpan(d activityDaysOut) []sliceSpan {
last, err := time.Parse("2006-01-02", d.Last)
if err != nil {
return nil
}
return []sliceSpan{{Label: "", From: last.AddDate(0, 0, -364).Format("2006-01-02"), Days: 365}}
}
// coreSliceSVG draws the slice. With fit true it is the home page's
// strip instead: one row, a cell per day with no gaps, stretched to
// its box — the compact door, not the grid. In the grid every day of
// the span is drawn: a day with no work of its own is an empty cell,
// so the year reads as a whole shape and not as marks floating on the
// page. user is the person singled out, or -1: their days are their
// colour at their own volume, a day that was someone else's a grey
// cell at the day's weight, and an empty day stays empty (§7).
func coreSliceSVG(d activityDaysOut, spans []sliceSpan, fit bool, user int) (string, []activityYear) {
if len(spans) == 0 {
return "", nil
}
var years []activityYear
// Columns are weeks; a span starting late in the week can need 54.
cols := 0
for _, s := range spans {
from, err := time.Parse("2006-01-02", s.From)
if err != nil {
return "", nil
}
if c := (int(from.Weekday())+s.Days-1)/7 + 1; c > cols {
cols = c
}
}
cellsX := float64(sliceLabelW)
if fit {
cellsX = 0
}
gridW := cols*sliceStep - (sliceStep - sliceCell)
gridH := 7*sliceStep - (sliceStep - sliceCell)
blockH := sliceTop + gridH
barX := cellsX + float64(gridW) + 18
w := int(barX) + sliceRightW
h := len(spans)*(blockH+sliceGap) - sliceGap + 2
if fit {
w, h = spans[0].Days*sliceCell, sliceStripH
}
colour := func(i int) string {
if i < 0 || i >= len(d.Authors) {
return "var(--muted)"
}
return activityColour(d.Authors[i])
}
name := func(i int) string {
if i < 0 || i >= len(d.Authors) {
return "?"
}
return d.Authors[i].Name
}
var b strings.Builder
active := 0
for _, s := range spans {
for i := 0; i < s.Days; i++ {
if _, ok := d.Days[dayAfter(s.From, i)]; ok {
active++
}
}
}
// The strip's cells keep the old repository-wide scale — it is one
// row of a door and its look is fixed.
var counts []int
for _, day := range d.Days {
if day.N > 0 {
counts = append(counts, day.N)
}
}
sort.Ints(counts)
p95 := 3
if len(counts) > 0 {
if n := counts[len(counts)*95/100]; n > p95 {
p95 = n
}
}
stripOpacity := func(n int) float64 {
level := math.Log1p(float64(n)) / math.Log1p(float64(p95))
if level > 1 {
level = 1
}
return 0.3 + 0.7*level
}
// In the grid the weight is what the reader is meant to see, so it
// runs the whole way: one check-in is faint at 0.25 and the span's
// own busy days (its 95th percentile) are solid, on a log ramp so
// the middle of a year is not all pinned near the top. Per span,
// because a quiet year measured against a loud one is a grid of
// near-blanks — the numbers beside it carry the comparison.
weigh := func(peak int) func(int) float64 {
if peak < 2 {
peak = 2
}
base, top := math.Log1p(1), math.Log1p(float64(peak))
return func(n int) float64 {
level := (math.Log1p(float64(n)) - base) / (top - base)
if level < 0 {
level = 0
}
if level > 1 {
level = 1
}
return 0.25 + 0.75*level
}
}
plural := func(n int, one, many string) string {
if n == 1 {
return one
}
return many
}
label := fmt.Sprintf("%d active %s across %d %s, %s to %s", active, plural(active, "day", "days"),
len(spans), plural(len(spans), "year", "years"), d.First, d.Last)
size := ""
if fit {
size = ` preserveAspectRatio="none"`
}
fmt.Fprintf(&b, `<svg class="slice"%s viewBox="0 0 %d %d" role="img" aria-label="%s" data-days="%d" data-spans="%d" data-cols="%d">`,
size, w, h, esc(label), active, len(spans), cols)
fmt.Fprintf(&b, `<title>%s</title>`, esc(label))
for si, s := range spans {
top := float64(si*(blockH+sliceGap)) + 1
gy := top + sliceTop
from, _ := time.Parse("2006-01-02", s.From)
dow0 := int(from.Weekday())
// The span's own busy day sets the weight scale: the person's
// high under a filter, the day's otherwise.
var spanCounts []int
for i := 0; i < s.Days; i++ {
day, ok := d.Days[dayAfter(s.From, i)]
if !ok || day.N == 0 {
continue
}
n := day.N
if user >= 0 {
n = 0
for _, t := range day.Top {
if len(t) == 2 && t[0] == user {
n = t[1]
break
}
}
}
if n > 0 {
spanCounts = append(spanCounts, n)
}
}
sort.Ints(spanCounts)
peak := 1
if len(spanCounts) > 0 {
peak = spanCounts[len(spanCounts)*95/100]
}
opacity := weigh(peak)
if fit {
opacity = stripOpacity
}
// Group cells by leading author so the colour is set once.
cells := map[int][]string{}
var notches, empties, others []string
led := map[int]int{}
checkins, merges, days := 0, 0, 0
for i := 0; i < s.Days; i++ {
date := dayAfter(s.From, i)
day, ok := d.Days[date]
cx := cellsX + float64((dow0+i)/7*sliceStep)
cy := gy + float64((dow0+i)%7*sliceStep)
cw, ch := sliceCell, sliceCell
if fit {
cx, cy, ch = float64(i*sliceCell), 0, sliceStripH
}
if tag, rel := d.Releases[date]; rel {
// "release 2.14.2" when the day carries a version,
// plain "release" when it carries only the tag.
tag = strings.TrimPrefix(tag, "version-")
if tag == "release" {
tag = ""
} else {
tag = " " + tag
}
if fit {
notches = append(notches, fmt.Sprintf(`<rect class="rel" x="%.0f" y="%.0f" width="%d" height="4" data-date="%s"><title>%s · release%s</title></rect>`,
cx, cy, cw, esc(date), esc(date), esc(tag)))
} else {
// A marker in the gutter above the grid, over its
// week's column: inside the cell it was a speck
// competing with the weight it sits on.
notches = append(notches, fmt.Sprintf(`<path class="rel" d="M%.0f %.0fL%.0f %.0fL%.0f %.0fZ" data-date="%s"><title>%s · release%s</title></path>`,
cx+sliceCell/2, gy-2, cx+1, gy-7, cx+sliceCell-1, gy-7, esc(date), esc(date), esc(tag)))
}
}
if !ok || day.N == 0 {
// An empty cell, so the grid is a grid. The strip has
// no room for them and shows the days it has.
if !fit {
empties = append(empties, fmt.Sprintf(`<rect class="c empty" x="%.0f" y="%.0f" width="%d" height="%d"/>`,
cx, cy, cw, ch))
}
continue
}
checkins += day.N
merges += day.Merges
days++
lead := -1
if len(day.Top) > 0 && len(day.Top[0]) == 2 {
lead = day.Top[0][0]
}
led[lead]++
title := fmt.Sprintf("%s · %d check-in", date, day.N)
if day.N != 1 {
title += "s"
}
if day.Merges > 0 {
title += fmt.Sprintf(" (%d merge", day.Merges)
if day.Merges != 1 {
title += "s"
}
title += ")"
}
for k, t := range day.Top {
if len(t) != 2 {
continue
}
if k == 0 {
title += " · "
} else {
title += ", "
}
title += fmt.Sprintf("%s %d", name(t[0]), t[1])
}
if day.Path >= 0 && day.Path < len(d.Paths) {
p := d.Paths[day.Path]
if p == "" {
p = "/"
}
title += " · " + p
}
if user >= 0 {
// One person's year: their days in their colour at
// their own weight; a day that was someone else's is
// a grey cell at the day's weight, so the rest of the
// project stays visible behind them and an empty day
// is still the emptiest cell (§7).
n := 0
for _, t := range day.Top {
if len(t) == 2 && t[0] == user {
n = t[1]
break
}
}
if n == 0 {
if !fit {
others = append(others, fmt.Sprintf(`<rect class="c other" x="%.0f" y="%.0f" width="%d" height="%d" fill-opacity="%.2f" data-date="%s"><title>%s</title></rect>`,
cx, cy, cw, ch, 0.6*opacity(day.N), esc(date), esc(title)))
}
continue
}
cells[user] = append(cells[user], fmt.Sprintf(`<rect class="c" x="%.0f" y="%.0f" width="%d" height="%d" fill-opacity="%.2f" data-date="%s"><title>%s</title></rect>`,
cx, cy, cw, ch, opacity(n), esc(date), esc(title)))
continue
}
// Nothing is drawn inside a cell: the whole cell is the
// day's leading person and its weight is the day's volume.
cells[lead] = append(cells[lead], fmt.Sprintf(`<rect class="c" x="%.0f" y="%.0f" width="%d" height="%d" fill-opacity="%.2f" data-date="%s"><title>%s</title></rect>`,
cx, cy, cw, ch, opacity(day.N), esc(date), esc(title)))
}
fmt.Fprintf(&b, `<g class="row" data-span="%s" data-checkins="%d" data-days="%d" data-merges="%d">`, esc(s.Label), checkins, days, merges)
yr := activityYear{Label: s.Label, Checkins: checkins, Days: days, Merges: merges}
// The year says its name in the gutter left of its grid, on every
// block: a filtered person's empty year still needs naming, or the
// reader loses count of where they are in the core.
if s.Label != "" && !fit {
fmt.Fprintf(&b, `<text class="yl" x="0" y="%.0f">%s</text>`, gy+10, esc(s.Label))
}
b.WriteString(strings.Join(empties, ""))
b.WriteString(strings.Join(others, ""))
for _, i := range authorKeys(cells) {
fmt.Fprintf(&b, `<g class="au" data-author="%d" style="--c:%s">%s</g>`, i, colour(i), strings.Join(cells[i], ""))
}
b.WriteString(strings.Join(notches, ""))
// The right column: the year's numbers and who led how many of
// its days.
if days > 0 && !fit {
fmt.Fprintf(&b, `<text class="n" x="%.0f" y="%.0f">%s check-%s · %d active %s</text>`,
barX, gy+10, commas(checkins), plural(checkins, "in", "ins"), days, plural(days, "day", "days"))
fmt.Fprintf(&b, `<g class="own" data-led="%d">`, len(led))
x := barX
for _, i := range sortedByCount(led) {
bw := float64(sliceBarW) * float64(led[i]) / float64(days)
seg, c := "au", colour(i)
if user >= 0 && i != user {
seg, c = "au ghost", "var(--slice-empty)"
}
fmt.Fprintf(&b, `<rect class="%s" data-author="%d" style="--c:%s" x="%.1f" y="%.0f" width="%.1f" height="12"><title>%s led %d of %d days</title></rect>`,
seg, i, c, x, gy+17, bw, esc(name(i)), led[i], days)
x += bw
}
b.WriteString(`</g>`)
leads := sortedByCount(led)
yr.Lead, yr.LeadPct = name(leads[0]), led[leads[0]]*100/days
mine := ""
if user >= 0 && user != leads[0] && led[user] > 0 {
mine = fmt.Sprintf(" · %s %d%%", esc(name(user)), led[user]*100/days)
}
fmt.Fprintf(&b, `<text class="m" x="%.0f" y="%.0f">%s led %d%% of days%s</text>`, barX, gy+45, esc(yr.Lead), yr.LeadPct, mine)
fmt.Fprintf(&b, `<text class="m" x="%.0f" y="%.0f">%s %s · %d%%</text>`, barX, gy+61, commas(merges), plural(merges, "merge", "merges"), merges*100/max(checkins, 1))
}
years = append(years, yr)
b.WriteString(`</g>`)
}
b.WriteString(`</svg>`)
return b.String(), years
}
// commas writes 12345 as 12,345.
func commas(n int) string {
s := strconv.Itoa(n)
for i := len(s) - 3; i > 0; i -= 3 {
s = s[:i] + "," + s[i:]
}
return s
}
func authorKeys(m map[int][]string) []int {
out := make([]int, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Ints(out)
return out
}
func sortedByCount(m map[int]int) []int {
out := make([]int, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Slice(out, func(i, j int) bool {
if m[out[i]] != m[out[j]] {
return m[out[i]] > m[out[j]]
}
return out[i] < out[j]
})
return out
}
// sliceHoverAttr is the slice's hover readout, as Datastar attributes
// on the container: pointerover reads the cell's (or release marker's)
// own <title> — the no-script hover and the screen reader's text — into
// a signal that the "slice-tip" card shows beside the pointer. Nothing
// is duplicated per cell, so the SVG costs the same bytes with or
// without it. The card is positioned in the container's own box, so the
// container must not be the strip's scroller — a card hanging out of a
// scroll box scrolls the strip away vertically.
//
// The first hover moves the <title> into data-tip and drops it: the
// browser's own tooltip only appears after a delay, so a title gone by
// then never shows, and the card is not doubled by it. Without script
// the title stays, and a morphed-in cell gets the same treatment when
// it is first hovered.
const sliceHoverAttr template.HTMLAttr = `data-signals="{sliceTip: '', sliceX: 0, sliceY: 0}" ` +
`data-on:pointerover="var c = evt.target.closest('rect,path'); var t = c && c.firstElementChild; if (t && t.tagName === 'title') { c.dataset.tip = t.textContent; t.remove() } $sliceTip = c && c.dataset.tip || ''" ` +
`data-on:pointermove="$sliceX = evt.clientX - el.getBoundingClientRect().left; $sliceY = evt.clientY - el.getBoundingClientRect().top" ` +
`data-on:pointerout="$sliceTip = ''"`
// peopleHoverAttr is the same readout for the people axis, on its own
// signals so the year-by-year strip below keeps its card: the marks are
// circles, and each carries a <title> the first hover lifts into data-tip.
const peopleHoverAttr template.HTMLAttr = `data-signals="{peopleTip: '', peopleX: 0, peopleY: 0}" ` +
`data-on:pointerover="var c = evt.target.closest('circle'); var t = c && c.firstElementChild; if (t && t.tagName === 'title') { c.dataset.tip = t.textContent; t.remove() } $peopleTip = c && c.dataset.tip || ''" ` +
`data-on:pointermove="$peopleX = evt.clientX - el.getBoundingClientRect().left; $peopleY = evt.clientY - el.getBoundingClientRect().top" ` +
`data-on:pointerout="$peopleTip = ''"`
// clockHoverAttr is the readout for the clock, which differs from the
// others in two ways. The hover card names the mark's timeline door as
// its href. And on a coarse pointer a tap does not follow the mark's
// link: it picks the nearest mark within a fingertip (so there is no
// extra hit element in the markup), shows its card, and the card is the
// link. A fine pointer keeps the plain hover and the plain link.
const clockHoverAttr template.HTMLAttr = `data-signals="{peopleTip: '', peopleHref: '', peopleX: 0, peopleY: 0}" ` +
`data-on:pointerover="var c = evt.target.closest('circle'); var t = c && c.firstElementChild; if (t && t.tagName === 'title') { c.dataset.tip = t.textContent; t.remove() } if (evt.pointerType !== 'touch') { $peopleTip = c && c.dataset.tip || ''; var a = c && c.closest('a'); $peopleHref = a && a.getAttribute('href') || '' }" ` +
`data-on:pointermove="$peopleX = evt.clientX - el.getBoundingClientRect().left; $peopleY = evt.clientY - el.getBoundingClientRect().top" ` +
`data-on:pointerout="if (evt.pointerType !== 'touch') { $peopleTip = '' }" ` +
`data-on:click="if (matchMedia('(pointer: coarse)').matches && !evt.target.closest('.act-tip')) { evt.preventDefault(); var c = null, bd = 784; el.querySelectorAll('circle[data-b]').forEach(function (m) { var b = m.getBoundingClientRect(); if (!b.width) { return } var dx = b.left + b.width / 2 - evt.clientX, dy = b.top + b.height / 2 - evt.clientY, d = dx * dx + dy * dy; if (d < bd) { bd = d; c = m } }); if (c) { var t = c.firstElementChild; if (t && t.tagName === 'title') { c.dataset.tip = t.textContent; t.remove() } var a = c.closest('a'); $peopleHref = a && a.getAttribute('href') || ''; var r = el.getBoundingClientRect(); $peopleX = evt.clientX - r.left; $peopleY = evt.clientY - r.top } $peopleTip = c ? c.dataset.tip : '' }"`
// homeSlice is the repository home's door: the last year as one row.
func homeSlice(r *ops.Registry, req *http.Request, repo string) template.HTML {
out, err := invoke(req.Context(), r, "repo.activity.days", url.Values{"repo": {repo}})
if err != nil {
return ""
}
var days activityDaysOut
remarshal(out, &days)
svg, _ := coreSliceSVG(days, lastYearSpan(days), true, -1)
return template.HTML(svg)
}
var activityPage = page(template.Must(template.Must(base.Clone()).Parse(`{{define "act-legend-li"}}<li><a href="{{.URL}}" data-author="{{.Index}}"{{if .Current}} aria-current="true"{{end}} {{morph .URL}}>{{identicon .Name}} {{.Name}} <span class="act-n" title="check-ins">{{count .Checkins}}</span></a></li>
{{end}}{{define "act-legend-li-wide"}}<li class="act-wide"><a href="{{.URL}}" data-author="{{.Index}}"{{if .Current}} aria-current="true"{{end}} {{morph .URL}}>{{identicon .Name}} {{.Name}} <span class="act-n" title="check-ins">{{count .Checkins}}</span></a></li>
{{end}}{{define "act-view"}}{{with .Activity}}
{{- /* Three controls and no more (§7): which axis, which window, and
the reset-zoom button the canvas adds inside .act-sample. There
is no branch chooser (branch is always all) and no merges toggle
(inherited touches never drew). The person is not a control here
either — the panel beside the graph is the chooser.
Every link morphs #act-view in place (morphAttr): the href is the
cold-loadable page, pushState puts it in the address bar. */ -}}
<div class="act-controls">
<nav class="seg-box" aria-label="axis">
<a href="{{activityURL $.Repo "clock" .User .Win}}"{{if eq .Axis "clock"}} class="here" aria-current="true"{{end}} {{morph (activityURL $.Repo "clock" .User .Win)}}>clock</a>
<a href="{{activityURL $.Repo "tree" .User .Win}}"{{if eq .Axis "tree"}} class="here" aria-current="true"{{end}} {{morph (activityURL $.Repo "tree" .User .Win)}}>tree</a>
<a href="{{activityURL $.Repo "people" .User .Win}}"{{if eq .Axis "people"}} class="here" aria-current="true"{{end}} {{morph (activityURL $.Repo "people" .User .Win)}}>people</a>
</nav>
<nav class="seg-box act-win" aria-label="window">
<a href="{{activityURL $.Repo .Axis .User ""}}"{{if eq .Win "recent"}} class="here" aria-current="true"{{end}} {{morph (activityURL $.Repo .Axis .User "")}}>recent</a>
<a href="{{activityURL $.Repo .Axis .User "all"}}"{{if eq .Win "all"}} class="here" aria-current="true"{{end}} {{morph (activityURL $.Repo .Axis .User "all")}}>all</a>
</nav>
</div>
<p class="act-window" data-window="{{.Window}}"{{if .Grain}} data-grain="{{.Grain}}"{{end}}>{{.Since}} to {{.Until}} · {{count .Rows}} {{plural .Rows "check-in" "check-ins"}}{{if eq .Axis "tree"}} · {{count .Touches}} {{plural .Touches "file change" "file changes"}}{{end}}</p>
{{- /* The hover card reads a mark's own title. On the clock a tap shows it
and the card itself is the way to the timeline, so a tap never
navigates before the reader has seen what the mark is. */ -}}
{{if eq .Axis "clock"}}<div class="act-strata" {{clockHover}}><div class="act-sample">{{.Sample}}</div><a class="act-tip slice-tip people-tip" role="status" aria-live="polite" data-text="$peopleTip" data-attr:href="$peopleHref" data-style:left="($peopleX + 14) + 'px'" data-style:top="($peopleY + 14) + 'px'"></a></div>
{{else}}<div class="act-strata" {{peopleHover}}><div class="act-sample">{{.Sample}}</div><div class="act-tip slice-tip people-tip" role="status" aria-live="polite" data-text="$peopleTip" data-style:left="($peopleX + 14) + 'px'" data-style:top="($peopleY + 14) + 'px'"></div></div>
{{end}}{{.Ranks}}
{{- /* How to read the picture, folded: the drawn key and one sentence. A
wide screen shows it open and no summary (the stylesheet forces the
content visible), a phone shows the summary alone. */ -}}
<details class="act-help"><summary>How to read this</summary>
{{.Key}}
{{- /* The size code, said once. A dot whose area means something and a
page that never says what is a chart the reader has to guess at,
and most will read it as decoration. */ -}}
{{if eq .Axis "clock"}}<p class="act-key">time runs across in {{if eq .Grain "2day"}}two-day{{else if eq .Grain "week"}}weekly{{else if eq .Grain "2week"}}fortnightly{{else}}monthly{{end}} buckets and the hour of the day (UTC) runs down; a mark is one hour of one bucket and its area is the files changed by the check-ins in it, one scale for the whole picture, a merge drawing smallest; a mark is in colour when one of the five busiest people, by rank, made more than half of it, and an outline otherwise; a hollow mark with a second ring is off the scale; each mark opens the timeline at the end of its bucket</p>{{end}}
{{if eq .Axis "tree"}}<p class="act-key">a strip per directory, the busiest opened into their subdirectories; time runs across in {{if eq .Grain "2day"}}two-day{{else if eq .Grain "week"}}weekly{{else if eq .Grain "2week"}}fortnightly{{else}}monthly{{end}} buckets; a mark is one person's work in a strip in a bucket and its area is the files they changed there, one scale for the whole picture; within a bucket the largest mark sits on the line and smaller ones step outward; colour here is the five busiest people by rank, named at the right edge, and everyone else is outlined, the names below keeping each person's own avatar; the six largest in a bucket are drawn and the rest fold into a dashed ring; a hollow mark with a second ring is off the scale</p>{{end}}
{{- /* The legend is the chooser: everyone in the window, the chosen one
marked and still in colour while the rest of the dots grey with
the plot, and a way back to everyone. */ -}}
{{if eq .Axis "people"}}<p class="act-key">a card per person, the twelve busiest and everyone else together; a row is a weekday and a column an hour (UTC); a mark is a slot with check-ins and its area is the files changed there, one scale for every card; a ring is a weekend, a double ring is off the scale, the thin ring is the busiest slot{{if .User}} · <a class="act-clear" href="{{.ClearURL}}" {{morph .ClearURL}}>everyone</a>{{end}}</p>{{end}}
</details>
{{- /* The legend: everyone in one run on a wide screen; on a narrow one the
first people, and the rest folded behind a count (the people past the
first few are listed twice, once for each, and the stylesheet shows
one). */ -}}
{{if .Legend}}<div class="act-legend-wrap"><ol class="act-legend"{{if ge .UserIdx 0}} data-user="{{.UserIdx}}"{{end}}>
{{range .Legend}}{{template "act-legend-li" .}}{{end}}{{range .LegendRest}}{{template "act-legend-li-wide" .}}{{end}}{{if .User}}<li><a class="act-clear" href="{{.ClearURL}}" {{morph .ClearURL}}>everyone</a></li>
{{end}}</ol>
{{if .LegendRest}}<details class="act-more"><summary>{{len .LegendRest}} more</summary><ol class="act-legend act-rest"{{if ge .UserIdx 0}} data-user="{{.UserIdx}}"{{end}}>
{{range .LegendRest}}{{template "act-legend-li" .}}{{end}}</ol></details>{{end}}</div>{{end}}
{{if .Summary}}<table class="sr-only" data-summary="{{.Axis}}"><caption>{{.SummaryLabel}}</caption>
{{range .Summary}}<tr><th scope="row">{{.Label}}</th><td>{{.N}}</td></tr>{{end}}</table>{{end}}
{{if .Slice}}<h2 class="act-h">Year by year</h2>
<div class="act-strata" {{sliceHover}}><div class="act-slice">{{.Slice}}</div>{{template "slice-tip"}}</div>
<p class="act-key">a column per week, a row per weekday; every day is a cell, the colour is the day's leading person and the weight is how much happened, the marker above a release{{if gt .YearsTotal (len .Years)}} · <a class="act-years" href="{{activityURL $.Repo .Axis .User .Win}}{{if or .User (ne .Axis "clock") (eq .Win "all")}}&{{else}}?{{end}}years=all">all {{.YearsTotal}} years</a>{{else if .AllYears}} · <a class="act-years" href="{{activityURL $.Repo .Axis .User .Win}}">recent years</a>{{end}}</p>
<table class="sr-only" data-summary="years"><caption>per year</caption><tr><th>year</th><th>check-ins</th><th>active days</th><th>merges</th><th>led most days</th></tr>
{{range .Years}}<tr><th scope="row">{{.Label}}</th><td>{{.Checkins}}</td><td>{{.Days}}</td><td>{{.Merges}}</td><td>{{.Lead}}{{if .Lead}} {{.LeadPct}}%{{end}}</td></tr>{{end}}</table>{{end}}
{{end}}{{end}}
{{define "content"}}
{{with .Activity}}
<div id="act-view" data-on:popstate__window="@get(location.pathname + location.search + (location.search ? '&' : '?') + 'frag=1')">{{template "act-view" $}}</div>
{{end}}
{{end}}`)))