detached 59d5a0b

heartbeat.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"encoding/binary"
7	"math"
8	"strconv"
9	"strings"
10	"sync"
11)
12
13// Heartbeat is the activity curve of a home row, drawn on a box of
14// heartbeatW by heartbeatH that the page scales to its width.
15type Heartbeat struct {
16	// Line is the curve, Area the same closed down to the bottom edge.
17	Line, Area string
18	// X and Y are where the curve ends: the pulse of today.
19	X, Y string
20	// GradID names the fill gradient of this row (ids are page-wide).
21	GradID string
22	// Label tells the curve in words.
23	Label string
24	// Live is set when the last week had commits: the pulse beats.
25	Live bool
26}
27
28const (
29	heartbeatW = 600.0
30	heartbeatH = 64.0
31	// heartbeatPad keeps the stroke and the dot inside the box; the right
32	// end stops before the edge so that the beating ring fits.
33	heartbeatPad  = 6.0
34	heartbeatEndX = heartbeatW - 10
35)
36
37// heartbeatOf draws the commits per day, oldest first. The days are smoothed
38// over about a week so that the curve breathes instead of spiking, then
39// scaled by the square root so that one big day does not flatten the rest.
40// nil for no data.
41// partial says the days count only some of the commits (heartbeatLabel).
42func heartbeatOf(id string, days []int, partial bool) *Heartbeat {
43	n := len(days)
44	if n < 2 {
45		return nil
46	}
47	total, lastWeek := 0, 0
48	for i, d := range days {
49		total += d
50		if i >= n-7 {
51			lastWeek += d
52		}
53	}
54	top, bottom := heartbeatPad, heartbeatH-heartbeatPad
55	var pts [][2]float64
56	if total == 0 {
57		// Asleep: a straight line on the base, two points.
58		pts = [][2]float64{{0, bottom}, {heartbeatEndX, bottom}}
59	} else {
60		smooth := gaussian(days, 2.2)
61		peak := 0.0
62		for _, v := range smooth {
63			peak = max(peak, math.Sqrt(v))
64		}
65		// One point every heartbeatStep days, and today: the smoothing
66		// leaves nothing finer to draw, and the row goes out on every live
67		// update.
68		for i := 0; i < n; i += heartbeatStep {
69			pts = append(pts, heartbeatPoint(i, n, smooth[i], peak, top, bottom))
70		}
71		if (n-1)%heartbeatStep != 0 {
72			pts = append(pts, heartbeatPoint(n-1, n, smooth[n-1], peak, top, bottom))
73		}
74	}
75	line := curve(pts)
76	end := pts[len(pts)-1]
77	return &Heartbeat{
78		Line:   line,
79		Area:   line + " L" + num(heartbeatEndX) + " " + num(heartbeatH) + " L0 " + num(heartbeatH) + " Z",
80		X:      num(end[0]),
81		Y:      num(end[1]),
82		GradID: "hb-" + id,
83		Label:  heartbeatLabel(total, n, partial),
84		Live:   lastWeek > 0,
85	}
86}
87
88// heartbeatStep is the number of days between two points of the curve.
89const heartbeatStep = 2
90
91func heartbeatPoint(i, n int, v, peak, top, bottom float64) [2]float64 {
92	return [2]float64{float64(i) / float64(n-1) * heartbeatEndX, bottom - math.Sqrt(v)/peak*(bottom-top)}
93}
94
95func heartbeatLabel(total, days int, partial bool) string {
96	count := plural(total, "commit", "commits")
97	if partial {
98		count = "at least " + count
99	}
100	return "Activity: " + count + " in the last " + itoa(days) + " days"
101}
102
103// pulseCache keeps the curves already drawn: the figures of a row stay the
104// same for minutes (the stats cache of Browse) while the home page and its
105// live updates render the row on every request. Keyed by the repository and
106// its days, so a new count draws a new curve; bounded, cleared when full.
107type pulseCache struct {
108	mu sync.Mutex
109	m  map[pulseKey]*Heartbeat
110}
111
112type pulseKey struct {
113	id      string
114	days    string // the counts, packed
115	partial bool
116}
117
118// maxPulses bounds pulseCache; a few per repository at most are live.
119const maxPulses = 4096
120
121// get is heartbeatOf, from the cache when it was drawn already. The
122// Heartbeat returned is shared: callers must not change it.
123func (c *pulseCache) get(id string, days []int, partial bool) *Heartbeat {
124	if len(days) < 2 {
125		return nil
126	}
127	packed := make([]byte, 0, len(days)*2)
128	for _, d := range days {
129		packed = binary.AppendUvarint(packed, uint64(max(d, 0)))
130	}
131	key := pulseKey{id: id, days: string(packed), partial: partial}
132	c.mu.Lock()
133	h, ok := c.m[key]
134	c.mu.Unlock()
135	if ok {
136		return h
137	}
138	h = heartbeatOf(id, days, partial)
139	c.mu.Lock()
140	if c.m == nil || len(c.m) >= maxPulses {
141		c.m = make(map[pulseKey]*Heartbeat)
142	}
143	c.m[key] = h
144	c.mu.Unlock()
145	return h
146}
147
148// gaussian blurs the values with a gaussian of the given width, the edges
149// clamped: a quiet start or end does not drag the curve to zero.
150func gaussian(days []int, sigma float64) []float64 {
151	r := int(math.Ceil(3 * sigma))
152	k := make([]float64, 2*r+1)
153	for i := range k {
154		d := float64(i - r)
155		k[i] = math.Exp(-d * d / (2 * sigma * sigma))
156	}
157	out := make([]float64, len(days))
158	for i := range days {
159		sum, w := 0.0, 0.0
160		for j, kj := range k {
161			at := min(max(i+j-r, 0), len(days)-1)
162			sum += kj * float64(days[at])
163			w += kj
164		}
165		out[i] = sum / w
166	}
167	return out
168}
169
170// curve is a smooth path through the points: a Catmull-Rom spline as cubic
171// Béziers, its handles clamped to the box so that it never overshoots below
172// the base line.
173func curve(p [][2]float64) string {
174	var b strings.Builder
175	b.WriteString("M" + num(p[0][0]) + " " + num(p[0][1]))
176	clampY := func(y float64) float64 { return min(max(y, heartbeatPad/2), heartbeatH-heartbeatPad) }
177	for i := 0; i < len(p)-1; i++ {
178		p0 := p[max(i-1, 0)]
179		p1, p2 := p[i], p[i+1]
180		p3 := p[min(i+2, len(p)-1)]
181		c1x, c1y := p1[0]+(p2[0]-p0[0])/6, clampY(p1[1]+(p2[1]-p0[1])/6)
182		c2x, c2y := p2[0]-(p3[0]-p1[0])/6, clampY(p2[1]-(p3[1]-p1[1])/6)
183		b.WriteString(" C" + num(c1x) + " " + num(c1y) + " " + num(c2x) + " " + num(c2y) + " " + num(p2[0]) + " " + num(p2[1]))
184	}
185	return b.String()
186}
187
188// num writes a coordinate with one decimal at most.
189func num(f float64) string {
190	return strconv.FormatFloat(math.Round(f*10)/10, 'f', -1, 64)
191}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close