Files
MujicaUI/scatter_chart.go
T

161 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mujica
import (
"fmt"
"log/slog"
"math"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// ScatterPoint is one dot; Size sets a bubble's area.
type ScatterPoint struct{ X, Y, Size float64 }
// ScatterGroup is named points sharing color and shape.
type ScatterGroup struct {
Name string
Points []ScatterPoint
}
// ScatterKey names point Point of group Group.
type ScatterKey struct{ Group, Point int }
// ScatterChartOptions configure a ScatterChart.
type ScatterChartOptions struct {
Frame ChartFrame
// Bubbles maps Size to dot area, 4–18 DIPs across.
Bubbles bool
Format func(v float64) string
}
type scatterDrag struct {
on bool
x0, y0, x1, y1 float32
}
// ScatterChart plots groups; box drag, Enter or Space selects.
func ScatterChart(c *ui.Context, selected *[]ScatterKey, groups []ScatterGroup, opts ScatterChartOptions) ChartResult {
ch := newChart(c, opts.Frame)
k := Tokens(c)
pal := internal.Palette(k)
size := FontSize(c, theme.CaptionSize)
keys := make([]chartKey, len(groups))
var marks []ScatterKey
b := [6]float64{math.Inf(1), math.Inf(-1), math.Inf(1), math.Inf(-1), math.Inf(1), math.Inf(-1)}
for gi, g := range groups {
keys[gi] = chartKey{Name: g.Name, Color: pal[gi%len(pal)], Shape: gi}
if g.Name == "" || ch.hidden(g.Name) {
continue
}
for pi, pt := range g.Points {
if math.IsNaN(pt.X) || math.IsNaN(pt.Y) || pt.Size < 0 {
panic("mujica: ScatterChart points need finite X, Y and Size ≥ 0")
}
marks = append(marks, ScatterKey{gi, pi})
b = [6]float64{min(b[0], pt.X), max(b[1], pt.X), min(b[2], pt.Y), max(b[3], pt.Y), min(b[4], pt.Size), max(b[5], pt.Size)}
}
}
var xs, ys [3]float64
if len(marks) > 0 {
// A margin keeps dots off the plot edges.
px, py := (b[1]-b[0])*0.06, (b[3]-b[2])*0.06
xs[0], xs[1], xs[2] = internal.Nice(b[0]-px, b[1]+px, 4)
ys[0], ys[1], ys[2] = internal.Nice(b[2]-py, b[3]+py, 4)
}
pt := func(m ScatterKey) ScatterPoint { return groups[m.Group].Points[m.Point] }
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
plot := chartInset(r, 52, 26)
return plot, internal.Linear{D0: xs[0], D1: xs[1], R0: float64(plot.X), R1: float64(plot.X + plot.W)},
internal.Linear{D0: ys[0], D1: ys[1], R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
}
radius := func(m ScatterKey) float32 {
if !opts.Bubbles || b[5] <= 0 {
return 4
}
return 2 + 7*float32(math.Sqrt(pt(m).Size/b[5]))
}
toggle := func(m ScatterKey) {
if i := slices.Index(*selected, m); i >= 0 {
*selected = slices.Delete(slices.Clone(*selected), i, i+1)
} else {
*selected = append(slices.Clone(*selected), m)
}
ch.res.value = true
slog.Debug("mujica: scatter_chart select", "count", len(*selected))
}
spec := chartSpec{n: len(marks), keys: keys, toggle: len(groups) > 1}
spec.readout = func(i int) string {
m, p := marks[i], pt(marks[i])
s := fmt.Sprintf("%s · x %s · y %s", groups[m.Group].Name, chartValue(c, p.X, opts.Format), chartValue(c, p.Y, opts.Format))
if opts.Bubbles {
s += " · " + msg(c, "scatter_chart.size") + " " + chartValue(c, p.Size, opts.Format)
}
if slices.Contains(*selected, m) {
s += " · " + msg(c, "scatter_chart.selected")
}
return s
}
spec.pick = func(r ui.Rect, x, y float32) int {
_, sx, sy := geom(r)
return internal.Nearest(len(marks), func(i int) (float32, float32) { return sx.Map(pt(marks[i]).X), sy.Map(pt(marks[i]).Y) }, x, y, 14, false)
}
spec.activate = func(i int) { toggle(marks[i]) }
drag := ui.Local(ch.res.Element, "mujica.scatter.drag", func() scatterDrag { return scatterDrag{} })
box := *drag
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, sx, sy := geom(r)
internal.AxisY(p, plot, sy, internal.Ticks(ys[0], ys[1], ys[2]), func(v float64) string { return internal.FormatTick(v, ys[2]) }, size, k)
var tx []float32
var tl []string
for _, t := range internal.Ticks(xs[0], xs[1], xs[2]) {
tx, tl = append(tx, sx.Map(t)), append(tl, internal.FormatTick(t, xs[2]))
}
internal.AxisX(p, plot, tx, tl, size, k)
for i, m := range marks {
x, y, rad := sx.Map(pt(m).X), sy.Map(pt(m).Y), radius(m)
internal.Marker(p, m.Group, x, y, 2*rad, keys[m.Group].Color.Alpha(0.85))
if slices.Contains(*selected, m) || i == active {
var ring ui.Path
ring.Circle(internal.Snap(p, x), internal.Snap(p, y), rad+3+2*float32(chartBool(i == active)))
p.StrokePath(&ring, 1.5, k.Text)
}
}
if box.on {
p.StrokeDashed(scatterRect(box, r.X, r.Y), k.Text, 0, 1)
}
}
res, plot := ch.build(spec)
if plot != nil && !plot.IsDisabled() {
x, y, _ := plot.PointerPosition()
if _, _, held := plot.Dragged(); held {
if !drag.on {
*drag = scatterDrag{on: true, x0: x, y0: y}
}
drag.x1, drag.y1 = x, y
} else if drag.on {
if pb := plot.Bounds(); max(abs32(drag.x1-drag.x0), abs32(drag.y1-drag.y0)) > 4 {
_, sx, sy := geom(ui.Rect{W: pb.W, H: pb.H})
in := scatterRect(*drag, 0, 0)
var picked []ScatterKey
for _, m := range marks {
if in.Contains(sx.Map(pt(m).X), sy.Map(pt(m).Y)) {
picked = append(picked, m)
}
}
*selected, res.value = picked, true
slog.Debug("mujica: scatter_chart box", "count", len(picked))
}
*drag = scatterDrag{}
}
}
return res
}
func scatterRect(d scatterDrag, ox, oy float32) ui.Rect {
return ui.Rect{X: ox + min(d.x0, d.x1), Y: oy + min(d.y0, d.y1), W: abs32(d.x1 - d.x0), H: abs32(d.y1 - d.y0)}
}