Files
MujicaUI/sparkline.go
T

171 lines
4.9 KiB
Go

package mujica
import (
"log/slog"
"math"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// SparklineKind is how a Sparkline draws its values.
type SparklineKind int
const (
SparklineLine SparklineKind = iota
SparklineBar
SparklineArea
)
// SparklineOptions configure a Sparkline; Frame.Height 0 means 28 DIPs.
type SparklineOptions struct {
Frame ChartFrame
Kind SparklineKind
// Width is the plot width; 0 means 96 DIPs.
Width float32
Format func(v float64) string
}
// Sparkline is an inline chart without axes.
func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResult {
f := opts.Frame
if f.Label == "" || f.Height < 0 || opts.Width < 0 || opts.Kind > SparklineArea {
panic("mujica: Sparkline needs a Label, sizes ≥ 0 and a known Kind")
}
if f.Height == 0 {
f.Height = 28
}
w := opts.Width
if w == 0 {
w = 96
}
k := Tokens(c)
u := Density(c).Unit()
col := internal.Palette(k)[0]
size := FontSize(c, theme.CaptionSize)
lo, hi := math.Inf(1), math.Inf(-1)
last := -1
for i, v := range values {
if !math.IsNaN(v) {
lo, hi, last = min(lo, v), max(hi, v), i
}
}
res := ChartResult{Point: -1}
res.Element = ui.Row(c).Gap(u * 2).AlignItems(ui.Center)
st := ui.Local(res.Element, "mujica.sparkline", func() chartState { return chartState{focus: -1, last: -1} })
res.Element.Children(func() {
if f.Status == DataLoading || f.Status == DataFailed || last < 0 {
box := ui.Row(c).Size(w, f.Height).AlignItems(ui.Center).Gap(u)
box.Children(func() {
switch f.Status {
case DataLoading:
Spinner(c, SpinnerOptions{Size: 14, Label: msg(c, "list.loading")})
case DataFailed:
ui.Text(c, dataError(c, f.Error)).FontSize(size).TextColor(k.Danger).SingleLine().Grow(1).MinWidth(0)
res.retry = iconAction(c, icons.Must("refresh-cw"), msg(c, "list.retry")).Clicked()
default:
empty := f.Empty
if empty == "" {
empty = msg(c, "chart.empty")
}
ui.Text(c, empty).FontSize(size).TextColor(k.TextMuted).SingleLine()
}
})
st.focus = -1
return
}
if lo == hi {
lo, hi = lo-1, hi+1
}
if opts.Kind == SparklineBar {
lo, hi = min(lo, 0), max(hi, 0)
}
plot := ui.Box(c).Size(w, f.Height).Shrink(0).Focusable().Label(f.Label).Role(ui.RoleGroup)
internal.Ring(plot, k.Focus, theme.ControlRadius)
n := len(values)
x := func(r ui.Rect, i int) float32 { return r.X + 3 + (r.W-6)*float32(i)/float32(max(n-1, 1)) }
ys := func(r ui.Rect) internal.Linear {
return internal.Linear{D0: lo, D1: hi, R0: float64(r.Y + r.H - 3), R1: float64(r.Y + 3)}
}
if !plot.IsDisabled() {
to := st.focus
switch {
case plot.Shortcut(0, ui.KeyLeft):
to = max(st.focus-1, 0)
case plot.Shortcut(0, ui.KeyRight):
to = min(st.focus+1, n-1)
case plot.Shortcut(0, ui.KeyHome):
to = 0
case plot.Shortcut(0, ui.KeyEnd):
to = n - 1
case plot.Shortcut(0, ui.KeyEscape):
to = -1
}
if to != st.focus {
st.focus = to
if to >= 0 {
c.Announce(chartValue(c, values[to], opts.Format))
}
slog.Debug("mujica: sparkline focus", "label", f.Label, "mark", to)
}
}
active := -1
if px, _, over := plot.PointerPosition(); over && !plot.IsDisabled() {
active = max(0, min(n-1, int(math.Round(float64((px-3)/(w-6)*float32(max(n-1, 1)))))))
} else if plot.Focused() {
active = st.focus
}
res.Point = active
plot.Draw(func(p *ui.Painter, r ui.Rect) {
s := ys(r)
if opts.Kind == SparklineBar {
bw := max((r.W-6)/float32(n)-1, 1)
for i, v := range values {
if math.IsNaN(v) {
continue
}
a, b := s.Map(0), s.Map(v)
cx := x(r, i)
p.Fill(ui.Rect{X: cx - bw/2, Y: min(a, b), W: bw, H: max(abs32(a-b), 1)}, col, 0)
}
} else {
chartRuns(values, func(i int) float32 { return x(r, i) }, func(idx []int) {
var line ui.Path
for j, i := range idx {
px, py := internal.Snap(p, x(r, i)), internal.Snap(p, s.Map(values[i]))
if j == 0 {
line.MoveTo(px, py)
} else {
line.LineTo(px, py)
}
}
if opts.Kind == SparklineArea {
fill := line
fill.LineTo(internal.Snap(p, x(r, idx[len(idx)-1])), r.Y+r.H-3).LineTo(internal.Snap(p, x(r, idx[0])), r.Y+r.H-3).Close()
p.FillPath(&fill, col.Alpha(0.22))
}
p.StrokePath(&line, 1.5, col)
})
}
// Diamond: last value. Ring: the value being read.
p.FillPath(internal.Diamond(internal.Snap(p, x(r, last)), internal.Snap(p, s.Map(values[last])), 7), k.Text)
if active >= 0 && !math.IsNaN(values[active]) {
var ring ui.Path
ring.Circle(internal.Snap(p, x(r, active)), internal.Snap(p, s.Map(values[active])), 4)
p.StrokePath(&ring, 1.5, k.Text)
}
})
shown := last
if active >= 0 {
shown = active
}
ui.Text(c, chartValue(c, values[shown], opts.Format)).FontSize(size).TextColor(k.Text).SingleLine().Role(ui.RoleStatus)
})
res.changed = res.Point != st.last
st.last = res.Point
return res
}