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" or "tree"
// 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"
// Bucket is the day width the SVG drew at. Over the byte budget
// the picture is drawn coarser, not shorter: the bucket doubles
// and marks collapse into one per (bucket, row, person) cell (§7).
// One is the normal case.
Bucket int
Rows int
Touches int // authored touches — the dots on the tree axis
Sample template.HTML
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
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
}
// activityByteBudget is the size a sample SVG must stay under; past it
// the picture is drawn at a coarser day resolution and says so (§7). A
// variable so a test can force the coarsening without a 25,000-touch
// fixture.
var activityByteBudget = 1 << 20
const (
// activityBucketMax bounds the coarsening: past it the page ships
// over budget, which is still bounded by days/32 x rows x people.
activityBucketMax = 32
// The bytes one mark costs, MEASURED, so the day width can be chosen
// before drawing rather than found by drawing. Per axis, because the
// two draw different things, and the difference is not the one you
// would guess: a clock mark at bucket 1 is a link wrapped round a
// dot and carries a check-in hash, so coarsening makes it SMALLER,
// while a tree mark gains a count and a sized rect when it
// coarsens, so it gets BIGGER. Assuming one number for both is what
// made the first attempt at this pick a width that did not fit.
//
// Each carries a little headroom over what was measured, because a
// width that does not fit costs a whole extra render of everything,
// while one step too coarse costs some resolution on a picture that
// is already coarse. TestMarkCostsMatchTheDrawing keeps them honest
// as the markup changes.
sampleClockFine = 210 // measured 198
sampleClockCoarse = 140 // measured 128
sampleTreeFine = 132 // measured 120
sampleTreeCoarse = 158 // measured 143
// What the frame costs before a single mark: axes, bands, labels.
sampleFixedBytes = 1200
// activityLegendMax is how many people the legend names.
activityLegendMax = 24
)
// 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" {
fail(r, w, req, fmt.Errorf("axis must be clock or tree: %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, Bucket: 1}
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
}
}
// Over the budget the picture is coarsened, not shortened: the
// day bucket doubles until the SVG fits (§7). The width is
// CHOSEN from the data first, so the common case renders once
// — the loop stays as the guarantee, not as the search.
v.Bucket = sampleBucket(axis, act, activityByteBudget)
var sample string
for {
sample = coreSampleSVG(repo, axis, act, v.UserIdx, v.Bucket)
if len(sample) <= activityByteBudget || v.Bucket >= activityBucketMax {
break
}
v.Bucket *= 2
}
v.Sample = template.HTML(sample)
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++
}
}
v.Legend = activityLegendOf(repo, axis, win, v.User, act.Authors)
v.ClearURL = activityURL(repo, axis, "", win)
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 ten most-touched paths on the tree axis.
func activitySummaryOf(axis string, act activityOut) (string, []activitySummary) {
if axis == "clock" {
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
}
counts := make(map[int]int)
for _, t := range act.Touches {
if t.Inherited == 0 {
counts[t.Path]++
}
}
idx := make([]int, 0, len(counts))
for p := range counts {
idx = append(idx, p)
}
sort.Slice(idx, func(i, j int) bool {
if counts[idx[i]] != counts[idx[j]] {
return counts[idx[i]] > counts[idx[j]]
}
return idx[i] < idx[j]
})
if len(idx) > 10 {
idx = idx[:10]
}
out := make([]activitySummary, 0, len(idx))
for _, p := range idx {
if p >= 0 && p < len(act.Paths) {
out = append(out, activitySummary{Label: act.Paths[p], N: counts[p]})
}
}
return "most-changed files", 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
)
// Layout of the tree axis (§7): time runs down the page and files
// across, so the block is the same height whatever the repository —
// one narrow column per path, one row per day. When the window is
// longer than the plot the rows overlap, and that overlap is the
// texture, not a defect.
const (
treeTop = 56 // the per-path bars (48) and a little breath
treeBottom = 14
treePlotH = 520
treeH = treeTop + treePlotH + treeBottom
treeBarH = 48 // the per-path marginal's tallest bar
treeBandMin = 46 // a directory band narrower than this gets no label
treeMonthMax = 900 // past this span the date rail steps by year
treeDense = 120 // past this many paths the dot shrinks with the column
)
// 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 colourAttr(act activityOut, i int) string {
if i < 0 || i >= len(act.Authors) {
return ""
}
return fmt.Sprintf(` style="--c:%s"`, activityColour(act.Authors[i]))
}
func maxOf(v []int) int {
m := 0
for _, n := range v {
if n > m {
m = n
}
}
return m
}
// bucketMid is a day bucket's centre, in days from the window's start,
// clamped to the window's last day.
func bucketMid(span, bucket, i int) float64 {
c := float64(i*bucket) + float64(bucket-1)/2
if c > float64(span-1) {
c = float64(span - 1)
}
return c
}
// bucketLabel names a bucket's days: one date, or the range.
func bucketLabel(since string, span, bucket, i int) string {
from := dayAfter(since, i*bucket)
last := i*bucket + bucket - 1
if last > span-1 {
last = span - 1
}
if last <= i*bucket {
return from
}
return from + " to " + dayAfter(since, last)
}
// activityBuckets are the day widths the sample may be drawn at, in
// the order the coarsening walks them.
var activityBuckets = []int{1, 2, 4, 8, 16, 32}
// sampleBucket is the day width to draw at, chosen from the data rather
// than found by drawing.
//
// The page used to render the WHOLE picture, measure the string, and if
// it was over budget double the bucket and render it again — up to five
// throwaway renders of every check-in and every file touch before the
// one it kept. On a repository the size of sqlite at full history that
// is the page's cost, and four fifths of it is discarded.
//
// The mark count is what drives the bytes, and it is arithmetic: at
// bucket 1 one mark per row (or per touch), and above it one per
// distinct cell. Counting is integer and map work over data already in
// memory — no strings built, nothing thrown away — so all six candidate
// widths are costed in a single pass for less than one render.
//
// The caller still measures what comes back: this picks the starting
// width, it does not replace the budget.
func sampleBucket(axis string, act activityOut, budget int) int {
span := daysSpan(act.Since, act.Until)
// One set per candidate width above 1, filled in the same pass.
type cell struct{ b, y, author int }
seen := make([]map[cell]struct{}, len(activityBuckets))
for i := range activityBuckets {
seen[i] = map[cell]struct{}{}
}
fine := 0 // marks at bucket 1, where every row or touch is its own
add := func(day, y, author int) {
fine++
for i, b := range activityBuckets {
if b == 1 {
continue
}
seen[i][cell{day / b, y, author}] = struct{}{}
}
}
if axis == "tree" {
paths := len(act.Paths)
for _, t := range act.Touches {
if t.Inherited != 0 || t.Author < 0 || t.Path < 0 || t.Path >= paths ||
t.Day < 0 || t.Day >= span {
continue
}
add(t.Day, t.Path, t.Author)
}
} else {
for _, r := range act.Rows {
if r.Author < 0 || r.Day < 0 || r.Day >= span || r.Hour < 0 || r.Hour > 23 {
continue
}
add(r.Day, r.Hour, r.Author)
}
}
fineCost, coarseCost := sampleClockFine, sampleClockCoarse
if axis == "tree" {
fineCost, coarseCost = sampleTreeFine, sampleTreeCoarse
}
room := budget - sampleFixedBytes
for i, b := range activityBuckets {
marks, per := fine, fineCost
if b > 1 {
marks, per = len(seen[i]), coarseCost
}
if marks*per <= room {
return b
}
}
return activityBucketMax
}
// coreSampleSVG draws the window. Dots are grouped by author so the
// colour is set once per person, not once per dot: the group carries
// --c and the stylesheet does the rest. The chosen person's group is
// marked mine and the sheet greys everyone else — the filter singles
// out, it never subtracts (§7). Nothing is written on the plot; the
// hover says what and when (§3). bucket is the day width: past one, the
// marks in a bucket collapse to one per (bucket, row, person), which is
// how a picture over its byte budget is made smaller.
// sampleRadius sizes one clock-axis dot by how many files its check-in
// touched, in four steps on a log scale.
//
// Linear would be useless: a licence re-stamp touching 400 files is not
// four hundred times the work of a one-line fix, and drawn linearly it
// would be the only thing on the chart. Four steps is also the most a
// reader can tell apart at this size — a continuous scale reads as
// noise, and the question a dot answers is "small, medium, large or
// sweep", never "how many exactly", which the tooltip says.
//
// Every radius is three characters wide on purpose. The byte budget
// that picks the day bucket (sampleBucket) is measured per mark, so a
// radius that sometimes rendered wider would quietly shift the
// coarsening threshold and change how much history fits.
func sampleRadius(files int) float64 {
switch {
case files >= 16:
return 4.0
case files >= 5:
return 3.2
case files >= 2:
return 2.5
default:
// Everything left: a one-file check-in, and a MERGE, whose
// authored file count is zero by RFC 0013's rule — its
// filechanges are the absorbed branch's work. A merge still
// happened, so it is drawn; it is drawn smallest because none
// of what it carried is its author's.
return 1.9
}
}
func coreSampleSVG(repo, axis string, act activityOut, user, bucket int) string {
if bucket < 1 {
bucket = 1
}
if axis == "tree" {
return treeSampleSVG(act, user, bucket)
}
span := daysSpan(act.Since, act.Until)
nb := (span + bucket - 1) / bucket
pw := float64(sampleW - sampleLeft - sampleRight)
dx := pw / float64(span)
x := func(day float64) float64 { return sampleLeft + (day+0.5)*dx }
ph := 24 * sampleHour
h := sampleTop + ph + sampleBottom
plotTop := float64(sampleTop)
var b strings.Builder
// Every dot is a link, and an image may not hold focusable things;
// this chart is a group with a name.
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-bucket="%d"%s data-since="%s" data-until="%s" data-window="%s">`,
sampleW, h, esc(activitySentence(act, user)), len(act.Rows), bucket, userAttr(user),
esc(act.Since), esc(act.Until), esc(act.Window))
fmt.Fprintf(&b, `<title>%s</title>`, esc(activitySentence(act, user)))
// The top marginal: the project's days in grey, and under a filter
// the person's own in their colour on top, on the project's scale
// so the two read as a share, not as two unrelated charts.
perDay, minePerDay := make([]int, nb), make([]int, nb)
var perHour, minePerHour [24]int
for _, r := range act.Rows {
if r.Day >= 0 && r.Day < span {
perDay[r.Day/bucket]++
if r.Author == user {
minePerDay[r.Day/bucket]++
}
}
if r.Hour >= 0 && r.Hour < 24 {
perHour[r.Hour]++
if r.Author == user {
minePerHour[r.Hour]++
}
}
}
maxDay := maxOf(perDay)
dayBars := func(counts []int, cls, style string) {
fmt.Fprintf(&b, `<g class="%s" data-marginal="days"%s>`, cls, style)
if maxDay > 0 {
scale := float64(sampleTop-10) / float64(maxDay)
bw := math.Max(1, dx*float64(bucket))
for i, n := range counts {
if n == 0 {
continue
}
bh := float64(n) * scale
fmt.Fprintf(&b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>`,
sampleLeft+float64(i*bucket)*dx, float64(sampleTop-4)-bh, bw, bh)
}
}
b.WriteString(`</g>`)
}
dayBars(perDay, "mx", "")
if user >= 0 {
dayBars(minePerDay, "mx mine", colourAttr(act, user))
}
// The hour ticks down the left edge: rules, not labels.
b.WriteString(`<g class="ax">`)
for hr := 0; hr < 24; hr += 3 {
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f"/>`,
sampleLeft-5, plotTop+float64(hr)*sampleHour, sampleLeft, plotTop+float64(hr)*sampleHour)
}
b.WriteString(`</g>`)
maxH := maxOf(perHour[:])
hourBars := func(counts []int, cls, style string) {
fmt.Fprintf(&b, `<g class="%s" data-marginal="hours"%s>`, cls, style)
if maxH > 0 {
scale := float64(sampleRight-10) / float64(maxH)
for hr, n := range counts {
if n == 0 {
continue
}
fmt.Fprintf(&b, `<rect x="%d" y="%.1f" width="%.1f" height="%d"/>`,
sampleW-sampleRight+4, plotTop+float64(hr)*sampleHour+1, float64(n)*scale, sampleHour-2)
}
}
b.WriteString(`</g>`)
}
hourBars(perHour[:], "my", "")
if user >= 0 {
hourBars(minePerHour[:], "my mine", colourAttr(act, user))
}
// The dots, one group per author. The radius is an attribute, not
// only a style: engines without CSS geometry would draw nothing.
byAuthor := make([][]string, len(act.Authors))
if bucket == 1 {
for _, r := range act.Rows {
if r.Author < 0 || r.Author >= len(byAuthor) {
continue
}
cls := "dot"
if r.Merge != 0 {
cls = "dot merge"
}
title := fmt.Sprintf("%s %s %02d:00", act.Authors[r.Author].Name, dayAfter(act.Since, r.Day), r.Hour)
if r.Merge != 0 {
title += " · merge"
} else if r.Files == 1 {
title += " · 1 file"
} else {
title += fmt.Sprintf(" · %d files", r.Files)
}
if r.Branch != "" {
title += " · " + r.Branch
}
byAuthor[r.Author] = append(byAuthor[r.Author], fmt.Sprintf(
`<a href="/r/%s/checkin/%s"><circle class="%s" cx="%.1f" cy="%.1f" r="%.1f"><title>%s</title></circle></a>`,
url.PathEscape(repo), url.PathEscape(r.Hash), cls, x(float64(r.Day)), plotTop+float64(r.Hour)*sampleHour+sampleHour/2,
sampleRadius(r.Files), esc(title)))
}
} else {
// Coarsened: a cell is a bucket of days at one hour for one
// person, so it is a count, not a check-in, and not a link.
type cell struct{ b, hour, author int }
type tally struct{ checkins, files int }
seen := map[cell]tally{}
var order []cell
for _, r := range act.Rows {
if r.Author < 0 || r.Author >= len(byAuthor) || r.Day < 0 || r.Day >= span || r.Hour < 0 || r.Hour > 23 {
continue
}
c := cell{r.Day / bucket, r.Hour, r.Author}
if _, ok := seen[c]; !ok {
order = append(order, c)
}
t := seen[c]
t.checkins++
t.files += r.Files
seen[c] = t
}
for _, c := range order {
t := seen[c]
// A coarsened cell is several check-ins at once, so it is
// sized by what they touched TOGETHER — the same reading as
// a fine dot, at the scale the bucket forced.
title := fmt.Sprintf("%s %s %02d:00 · %s · %s",
act.Authors[c.author].Name, bucketLabel(act.Since, span, bucket, c.b), c.hour,
plural(t.checkins, "check-in"), plural(t.files, "file"))
byAuthor[c.author] = append(byAuthor[c.author], fmt.Sprintf(
`<circle class="dot" cx="%.1f" cy="%.1f" r="%.1f"><title>%s</title></circle>`,
x(bucketMid(span, bucket, c.b)), plotTop+float64(c.hour)*sampleHour+sampleHour/2,
sampleRadius(t.files), esc(title)))
}
}
writeAuthorGroups(&b, act, byAuthor, user)
b.WriteString(`</svg>`)
return b.String()
}
// treeSampleSVG draws the tree axis: one column per path, one row per
// day with the newest at the top, so the picture's height is fixed and
// only the columns thin out as a repository grows. Only authored
// touches are dots — the inherited ones a merge carried in are shape,
// not anyone's work, and drawing them here would double the bytes for a
// picture the reader can only see with a pointer anyway.
func treeSampleSVG(act activityOut, user, bucket int) string {
span := daysSpan(act.Since, act.Until)
nb := (span + bucket - 1) / bucket
paths := len(act.Paths)
pw := float64(sampleW - sampleLeft - sampleRight)
colW := pw
if paths > 0 {
colW = pw / float64(paths)
}
rowH := float64(treePlotH) / float64(span)
x := func(p int) float64 { return sampleLeft + (float64(p)+0.5)*colW }
y := func(d float64) float64 { return treeTop + (float64(span-1)-d+0.5)*rowH }
// A dot wide enough to see, or as wide as its column allows. Below
// 1.4 a circle is mush; a square of the same size reads crisper
// and costs fewer bytes.
r := 2.4
if paths > treeDense {
r = math.Max(0.9, colW*0.45)
}
mark := func(cx, cy float64, title string) string {
if r < 1.4 {
return fmt.Sprintf(`<rect class="dot" x="%.1f" y="%.1f" width="%.1f" height="%.1f"><title>%s</title></rect>`,
cx-r, cy-r, 2*r, 2*r, title)
}
return fmt.Sprintf(`<circle class="dot" cx="%.1f" cy="%.1f" r="%.1f"><title>%s</title></circle>`,
cx, cy, r, title)
}
var b strings.Builder
fmt.Fprintf(&b, `<svg class="act act-tree" viewBox="0 0 %d %d" role="img" aria-label="%s" data-axis="tree" data-rows="%d" data-paths="%d" data-span="%d" data-bucket="%d"%s data-since="%s" data-until="%s" data-window="%s">`,
sampleW, treeH, esc(activitySentence(act, user)), len(act.Rows), paths, span, bucket, userAttr(user),
esc(act.Since), esc(act.Until), esc(act.Window))
fmt.Fprintf(&b, `<title>%s</title>`, esc(activitySentence(act, user)))
// Directory bands (§5): paths arrive sorted so a directory's files
// are contiguous; a run of five or more is a column of the plot.
// The band is the structure and keeps its name as a data hook; the
// name is not written on the plot (§3).
b.WriteString(`<g class="bands">`)
start, shade := 0, false
for i := 1; i <= paths; i++ {
if i < paths && dirOf(act.Paths[i]) == dirOf(act.Paths[start]) {
continue
}
if n := i - start; n >= 5 {
label := dirOf(act.Paths[start])
if label == "" {
label = "/"
}
x0, w := sampleLeft+float64(start)*colW, float64(n)*colW
fmt.Fprintf(&b, `<g data-dir="%s">`, esc(label))
if shade {
fmt.Fprintf(&b, `<rect class="band" x="%.1f" y="%d" width="%.1f" height="%d"/>`,
x0, treeTop, w, treePlotH)
}
shade = !shade
b.WriteString(`</g>`)
}
start = i
}
b.WriteString(`</g>`)
// The date rail down the left: a rule wherever the month changes,
// or the year on a window too long for months. The rule is the
// structure; the date is in the hover.
b.WriteString(`<g class="rail">`)
if since, err := time.Parse("2006-01-02", act.Since); err == nil {
prev := ""
for d := span - 1; d >= 0; d-- {
t := since.AddDate(0, 0, d)
label := t.Format("2006-01")
if span > treeMonthMax {
label = t.Format("2006")
}
if label == prev {
continue
}
prev = label
yy := y(float64(d))
fmt.Fprintf(&b, `<line x1="%d" y1="%.1f" x2="%d" y2="%.1f"><title>%s</title></line>`,
sampleLeft, yy, sampleW-sampleRight, yy, label)
}
}
b.WriteString(`</g>`)
// Marginals: which files are touched most across the top, how busy
// each day was down the right edge; under a filter the person's own
// counts sit on top in their colour, on the project's scale.
perPath, minePath := make([]int, paths), make([]int, paths)
perDay, mineDay := make([]int, nb), make([]int, nb)
for _, t := range act.Touches {
if t.Inherited != 0 {
continue
}
mine := t.Author == user
if t.Path >= 0 && t.Path < paths {
perPath[t.Path]++
if mine {
minePath[t.Path]++
}
}
if t.Day >= 0 && t.Day < span {
perDay[t.Day/bucket]++
if mine {
mineDay[t.Day/bucket]++
}
}
}
maxP, maxD := maxOf(perPath), maxOf(perDay)
pathBars := func(counts []int, cls, style string) {
fmt.Fprintf(&b, `<g class="%s" data-marginal="paths"%s>`, cls, style)
if maxP > 0 {
scale := float64(treeBarH) / float64(maxP)
bw := math.Max(1, colW)
for p, n := range counts {
if n == 0 {
continue
}
bh := float64(n) * scale
fmt.Fprintf(&b, `<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f"/>`,
sampleLeft+float64(p)*colW, float64(treeBarH)-bh, bw, bh)
}
}
b.WriteString(`</g>`)
}
dayBars := func(counts []int, cls, style string) {
fmt.Fprintf(&b, `<g class="%s" data-marginal="days"%s>`, cls, style)
if maxD > 0 {
scale := float64(sampleRight-10) / float64(maxD)
bh := math.Max(1, rowH*float64(bucket))
for i, n := range counts {
if n == 0 {
continue
}
fmt.Fprintf(&b, `<rect x="%d" y="%.1f" width="%.1f" height="%.1f"/>`,
sampleW-sampleRight+4, y(bucketMid(span, bucket, i))-bh/2, float64(n)*scale, bh)
}
}
b.WriteString(`</g>`)
}
pathBars(perPath, "mx", "")
if user >= 0 {
pathBars(minePath, "mx mine", colourAttr(act, user))
}
dayBars(perDay, "my", "")
if user >= 0 {
dayBars(mineDay, "my mine", colourAttr(act, user))
}
byAuthor := make([][]string, len(act.Authors))
if bucket == 1 {
for _, t := range act.Touches {
if t.Inherited != 0 || t.Author < 0 || t.Author >= len(byAuthor) || t.Path < 0 || t.Path >= paths {
continue
}
byAuthor[t.Author] = append(byAuthor[t.Author], mark(x(t.Path), y(float64(t.Day)), fmt.Sprintf("%s %s %s",
esc(act.Authors[t.Author].Name), dayAfter(act.Since, t.Day), esc(act.Paths[t.Path]))))
}
} else {
type cell struct{ b, path, author int }
seen := map[cell]int{}
var order []cell
for _, t := range act.Touches {
if t.Inherited != 0 || t.Author < 0 || t.Author >= len(byAuthor) ||
t.Path < 0 || t.Path >= paths || t.Day < 0 || t.Day >= span {
continue
}
c := cell{t.Day / bucket, t.Path, t.Author}
if _, ok := seen[c]; !ok {
order = append(order, c)
}
seen[c]++
}
for _, c := range order {
byAuthor[c.author] = append(byAuthor[c.author], mark(x(c.path), y(bucketMid(span, bucket, c.b)),
fmt.Sprintf("%s %s %s · %d changes", esc(act.Authors[c.author].Name),
bucketLabel(act.Since, span, bucket, c.b), esc(act.Paths[c.path]), seen[c])))
}
}
writeAuthorGroups(&b, act, byAuthor, user)
b.WriteString(`</svg>`)
return b.String()
}
// writeAuthorGroups writes the marks one group per author: the colour
// is set once per person, on the group, not once per dot. The chosen
// person's group is marked mine, which is the hook the sheet greys the
// others by — a per-index rule cannot be written in a static sheet.
func writeAuthorGroups(b *strings.Builder, act activityOut, byAuthor [][]string, user int) {
for i, dots := range byAuthor {
if len(dots) == 0 {
continue
}
cls := "au"
if i == user {
cls = "au mine"
}
fmt.Fprintf(b, `<g class="%s" data-author="%d" style="--c:%s">`, cls, i, activityColour(act.Authors[i]))
for _, d := range dots {
b.WriteString(d)
}
b.WriteString(`</g>`)
}
}
func dirOf(path string) string {
if i := strings.LastIndexByte(path, '/'); i >= 0 {
return path[:i]
}
return ""
}
// 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 = ''"`
// 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-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>
</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}}" data-bucket="{{.Bucket}}">{{.Since}} to {{.Until}} · {{count .Rows}} {{plural .Rows "check-in" "check-ins"}}{{if eq .Axis "tree"}} · {{count .Touches}} {{plural .Touches "file change" "file changes"}}{{end}}{{if gt .Bucket 1}} · drawn at {{.Bucket}}-day resolution{{end}}</p>
<div class="act-sample">{{.Sample}}</div>
{{- /* 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. The tree axis draws one mark
per file, where size would mean nothing, so it says nothing. */ -}}
{{if eq .Axis "clock"}}<p class="act-key">colour is who; a dot is one check-in and its size is how many files that check-in touched, in four steps — a merge draws smallest, because what it carried in is the branch's work rather than the merger's</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 .Legend}}<ol class="act-legend"{{if ge .UserIdx 0}} data-user="{{.UserIdx}}"{{end}}>
{{range .Legend}}<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}}{{if .User}}<li><a class="act-clear" href="{{.ClearURL}}" {{morph .ClearURL}}>everyone</a></li>
{{end}}</ol>{{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}}`)))