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}