diff --git a/area_chart.go b/area_chart.go new file mode 100644 index 0000000..739085d --- /dev/null +++ b/area_chart.go @@ -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 +} diff --git a/area_chart_test.go b/area_chart_test.go new file mode 100644 index 0000000..04db044 --- /dev/null +++ b/area_chart_test.go @@ -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}) + }) + }) +} diff --git a/bar_chart.go b/bar_chart.go new file mode 100644 index 0000000..7989ad2 --- /dev/null +++ b/bar_chart.go @@ -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) + } +} diff --git a/bar_chart_test.go b/bar_chart_test.go new file mode 100644 index 0000000..dfde4b0 --- /dev/null +++ b/bar_chart_test.go @@ -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}) + }) + }) + }) +} diff --git a/box_plot.go b/box_plot.go new file mode 100644 index 0000000..518ad02 --- /dev/null +++ b/box_plot.go @@ -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 +} diff --git a/box_plot_test.go b/box_plot_test.go new file mode 100644 index 0000000..34b3bfd --- /dev/null +++ b/box_plot_test.go @@ -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"}}) + }) + }) +} diff --git a/bullet_chart.go b/bullet_chart.go new file mode 100644 index 0000000..7dbd32e --- /dev/null +++ b/bullet_chart.go @@ -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 +} diff --git a/bullet_chart_test.go b/bullet_chart_test.go new file mode 100644 index 0000000..1d8ba54 --- /dev/null +++ b/bullet_chart_test.go @@ -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}}) + }) + }) + }) +} diff --git a/calendar_heatmap.go b/calendar_heatmap.go new file mode 100644 index 0000000..1b3e542 --- /dev/null +++ b/calendar_heatmap.go @@ -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 +} diff --git a/calendar_heatmap_test.go b/calendar_heatmap_test.go new file mode 100644 index 0000000..6c417e2 --- /dev/null +++ b/calendar_heatmap_test.go @@ -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}) + }) + }) +} diff --git a/chart_frame.go b/chart_frame.go new file mode 100644 index 0000000..9fdad75 --- /dev/null +++ b/chart_frame.go @@ -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 +} diff --git a/chart_frame_test.go b/chart_frame_test.go new file mode 100644 index 0000000..a678038 --- /dev/null +++ b/chart_frame_test.go @@ -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" } diff --git a/chart_series.go b/chart_series.go new file mode 100644 index 0000000..2e992bc --- /dev/null +++ b/chart_series.go @@ -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 + } +} diff --git a/chart_tree.go b/chart_tree.go new file mode 100644 index 0000000..4f51d5a --- /dev/null +++ b/chart_tree.go @@ -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)) +} diff --git a/data_table.go b/data_table.go index b5ea36c..9d36c5e 100644 --- a/data_table.go +++ b/data_table.go @@ -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" diff --git a/docs/components/101-line-chart.md b/docs/components/101-line-chart.md new file mode 100644 index 0000000..aefc23b --- /dev/null +++ b/docs/components/101-line-chart.md @@ -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 切换序列显隐。 + +## 限制 + +- 数据切片以首元素地址与长度识别;原地修改数据后请传入新切片,缓存的坐标范围才会更新。 +- 每条序列除颜色外还有不同形状的标记(圆、方、菱形、三角),密集时按间隔绘制。 diff --git a/docs/components/102-area-chart.md b/docs/components/102-area-chart.md new file mode 100644 index 0000000..bf747b7 --- /dev/null +++ b/docs/components/102-area-chart.md @@ -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 对比。 diff --git a/docs/components/103-bar-chart.md b/docs/components/103-bar-chart.md new file mode 100644 index 0000000..d162090 --- /dev/null +++ b/docs/components/103-bar-chart.md @@ -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 的悬停范围。 +- 类别标签过密时会跳过重叠的标签,读数仍包含完整类别名。 diff --git a/docs/components/104-pie-chart.md b/docs/components/104-pie-chart.md new file mode 100644 index 0000000..a2e760c --- /dev/null +++ b/docs/components/104-pie-chart.md @@ -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:清除读数。 + +## 限制 + +- 只有一个窄扇区时不合并,以免“其他”只代表一项。 +- 扇区内的形状标记与百分比文字按对比度自动选用正文色或表面色。 diff --git a/docs/components/105-scatter-chart.md b/docs/components/105-scatter-chart.md new file mode 100644 index 0000000..baf1af2 --- /dev/null +++ b/docs/components/105-scatter-chart.md @@ -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:选中或取消当前点。 +- 在绘图区拖动:框选,替换现有选择。 + +## 限制 + +- 框选替换选择,不支持按住修饰键追加。 +- 点数较多时逐点绘制,没有抽稀。 diff --git a/docs/components/106-radar-chart.md b/docs/components/106-radar-chart.md new file mode 100644 index 0000000..35a017e --- /dev/null +++ b/docs/components/106-radar-chart.md @@ -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 画在中心,读数显示“无数值”。 diff --git a/docs/components/107-heatmap-chart.md b/docs/components/107-heatmap-chart.md new file mode 100644 index 0000000..b931ea2 --- /dev/null +++ b/docs/components/107-heatmap-chart.md @@ -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:清除读数。 + +## 限制 + +- 颜色分五级,不做连续渐变。 diff --git a/docs/components/108-calendar-heatmap.md b/docs/components/108-calendar-heatmap.md new file mode 100644 index 0000000..26f6a30 --- /dev/null +++ b/docs/components/108-calendar-heatmap.md @@ -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 视为无记录。 diff --git a/docs/components/109-histogram.md b/docs/components/109-histogram.md new file mode 100644 index 0000000..3817d33 --- /dev/null +++ b/docs/components/109-histogram.md @@ -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:清除读数。 + +## 限制 + +- 按箱数分箱时最后一箱包含上界,读数用 `]` 表示。 diff --git a/docs/components/110-box-plot.md b/docs/components/110-box-plot.md new file mode 100644 index 0000000..bd80246 --- /dev/null +++ b/docs/components/110-box-plot.md @@ -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:清除读数。 + +## 限制 + +- 四分位数用线性插值计算。 diff --git a/docs/components/111-waterfall-chart.md b/docs/components/111-waterfall-chart.md new file mode 100644 index 0000000..2dfb8c9 --- /dev/null +++ b/docs/components/111-waterfall-chart.md @@ -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:清除读数。 + +## 限制 + +- 小计与总计都显示到该步为止的累计值,不清零。 diff --git a/docs/components/112-funnel-chart.md b/docs/components/112-funnel-chart.md new file mode 100644 index 0000000..436180c --- /dev/null +++ b/docs/components/112-funnel-chart.md @@ -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 时转化率显示“—”。 diff --git a/docs/components/113-bullet-chart.md b/docs/components/113-bullet-chart.md new file mode 100644 index 0000000..aab7ffa --- /dev/null +++ b/docs/components/113-bullet-chart.md @@ -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` 的值画到刻度终点,读数仍为原值。 diff --git a/docs/components/114-sparkline.md b/docs/components/114-sparkline.md new file mode 100644 index 0000000..7ff932f --- /dev/null +++ b/docs/components/114-sparkline.md @@ -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:恢复显示末值。 + +## 限制 + +- 不显示图例与提示行,以便放在表格行或文字中。 diff --git a/docs/components/115-gauge.md b/docs/components/115-gauge.md new file mode 100644 index 0000000..64360d8 --- /dev/null +++ b/docs/components/115-gauge.md @@ -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:清除读数。 + +## 限制 + +- 不做指针动画,值变化时直接切换。 diff --git a/docs/components/116-treemap.md b/docs/components/116-treemap.md new file mode 100644 index 0000000..e2347b4 --- /dev/null +++ b/docs/components/116-treemap.md @@ -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。 +- 只显示当前节点的一层子项。 diff --git a/docs/components/117-sunburst.md b/docs/components/117-sunburst.md new file mode 100644 index 0000000..908abbb --- /dev/null +++ b/docs/components/117-sunburst.md @@ -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:返回上一层。 + +## 限制 + +- 弧长不足时不写标签,名称在读数中完整给出。 diff --git a/docs/components/118-sankey-chart.md b/docs/components/118-sankey-chart.md new file mode 100644 index 0000000..f3568a3 --- /dev/null +++ b/docs/components/118-sankey-chart.md @@ -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。 +- 布局只在节点或链接切片变化时计算。 diff --git a/docs/components/119-network-graph.md b/docs/components/119-network-graph.md new file mode 100644 index 0000000..5677d5e --- /dev/null +++ b/docs/components/119-network-graph.md @@ -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:复位视图。 + +## 限制 + +- 布局只在节点或边的切片变化时重新计算,拖动不会重新适配视图。 +- 不支持边标签与有向箭头。 diff --git a/docs/components/120-realtime-chart.md b/docs/components/120-realtime-chart.md new file mode 100644 index 0000000..edb1c70 --- /dev/null +++ b/docs/components/120-realtime-chart.md @@ -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` 回到界面线程追加并刷新。 +- 缓冲区按块丢弃旧样本,最多占用两倍容量。 diff --git a/example_charts1_test.go b/example_charts1_test.go new file mode 100644 index 0000000..90433bb --- /dev/null +++ b/example_charts1_test.go @@ -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) +} diff --git a/examples/gallery/pages_charts1.go b/examples/gallery/pages_charts1.go index 77aacff..f7cb1dd 100644 --- a/examples/gallery/pages_charts1.go +++ b/examples/gallery/pages_charts1.go @@ -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") + } +} diff --git a/examples/gallery/pages_charts1_code.go b/examples/gallery/pages_charts1_code.go new file mode 100644 index 0000000..8548a47 --- /dev/null +++ b/examples/gallery/pages_charts1_code.go @@ -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)` diff --git a/examples/gallery/pages_charts1_flow.go b/examples/gallery/pages_charts1_flow.go new file mode 100644 index 0000000..02161ff --- /dev/null +++ b/examples/gallery/pages_charts1_flow.go @@ -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)) + } + }) +} diff --git a/examples/gallery/pages_charts1_series.go b/examples/gallery/pages_charts1_series.go new file mode 100644 index 0000000..6716f57 --- /dev/null +++ b/examples/gallery/pages_charts1_series.go @@ -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})) + }) +} diff --git a/examples/gallery/pages_charts1_stats.go b/examples/gallery/pages_charts1_stats.go new file mode 100644 index 0000000..3cb2c1e --- /dev/null +++ b/examples/gallery/pages_charts1_stats.go @@ -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})) + }) +} diff --git a/examples/gallery/pages_charts1_test.go b/examples/gallery/pages_charts1_test.go new file mode 100644 index 0000000..af673ca --- /dev/null +++ b/examples/gallery/pages_charts1_test.go @@ -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) +} diff --git a/funnel_chart.go b/funnel_chart.go new file mode 100644 index 0000000..afac694 --- /dev/null +++ b/funnel_chart.go @@ -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 +} diff --git a/funnel_chart_test.go b/funnel_chart_test.go new file mode 100644 index 0000000..d3dbfa6 --- /dev/null +++ b/funnel_chart_test.go @@ -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}}) + }) + }) +} diff --git a/gauge.go b/gauge.go new file mode 100644 index 0000000..21915df --- /dev/null +++ b/gauge.go @@ -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 +} diff --git a/gauge_test.go b/gauge_test.go new file mode 100644 index 0000000..5f87b6a --- /dev/null +++ b/gauge_test.go @@ -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}) + }) + }) + }) +} diff --git a/heatmap_chart.go b/heatmap_chart.go new file mode 100644 index 0000000..321dbf0 --- /dev/null +++ b/heatmap_chart.go @@ -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) +} diff --git a/heatmap_chart_test.go b/heatmap_chart_test.go new file mode 100644 index 0000000..4b6b10d --- /dev/null +++ b/heatmap_chart_test.go @@ -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}}) + }) + }) +} diff --git a/histogram.go b/histogram.go new file mode 100644 index 0000000..b36e96d --- /dev/null +++ b/histogram.go @@ -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 +} diff --git a/histogram_test.go b/histogram_test.go new file mode 100644 index 0000000..514bbdd --- /dev/null +++ b/histogram_test.go @@ -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}) + }) + }) +} diff --git a/internal/chart.go b/internal/chart.go new file mode 100644 index 0000000..30286b9 --- /dev/null +++ b/internal/chart.go @@ -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 +} diff --git a/internal/chart_test.go b/internal/chart_test.go new file mode 100644 index 0000000..f8eca08 --- /dev/null +++ b/internal/chart_test.go @@ -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") + } + } +} diff --git a/line_chart.go b/line_chart.go new file mode 100644 index 0000000..44a09c2 --- /dev/null +++ b/line_chart.go @@ -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 +} diff --git a/line_chart_test.go b/line_chart_test.go new file mode 100644 index 0000000..a933b4d --- /dev/null +++ b/line_chart_test.go @@ -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}}) + }) + }) +} diff --git a/list.go b/list.go index 32205b0..439d023 100644 --- a/list.go +++ b/list.go @@ -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. diff --git a/messages_charts1.go b/messages_charts1.go new file mode 100644 index 0000000..e207570 --- /dev/null +++ b/messages_charts1.go @@ -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", "继续"}, + }) +} diff --git a/network_graph.go b/network_graph.go new file mode 100644 index 0000000..c9f368e --- /dev/null +++ b/network_graph.go @@ -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 +} diff --git a/network_graph_test.go b/network_graph_test.go new file mode 100644 index 0000000..7b45133 --- /dev/null +++ b/network_graph_test.go @@ -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}) + }) + }) +} diff --git a/pie_chart.go b/pie_chart.go new file mode 100644 index 0000000..7024b6b --- /dev/null +++ b/pie_chart.go @@ -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 +} diff --git a/pie_chart_test.go b/pie_chart_test.go new file mode 100644 index 0000000..addb559 --- /dev/null +++ b/pie_chart_test.go @@ -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}) + }) + }) + }) +} diff --git a/radar_chart.go b/radar_chart.go new file mode 100644 index 0000000..d78b869 --- /dev/null +++ b/radar_chart.go @@ -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 +} diff --git a/radar_chart_test.go b/radar_chart_test.go new file mode 100644 index 0000000..fde7651 --- /dev/null +++ b/radar_chart_test.go @@ -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"}}) + }) + }) +} diff --git a/realtime_chart.go b/realtime_chart.go new file mode 100644 index 0000000..559f7be --- /dev/null +++ b/realtime_chart.go @@ -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 +} diff --git a/realtime_chart_test.go b/realtime_chart_test.go new file mode 100644 index 0000000..ab71e08 --- /dev/null +++ b/realtime_chart_test.go @@ -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}) + }) + }) +} diff --git a/sankey_chart.go b/sankey_chart.go new file mode 100644 index 0000000..edfe561 --- /dev/null +++ b/sankey_chart.go @@ -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 +} diff --git a/sankey_chart_test.go b/sankey_chart_test.go new file mode 100644 index 0000000..056c1b5 --- /dev/null +++ b/sankey_chart_test.go @@ -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"}}) + }) + }) +} diff --git a/scatter_chart.go b/scatter_chart.go new file mode 100644 index 0000000..99764f8 --- /dev/null +++ b/scatter_chart.go @@ -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) } diff --git a/scatter_chart_test.go b/scatter_chart_test.go new file mode 100644 index 0000000..713b57c --- /dev/null +++ b/scatter_chart_test.go @@ -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}) + }) + }) +} diff --git a/sparkline.go b/sparkline.go new file mode 100644 index 0000000..7048ebe --- /dev/null +++ b/sparkline.go @@ -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 +} diff --git a/sparkline_test.go b/sparkline_test.go new file mode 100644 index 0000000..b8bdbbd --- /dev/null +++ b/sparkline_test.go @@ -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}) + } + }) + }) +} diff --git a/sunburst.go b/sunburst.go new file mode 100644 index 0000000..6ee6712 --- /dev/null +++ b/sunburst.go @@ -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 +} diff --git a/sunburst_test.go b/sunburst_test.go new file mode 100644 index 0000000..e65d9c4 --- /dev/null +++ b/sunburst_test.go @@ -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}}) + }) + }) +} diff --git a/testdata/snapshots/darwin/area_chart-dark.png b/testdata/snapshots/darwin/area_chart-dark.png new file mode 100644 index 0000000..26128f5 Binary files /dev/null and b/testdata/snapshots/darwin/area_chart-dark.png differ diff --git a/testdata/snapshots/darwin/area_chart-light.png b/testdata/snapshots/darwin/area_chart-light.png new file mode 100644 index 0000000..101392a Binary files /dev/null and b/testdata/snapshots/darwin/area_chart-light.png differ diff --git a/testdata/snapshots/darwin/bar_chart-dark.png b/testdata/snapshots/darwin/bar_chart-dark.png new file mode 100644 index 0000000..118d393 Binary files /dev/null and b/testdata/snapshots/darwin/bar_chart-dark.png differ diff --git a/testdata/snapshots/darwin/bar_chart-light.png b/testdata/snapshots/darwin/bar_chart-light.png new file mode 100644 index 0000000..17580fc Binary files /dev/null and b/testdata/snapshots/darwin/bar_chart-light.png differ diff --git a/testdata/snapshots/darwin/box_plot-dark.png b/testdata/snapshots/darwin/box_plot-dark.png new file mode 100644 index 0000000..a6f9639 Binary files /dev/null and b/testdata/snapshots/darwin/box_plot-dark.png differ diff --git a/testdata/snapshots/darwin/box_plot-light.png b/testdata/snapshots/darwin/box_plot-light.png new file mode 100644 index 0000000..f6433c3 Binary files /dev/null and b/testdata/snapshots/darwin/box_plot-light.png differ diff --git a/testdata/snapshots/darwin/bullet_chart-dark.png b/testdata/snapshots/darwin/bullet_chart-dark.png new file mode 100644 index 0000000..82f62d5 Binary files /dev/null and b/testdata/snapshots/darwin/bullet_chart-dark.png differ diff --git a/testdata/snapshots/darwin/bullet_chart-light.png b/testdata/snapshots/darwin/bullet_chart-light.png new file mode 100644 index 0000000..45de0d3 Binary files /dev/null and b/testdata/snapshots/darwin/bullet_chart-light.png differ diff --git a/testdata/snapshots/darwin/calendar_heatmap-dark.png b/testdata/snapshots/darwin/calendar_heatmap-dark.png new file mode 100644 index 0000000..75a10e3 Binary files /dev/null and b/testdata/snapshots/darwin/calendar_heatmap-dark.png differ diff --git a/testdata/snapshots/darwin/calendar_heatmap-light.png b/testdata/snapshots/darwin/calendar_heatmap-light.png new file mode 100644 index 0000000..cb21e9b Binary files /dev/null and b/testdata/snapshots/darwin/calendar_heatmap-light.png differ diff --git a/testdata/snapshots/darwin/funnel_chart-dark.png b/testdata/snapshots/darwin/funnel_chart-dark.png new file mode 100644 index 0000000..0457c86 Binary files /dev/null and b/testdata/snapshots/darwin/funnel_chart-dark.png differ diff --git a/testdata/snapshots/darwin/funnel_chart-light.png b/testdata/snapshots/darwin/funnel_chart-light.png new file mode 100644 index 0000000..d13fb80 Binary files /dev/null and b/testdata/snapshots/darwin/funnel_chart-light.png differ diff --git a/testdata/snapshots/darwin/gauge-dark.png b/testdata/snapshots/darwin/gauge-dark.png new file mode 100644 index 0000000..3abc39e Binary files /dev/null and b/testdata/snapshots/darwin/gauge-dark.png differ diff --git a/testdata/snapshots/darwin/gauge-light.png b/testdata/snapshots/darwin/gauge-light.png new file mode 100644 index 0000000..fb55f17 Binary files /dev/null and b/testdata/snapshots/darwin/gauge-light.png differ diff --git a/testdata/snapshots/darwin/heatmap_chart-dark.png b/testdata/snapshots/darwin/heatmap_chart-dark.png new file mode 100644 index 0000000..1bd7b9e Binary files /dev/null and b/testdata/snapshots/darwin/heatmap_chart-dark.png differ diff --git a/testdata/snapshots/darwin/heatmap_chart-light.png b/testdata/snapshots/darwin/heatmap_chart-light.png new file mode 100644 index 0000000..3547f65 Binary files /dev/null and b/testdata/snapshots/darwin/heatmap_chart-light.png differ diff --git a/testdata/snapshots/darwin/histogram-dark.png b/testdata/snapshots/darwin/histogram-dark.png new file mode 100644 index 0000000..66cc3ad Binary files /dev/null and b/testdata/snapshots/darwin/histogram-dark.png differ diff --git a/testdata/snapshots/darwin/histogram-light.png b/testdata/snapshots/darwin/histogram-light.png new file mode 100644 index 0000000..c803403 Binary files /dev/null and b/testdata/snapshots/darwin/histogram-light.png differ diff --git a/testdata/snapshots/darwin/line_chart-dark.png b/testdata/snapshots/darwin/line_chart-dark.png new file mode 100644 index 0000000..92043c4 Binary files /dev/null and b/testdata/snapshots/darwin/line_chart-dark.png differ diff --git a/testdata/snapshots/darwin/line_chart-light.png b/testdata/snapshots/darwin/line_chart-light.png new file mode 100644 index 0000000..4de7657 Binary files /dev/null and b/testdata/snapshots/darwin/line_chart-light.png differ diff --git a/testdata/snapshots/darwin/network_graph-dark.png b/testdata/snapshots/darwin/network_graph-dark.png new file mode 100644 index 0000000..f874117 Binary files /dev/null and b/testdata/snapshots/darwin/network_graph-dark.png differ diff --git a/testdata/snapshots/darwin/network_graph-light.png b/testdata/snapshots/darwin/network_graph-light.png new file mode 100644 index 0000000..90c1640 Binary files /dev/null and b/testdata/snapshots/darwin/network_graph-light.png differ diff --git a/testdata/snapshots/darwin/pie_chart-dark.png b/testdata/snapshots/darwin/pie_chart-dark.png new file mode 100644 index 0000000..8629a99 Binary files /dev/null and b/testdata/snapshots/darwin/pie_chart-dark.png differ diff --git a/testdata/snapshots/darwin/pie_chart-light.png b/testdata/snapshots/darwin/pie_chart-light.png new file mode 100644 index 0000000..8237cec Binary files /dev/null and b/testdata/snapshots/darwin/pie_chart-light.png differ diff --git a/testdata/snapshots/darwin/radar_chart-dark.png b/testdata/snapshots/darwin/radar_chart-dark.png new file mode 100644 index 0000000..aa910f8 Binary files /dev/null and b/testdata/snapshots/darwin/radar_chart-dark.png differ diff --git a/testdata/snapshots/darwin/radar_chart-light.png b/testdata/snapshots/darwin/radar_chart-light.png new file mode 100644 index 0000000..3ce2f97 Binary files /dev/null and b/testdata/snapshots/darwin/radar_chart-light.png differ diff --git a/testdata/snapshots/darwin/realtime_chart-dark.png b/testdata/snapshots/darwin/realtime_chart-dark.png new file mode 100644 index 0000000..08f61cf Binary files /dev/null and b/testdata/snapshots/darwin/realtime_chart-dark.png differ diff --git a/testdata/snapshots/darwin/realtime_chart-light.png b/testdata/snapshots/darwin/realtime_chart-light.png new file mode 100644 index 0000000..acfc8c4 Binary files /dev/null and b/testdata/snapshots/darwin/realtime_chart-light.png differ diff --git a/testdata/snapshots/darwin/sankey_chart-dark.png b/testdata/snapshots/darwin/sankey_chart-dark.png new file mode 100644 index 0000000..29ad59c Binary files /dev/null and b/testdata/snapshots/darwin/sankey_chart-dark.png differ diff --git a/testdata/snapshots/darwin/sankey_chart-light.png b/testdata/snapshots/darwin/sankey_chart-light.png new file mode 100644 index 0000000..706817a Binary files /dev/null and b/testdata/snapshots/darwin/sankey_chart-light.png differ diff --git a/testdata/snapshots/darwin/scatter_chart-dark.png b/testdata/snapshots/darwin/scatter_chart-dark.png new file mode 100644 index 0000000..35cea09 Binary files /dev/null and b/testdata/snapshots/darwin/scatter_chart-dark.png differ diff --git a/testdata/snapshots/darwin/scatter_chart-light.png b/testdata/snapshots/darwin/scatter_chart-light.png new file mode 100644 index 0000000..2838b97 Binary files /dev/null and b/testdata/snapshots/darwin/scatter_chart-light.png differ diff --git a/testdata/snapshots/darwin/sparkline-dark.png b/testdata/snapshots/darwin/sparkline-dark.png new file mode 100644 index 0000000..f5e0358 Binary files /dev/null and b/testdata/snapshots/darwin/sparkline-dark.png differ diff --git a/testdata/snapshots/darwin/sparkline-light.png b/testdata/snapshots/darwin/sparkline-light.png new file mode 100644 index 0000000..e17c629 Binary files /dev/null and b/testdata/snapshots/darwin/sparkline-light.png differ diff --git a/testdata/snapshots/darwin/sunburst-dark.png b/testdata/snapshots/darwin/sunburst-dark.png new file mode 100644 index 0000000..4c8c9f6 Binary files /dev/null and b/testdata/snapshots/darwin/sunburst-dark.png differ diff --git a/testdata/snapshots/darwin/sunburst-light.png b/testdata/snapshots/darwin/sunburst-light.png new file mode 100644 index 0000000..297c871 Binary files /dev/null and b/testdata/snapshots/darwin/sunburst-light.png differ diff --git a/testdata/snapshots/darwin/treemap-dark.png b/testdata/snapshots/darwin/treemap-dark.png new file mode 100644 index 0000000..18f2d90 Binary files /dev/null and b/testdata/snapshots/darwin/treemap-dark.png differ diff --git a/testdata/snapshots/darwin/treemap-light.png b/testdata/snapshots/darwin/treemap-light.png new file mode 100644 index 0000000..4e9d7a7 Binary files /dev/null and b/testdata/snapshots/darwin/treemap-light.png differ diff --git a/testdata/snapshots/darwin/waterfall_chart-dark.png b/testdata/snapshots/darwin/waterfall_chart-dark.png new file mode 100644 index 0000000..25c1ea2 Binary files /dev/null and b/testdata/snapshots/darwin/waterfall_chart-dark.png differ diff --git a/testdata/snapshots/darwin/waterfall_chart-light.png b/testdata/snapshots/darwin/waterfall_chart-light.png new file mode 100644 index 0000000..cc16b4b Binary files /dev/null and b/testdata/snapshots/darwin/waterfall_chart-light.png differ diff --git a/treemap.go b/treemap.go new file mode 100644 index 0000000..9b072af --- /dev/null +++ b/treemap.go @@ -0,0 +1,98 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "slices" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// TreemapOptions configure a Treemap. +type TreemapOptions struct { + Frame ChartFrame + Format func(v float64) string +} + +// Treemap tiles the children at *path; branches drill in. +func Treemap(c *ui.Context, path *[]string, root ChartNode, opts TreemapOptions) ChartResult { + chartCheckTree(root) + node := 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) + var kids []ChartNode + for _, ch := range node.Children { + if ch.total() > 0 { + kids = append(kids, ch) + } + } + slices.SortStableFunc(kids, func(a, b ChartNode) int { return cmp.Compare(b.total(), a.total()) }) + sum := node.total() + values := make([]float64, len(kids)) + for i, ch := range kids { + values[i] = ch.total() + } + ch := newChart(c, opts.Frame) + tiles := func(r ui.Rect) []ui.Rect { return squarify(values, r) } + spec := chartSpec{n: len(kids)} + spec.readout = func(i int) string { + s := fmt.Sprintf("%s · %s · %.1f%%", kids[i].Name, chartValue(c, values[i], opts.Format), 100*values[i]/sum) + if len(kids[i].Children) > 0 { + s += " · " + msg(c, "chart.opens") + } + return s + } + spec.pick = func(r ui.Rect, x, y float32) int { + for i, t := range tiles(r) { + if t.Contains(x, y) { + return i + } + } + return -1 + } + spec.activate = func(i int) { + if len(kids[i].Children) > 0 { + *path = append(slices.Clone(*path), kids[i].Name) + res.value = true + slog.Debug("mujica: treemap drill", "path", strings.Join(*path, "/")) + } + } + spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + for i, t := range tiles(r) { + bg := pal[i%len(pal)] + p.Fill(t, bg, 2) + p.Stroke(t, k.Surface, 2, 2) + if i == active { + p.Stroke(ui.Rect{X: t.X + 3, Y: t.Y + 3, W: t.W - 6, H: t.H - 6}, chartInk(k, bg), 1, 2) + } + // Labels show only where tiles have room. + if t.W >= 36 && t.H >= 22 { + ink := chartInk(k, bg) + chartText(p, chartFit(p, kids[i].Name, t.W-12, size), t.X+6, t.Y+5, size, ink, 0, 0) + if t.H >= 40 { + chartText(p, chartFit(p, chartValue(c, values[i], opts.Format), t.W-12, size), t.X+6, t.Y+22, size, ink, 0, 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.Element, res.Point, res.changed, res.retry = r.Element, r.Point, r.changed, r.retry + }) + res.Element = wrap + return res +} diff --git a/treemap_test.go b/treemap_test.go new file mode 100644 index 0000000..4abeacb --- /dev/null +++ b/treemap_test.go @@ -0,0 +1,89 @@ +package mujica + +import ( + "fmt" + "testing" + + "github.com/egoist/mygo/ui" +) + +func chartTree() ChartNode { + return ChartNode{Name: "Abbey", Children: []ChartNode{ + {Name: "Library", Children: []ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}}, + {Name: "Kitchen", Value: 25}, + {Name: "Garden", Children: []ChartNode{{Name: "Herbs", Value: 12}, {Name: "Vines", Value: 8}}}, + {Name: "Cell of a very long-winded brother", Value: 3}, + }} +} + +func TestSquarifyFills(t *testing.T) { + rects := squarify([]float64{6, 6, 4, 3, 2, 2, 1}, ui.Rect{W: 600, H: 400}) + area := float32(0) + for _, r := range rects { + area += r.W * r.H + if r.W <= 0 || r.H <= 0 { + t.Fatalf("empty tile %v", r) + } + } + if area < 600*400-1 || area > 600*400+1 { + t.Fatalf("tiles cover %v of %v", area, 600*400) + } +} + +func TestTreemapDrillAndBack(t *testing.T) { + var path []string + values := 0 + h := &chartHarness{build: func(c *ui.Context) ChartResult { + r := Treemap(c, &path, chartTree(), TreemapOptions{Frame: ChartFrame{Label: "Rooms"}}) + if r.ValueChanged() { + values++ + } + return r + }} + tt := ui.NewTester(h.view, 560, 440) + tabTo(t, tt, "Rooms") + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "Library · 40 · 45.5% · Enter opens" { + t.Fatalf("readout %q", got) + } + tt.Key(0, ui.KeyEnter) + if fmt.Sprint(path) != "[Library]" || values != 1 || !tt.HasText("Library") { + t.Fatalf("drill path %v events %d", path, values) + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyEnter) // a leaf stays + if fmt.Sprint(path) != "[Library]" { + t.Fatalf("leaf drilled: %v", path) + } + tt.Key(0, ui.KeyBackspace) + if len(path) != 0 { + t.Fatalf("Backspace kept %v", path) + } + path = []string{"Garden"} + tt.Frame() + if err := tt.Click("Back"); err != nil || len(path) != 0 { + t.Fatalf("Back: %v %v", err, path) + } +} + +func TestTreemapBadPath(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for an unknown path") + } + }() + path := []string{"Nowhere"} + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Treemap(c, &path, chartTree(), TreemapOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) +} + +func TestTreemapSnapshot(t *testing.T) { + snapshot(t, "treemap", 560, 340, func(c *ui.Context) { + var path []string + ui.Column(c).Padding(16).Children(func() { + Treemap(c, &path, chartTree(), TreemapOptions{Frame: ChartFrame{Label: "Rooms"}}) + }) + }) +} diff --git a/waterfall_chart.go b/waterfall_chart.go new file mode 100644 index 0000000..e7bf0b7 --- /dev/null +++ b/waterfall_chart.go @@ -0,0 +1,125 @@ +package mujica + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// WaterfallKind says how a step's bar is drawn. +type WaterfallKind int + +const ( + // WaterfallDelta adds Value to the running total. + WaterfallDelta WaterfallKind = iota + // WaterfallSubtotal shows the running total; its Value must be zero. + WaterfallSubtotal + // WaterfallTotal shows the final total; Value must be zero. + WaterfallTotal +) + +// WaterfallStep is one bar of a WaterfallChart. +type WaterfallStep struct { + Label string + Value float64 + Kind WaterfallKind +} + +// WaterfallChartOptions configure a WaterfallChart. +type WaterfallChartOptions struct { + Frame ChartFrame + Format func(v float64) string +} + +// WaterfallChart builds a total from rises and falls. +func WaterfallChart(c *ui.Context, steps []WaterfallStep, opts WaterfallChartOptions) ChartResult { + from, to := make([]float64, len(steps)), make([]float64, len(steps)) + run, lo, hi := 0.0, 0.0, 0.0 + for i, s := range steps { + if s.Label == "" || math.IsNaN(s.Value) || math.IsInf(s.Value, 0) || s.Kind != WaterfallDelta && s.Value != 0 || s.Kind > WaterfallTotal { + panic("mujica: WaterfallChart steps need a label, a finite value, and zero for totals") + } + if s.Kind == WaterfallDelta { + from[i], to[i], run = run, run+s.Value, run+s.Value + } else { + from[i], to[i] = 0, run + } + lo, hi = min(lo, from[i], to[i]), max(hi, from[i], to[i]) + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + size := FontSize(c, theme.CaptionSize) + var step float64 + if len(steps) > 0 { + pad := (hi - lo) * 0.12 + lo, hi, step = internal.Nice(lo-pad*chartBool(lo < 0), hi+pad, 4) + } + val := func(v float64) string { return chartValue(c, v, opts.Format) } + signed := func(v float64) string { + if v >= 0 { + return "+" + val(v) + } + return "−" + val(-v) + } + geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Linear) { + plot := chartInset(r, 52, 26) + return plot, internal.Band{N: len(steps), 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)} + } + spec := chartSpec{n: len(steps)} + spec.readout = func(i int) string { + if steps[i].Kind != WaterfallDelta { + return steps[i].Label + " · " + val(to[i]) + } + return steps[i].Label + " · " + signed(steps[i].Value) + " · " + msg(c, "waterfall_chart.running") + " " + val(to[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(steps)-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, s := range steps { + xs, names = append(xs, band.Center(i)), append(names, s.Label) + } + internal.AxisX(p, plot, xs, names, size, k) + internal.Rule(p, plot.X, plot.X+plot.W, ys.Map(0), k.ControlBorder) + for i, s := range steps { + a, b := ys.Map(from[i]), ys.Map(to[i]) + bar := ui.Rect{X: band.Start(i), Y: min(a, b), W: band.Width(), H: max(abs32(a-b), 1)} + col, shape, text := internal.Palette(k)[5], 1, val(to[i]) + switch { + case s.Kind != WaterfallDelta: + case s.Value >= 0: + col, shape, text = k.Success, 3, signed(s.Value) + default: + col, shape, text = k.Danger, 4, signed(s.Value) + } + p.Fill(bar, col, 1) + internal.Marker(p, shape, bar.X+bar.W/2, bar.Y-14+(bar.H+28)*float32(chartBool(s.Kind == WaterfallDelta && s.Value < 0)), 8, col) + ty, ay := bar.Y-22, float32(1) + if s.Kind == WaterfallDelta && s.Value < 0 { + ty, ay = bar.Y+bar.H+22, 0 + } + chartText(p, text, bar.X+bar.W/2, ty, size, k.Text, 0.5, ay) + if i+1 < len(steps) { + y := internal.Snap(p, ys.Map(to[i])) + p.Line(bar.X+bar.W, y, band.Start(i+1), y, 1, k.TextMuted) + } + 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 +} diff --git a/waterfall_chart_test.go b/waterfall_chart_test.go new file mode 100644 index 0000000..70c9b7d --- /dev/null +++ b/waterfall_chart_test.go @@ -0,0 +1,54 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var waterfallSteps = []WaterfallStep{ + {Label: "Tithes", Value: 12}, + {Label: "Repairs", Value: -5}, + {Label: "Q1", Kind: WaterfallSubtotal}, + {Label: "Alms", Value: 4}, + {Label: "Year", Kind: WaterfallTotal}, +} + +func TestWaterfallChartReadout(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return WaterfallChart(c, waterfallSteps, WaterfallChartOptions{Frame: ChartFrame{Label: "Ledger"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Ledger") + want := []string{"Tithes · +12 · running total 12", "Repairs · −5 · running total 7", "Q1 · 7", "Alms · +4 · running total 11", "Year · 11"} + for i, w := range want { + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != w { + t.Fatalf("step %d: %q, want %q", i, got, w) + } + } +} + +func TestWaterfallChartBad(t *testing.T) { + for _, s := range []WaterfallStep{{Label: "x", Value: 3, Kind: WaterfallTotal}, {Value: 1}, {Label: "x", Kind: 9}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: no panic", s) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + WaterfallChart(c, []WaterfallStep{s}, WaterfallChartOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) + }() + } +} + +func TestWaterfallChartSnapshot(t *testing.T) { + snapshot(t, "waterfall_chart", 520, 320, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + WaterfallChart(c, waterfallSteps, WaterfallChartOptions{Frame: ChartFrame{Label: "Ledger"}}) + }) + }) +}