Files
MujicaUI/burndown_chart.go
T

217 lines
6.7 KiB
Go

package mujica
import (
"fmt"
"math"
"slices"
"strconv"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// BurndownChartOptions configure a BurndownChart.
type BurndownChartOptions struct {
Frame ChartFrame
// Start and End are the sprint's first and last days.
Start, End Date
// Total starts the ideal line, which reaches zero.
Total float64
// Format writes amounts; nil uses up to two decimals.
Format func(v float64) string
// Version changes when remaining work changes in place.
Version int
}
// BurndownChart compares remaining work with the ideal line.
func BurndownChart(c *ui.Context, remaining []float64, opts BurndownChartOptions) ChartResult {
datetimeCheckDate("BurndownChart Start", opts.Start)
datetimeCheckDate("BurndownChart End", opts.End)
if opts.Start.IsZero() || opts.End.Compare(opts.Start) <= 0 || !(opts.Total > 0) || math.IsInf(opts.Total, 0) {
panic(fmt.Sprintf("mujica: BurndownChart needs Start < End and a positive Total, got %v..%v %v", opts.Start, opts.End, opts.Total))
}
days := int((opts.End.civil().Unix()-opts.Start.civil().Unix())/86400) + 1
if len(remaining) > days {
panic(fmt.Sprintf("mujica: BurndownChart has %d values for %d days", len(remaining), days))
}
format := opts.Format
if format == nil {
format = func(v float64) string {
return strings.TrimRight(strings.TrimRight(strconv.FormatFloat(v, 'f', 2, 64), "0"), ".")
}
}
ch := newChart(c, opts.Frame)
summary := chartMemo(ch, "burndown-domain", struct {
data string
total float64
version int
}{chartID(remaining), opts.Total, opts.Version}, func() float64 {
hi := opts.Total
for _, v := range remaining {
if v < 0 || math.IsInf(v, 0) {
panic("mujica: BurndownChart amounts must be non-negative and finite or NaN")
}
if !math.IsNaN(v) {
hi = max(hi, v)
}
}
return hi
})
cache := ui.Local(ch.res.Element, "burndown-samples", func() burndownCache { return burndownCache{} })
k := Tokens(c)
pal := internal.Palette(k)
size := FontSize(c, theme.CaptionSize)
ideal := func(i int) float64 { return opts.Total * (1 - float64(i)/float64(days-1)) }
actual := func(i int) float64 {
if i < len(remaining) {
return remaining[i]
}
return math.NaN()
}
lo, top, step := internal.Nice(0, summary, 4)
ticks := internal.Ticks(lo, top, step)
keys := []chartKey{{Name: msg(c, "burndown_chart.actual"), Color: pal[0], Shape: 0}, {Name: msg(c, "burndown_chart.ideal"), Color: pal[5], Shape: 1}}
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
plot := chartInset(r, 48, 26)
return plot, internal.Linear{D0: 0, D1: float64(days - 1), R0: float64(plot.X), R1: float64(plot.X + plot.W)},
internal.Linear{D0: lo, D1: top, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
}
n := days
if len(remaining) == 0 {
n = 0
}
spec := chartSpec{n: n, keys: keys, toggle: true}
spec.pick = func(r ui.Rect, x, y float32) int {
plot, xs, _ := geom(r)
if x < plot.X-8 || x > plot.X+plot.W+8 {
return -1
}
return max(0, min(days-1, int(math.Round(xs.Invert(x)))))
}
spec.readout = func(i int) string {
day := opts.Start.AddDays(i)
parts := datetimeMonthName(c, day.Month) + " " + strconv.Itoa(day.Day)
if v := actual(i); !math.IsNaN(v) && !ch.hidden(keys[0].Name) {
parts += " · " + keys[0].Name + " " + format(v)
}
if !ch.hidden(keys[1].Name) {
parts += " · " + keys[1].Name + " " + format(math.Round(ideal(i)*100)/100)
}
return parts
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, xs, ys := geom(r)
internal.AxisY(p, plot, ys, ticks, func(v float64) string { return internal.FormatTick(v, step) }, size, k)
var tx []float32
var tl []string
stride := max(1, int(math.Ceil(float64(days)/max(float64(plot.W)/70, 1))))
for i := 0; i < days; i += stride {
tx = append(tx, xs.Map(float64(i)))
tl = append(tl, strconv.Itoa(opts.Start.AddDays(i).Day))
}
internal.AxisX(p, plot, tx, tl, size, k)
pt := func(i int, v float64) (float32, float32) {
return internal.Snap(p, xs.Map(float64(i))), internal.Snap(p, ys.Map(v))
}
if !ch.hidden(keys[1].Name) {
x0, y0 := pt(0, ideal(0))
x1, y1 := pt(days-1, 0)
burndownDashed(p, x0, y0, x1, y1, keys[1].Color)
}
if !ch.hidden(keys[0].Name) {
var line ui.Path
open := false
width := max(1, int(plot.W*p.Scale()))
data := chartID(remaining)
if cache.data != data || cache.version != opts.Version || cache.days != days || cache.width != width {
cache.data, cache.version, cache.days, cache.width = data, opts.Version, days, width
cache.points = burndownSamples(remaining, days, width)
}
lastRun := -1
for _, point := range cache.points {
x, y := pt(point.index, remaining[point.index])
if open && lastRun == point.run {
line.LineTo(x, y)
} else {
line.MoveTo(x, y)
open = true
}
lastRun = point.run
internal.Marker(p, 0, x, y, 7, keys[0].Color)
}
p.StrokePath(&line, 2, keys[0].Color)
}
if active >= 0 {
ax, _ := pt(active, 0)
p.Line(ax, plot.Y, ax, plot.Y+plot.H, 1, k.TextMuted)
}
}
res, _ := ch.build(spec)
return res
}
// burndownDashed draws the ideal line with a distinct pattern.
func burndownDashed(p *ui.Painter, x0, y0, x1, y1 float32, col ui.Color) {
l := float32(math.Hypot(float64(x1-x0), float64(y1-y0)))
var path ui.Path
for t := float32(0); t < l; t += 10 {
e := min(t+6, l)
path.MoveTo(internal.Snap(p, x0+(x1-x0)*t/l), internal.Snap(p, y0+(y1-y0)*t/l))
path.LineTo(internal.Snap(p, x0+(x1-x0)*e/l), internal.Snap(p, y0+(y1-y0)*e/l))
}
p.StrokePath(&path, 1.5, col)
}
type burndownPoint struct{ index, run int }
type burndownCache struct {
data string
version, days, width int
points []burndownPoint
}
// burndownSamples keeps four finite points per device pixel.
func burndownSamples(values []float64, days, width int) []burndownPoint {
var out []burndownPoint
run := 0
for i := 0; i < len(values); {
bucket := i * width / max(days-1, 1)
var first, low, high, last burndownPoint
count := 0
for i < len(values) && i*width/max(days-1, 1) == bucket {
v := values[i]
if math.IsNaN(v) {
run++
i++
continue
}
point := burndownPoint{index: i, run: run}
if count == 0 {
first, low, high = point, point, point
}
if v < values[low.index] {
low = point
}
if v > values[high.index] {
high = point
}
last = point
count++
i++
}
if count == 0 {
continue
}
points := []burndownPoint{first, low, high, last}
slices.SortFunc(points, func(a, b burndownPoint) int { return a.index - b.index })
for j, point := range points {
if j == 0 || points[j-1].index != point.index {
out = append(out, point)
}
}
}
return out
}