Charts I: components 101-120

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:34:31 -04:00
1 parent 6f0c02d339
commit efee19c7d4
116 files changed
+7825 -3

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
package mujica
import (
"math"
"time"
"github.com/egoist/mygo/ui"
)
// AreaChartOptions configure an AreaChart.
type AreaChartOptions struct {
Frame ChartFrame
// X places point i at ascending X[i]; nil means i.
X []float64
// Times makes a time axis; set X or Times.
Times []time.Time
// Stacked piles visible series on each other.
Stacked bool
// Baseline is where areas start; zero by default.
Baseline float64
Format func(v float64) string
}
// AreaChart fills series to the baseline, or stacked.
func AreaChart(c *ui.Context, series []ChartSeries, opts AreaChartOptions) ChartResult {
n := chartCheckSeries(series)
ch := newChart(c, opts.Frame)
ax := chartMemo(ch, "x", [3]string{chartID(opts.X), chartID(opts.Times), chartID(series)}, func() chartX { return newChartX(n, opts.X, opts.Times) })
deps := struct {
id string
stacked bool
base float64
}{chartSeriesID(ch, series), opts.Stacked, opts.Baseline}
bands := chartMemo(ch, "bands", deps, func() [2][][]float64 { return areaBands(series, ch.hidden, opts.Stacked, opts.Baseline) })
lo, hi, ok := chartYRange(ch, series, false, bands[0])
if !ok {
n = 0
}
lo, hi = min(lo, opts.Baseline), max(hi, opts.Baseline)
return chartLines(ch, chartLineData{series: series, tops: bands[0], bases: bands[1], ax: ax, lo: lo, hi: hi, n: n, format: opts.Format})
}
// areaBands returns each series' top and bottom edges.
func areaBands(series []ChartSeries, hidden func(string) bool, stacked bool, base float64) [2][][]float64 {
var tops, bases [][]float64
var run []float64
if len(series) > 0 {
run = make([]float64, len(series[0].Values))
for i := range run {
run[i] = base
}
}
for _, s := range series {
top, bot := make([]float64, len(s.Values)), make([]float64, len(s.Values))
for i, v := range s.Values {
bot[i], top[i] = base, v
if stacked {
bot[i], top[i] = run[i], run[i]+v
}
if stacked && !math.IsNaN(v) && !hidden(s.Name) {
run[i] += v
}
}
tops, bases = append(tops, top), append(bases, bot)
}
return [2][][]float64{tops, bases}
}
func chartBool(b bool) float64 {
if b {
return 1
}
return 0
}
+54
View File
@@ -0,0 +1,54 @@
package mujica
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
func TestAreaBands(t *testing.T) {
series := []ChartSeries{{Name: "a", Values: []float64{1, math.NaN(), 3}}, {Name: "b", Values: []float64{2, 2, 2}}}
none := func(string) bool { return false }
st := areaBands(series, none, true, 10)
if st[0][0][0] != 11 || st[1][1][0] != 11 || st[0][1][0] != 13 || !math.IsNaN(st[0][0][1]) || st[0][1][1] != 12 {
t.Fatalf("stacked %v", st)
}
hideA := func(s string) bool { return s == "a" }
st = areaBands(series, hideA, true, 0)
if st[0][1][2] != 2 || st[1][1][2] != 0 {
t.Fatalf("hidden series still stacked: %v", st)
}
flat := areaBands(series, none, false, -1)
if flat[0][0][2] != 3 || flat[1][0][2] != -1 || flat[1][1][2] != -1 {
t.Fatalf("baseline %v", flat)
}
}
func TestAreaChartStackedReadout(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return AreaChart(c, lineSeries(), AreaChartOptions{Frame: ChartFrame{Label: "Nave"}, Stacked: true, Baseline: 1})
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Nave")
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "0 · Pews 3 · Candles 5" {
t.Fatalf("readout shows the series values, not the stack: %q", got)
}
b := h.res.Element.Bounds()
tt.Move(b.X+52+(b.W-64)*0.5, b.Y+b.H/2)
if got := chartReadout(tt); got != "2 · Pews no value · Candles 6" || h.res.Point != 2 {
t.Fatalf("hover %q", got)
}
}
func TestAreaChartSnapshot(t *testing.T) {
snapshot(t, "area_chart", 520, 340, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
AreaChart(c, []ChartSeries{
{Name: "Matins", Values: []float64{3, 4, 6, 5, 7, 8}},
{Name: "Vespers", Values: []float64{2, 3, 2, 4, 3, 5}},
}, AreaChartOptions{Frame: ChartFrame{Label: "Attendance"}, Stacked: true})
})
})
}
+185
View File
@@ -0,0 +1,185 @@
package mujica
import (
"math"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// BarChartOptions configure a BarChart.
type BarChartOptions struct {
Frame ChartFrame
// Horizontal lays categories down the side and bars across.
Horizontal bool
// Stacked piles series into one bar per category.
Stacked bool
// Labels writes each bar's value at its end.
Labels bool
Format func(v float64) string
}
type barMark struct{ cat, series int }
// BarChart draws grouped or stacked bars; NaN draws none.
func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts BarChartOptions) ChartResult {
if n := chartCheckSeries(series); n != len(categories) && len(series) > 0 {
panic("mujica: BarChart series length differs from categories")
}
ch := newChart(c, opts.Frame)
k := Tokens(c)
pal := internal.Palette(k)
size := FontSize(c, theme.CaptionSize)
keys := make([]chartKey, len(series))
var vis []int
for i, s := range series {
keys[i] = chartKey{Name: s.Name, Color: pal[i%len(pal)], Shape: i}
if !ch.hidden(s.Name) {
vis = append(vis, i)
}
}
// Bars stacked or not; lo and hi always include zero.
type layout struct {
marks []barMark
from []float64
lo, hi float64
}
lay := chartMemo(ch, "bars", [2]any{chartSeriesID(ch, series), opts.Stacked}, func() layout {
var l layout
for ci := range categories {
pos, neg := 0.0, 0.0
for _, si := range vis {
v := series[si].Values[ci]
if math.IsNaN(v) {
continue
}
from := 0.0
if opts.Stacked && v >= 0 {
from, pos = pos, pos+v
} else if opts.Stacked {
from, neg = neg, neg+v
}
l.marks, l.from = append(l.marks, barMark{ci, si}), append(l.from, from)
l.lo, l.hi = min(l.lo, from, from+v), max(l.hi, from, from+v)
}
}
return l
})
var lo, hi, step float64
var ticks []float64
if len(lay.marks) > 0 {
lo, hi = lay.lo, lay.hi
if opts.Labels {
// Headroom keeps value labels inside the plot.
pad := (hi - lo) * 0.15
lo, hi = lo-pad*chartBool(lo < 0), hi+pad*chartBool(hi > 0)
}
lo, hi, step = internal.Nice(lo, hi, 4)
ticks = internal.Ticks(lo, hi, step)
}
geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Linear) {
if opts.Horizontal {
plot := chartInset(r, 96, 26)
return plot, internal.Band{N: len(categories), R0: plot.Y, R1: plot.Y + plot.H, Pad: 0.3},
internal.Linear{D0: lo, D1: hi, R0: float64(plot.X), R1: float64(plot.X + plot.W)}
}
plot := chartInset(r, 52, 26)
return plot, internal.Band{N: len(categories), R0: plot.X, R1: plot.X + plot.W, Pad: 0.3},
internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
}
// rect is bar i's box in plot space.
rect := func(band internal.Band, vs internal.Linear, i int) ui.Rect {
m := lay.marks[i]
at, w := band.Start(m.cat), band.Width()
if !opts.Stacked && len(vis) > 0 {
slot := 0
for j, si := range vis {
if si == m.series {
slot = j
}
}
w /= float32(len(vis))
at += w * float32(slot)
}
a, b := vs.Map(lay.from[i]), vs.Map(lay.from[i]+series[m.series].Values[m.cat])
a, b = min(a, b), max(a, b)
if opts.Horizontal {
return ui.Rect{X: a, Y: at, W: max(b-a, 1), H: w}
}
return ui.Rect{X: at, Y: a, W: w, H: max(b-a, 1)}
}
value := func(i int) float64 { m := lay.marks[i]; return series[m.series].Values[m.cat] }
spec := chartSpec{n: len(lay.marks), keys: keys, toggle: len(series) > 1}
spec.readout = func(i int) string {
m := lay.marks[i]
return categories[m.cat] + " · " + series[m.series].Name + " " + chartValue(c, value(i), opts.Format)
}
spec.pick = func(r ui.Rect, x, y float32) int {
_, band, vs := geom(r)
for i := range lay.marks {
b := rect(band, vs, i)
// Thin bars still take a 6 DIP target.
if (ui.Rect{X: b.X - 3, Y: b.Y - 3, W: b.W + 6, H: b.H + 6}).Contains(x, y) {
return i
}
}
return -1
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, band, vs := geom(r)
label := func(v float64) string { return internal.FormatTick(v, step) }
var pos []float32
for i := range categories {
pos = append(pos, band.Center(i))
}
zero := vs.Map(0)
if opts.Horizontal {
for _, t := range ticks {
x := internal.Snap(p, vs.Map(t))
p.Line(x, plot.Y, x, plot.Y+plot.H, 1, k.Border)
chartText(p, label(t), x, plot.Y+plot.H+6, size, k.TextMuted, 0.5, 0)
}
for i, y := range pos {
chartText(p, categories[i], plot.X-8, y, size, k.TextMuted, 1, 0.5)
}
p.Line(internal.Snap(p, zero), plot.Y, internal.Snap(p, zero), plot.Y+plot.H, 1, k.ControlBorder)
} else {
internal.AxisY(p, plot, vs, ticks, label, size, k)
internal.AxisX(p, plot, pos, categories, size, k)
internal.Rule(p, plot.X, plot.X+plot.W, zero, k.ControlBorder)
}
for i, m := range lay.marks {
b := rect(band, vs, i)
col := keys[m.series].Color
p.Fill(b, col, 1)
if i == active {
p.Stroke(ui.Rect{X: b.X - 2, Y: b.Y - 2, W: b.W + 4, H: b.H + 4}, k.Text, 2, 1.5)
}
// The series shape marks the bar besides its color.
if min(b.W, b.H) >= 10 && max(b.W, b.H) >= 14 {
internal.Marker(p, m.series, b.X+b.W/2, b.Y+b.H/2, min(b.W, b.H, 14)*0.6, k.Surface)
}
if opts.Labels && !opts.Stacked {
barLabel(p, chartValue(c, value(i), opts.Format), b, value(i) < 0, opts.Horizontal, size, k.Text)
}
}
}
res, _ := ch.build(spec)
return res
}
// barLabel writes a value just past the bar's far end.
func barLabel(p *ui.Painter, s string, b ui.Rect, neg, horizontal bool, size float32, col ui.Color) {
switch {
case horizontal && neg:
chartText(p, s, b.X-4, b.Y+b.H/2, size, col, 1, 0.5)
case horizontal:
chartText(p, s, b.X+b.W+4, b.Y+b.H/2, size, col, 0, 0.5)
case neg:
chartText(p, s, b.X+b.W/2, b.Y+b.H+2, size, col, 0.5, 0)
default:
chartText(p, s, b.X+b.W/2, b.Y-2, size, col, 0.5, 1)
}
}
+90
View File
@@ -0,0 +1,90 @@
package mujica
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
var barCats = []string{"Nave", "Choir", "Crypt"}
func barSeries() []ChartSeries {
return []ChartSeries{
{Name: "Gold", Values: []float64{4, -2, 3}},
{Name: "Silver", Values: []float64{2, 5, math.NaN()}},
}
}
func TestBarChartKeyboardPerBar(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return BarChart(c, barCats, barSeries(), BarChartOptions{Frame: ChartFrame{Label: "Tithes"}})
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Tithes")
want := []string{"Nave · Gold 4", "Nave · Silver 2", "Choir · Gold -2", "Choir · Silver 5", "Crypt · Gold 3"}
for i, w := range want {
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != w {
t.Fatalf("bar %d readout %q, want %q", i, got, w)
}
}
tt.Key(0, ui.KeyRight)
if h.res.Point != 4 {
t.Fatalf("stepped past the missing bar: %d", h.res.Point)
}
}
func TestBarChartHoverStackedHorizontal(t *testing.T) {
for _, o := range []BarChartOptions{{Stacked: true}, {Horizontal: true, Labels: true}, {Horizontal: true, Stacked: true}} {
o.Frame = ChartFrame{Label: "Tithes"}
h := &chartHarness{build: func(c *ui.Context) ChartResult { return BarChart(c, barCats, barSeries(), o) }}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Tithes")
found := map[string]bool{}
b := h.res.Element.Bounds()
for y := b.Y + 30; y < b.Y+b.H-40; y += 4 {
for x := b.X + 60; x < b.X+b.W-16; x += 8 {
tt.Move(x, y)
if h.res.Point >= 0 {
found[chartReadout(tt)] = true
}
}
}
if len(found) != 5 || !found["Choir · Gold -2"] {
t.Fatalf("%+v: hover found %v", o, found)
}
}
}
func TestBarChartBadAndEmpty(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for mismatched categories")
}
}()
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return BarChart(c, barCats, nil, BarChartOptions{Frame: ChartFrame{Label: "Tithes"}})
}}
tt := ui.NewTester(h.view, 520, 420)
if !tt.HasText("No data") {
t.Fatalf("empty: %q", tt.Texts())
}
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
BarChart(c, barCats[:2], barSeries(), BarChartOptions{Frame: ChartFrame{Label: "x"}})
}, 300, 300)
}
func TestBarChartSnapshot(t *testing.T) {
snapshot(t, "bar_chart", 640, 320, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
ui.Column(c).Width(296).Children(func() {
BarChart(c, barCats, barSeries(), BarChartOptions{Frame: ChartFrame{Label: "Grouped", Height: 220}, Labels: true})
})
ui.Column(c).Width(296).Children(func() {
BarChart(c, barCats, barSeries(), BarChartOptions{Frame: ChartFrame{Label: "Stacked", Height: 220}, Horizontal: true, Stacked: true})
})
})
})
}
+160
View File
@@ -0,0 +1,160 @@
package mujica
import (
"fmt"
"math"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// BoxGroup is one named sample of a BoxPlot.
type BoxGroup struct {
Name string
Values []float64
}
// BoxPlotOptions configure a BoxPlot.
type BoxPlotOptions struct {
Frame ChartFrame
Format func(v float64) string
}
// boxStats are quartiles, whisker ends and outliers.
type boxStats struct {
q1, median, q3, lo, hi float64
outliers []float64
n int
}
// boxSummary summarizes finite values; n is 0 without any.
func boxSummary(values []float64) boxStats {
var v []float64
for _, x := range values {
if !math.IsNaN(x) && !math.IsInf(x, 0) {
v = append(v, x)
}
}
if len(v) == 0 {
return boxStats{}
}
slices.Sort(v)
q := func(p float64) float64 {
at := p * float64(len(v)-1)
i := int(at)
if i+1 >= len(v) {
return v[i]
}
return v[i] + (at-float64(i))*(v[i+1]-v[i])
}
s := boxStats{q1: q(0.25), median: q(0.5), q3: q(0.75), n: len(v)}
fence := 1.5 * (s.q3 - s.q1)
s.lo, s.hi = s.q1, s.q3
for _, x := range v {
if x < s.q1-fence || x > s.q3+fence {
s.outliers = append(s.outliers, x)
continue
}
s.lo, s.hi = min(s.lo, x), max(s.hi, x)
}
return s
}
// BoxPlot compares groups by quartile box, whiskers and outlier points.
func BoxPlot(c *ui.Context, groups []BoxGroup, opts BoxPlotOptions) ChartResult {
ch := newChart(c, opts.Frame)
k := Tokens(c)
pal := internal.Palette(k)
size := FontSize(c, theme.CaptionSize)
ids := ""
for _, g := range groups {
if g.Name == "" {
panic("mujica: BoxPlot groups need names")
}
ids += chartID(g.Values) + ";"
}
stats := chartMemo(ch, "stats", ids, func() []boxStats {
out := make([]boxStats, len(groups))
for i, g := range groups {
out[i] = boxSummary(g.Values)
}
return out
})
lo, hi, n := math.Inf(1), math.Inf(-1), 0
for _, s := range stats {
if s.n > 0 {
n++
lo, hi = min(lo, s.lo, slices.Min(append([]float64{s.lo}, s.outliers...))), max(hi, s.hi, slices.Max(append([]float64{s.hi}, s.outliers...)))
}
}
var step float64
if n > 0 {
lo, hi, step = internal.Nice(lo, hi, 4)
} else {
groups = nil
}
val := func(v float64) string { return chartValue(c, v, opts.Format) }
geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Linear) {
plot := chartInset(r, 52, 26)
return plot, internal.Band{N: len(groups), R0: plot.X, R1: plot.X + plot.W, Pad: 0.45},
internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
}
spec := chartSpec{n: len(groups)}
spec.readout = func(i int) string {
s := stats[i]
if s.n == 0 {
return groups[i].Name + " · " + msg(c, "chart.missing")
}
return fmt.Sprintf(msg(c, "box_plot.readout"), groups[i].Name, val(s.median), val(s.q1), val(s.q3), val(s.lo), val(s.hi), len(s.outliers))
}
spec.pick = func(r ui.Rect, x, y float32) int {
plot, band, _ := geom(r)
if !plot.Contains(x, y) {
return -1
}
return min(len(groups)-1, int((x-plot.X)/band.Step()))
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, band, ys := geom(r)
internal.AxisY(p, plot, ys, internal.Ticks(lo, hi, step), func(v float64) string { return internal.FormatTick(v, step) }, size, k)
var xs []float32
var names []string
for i, g := range groups {
xs, names = append(xs, band.Center(i)), append(names, g.Name)
}
internal.AxisX(p, plot, xs, names, size, k)
for i, s := range stats {
if s.n == 0 {
continue
}
col := pal[i%len(pal)]
x, w := band.Start(i), band.Width()
cx := internal.Snap(p, x+w/2)
whisker := func(a, b float64) {
var path ui.Path
path.MoveTo(cx, ys.Map(a)).LineTo(cx, ys.Map(b))
p.StrokePath(&path, 1.5, k.TextMuted)
p.Line(cx-w/4, ys.Map(b), cx+w/4, ys.Map(b), 1.5, k.TextMuted)
}
whisker(s.q3, s.hi)
whisker(s.q1, s.lo)
box := ui.Rect{X: x, Y: ys.Map(s.q3), W: w, H: max(ys.Map(s.q1)-ys.Map(s.q3), 1)}
p.Fill(box, col.Alpha(0.22), 2)
p.Stroke(box, col, 2, 1.5)
p.Line(x, ys.Map(s.median), x+w, ys.Map(s.median), 2.5, k.Text)
for _, o := range s.outliers {
var dot ui.Path
dot.Circle(cx, internal.Snap(p, ys.Map(o)), 3.5)
p.StrokePath(&dot, 1.5, col)
}
if i == active {
p.Stroke(ui.Rect{X: x - 6, Y: plot.Y, W: w + 12, H: plot.H}, k.Text, 3, 1.5)
}
}
}
res, _ := ch.build(spec)
return res
}
+59
View File
@@ -0,0 +1,59 @@
package mujica
import (
"fmt"
"math"
"testing"
"github.com/egoist/mygo/ui"
)
var boxGroups = []BoxGroup{
{Name: "Choir", Values: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 40}},
{Name: "Nave", Values: []float64{4, 5, 5, 6, 7, math.NaN()}},
}
func TestBoxSummary(t *testing.T) {
s := boxSummary(boxGroups[0].Values)
if s.q1 != 3.25 || s.median != 5.5 || s.q3 != 7.75 || s.lo != 1 || s.hi != 9 || fmt.Sprint(s.outliers) != "[40]" || s.n != 10 {
t.Fatalf("summary %+v", s)
}
if s := boxSummary([]float64{math.NaN()}); s.n != 0 {
t.Fatal("NaN counted")
}
}
func TestBoxPlotReadout(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return BoxPlot(c, boxGroups, BoxPlotOptions{Frame: ChartFrame{Label: "Hymns"}})
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Hymns")
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "Choir · median 5.5 · quartiles 3.25–7.75 · whiskers 1–9 · 1 outliers" {
t.Fatalf("readout %q", got)
}
b := h.res.Element.Bounds()
tt.Key(0, ui.KeyEscape)
tt.Move(b.X+52+(b.W-64)*0.75, b.Y+100)
if got := chartReadout(tt); got != "Nave · median 5 · quartiles 5–6 · whiskers 4–7 · 0 outliers" {
t.Fatalf("hover %q", got)
}
}
func TestBoxPlotEmpty(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return BoxPlot(c, []BoxGroup{{Name: "a"}}, BoxPlotOptions{Frame: ChartFrame{Label: "Hymns"}})
}}
if tt := ui.NewTester(h.view, 520, 420); !tt.HasText("No data") {
t.Fatalf("empty: %q", tt.Texts())
}
}
func TestBoxPlotSnapshot(t *testing.T) {
snapshot(t, "box_plot", 520, 320, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
BoxPlot(c, append(boxGroups, BoxGroup{Name: "Crypt", Values: []float64{12, 14, 15, 18, 22, 25}}), BoxPlotOptions{Frame: ChartFrame{Label: "Hymns"}})
})
})
}
+149
View File
@@ -0,0 +1,149 @@
package mujica
import (
"fmt"
"math"
"slices"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// BulletChartOptions configure a BulletChart; Frame.Height 0 means 72 DIPs.
type BulletChartOptions struct {
Frame ChartFrame
// Target, when set, draws a cross line at the goal.
Target *float64
// Bands are the ascending upper ends of the qualitative ranges.
Bands []float64
// Max ends the scale; 0 means automatic.
Max float64
Vertical bool
Format func(v float64) string
}
// BulletChart shows a value against a target and ranges.
func BulletChart(c *ui.Context, value float64, opts BulletChartOptions) ChartResult {
if math.IsNaN(value) || value < 0 || opts.Max < 0 || !slices.IsSorted(opts.Bands) || len(opts.Bands) > 0 && opts.Bands[0] <= 0 {
panic("mujica: BulletChart needs a value ≥ 0, Max ≥ 0 and positive ascending Bands")
}
if opts.Frame.Height == 0 {
opts.Frame.Height = 72
}
ch := newChart(c, opts.Frame)
k := Tokens(c)
size := FontSize(c, theme.CaptionSize)
top := opts.Max
if top == 0 {
top = value
if opts.Target != nil {
top = max(top, *opts.Target)
}
if len(opts.Bands) > 0 {
top = max(top, opts.Bands[len(opts.Bands)-1])
}
_, top, _ = internal.Nice(0, max(top, 1e-9), 4)
}
_, _, step := internal.Nice(0, top, 4)
val := func(v float64) string { return chartValue(c, v, opts.Format) }
marks := []string{msg(c, "bullet_chart.actual") + " " + val(value)}
if opts.Target != nil {
marks = append(marks, msg(c, "bullet_chart.target")+" "+val(*opts.Target))
}
for i, b := range opts.Bands {
lo := 0.0
if i > 0 {
lo = opts.Bands[i-1]
}
marks = append(marks, fmt.Sprintf(msg(c, "bullet_chart.band"), i+1, val(lo), val(b)))
}
// geom returns the plot and its value scale.
geom := func(r ui.Rect) (ui.Rect, internal.Linear) {
if opts.Vertical {
plot := ui.Rect{X: r.X + r.W/2 - 14, Y: r.Y + 8, W: 28, H: r.H - 16}
return plot, internal.Linear{D0: 0, D1: top, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
}
plot := ui.Rect{X: r.X + 8, Y: r.Y + 6, W: r.W - 24, H: max(r.H-30, 12)}
return plot, internal.Linear{D0: 0, D1: top, R0: float64(plot.X), R1: float64(plot.X + plot.W)}
}
span := func(plot ui.Rect, s internal.Linear, a, b, inset float32) ui.Rect {
if opts.Vertical {
return ui.Rect{X: plot.X + inset, Y: s.Map(float64(b)), W: plot.W - 2*inset, H: s.Map(float64(a)) - s.Map(float64(b))}
}
return ui.Rect{X: s.Map(float64(a)), Y: plot.Y + inset, W: s.Map(float64(b)) - s.Map(float64(a)), H: plot.H - 2*inset}
}
spec := chartSpec{n: len(marks)}
spec.readout = func(i int) string { return marks[i] }
spec.pick = func(r ui.Rect, x, y float32) int {
plot, s := geom(r)
if !plot.Contains(x, y) {
return -1
}
at := x
if opts.Vertical {
at = y
}
v := s.Invert(at)
if opts.Target != nil && math.Abs(float64(s.Map(*opts.Target)-at)) < 4 {
return 1
}
if span(plot, s, 0, float32(min(value, top)), bulletInset(plot, opts.Vertical)).Contains(x, y) {
return 0
}
for i, b := range opts.Bands {
if v <= b {
return len(marks) - len(opts.Bands) + i
}
}
return -1
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, s := geom(r)
inset := bulletInset(plot, opts.Vertical)
prev := 0.0
for i, b := range opts.Bands {
shade := k.SurfaceHover.Mix(k.Border, float32(len(opts.Bands)-1-i)/float32(max(len(opts.Bands)-1, 1)))
p.Fill(span(plot, s, float32(prev), float32(min(b, top)), 0), shade, 0)
if active == len(marks)-len(opts.Bands)+i {
p.Stroke(span(plot, s, float32(prev), float32(min(b, top)), 0), k.Text, 0, 1.5)
}
prev = b
}
bar := span(plot, s, 0, float32(min(value, top)), inset)
p.Fill(bar, internal.Palette(k)[0], 0)
if active == 0 {
p.Stroke(ui.Rect{X: bar.X - 2, Y: bar.Y - 2, W: bar.W + 4, H: bar.H + 4}, k.Text, 1, 1.5)
}
if opts.Target != nil {
t := internal.Snap(p, s.Map(min(*opts.Target, top)))
w := float32(2.5)
if active == 1 {
w = 4
}
if opts.Vertical {
p.Line(plot.X+2, t, plot.X+plot.W-2, t, w, k.Text)
} else {
p.Line(t, plot.Y+2, t, plot.Y+plot.H-2, w, k.Text)
}
}
for _, tk := range internal.Ticks(0, top, step) {
if opts.Vertical {
chartText(p, internal.FormatTick(tk, step), plot.X+plot.W+6, s.Map(tk), size, k.TextMuted, 0, 0.5)
} else {
chartText(p, internal.FormatTick(tk, step), s.Map(tk), plot.Y+plot.H+4, size, k.TextMuted, 0.5, 0)
}
}
}
res, _ := ch.build(spec)
return res
}
// bulletInset is a third of the band's thickness.
func bulletInset(plot ui.Rect, vertical bool) float32 {
if vertical {
return plot.W / 3
}
return plot.H / 3
}
+69
View File
@@ -0,0 +1,69 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
func TestBulletChartReadout(t *testing.T) {
target := 250.0
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return BulletChart(c, 270, BulletChartOptions{Frame: ChartFrame{Label: "Alms"}, Target: &target, Bands: []float64{150, 225, 300}})
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Alms")
want := []string{"Actual 270", "Target 250", "Range 1: 0–150", "Range 2: 150–225", "Range 3: 225–300"}
for i, w := range want {
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != w {
t.Fatalf("mark %d %q, want %q", i, got, w)
}
}
tt.Key(0, ui.KeyEscape)
b := h.res.Element.Bounds()
plotW := b.W - 24
tt.Move(b.X+8+plotW*0.1, b.Y+6+21) // band 1, inside the value bar
if got := chartReadout(tt); got != "Actual 270" {
t.Fatalf("hover bar %q", got)
}
tt.Move(b.X+8+plotW*0.1, b.Y+8) // band 1, above the bar
if got := chartReadout(tt); got != "Range 1: 0–150" {
t.Fatalf("hover band %q", got)
}
}
func TestBulletChartVerticalAndBad(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return BulletChart(c, 40, BulletChartOptions{Frame: ChartFrame{Label: "Alms", Height: 200}, Vertical: true, Max: 100})
}}
tt := ui.NewTester(h.view, 520, 420)
b := h.res.Element.Bounds()
tt.Move(b.X+b.W/2, b.Y+8+184*0.8) // low on the scale: inside the bar
if got := chartReadout(tt); got != "Actual 40" {
t.Fatalf("vertical hover %q", got)
}
defer func() {
if recover() == nil {
t.Fatal("no panic for unsorted bands")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
BulletChart(c, 1, BulletChartOptions{Frame: ChartFrame{Label: "x"}, Bands: []float64{5, 2}})
}, 300, 300)
}
func TestBulletChartSnapshot(t *testing.T) {
target := 250.0
snapshot(t, "bullet_chart", 520, 300, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
ui.Column(c).Width(360).Children(func() {
BulletChart(c, 270, BulletChartOptions{Frame: ChartFrame{Label: "Alms"}, Target: &target, Bands: []float64{150, 225, 300}})
})
ui.Column(c).Width(100).Children(func() {
BulletChart(c, 40, BulletChartOptions{Frame: ChartFrame{Label: "Oil", Height: 200}, Vertical: true, Target: &[]float64{70}[0], Bands: []float64{50, 80, 100}})
})
})
})
}
+139
View File
@@ -0,0 +1,139 @@
package mujica
import (
"log/slog"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// CalendarHeatmapOptions configure a CalendarHeatmap.
type CalendarHeatmapOptions struct {
Frame ChartFrame
// WeekStart picks each column's first weekday; zero follows the locale.
WeekStart WeekStart
Format func(v float64) string
}
// CalendarHeatmap colors each day of *year, a column per week.
func CalendarHeatmap(c *ui.Context, year *int, values map[Date]float64, opts CalendarHeatmapOptions) ChartResult {
if *year < 1 || *year > 9999 {
panic("mujica: CalendarHeatmap year out of range")
}
k := Tokens(c)
u := Density(c).Unit()
size := FontSize(c, theme.CaptionSize)
first := Date{Year: *year, Month: time.January, Day: 1}
days := first.AddMonths(12).civil().Sub(first.civil()).Hours() / 24
n := int(days)
lead := (int(first.Weekday()) - int(calendarFirstWeekday(c, opts.WeekStart)) + 7) % 7
weeks := (lead + n + 6) / 7
top := 0.0
for i := range n {
if v, ok := values[first.AddDays(i)]; ok {
top = max(top, v)
}
}
// level is a day's color step, 0 to 4.
level := func(i int) int {
v, ok := values[first.AddDays(i)]
if !ok || v <= 0 || top <= 0 {
return 0
}
return 1 + min(3, int(v/top*4))
}
var res ChartResult
wrap := ui.Column(c).Gap(u * 2).MinWidth(0)
wrap.Children(func() {
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
prev := iconAction(c, icons.Must("chevron-left"), msg(c, "calendar_heatmap.prev")).Disabled(*year <= 1)
ui.Text(c, strconv.Itoa(*year)).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).Width(64).TextAlign(ui.Center)
next := iconAction(c, icons.Must("chevron-right"), msg(c, "calendar_heatmap.next")).Disabled(*year >= 9999)
if prev.Clicked() && !prev.IsDisabled() {
*year--
res.value = true
}
if next.Clicked() && !next.IsDisabled() {
*year++
res.value = true
}
})
ch := newChart(c, opts.Frame)
geom := func(r ui.Rect) (x, y, step float32) {
step = min((r.W-36)/float32(weeks), (r.H-48)/7)
return r.X + 32, r.Y + 18, step
}
cell := func(r ui.Rect, i int) ui.Rect {
x, y, step := geom(r)
at := lead + i
return ui.Rect{X: x + float32(at/7)*step, Y: y + float32(at%7)*step, W: step - 2, H: step - 2}
}
spec := chartSpec{n: n}
spec.readout = func(i int) string {
d := first.AddDays(i)
v, ok := values[d]
s := d.String() + " · "
if !ok {
return s + msg(c, "chart.missing")
}
return s + chartValue(c, v, opts.Format)
}
spec.move = func(i int, key ui.Key) int {
step := map[ui.Key]int{ui.KeyLeft: -7, ui.KeyRight: 7, ui.KeyUp: -1, ui.KeyDown: 1}[key]
return max(0, min(n-1, i+step))
}
spec.pick = func(r ui.Rect, x, y float32) int {
x0, y0, step := geom(r)
col, row := int((x-x0)/step), int((y-y0)/step)
i := col*7 + row - lead
if x < x0 || y < y0 || row > 6 || i < 0 || i >= n {
return -1
}
return i
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
x0, y0, step := geom(r)
for i := range n {
b := cell(r, i)
if lv := level(i); lv == 0 {
p.Fill(b, k.SurfaceHover, 2)
} else {
p.Fill(b, internal.Ramp(k, float64(lv)/4), 2)
}
if d := first.AddDays(i); d.Day == 1 {
chartText(p, d.Format("Jan"), b.X, y0-4, size, k.TextMuted, 0, 1)
}
if i == active {
p.Stroke(ui.Rect{X: b.X - 2, Y: b.Y - 2, W: b.W + 4, H: b.H + 4}, k.Text, 3, 2)
}
}
for row := 0; row < 7; row += 2 {
wd := time.Weekday((int(calendarFirstWeekday(c, opts.WeekStart)) + row) % 7)
chartText(p, wd.String()[:3], x0-4, y0+float32(row)*step+step/2, size, k.TextMuted, 1, 0.5)
}
ly := y0 + 7*step + 8
w, _ := chartText(p, msg(c, "calendar_heatmap.less"), x0, ly, size, k.TextMuted, 0, 0)
for lv := range 5 {
sw := ui.Rect{X: x0 + w + 6 + float32(lv)*(step+2), Y: ly + 2, W: step - 2, H: step - 2}
if lv == 0 {
p.Fill(sw, k.SurfaceHover, 2)
} else {
p.Fill(sw, internal.Ramp(k, float64(lv)/4), 2)
}
}
chartText(p, msg(c, "calendar_heatmap.more"), x0+w+12+5*(step+2), ly, size, k.TextMuted, 0, 0)
}
res2, _ := ch.build(spec)
res.Point, res.changed, res.retry = res2.Point, res2.changed, res2.retry
})
if res.value {
slog.Debug("mujica: calendar_heatmap year", "year", *year)
}
res.Element = wrap
return res
}
+95
View File
@@ -0,0 +1,95 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
func calendarValues() map[Date]float64 {
m := map[Date]float64{}
d := Date{Year: 2026, Month: time.January, Day: 1}
for i := range 365 {
if i%3 == 0 {
m[d.AddDays(i)] = float64(i % 11)
}
}
return m
}
func TestCalendarHeatmapKeysAndYear(t *testing.T) {
year := 2026
values := 0
start := WeekStartMonday
h := &chartHarness{build: func(c *ui.Context) ChartResult {
r := CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: ChartFrame{Label: "Prayers"}, WeekStart: start})
if r.ValueChanged() {
values++
}
return r
}}
tt := ui.NewTester(h.view, 560, 420)
tabTo(t, tt, "Prayers")
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "2026-01-01 · 0" {
t.Fatalf("first day %q", got)
}
tt.Key(0, ui.KeyRight) // a week later
tt.Key(0, ui.KeyDown) // a day later
if got := chartReadout(tt); got != "2026-01-09 · no value" {
t.Fatalf("week and day %q", got)
}
tt.Key(0, ui.KeyEnd)
if got := chartReadout(tt); got != "2026-12-31 · no value" {
t.Fatalf("End %q", got)
}
if err := tt.Click("Next year"); err != nil {
t.Fatal(err)
}
if year != 2027 || values != 1 || !tt.HasText("2027") {
t.Fatalf("year %d events %d", year, values)
}
}
func TestCalendarHeatmapWeekStartHover(t *testing.T) {
year := 2026 // 1 January 2026 is a Thursday
for _, tc := range []struct {
start WeekStart
row int
}{{WeekStartSunday, 4}, {WeekStartMonday, 3}} {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: ChartFrame{Label: "Prayers"}, WeekStart: tc.start})
}}
tt := ui.NewTester(h.view, 560, 420)
b := h.res.Element.Bounds()
plotY := b.Y + b.H - 20 - 8 - 240 // above the readout line and gap
step := min((b.W-36)/53, float32(240-48)/7)
tt.Move(b.X+32+step/2, plotY+18+float32(tc.row)*step+step/2)
if got := chartReadout(tt); got != "2026-01-01 · 0" {
t.Fatalf("start %d: row %d reads %q", tc.start, tc.row, got)
}
}
}
func TestCalendarHeatmapBadYear(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic")
}
}()
year := 0
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
CalendarHeatmap(c, &year, nil, CalendarHeatmapOptions{Frame: ChartFrame{Label: "x"}})
}, 300, 300)
}
func TestCalendarHeatmapSnapshot(t *testing.T) {
snapshot(t, "calendar_heatmap", 600, 260, func(c *ui.Context) {
year := 2026
ui.Column(c).Padding(16).Children(func() {
CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: ChartFrame{Label: "Prayers", Height: 150}, WeekStart: WeekStartMonday})
})
})
}
+260
View File
@@ -0,0 +1,260 @@
package mujica
import (
"log/slog"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// ChartFrame is what every chart shares.
type ChartFrame struct {
// Label names the chart for assistive technology; required.
Label string
// Height is the plot height in DIPs; 0 means 240.
Height float32
// Status shows loading or failure in the plot's place.
Status DataStatus
Error string
// Empty replaces the built-in "No data" text.
Empty string
}
// ChartResult is a built chart and this frame's events.
type ChartResult struct {
Element *ui.Element
// Point is the hovered or focused mark, -1 for none.
Point int
changed, retry bool
toggled string
value bool
}
// Changed reports that the readout moved to another mark.
func (r ChartResult) Changed() bool { return r.changed }
// Retried reports a click on a failed load's retry.
func (r ChartResult) Retried() bool { return r.retry }
// ValueChanged reports a changed selection, path, year or pause.
func (r ChartResult) ValueChanged() bool { return r.value }
// Toggled returns the legend series switched in this frame.
func (r ChartResult) Toggled() (string, bool) { return r.toggled, r.toggled != "" }
// chartKey is a legend entry: name, color and shape.
type chartKey struct {
Name string
Color ui.Color
Shape int
}
// chartSpec is one chart's drawing and behavior.
type chartSpec struct {
// n counts the marks keys step through; 0 is empty.
n int
keys []chartKey
// toggle lets legend entries hide and show series.
toggle bool
draw func(p *ui.Painter, r ui.Rect, active int)
// pick returns the mark at (x, y), or -1.
pick func(r ui.Rect, x, y float32) int
readout func(i int) string
// move steps from mark i; nil steps in order.
move func(i int, key ui.Key) int
// activate runs on Enter, Space or a click.
activate func(i int)
}
type chartState struct {
focus, last int
hidden map[string]bool
}
// chart is a chart being built.
type chart struct {
c *ui.Context
frame ChartFrame
st *chartState
res ChartResult
}
// newChart starts a chart; ask it which series are hidden.
func newChart(c *ui.Context, frame ChartFrame) *chart {
if frame.Label == "" || frame.Height < 0 {
panic("mujica: chart needs a Label and a non-negative Height")
}
if frame.Height == 0 {
frame.Height = 240
}
ch := &chart{c: c, frame: frame}
ch.res.Element = ui.Column(c).Gap(Density(c).Unit() * 2).MinWidth(0)
ch.st = ui.Local(ch.res.Element, "mujica.chart", func() chartState { return chartState{focus: -1, last: -1, hidden: map[string]bool{}} })
return ch
}
// hidden reports whether the legend switched series name off.
func (ch *chart) hidden(name string) bool { return ch.st.hidden[name] }
// build lays out legend, plot and readout; returns the plot.
func (ch *chart) build(spec chartSpec) (ChartResult, *ui.Element) {
c, k, st := ch.c, Tokens(ch.c), ch.st
u := Density(c).Unit()
var plot *ui.Element
ch.res.Element.Children(func() {
if len(spec.keys) > 0 {
ch.legend(spec)
}
box := ui.Box(c).Height(ch.frame.Height).FillWidth().MinWidth(0)
if ch.frame.Status == DataLoading || ch.frame.Status == DataFailed || spec.n == 0 {
st.focus = -1
box.Children(func() {
ui.Column(c).Fill().Children(func() {
ch.res.retry = dataPlaceholder(c, ch.frame.Status, ch.frame.Error, ch.frame.Empty, msg(c, "chart.empty"))
})
})
ch.res.Point = -1
ui.Box(c).Height(u * 5)
return
}
plot = box.Focusable().Label(ch.frame.Label).Role(ui.RoleGroup)
internal.Ring(plot, k.Focus, theme.ControlRadius)
st.focus = min(st.focus, spec.n-1)
ch.input(plot, spec)
active := -1
if x, y, over := plot.PointerPosition(); over && !plot.IsDisabled() {
b := plot.Bounds()
active = spec.pick(ui.Rect{W: b.W, H: b.H}, x, y)
}
if active < 0 && plot.Focused() {
active = st.focus
}
ch.res.Point = active
plot.Draw(func(p *ui.Painter, r ui.Rect) { spec.draw(p, r, active) })
text, col := msg(c, "chart.hint"), k.TextMuted
if active >= 0 {
text, col = spec.readout(active), k.Text
}
ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).TextColor(col).Role(ui.RoleStatus).SingleLine().Height(u * 5)
})
ch.res.changed = ch.res.Point != st.last
st.last = ch.res.Point
return ch.res, plot
}
// input moves the keyboard mark, and takes clicks on marks.
func (ch *chart) input(plot *ui.Element, spec chartSpec) {
st := ch.st
if plot.IsDisabled() {
return
}
to := st.focus
for _, key := range []ui.Key{ui.KeyLeft, ui.KeyRight, ui.KeyUp, ui.KeyDown} {
if !plot.Shortcut(0, key) {
continue
}
switch {
case st.focus < 0:
to = 0
case spec.move != nil:
to = spec.move(st.focus, key)
case key == ui.KeyLeft || key == ui.KeyUp:
to = max(st.focus-1, 0)
default:
to = min(st.focus+1, spec.n-1)
}
}
if plot.Shortcut(0, ui.KeyHome) {
to = 0
}
if plot.Shortcut(0, ui.KeyEnd) {
to = spec.n - 1
}
if plot.Shortcut(0, ui.KeyEscape) {
to = -1
}
if to != st.focus {
st.focus = to
if to >= 0 {
ch.c.Announce(spec.readout(to))
}
slog.Debug("mujica: chart focus", "label", ch.frame.Label, "mark", to)
}
act := st.focus >= 0 && (plot.Shortcut(0, ui.KeyEnter) || plot.Shortcut(0, ui.KeySpace))
if plot.Clicked() {
plot.Focus()
x, y, _ := plot.PointerPosition()
b := plot.Bounds()
if i := spec.pick(ui.Rect{W: b.W, H: b.H}, x, y); i >= 0 {
st.focus, act = i, true
}
}
if act && spec.activate != nil {
spec.activate(st.focus)
}
}
// legend shows the series keys; toggling ones are check boxes.
func (ch *chart) legend(spec chartSpec) {
c, k, st := ch.c, Tokens(ch.c), ch.st
u := Density(c).Unit()
ui.Row(c).Wrap().Gap(u * 3).Children(func() {
for _, key := range spec.keys {
ui.Row(c).Key(key.Name).Children(func() {
on := !st.hidden[key.Name]
var e *ui.Element
if spec.toggle {
e = ui.CheckboxBase(c, &on).Disabled(ch.res.Element.IsDisabled()).Label(key.Name)
internal.Ring(e, k.Focus, theme.ControlRadius)
if e.Changed() && !e.IsDisabled() {
st.hidden[key.Name] = !on
ch.res.toggled = key.Name
slog.Debug("mujica: chart toggle", "series", key.Name, "visible", on)
}
} else {
e = ui.Row(c)
}
e.MinHeight(theme.MinHitSize).Gap(u*2).AlignItems(ui.Center).Padding(0, u).Radius(theme.ControlRadius)
col := key.Color
if !on {
col = k.TextMuted
}
e.Children(func() {
ui.Box(c).Size(12, 12).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
if on {
internal.Marker(p, key.Shape, r.X+6, r.Y+6, 10, col)
} else {
p.Stroke(r, col, 2, 1)
}
})
t := ui.Text(c, key.Name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text)
if !on {
t.Strikethrough().TextColor(k.TextMuted)
}
})
})
}
})
}
// chartText draws s anchored at (x, y) by ax, ay.
func chartText(p *ui.Painter, s string, x, y, size float32, col ui.Color, ax, ay float32) (w, h float32) {
w, h = p.MeasureText(0, ui.Span{Text: s, Size: size})
p.Text(x-w*ax, y-h*ay, s, size, col)
return w, h
}
// chartInset is r less room for axis labels.
func chartInset(r ui.Rect, left, bottom float32) ui.Rect {
return ui.Rect{X: r.X + left, Y: r.Y + 8, W: max(r.W-left-12, 1), H: max(r.H-bottom-8, 1)}
}
// chartInk is Text or Surface, whichever reads better on bg.
func chartInk(k theme.Tokens, bg ui.Color) ui.Color {
if theme.Contrast(k.Text, bg) >= theme.Contrast(k.Surface, bg) {
return k.Text
}
return k.Surface
}
+108
View File
@@ -0,0 +1,108 @@
package mujica
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
)
// chartBuilders builds every Charts I component with a small data set.
func chartBuilders() map[string]func(c *ui.Context) ChartResult {
year := 2026
var path, path2 []string
var sel []ScatterKey
var graph NetworkGraphState
var rt RealtimeChartState
buf := NewRealtimeBuffer(10, "a")
buf.Append(rtStart, 1)
buf.Append(rtStart.Add(time.Second), 2)
target := 3.0
f := ChartFrame{Label: "Under test"}
return map[string]func(c *ui.Context) ChartResult{
"line": func(c *ui.Context) ChartResult { return LineChart(c, lineSeries(), LineChartOptions{Frame: f}) },
"area": func(c *ui.Context) ChartResult { return AreaChart(c, lineSeries(), AreaChartOptions{Frame: f}) },
"bar": func(c *ui.Context) ChartResult { return BarChart(c, barCats, barSeries(), BarChartOptions{Frame: f}) },
"pie": func(c *ui.Context) ChartResult { return PieChart(c, pieSlices, PieChartOptions{Frame: f}) },
"scat": func(c *ui.Context) ChartResult {
return ScatterChart(c, &sel, scatterGroups, ScatterChartOptions{Frame: f})
},
"radar": func(c *ui.Context) ChartResult {
return RadarChart(c, radarAxes, radarSeries(), RadarChartOptions{Frame: f})
},
"heat": func(c *ui.Context) ChartResult {
return HeatmapChart(c, heatRows, heatCols, heatValues(), HeatmapChartOptions{Frame: f})
},
"cal": func(c *ui.Context) ChartResult {
return CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: f})
},
"hist": func(c *ui.Context) ChartResult { return Histogram(c, []float64{1, 2, 3}, HistogramOptions{Frame: f}) },
"box": func(c *ui.Context) ChartResult { return BoxPlot(c, boxGroups, BoxPlotOptions{Frame: f}) },
"fall": func(c *ui.Context) ChartResult {
return WaterfallChart(c, waterfallSteps, WaterfallChartOptions{Frame: f})
},
"funnel": func(c *ui.Context) ChartResult { return FunnelChart(c, funnelStages, FunnelChartOptions{Frame: f}) },
"bullet": func(c *ui.Context) ChartResult {
return BulletChart(c, 2, BulletChartOptions{Frame: f, Target: &target, Bands: []float64{1, 4}})
},
"spark": func(c *ui.Context) ChartResult { return Sparkline(c, sparkValues, SparklineOptions{Frame: f}) },
"gauge": func(c *ui.Context) ChartResult {
return Gauge(c, 72, GaugeOptions{Frame: f, Max: 100, Segments: gaugeSegments})
},
"tree": func(c *ui.Context) ChartResult { return Treemap(c, &path, chartTree(), TreemapOptions{Frame: f}) },
"sun": func(c *ui.Context) ChartResult { return Sunburst(c, &path2, chartTree(), SunburstOptions{Frame: f}) },
"sankey": func(c *ui.Context) ChartResult {
return SankeyChart(c, sankeyNodes, sankeyLinks, SankeyChartOptions{Frame: f})
},
"graph": func(c *ui.Context) ChartResult {
return NetworkGraph(c, &graph, graphNodes, graphEdges, NetworkGraphOptions{Frame: f})
},
"rt": func(c *ui.Context) ChartResult {
return RealtimeChart(c, &rt, buf, RealtimeChartOptions{Frame: f, Window: time.Minute})
},
}
}
func TestChartsIgnoreInputWhenDisabled(t *testing.T) {
for name, build := range chartBuilders() {
values := 0
h := &chartHarness{disabled: true, build: func(c *ui.Context) ChartResult {
r := build(c)
if r.ValueChanged() || r.Retried() {
values++
}
if _, ok := r.Toggled(); ok {
values++
}
return r
}}
tt := ui.NewTester(h.view, 560, 480)
for range 6 {
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeySpace)
}
b := h.res.Element.Bounds()
tt.Click("Under test")
tt.ClickAt(b.X+b.W/2, b.Y+b.H/2)
if h.res.Point != -1 || values != 0 {
t.Errorf("%s: disabled chart took input: point %d, events %d", name, h.res.Point, values)
}
}
}
func TestChartsEnterAtFirstMark(t *testing.T) {
for name, build := range chartBuilders() {
h := &chartHarness{build: build}
tt := ui.NewTester(h.view, 560, 480)
tabTo(t, tt, "Under test")
tt.Key(0, ui.KeyRight)
if h.res.Point != 0 || chartReadout(tt) == msg2(tt) {
t.Errorf("%s: first arrow read point %d", name, h.res.Point)
}
}
}
// msg2 is the hint line, read back to tell it from a readout.
func msg2(*ui.Tester) string { return "Focus the chart and use the arrow keys to read values" }
+206
View File
@@ -0,0 +1,206 @@
package mujica
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
)
// ChartSeries is named values; NaN marks a missing one.
type ChartSeries struct {
Name string
Values []float64
}
type chartMemoEntry struct {
deps any
val any
}
// chartMemo recomputes f only when the comparable deps change.
func chartMemo[T any](ch *chart, name string, deps any, f func() T) T {
m := ui.Local(ch.res.Element, "mujica.memo."+name, func() chartMemoEntry { return chartMemoEntry{} })
if m.val == nil || m.deps != deps {
m.deps, m.val = deps, f()
}
return m.val.(T)
}
// chartID identifies a slice by first element and length.
func chartID[T any](s []T) string {
if len(s) == 0 {
return "0"
}
return fmt.Sprintf("%p:%d", &s[0], len(s))
}
// chartSeriesID identifies series data and the hidden set together.
func chartSeriesID(ch *chart, series []ChartSeries) string {
var b strings.Builder
for _, s := range series {
fmt.Fprintf(&b, "%s|%s|%v;", s.Name, chartID(s.Values), ch.hidden(s.Name))
}
return b.String()
}
// chartCheckSeries panics on duplicate names or unequal lengths.
func chartCheckSeries(series []ChartSeries) int {
if len(series) == 0 {
return 0
}
n := len(series[0].Values)
seen := map[string]bool{}
for _, s := range series {
if s.Name == "" || seen[s.Name] || len(s.Values) != n {
panic("mujica: chart series need unique names and equal lengths: " + s.Name)
}
seen[s.Name] = true
}
return n
}
// chartX is an ascending axis of numbers, times or indices.
type chartX struct {
vals []float64
times bool
// d0 and d1, when d1 > d0, fix the domain.
d0, d1 float64
}
func newChartX(n int, x []float64, times []time.Time) chartX {
switch {
case x != nil && times != nil:
panic("mujica: chart takes X or Times, not both")
case x != nil && len(x) != n, times != nil && len(times) != n:
panic("mujica: chart X or Times length differs from the series")
}
a := chartX{vals: x, times: times != nil}
if times != nil {
a.vals = make([]float64, n)
for i, t := range times {
a.vals[i] = float64(t.UnixNano()) / 1e9
}
}
if a.vals == nil {
a.vals = make([]float64, n)
for i := range a.vals {
a.vals[i] = float64(i)
}
}
if !sort.Float64sAreSorted(a.vals) {
panic("mujica: chart X or Times must ascend")
}
return a
}
func (a chartX) scale(plot ui.Rect) internal.Linear {
d0, d1 := a.vals[0], a.vals[len(a.vals)-1]
if a.d1 > a.d0 {
d0, d1 = a.d0, a.d1
}
return internal.Linear{D0: d0, D1: d1, R0: float64(plot.X), R1: float64(plot.X + plot.W)}
}
func (a chartX) label(i int) string {
if a.times {
return chartTime(a.vals[i]).Format("2006-01-02 15:04:05")
}
return strconv.FormatFloat(a.vals[i], 'g', 6, 64)
}
// ticks returns tick positions and labels along plot.
func (a chartX) ticks(plot ui.Rect) ([]float32, []string) {
s := a.scale(plot)
n := max(2, int(plot.W/90))
var xs []float32
var labels []string
if a.times {
ts, layout := internal.TimeTicks(chartTime(s.D0), chartTime(s.D1), n)
for _, t := range ts {
xs = append(xs, s.Map(float64(t.UnixNano())/1e9))
labels = append(labels, t.Format(layout))
}
return xs, labels
}
lo, hi, step := internal.Nice(s.D0, s.D1, n)
for _, t := range internal.Ticks(lo, hi, step) {
if t >= s.D0 && t <= s.D1 {
xs = append(xs, s.Map(t))
labels = append(labels, internal.FormatTick(t, step))
}
}
return xs, labels
}
// nearest returns the index whose x is closest to v.
func (a chartX) nearest(v float64) int {
i := sort.SearchFloat64s(a.vals, v)
if i == len(a.vals) || i > 0 && v-a.vals[i-1] < a.vals[i]-v {
i--
}
return i
}
func chartTime(sec float64) time.Time {
return time.Unix(0, int64(math.Round(sec*1e9))).UTC()
}
// chartYDomain spans visible finite values, optionally with zero.
func chartYDomain(series []ChartSeries, hidden func(string) bool, withZero bool) (lo, hi float64, ok bool) {
lo, hi = math.Inf(1), math.Inf(-1)
for _, s := range series {
if hidden(s.Name) {
continue
}
for _, v := range s.Values {
if !math.IsNaN(v) {
lo, hi = min(lo, v), max(hi, v)
}
}
}
if math.IsInf(lo, 1) {
return 0, 0, false
}
if withZero {
lo, hi = min(lo, 0), max(hi, 0)
}
return lo, hi, true
}
// chartValue formats v, or names a missing value.
func chartValue(c *ui.Context, v float64, format func(float64) string) string {
if math.IsNaN(v) {
return msg(c, "chart.missing")
}
if format != nil {
return format(v)
}
return strconv.FormatFloat(v, 'g', 6, 64)
}
// chartRuns calls f per finite run, thinned to pixel columns.
func chartRuns(vals []float64, x func(i int) float32, f func(idx []int)) {
for i := 0; i < len(vals); {
if math.IsNaN(vals[i]) {
i++
continue
}
j := i
for j < len(vals) && !math.IsNaN(vals[j]) {
j++
}
run := internal.Columns(j-i, func(k int) float32 { return x(i + k) }, func(k int) float64 { return vals[i+k] })
for k := range run {
run[k] += i
}
f(run)
i = j
}
}
+171
View File
@@ -0,0 +1,171 @@
package mujica
import (
"log/slog"
"math"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/icons"
"mujicaui/internal"
"mujicaui/theme"
)
// ChartNode is a hierarchy node; branches sum their leaves.
type ChartNode struct {
Name string
Value float64
Children []ChartNode
}
// total is the node's value, or its children's sum.
func (n ChartNode) total() float64 {
if len(n.Children) == 0 {
return n.Value
}
s := 0.0
for _, ch := range n.Children {
s += ch.total()
}
return s
}
// chartCheckTree panics on unnamed, duplicate-sibling or negative nodes.
func chartCheckTree(n ChartNode) {
if n.Name == "" || n.Value < 0 || math.IsNaN(n.Value) || math.IsInf(n.Value, 0) {
panic("mujica: chart nodes need a name and a finite value ≥ 0")
}
seen := map[string]bool{}
for _, ch := range n.Children {
if seen[ch.Name] {
panic("mujica: chart node has two children named " + ch.Name)
}
seen[ch.Name] = true
chartCheckTree(ch)
}
}
// chartDescend returns the node at path, panicking if unknown.
func chartDescend(root ChartNode, path []string) ChartNode {
n := root
for _, name := range path {
i := slices.IndexFunc(n.Children, func(ch ChartNode) bool { return ch.Name == name })
if i < 0 {
panic("mujica: chart path has no node " + name)
}
n = n.Children[i]
}
return n
}
// chartPath shows Back and the breadcrumb; reports a move up.
func chartPath(c *ui.Context, root string, path *[]string, disabled bool) bool {
k := Tokens(c)
u := Density(c).Unit()
moved := false
ui.Row(c).Gap(u).AlignItems(ui.Center).Wrap().Children(func() {
back := iconAction(c, icons.Must("chevron-left"), msg(c, "chart.back")).Disabled(disabled || len(*path) == 0)
if back.Clicked() && !back.IsDisabled() {
*path, moved = (*path)[:len(*path)-1], true
}
names := append([]string{root}, *path...)
for i, name := range names {
if i > 0 {
ui.Text(c, "›").TextColor(k.TextMuted)
}
if i == len(names)-1 {
ui.Text(c, name).FontWeight(600).SingleLine()
continue
}
crumb := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u).Radius(theme.ControlRadius).Disabled(disabled).TextColor(k.AccentText)
internal.Ring(crumb, k.Focus, theme.ControlRadius)
crumb.Children(func() { ui.Text(c, name).Underline().SingleLine() })
if crumb.Clicked() && !crumb.IsDisabled() {
*path, moved = slices.Clone((*path)[:i]), true
}
}
})
if moved {
slog.Debug("mujica: chart path", "path", strings.Join(*path, "/"))
}
return moved
}
// chartFit shortens s with an ellipsis to fit w.
func chartFit(p *ui.Painter, s string, w, size float32) string {
r := []rune(s)
for len(r) > 0 {
t := string(r)
if len(r) < len([]rune(s)) {
t += "…"
}
if tw, _ := p.MeasureText(0, ui.Span{Text: t, Size: size}); tw <= w {
return t
}
r = r[:len(r)-1]
}
return ""
}
// squarify tiles descending values into near-square rectangles.
func squarify(values []float64, r ui.Rect) []ui.Rect {
out := make([]ui.Rect, len(values))
total := 0.0
for _, v := range values {
total += v
}
if total <= 0 {
return out
}
scale := float64(r.W*r.H) / total
for i := 0; i < len(values); {
side := float64(min(r.W, r.H))
j, best := i+1, math.Inf(1)
for ; j <= len(values); j++ {
worst := squarifyWorst(values[i:j], side, scale)
if worst > best {
break
}
best = worst
}
j--
row := 0.0
for _, v := range values[i:j] {
row += v * scale
}
thick := float32(row / side)
at := float32(0)
for k := i; k < j; k++ {
l := float32(values[k]*scale) / thick
if r.W >= r.H {
out[k] = ui.Rect{X: r.X, Y: r.Y + at, W: thick, H: l}
} else {
out[k] = ui.Rect{X: r.X + at, Y: r.Y, W: l, H: thick}
}
at += l
}
if r.W >= r.H {
r.X, r.W = r.X+thick, r.W-thick
} else {
r.Y, r.H = r.Y+thick, r.H-thick
}
i = j
}
return out
}
// squarifyWorst is a row's worst aspect ratio along side.
func squarifyWorst(row []float64, side, scale float64) float64 {
sum, lo, hi := 0.0, math.Inf(1), 0.0
for _, v := range row {
a := v * scale
sum, lo, hi = sum+a, min(lo, a), max(hi, a)
}
if lo == 0 {
return math.Inf(1)
}
s2 := side * side
return max(s2*hi/(sum*sum), sum*sum/(s2*lo))
}
+1 -1
View File
@@ -98,7 +98,7 @@ func (v DataTableView) Submitted() bool { return v.submitted }
// SortChanged reports a header click or menu choice that changed State.Sort.
func (v DataTableView) SortChanged() bool { return v.sorted }
// Retried reports a click on the retry action of a failed load.
// Retried reports a click on a failed load's retry.
func (v DataTableView) Retried() bool { return v.retry }
const dataTableMarkID = "mujica.select"
+54
View File
@@ -0,0 +1,54 @@
# 101 LineChart 折线图
## 用途
多序列折线图:数值或时间横轴,缺值(NaN)处断开,悬停与键盘读数,图例勾选框控制序列显隐。大数据按像素列抽稀绘制,10 万点保持流畅。
## 最小示例
```go
series := []mujica.ChartSeries{
{Name: "Pews", Values: []float64{3, 5, math.NaN(), 4, 8}},
{Name: "Candles", Values: []float64{5, 2, 6, 7, 1}},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.LineChart(c, series, mujica.LineChartOptions{Frame: mujica.ChartFrame{Label: "Chapel"}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func LineChart(c *ui.Context, series []ChartSeries, opts LineChartOptions) ChartResult`;`ChartSeries{Name, Values}`,各序列等长且名称唯一。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `X` | `[]float64` | nil | 点 i 的横坐标,须递增;nil 时为下标。 |
| `Times` | `[]time.Time` | nil | 时间轴;与 `X` 二选一,须递增。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 读数中的数值格式。 |
## 状态
默认、悬停或键盘读数(竖线 + 各序列放大标记)、隐藏序列(图例划线、空心标记)、加载、失败、无数据(全部为缺值时)。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;`Toggled()` 返回本帧被切换显隐的序列名。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):在数据点之间移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
- 图例勾选框:Space 切换序列显隐。
## 限制
- 数据切片以首元素地址与长度识别;原地修改数据后请传入新切片,缓存的坐标范围才会更新。
- 每条序列除颜色外还有不同形状的标记(圆、方、菱形、三角),密集时按间隔绘制。
+54
View File
@@ -0,0 +1,54 @@
# 102 AreaChart 面积图
## 用途
面积图:单序列填充到基线,或多序列堆叠;基线可配置;悬停与键盘读数显示各序列原值(不是堆叠后的值)。
## 最小示例
```go
series := []mujica.ChartSeries{
{Name: "Matins", Values: []float64{3, 4, 6, 5}},
{Name: "Vespers", Values: []float64{2, 3, 2, 4}},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AreaChart(c, series, mujica.AreaChartOptions{Frame: mujica.ChartFrame{Label: "Attendance"}, Stacked: true})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func AreaChart(c *ui.Context, series []ChartSeries, opts AreaChartOptions) ChartResult`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `X`, `Times` | | nil | 同 LineChart。 |
| `Stacked` | `bool` | false | 可见序列依次堆叠;隐藏序列不参与堆叠。 |
| `Baseline` | `float64` | `0` | 面积的起始值;坐标轴总包含基线。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 读数格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;`Toggled()` 同 LineChart。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):在数据点之间移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
## 限制
- 缺值处面积断开;堆叠时缺值按 0 计入后续序列。
- 填充为半透明的序列色,边线保持实色以满足 3:1 对比。
+52
View File
@@ -0,0 +1,52 @@
# 103 BarChart 柱状图
## 用途
柱状图:纵向或横向,分组或堆叠,支持负值与柱端数值标签,键盘逐柱读数。
## 最小示例
```go
categories := []string{"Nave", "Choir", "Crypt"}
series := []mujica.ChartSeries{{Name: "Gold", Values: []float64{4, -2, 3}}, {Name: "Silver", Values: []float64{2, 5, 1}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.BarChart(c, categories, series, mujica.BarChartOptions{Frame: mujica.ChartFrame{Label: "Tithes"}, Labels: true})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts BarChartOptions) ChartResult`;每条序列的值与类别一一对应,NaN 不画柱。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Horizontal` | `bool` | false | 类别在纵轴,柱横向延伸。 |
| `Stacked` | `bool` | false | 每个类别一根柱,正值向上、负值向下堆叠。 |
| `Labels` | `bool` | false | 在柱端写出数值(堆叠时不写)。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 选中柱以描边标出,柱内有序列形状标记。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;多序列时 `Toggled()` 报告图例切换。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):逐柱移动(按类别、再按序列),读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
## 限制
- 极细的柱仍有 6 DIP 的悬停范围。
- 类别标签过密时会跳过重叠的标签,读数仍包含完整类别名。
+50
View File
@@ -0,0 +1,50 @@
# 104 PieChart 饼图
## 用途
饼图与环形图:扇区按占比绘制并标注百分比;过窄的扇区可合并为“其他”;环形图中心显示总计。
## 最小示例
```go
slices := []mujica.ChartSlice{{Label: "Wax", Value: 50}, {Label: "Oil", Value: 30}, {Label: "Salt", Value: 4}, {Label: "Ash", Value: 3}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.PieChart(c, slices, mujica.PieChartOptions{Frame: mujica.ChartFrame{Label: "Stores"}, Donut: true, MinAngle: 20})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func PieChart(c *ui.Context, slices []ChartSlice, opts PieChartOptions) ChartResult`;`ChartSlice{Label, Value}`,值须为有限且 ≥ 0。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Donut` | `bool` | false | 环形图,中心写总计。 |
| `MinAngle` | `float64` | `0` | 小于该角度(度)的扇区达到两个及以上时合并为“其他”;须在 [0, 360)。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 当前扇区向外凸出。总计为 0 时显示无数据。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):在数据点之间移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
## 限制
- 只有一个窄扇区时不合并,以免“其他”只代表一项。
- 扇区内的形状标记与百分比文字按对比度自动选用正文色或表面色。
+52
View File
@@ -0,0 +1,52 @@
# 105 ScatterChart 散点图
## 用途
散点图:按分组着色并配不同形状,点大小可映射为气泡,拖动框选或用键盘选中点。
## 最小示例
```go
var selected []mujica.ScatterKey
groups := []mujica.ScatterGroup{{Name: "Monks", Points: []mujica.ScatterPoint{{X: 1, Y: 2, Size: 4}, {X: 3, Y: 5, Size: 9}}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ScatterChart(c, &selected, groups, mujica.ScatterChartOptions{Frame: mujica.ChartFrame{Label: "Choir"}, Bubbles: true})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func ScatterChart(c *ui.Context, selected *[]ScatterKey, groups []ScatterGroup, opts ScatterChartOptions) ChartResult`;`ScatterGroup{Name, Points []ScatterPoint{X, Y, Size}}`,`ScatterKey{Group, Point}` 标识一个点。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Bubbles` | `bool` | false | 按 `Size` 映射点面积(直径 4–18 DIP)。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 读数格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 选中的点外加圆环,读数注明“已选”。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;`ValueChanged()` 表示 `*selected` 被框选或键盘改变;多组时 `Toggled()` 报告图例切换。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):在数据点之间移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
- Enter/Space:选中或取消当前点。
- 在绘图区拖动:框选,替换现有选择。
## 限制
- 框选替换选择,不支持按住修饰键追加。
- 点数较多时逐点绘制,没有抽稀。
+49
View File
@@ -0,0 +1,49 @@
# 106 RadarChart 雷达图
## 用途
雷达图:三个及以上维度,多序列多边形,刻度环,按维度悬停或键盘读数。
## 最小示例
```go
axes := []string{"Voice", "Latin", "Chant", "Organ"}
series := []mujica.ChartSeries{{Name: "Agnès", Values: []float64{8, 6, 9, 4}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.RadarChart(c, axes, series, mujica.RadarChartOptions{Frame: mujica.ChartFrame{Label: "Skills"}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func RadarChart(c *ui.Context, axes []string, series []ChartSeries, opts RadarChartOptions) ChartResult`;每条序列每个维度一个值,值须 ≥ 0。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Max` | `float64` | 自动取整 | 外环代表的值;0 按数据向上取整。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 读数格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 当前维度的轴线加粗、标签变为正文色。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;多序列时 `Toggled()` 报告图例切换。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):按维度移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
## 限制
- 缺值按 0 画在中心,读数显示“无数值”。
+48
View File
@@ -0,0 +1,48 @@
# 107 HeatmapChart 热力图
## 用途
二维矩阵热力图:按值分五级着色,右侧色阶图例标出最小与最大值;单元格读数;空值单元格为虚线框。
## 最小示例
```go
rows, cols := []string{"Mon", "Tue"}, []string{"Matins", "Lauds", "Vespers"}
values := [][]float64{{1, 4, 9}, {3, math.NaN(), 7}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.HeatmapChart(c, rows, cols, values, mujica.HeatmapChartOptions{Frame: mujica.ChartFrame{Label: "Hours"}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func HeatmapChart(c *ui.Context, rows, cols []string, values [][]float64, opts HeatmapChartOptions) ChartResult`;`values[行][列]`,NaN 为空值。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 单元格与读数格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 单元格足够大时直接写出数值,不只依赖颜色。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。
## 键盘操作
- Tab:聚焦热力图。
- ←/→:同一行左右移动;↑/↓:同一列上下移动;第一次按键进入左上角。
- Home/End:第一个/最后一个单元格;Escape:清除读数。
## 限制
- 颜色分五级,不做连续渐变。
+50
View File
@@ -0,0 +1,50 @@
# 108 CalendarHeatmap 日历热力图
## 用途
按日的贡献图:一列一周,按当年最大值分四级着色;年份切换;周起始可配置;逐日读数。
## 最小示例
```go
year := 2026
values := map[mujica.Date]float64{{Year: 2026, Month: time.March, Day: 4}: 5}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.CalendarHeatmap(c, &year, values, mujica.CalendarHeatmapOptions{Frame: mujica.ChartFrame{Label: "Prayers"}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func CalendarHeatmap(c *ui.Context, year *int, values map[Date]float64, opts CalendarHeatmapOptions) ChartResult`;`*year` 须在 1–9999。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `WeekStart` | `WeekStart` | 跟随语言 | 每列第一行是星期几。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 读数格式。 |
## 状态
默认、无记录日(浅色格)、四级色阶、悬停或键盘读数(描边)、加载、失败。下方图例从“少”到“多”。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;`ValueChanged()` 表示上一年/下一年按钮改变了 `*year`。
## 键盘操作
- Tab:年份按钮,再到日历。
- ←/→:前后一周;↑/↓:前后一天;Home/End:全年第一天与最后一天。
- 上一年/下一年按钮:Enter/Space。
## 限制
- 方格较小,只作读数目标,不是独立操作,因此不要求 28 DIP 命中区。
- 值 ≤ 0 视为无记录。
+50
View File
@@ -0,0 +1,50 @@
# 109 Histogram 直方图
## 用途
分箱计数直方图:箱数或箱宽可配置,可切换为累计模式;分箱只在数据或参数变化时计算一次。
## 最小示例
```go
values := []float64{1, 2, 2, 3, 4, 5, 9, 10}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Histogram(c, values, mujica.HistogramOptions{Frame: mujica.ChartFrame{Label: "Ages"}, BinWidth: 5})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Histogram(c *ui.Context, values []float64, opts HistogramOptions) ChartResult`;NaN 与无穷值被忽略。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Bins` | `int` | Sturges 规则 | 箱数;负数 panic。 |
| `BinWidth` | `float64` | 0 | 箱宽;设定时优先于 `Bins`,箱边取箱宽的整数倍。超过 10000 箱 panic。 |
| `Cumulative` | `bool` | false | 每箱计入不超过其上界的全部值。 |
| `Format` | `func(float64) string` | 按箱宽取精度 | 箱边格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):逐箱移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
## 限制
- 按箱数分箱时最后一箱包含上界,读数用 `]` 表示。
+47
View File
@@ -0,0 +1,47 @@
# 110 BoxPlot 箱线图
## 用途
箱线图:四分位箱、中位线、1.5 倍四分距内的须线与离群点,多组对比。统计只在数据变化时计算。
## 最小示例
```go
groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 5, 40}}, {Name: "Nave", Values: []float64{4, 5, 6, 7}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.BoxPlot(c, groups, mujica.BoxPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func BoxPlot(c *ui.Context, groups []BoxGroup, opts BoxPlotOptions) ChartResult`;`BoxGroup{Name, Values}`,名称必填。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 读数格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 离群点画为空心圆。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):按组移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
## 限制
- 四分位数用线性插值计算。
+52
View File
@@ -0,0 +1,52 @@
# 111 WaterfallChart 瀑布图
## 用途
瀑布图:增减项累计,小计与总计柱显示累计值;增加为 Success 色并配 ▲,减少为 Danger 色并配 ▼,合计为中性色配 ■,柱端写出带符号的数值。
## 最小示例
```go
steps := []mujica.WaterfallStep{
{Label: "Tithes", Value: 12},
{Label: "Repairs", Value: -5},
{Label: "Year", Kind: mujica.WaterfallTotal},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.WaterfallChart(c, steps, mujica.WaterfallChartOptions{Frame: mujica.ChartFrame{Label: "Ledger"}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func WaterfallChart(c *ui.Context, steps []WaterfallStep, opts WaterfallChartOptions) ChartResult`;`WaterfallStep{Label, Value, Kind}`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `steps[i].Kind` | `WaterfallKind` | `WaterfallDelta` | `WaterfallDelta` 累加;`WaterfallSubtotal`、`WaterfallTotal` 显示当前累计,`Value` 须为 0。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):逐柱移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
## 限制
- 小计与总计都显示到该步为止的累计值,不清零。
+47
View File
@@ -0,0 +1,47 @@
# 112 FunnelChart 漏斗图
## 用途
漏斗图:阶段宽度与值成比例,右侧写出值与相对上一阶段的转化率;读数同时给出相对首阶段的比例。
## 最小示例
```go
stages := []mujica.ChartSlice{{Label: "Visitors", Value: 1000}, {Label: "Novices", Value: 400}, {Label: "Vows", Value: 100}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FunnelChart(c, stages, mujica.FunnelChartOptions{Frame: mujica.ChartFrame{Label: "Order"}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func FunnelChart(c *ui.Context, stages []ChartSlice, opts FunnelChartOptions) ChartResult`;值须为有限且 ≥ 0。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 全部为 0 时显示无数据。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):逐阶段移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
## 限制
- 上一阶段为 0 时转化率显示“—”。
+51
View File
@@ -0,0 +1,51 @@
# 113 BulletChart 子弹图
## 用途
子弹图:实际值的细条、目标值的垂直线与定性区间带,可横向或纵向。
## 最小示例
```go
target := 250.0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.BulletChart(c, 270, mujica.BulletChartOptions{Frame: mujica.ChartFrame{Label: "Alms"}, Target: &target, Bands: []float64{150, 225, 300}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func BulletChart(c *ui.Context, value float64, opts BulletChartOptions) ChartResult`;值须 ≥ 0。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `72` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Target` | `*float64` | nil | 目标值,画为与刻度垂直的粗线。 |
| `Bands` | `[]float64` | nil | 各区间的上界,须为正且递增。 |
| `Max` | `float64` | 自动取整 | 刻度终点;0 按值、目标与区间取整。 |
| `Vertical` | `bool` | false | 纵向摆放。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 区间由浅到深的中性色带表示,读数写出区间范围。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):依次读实际值、目标值与各区间,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
## 限制
- 超出 `Max` 的值画到刻度终点,读数仍为原值。
+48
View File
@@ -0,0 +1,48 @@
# 114 Sparkline 迷你图
## 用途
行内迷你图:折线、柱形或面积,无坐标轴;末值以菱形标记并在右侧写出数值;聚焦后可逐点读数。
## 最小示例
```go
values := []float64{3, 5, 4, 7, 6, 9}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Sparkline(c, values, mujica.SparklineOptions{Frame: mujica.ChartFrame{Label: "Bells"}, Kind: mujica.SparklineArea})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResult`;NaN 为缺值。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `28` | 迷你图高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Kind` | `SparklineKind` | `SparklineLine` | `SparklineLine`、`SparklineBar` 或 `SparklineArea`。 |
| `Width` | `float32` | `96` | 迷你图宽度(DIP)。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 右侧数值格式。 |
## 状态
默认(显示末值)、悬停或键盘读数(右侧数值变为当前点,点外加圆环)、加载(小型加载标记)、失败(错误文字与重试图标)、无数据。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。
## 键盘操作
- Tab:聚焦迷你图。
- ←/→:逐点移动;Home/End:首尾;Escape:恢复显示末值。
## 限制
- 不显示图例与提示行,以便放在表格行或文字中。
+51
View File
@@ -0,0 +1,51 @@
# 115 Gauge 仪表盘
## 用途
仪表盘:半圆或整圆表盘,按阈值分段着色,指针指向当前值,下方写出数值、单位和所在分段的名称,不只靠颜色表达区间。
## 最小示例
```go
segments := []mujica.GaugeSegment{{To: 60, Label: "Calm", Tone: mujica.GaugeGood}, {To: 100, Label: "Crowded", Tone: mujica.GaugeDanger}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Gauge(c, 72, mujica.GaugeOptions{Frame: mujica.ChartFrame{Label: "Nave"}, Max: 100, Unit: "%", Segments: segments})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Gauge(c *ui.Context, value float64, opts GaugeOptions) ChartResult`;值须在 `Min`–`Max` 之间。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `200` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Min`, `Max` | `float64` | 必须 `Min < Max` | 刻度范围。 |
| `Segments` | `[]GaugeSegment` | nil | 分段 `{To, Label, Tone}`,上界递增且最后一段止于 `Max`;`Tone` 为 `GaugeNeutral`、`GaugeGood`、`GaugeWarning`、`GaugeDanger`。 |
| `Unit` | `string` | 空 | 数值后的单位。 |
| `Full` | `bool` | false | 整圆表盘,从底部顺时针一周。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 当前分段加宽显示。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):依次读当前值与各分段,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
## 限制
- 不做指针动画,值变化时直接切换。
+53
View File
@@ -0,0 +1,53 @@
# 116 Treemap 矩形树图
## 用途
层级矩形树图:按大小平铺当前节点的子项(squarify 布局),点击或回车下钻有子项的矩形,返回按钮与面包屑回到上层;标签按面积显示并截断。
## 最小示例
```go
var path []string
root := mujica.ChartNode{Name: "Abbey", Children: []mujica.ChartNode{
{Name: "Library", Children: []mujica.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}},
{Name: "Kitchen", Value: 25},
}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Treemap(c, &path, root, mujica.TreemapOptions{Frame: mujica.ChartFrame{Label: "Rooms"}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Treemap(c *ui.Context, path *[]string, root ChartNode, opts TreemapOptions) ChartResult`;`ChartNode{Name, Value, Children}`,叶子的 `Value` 为大小,分支为子项之和;`*path` 是从根的子项起的名称路径。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 当前矩形内描边。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;`ValueChanged()` 表示下钻、返回或点击面包屑改变了 `*path`。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):按大小顺序在矩形间移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
- Enter/Space:下钻;Backspace:返回上一层;返回按钮与面包屑可用 Tab 到达。
## 限制
- 名称为空、兄弟同名或值为负时 panic;`*path` 指向不存在的节点时 panic。
- 只显示当前节点的一层子项。
+53
View File
@@ -0,0 +1,53 @@
# 117 Sunburst 旭日图
## 用途
层级环形图:中心是当前节点及总计,外圈依次是各层后代;点击或回车下钻,路径面包屑与返回按钮回到上层。
## 最小示例
```go
var path []string
root := mujica.ChartNode{Name: "Abbey", Children: []mujica.ChartNode{
{Name: "Library", Children: []mujica.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}},
{Name: "Kitchen", Value: 25},
}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Sunburst(c, &path, root, mujica.SunburstOptions{Frame: mujica.ChartFrame{Label: "Rooms"}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func Sunburst(c *ui.Context, path *[]string, root ChartNode, opts SunburstOptions) ChartResult`;数据结构同 Treemap。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Depth` | `int` | `3` | 显示的层数;负数 panic。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 外层颜色向表面色淡化,但保持 3:1 对比。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;`ValueChanged()` 表示 `*path` 改变。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):按深度优先顺序在扇区间移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
- Enter/Space:下钻到当前扇区;Backspace:返回上一层。
## 限制
- 弧长不足时不写标签,名称在读数中完整给出。
+49
View File
@@ -0,0 +1,49 @@
# 118 SankeyChart 桑基图
## 用途
桑基图:节点按最长路径分列,列内顺序与传入顺序一致(稳定);流量带宽度与值成比例;悬停或键盘读到节点或流量时,高亮经过它的全部路径。
## 最小示例
```go
nodes := []mujica.SankeyNode{{ID: "tithe", Label: "Tithes"}, {ID: "fund", Label: "Fund"}, {ID: "roof", Label: "Roof"}}
links := []mujica.SankeyLink{{From: "tithe", To: "fund", Value: 60}, {From: "fund", To: "roof", Value: 50}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SankeyChart(c, nodes, links, mujica.SankeyChartOptions{Frame: mujica.ChartFrame{Label: "Money"}})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func SankeyChart(c *ui.Context, nodes []SankeyNode, links []SankeyLink, opts SankeyChartOptions) ChartResult`;`SankeyNode{ID, Label}`,`SankeyLink{From, To, Value}`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 数值格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 高亮时其余流量淡化。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):先按节点、再按流量移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
## 限制
- 链接成环、端点未知或值不为正时 panic。
- 布局只在节点或链接切片变化时计算。
+51
View File
@@ -0,0 +1,51 @@
# 119 NetworkGraph 网络图
## 用途
网络图:节点与边,使用调用方给定的坐标或简单的力导布局;拖动节点、拖动空白处平移、滚轮或 +/- 缩放。
## 最小示例
```go
var state mujica.NetworkGraphState
nodes := []mujica.GraphNode{{ID: "a", Label: "Abbot"}, {ID: "b", Label: "Prior"}, {ID: "c", Label: "Novice"}}
edges := []mujica.GraphEdge{{From: "a", To: "b"}, {From: "b", To: "c"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.NetworkGraph(c, &state, nodes, edges, mujica.NetworkGraphOptions{Frame: mujica.ChartFrame{Label: "House"}, Force: true})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func NetworkGraph(c *ui.Context, s *NetworkGraphState, nodes []GraphNode, edges []GraphEdge, opts NetworkGraphOptions) ChartResult`;`GraphNode{ID, Label, X, Y}`,`GraphEdge{From, To}`。`NetworkGraphState` 保存拖动后的位置与视图:`Position(id)`、`Zoom()`、`ResetView()`。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Force` | `bool` | false | 用力导模拟布局(固定 300 步,结果确定);最多 500 个节点。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 当前节点外加圆环,相连的边加粗。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试。
## 键盘操作
- Tab:聚焦绘图区(图例勾选框在其前)。
- ←/→(以及 ↑/↓):在节点间移动,读数显示在图表下方并向读屏软件播报;第一次按键进入第一个点。
- Home/End:第一个/最后一个点;Escape:清除读数。
- Shift+方向键:移动当前节点;+/-:缩放;0:复位视图。
## 限制
- 布局只在节点或边的切片变化时重新计算,拖动不会重新适配视图。
- 不支持边标签与有向箭头。
+53
View File
@@ -0,0 +1,53 @@
# 120 RealtimeChart 实时图表
## 用途
实时折线图:调用方向 `RealtimeBuffer` 追加数据后刷新,图表显示截至最新数据的固定时间窗口;可暂停视图;组件自身不设计时器,隐藏或空闲时不重绘。
## 最小示例
```go
buf := mujica.NewRealtimeBuffer(600, "Bell")
start := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
for i := range 60 {
buf.Append(start.Add(time.Duration(i)*time.Second), float64(i%7))
}
var state mujica.RealtimeChartState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.RealtimeChart(c, &state, buf, mujica.RealtimeChartOptions{Frame: mujica.ChartFrame{Label: "Sound"}, Window: 30 * time.Second})
}
ui.Render(view, 400, 300, 1)
```
## 参数
`func RealtimeChart(c *ui.Context, s *RealtimeChartState, buf *RealtimeBuffer, opts RealtimeChartOptions) ChartResult`;`NewRealtimeBuffer(capacity, names...)` 保存最近 `capacity` 个样本,`Append(t, values...)` 每个序列一个值且时间不倒退。
| 字段 | 类型 | 默认值 | 含义 |
| --- | --- | --- | --- |
| `Frame.Label` | `string` | 必填 | 图表的无障碍名称;为空 panic。 |
| `Frame.Height` | `float32` | `240` | 绘图区高度(DIP);负数 panic。 |
| `Frame.Status` | `DataStatus` | `DataReady` | `DataLoading` 显示加载标记,`DataFailed` 显示错误与“重试”;占位与图表同尺寸。 |
| `Frame.Error` | `string` | 内置“加载失败” | 失败时的具体说明。 |
| `Frame.Empty` | `string` | 内置“暂无数据” | 无数据时的说明。 |
| `Window` | `time.Duration` | 必填 | 显示的时间窗口,止于最新样本(暂停时止于暂停那一刻)。 |
| `Format` | `func(float64) string` | 6 位有效数字 | 读数格式。 |
## 状态
默认、悬停读数、键盘读数(焦点环 + 当前点加粗或描边)、加载、失败(可重试)、无数据。图表区始终保持同样尺寸。 暂停时按钮显示“继续”,新数据继续进入缓冲区但不改变视图。
## 事件
返回 `ChartResult`:`Point` 为当前读数点(无则 -1);`Changed()` 读数点变化;`Retried()` 点击了重试;`ValueChanged()` 表示暂停或继续;`Toggled()` 报告图例切换。
## 键盘操作
- Tab:暂停按钮、图例、图表。
- 图表读数键同 LineChart;暂停按钮:Enter/Space。
## 限制
- 数据追加在后台时,请通过 `Window.Update` 回到界面线程追加并刷新。
- 缓冲区按块丢弃旧样本,最多占用两倍容量。
+221
View File
@@ -0,0 +1,221 @@
package mujica_test
import (
"math"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func ExampleLineChart() {
series := []mujica.ChartSeries{
{Name: "Pews", Values: []float64{3, 5, math.NaN(), 4, 8}},
{Name: "Candles", Values: []float64{5, 2, 6, 7, 1}},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.LineChart(c, series, mujica.LineChartOptions{Frame: mujica.ChartFrame{Label: "Chapel"}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleAreaChart() {
series := []mujica.ChartSeries{
{Name: "Matins", Values: []float64{3, 4, 6, 5}},
{Name: "Vespers", Values: []float64{2, 3, 2, 4}},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AreaChart(c, series, mujica.AreaChartOptions{Frame: mujica.ChartFrame{Label: "Attendance"}, Stacked: true})
}
ui.Render(view, 400, 300, 1)
}
func ExampleBarChart() {
categories := []string{"Nave", "Choir", "Crypt"}
series := []mujica.ChartSeries{{Name: "Gold", Values: []float64{4, -2, 3}}, {Name: "Silver", Values: []float64{2, 5, 1}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.BarChart(c, categories, series, mujica.BarChartOptions{Frame: mujica.ChartFrame{Label: "Tithes"}, Labels: true})
}
ui.Render(view, 400, 300, 1)
}
func ExamplePieChart() {
slices := []mujica.ChartSlice{{Label: "Wax", Value: 50}, {Label: "Oil", Value: 30}, {Label: "Salt", Value: 4}, {Label: "Ash", Value: 3}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.PieChart(c, slices, mujica.PieChartOptions{Frame: mujica.ChartFrame{Label: "Stores"}, Donut: true, MinAngle: 20})
}
ui.Render(view, 400, 300, 1)
}
func ExampleScatterChart() {
var selected []mujica.ScatterKey
groups := []mujica.ScatterGroup{{Name: "Monks", Points: []mujica.ScatterPoint{{X: 1, Y: 2, Size: 4}, {X: 3, Y: 5, Size: 9}}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ScatterChart(c, &selected, groups, mujica.ScatterChartOptions{Frame: mujica.ChartFrame{Label: "Choir"}, Bubbles: true})
}
ui.Render(view, 400, 300, 1)
}
func ExampleRadarChart() {
axes := []string{"Voice", "Latin", "Chant", "Organ"}
series := []mujica.ChartSeries{{Name: "Agnès", Values: []float64{8, 6, 9, 4}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.RadarChart(c, axes, series, mujica.RadarChartOptions{Frame: mujica.ChartFrame{Label: "Skills"}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleHeatmapChart() {
rows, cols := []string{"Mon", "Tue"}, []string{"Matins", "Lauds", "Vespers"}
values := [][]float64{{1, 4, 9}, {3, math.NaN(), 7}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.HeatmapChart(c, rows, cols, values, mujica.HeatmapChartOptions{Frame: mujica.ChartFrame{Label: "Hours"}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleCalendarHeatmap() {
year := 2026
values := map[mujica.Date]float64{{Year: 2026, Month: time.March, Day: 4}: 5}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.CalendarHeatmap(c, &year, values, mujica.CalendarHeatmapOptions{Frame: mujica.ChartFrame{Label: "Prayers"}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleHistogram() {
values := []float64{1, 2, 2, 3, 4, 5, 9, 10}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Histogram(c, values, mujica.HistogramOptions{Frame: mujica.ChartFrame{Label: "Ages"}, BinWidth: 5})
}
ui.Render(view, 400, 300, 1)
}
func ExampleBoxPlot() {
groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 5, 40}}, {Name: "Nave", Values: []float64{4, 5, 6, 7}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.BoxPlot(c, groups, mujica.BoxPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleWaterfallChart() {
steps := []mujica.WaterfallStep{
{Label: "Tithes", Value: 12},
{Label: "Repairs", Value: -5},
{Label: "Year", Kind: mujica.WaterfallTotal},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.WaterfallChart(c, steps, mujica.WaterfallChartOptions{Frame: mujica.ChartFrame{Label: "Ledger"}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleFunnelChart() {
stages := []mujica.ChartSlice{{Label: "Visitors", Value: 1000}, {Label: "Novices", Value: 400}, {Label: "Vows", Value: 100}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FunnelChart(c, stages, mujica.FunnelChartOptions{Frame: mujica.ChartFrame{Label: "Order"}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleBulletChart() {
target := 250.0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.BulletChart(c, 270, mujica.BulletChartOptions{Frame: mujica.ChartFrame{Label: "Alms"}, Target: &target, Bands: []float64{150, 225, 300}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSparkline() {
values := []float64{3, 5, 4, 7, 6, 9}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Sparkline(c, values, mujica.SparklineOptions{Frame: mujica.ChartFrame{Label: "Bells"}, Kind: mujica.SparklineArea})
}
ui.Render(view, 400, 300, 1)
}
func ExampleGauge() {
segments := []mujica.GaugeSegment{{To: 60, Label: "Calm", Tone: mujica.GaugeGood}, {To: 100, Label: "Crowded", Tone: mujica.GaugeDanger}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Gauge(c, 72, mujica.GaugeOptions{Frame: mujica.ChartFrame{Label: "Nave"}, Max: 100, Unit: "%", Segments: segments})
}
ui.Render(view, 400, 300, 1)
}
func ExampleTreemap() {
var path []string
root := mujica.ChartNode{Name: "Abbey", Children: []mujica.ChartNode{
{Name: "Library", Children: []mujica.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}},
{Name: "Kitchen", Value: 25},
}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Treemap(c, &path, root, mujica.TreemapOptions{Frame: mujica.ChartFrame{Label: "Rooms"}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSunburst() {
var path []string
root := mujica.ChartNode{Name: "Abbey", Children: []mujica.ChartNode{
{Name: "Library", Children: []mujica.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}},
{Name: "Kitchen", Value: 25},
}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Sunburst(c, &path, root, mujica.SunburstOptions{Frame: mujica.ChartFrame{Label: "Rooms"}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleSankeyChart() {
nodes := []mujica.SankeyNode{{ID: "tithe", Label: "Tithes"}, {ID: "fund", Label: "Fund"}, {ID: "roof", Label: "Roof"}}
links := []mujica.SankeyLink{{From: "tithe", To: "fund", Value: 60}, {From: "fund", To: "roof", Value: 50}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SankeyChart(c, nodes, links, mujica.SankeyChartOptions{Frame: mujica.ChartFrame{Label: "Money"}})
}
ui.Render(view, 400, 300, 1)
}
func ExampleNetworkGraph() {
var state mujica.NetworkGraphState
nodes := []mujica.GraphNode{{ID: "a", Label: "Abbot"}, {ID: "b", Label: "Prior"}, {ID: "c", Label: "Novice"}}
edges := []mujica.GraphEdge{{From: "a", To: "b"}, {From: "b", To: "c"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.NetworkGraph(c, &state, nodes, edges, mujica.NetworkGraphOptions{Frame: mujica.ChartFrame{Label: "House"}, Force: true})
}
ui.Render(view, 400, 300, 1)
}
func ExampleRealtimeChart() {
buf := mujica.NewRealtimeBuffer(600, "Bell")
start := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
for i := range 60 {
buf.Append(start.Add(time.Duration(i)*time.Second), float64(i%7))
}
var state mujica.RealtimeChartState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.RealtimeChart(c, &state, buf, mujica.RealtimeChartOptions{Frame: mujica.ChartFrame{Label: "Sound"}, Window: 30 * time.Second})
}
ui.Render(view, 400, 300, 1)
}
+75 -1
View File
@@ -1,3 +1,77 @@
package main
var charts1Pages = []page{}
import (
"fmt"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
"mujicaui/theme"
)
const c1Category = "Charts I"
const c1Keys = "Tab focuses the chart (legend check boxes first); arrows move between points and the readout is announced; Home/End jump; Escape clears."
var charts1Pages = []page{
{ID: "101", Name: "LineChart", ZhName: "折线图", Category: c1Category, Demo: c1LineDemo, Code: c1LineCode, Keys: c1Keys + " Space on a legend entry hides or shows its series."},
{ID: "102", Name: "AreaChart", ZhName: "面积图", Category: c1Category, Demo: c1AreaDemo, Code: c1AreaCode, Keys: c1Keys},
{ID: "103", Name: "BarChart", ZhName: "柱状图", Category: c1Category, Demo: c1BarDemo, Code: c1BarCode, Keys: c1Keys + " Arrows step bar by bar."},
{ID: "104", Name: "PieChart", ZhName: "饼图", Category: c1Category, Demo: c1PieDemo, Code: c1PieCode, Keys: c1Keys},
{ID: "105", Name: "ScatterChart", ZhName: "散点图", Category: c1Category, Demo: c1ScatterDemo, Code: c1ScatterCode, Keys: c1Keys + " Enter or Space selects the point; drag a box to select."},
{ID: "106", Name: "RadarChart", ZhName: "雷达图", Category: c1Category, Demo: c1RadarDemo, Code: c1RadarCode, Keys: c1Keys},
{ID: "107", Name: "HeatmapChart", ZhName: "热力图", Category: c1Category, Demo: c1HeatDemo, Code: c1HeatCode, Keys: "Tab focuses the map; Left/Right move along a row, Up/Down along a column; Home/End jump; Escape clears."},
{ID: "108", Name: "CalendarHeatmap", ZhName: "日历热力图", Category: c1Category, Demo: c1CalendarDemo, Code: c1CalendarCode, Keys: "Left/Right move a week, Up/Down a day; Home/End jump to the year's ends; the year buttons take Enter or Space."},
{ID: "109", Name: "Histogram", ZhName: "直方图", Category: c1Category, Demo: c1HistogramDemo, Code: c1HistogramCode, Keys: c1Keys + " Arrows step bin by bin."},
{ID: "110", Name: "BoxPlot", ZhName: "箱线图", Category: c1Category, Demo: c1BoxDemo, Code: c1BoxCode, Keys: c1Keys + " Arrows step group by group."},
{ID: "111", Name: "WaterfallChart", ZhName: "瀑布图", Category: c1Category, Demo: c1WaterfallDemo, Code: c1WaterfallCode, Keys: c1Keys},
{ID: "112", Name: "FunnelChart", ZhName: "漏斗图", Category: c1Category, Demo: c1FunnelDemo, Code: c1FunnelCode, Keys: c1Keys + " Arrows step stage by stage."},
{ID: "113", Name: "BulletChart", ZhName: "子弹图", Category: c1Category, Demo: c1BulletDemo, Code: c1BulletCode, Keys: c1Keys + " Arrows read the value, the target, then each range."},
{ID: "114", Name: "Sparkline", ZhName: "迷你图", Category: c1Category, Demo: c1SparkDemo, Code: c1SparkCode, Keys: "Tab focuses the sparkline; Left/Right read points; Home/End jump; Escape shows the last value again."},
{ID: "115", Name: "Gauge", ZhName: "仪表盘", Category: c1Category, Demo: c1GaugeDemo, Code: c1GaugeCode, Keys: c1Keys + " Arrows read the value, then each segment."},
{ID: "116", Name: "Treemap", ZhName: "矩形树图", Category: c1Category, Demo: c1TreemapDemo, Code: c1TreemapCode, Keys: c1Keys + " Enter or Space drills into a branch; Backspace goes back; Back and the breadcrumb take Tab."},
{ID: "117", Name: "Sunburst", ZhName: "旭日图", Category: c1Category, Demo: c1SunburstDemo, Code: c1SunburstCode, Keys: c1Keys + " Enter or Space drills in; Backspace goes back."},
{ID: "118", Name: "SankeyChart", ZhName: "桑基图", Category: c1Category, Demo: c1SankeyDemo, Code: c1SankeyCode, Keys: c1Keys + " Reading a node or link highlights every path through it."},
{ID: "119", Name: "NetworkGraph", ZhName: "网络图", Category: c1Category, Demo: c1NetworkDemo, Code: c1NetworkCode, Keys: c1Keys + " Shift+arrows move the node; +/- zoom; 0 resets the view; drag nodes or the background."},
{ID: "120", Name: "RealtimeChart", ZhName: "实时图表", Category: c1Category, Demo: c1RealtimeDemo, Code: c1RealtimeCode, Keys: "Tab reaches Pause, the legend and the chart; chart keys as in LineChart."},
}
var c1Statuses = []string{"Ready", "Loading", "Failed", "Empty"}
// c1Frame shows the status picker and returns the frame.
func c1Frame(c *ui.Context, label string, status *string) (mujica.ChartFrame, bool) {
ui.Select(c, status, c1Statuses).Width(160).AlignSelf(ui.Start)
f := mujica.ChartFrame{Label: label}
switch *status {
case "Loading":
f.Status = mujica.DataLoading
case "Failed":
f.Status, f.Error = mujica.DataFailed, "The archive did not answer"
}
return f, *status != "Empty"
}
// c1Section lays out a title, controls, then the chart.
func c1Section(c *ui.Context, title string, controls, body func()) {
ui.Column(c).Gap(12).Children(func() {
ui.Text(c, title).Font(theme.SerifFont).FontSize(mujica.FontSize(c, theme.H3Size))
ui.Row(c).Gap(16).Wrap().AlignItems(ui.Center).Children(controls)
ui.Column(c).Width(560).Children(body)
})
}
// c1Log records a chart's events in the gallery log.
func c1Log(log func(string), name string, r mujica.ChartResult) {
if r.Changed() {
log(fmt.Sprintf("%s: point %d", name, r.Point))
}
if s, ok := r.Toggled(); ok {
log(name + ": toggled " + s)
}
if r.ValueChanged() {
log(name + ": value changed")
}
if r.Retried() {
log(name + ": retry")
}
}
+174
View File
@@ -0,0 +1,174 @@
package main
// Snippets are the bodies of the Examples in example_charts1_test.go.
const c1LineCode = `series := []mujica.ChartSeries{
{Name: "Pews", Values: []float64{3, 5, math.NaN(), 4, 8}},
{Name: "Candles", Values: []float64{5, 2, 6, 7, 1}},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.LineChart(c, series, mujica.LineChartOptions{Frame: mujica.ChartFrame{Label: "Chapel"}})
}
ui.Render(view, 400, 300, 1)`
const c1AreaCode = `series := []mujica.ChartSeries{
{Name: "Matins", Values: []float64{3, 4, 6, 5}},
{Name: "Vespers", Values: []float64{2, 3, 2, 4}},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.AreaChart(c, series, mujica.AreaChartOptions{Frame: mujica.ChartFrame{Label: "Attendance"}, Stacked: true})
}
ui.Render(view, 400, 300, 1)`
const c1BarCode = `categories := []string{"Nave", "Choir", "Crypt"}
series := []mujica.ChartSeries{{Name: "Gold", Values: []float64{4, -2, 3}}, {Name: "Silver", Values: []float64{2, 5, 1}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.BarChart(c, categories, series, mujica.BarChartOptions{Frame: mujica.ChartFrame{Label: "Tithes"}, Labels: true})
}
ui.Render(view, 400, 300, 1)`
const c1PieCode = `slices := []mujica.ChartSlice{{Label: "Wax", Value: 50}, {Label: "Oil", Value: 30}, {Label: "Salt", Value: 4}, {Label: "Ash", Value: 3}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.PieChart(c, slices, mujica.PieChartOptions{Frame: mujica.ChartFrame{Label: "Stores"}, Donut: true, MinAngle: 20})
}
ui.Render(view, 400, 300, 1)`
const c1ScatterCode = `var selected []mujica.ScatterKey
groups := []mujica.ScatterGroup{{Name: "Monks", Points: []mujica.ScatterPoint{{X: 1, Y: 2, Size: 4}, {X: 3, Y: 5, Size: 9}}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.ScatterChart(c, &selected, groups, mujica.ScatterChartOptions{Frame: mujica.ChartFrame{Label: "Choir"}, Bubbles: true})
}
ui.Render(view, 400, 300, 1)`
const c1RadarCode = `axes := []string{"Voice", "Latin", "Chant", "Organ"}
series := []mujica.ChartSeries{{Name: "Agnès", Values: []float64{8, 6, 9, 4}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.RadarChart(c, axes, series, mujica.RadarChartOptions{Frame: mujica.ChartFrame{Label: "Skills"}})
}
ui.Render(view, 400, 300, 1)`
const c1HeatCode = `rows, cols := []string{"Mon", "Tue"}, []string{"Matins", "Lauds", "Vespers"}
values := [][]float64{{1, 4, 9}, {3, math.NaN(), 7}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.HeatmapChart(c, rows, cols, values, mujica.HeatmapChartOptions{Frame: mujica.ChartFrame{Label: "Hours"}})
}
ui.Render(view, 400, 300, 1)`
const c1CalendarCode = `year := 2026
values := map[mujica.Date]float64{{Year: 2026, Month: time.March, Day: 4}: 5}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.CalendarHeatmap(c, &year, values, mujica.CalendarHeatmapOptions{Frame: mujica.ChartFrame{Label: "Prayers"}})
}
ui.Render(view, 400, 300, 1)`
const c1HistogramCode = `values := []float64{1, 2, 2, 3, 4, 5, 9, 10}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Histogram(c, values, mujica.HistogramOptions{Frame: mujica.ChartFrame{Label: "Ages"}, BinWidth: 5})
}
ui.Render(view, 400, 300, 1)`
const c1BoxCode = `groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 5, 40}}, {Name: "Nave", Values: []float64{4, 5, 6, 7}}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.BoxPlot(c, groups, mujica.BoxPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}})
}
ui.Render(view, 400, 300, 1)`
const c1WaterfallCode = `steps := []mujica.WaterfallStep{
{Label: "Tithes", Value: 12},
{Label: "Repairs", Value: -5},
{Label: "Year", Kind: mujica.WaterfallTotal},
}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.WaterfallChart(c, steps, mujica.WaterfallChartOptions{Frame: mujica.ChartFrame{Label: "Ledger"}})
}
ui.Render(view, 400, 300, 1)`
const c1FunnelCode = `stages := []mujica.ChartSlice{{Label: "Visitors", Value: 1000}, {Label: "Novices", Value: 400}, {Label: "Vows", Value: 100}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.FunnelChart(c, stages, mujica.FunnelChartOptions{Frame: mujica.ChartFrame{Label: "Order"}})
}
ui.Render(view, 400, 300, 1)`
const c1BulletCode = `target := 250.0
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.BulletChart(c, 270, mujica.BulletChartOptions{Frame: mujica.ChartFrame{Label: "Alms"}, Target: &target, Bands: []float64{150, 225, 300}})
}
ui.Render(view, 400, 300, 1)`
const c1SparkCode = `values := []float64{3, 5, 4, 7, 6, 9}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Sparkline(c, values, mujica.SparklineOptions{Frame: mujica.ChartFrame{Label: "Bells"}, Kind: mujica.SparklineArea})
}
ui.Render(view, 400, 300, 1)`
const c1GaugeCode = `segments := []mujica.GaugeSegment{{To: 60, Label: "Calm", Tone: mujica.GaugeGood}, {To: 100, Label: "Crowded", Tone: mujica.GaugeDanger}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Gauge(c, 72, mujica.GaugeOptions{Frame: mujica.ChartFrame{Label: "Nave"}, Max: 100, Unit: "%", Segments: segments})
}
ui.Render(view, 400, 300, 1)`
const c1TreemapCode = `var path []string
root := mujica.ChartNode{Name: "Abbey", Children: []mujica.ChartNode{
{Name: "Library", Children: []mujica.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}},
{Name: "Kitchen", Value: 25},
}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Treemap(c, &path, root, mujica.TreemapOptions{Frame: mujica.ChartFrame{Label: "Rooms"}})
}
ui.Render(view, 400, 300, 1)`
const c1SunburstCode = `var path []string
root := mujica.ChartNode{Name: "Abbey", Children: []mujica.ChartNode{
{Name: "Library", Children: []mujica.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}},
{Name: "Kitchen", Value: 25},
}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.Sunburst(c, &path, root, mujica.SunburstOptions{Frame: mujica.ChartFrame{Label: "Rooms"}})
}
ui.Render(view, 400, 300, 1)`
const c1SankeyCode = `nodes := []mujica.SankeyNode{{ID: "tithe", Label: "Tithes"}, {ID: "fund", Label: "Fund"}, {ID: "roof", Label: "Roof"}}
links := []mujica.SankeyLink{{From: "tithe", To: "fund", Value: 60}, {From: "fund", To: "roof", Value: 50}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.SankeyChart(c, nodes, links, mujica.SankeyChartOptions{Frame: mujica.ChartFrame{Label: "Money"}})
}
ui.Render(view, 400, 300, 1)`
const c1NetworkCode = `var state mujica.NetworkGraphState
nodes := []mujica.GraphNode{{ID: "a", Label: "Abbot"}, {ID: "b", Label: "Prior"}, {ID: "c", Label: "Novice"}}
edges := []mujica.GraphEdge{{From: "a", To: "b"}, {From: "b", To: "c"}}
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.NetworkGraph(c, &state, nodes, edges, mujica.NetworkGraphOptions{Frame: mujica.ChartFrame{Label: "House"}, Force: true})
}
ui.Render(view, 400, 300, 1)`
const c1RealtimeCode = `buf := mujica.NewRealtimeBuffer(600, "Bell")
start := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
for i := range 60 {
buf.Append(start.Add(time.Duration(i)*time.Second), float64(i%7))
}
var state mujica.RealtimeChartState
view := func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
mujica.RealtimeChart(c, &state, buf, mujica.RealtimeChartOptions{Frame: mujica.ChartFrame{Label: "Sound"}, Window: 30 * time.Second})
}
ui.Render(view, 400, 300, 1)`
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"fmt"
"math"
"strings"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
var c1Abbey = mujica.ChartNode{Name: "Abbey", Children: []mujica.ChartNode{
{Name: "Library", Children: []mujica.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}, {Name: "Letters", Value: 8}}},
{Name: "Kitchen", Value: 25},
{Name: "Garden", Children: []mujica.ChartNode{{Name: "Herbs", Value: 12}, {Name: "Vines", Value: 8}}},
{Name: "Chapel", Children: []mujica.ChartNode{{Name: "Nave", Value: 18}, {Name: "Crypt", Value: 6}}},
}}
func c1TreemapDemo(c *ui.Context, log func(string)) {
type state struct {
status string
path []string
}
s := ui.Local(c.Root(), "116", func() state { return state{status: "Ready"} })
c1Section(c, "Treemap", func() {}, func() {
frame, show := c1Frame(c, "Rooms", &s.status)
root := c1Abbey
if !show {
root = mujica.ChartNode{Name: "Abbey"}
s.path = nil
}
r := mujica.Treemap(c, &s.path, root, mujica.TreemapOptions{Frame: frame})
c1Log(log, "Treemap", r)
if r.ValueChanged() {
log("Treemap: path /" + strings.Join(s.path, "/"))
}
})
}
func c1SunburstDemo(c *ui.Context, log func(string)) {
type state struct {
status string
path []string
depth float64
}
s := ui.Local(c.Root(), "117", func() state { return state{status: "Ready", depth: 2} })
c1Section(c, "Sunburst", func() {
ui.Text(c, fmt.Sprintf("%.0f rings", s.depth))
ui.StepSlider(c, &s.depth, 1, 3, 1).Width(120)
}, func() {
frame, show := c1Frame(c, "Rooms", &s.status)
frame.Height = 300
root := c1Abbey
if !show {
root = mujica.ChartNode{Name: "Abbey"}
s.path = nil
}
r := mujica.Sunburst(c, &s.path, root, mujica.SunburstOptions{Frame: frame, Depth: int(s.depth)})
c1Log(log, "Sunburst", r)
if r.ValueChanged() {
log("Sunburst: path /" + strings.Join(s.path, "/"))
}
})
}
func c1SankeyDemo(c *ui.Context, log func(string)) {
type state struct{ status string }
s := ui.Local(c.Root(), "118", func() state { return state{status: "Ready"} })
c1Section(c, "SankeyChart", func() {}, func() {
frame, show := c1Frame(c, "Money", &s.status)
nodes := []mujica.SankeyNode{{ID: "tithe", Label: "Tithes"}, {ID: "alms", Label: "Alms"}, {ID: "fund", Label: "Fund"}, {ID: "roof", Label: "Roof"}, {ID: "bread", Label: "Bread"}, {ID: "bells", Label: "Bells"}}
links := []mujica.SankeyLink{{From: "tithe", To: "fund", Value: 60}, {From: "alms", To: "fund", Value: 30}, {From: "alms", To: "bread", Value: 10}, {From: "fund", To: "roof", Value: 50}, {From: "fund", To: "bells", Value: 40}}
if !show {
links = nil
}
c1Log(log, "SankeyChart", mujica.SankeyChart(c, nodes, links, mujica.SankeyChartOptions{Frame: frame}))
})
}
func c1NetworkDemo(c *ui.Context, log func(string)) {
type state struct {
status string
force bool
graph mujica.NetworkGraphState
}
s := ui.Local(c.Root(), "119", func() state { return state{status: "Ready", force: true} })
c1Section(c, "NetworkGraph", func() {
ui.Checkbox(c, &s.force, "Force layout")
if mujica.Button(c, "Reset view", mujica.ButtonOptions{Variant: mujica.Secondary}).Clicked() {
s.graph.ResetView()
log("NetworkGraph: reset view")
}
}, func() {
frame, show := c1Frame(c, "House", &s.status)
nodes := []mujica.GraphNode{{ID: "a", Label: "Abbot", X: 0, Y: 0}, {ID: "b", Label: "Prior", X: 10, Y: 0}, {ID: "c", Label: "Cellarer", X: 10, Y: 10}, {ID: "d", Label: "Novice", X: 0, Y: 10}, {ID: "e", Label: "Cantor", X: 5, Y: 5}}
edges := []mujica.GraphEdge{{From: "a", To: "b"}, {From: "a", To: "c"}, {From: "c", To: "d"}, {From: "b", To: "e"}, {From: "e", To: "d"}}
if !show {
nodes, edges = nil, nil
}
c1Log(log, "NetworkGraph", mujica.NetworkGraph(c, &s.graph, nodes, edges, mujica.NetworkGraphOptions{Frame: frame, Force: s.force}))
})
}
func c1RealtimeDemo(c *ui.Context, log func(string)) {
type state struct {
status string
feed bool
buf *mujica.RealtimeBuffer
next time.Time
tick int
view mujica.RealtimeChartState
}
s := ui.Local(c.Root(), "120", func() state {
return state{status: "Ready", feed: true, buf: mujica.NewRealtimeBuffer(600, "Bell", "Organ")}
})
// The demo appends a sample every 500 ms while feeding.
if s.feed && !c.Now().Before(s.next) {
t := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC).Add(time.Duration(s.tick) * 500 * time.Millisecond)
x := float64(s.tick)
s.buf.Append(t, 5+3*math.Sin(x/6), 4+2*math.Sin(x/4+1))
s.tick++
s.next = c.Now().Add(500 * time.Millisecond)
}
if s.feed {
c.After(s.next.Sub(c.Now()))
}
c1Section(c, "RealtimeChart", func() { ui.Checkbox(c, &s.feed, "Feed data") }, func() {
frame, _ := c1Frame(c, "Sound", &s.status)
r := mujica.RealtimeChart(c, &s.view, s.buf, mujica.RealtimeChartOptions{Frame: frame, Window: 20 * time.Second})
c1Log(log, "RealtimeChart", r)
if r.ValueChanged() {
log(fmt.Sprintf("RealtimeChart: paused=%v", s.view.Paused))
}
})
}
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"fmt"
"math"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func c1Series(gap bool) []mujica.ChartSeries {
a := []float64{3, 5, 4, 6, 8, 7, 9, 11}
if gap {
a = []float64{3, 5, math.NaN(), 6, 8, 7, 9, 11}
}
return []mujica.ChartSeries{
{Name: "Matins", Values: a},
{Name: "Vespers", Values: []float64{2, 3, 5, 4, 6, 9, 8, 7}},
{Name: "Compline", Values: []float64{1, 2, 2, 3, 2, 4, 5, 4}},
}
}
func c1LineDemo(c *ui.Context, log func(string)) {
type state struct {
status string
gap, time bool
big bool
}
s := ui.Local(c.Root(), "101", func() state { return state{status: "Ready", gap: true} })
c1Section(c, "LineChart", func() {
ui.Checkbox(c, &s.gap, "Missing value")
ui.Checkbox(c, &s.time, "Time axis")
ui.Checkbox(c, &s.big, "100,000 points")
}, func() {
frame, show := c1Frame(c, "Attendance", &s.status)
series, opts := c1Series(s.gap), mujica.LineChartOptions{Frame: frame}
if s.big {
series = []mujica.ChartSeries{{Name: "Wave", Values: c1Wave()}}
}
if s.time {
t0 := time.Date(2026, 10, 5, 6, 0, 0, 0, time.UTC)
opts.Times = make([]time.Time, len(series[0].Values))
for i := range opts.Times {
opts.Times[i] = t0.Add(time.Duration(i) * time.Hour)
}
}
if !show {
series = nil
opts.Times = nil
}
c1Log(log, "LineChart", mujica.LineChart(c, series, opts))
})
}
var c1WaveData []float64
// c1Wave is 100,000 points, made once.
func c1Wave() []float64 {
if c1WaveData == nil {
c1WaveData = make([]float64, 100_000)
for i := range c1WaveData {
c1WaveData[i] = math.Sin(float64(i)/900) + 0.3*math.Sin(float64(i)/37)
}
}
return c1WaveData
}
func c1AreaDemo(c *ui.Context, log func(string)) {
type state struct {
status string
stacked bool
base float64
}
s := ui.Local(c.Root(), "102", func() state { return state{status: "Ready", stacked: true} })
c1Section(c, "AreaChart", func() {
ui.Checkbox(c, &s.stacked, "Stacked")
ui.Text(c, fmt.Sprintf("Baseline %.0f", s.base))
ui.StepSlider(c, &s.base, -4, 4, 1).Width(140)
}, func() {
frame, show := c1Frame(c, "Attendance", &s.status)
series := c1Series(false)
if !show {
series = nil
}
c1Log(log, "AreaChart", mujica.AreaChart(c, series, mujica.AreaChartOptions{Frame: frame, Stacked: s.stacked, Baseline: s.base}))
})
}
func c1BarDemo(c *ui.Context, log func(string)) {
type state struct {
status string
horizontal, stacked, labels bool
}
s := ui.Local(c.Root(), "103", func() state { return state{status: "Ready", labels: true} })
c1Section(c, "BarChart", func() {
ui.Checkbox(c, &s.horizontal, "Horizontal")
ui.Checkbox(c, &s.stacked, "Stacked")
ui.Checkbox(c, &s.labels, "Labels")
}, func() {
frame, show := c1Frame(c, "Tithes", &s.status)
cats := []string{"Nave", "Choir", "Crypt", "Bells"}
series := []mujica.ChartSeries{{Name: "Gold", Values: []float64{4, -2, 3, 5}}, {Name: "Silver", Values: []float64{2, 5, 1, -1}}}
if !show {
series = nil
}
c1Log(log, "BarChart", mujica.BarChart(c, cats, series, mujica.BarChartOptions{Frame: frame, Horizontal: s.horizontal, Stacked: s.stacked, Labels: s.labels}))
})
}
func c1PieDemo(c *ui.Context, log func(string)) {
type state struct {
status string
donut bool
min float64
}
s := ui.Local(c.Root(), "104", func() state { return state{status: "Ready", donut: true, min: 20} })
c1Section(c, "PieChart", func() {
ui.Checkbox(c, &s.donut, "Donut")
ui.Text(c, fmt.Sprintf("Merge under %.0f°", s.min))
ui.StepSlider(c, &s.min, 0, 40, 5).Width(140)
}, func() {
frame, show := c1Frame(c, "Stores", &s.status)
slices := []mujica.ChartSlice{{Label: "Wax", Value: 50}, {Label: "Oil", Value: 30}, {Label: "Myrrh", Value: 12}, {Label: "Salt", Value: 4}, {Label: "Ash", Value: 4}}
if !show {
slices = nil
}
c1Log(log, "PieChart", mujica.PieChart(c, slices, mujica.PieChartOptions{Frame: frame, Donut: s.donut, MinAngle: s.min}))
})
}
func c1ScatterDemo(c *ui.Context, log func(string)) {
type state struct {
status string
bubbles bool
selected []mujica.ScatterKey
}
s := ui.Local(c.Root(), "105", func() state { return state{status: "Ready", bubbles: true} })
c1Section(c, "ScatterChart", func() {
ui.Checkbox(c, &s.bubbles, "Bubbles")
ui.Text(c, fmt.Sprintf("%d selected", len(s.selected)))
}, func() {
frame, show := c1Frame(c, "Choir", &s.status)
groups := []mujica.ScatterGroup{
{Name: "Monks", Points: []mujica.ScatterPoint{{X: 1, Y: 2, Size: 4}, {X: 3, Y: 5, Size: 9}, {X: 6, Y: 4, Size: 1}, {X: 7, Y: 8, Size: 6}}},
{Name: "Nuns", Points: []mujica.ScatterPoint{{X: 2, Y: 7, Size: 16}, {X: 8, Y: 9, Size: 2}, {X: 5, Y: 2, Size: 8}}},
}
if !show {
groups = nil
}
r := mujica.ScatterChart(c, &s.selected, groups, mujica.ScatterChartOptions{Frame: frame, Bubbles: s.bubbles})
c1Log(log, "ScatterChart", r)
if r.ValueChanged() {
log(fmt.Sprintf("ScatterChart: %d selected", len(s.selected)))
}
})
}
func c1RadarDemo(c *ui.Context, log func(string)) {
type state struct{ status string }
s := ui.Local(c.Root(), "106", func() state { return state{status: "Ready"} })
c1Section(c, "RadarChart", func() {}, func() {
frame, show := c1Frame(c, "Skills", &s.status)
series := []mujica.ChartSeries{{Name: "Agnès", Values: []float64{8, 6, 9, 4, 7}}, {Name: "Clare", Values: []float64{5, 9, 6, 8, 3}}}
if !show {
series = nil
}
c1Log(log, "RadarChart", mujica.RadarChart(c, []string{"Voice", "Latin", "Chant", "Organ", "Bells"}, series, mujica.RadarChartOptions{Frame: frame}))
})
}
func c1HeatDemo(c *ui.Context, log func(string)) {
type state struct {
status string
gaps bool
}
s := ui.Local(c.Root(), "107", func() state { return state{status: "Ready", gaps: true} })
c1Section(c, "HeatmapChart", func() { ui.Checkbox(c, &s.gaps, "Empty cells") }, func() {
frame, show := c1Frame(c, "Hours", &s.status)
rows, cols := []string{"Mon", "Tue", "Wed", "Thu"}, []string{"Matins", "Lauds", "Terce", "Vespers", "Compline"}
values := make([][]float64, len(rows))
for r := range rows {
values[r] = make([]float64, len(cols))
for col := range cols {
values[r][col] = float64((r*7 + col*5) % 11)
if s.gaps && (r+col)%6 == 1 {
values[r][col] = math.NaN()
}
}
}
if !show {
rows, values = nil, nil
}
c1Log(log, "HeatmapChart", mujica.HeatmapChart(c, rows, cols, values, mujica.HeatmapChartOptions{Frame: frame}))
})
}
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"fmt"
"math"
"strconv"
"time"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
func c1CalendarDemo(c *ui.Context, log func(string)) {
type state struct {
status, start string
year int
}
s := ui.Local(c.Root(), "108", func() state { return state{status: "Ready", start: "Monday", year: 2026} })
c1Section(c, "CalendarHeatmap", func() { ui.Select(c, &s.start, []string{"Locale", "Sunday", "Monday", "Saturday"}) }, func() {
frame, show := c1Frame(c, "Prayers", &s.status)
frame.Height = 160
values := map[mujica.Date]float64{}
if show {
d := mujica.Date{Year: s.year, Month: time.January, Day: 1}
for i := range 366 {
if (i*37)%5 != 0 {
values[d.AddDays(i)] = float64((i * 13) % 9)
}
}
}
start := map[string]mujica.WeekStart{"Locale": mujica.WeekStartLocale, "Sunday": mujica.WeekStartSunday, "Monday": mujica.WeekStartMonday, "Saturday": mujica.WeekStartSaturday}[s.start]
r := mujica.CalendarHeatmap(c, &s.year, values, mujica.CalendarHeatmapOptions{Frame: frame, WeekStart: start})
c1Log(log, "CalendarHeatmap", r)
if r.ValueChanged() {
log("CalendarHeatmap: year " + strconv.Itoa(s.year))
}
})
}
func c1HistogramDemo(c *ui.Context, log func(string)) {
type state struct {
status string
bins float64
cumulative bool
values []float64
}
s := ui.Local(c.Root(), "109", func() state {
v := make([]float64, 500)
for i := range v {
v[i] = 50 + 12*math.Sin(float64(i)*1.7)*math.Cos(float64(i)*0.31)
}
return state{status: "Ready", bins: 10, values: v}
})
c1Section(c, "Histogram", func() {
ui.Text(c, fmt.Sprintf("%.0f bins", s.bins))
ui.StepSlider(c, &s.bins, 3, 30, 1).Width(140)
ui.Checkbox(c, &s.cumulative, "Cumulative")
}, func() {
frame, show := c1Frame(c, "Ages", &s.status)
values := s.values
if !show {
values = nil
}
c1Log(log, "Histogram", mujica.Histogram(c, values, mujica.HistogramOptions{Frame: frame, Bins: int(s.bins), Cumulative: s.cumulative}))
})
}
func c1BoxDemo(c *ui.Context, log func(string)) {
type state struct{ status string }
s := ui.Local(c.Root(), "110", func() state { return state{status: "Ready"} })
c1Section(c, "BoxPlot", func() {}, func() {
frame, show := c1Frame(c, "Hymns", &s.status)
groups := []mujica.BoxGroup{
{Name: "Choir", Values: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 24}},
{Name: "Nave", Values: []float64{4, 5, 5, 6, 7, 8}},
{Name: "Crypt", Values: []float64{12, 14, 15, 18, 22, 25}},
}
if !show {
groups = nil
}
c1Log(log, "BoxPlot", mujica.BoxPlot(c, groups, mujica.BoxPlotOptions{Frame: frame}))
})
}
func c1WaterfallDemo(c *ui.Context, log func(string)) {
type state struct{ status string }
s := ui.Local(c.Root(), "111", func() state { return state{status: "Ready"} })
c1Section(c, "WaterfallChart", func() {}, func() {
frame, show := c1Frame(c, "Ledger", &s.status)
steps := []mujica.WaterfallStep{
{Label: "Tithes", Value: 12}, {Label: "Repairs", Value: -5}, {Label: "Q1", Kind: mujica.WaterfallSubtotal},
{Label: "Alms", Value: 4}, {Label: "Bread", Value: -2}, {Label: "Year", Kind: mujica.WaterfallTotal},
}
if !show {
steps = nil
}
c1Log(log, "WaterfallChart", mujica.WaterfallChart(c, steps, mujica.WaterfallChartOptions{Frame: frame}))
})
}
func c1FunnelDemo(c *ui.Context, log func(string)) {
type state struct{ status string }
s := ui.Local(c.Root(), "112", func() state { return state{status: "Ready"} })
c1Section(c, "FunnelChart", func() {}, func() {
frame, show := c1Frame(c, "Order", &s.status)
stages := []mujica.ChartSlice{{Label: "Visitors", Value: 1000}, {Label: "Novices", Value: 400}, {Label: "Vows", Value: 100}, {Label: "Priors", Value: 12}}
if !show {
stages = nil
}
c1Log(log, "FunnelChart", mujica.FunnelChart(c, stages, mujica.FunnelChartOptions{Frame: frame}))
})
}
func c1BulletDemo(c *ui.Context, log func(string)) {
type state struct {
status string
value, target float64
vertical bool
}
s := ui.Local(c.Root(), "113", func() state { return state{status: "Ready", value: 270, target: 250} })
c1Section(c, "BulletChart", func() {
ui.Text(c, fmt.Sprintf("Value %.0f", s.value))
ui.StepSlider(c, &s.value, 0, 300, 10).Width(140)
ui.Checkbox(c, &s.vertical, "Vertical")
}, func() {
frame, _ := c1Frame(c, "Alms", &s.status)
if s.vertical {
frame.Height = 200
}
target := s.target
c1Log(log, "BulletChart", mujica.BulletChart(c, s.value, mujica.BulletChartOptions{Frame: frame, Target: &target, Bands: []float64{150, 225, 300}, Vertical: s.vertical}))
})
}
func c1SparkDemo(c *ui.Context, log func(string)) {
type state struct{ status string }
s := ui.Local(c.Root(), "114", func() state { return state{status: "Ready"} })
c1Section(c, "Sparkline", func() {}, func() {
frame, show := c1Frame(c, "Bells", &s.status)
values := []float64{3, 5, 4, math.NaN(), 7, 6, 9, 8, 11}
if !show {
values = nil
}
ui.Row(c).Gap(24).Wrap().Children(func() {
for _, k := range []mujica.SparklineKind{mujica.SparklineLine, mujica.SparklineBar, mujica.SparklineArea} {
f := frame
f.Label = fmt.Sprintf("Bells %d", k+1)
c1Log(log, f.Label, mujica.Sparkline(c, values, mujica.SparklineOptions{Frame: f, Kind: k}))
}
})
})
}
func c1GaugeDemo(c *ui.Context, log func(string)) {
type state struct {
status string
value float64
full bool
}
s := ui.Local(c.Root(), "115", func() state { return state{status: "Ready", value: 72} })
c1Section(c, "Gauge", func() {
ui.Text(c, fmt.Sprintf("Value %.0f", s.value))
ui.StepSlider(c, &s.value, 0, 100, 1).Width(140)
ui.Checkbox(c, &s.full, "Full circle")
}, func() {
frame, _ := c1Frame(c, "Nave", &s.status)
segments := []mujica.GaugeSegment{{To: 60, Label: "Calm", Tone: mujica.GaugeGood}, {To: 85, Label: "Busy", Tone: mujica.GaugeWarning}, {To: 100, Label: "Crowded", Tone: mujica.GaugeDanger}}
c1Log(log, "Gauge", mujica.Gauge(c, s.value, mujica.GaugeOptions{Frame: frame, Max: 100, Unit: "%", Segments: segments, Full: s.full}))
})
}
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"slices"
"strings"
"testing"
"github.com/egoist/mygo/ui"
mujica "mujicaui"
)
// c1Demo runs page id's demo with an event log.
func c1Demo(t *testing.T, id string) (*ui.Tester, *[]string) {
t.Helper()
i := slices.IndexFunc(charts1Pages, func(p page) bool { return p.ID == id })
if i < 0 {
t.Fatalf("no page %s", id)
}
events := &[]string{}
tt := ui.NewTester(func(c *ui.Context) {
mujica.Use(c, mujica.Settings{})
ui.Scroll(c).Fill().Padding(24).Children(func() {
charts1Pages[i].Demo(c, func(s string) { *events = append(*events, s) })
})
}, 900, 760)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
return tt, events
}
// c1Labels names each demo's chart for the keys.
var c1Labels = map[string]string{
"101": "Attendance", "102": "Attendance", "103": "Tithes", "104": "Stores", "105": "Choir", "106": "Skills",
"107": "Hours", "108": "Prayers", "109": "Ages", "110": "Hymns", "111": "Ledger", "112": "Order", "113": "Alms",
"114": "Bells 1", "115": "Nave", "116": "Rooms", "117": "Rooms", "118": "Money", "119": "House", "120": "Sound",
}
func TestCharts1DemosLogReadouts(t *testing.T) {
for _, p := range charts1Pages {
if p.Category != "Charts I" || p.Code == "" || p.Keys == "" {
t.Fatalf("%s: incomplete page", p.ID)
}
tt, events := c1Demo(t, p.ID)
tabUntil(t, tt, c1Labels[p.ID])
tt.Key(0, ui.KeyRight)
if len(*events) == 0 || !strings.HasSuffix((*events)[len(*events)-1], "point 0") {
t.Errorf("%s: keyboard readout not logged: %q", p.ID, *events)
}
}
}
func TestCharts1TreemapDrillLogs(t *testing.T) {
tt, events := c1Demo(t, "116")
tabUntil(t, tt, "Rooms")
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyEnter)
if !slices.Contains(*events, "Treemap: path /Library") {
t.Fatalf("drill not logged: %q", *events)
}
}
func tabUntil(t *testing.T, tt *ui.Tester, label string) {
t.Helper()
for range 40 {
if tt.Focused(label) {
return
}
tt.Key(0, ui.KeyTab)
}
t.Fatalf("Tab never reached %q", label)
}
+85
View File
@@ -0,0 +1,85 @@
package mujica
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// FunnelChartOptions configure a FunnelChart.
type FunnelChartOptions struct {
Frame ChartFrame
Format func(v float64) string
}
// FunnelChart narrows by stage, labeled with conversion rates.
func FunnelChart(c *ui.Context, stages []ChartSlice, opts FunnelChartOptions) ChartResult {
top := 0.0
for _, s := range stages {
if s.Label == "" || s.Value < 0 || math.IsNaN(s.Value) || math.IsInf(s.Value, 0) {
panic("mujica: FunnelChart stages need a label and a finite value ≥ 0")
}
top = max(top, s.Value)
}
ch := newChart(c, opts.Frame)
k := Tokens(c)
col := internal.Palette(k)[0]
size := FontSize(c, theme.CaptionSize)
n := len(stages)
if top == 0 {
n = 0
}
pct := func(a, b float64) string {
if b == 0 {
return "—"
}
return fmt.Sprintf("%.1f%%", 100*a/b)
}
rate := func(i int) string {
if i == 0 {
return pct(stages[0].Value, stages[0].Value)
}
return pct(stages[i].Value, stages[i-1].Value)
}
geom := func(r ui.Rect) (ui.Rect, internal.Band) {
plot := ui.Rect{X: r.X + 8, Y: r.Y + 4, W: r.W * 0.58, H: r.H - 8}
return plot, internal.Band{N: len(stages), R0: plot.Y, R1: plot.Y + plot.H, Pad: 0.08}
}
spec := chartSpec{n: n}
spec.readout = func(i int) string {
s := stages[i]
return fmt.Sprintf(msg(c, "funnel_chart.readout"), s.Label, chartValue(c, s.Value, opts.Format), rate(i), pct(s.Value, stages[0].Value))
}
spec.pick = func(r ui.Rect, x, y float32) int {
plot, band := geom(r)
if y < plot.Y || y >= plot.Y+plot.H || x < r.X || x > r.X+r.W {
return -1
}
return min(len(stages)-1, int((y-plot.Y)/band.Step()))
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, band := geom(r)
cx := plot.X + plot.W/2
width := func(i int) float32 { return max(plot.W*float32(stages[min(i, len(stages)-1)].Value/top), 2) }
for i, s := range stages {
y0, y1 := band.Start(i), band.Start(i)+band.Width()
w0, w1 := width(i), width(i+1)
var shape ui.Path
shape.MoveTo(cx-w0/2, y0).LineTo(cx+w0/2, y0).LineTo(cx+w1/2, y1).LineTo(cx-w1/2, y1).Close()
p.FillPath(&shape, col)
if i == active {
p.StrokePath(&shape, 2, k.Text)
}
ly := (y0 + y1) / 2
lx := plot.X + plot.W + 16
chartText(p, s.Label+" · "+chartValue(c, s.Value, opts.Format), lx, ly, size, k.Text, 0, 1)
chartText(p, rate(i), lx, ly+2, size, k.TextMuted, 0, 0)
}
}
res, _ := ch.build(spec)
return res
}
+54
View File
@@ -0,0 +1,54 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var funnelStages = []ChartSlice{{"Visitors", 1000}, {"Novices", 400}, {"Vows", 100}, {"Abbots", 0}}
func TestFunnelChartReadout(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return FunnelChart(c, funnelStages, FunnelChartOptions{Frame: ChartFrame{Label: "Order"}})
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Order")
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "Novices · 400 · 40.0% of the stage before · 40.0% of the first" {
t.Fatalf("readout %q", got)
}
tt.Key(0, ui.KeyEscape)
b := h.res.Element.Bounds()
tt.Move(b.X+b.W/3, b.Y+4+232*0.6) // third stage band
if got := chartReadout(tt); got != "Vows · 100 · 25.0% of the stage before · 10.0% of the first" {
t.Fatalf("hover %q", got)
}
}
func TestFunnelChartBadAndEmpty(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return FunnelChart(c, []ChartSlice{{"a", 0}}, FunnelChartOptions{Frame: ChartFrame{Label: "Order"}})
}}
if tt := ui.NewTester(h.view, 520, 420); !tt.HasText("No data") {
t.Fatal("all-zero funnel is not empty")
}
defer func() {
if recover() == nil {
t.Fatal("no panic for a negative stage")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
FunnelChart(c, []ChartSlice{{"a", -1}}, FunnelChartOptions{Frame: ChartFrame{Label: "x"}})
}, 300, 300)
}
func TestFunnelChartSnapshot(t *testing.T) {
snapshot(t, "funnel_chart", 520, 300, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
FunnelChart(c, funnelStages[:3], FunnelChartOptions{Frame: ChartFrame{Label: "Order", Height: 220}})
})
})
}
+154
View File
@@ -0,0 +1,154 @@
package mujica
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// GaugeTone colors a Gauge segment; its label still names it.
type GaugeTone int
const (
GaugeNeutral GaugeTone = iota
GaugeGood
GaugeWarning
GaugeDanger
)
// GaugeSegment is a named dial range up to To.
type GaugeSegment struct {
To float64
Label string
Tone GaugeTone
}
// GaugeOptions configure a Gauge; Frame.Height 0 means 200 DIPs.
type GaugeOptions struct {
Frame ChartFrame
Min, Max float64
// Segments split Min..Max ascending; the last ends at Max.
Segments []GaugeSegment
Unit string
// Full draws a whole circle instead of a half.
Full bool
Format func(v float64) string
}
// Gauge points a needle at value on named segments.
func Gauge(c *ui.Context, value float64, opts GaugeOptions) ChartResult {
if !(opts.Max > opts.Min) || math.IsNaN(value) || value < opts.Min || value > opts.Max {
panic("mujica: Gauge needs Min < Max and a value between them")
}
prev := opts.Min
for i, s := range opts.Segments {
if s.To <= prev || s.Label == "" || i == len(opts.Segments)-1 && s.To != opts.Max {
panic("mujica: Gauge segments need labels and ascending ends finishing at Max")
}
prev = s.To
}
if opts.Frame.Height == 0 {
opts.Frame.Height = 200
}
ch := newChart(c, opts.Frame)
k := Tokens(c)
size := FontSize(c, theme.CaptionSize)
tone := func(t GaugeTone) ui.Color {
return map[GaugeTone]ui.Color{GaugeNeutral: internal.Palette(k)[5], GaugeGood: k.Success, GaugeWarning: k.Warning, GaugeDanger: k.Danger}[t]
}
val := func(v float64) string { return chartValue(c, v, opts.Format) }
seg := -1
for i, s := range opts.Segments {
if value <= s.To && seg < 0 {
seg = i
}
}
a0, a1 := -math.Pi/2, math.Pi/2
if opts.Full {
a0, a1 = -math.Pi, math.Pi
}
angle := func(v float64) float64 { return a0 + (a1-a0)*(v-opts.Min)/(opts.Max-opts.Min) }
geom := func(r ui.Rect) (cx, cy, rad float32) {
if opts.Full {
rad = min(r.W, r.H)/2 - 12
return r.X + r.W/2, r.Y + r.H/2, rad
}
rad = min(r.W/2-12, r.H-36)
return r.X + r.W/2, r.Y + 8 + rad, rad
}
reading := val(value) + " " + opts.Unit
if seg >= 0 {
reading += " · " + opts.Segments[seg].Label
}
spec := chartSpec{n: 1 + len(opts.Segments)}
spec.readout = func(i int) string {
if i == 0 {
return reading
}
s, from := opts.Segments[i-1], opts.Min
if i > 1 {
from = opts.Segments[i-2].To
}
return fmt.Sprintf("%s: %s–%s", s.Label, val(from), val(s.To))
}
spec.pick = func(r ui.Rect, x, y float32) int {
cx, cy, rad := geom(r)
d := float32(math.Hypot(float64(x-cx), float64(y-cy)))
if d < rad*0.45 {
return 0
}
if d > rad+6 {
return -1
}
a := math.Atan2(float64(x-cx), float64(cy-y))
from := a0
for i, s := range opts.Segments {
if a >= from && a <= angle(s.To) {
return i + 1
}
from = angle(s.To)
}
return -1
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
cx, cy, rad := geom(r)
from := a0
if len(opts.Segments) == 0 {
p.FillPath(internal.Sector(cx, cy, rad*0.78, rad, a0, a1), k.SurfaceHover)
}
for i, s := range opts.Segments {
to := angle(s.To)
grow := float32(0)
if active == i+1 {
grow = 4
}
p.FillPath(internal.Sector(cx, cy, rad*0.78-grow, rad+grow, from, to), tone(s.Tone))
p.StrokePath(internal.Sector(cx, cy, rad*0.78-grow, rad+grow, from, to), 1.5, k.Surface)
lx, ly := internal.Polar(cx, cy, rad+10, to)
if i < len(opts.Segments)-1 {
chartText(p, val(s.To), lx, ly, size, k.TextMuted, 0.5, 1)
}
from = to
}
nx, ny := internal.Polar(cx, cy, rad*0.9, angle(value))
var needle ui.Path
needle.MoveTo(cx, cy).LineTo(internal.Snap(p, nx), internal.Snap(p, ny))
w := float32(3)
if active == 0 {
w = 4.5
}
p.StrokePath(&needle, w, k.Text)
p.FillPath(internal.Diamond(cx, cy, 12), k.Text)
ty := cy + 34
chartText(p, val(value)+" "+opts.Unit, cx, ty, FontSize(c, theme.H3Size), k.Text, 0.5, 1)
if seg >= 0 {
chartText(p, opts.Segments[seg].Label, cx, ty+2, size, k.TextMuted, 0.5, 0)
}
}
res, _ := ch.build(spec)
return res
}
+75
View File
@@ -0,0 +1,75 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var gaugeSegments = []GaugeSegment{{60, "Calm", GaugeGood}, {85, "Busy", GaugeWarning}, {100, "Crowded", GaugeDanger}}
func TestGaugeReadoutNamesSegment(t *testing.T) {
full := false
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return Gauge(c, 72, GaugeOptions{Frame: ChartFrame{Label: "Nave"}, Max: 100, Unit: "%", Segments: gaugeSegments, Full: full})
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Nave")
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "72 % · Busy" {
t.Fatalf("value %q", got)
}
tt.Key(0, ui.KeyEnd)
if got := chartReadout(tt); got != "Crowded: 85–100" {
t.Fatalf("last segment %q", got)
}
tt.Key(0, ui.KeyEscape)
for _, full = range []bool{false, true} {
tt.Frame()
b := h.res.Element.Bounds()
cx := b.X + b.W/2
var cy, rad float32
if full {
rad = 200/2 - 12
cy = b.Y + 100
} else {
rad = min(b.W/2-12, 200-36)
cy = b.Y + 8 + rad
}
tt.Move(cx-rad*0.7, cy-rad*0.6) // the left of the dial: the first segment
if got := chartReadout(tt); got != "Calm: 0–60" {
t.Fatalf("full=%v hover %q", full, got)
}
}
}
func TestGaugeBad(t *testing.T) {
for name, o := range map[string]GaugeOptions{
"range": {Max: 0},
"segments": {Max: 100, Segments: []GaugeSegment{{50, "a", 0}, {40, "b", 0}}},
"end": {Max: 100, Segments: []GaugeSegment{{50, "a", 0}}},
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
o.Frame.Label = "x"
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); Gauge(c, 0, o) }, 300, 300)
}()
}
}
func TestGaugeSnapshot(t *testing.T) {
snapshot(t, "gauge", 560, 300, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
ui.Column(c).Width(256).Children(func() {
Gauge(c, 72, GaugeOptions{Frame: ChartFrame{Label: "Half"}, Max: 100, Unit: "%", Segments: gaugeSegments})
})
ui.Column(c).Width(256).Children(func() {
Gauge(c, 30, GaugeOptions{Frame: ChartFrame{Label: "Full"}, Max: 100, Unit: "%", Segments: gaugeSegments, Full: true})
})
})
})
}
+123
View File
@@ -0,0 +1,123 @@
package mujica
import (
"math"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// HeatmapChartOptions configure a HeatmapChart.
type HeatmapChartOptions struct {
Frame ChartFrame
Format func(v float64) string
}
// heatSteps is the number of color steps.
const heatSteps = 5
// HeatmapChart colors a matrix by value; NaN cells are dashed.
func HeatmapChart(c *ui.Context, rows, cols []string, values [][]float64, opts HeatmapChartOptions) ChartResult {
if len(values) != len(rows) {
panic("mujica: HeatmapChart needs one value row per row label")
}
lo, hi := math.Inf(1), math.Inf(-1)
for _, r := range values {
if len(r) != len(cols) {
panic("mujica: HeatmapChart needs one value per column label")
}
for _, v := range r {
if !math.IsNaN(v) {
lo, hi = min(lo, v), max(hi, v)
}
}
}
ch := newChart(c, opts.Frame)
k := Tokens(c)
size := FontSize(c, theme.CaptionSize)
n := len(rows) * len(cols)
if math.IsInf(lo, 1) {
n = 0
}
// level is v's color step, 0 to heatSteps-1.
level := func(v float64) int {
if hi == lo {
return heatSteps - 1
}
return min(heatSteps-1, int((v-lo)/(hi-lo)*heatSteps))
}
geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Band) {
plot := ui.Rect{X: r.X + 72, Y: r.Y + 4, W: max(r.W-72-64, 1), H: max(r.H-28, 1)}
return plot, internal.Band{N: len(cols), R0: plot.X, R1: plot.X + plot.W, Pad: 0.06}, internal.Band{N: len(rows), R0: plot.Y, R1: plot.Y + plot.H, Pad: 0.06}
}
cell := func(xb, yb internal.Band, i int) ui.Rect {
return ui.Rect{X: xb.Start(i % len(cols)), Y: yb.Start(i / len(cols)), W: xb.Width(), H: yb.Width()}
}
spec := chartSpec{n: n}
spec.readout = func(i int) string {
return rows[i/len(cols)] + " · " + cols[i%len(cols)] + " · " + chartValue(c, values[i/len(cols)][i%len(cols)], opts.Format)
}
spec.move = func(i int, key ui.Key) int {
r, col := i/len(cols), i%len(cols)
switch key {
case ui.KeyLeft:
col = max(col-1, 0)
case ui.KeyRight:
col = min(col+1, len(cols)-1)
case ui.KeyUp:
r = max(r-1, 0)
default:
r = min(r+1, len(rows)-1)
}
return r*len(cols) + col
}
spec.pick = func(r ui.Rect, x, y float32) int {
plot, xb, yb := geom(r)
if !plot.Contains(x, y) {
return -1
}
col := min(len(cols)-1, int((x-plot.X)/xb.Step()))
row := min(len(rows)-1, int((y-plot.Y)/yb.Step()))
return row*len(cols) + col
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, xb, yb := geom(r)
for i := range len(rows) * len(cols) {
b := cell(xb, yb, i)
v := values[i/len(cols)][i%len(cols)]
if math.IsNaN(v) {
p.StrokeDashed(b, k.ControlBorder, 2, 1)
} else {
bg := internal.Ramp(k, (float64(level(v))+0.5)/heatSteps)
p.Fill(b, bg, 2)
if b.W >= 30 && b.H >= 18 {
chartText(p, chartValue(c, v, opts.Format), b.X+b.W/2, b.Y+b.H/2, size, chartInk(k, bg), 0.5, 0.5)
}
}
if i == active {
p.Stroke(ui.Rect{X: b.X - 2, Y: b.Y - 2, W: b.W + 4, H: b.H + 4}, k.Text, 3, 2)
}
}
for i, name := range rows {
chartText(p, name, plot.X-8, yb.Center(i), size, k.TextMuted, 1, 0.5)
}
for i, name := range cols {
chartText(p, name, xb.Center(i), plot.Y+plot.H+4, size, k.TextMuted, 0.5, 0)
}
heatLegend(p, ui.Rect{X: plot.X + plot.W + 16, Y: plot.Y, W: 14, H: plot.H}, lo, hi, size, k, opts.Format, c)
}
res, _ := ch.build(spec)
return res
}
// heatLegend stacks the color steps with the range at ends.
func heatLegend(p *ui.Painter, r ui.Rect, lo, hi float64, size float32, k theme.Tokens, format func(float64) string, c *ui.Context) {
h := min(r.H, 120) / heatSteps
for i := range heatSteps {
p.Fill(ui.Rect{X: r.X, Y: r.Y + float32(heatSteps-1-i)*h, W: r.W, H: h - 1}, internal.Ramp(k, (float64(i)+0.5)/heatSteps), 1)
}
chartText(p, chartValue(c, hi, format), r.X+r.W+4, r.Y, size, k.TextMuted, 0, 0)
chartText(p, chartValue(c, lo, format), r.X+r.W+4, r.Y+h*heatSteps, size, k.TextMuted, 0, 1)
}
+68
View File
@@ -0,0 +1,68 @@
package mujica
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
var heatRows = []string{"Mon", "Tue", "Wed"}
var heatCols = []string{"Matins", "Lauds", "Vespers", "Compline"}
func heatValues() [][]float64 {
return [][]float64{{1, 4, 9, 2}, {3, math.NaN(), 7, 5}, {8, 6, 0, 3}}
}
func TestHeatmapChartKeyboard2D(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return HeatmapChart(c, heatRows, heatCols, heatValues(), HeatmapChartOptions{Frame: ChartFrame{Label: "Hours"}})
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Hours")
tt.Key(0, ui.KeyRight) // enters at the first cell
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyDown)
if got := chartReadout(tt); got != "Tue · Lauds · no value" {
t.Fatalf("readout %q", got)
}
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyDown)
tt.Key(0, ui.KeyEnd)
if got := chartReadout(tt); got != "Wed · Compline · 3" {
t.Fatalf("End %q", got)
}
}
func TestHeatmapChartHover(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return HeatmapChart(c, heatRows, heatCols, heatValues(), HeatmapChartOptions{Frame: ChartFrame{Label: "Hours"}})
}}
tt := ui.NewTester(h.view, 520, 420)
b := h.res.Element.Bounds()
plotW := b.W - 72 - 64
tt.Move(b.X+72+plotW*0.6, b.Y+4+212*0.2) // third column, first row
if got := chartReadout(tt); got != "Mon · Vespers · 9" {
t.Fatalf("hover %q", got)
}
}
func TestHeatmapChartBad(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for a short row")
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
HeatmapChart(c, heatRows[:1], heatCols, [][]float64{{1}}, HeatmapChartOptions{Frame: ChartFrame{Label: "x"}})
}, 300, 300)
}
func TestHeatmapChartSnapshot(t *testing.T) {
snapshot(t, "heatmap_chart", 520, 300, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
HeatmapChart(c, heatRows, heatCols, heatValues(), HeatmapChartOptions{Frame: ChartFrame{Label: "Hours", Height: 220}})
})
})
}
+157
View File
@@ -0,0 +1,157 @@
package mujica
import (
"fmt"
"math"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// HistogramOptions configure a Histogram.
type HistogramOptions struct {
Frame ChartFrame
// Bins is the bin count; 0 uses Sturges' rule.
Bins int
BinWidth float64
// Cumulative counts every value up to each bin's upper edge.
Cumulative bool
Format func(v float64) string
}
// histogramMaxBins bounds the bins a width may make.
const histogramMaxBins = 10_000
type histBins struct {
start, width float64
counts []int
}
// histogramBin counts finite values into equal bins.
func histogramBin(values []float64, bins int, width float64, cumulative bool) histBins {
lo, hi := math.Inf(1), math.Inf(-1)
n := 0
for _, v := range values {
if !math.IsNaN(v) && !math.IsInf(v, 0) {
lo, hi, n = min(lo, v), max(hi, v), n+1
}
}
if n == 0 {
return histBins{}
}
var b histBins
switch {
case width > 0:
b.start, b.width = math.Floor(lo/width)*width, width
bins = max(1, int(math.Ceil((hi-b.start)/width-1e-9)))
if hi == b.start+float64(bins)*width {
bins++
}
default:
if bins == 0 {
bins = int(math.Ceil(math.Log2(float64(n)))) + 1
}
b.start, b.width = lo, (hi-lo)/float64(bins)
if b.width == 0 {
b.width = 1
}
}
if bins > histogramMaxBins {
panic("mujica: Histogram would make more than 10000 bins")
}
b.counts = make([]int, bins)
for _, v := range values {
if math.IsNaN(v) || math.IsInf(v, 0) {
continue
}
b.counts[min(bins-1, int((v-b.start)/b.width))]++
}
if cumulative {
for i := 1; i < bins; i++ {
b.counts[i] += b.counts[i-1]
}
}
return b
}
// Histogram counts values into equal bins drawn as bars.
func Histogram(c *ui.Context, values []float64, opts HistogramOptions) ChartResult {
if opts.Bins < 0 || opts.BinWidth < 0 || math.IsNaN(opts.BinWidth) {
panic("mujica: Histogram Bins and BinWidth must be ≥ 0")
}
ch := newChart(c, opts.Frame)
k := Tokens(c)
col := internal.Palette(k)[0]
size := FontSize(c, theme.CaptionSize)
b := chartMemo(ch, "bins", [4]any{chartID(values), opts.Bins, opts.BinWidth, opts.Cumulative}, func() histBins {
return histogramBin(values, opts.Bins, opts.BinWidth, opts.Cumulative)
})
top := 0
for _, n := range b.counts {
top = max(top, n)
}
var ys internal.Linear
var hi, step float64
if top > 0 {
_, hi, step = internal.Nice(0, float64(top), 4)
step = max(step, 1)
}
// edge writes bin edge i at a given precision.
edge := func(i int, fine float64) string {
v := b.start + float64(i)*b.width
if opts.Format != nil {
return opts.Format(v)
}
s := internal.FormatTick(v, b.width/fine)
if strings.Contains(s, ".") {
s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
}
return s
}
geom := func(r ui.Rect) (ui.Rect, internal.Band) {
plot := chartInset(r, 52, 26)
ys = internal.Linear{D0: 0, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
return plot, internal.Band{N: len(b.counts), R0: plot.X, R1: plot.X + plot.W, Pad: 0.06}
}
spec := chartSpec{n: len(b.counts)}
spec.readout = func(i int) string {
if opts.Cumulative {
return fmt.Sprintf("≤ %s · %d", edge(i+1, 100), b.counts[i])
}
closer := ")"
if i == len(b.counts)-1 && opts.BinWidth == 0 {
closer = "]"
}
return fmt.Sprintf("[%s, %s%s · %d", edge(i, 100), edge(i+1, 100), closer, b.counts[i])
}
spec.pick = func(r ui.Rect, x, y float32) int {
plot, band := geom(r)
if !plot.Contains(x, y) {
return -1
}
return min(len(b.counts)-1, int((x-plot.X)/band.Step()))
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, band := geom(r)
internal.AxisY(p, plot, ys, internal.Ticks(0, hi, step), func(v float64) string { return internal.FormatTick(v, step) }, size, k)
var xs []float32
var labels []string
for i := 0; i <= len(b.counts); i++ {
xs, labels = append(xs, plot.X+band.Step()*float32(i)), append(labels, edge(i, 1))
}
internal.AxisX(p, plot, xs, labels, size, k)
for i, n := range b.counts {
y := ys.Map(float64(n))
bar := ui.Rect{X: band.Start(i), Y: y, W: band.Width(), H: plot.Y + plot.H - y}
p.Fill(bar, col, 1)
if i == active {
p.Stroke(ui.Rect{X: bar.X - 2, Y: bar.Y - 2, W: bar.W + 4, H: bar.H + 4}, k.Text, 2, 1.5)
}
}
}
res, _ := ch.build(spec)
return res
}
+76
View File
@@ -0,0 +1,76 @@
package mujica
import (
"fmt"
"math"
"testing"
"github.com/egoist/mygo/ui"
)
func TestHistogramBin(t *testing.T) {
vals := []float64{1, 2, 2, 3, 4, 5, 9, 10, math.NaN()}
if got := histogramBin(vals, 3, 0, false); fmt.Sprint(got.counts) != "[4 2 2]" || got.start != 1 || got.width != 3 {
t.Fatalf("3 bins %+v", got)
}
if got := histogramBin(vals, 0, 5, false); fmt.Sprint(got.counts) != "[5 2 1]" || got.start != 0 {
t.Fatalf("width 5 %+v", got)
}
if got := histogramBin(vals, 0, 5, true); fmt.Sprint(got.counts) != "[5 7 8]" {
t.Fatalf("cumulative %+v", got)
}
if got := histogramBin(vals, 0, 0, false); len(got.counts) != 4 {
t.Fatalf("Sturges for 8 values: %d bins", len(got.counts))
}
if got := histogramBin([]float64{math.NaN()}, 3, 0, false); got.counts != nil {
t.Fatal("no finite values made bins")
}
}
func TestHistogramReadoutAndModes(t *testing.T) {
opts := HistogramOptions{Frame: ChartFrame{Label: "Ages"}, BinWidth: 5}
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return Histogram(c, []float64{1, 2, 2, 3, 4, 5, 9, 10}, opts)
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Ages")
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "[0, 5) · 5" {
t.Fatalf("first bin %q", got)
}
tt.Key(0, ui.KeyEnd)
if got := chartReadout(tt); got != "[10, 15) · 1" {
t.Fatalf("last bin %q", got)
}
opts.Cumulative = true
tt.Frame()
if got := chartReadout(tt); got != "≤ 15 · 8" {
t.Fatalf("cumulative %q", got)
}
}
func TestHistogramBad(t *testing.T) {
for _, o := range []HistogramOptions{{Bins: -1}, {BinWidth: 1e-9}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%+v: no panic", o)
}
}()
o.Frame.Label = "x"
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); Histogram(c, []float64{0, 1}, o) }, 300, 300)
}()
}
}
func TestHistogramSnapshot(t *testing.T) {
vals := make([]float64, 400)
for i := range vals {
vals[i] = 50 + 15*math.Sin(float64(i)*1.7)*math.Cos(float64(i)*0.3)
}
snapshot(t, "histogram", 520, 320, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
Histogram(c, vals, HistogramOptions{Frame: ChartFrame{Label: "Ages"}, Bins: 12})
})
})
}
+264
View File
@@ -0,0 +1,264 @@
package internal
import (
"math"
"strconv"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// Linear maps domain D0..D1 onto pixels R0..R1.
type Linear struct{ D0, D1, R0, R1 float64 }
// Map returns the pixel of v.
func (s Linear) Map(v float64) float32 {
if s.D1 == s.D0 {
return float32((s.R0 + s.R1) / 2)
}
return float32(s.R0 + (v-s.D0)/(s.D1-s.D0)*(s.R1-s.R0))
}
// Invert returns the value at pixel px.
func (s Linear) Invert(px float32) float64 {
if s.R1 == s.R0 {
return s.D0
}
return s.D0 + (float64(px)-s.R0)/(s.R1-s.R0)*(s.D1-s.D0)
}
// Nice widens lo..hi to round steps of about n ticks.
func Nice(lo, hi float64, n int) (nlo, nhi, step float64) {
if n < 1 || math.IsNaN(lo) || math.IsNaN(hi) || lo > hi {
panic("internal: bad Nice domain")
}
if lo == hi {
lo, hi = lo-1, hi+1
}
raw := (hi - lo) / float64(n)
mag := math.Pow(10, math.Floor(math.Log10(raw)))
step = 10 * mag
for _, m := range []float64{1, 2, 5} {
if m*mag >= raw {
step = m * mag
break
}
}
return math.Floor(lo/step) * step, math.Ceil(hi/step) * step, step
}
// Ticks lists the multiples of step from lo to hi.
func Ticks(lo, hi, step float64) []float64 {
var out []float64
for i := math.Ceil(lo/step - 1e-9); i*step <= hi+step*1e-9; i++ {
out = append(out, i*step)
}
return out
}
// FormatTick writes v with as many decimals as step needs.
func FormatTick(v, step float64) string {
d := max(0, int(-math.Floor(math.Log10(step)+1e-9)))
if math.Abs(v) < step*1e-9 {
v = 0
}
return strconv.FormatFloat(v, 'f', d, 64)
}
// timeSteps are the tick spacings a time axis chooses from.
var timeSteps = []struct {
d time.Duration
layout string
}{
{time.Second, "15:04:05"}, {5 * time.Second, "15:04:05"}, {15 * time.Second, "15:04:05"}, {30 * time.Second, "15:04:05"},
{time.Minute, "15:04"}, {5 * time.Minute, "15:04"}, {15 * time.Minute, "15:04"}, {30 * time.Minute, "15:04"},
{time.Hour, "15:04"}, {3 * time.Hour, "15:04"}, {6 * time.Hour, "Jan 2 15:04"}, {12 * time.Hour, "Jan 2 15:04"},
{24 * time.Hour, "Jan 2"}, {7 * 24 * time.Hour, "Jan 2"}, {30 * 24 * time.Hour, "Jan 2006"}, {365 * 24 * time.Hour, "2006"},
}
// TimeTicks returns about n round times and their layout.
func TimeTicks(lo, hi time.Time, n int) ([]time.Time, string) {
if n < 1 || hi.Before(lo) {
panic("internal: bad TimeTicks domain")
}
span := hi.Sub(lo) / time.Duration(n)
step := timeSteps[len(timeSteps)-1]
for _, s := range timeSteps {
if s.d >= span {
step = s
break
}
}
var out []time.Time
for t := lo.Truncate(step.d); !t.After(hi); t = t.Add(step.d) {
if !t.Before(lo) {
out = append(out, t)
}
}
return out, step.layout
}
// Band splits R0..R1 into N bands, each Pad empty.
type Band struct {
N int
R0, R1 float32
Pad float32
}
// Step is the room one band takes.
func (b Band) Step() float32 { return (b.R1 - b.R0) / float32(max(b.N, 1)) }
// Width is a band's drawn width.
func (b Band) Width() float32 { return b.Step() * (1 - b.Pad) }
// Start is where band i is drawn from.
func (b Band) Start(i int) float32 { return b.R0 + b.Step()*float32(i) + b.Step()*b.Pad/2 }
// Center is the middle of band i.
func (b Band) Center(i int) float32 { return b.R0 + b.Step()*(float32(i)+0.5) }
// Palette returns six series colors at 3:1 against Surface.
func Palette(k theme.Tokens) []ui.Color {
out := []ui.Color{k.AccentText, k.Info, k.Success, k.Warning, k.Ornament, k.TextMuted}
for i, col := range out {
for t := float32(0.1); theme.Contrast(col, k.Surface) < 3; t += 0.1 {
col = out[i].Mix(k.Text, t)
}
out[i] = col
}
return out
}
// Ramp is the sequential color for t in 0..1.
func Ramp(k theme.Tokens, t float64) ui.Color {
return k.Surface.Mix(k.AccentText, float32(0.12+0.88*max(0, min(t, 1))))
}
// Marker draws series shape i centered at (cx, cy).
func Marker(p *ui.Painter, i int, cx, cy, size float32, col ui.Color) {
cx, cy = Snap(p, cx), Snap(p, cy)
h := size / 2
var path ui.Path
switch i % 5 {
case 0:
path.Circle(cx, cy, h)
case 1:
h *= 0.85
path.MoveTo(cx-h, cy-h).LineTo(cx+h, cy-h).LineTo(cx+h, cy+h).LineTo(cx-h, cy+h).Close()
case 2:
p.FillPath(Diamond(cx, cy, size*1.2), col)
return
case 3:
path.MoveTo(cx, cy-h).LineTo(cx+h, cy+h*0.8).LineTo(cx-h, cy+h*0.8).Close()
default:
path.MoveTo(cx-h, cy-h*0.8).LineTo(cx+h, cy-h*0.8).LineTo(cx, cy+h).Close()
}
p.FillPath(&path, col)
}
// Arc appends a polyline from a0 to a1, clockwise radians.
func Arc(path *ui.Path, cx, cy, r float32, a0, a1 float64, move bool) {
n := max(2, int(math.Ceil(math.Abs(a1-a0)/(math.Pi/48))))
for i := 0; i <= n; i++ {
x, y := Polar(cx, cy, r, a0+(a1-a0)*float64(i)/float64(n))
if i == 0 && move {
path.MoveTo(x, y)
} else {
path.LineTo(x, y)
}
}
}
// Polar returns the point r from center at angle a.
func Polar(cx, cy, r float32, a float64) (float32, float32) {
return cx + r*float32(math.Sin(a)), cy - r*float32(math.Cos(a))
}
// Sector returns a ring slice; zero r0 makes a wedge.
func Sector(cx, cy, r0, r1 float32, a0, a1 float64) *ui.Path {
var path ui.Path
Arc(&path, cx, cy, r1, a0, a1, true)
if r0 > 0 {
Arc(&path, cx, cy, r0, a1, a0, false)
} else {
path.LineTo(cx, cy)
}
path.Close()
return &path
}
// Rule draws a pixel-aligned 1 DIP horizontal line at y.
func Rule(p *ui.Painter, x0, x1, y float32, c ui.Color) {
yy := ruleY(p, y)
p.Line(x0, yy, x1, yy, 1, c)
}
// AxisY draws grid lines and tick labels left of plot.
func AxisY(p *ui.Painter, plot ui.Rect, s Linear, ticks []float64, label func(float64) string, size float32, k theme.Tokens) {
for _, t := range ticks {
y := s.Map(t)
Rule(p, plot.X, plot.X+plot.W, y, k.Border)
txt := label(t)
w, h := p.MeasureText(0, ui.Span{Text: txt, Size: size})
p.Text(plot.X-w-6, y-h/2, txt, size, k.TextMuted)
}
}
// AxisX draws a baseline and labels centered at xs.
func AxisX(p *ui.Painter, plot ui.Rect, xs []float32, labels []string, size float32, k theme.Tokens) {
Rule(p, plot.X, plot.X+plot.W, plot.Y+plot.H, k.ControlBorder)
right := float32(math.Inf(-1))
for i, x := range xs {
w, _ := p.MeasureText(0, ui.Span{Text: labels[i], Size: size})
at := min(max(x-w/2, plot.X-12), plot.X+plot.W+12-w)
// Labels that would overlap the last one drawn are skipped.
if at < right+6 {
continue
}
p.Text(at, plot.Y+plot.H+6, labels[i], size, k.TextMuted)
right = at + w
}
}
// Nearest returns the closest point within reach, or -1.
func Nearest(n int, at func(i int) (float32, float32), x, y, reach float32, byX bool) int {
best, dist := -1, reach*reach
for i := range n {
px, py := at(i)
d := (px - x) * (px - x)
if !byX {
d += (py - y) * (py - y)
}
if d <= dist {
best, dist = i, d
}
}
return best
}
// Columns keeps first, lowest, highest and last per pixel column.
func Columns(n int, x func(i int) float32, y func(i int) float64) []int {
var out []int
for i := 0; i < n; {
col := math.Floor(float64(x(i)))
lo, hi, j := i, i, i
for ; j < n && math.Floor(float64(x(j))) == col; j++ {
if y(j) < y(lo) {
lo = j
}
if y(j) > y(hi) {
hi = j
}
}
out = append(out, i)
for _, k := range []int{min(lo, hi), max(lo, hi), j - 1} {
if k != out[len(out)-1] {
out = append(out, k)
}
}
i = j
}
return out
}
+101
View File
@@ -0,0 +1,101 @@
package internal
import (
"fmt"
"math"
"testing"
"time"
"mujicaui/theme"
)
func TestNiceAndTicks(t *testing.T) {
for _, tc := range []struct {
lo, hi float64
n int
nlo, nhi, stp float64
}{
{0, 97, 5, 0, 100, 20},
{-3.2, 7.9, 4, -5, 10, 5},
{0.012, 0.087, 5, 0, 0.1, 0.02},
{5, 5, 4, 4, 6, 0.5},
} {
lo, hi, step := Nice(tc.lo, tc.hi, tc.n)
if math.Abs(lo-tc.nlo) > 1e-9 || math.Abs(hi-tc.nhi) > 1e-9 || math.Abs(step-tc.stp) > 1e-9 {
t.Errorf("Nice(%v, %v, %d) = %v %v %v", tc.lo, tc.hi, tc.n, lo, hi, step)
}
}
if got := fmt.Sprint(Ticks(-5, 10, 5)); got != "[-5 0 5 10]" {
t.Errorf("ticks %s", got)
}
if FormatTick(0.30000000000000004, 0.1) != "0.3" || FormatTick(1500, 500) != "1500" || FormatTick(-1e-17, 0.5) != "0.0" {
t.Error("FormatTick")
}
}
func TestNicePanics(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for an inverted domain")
}
}()
Nice(2, 1, 4)
}
func TestTimeTicks(t *testing.T) {
lo := time.Date(2026, 10, 5, 9, 7, 0, 0, time.UTC)
ticks, layout := TimeTicks(lo, lo.Add(2*time.Hour), 4)
if layout != "15:04" || len(ticks) != 4 || ticks[0].Format("15:04") != "09:30" || ticks[3].Format("15:04") != "11:00" {
t.Fatalf("ticks %v layout %s", ticks, layout)
}
}
func TestLinearAndBand(t *testing.T) {
s := Linear{D0: 0, D1: 10, R0: 100, R1: 0}
if s.Map(5) != 50 || s.Invert(25) != 7.5 {
t.Fatal("Linear")
}
b := Band{N: 4, R0: 0, R1: 200, Pad: 0.5}
if b.Step() != 50 || b.Width() != 25 || b.Start(1) != 62.5 || b.Center(3) != 175 {
t.Fatal("Band")
}
}
func TestPaletteContrast(t *testing.T) {
for _, k := range []theme.Tokens{theme.Light(), theme.Dark()} {
pal := Palette(k)
seen := map[[3]uint8]bool{}
for i, c := range pal {
if r := theme.Contrast(c, k.Surface); r < 3 {
t.Errorf("dark=%v color %d contrast %.2f", k.Dark, i, r)
}
seen[[3]uint8{c.R, c.G, c.B}] = true
}
if len(pal) != 6 || len(seen) != 6 {
t.Errorf("dark=%v palette not six distinct colors", k.Dark)
}
}
}
func TestNearest(t *testing.T) {
pts := [][2]float32{{0, 0}, {10, 10}, {20, 0}}
at := func(i int) (float32, float32) { return pts[i][0], pts[i][1] }
if Nearest(3, at, 9, 2, 30, false) != 1 || Nearest(3, at, 18, 50, 30, true) != 2 || Nearest(3, at, 100, 100, 5, false) != -1 {
t.Fatal("Nearest")
}
}
func TestColumnsBounded(t *testing.T) {
const n = 100_000
x := func(i int) float32 { return float32(i) * 400 / n }
y := func(i int) float64 { return math.Sin(float64(i)) }
got := Columns(n, x, y)
if len(got) > 4*400+4 || got[0] != 0 || got[len(got)-1] != n-1 {
t.Fatalf("%d points kept, first %d last %d", len(got), got[0], got[len(got)-1])
}
for i := 1; i < len(got); i++ {
if got[i] <= got[i-1] {
t.Fatal("indices out of order")
}
}
}
+159
View File
@@ -0,0 +1,159 @@
package mujica
import (
"math"
"strings"
"time"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// LineChartOptions configure a LineChart.
type LineChartOptions struct {
Frame ChartFrame
// X places point i at ascending X[i]; nil means i.
X []float64
// Times makes a time axis; set X or Times.
Times []time.Time
// Format writes readout values; nil uses six digits.
Format func(v float64) string
}
// LineChart draws series as lines; NaN breaks them.
func LineChart(c *ui.Context, series []ChartSeries, opts LineChartOptions) ChartResult {
n := chartCheckSeries(series)
ch := newChart(c, opts.Frame)
ax := chartMemo(ch, "x", [3]string{chartID(opts.X), chartID(opts.Times), chartID(series)}, func() chartX { return newChartX(n, opts.X, opts.Times) })
lo, hi, ok := chartYRange(ch, series, false, nil)
if !ok {
n = 0
}
tops := make([][]float64, len(series))
for i, s := range series {
tops[i] = s.Values
}
return chartLines(ch, chartLineData{series: series, tops: tops, ax: ax, lo: lo, hi: hi, n: n, format: opts.Format})
}
// chartYRange is the memoized y domain of visible series.
func chartYRange(ch *chart, series []ChartSeries, zero bool, tops [][]float64) (lo, hi float64, ok bool) {
d := chartMemo(ch, "y", chartSeriesID(ch, series)+chartID(tops), func() [3]float64 {
src := series
if tops != nil {
src = make([]ChartSeries, len(series))
for i, s := range series {
src[i] = ChartSeries{Name: s.Name, Values: tops[i]}
}
}
lo, hi, ok := chartYDomain(src, ch.hidden, zero)
if !ok {
return [3]float64{}
}
return [3]float64{lo, hi, 1}
})
return d[0], d[1], d[2] == 1
}
// chartLineData is what line and area charts draw.
type chartLineData struct {
series []ChartSeries
tops, bases [][]float64
ax chartX
lo, hi float64
n int
format func(float64) string
}
func chartLines(ch *chart, d chartLineData) ChartResult {
c, k := ch.c, Tokens(ch.c)
pal := internal.Palette(k)
size := FontSize(c, theme.CaptionSize)
keys := make([]chartKey, len(d.series))
for i, s := range d.series {
keys[i] = chartKey{Name: s.Name, Color: pal[i%len(pal)], Shape: i}
}
var ticks []float64
var step float64
lo, hi := d.lo, d.hi
if d.n > 0 {
lo, hi, step = internal.Nice(lo, hi, 4)
ticks = internal.Ticks(lo, hi, step)
}
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
plot := chartInset(r, 52, 26)
return plot, d.ax.scale(plot), internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
}
spec := chartSpec{n: d.n, keys: keys, toggle: true}
spec.pick = func(r ui.Rect, x, y float32) int {
plot, xs, _ := geom(r)
if !(ui.Rect{X: plot.X - 6, Y: plot.Y, W: plot.W + 12, H: plot.H}).Contains(x, y) {
return -1
}
return d.ax.nearest(xs.Invert(x))
}
spec.readout = func(i int) string {
parts := []string{d.ax.label(i)}
for _, s := range d.series {
if !ch.hidden(s.Name) {
parts = append(parts, s.Name+" "+chartValue(c, s.Values[i], d.format))
}
}
return strings.Join(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)
tx, tl := d.ax.ticks(plot)
internal.AxisX(p, plot, tx, tl, size, k)
x := func(i int) float32 { return xs.Map(d.ax.vals[i]) }
pt := func(i int, v float64) (float32, float32) { return internal.Snap(p, x(i)), internal.Snap(p, ys.Map(v)) }
for si, s := range d.series {
if ch.hidden(s.Name) {
continue
}
col, top := keys[si].Color, d.tops[si]
chartRuns(top, x, func(idx []int) {
var line ui.Path
for j, i := range idx {
if j == 0 {
line.MoveTo(pt(i, top[i]))
} else {
line.LineTo(pt(i, top[i]))
}
}
if d.bases != nil {
fill := line
for j := len(idx) - 1; j >= 0; j-- {
fill.LineTo(pt(idx[j], max(lo, min(hi, d.bases[si][idx[j]]))))
}
fill.Close()
p.FillPath(&fill, col.Alpha(0.22))
}
p.StrokePath(&line, 2, col)
})
// Sparse markers name each series by shape, not only color.
every := max(1, int(float32(d.n)/max(plot.W/48, 1)))
for i := 0; i < d.n; i += every {
if !math.IsNaN(top[i]) {
internal.Marker(p, si, x(i), ys.Map(top[i]), 7, col)
}
}
}
if active < 0 {
return
}
ax0 := internal.Snap(p, x(active))
p.Line(ax0, plot.Y, ax0, plot.Y+plot.H, 1, k.TextMuted)
for si, s := range d.series {
if v := d.tops[si][active]; !ch.hidden(s.Name) && !math.IsNaN(v) {
internal.Marker(p, si, ax0, ys.Map(v), 13, k.Surface)
internal.Marker(p, si, ax0, ys.Map(v), 9, keys[si].Color)
}
}
}
res, _ := ch.build(spec)
return res
}
+194
View File
@@ -0,0 +1,194 @@
package mujica
import (
"math"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
type chartHarness struct {
res ChartResult
changes int
disabled bool
build func(c *ui.Context) ChartResult
}
func (h *chartHarness) view(c *ui.Context) {
Use(c, Settings{Mode: Light})
ui.Column(c).Padding(16).Width(480).Disabled(h.disabled).Children(func() {
h.res = h.build(c)
if h.res.Changed() {
h.changes++
}
})
}
// chartReadout returns the readout line, the text after the plot.
func chartReadout(tt *ui.Tester) string {
texts := tt.Texts()
return texts[len(texts)-1]
}
func lineSeries() []ChartSeries {
return []ChartSeries{
{Name: "Pews", Values: []float64{3, 5, math.NaN(), 4, 8}},
{Name: "Candles", Values: []float64{5, 2, 6, 7, 1}},
}
}
func TestLineChartKeyboardReadout(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "Chapel"}})
}}
tt := ui.NewTester(h.view, 520, 420)
if !strings.Contains(chartReadout(tt), "arrow keys") {
t.Fatalf("hint %q", chartReadout(tt))
}
tabTo(t, tt, "Chapel")
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "1 · Pews 5 · Candles 2" || h.res.Point != 1 || h.changes != 2 {
t.Fatalf("readout %q point %d", got, h.res.Point)
}
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "2 · Pews no value · Candles 6" {
t.Fatalf("gap readout %q", got)
}
if a := tt.Announcements(); len(a) == 0 || a[len(a)-1] != "2 · Pews no value · Candles 6" {
t.Fatalf("announcements %q", a)
}
tt.Key(0, ui.KeyEnd)
tt.Key(0, ui.KeyRight)
if h.res.Point != 4 {
t.Fatalf("End then Right: %d", h.res.Point)
}
tt.Key(0, ui.KeyEscape)
if h.res.Point != -1 {
t.Fatalf("Escape kept point %d", h.res.Point)
}
}
func TestLineChartHoverAndToggle(t *testing.T) {
var toggled string
h := &chartHarness{build: func(c *ui.Context) ChartResult {
r := LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "Chapel"}, X: []float64{0, 10, 20, 30, 40}})
if s, ok := r.Toggled(); ok {
toggled = s
}
return r
}}
tt := ui.NewTester(h.view, 520, 420)
b := h.res.Element.Bounds()
// The plot spans 52..W-12; x 30 is three quarters across.
px := b.X + 52 + (b.W-64)*0.75
tt.Move(px, b.Y+b.H/2)
if got := chartReadout(tt); got != "30 · Pews 4 · Candles 7" {
t.Fatalf("hover readout %q", got)
}
if err := tt.Click("Candles"); err != nil {
t.Fatal(err)
}
tt.Move(px, b.Y+b.H/2)
if got := chartReadout(tt); got != "30 · Pews 4" || toggled != "Candles" {
t.Fatalf("hidden series still read: %q toggled %q", got, toggled)
}
}
func TestLineChartTimeAxisAndStates(t *testing.T) {
t0 := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
times := []time.Time{t0, t0.Add(time.Hour), t0.Add(2 * time.Hour), t0.Add(3 * time.Hour), t0.Add(4 * time.Hour)}
status := DataReady
var series []ChartSeries
var heights []float32
h := &chartHarness{build: func(c *ui.Context) ChartResult {
r := LineChart(c, series, LineChartOptions{Frame: ChartFrame{Label: "Chapel", Status: status}, Times: times[:len(series[0].Values)]})
return r
}}
series = lineSeries()
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Chapel")
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); !strings.HasPrefix(got, "2026-10-05 10:00:00 · ") {
t.Fatalf("time readout %q", got)
}
heights = append(heights, h.res.Element.Bounds().H)
for _, st := range []DataStatus{DataLoading, DataFailed} {
status = st
tt.Frame()
heights = append(heights, h.res.Element.Bounds().H)
}
if !tt.HasText("Retry") || heights[0] != heights[1] || heights[1] != heights[2] {
t.Fatalf("states changed size: %v", heights)
}
status, series = DataReady, []ChartSeries{{Name: "A", Values: []float64{math.NaN()}}}
tt.Frame()
if !tt.HasText("No data") {
t.Fatalf("all-missing chart: %q", tt.Texts())
}
}
func TestLineChartDisabledIgnoresKeys(t *testing.T) {
h := &chartHarness{disabled: true, build: func(c *ui.Context) ChartResult {
return LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "Chapel"}})
}}
tt := ui.NewTester(h.view, 520, 420)
for range 4 {
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyRight)
}
if h.res.Point != -1 {
t.Fatalf("disabled chart moved to %d", h.res.Point)
}
}
func TestLineChartBadOptions(t *testing.T) {
for name, build := range map[string]func(c *ui.Context){
"lengths": func(c *ui.Context) {
LineChart(c, []ChartSeries{{Name: "a", Values: []float64{1}}, {Name: "b"}}, LineChartOptions{Frame: ChartFrame{Label: "x"}})
},
"order": func(c *ui.Context) {
LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "x"}, X: []float64{0, 2, 1, 3, 4}})
},
"no label": func(c *ui.Context) { LineChart(c, lineSeries(), LineChartOptions{}) },
"duplicate": func(c *ui.Context) {
LineChart(c, []ChartSeries{{Name: "a"}, {Name: "a"}}, LineChartOptions{Frame: ChartFrame{Label: "x"}})
},
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); build(c) }, 300, 300)
}()
}
}
func TestLineChartLarge(t *testing.T) {
vals := make([]float64, 100_000)
for i := range vals {
vals[i] = math.Sin(float64(i) / 500)
}
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return LineChart(c, []ChartSeries{{Name: "Wave", Values: vals}}, LineChartOptions{Frame: ChartFrame{Label: "Tide"}})
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Tide")
tt.Key(0, ui.KeyEnd)
if h.res.Point != 99_999 || !strings.HasPrefix(chartReadout(tt), "99999 · Wave ") {
t.Fatalf("End: %d %q", h.res.Point, chartReadout(tt))
}
}
func TestLineChartSnapshot(t *testing.T) {
snapshot(t, "line_chart", 520, 340, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "Chapel", Height: 240}})
})
})
}
+1 -1
View File
@@ -75,7 +75,7 @@ func (v ListView) Changed() bool { return v.changed }
// Submitted reports Enter or a double click on an item.
func (v ListView) Submitted() bool { return v.submitted }
// Retried reports a click on the retry action of a failed load.
// Retried reports a click on a failed load's retry.
func (v ListView) Retried() bool { return v.retry }
// List shows n items, building only those in view, as rows or a grid.
+30
View File
@@ -0,0 +1,30 @@
package mujica
func init() {
addMessages(map[string][2]string{
"chart.empty": {"No data", "暂无数据"},
"chart.hint": {"Focus the chart and use the arrow keys to read values", "聚焦图表后用方向键读取数值"},
"chart.missing": {"no value", "无数值"},
"pie_chart.other": {"Other", "其他"},
"pie_chart.total": {"Total", "总计"},
"scatter_chart.size": {"size", "大小"},
"scatter_chart.selected": {"selected", "已选"},
"calendar_heatmap.prev": {"Previous year", "上一年"},
"calendar_heatmap.next": {"Next year", "下一年"},
"calendar_heatmap.less": {"Less", "少"},
"calendar_heatmap.more": {"More", "多"},
"box_plot.readout": {"%s · median %s · quartiles %s–%s · whiskers %s–%s · %d outliers", "%s · 中位数 %s · 四分位 %s–%s · 须线 %s–%s · 离群点 %d 个"},
"waterfall_chart.running": {"running total", "累计"},
"funnel_chart.readout": {"%s · %s · %s of the stage before · %s of the first", "%s · %s · 较上一阶段 %s · 占首阶段 %s"},
"bullet_chart.actual": {"Actual", "实际"},
"bullet_chart.target": {"Target", "目标"},
"bullet_chart.band": {"Range %d: %s–%s", "区间 %d:%s–%s"},
"chart.back": {"Back", "返回"},
"chart.opens": {"Enter opens", "回车展开"},
"sankey_chart.in": {"in", "流入"},
"sankey_chart.out": {"out", "流出"},
"network_graph.readout": {"%s · %d links: %s", "%s · %d 条连线:%s"},
"realtime_chart.pause": {"Pause", "暂停"},
"realtime_chart.resume": {"Resume", "继续"},
})
}
+263
View File
@@ -0,0 +1,263 @@
package mujica
import (
"fmt"
"log/slog"
"math"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// GraphNode is a node placed at X, Y unless forced.
type GraphNode struct {
ID, Label string
X, Y float64
}
// GraphEdge joins two nodes by ID.
type GraphEdge struct{ From, To string }
// NetworkGraphOptions configure a NetworkGraph.
type NetworkGraphOptions struct {
Frame ChartFrame
// Force lays nodes out by a simple force simulation.
Force bool
}
// NetworkGraphState holds node positions, zoom and pan.
type NetworkGraphState struct {
pos map[string][2]float64
lo, hi [2]float64
layout string
zoom float32
panX float32
panY float32
drag string
dragging bool
}
// Position returns where node id is, in layout units.
func (s *NetworkGraphState) Position(id string) (x, y float64, ok bool) {
p, ok := s.pos[id]
return p[0], p[1], ok
}
// Zoom returns the view's scale, 1 when fitted.
func (s *NetworkGraphState) Zoom() float32 { return max(s.zoom, 0) }
// ResetView fits the graph again.
func (s *NetworkGraphState) ResetView() { s.zoom, s.panX, s.panY = 1, 0, 0 }
// networkForceLimit bounds the nodes the O(n²) force layout takes.
const networkForceLimit = 500
// NetworkGraph draws nodes and edges; drag, pan and zoom.
func NetworkGraph(c *ui.Context, s *NetworkGraphState, nodes []GraphNode, edges []GraphEdge, opts NetworkGraphOptions) ChartResult {
at := map[string]int{}
for i, n := range nodes {
if _, dup := at[n.ID]; dup || n.ID == "" || n.Label == "" {
panic("mujica: NetworkGraph nodes need unique IDs and labels")
}
at[n.ID] = i
}
links := make([][2]int, len(edges))
for i, e := range edges {
f, okf := at[e.From]
t, okt := at[e.To]
if !okf || !okt {
panic("mujica: NetworkGraph edge ends must be node IDs")
}
links[i] = [2]int{f, t}
}
if opts.Force && len(nodes) > networkForceLimit {
panic("mujica: NetworkGraph Force takes at most 500 nodes")
}
if s.zoom == 0 {
s.zoom = 1
}
if key := chartID(nodes) + chartID(edges) + fmt.Sprint(opts.Force); s.layout != key {
s.layout, s.pos = key, networkLayout(nodes, links, opts.Force)
// The view fits the layout; dragging does not refit.
s.lo, s.hi = [2]float64{math.Inf(1), math.Inf(1)}, [2]float64{math.Inf(-1), math.Inf(-1)}
for _, p := range s.pos {
s.lo, s.hi = [2]float64{min(s.lo[0], p[0]), min(s.lo[1], p[1])}, [2]float64{max(s.hi[0], p[0]), max(s.hi[1], p[1])}
}
slog.Debug("mujica: network_graph layout", "nodes", len(nodes))
}
ch := newChart(c, opts.Frame)
k := Tokens(c)
pal := internal.Palette(k)
size := FontSize(c, theme.CaptionSize)
lo, hi := s.lo, s.hi
// screen maps a layout point into r, zoomed and panned.
screen := func(r ui.Rect, p [2]float64) (float32, float32) {
span := max(hi[0]-lo[0], hi[1]-lo[1], 1e-9)
fit := float64(min(r.W, r.H)-48) / span
cx, cy := (lo[0]+hi[0])/2, (lo[1]+hi[1])/2
x := float32((p[0]-cx)*fit)*s.zoom + r.X + r.W/2 + s.panX
y := float32((p[1]-cy)*fit)*s.zoom + r.Y + r.H/2 + s.panY
return x, y
}
unit := func(r ui.Rect) float64 {
return max(hi[0]-lo[0], hi[1]-lo[1], 1e-9) / float64(min(r.W, r.H)-48) / float64(s.zoom)
}
pt := func(r ui.Rect, i int) (float32, float32) { return screen(r, s.pos[nodes[i].ID]) }
spec := chartSpec{n: len(nodes)}
spec.readout = func(i int) string {
var near []string
for _, l := range links {
switch i {
case l[0]:
near = append(near, nodes[l[1]].Label)
case l[1]:
near = append(near, nodes[l[0]].Label)
}
}
return fmt.Sprintf(msg(c, "network_graph.readout"), nodes[i].Label, len(near), strings.Join(near, ", "))
}
spec.pick = func(r ui.Rect, x, y float32) int {
return internal.Nearest(len(nodes), func(i int) (float32, float32) { return pt(r, i) }, x, y, 12, false)
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
p.Clip(r, 0, func() {
for _, l := range links {
x0, y0 := pt(r, l[0])
x1, y1 := pt(r, l[1])
var line ui.Path
line.MoveTo(internal.Snap(p, x0), internal.Snap(p, y0)).LineTo(internal.Snap(p, x1), internal.Snap(p, y1))
w, col := float32(1.25), k.ControlBorder
if active == l[0] || active == l[1] {
w, col = 2.5, k.Text
}
p.StrokePath(&line, w, col)
}
for i, n := range nodes {
x, y := pt(r, i)
internal.Marker(p, 0, x, y, 14, k.Surface)
internal.Marker(p, 0, x, y, 11, pal[i%len(pal)])
if i == active {
var ring ui.Path
ring.Circle(internal.Snap(p, x), internal.Snap(p, y), 10)
p.StrokePath(&ring, 2, k.Text)
}
chartText(p, n.Label, x, y+9, size, k.Text, 0.5, 0)
}
})
}
res, plot := ch.build(spec)
if plot == nil || plot.IsDisabled() {
return res
}
hit := func(x, y float32) string {
b := plot.Bounds()
if i := spec.pick(ui.Rect{W: b.W, H: b.H}, x, y); i >= 0 {
return nodes[i].ID
}
return ""
}
networkInput(plot, s, ch.st.focus, nodes, unit, hit)
return res
}
// networkInput drags, pans, zooms and moves the focused node.
func networkInput(plot *ui.Element, s *NetworkGraphState, focus int, nodes []GraphNode, unit func(ui.Rect) float64, hit func(x, y float32) string) {
b := plot.Bounds()
r := ui.Rect{W: b.W, H: b.H}
move := func(id string, dx, dy float32) {
p := s.pos[id]
s.pos[id] = [2]float64{p[0] + float64(dx)*unit(r), p[1] + float64(dy)*unit(r)}
}
zoom := func(f float32) { s.zoom = max(0.25, min(8, s.zoom*f)) }
if dx, dy, held := plot.Dragged(); held {
if !s.dragging {
x, y, _ := plot.PointerPosition()
s.dragging, s.drag = true, hit(x, y)
}
if s.drag != "" {
move(s.drag, dx, dy)
} else {
s.panX, s.panY = s.panX+dx, s.panY+dy
}
} else if s.dragging {
s.dragging = false
slog.Debug("mujica: network_graph drag", "node", s.drag)
}
plot.HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind != ui.InputScroll || ev.DY == 0 || plot.IsDisabled() {
return false
}
if ev.DY < 0 {
zoom(1.2)
} else {
zoom(1 / 1.2)
}
return true
})
switch {
case plot.Shortcut(0, ui.KeyEqual), plot.Shortcut(ui.Shift, ui.KeyEqual):
zoom(1.2)
case plot.Shortcut(0, ui.KeyMinus):
zoom(1 / 1.2)
case plot.Shortcut(0, ui.Key0):
s.ResetView()
}
if focus < 0 || focus >= len(nodes) {
return
}
for key, d := range map[ui.Key][2]float32{ui.KeyLeft: {-12, 0}, ui.KeyRight: {12, 0}, ui.KeyUp: {0, -12}, ui.KeyDown: {0, 12}} {
if plot.Shortcut(ui.Shift, key) {
move(nodes[focus].ID, d[0], d[1])
}
}
}
// networkLayout uses X, Y, or fixed force steps.
func networkLayout(nodes []GraphNode, links [][2]int, force bool) map[string][2]float64 {
pos := make([][2]float64, len(nodes))
for i, n := range nodes {
pos[i] = [2]float64{n.X, n.Y}
if force {
a := 2 * math.Pi * float64(i) / float64(len(nodes))
pos[i] = [2]float64{math.Cos(a), math.Sin(a)}
}
}
if force && len(nodes) > 1 {
ideal := math.Sqrt(4.0 / float64(len(nodes)))
for step := range 300 {
heat := 0.1 * (1 - float64(step)/300)
move := make([][2]float64, len(nodes))
for i := range pos {
for j := range pos {
if i == j {
continue
}
dx, dy := pos[i][0]-pos[j][0], pos[i][1]-pos[j][1]
d := max(math.Hypot(dx, dy), 1e-6)
f := ideal * ideal / d
move[i][0], move[i][1] = move[i][0]+dx/d*f, move[i][1]+dy/d*f
}
}
for _, l := range links {
dx, dy := pos[l[0]][0]-pos[l[1]][0], pos[l[0]][1]-pos[l[1]][1]
d := max(math.Hypot(dx, dy), 1e-6)
f := d * d / ideal
move[l[0]][0], move[l[0]][1] = move[l[0]][0]-dx/d*f, move[l[0]][1]-dy/d*f
move[l[1]][0], move[l[1]][1] = move[l[1]][0]+dx/d*f, move[l[1]][1]+dy/d*f
}
for i, m := range move {
d := max(math.Hypot(m[0], m[1]), 1e-9)
pos[i][0] += m[0] / d * min(d, heat)
pos[i][1] += m[1] / d * min(d, heat)
}
}
}
out := make(map[string][2]float64, len(nodes))
for i, n := range nodes {
out[n.ID] = pos[i]
}
return out
}
+101
View File
@@ -0,0 +1,101 @@
package mujica
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
var graphNodes = []GraphNode{{"a", "Abbot", 0, 0}, {"b", "Prior", 10, 0}, {"c", "Cellarer", 10, 10}, {"d", "Novice", 0, 10}}
var graphEdges = []GraphEdge{{"a", "b"}, {"a", "c"}, {"c", "d"}}
func TestNetworkGraphReadoutAndKeys(t *testing.T) {
var s NetworkGraphState
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return NetworkGraph(c, &s, graphNodes, graphEdges, NetworkGraphOptions{Frame: ChartFrame{Label: "House"}})
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "House")
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "Abbot · 2 links: Prior, Cellarer" {
t.Fatalf("readout %q", got)
}
x0, y0, _ := s.Position("a")
tt.Key(ui.Shift, ui.KeyRight)
if x, y, _ := s.Position("a"); x <= x0 || y != y0 {
t.Fatalf("Shift+Right left the node at %v,%v", x, y)
}
tt.Key(0, ui.KeyEqual)
if s.Zoom() < 1.19 {
t.Fatalf("zoom %v", s.Zoom())
}
tt.Key(0, ui.Key0)
if s.Zoom() != 1 {
t.Fatalf("reset zoom %v", s.Zoom())
}
}
func TestNetworkGraphDragNodeAndPan(t *testing.T) {
var s NetworkGraphState
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return NetworkGraph(c, &s, graphNodes, graphEdges, NetworkGraphOptions{Frame: ChartFrame{Label: "House"}})
}}
tt := ui.NewTester(h.view, 520, 420)
b := h.res.Element.Bounds()
// The 10×10 layout fits 240-48 DIPs, centered in the plot.
cx, cy := b.X+b.W/2, b.Y+120
ax, ay := cx-96, cy-96
tt.Press(ax, ay)
tt.Move(ax+20, ay)
tt.Move(ax+48, ay)
tt.Release(ax+48, ay)
if x, y, _ := s.Position("a"); math.Abs(x-2.5) > 0.2 || math.Abs(y) > 0.01 {
t.Fatalf("dragged node to %v,%v", x, y)
}
tt.Press(cx, cy) // the empty middle pans
tt.Move(cx+30, cy+10)
tt.Release(cx+30, cy+10)
if x, _, _ := s.Position("b"); x != 10 {
t.Fatalf("panning moved a node: %v", x)
}
if got := s.panX; got != 30 {
t.Fatalf("pan %v", got)
}
}
func TestNetworkGraphForceLayout(t *testing.T) {
pos := networkLayout(graphNodes, [][2]int{{0, 1}, {0, 2}, {2, 3}}, true)
again := networkLayout(graphNodes, [][2]int{{0, 1}, {0, 2}, {2, 3}}, true)
for id, p := range pos {
if again[id] != p || math.IsNaN(p[0]) {
t.Fatalf("layout not deterministic for %s", id)
}
}
d := func(a, b string) float64 { return math.Hypot(pos[a][0]-pos[b][0], pos[a][1]-pos[b][1]) }
if d("c", "d") >= d("b", "d") {
t.Fatalf("linked nodes not closer: %v vs %v", d("c", "d"), d("b", "d"))
}
}
func TestNetworkGraphBad(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for an unknown edge end")
}
}()
var s NetworkGraphState
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
NetworkGraph(c, &s, graphNodes, []GraphEdge{{"a", "z"}}, NetworkGraphOptions{Frame: ChartFrame{Label: "x"}})
}, 300, 300)
}
func TestNetworkGraphSnapshot(t *testing.T) {
snapshot(t, "network_graph", 520, 320, func(c *ui.Context) {
var s NetworkGraphState
ui.Column(c).Padding(16).Children(func() {
NetworkGraph(c, &s, graphNodes, append(graphEdges, GraphEdge{"b", "d"}, GraphEdge{"b", "c"}), NetworkGraphOptions{Frame: ChartFrame{Label: "House"}, Force: true})
})
})
}
+146
View File
@@ -0,0 +1,146 @@
package mujica
import (
"fmt"
"math"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// ChartSlice is one share of a PieChart or FunnelChart.
type ChartSlice struct {
Label string
Value float64
}
// PieChartOptions configure a PieChart.
type PieChartOptions struct {
Frame ChartFrame
// Donut cuts out the middle and writes the total there.
Donut bool
// MinAngle merges two or more narrower slices into Other.
MinAngle float64
Format func(v float64) string
}
// PieChart shows shares as slices labeled with percents.
func PieChart(c *ui.Context, slices []ChartSlice, opts PieChartOptions) ChartResult {
if opts.MinAngle < 0 || opts.MinAngle >= 360 {
panic("mujica: PieChart MinAngle must be in [0, 360)")
}
ch := newChart(c, opts.Frame)
k := Tokens(c)
pal := internal.Palette(k)
size := FontSize(c, theme.CaptionSize)
parts := chartMemo(ch, "parts", [2]any{chartID(slices), opts.MinAngle}, func() []ChartSlice {
return pieMerge(slices, opts.MinAngle, msg(c, "pie_chart.other"))
})
total := 0.0
for _, s := range parts {
total += s.Value
}
n := len(parts)
if total == 0 {
n = 0
}
keys := make([]chartKey, len(parts))
for i, s := range parts {
keys[i] = chartKey{Name: s.Label, Color: pal[i%len(pal)], Shape: i}
}
// angle returns slice i's start and end, clockwise.
angle := func(i int) (float64, float64) {
a := 0.0
for j := range i {
a += parts[j].Value
}
return 2 * math.Pi * a / total, 2 * math.Pi * (a + parts[i].Value) / total
}
geom := func(r ui.Rect) (cx, cy, r0, r1 float32) {
r1 = min(r.W, r.H)/2 - 8
if opts.Donut {
r0 = r1 * 0.58
}
return r.X + r.W/2, r.Y + r.H/2, r0, r1
}
share := func(i int) string { return fmt.Sprintf("%.1f%%", 100*parts[i].Value/total) }
spec := chartSpec{n: n, keys: keys}
spec.readout = func(i int) string {
return parts[i].Label + " · " + chartValue(c, parts[i].Value, opts.Format) + " · " + share(i)
}
spec.pick = func(r ui.Rect, x, y float32) int {
cx, cy, r0, r1 := geom(r)
d := float32(math.Hypot(float64(x-cx), float64(y-cy)))
if d < r0 || d > r1 {
return -1
}
a := math.Atan2(float64(x-cx), float64(cy-y))
if a < 0 {
a += 2 * math.Pi
}
for i := range parts {
if a0, a1 := angle(i); a >= a0 && a < a1 {
return i
}
}
return -1
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
cx, cy, r0, r1 := geom(r)
for i := range parts {
a0, a1 := angle(i)
if a1 <= a0 {
continue
}
grow := float32(0)
if i == active {
grow = 6
}
p.FillPath(internal.Sector(cx, cy, r0, r1+grow, a0, a1), keys[i].Color)
p.StrokePath(internal.Sector(cx, cy, r0, r1+grow, a0, a1), 1.5, k.Surface)
// Wide slices carry their shape and percent.
if a1-a0 > 0.38 {
mx, my := internal.Polar(cx, cy, (r0+r1)/2+float32(chartBool(r0 == 0))*r1*0.12, (a0+a1)/2)
ink := chartInk(k, keys[i].Color)
internal.Marker(p, i, mx, my-8, 9, ink)
chartText(p, share(i), mx, my+2, size, ink, 0.5, 0)
}
}
if opts.Donut {
chartText(p, chartValue(c, total, opts.Format), cx, cy, FontSize(c, theme.H3Size), k.Text, 0.5, 0.75)
chartText(p, msg(c, "pie_chart.total"), cx, cy+4, size, k.TextMuted, 0.5, 0)
}
}
res, _ := ch.build(spec)
return res
}
// pieMerge folds two or more narrow slices into other.
func pieMerge(slices []ChartSlice, minAngle float64, other string) []ChartSlice {
total := 0.0
for _, s := range slices {
if s.Value < 0 || math.IsNaN(s.Value) || math.IsInf(s.Value, 0) || s.Label == "" {
panic("mujica: PieChart slices need a label and a finite value ≥ 0")
}
total += s.Value
}
var keep []ChartSlice
small, rest := 0, 0.0
for _, s := range slices {
if total > 0 && s.Value/total*360 < minAngle {
small++
rest += s.Value
}
}
for _, s := range slices {
if small < 2 || total == 0 || s.Value/total*360 >= minAngle {
keep = append(keep, s)
}
}
if small >= 2 {
keep = append(keep, ChartSlice{Label: other, Value: rest})
}
return keep
}
+82
View File
@@ -0,0 +1,82 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
var pieSlices = []ChartSlice{{"Wax", 50}, {"Oil", 30}, {"Myrrh", 12}, {"Salt", 4}, {"Ash", 4}}
func TestPieMerge(t *testing.T) {
got := pieMerge(pieSlices, 20, "Other")
if fmt.Sprint(got) != "[{Wax 50} {Oil 30} {Myrrh 12} {Other 8}]" {
t.Fatalf("merge 20°: %v", got)
}
if got := pieMerge(pieSlices[:4], 20, "Other"); len(got) != 4 {
t.Fatalf("a single narrow slice merged: %v", got)
}
}
func TestPieChartReadoutAndHover(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return PieChart(c, pieSlices, PieChartOptions{Frame: ChartFrame{Label: "Stores"}, Donut: true, MinAngle: 20})
}}
tt := ui.NewTester(h.view, 520, 420)
if !tt.HasText("Other") {
t.Fatalf("legend lacks Other: %q", tt.Texts())
}
tabTo(t, tt, "Stores")
tt.Key(0, ui.KeyEnd)
if got := chartReadout(tt); got != "Other · 8 · 8.0%" {
t.Fatalf("readout %q", got)
}
b := h.res.Element.Bounds()
plotTop := b.Y + 38 // below the one-line legend
cy := plotTop + 120
tt.Move(b.X+b.W/2+60, cy-40) // upper right: the first slice
if got := chartReadout(tt); got != "Wax · 50 · 50.0%" {
t.Fatalf("hover %q", got)
}
tt.Key(0, ui.KeyEscape)
tt.Move(b.X+b.W/2, cy) // the hole
if h.res.Point != -1 {
t.Fatalf("hole picked %d", h.res.Point)
}
}
func TestPieChartBad(t *testing.T) {
for _, s := range [][]ChartSlice{{{"a", -1}}, {{"", 1}}} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%v: no panic", s)
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
PieChart(c, s, PieChartOptions{Frame: ChartFrame{Label: "x"}})
}, 300, 300)
}()
}
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return PieChart(c, []ChartSlice{{"a", 0}}, PieChartOptions{Frame: ChartFrame{Label: "x"}})
}}
if tt := ui.NewTester(h.view, 300, 300); !tt.HasText("No data") {
t.Fatal("zero total is not empty")
}
}
func TestPieChartSnapshot(t *testing.T) {
snapshot(t, "pie_chart", 640, 320, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(16).Children(func() {
ui.Column(c).Width(296).Children(func() {
PieChart(c, pieSlices, PieChartOptions{Frame: ChartFrame{Label: "Pie", Height: 220}})
})
ui.Column(c).Width(296).Children(func() {
PieChart(c, pieSlices, PieChartOptions{Frame: ChartFrame{Label: "Donut", Height: 220}, Donut: true, MinAngle: 20})
})
})
})
}
+141
View File
@@ -0,0 +1,141 @@
package mujica
import (
"math"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// RadarChartOptions configure a RadarChart.
type RadarChartOptions struct {
Frame ChartFrame
// Max is the outer ring's value; 0 means automatic.
Max float64
Format func(v float64) string
}
// RadarChart draws series as polygons over three or more axes.
func RadarChart(c *ui.Context, axes []string, series []ChartSeries, opts RadarChartOptions) ChartResult {
if n := chartCheckSeries(series); len(axes) < 3 || n != len(axes) && len(series) > 0 || opts.Max < 0 {
panic("mujica: RadarChart needs three or more axes, one value per axis and Max ≥ 0")
}
ch := newChart(c, opts.Frame)
k := Tokens(c)
pal := internal.Palette(k)
size := FontSize(c, theme.CaptionSize)
keys := make([]chartKey, len(series))
top := opts.Max
for i, s := range series {
keys[i] = chartKey{Name: s.Name, Color: pal[i%len(pal)], Shape: i}
for _, v := range s.Values {
if v < 0 {
panic("mujica: RadarChart values must be ≥ 0")
}
if !ch.hidden(s.Name) && opts.Max == 0 && !math.IsNaN(v) {
top = max(top, v)
}
}
}
n := len(axes)
if top == 0 {
n = 0
}
var step float64
if n > 0 && opts.Max == 0 {
_, top, step = internal.Nice(0, top, 4)
} else if n > 0 {
_, _, step = internal.Nice(0, top, 4)
}
geom := func(r ui.Rect) (cx, cy, rad float32) {
return r.X + r.W/2, r.Y + r.H/2 + 4, min(r.W/2-70, r.H/2-24)
}
angle := func(i int) float64 { return 2 * math.Pi * float64(i) / float64(len(axes)) }
spec := chartSpec{n: n, keys: keys, toggle: len(series) > 1}
spec.readout = func(i int) string {
parts := []string{axes[i]}
for _, s := range series {
if !ch.hidden(s.Name) {
parts = append(parts, s.Name+" "+chartValue(c, s.Values[i], opts.Format))
}
}
return strings.Join(parts, " · ")
}
spec.pick = func(r ui.Rect, x, y float32) int {
cx, cy, rad := geom(r)
if math.Hypot(float64(x-cx), float64(y-cy)) > float64(rad)+16 {
return -1
}
return internal.Nearest(len(axes), func(i int) (float32, float32) { return internal.Polar(cx, cy, rad, angle(i)) }, x, y, 2*rad+32, false)
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
cx, cy, rad := geom(r)
for _, t := range internal.Ticks(step, top, step) {
var ring ui.Path
for i := range axes {
x, y := internal.Polar(cx, cy, rad*float32(t/top), angle(i))
if i == 0 {
ring.MoveTo(x, y)
} else {
ring.LineTo(x, y)
}
}
ring.Close()
p.StrokePath(&ring, 1, k.Border)
chartText(p, internal.FormatTick(t, step), cx-4, cy-rad*float32(t/top), size, k.TextMuted, 1, 0.5)
}
for i, a := range axes {
x, y := internal.Polar(cx, cy, rad, angle(i))
w := float32(1)
if i == active {
w = 2
}
var spoke ui.Path
spoke.MoveTo(cx, cy).LineTo(x, y)
p.StrokePath(&spoke, w, k.ControlBorder)
lx, ly := internal.Polar(cx, cy, rad+10, angle(i))
ax := 0.5 + 0.5*float32(math.Sin(angle(i)))
col := k.TextMuted
if i == active {
col = k.Text
}
chartText(p, a, lx, ly, size, col, 1-ax, 0.5+0.5*float32(math.Cos(angle(i))))
}
for si, s := range series {
if ch.hidden(s.Name) {
continue
}
var poly ui.Path
pt := func(i int) (float32, float32) {
v := s.Values[i]
if math.IsNaN(v) {
v = 0
}
return internal.Polar(cx, cy, rad*float32(v/top), angle(i))
}
for i := range axes {
if i == 0 {
poly.MoveTo(pt(i))
} else {
poly.LineTo(pt(i))
}
}
poly.Close()
p.FillPath(&poly, keys[si].Color.Alpha(0.16))
p.StrokePath(&poly, 2, keys[si].Color)
for i := range axes {
x, y := pt(i)
sz := float32(7)
if i == active {
sz = 11
}
internal.Marker(p, si, x, y, sz, keys[si].Color)
}
}
}
res, _ := ch.build(spec)
return res
}
+71
View File
@@ -0,0 +1,71 @@
package mujica
import (
"testing"
"github.com/egoist/mygo/ui"
)
var radarAxes = []string{"Voice", "Latin", "Chant", "Organ", "Bells"}
func radarSeries() []ChartSeries {
return []ChartSeries{
{Name: "Agnès", Values: []float64{8, 6, 9, 4, 7}},
{Name: "Clare", Values: []float64{5, 9, 6, 8, 3}},
}
}
func TestRadarChartReadout(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return RadarChart(c, radarAxes, radarSeries(), RadarChartOptions{Frame: ChartFrame{Label: "Skills"}})
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Skills")
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "Latin · Agnès 6 · Clare 9" {
t.Fatalf("readout %q", got)
}
b := h.res.Element.Bounds()
cx, cy := b.X+b.W/2, b.Y+38+120+4
tt.Key(0, ui.KeyEscape)
tt.Move(cx, cy-60) // straight up: the first axis
if got := chartReadout(tt); got != "Voice · Agnès 8 · Clare 5" {
t.Fatalf("hover %q", got)
}
if err := tt.Click("Clare"); err != nil {
t.Fatal(err)
}
tt.Move(cx, cy-60)
if got := chartReadout(tt); got != "Voice · Agnès 8" {
t.Fatalf("hidden series read: %q", got)
}
}
func TestRadarChartBad(t *testing.T) {
for name, build := range map[string]func(c *ui.Context){
"two axes": func(c *ui.Context) {
RadarChart(c, radarAxes[:2], nil, RadarChartOptions{Frame: ChartFrame{Label: "x"}})
},
"negative": func(c *ui.Context) {
RadarChart(c, radarAxes[:3], []ChartSeries{{Name: "a", Values: []float64{1, -1, 2}}}, RadarChartOptions{Frame: ChartFrame{Label: "x"}})
},
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); build(c) }, 300, 300)
}()
}
}
func TestRadarChartSnapshot(t *testing.T) {
snapshot(t, "radar_chart", 520, 340, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
RadarChart(c, radarAxes, radarSeries(), RadarChartOptions{Frame: ChartFrame{Label: "Skills"}})
})
})
}
+121
View File
@@ -0,0 +1,121 @@
package mujica
import (
"log/slog"
"math"
"sort"
"time"
"github.com/egoist/mygo/ui"
)
// RealtimeBuffer keeps the latest samples of named series.
type RealtimeBuffer struct {
names []string
limit int
times []time.Time
vals [][]float64
}
// NewRealtimeBuffer keeps up to capacity samples of the named series.
func NewRealtimeBuffer(capacity int, names ...string) *RealtimeBuffer {
if capacity < 2 || len(names) == 0 {
panic("mujica: RealtimeBuffer needs a capacity of 2 or more and a series name")
}
return &RealtimeBuffer{names: names, limit: capacity, vals: make([][]float64, len(names))}
}
// Append adds one sample per series at a non-decreasing t.
func (b *RealtimeBuffer) Append(t time.Time, values ...float64) {
if len(values) != len(b.names) || len(b.times) > 0 && t.Before(b.times[len(b.times)-1]) {
panic("mujica: RealtimeBuffer.Append needs one value per series and a time not before the last")
}
b.times = append(b.times, t)
for i, v := range values {
b.vals[i] = append(b.vals[i], v)
}
// Old samples are dropped in blocks, so appends stay cheap.
if len(b.times) >= 2*b.limit {
drop := len(b.times) - b.limit
b.times = append([]time.Time(nil), b.times[drop:]...)
for i := range b.vals {
b.vals[i] = append([]float64(nil), b.vals[i][drop:]...)
}
}
}
// Len is how many samples the buffer keeps.
func (b *RealtimeBuffer) Len() int { return min(len(b.times), b.limit) }
// RealtimeChartOptions configure a RealtimeChart.
type RealtimeChartOptions struct {
Frame ChartFrame
// Window is the time shown up to the newest sample.
Window time.Duration
Format func(v float64) string
}
// RealtimeChartState holds whether the view is paused, and where.
type RealtimeChartState struct {
Paused bool
end time.Time
}
// RealtimeChart shows a fixed window ending at the newest sample.
func RealtimeChart(c *ui.Context, s *RealtimeChartState, buf *RealtimeBuffer, opts RealtimeChartOptions) ChartResult {
if opts.Window <= 0 {
panic("mujica: RealtimeChart needs a positive Window")
}
u := Density(c).Unit()
var res ChartResult
wrap := ui.Column(c).Gap(u * 2).MinWidth(0)
wrap.Children(func() {
label := msg(c, "realtime_chart.pause")
if s.Paused {
label = msg(c, "realtime_chart.resume")
}
if b := Button(c, label, ButtonOptions{Variant: Secondary, Disabled: wrap.IsDisabled()}).AlignSelf(ui.Start); b.Clicked() && !b.IsDisabled() {
s.Paused, res.value = !s.Paused, true
slog.Debug("mujica: realtime_chart pause", "paused", s.Paused)
}
times := buf.times[max(0, len(buf.times)-buf.limit):]
last := time.Time{}
if len(times) > 0 {
last = times[len(times)-1]
}
if !s.Paused || s.end.IsZero() {
s.end = last
}
from := s.end.Add(-opts.Window)
i0 := sort.Search(len(times), func(i int) bool { return !times[i].Before(from) })
i1 := sort.Search(len(times), func(i int) bool { return times[i].After(s.end) })
off := len(buf.times) - len(times)
ch := newChart(c, opts.Frame)
series := make([]ChartSeries, len(buf.names))
tops := make([][]float64, len(buf.names))
for i, name := range buf.names {
series[i] = ChartSeries{Name: name, Values: buf.vals[i][off+i0 : off+i1]}
tops[i] = series[i].Values
}
n := i1 - i0
var ax chartX
if n > 0 {
ax = chartMemo(ch, "x", [2]any{chartID(times[i0:i1]), s.end}, func() chartX {
a := newChartX(n, nil, times[i0:i1])
a.d0, a.d1 = float64(from.UnixNano())/1e9, float64(s.end.UnixNano())/1e9
if a.d1 == a.d0 {
a.d0 -= 1
}
return a
})
}
lo, hi, ok := chartYRange(ch, series, false, nil)
if !ok || math.IsNaN(lo) {
n = 0
}
r := chartLines(ch, chartLineData{series: series, tops: tops, ax: ax, lo: lo, hi: hi, n: n, format: opts.Format})
res.Point, res.changed, res.retry, res.toggled = r.Point, r.changed, r.retry, r.toggled
})
res.Element = wrap
return res
}
+86
View File
@@ -0,0 +1,86 @@
package mujica
import (
"math"
"strings"
"testing"
"time"
"github.com/egoist/mygo/ui"
)
var rtStart = time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
func TestRealtimeBufferTrims(t *testing.T) {
b := NewRealtimeBuffer(100, "a")
for i := range 100_000 {
b.Append(rtStart.Add(time.Duration(i)*time.Second), float64(i))
}
if b.Len() != 100 || len(b.times) >= 200 || b.vals[0][len(b.vals[0])-1] != 99_999 {
t.Fatalf("kept %d samples, %d stored", b.Len(), len(b.times))
}
defer func() {
if recover() == nil {
t.Fatal("no panic for a sample back in time")
}
}()
b.Append(rtStart, 1)
}
func TestRealtimeChartWindowAndPause(t *testing.T) {
buf := NewRealtimeBuffer(1000, "Bell", "Organ")
var st RealtimeChartState
values := 0
h := &chartHarness{build: func(c *ui.Context) ChartResult {
r := RealtimeChart(c, &st, buf, RealtimeChartOptions{Frame: ChartFrame{Label: "Sound"}, Window: 10 * time.Second})
if r.ValueChanged() {
values++
}
return r
}}
tt := ui.NewTester(h.view, 520, 440)
if !tt.HasText("No data") {
t.Fatalf("empty buffer: %q", tt.Texts())
}
for i := range 30 {
buf.Append(rtStart.Add(time.Duration(i)*time.Second), float64(i), float64(2*i))
}
tt.Frame()
tabTo(t, tt, "Sound")
tt.Key(0, ui.KeyHome)
if got := chartReadout(tt); got != "2026-10-05 09:00:19 · Bell 19 · Organ 38" {
t.Fatalf("window start %q", got)
}
if err := tt.Click("Pause"); err != nil || !st.Paused || values != 1 {
t.Fatalf("pause: %v %v %d", err, st.Paused, values)
}
for i := 30; i < 40; i++ {
buf.Append(rtStart.Add(time.Duration(i)*time.Second), float64(i), 0)
}
tt.Frame()
tabTo(t, tt, "Sound")
tt.Key(0, ui.KeyEnd)
if got := chartReadout(tt); !strings.HasPrefix(got, "2026-10-05 09:00:29 ·") {
t.Fatalf("paused view moved: %q", got)
}
_ = tt.Click("Resume")
tabTo(t, tt, "Sound")
tt.Key(0, ui.KeyEnd)
if got := chartReadout(tt); !strings.HasPrefix(got, "2026-10-05 09:00:39 ·") || st.Paused {
t.Fatalf("resumed view %q", got)
}
}
func TestRealtimeChartSnapshot(t *testing.T) {
buf := NewRealtimeBuffer(200, "Bell", "Organ")
for i := range 60 {
x := float64(i)
buf.Append(rtStart.Add(time.Duration(i)*time.Second), 5+3*math.Sin(x/5), 4+2*math.Sin(x/3+1))
}
snapshot(t, "realtime_chart", 520, 380, func(c *ui.Context) {
var st RealtimeChartState
ui.Column(c).Padding(16).Children(func() {
RealtimeChart(c, &st, buf, RealtimeChartOptions{Frame: ChartFrame{Label: "Sound"}, Window: 30 * time.Second})
})
})
}
+235
View File
@@ -0,0 +1,235 @@
package mujica
import (
"math"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// SankeyNode is a stage that flow passes through.
type SankeyNode struct{ ID, Label string }
// SankeyLink carries Value from node From to node To.
type SankeyLink struct {
From, To string
Value float64
}
// SankeyChartOptions configure a SankeyChart.
type SankeyChartOptions struct {
Frame ChartFrame
Format func(v float64) string
}
// sankeyLayout puts nodes in columns by longest path.
type sankeyLayout struct {
col []int
in, out []float64
cols int
from, to []int
}
func sankeyPlace(nodes []SankeyNode, links []SankeyLink) sankeyLayout {
at := map[string]int{}
for i, n := range nodes {
if n.ID == "" || n.Label == "" {
panic("mujica: SankeyChart nodes need an ID and a Label")
}
if _, dup := at[n.ID]; dup {
panic("mujica: SankeyChart has two nodes " + n.ID)
}
at[n.ID] = i
}
l := sankeyLayout{col: make([]int, len(nodes)), in: make([]float64, len(nodes)), out: make([]float64, len(nodes))}
for _, e := range links {
f, okf := at[e.From]
t, okt := at[e.To]
if !okf || !okt || f == t || !(e.Value > 0) || math.IsInf(e.Value, 0) {
panic("mujica: SankeyChart links need known ends and a positive value")
}
l.from, l.to = append(l.from, f), append(l.to, t)
l.out[f] += e.Value
l.in[t] += e.Value
}
// Relax columns; more passes than nodes means a cycle.
for pass := 0; ; pass++ {
if pass > len(nodes) {
panic("mujica: SankeyChart links form a cycle")
}
moved := false
for i := range links {
if l.col[l.to[i]] < l.col[l.from[i]]+1 {
l.col[l.to[i]], moved = l.col[l.from[i]]+1, true
}
}
if !moved {
break
}
}
for i := range nodes {
l.cols = max(l.cols, l.col[i]+1)
}
return l
}
// SankeyChart draws flows; reading a mark highlights its paths.
func SankeyChart(c *ui.Context, nodes []SankeyNode, links []SankeyLink, opts SankeyChartOptions) ChartResult {
ch := newChart(c, opts.Frame)
l := chartMemo(ch, "layout", [2]string{chartID(nodes), chartID(links)}, func() sankeyLayout { return sankeyPlace(nodes, links) })
k := Tokens(c)
pal := internal.Palette(k)
size := FontSize(c, theme.CaptionSize)
val := func(v float64) string { return chartValue(c, v, opts.Format) }
size1 := func(i int) float64 { return max(l.in[i], l.out[i]) }
// geom returns node boxes and link ends.
geom := func(r ui.Rect) (boxes []ui.Rect, y0, y1 []float32, scale float32) {
const gap, w = 10, 12
plot := ui.Rect{X: r.X + 8, Y: r.Y + 6, W: r.W - 16, H: r.H - 12}
most, count := 0.0, 0
for col := range l.cols {
sum, n := 0.0, 0
for i := range nodes {
if l.col[i] == col {
sum, n = sum+size1(i), n+1
}
}
if sum > most {
most, count = sum, n
}
}
scale = (plot.H - gap*float32(max(count-1, 0))) / float32(max(most, 1e-9))
boxes = make([]ui.Rect, len(nodes))
step := (plot.W - w) / float32(max(l.cols-1, 1))
next := make([]float32, l.cols)
for i := range nodes {
h := max(float32(size1(i))*scale, 2)
boxes[i] = ui.Rect{X: plot.X + step*float32(l.col[i]), Y: plot.Y + next[l.col[i]], W: w, H: h}
next[l.col[i]] += h + gap
}
outAt, inAt := make([]float32, len(nodes)), make([]float32, len(nodes))
for i := range links {
y0, y1 = append(y0, boxes[l.from[i]].Y+outAt[l.from[i]]), append(y1, boxes[l.to[i]].Y+inAt[l.to[i]])
outAt[l.from[i]] += float32(links[i].Value) * scale
inAt[l.to[i]] += float32(links[i].Value) * scale
}
return
}
// ribbon returns link i's top edge at t and thickness.
ribbon := func(boxes []ui.Rect, y0, y1 []float32, scale float32, i int, t float32) (x, y, h float32) {
a, b := boxes[l.from[i]], boxes[l.to[i]]
s := t * t * (3 - 2*t)
return a.X + a.W + (b.X-a.X-a.W)*t, y0[i] + (y1[i]-y0[i])*s, float32(links[i].Value) * scale
}
n := len(nodes) + len(links)
if len(links) == 0 {
n = 0
}
spec := chartSpec{n: n}
spec.readout = func(i int) string {
if i < len(nodes) {
return nodes[i].Label + " · " + msg(c, "sankey_chart.in") + " " + val(l.in[i]) + " · " + msg(c, "sankey_chart.out") + " " + val(l.out[i])
}
e := links[i-len(nodes)]
return nodes[l.from[i-len(nodes)]].Label + " → " + nodes[l.to[i-len(nodes)]].Label + " · " + val(e.Value)
}
spec.pick = func(r ui.Rect, x, y float32) int {
boxes, y0, y1, scale := geom(r)
for i, b := range boxes {
if (ui.Rect{X: b.X - 4, Y: b.Y, W: b.W + 8, H: b.H}).Contains(x, y) {
return i
}
}
for i := range links {
a, b := boxes[l.from[i]], boxes[l.to[i]]
if x < a.X+a.W || x > b.X {
continue
}
_, top, h := ribbon(boxes, y0, y1, scale, i, (x-a.X-a.W)/(b.X-a.X-a.W))
if y >= top && y <= top+max(h, 4) {
return len(nodes) + i
}
}
return -1
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
boxes, y0, y1, scale := geom(r)
lit := sankeyLit(l, len(nodes), active)
for i := range links {
var band ui.Path
const steps = 24
for s := 0; s <= steps; s++ {
x, y, _ := ribbon(boxes, y0, y1, scale, i, float32(s)/steps)
if s == 0 {
band.MoveTo(x, y)
} else {
band.LineTo(x, y)
}
}
for s := steps; s >= 0; s-- {
x, y, h := ribbon(boxes, y0, y1, scale, i, float32(s)/steps)
band.LineTo(x, y+h)
}
band.Close()
alpha := float32(0.4)
if lit != nil && !lit[len(nodes)+i] {
alpha = 0.12
}
p.FillPath(&band, pal[l.from[i]%len(pal)].Alpha(alpha))
if active == len(nodes)+i {
p.StrokePath(&band, 1.5, k.Text)
}
}
for i, b := range boxes {
col := pal[i%len(pal)]
if lit != nil && !lit[i] {
col = col.Mix(k.Surface, 0.6)
}
p.Fill(b, col, 1)
if i == active {
p.Stroke(ui.Rect{X: b.X - 2, Y: b.Y - 2, W: b.W + 4, H: b.H + 4}, k.Text, 2, 1.5)
}
if l.col[i] == l.cols-1 {
chartText(p, nodes[i].Label, b.X-6, b.Y+b.H/2, size, k.Text, 1, 0.5)
} else {
chartText(p, nodes[i].Label, b.X+b.W+6, b.Y+b.H/2, size, k.Text, 0, 0.5)
}
}
}
res, _ := ch.build(spec)
return res
}
// sankeyLit marks everything on paths through active; nil means all.
func sankeyLit(l sankeyLayout, nodes, active int) []bool {
if active < 0 {
return nil
}
lit := make([]bool, nodes+len(l.from))
var walk func(node int, down bool)
walk = func(node int, down bool) {
lit[node] = true
for i := range l.from {
if down && l.from[i] == node && !lit[nodes+i] {
lit[nodes+i] = true
walk(l.to[i], true)
}
if !down && l.to[i] == node && !lit[nodes+i] {
lit[nodes+i] = true
walk(l.from[i], false)
}
}
}
if active < nodes {
walk(active, true)
walk(active, false)
return lit
}
i := active - nodes
lit[active] = true
walk(l.to[i], true)
walk(l.from[i], false)
return lit
}
+81
View File
@@ -0,0 +1,81 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
var sankeyNodes = []SankeyNode{{"tithe", "Tithes"}, {"alms", "Alms"}, {"fund", "Fund"}, {"roof", "Roof"}, {"bread", "Bread"}, {"bells", "Bells"}}
var sankeyLinks = []SankeyLink{
{"tithe", "fund", 60}, {"alms", "fund", 30}, {"alms", "bread", 10},
{"fund", "roof", 50}, {"fund", "bells", 40},
}
func TestSankeyPlaceStable(t *testing.T) {
l := sankeyPlace(sankeyNodes, sankeyLinks)
if fmt.Sprint(l.col) != "[0 0 1 2 1 2]" || l.cols != 3 || l.in[2] != 90 || l.out[1] != 40 {
t.Fatalf("layout %+v", l)
}
lit := sankeyLit(l, len(sankeyNodes), 0) // Tithes: its fund, roof and bells, not alms
if !lit[2] || !lit[3] || !lit[5] || lit[1] || lit[4] || !lit[6] || lit[7] {
t.Fatalf("lit from Tithes %v", lit)
}
lit = sankeyLit(l, len(sankeyNodes), 6+3) // the fund → roof link
if !lit[0] || !lit[1] || !lit[3] || lit[5] || lit[6+4] {
t.Fatalf("lit from fund → roof %v", lit)
}
}
func TestSankeyChartReadout(t *testing.T) {
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return SankeyChart(c, sankeyNodes, sankeyLinks, SankeyChartOptions{Frame: ChartFrame{Label: "Money"}})
}}
tt := ui.NewTester(h.view, 560, 440)
tabTo(t, tt, "Money")
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "Fund · in 90 · out 90" {
t.Fatalf("node %q", got)
}
tt.Key(0, ui.KeyEnd)
if got := chartReadout(tt); got != "Fund → Bells · 40" {
t.Fatalf("link %q", got)
}
tt.Key(0, ui.KeyEscape)
b := h.res.Element.Bounds()
tt.Move(b.X+8+4, b.Y+10) // the first node, at the top left
if got := chartReadout(tt); got != "Tithes · in 0 · out 60" {
t.Fatalf("hover %q", got)
}
}
func TestSankeyChartBad(t *testing.T) {
for name, links := range map[string][]SankeyLink{
"cycle": {{"tithe", "fund", 1}, {"fund", "tithe", 1}},
"unknown": {{"tithe", "nowhere", 1}},
"zero": {{"tithe", "fund", 0}},
} {
func() {
defer func() {
if recover() == nil {
t.Errorf("%s: no panic", name)
}
}()
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
SankeyChart(c, sankeyNodes, links, SankeyChartOptions{Frame: ChartFrame{Label: "x"}})
}, 300, 300)
}()
}
}
func TestSankeyChartSnapshot(t *testing.T) {
snapshot(t, "sankey_chart", 560, 320, func(c *ui.Context) {
ui.Column(c).Padding(16).Children(func() {
SankeyChart(c, sankeyNodes, sankeyLinks, SankeyChartOptions{Frame: ChartFrame{Label: "Money"}})
})
})
}
+162
View File
@@ -0,0 +1,162 @@
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)}
}
func abs32(v float32) float32 { return max(v, -v) }
+82
View File
@@ -0,0 +1,82 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
var scatterGroups = []ScatterGroup{
{Name: "Monks", Points: []ScatterPoint{{1, 2, 4}, {3, 5, 9}, {6, 4, 1}}},
{Name: "Nuns", Points: []ScatterPoint{{2, 7, 16}, {8, 9, 2}}},
}
func TestScatterChartKeyboardSelect(t *testing.T) {
var sel []ScatterKey
values := 0
h := &chartHarness{build: func(c *ui.Context) ChartResult {
r := ScatterChart(c, &sel, scatterGroups, ScatterChartOptions{Frame: ChartFrame{Label: "Choir"}, Bubbles: true})
if r.ValueChanged() {
values++
}
return r
}}
tt := ui.NewTester(h.view, 520, 420)
tabTo(t, tt, "Choir")
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeySpace)
if fmt.Sprint(sel) != "[{0 1}]" || values != 1 {
t.Fatalf("Space selected %v (%d events)", sel, values)
}
if got := chartReadout(tt); got != "Monks · x 3 · y 5 · size 9 · selected" {
t.Fatalf("readout %q", got)
}
tt.Key(0, ui.KeyEnter)
if len(sel) != 0 {
t.Fatalf("Enter did not unselect: %v", sel)
}
}
func TestScatterChartBoxSelect(t *testing.T) {
var sel []ScatterKey
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return ScatterChart(c, &sel, scatterGroups, ScatterChartOptions{Frame: ChartFrame{Label: "Choir"}})
}}
tt := ui.NewTester(h.view, 520, 420)
b := h.res.Element.Bounds()
top := b.Y + 38
// Nice domains: x 0..10 over 52..W-12; y 0..10 over top+8..top+214.
x := func(v float32) float32 { return b.X + 52 + (b.W-64)*v/10 }
y := func(v float32) float32 { return top + 8 + 206*(1-v/10) }
tt.Press(x(0.5), y(6))
tt.Move(x(4), y(4))
tt.Move(x(6.5), y(1.5))
tt.Release(x(6.5), y(1.5))
if fmt.Sprint(sel) != "[{0 0} {0 1} {0 2}]" {
t.Fatalf("box chose %v", sel)
}
}
func TestScatterChartBad(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for negative size")
}
}()
var sel []ScatterKey
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
ScatterChart(c, &sel, []ScatterGroup{{Name: "a", Points: []ScatterPoint{{1, 1, -1}}}}, ScatterChartOptions{Frame: ChartFrame{Label: "x"}})
}, 300, 300)
}
func TestScatterChartSnapshot(t *testing.T) {
snapshot(t, "scatter_chart", 520, 340, func(c *ui.Context) {
sel := []ScatterKey{{1, 0}}
ui.Column(c).Padding(16).Children(func() {
ScatterChart(c, &sel, scatterGroups, ScatterChartOptions{Frame: ChartFrame{Label: "Choir"}, Bubbles: true})
})
})
}
+170
View File
@@ -0,0 +1,170 @@
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
}
+78
View File
@@ -0,0 +1,78 @@
package mujica
import (
"math"
"testing"
"github.com/egoist/mygo/ui"
)
var sparkValues = []float64{3, 5, 4, math.NaN(), 7, 6, 9}
func TestSparklineEndValueAndKeys(t *testing.T) {
kind := SparklineLine
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return Sparkline(c, sparkValues, SparklineOptions{Frame: ChartFrame{Label: "Bells"}, Kind: kind})
}}
tt := ui.NewTester(h.view, 300, 200)
if got := chartReadout(tt); got != "9" {
t.Fatalf("end value %q", got)
}
r, _ := tt.Find("Bells")
if r.H < 28 || r.W != 96 {
t.Fatalf("sparkline box %v", r)
}
tabTo(t, tt, "Bells")
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "5" || h.res.Point != 1 {
t.Fatalf("read %q at %d", got, h.res.Point)
}
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "no value" {
t.Fatalf("missing %q", got)
}
for _, kind = range []SparklineKind{SparklineBar, SparklineArea} {
tt.Frame()
tt.Move(r.X+r.W-2, r.Y+r.H/2)
if got := chartReadout(tt); got != "9" || h.res.Point != 6 {
t.Fatalf("kind %d hover %q", kind, got)
}
}
}
func TestSparklineStates(t *testing.T) {
status := DataLoading
var vals []float64
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return Sparkline(c, vals, SparklineOptions{Frame: ChartFrame{Label: "Bells", Status: status}})
}}
tt := ui.NewTester(h.view, 300, 200)
status = DataFailed
tt.Frame()
if err := tt.Click("Retry"); err != nil {
t.Fatal(err)
}
status = DataReady
tt.Frame()
if !tt.HasText("No data") {
t.Fatalf("empty %q", tt.Texts())
}
defer func() {
if recover() == nil {
t.Fatal("no panic without a label")
}
}()
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); Sparkline(c, vals, SparklineOptions{}) }, 100, 100)
}
func TestSparklineSnapshot(t *testing.T) {
snapshot(t, "sparkline", 420, 80, func(c *ui.Context) {
ui.Row(c).Padding(16).Gap(24).Children(func() {
for _, k := range []SparklineKind{SparklineLine, SparklineBar, SparklineArea} {
Sparkline(c, sparkValues, SparklineOptions{Frame: ChartFrame{Label: "Bells"}, Kind: k})
}
})
})
}
+155
View File
@@ -0,0 +1,155 @@
package mujica
import (
"fmt"
"log/slog"
"math"
"slices"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// SunburstOptions configure a Sunburst.
type SunburstOptions struct {
Frame ChartFrame
// Depth is the number of rings; 0 means 3.
Depth int
Format func(v float64) string
}
// sunArc is one drawn segment of a ring.
type sunArc struct {
path []string
node ChartNode
ring int
a0, a1 float64
color int
}
// Sunburst draws descendants of *path as rings; branches drill in.
func Sunburst(c *ui.Context, path *[]string, root ChartNode, opts SunburstOptions) ChartResult {
if opts.Depth < 0 {
panic("mujica: Sunburst Depth must be ≥ 0")
}
depth := opts.Depth
if depth == 0 {
depth = 3
}
chartCheckTree(root)
chartDescend(root, *path)
k := Tokens(c)
u := Density(c).Unit()
size := FontSize(c, theme.CaptionSize)
pal := internal.Palette(k)
var res ChartResult
wrap := ui.Column(c).Gap(u * 2).MinWidth(0)
wrap.Children(func() {
res.value = chartPath(c, root.Name, path, wrap.IsDisabled())
node := chartDescend(root, *path)
sum := node.total()
var arcs []sunArc
var walk func(n ChartNode, at []string, ring int, a0, a1 float64, color int)
walk = func(n ChartNode, at []string, ring int, a0, a1 float64, color int) {
total, width := n.total(), a1-a0
for i, ch := range n.Children {
v := ch.total()
if v <= 0 {
continue
}
span := width * v / total
col := color
if ring == 0 {
col = i
}
arc := sunArc{path: append(slices.Clone(at), ch.Name), node: ch, ring: ring, a0: a0, a1: a0 + span, color: col}
arcs = append(arcs, arc)
if ring+1 < depth {
walk(ch, arc.path, ring+1, arc.a0, arc.a1, col)
}
a0 += span
}
}
if sum > 0 {
walk(node, nil, 0, 0, 2*math.Pi, 0)
}
geom := func(r ui.Rect) (cx, cy, hole, ring float32) {
rad := min(r.W, r.H)/2 - 6
hole = rad * 0.3
return r.X + r.W/2, r.Y + r.H/2, hole, (rad - hole) / float32(depth)
}
ch := newChart(c, opts.Frame)
spec := chartSpec{n: len(arcs)}
spec.readout = func(i int) string {
a := arcs[i]
s := fmt.Sprintf("%s · %s · %.1f%%", strings.Join(a.path, " › "), chartValue(c, a.node.total(), opts.Format), 100*a.node.total()/sum)
if len(a.node.Children) > 0 {
s += " · " + msg(c, "chart.opens")
}
return s
}
spec.pick = func(r ui.Rect, x, y float32) int {
cx, cy, hole, ring := geom(r)
d := float32(math.Hypot(float64(x-cx), float64(y-cy)))
a := math.Atan2(float64(x-cx), float64(cy-y))
if a < 0 {
a += 2 * math.Pi
}
for i, arc := range arcs {
r0 := hole + ring*float32(arc.ring)
if d >= r0 && d < r0+ring && a >= arc.a0 && a < arc.a1 {
return i
}
}
return -1
}
spec.activate = func(i int) {
if len(arcs[i].node.Children) > 0 {
*path = append(slices.Clone(*path), arcs[i].path...)
res.value = true
slog.Debug("mujica: sunburst drill", "path", strings.Join(*path, "/"))
}
}
// color fades outer rings, keeping 3:1 against Surface.
color := func(a sunArc) ui.Color {
col := pal[a.color%len(pal)]
if faded := col.Mix(k.Surface, 0.18*float32(a.ring)); theme.Contrast(faded, k.Surface) >= 3 {
return faded
}
return col
}
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
cx, cy, hole, ring := geom(r)
for i, a := range arcs {
r0 := hole + ring*float32(a.ring)
col := color(a)
p.FillPath(internal.Sector(cx, cy, r0, r0+ring, a.a0, a.a1), col)
p.StrokePath(internal.Sector(cx, cy, r0, r0+ring, a.a0, a.a1), 1.5, k.Surface)
if i == active {
p.StrokePath(internal.Sector(cx, cy, r0+2, r0+ring-2, a.a0, a.a1), 2, chartInk(k, col))
}
}
// Labels go on top where arcs are long enough.
for _, a := range arcs {
r0 := hole + ring*float32(a.ring)
col := color(a)
if mid := float64(r0+ring/2) * (a.a1 - a.a0); mid > 44 {
x, y := internal.Polar(cx, cy, r0+ring/2, (a.a0+a.a1)/2)
chartText(p, chartFit(p, a.node.Name, min(float32(mid)*0.8, ring*1.25), size), x, y, size, chartInk(k, col), 0.5, 0.5)
}
}
chartText(p, chartFit(p, node.Name, hole*2-8, size), cx, cy, size, k.TextMuted, 0.5, 1)
chartText(p, chartValue(c, sum, opts.Format), cx, cy+2, FontSize(c, theme.BodySize), k.Text, 0.5, 0)
}
r, plot := ch.build(spec)
if plot != nil && !plot.IsDisabled() && plot.Shortcut(0, ui.KeyBackspace) && len(*path) > 0 {
*path, res.value = (*path)[:len(*path)-1], true
}
res.Point, res.changed, res.retry = r.Point, r.changed, r.retry
})
res.Element = wrap
return res
}
+64
View File
@@ -0,0 +1,64 @@
package mujica
import (
"fmt"
"testing"
"github.com/egoist/mygo/ui"
)
func TestSunburstReadoutAndDrill(t *testing.T) {
var path []string
h := &chartHarness{build: func(c *ui.Context) ChartResult {
return Sunburst(c, &path, chartTree(), SunburstOptions{Frame: ChartFrame{Label: "Rooms"}})
}}
tt := ui.NewTester(h.view, 560, 440)
tabTo(t, tt, "Rooms")
tt.Key(0, ui.KeyRight)
tt.Key(0, ui.KeyRight)
if got := chartReadout(tt); got != "Library › Psalters · 30 · 34.1%" {
t.Fatalf("readout %q", got)
}
tt.Key(0, ui.KeyLeft)
tt.Key(0, ui.KeySpace)
if fmt.Sprint(path) != "[Library]" || !tt.HasText("Abbey") {
t.Fatalf("drill %v", path)
}
if err := tt.Click("Abbey"); err != nil || len(path) != 0 {
t.Fatalf("breadcrumb back: %v %v", err, path)
}
b := h.res.Element.Bounds()
plotTop := b.Y + b.H - 20 - 8 - 240
cx, cy := b.X+b.W/2, plotTop+120
tt.Key(0, ui.KeyEscape)
tt.Move(cx+50, cy-30) // inner ring, upper right: Library
if got := chartReadout(tt); got != "Library · 40 · 45.5% · Enter opens" {
t.Fatalf("hover %q", got)
}
tt.Move(cx-48*0.866, cy-48*0.5) // inner ring at 300°: Garden spans 266°–348°
if got := chartReadout(tt); got != "Garden · 20 · 22.7% · Enter opens" {
t.Fatalf("hover at 300° %q", got)
}
}
func TestSunburstBad(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("no panic for duplicate children")
}
}()
var path []string
ui.NewTester(func(c *ui.Context) {
Use(c, Settings{})
Sunburst(c, &path, ChartNode{Name: "r", Children: []ChartNode{{Name: "a", Value: 1}, {Name: "a", Value: 2}}}, SunburstOptions{Frame: ChartFrame{Label: "x"}})
}, 300, 300)
}
func TestSunburstSnapshot(t *testing.T) {
snapshot(t, "sunburst", 560, 360, func(c *ui.Context) {
var path []string
ui.Column(c).Padding(16).Children(func() {
Sunburst(c, &path, chartTree(), SunburstOptions{Frame: ChartFrame{Label: "Rooms", Height: 260}})
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more