217 lines
6.7 KiB
Go
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
|
|
}
|