diff --git a/bar_chart.go b/bar_chart.go index 7989ad2..e8aaabd 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -32,10 +32,10 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar k := Tokens(c) pal := internal.Palette(k) size := FontSize(c, theme.CaptionSize) - keys := make([]chartKey, len(series)) + keys := make([]ChartLegendItem, len(series)) var vis []int for i, s := range series { - keys[i] = chartKey{Name: s.Name, Color: pal[i%len(pal)], Shape: i} + keys[i] = ChartLegendItem{Name: s.Name, Color: pal[i%len(pal)], Shape: i} if !ch.hidden(s.Name) { vis = append(vis, i) } @@ -111,12 +111,12 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar 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 { + spec := ChartSpec{Marks: len(lay.marks), Legend: keys, Hidden: ch.toggles(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 { + spec.Pick = func(r ui.Rect, x, y float32) int { _, band, vs := geom(r) for i := range lay.marks { b := rect(band, vs, i) @@ -127,7 +127,7 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar } return -1 } - spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + 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 diff --git a/box_plot.go b/box_plot.go index 518ad02..931bc50 100644 --- a/box_plot.go +++ b/box_plot.go @@ -102,22 +102,22 @@ func BoxPlot(c *ui.Context, groups []BoxGroup, opts BoxPlotOptions) ChartResult 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 { + spec := ChartSpec{Marks: 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 { + 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) { + 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 diff --git a/bullet_chart.go b/bullet_chart.go index 7dbd32e..be84ad5 100644 --- a/bullet_chart.go +++ b/bullet_chart.go @@ -74,9 +74,9 @@ func BulletChart(c *ui.Context, value float64, opts BulletChartOptions) ChartRes } 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 { + spec := ChartSpec{Marks: 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 @@ -99,7 +99,7 @@ func BulletChart(c *ui.Context, value float64, opts BulletChartOptions) ChartRes } return -1 } - spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + spec.Draw = func(p *ui.Painter, r ui.Rect, active int) { plot, s := geom(r) inset := bulletInset(plot, opts.Vertical) prev := 0.0 diff --git a/calendar_heatmap.go b/calendar_heatmap.go index 1b3e542..6415748 100644 --- a/calendar_heatmap.go +++ b/calendar_heatmap.go @@ -73,8 +73,8 @@ func CalendarHeatmap(c *ui.Context, year *int, values map[Date]float64, opts Cal 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 { + spec := ChartSpec{Marks: n} + spec.Readout = func(i int) string { d := first.AddDays(i) v, ok := values[d] s := d.String() + " · " @@ -83,11 +83,11 @@ func CalendarHeatmap(c *ui.Context, year *int, values map[Date]float64, opts Cal } return s + chartValue(c, v, opts.Format) } - spec.move = func(i int, key ui.Key) int { + 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 { + 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 @@ -96,7 +96,7 @@ func CalendarHeatmap(c *ui.Context, year *int, values map[Date]float64, opts Cal } return i } - spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + spec.Draw = func(p *ui.Painter, r ui.Rect, active int) { x0, y0, step := geom(r) for i := range n { b := cell(r, i) diff --git a/chart_annotation.go b/chart_annotation.go new file mode 100644 index 0000000..92e7bf8 --- /dev/null +++ b/chart_annotation.go @@ -0,0 +1,250 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartNoteKind is what a ChartNote draws. +type ChartNoteKind int + +const ( + // NoteHLine is a horizontal line at Y value Value. + NoteHLine ChartNoteKind = iota + // NoteVLine is a vertical line at X value Value. + NoteVLine + // NoteBand shades X values Value to End. + NoteBand + // NoteText writes Text at X Value, Y End. + NoteText +) + +// ChartNote is one annotation; Draggable lets the pointer move it. +type ChartNote struct { + Kind ChartNoteKind + Value, End float64 + Text string + Draggable bool +} + +// ChartAnnotationOptions configure a ChartAnnotation; both axes are required. +type ChartAnnotationOptions struct { + X, Y *ChartAxisPart +} + +// ChartAnnotationPart is notes ready to draw, and to drag. +type ChartAnnotationPart struct { + c *ui.Context + notes []ChartNote + opts ChartAnnotationOptions + k theme.Tokens + size float32 +} + +// ChartAnnotation draws notes and enables optional movement. +func ChartAnnotation(c *ui.Context, notes []ChartNote, opts ChartAnnotationOptions) ChartAnnotationPart { + if opts.X == nil || opts.Y == nil { + panic("mujica: ChartAnnotation needs X and Y axes") + } + for _, n := range notes { + if n.Kind < NoteHLine || n.Kind > NoteText || math.IsNaN(n.Value) || math.IsInf(n.Value, 0) || math.IsNaN(n.End) || math.IsInf(n.End, 0) || n.Kind == NoteBand && n.End <= n.Value || n.Kind == NoteText && n.Text == "" { + panic("mujica: ChartAnnotation note needs a known kind, a band End above Value, and text") + } + } + return ChartAnnotationPart{c: c, notes: notes, opts: opts, k: Tokens(c), size: FontSize(c, theme.CaptionSize)} +} + +// Draw paints bands first, then lines and text, inside plot. +func (a ChartAnnotationPart) Draw(p *ui.Painter, plot ui.Rect) { + k, x, y := a.k, a.opts.X, a.opts.Y + for _, n := range a.notes { + if n.Kind == NoteBand { + x0, x1 := x.Map(plot, n.Value), x.Map(plot, n.End) + p.Fill(ui.Rect{X: x0, Y: plot.Y, W: x1 - x0, H: plot.H}, k.Info.Alpha(0.12), 0) + chartText(p, n.Text, x0+4, plot.Y+4, a.size, k.Text, 0, 0) + } + } + for _, n := range a.notes { + switch n.Kind { + case NoteHLine: + at := internal.Snap(p, y.Map(plot, n.Value)) + chartDash(p, plot.X, at, plot.X+plot.W, at, k.Text) + chartText(p, chartNoteLabel(n, y), plot.X+plot.W-4, at-3, a.size, k.Text, 1, 1) + if n.Draggable { + p.FillPath(internal.Diamond(internal.Snap(p, plot.X+8), at, 11), k.AccentText) + } + case NoteVLine: + at := internal.Snap(p, x.Map(plot, n.Value)) + chartDash(p, at, plot.Y, at, plot.Y+plot.H, k.Text) + chartText(p, chartNoteLabel(n, x), at+4, plot.Y+4, a.size, k.Text, 0, 0) + if n.Draggable { + p.FillPath(internal.Diamond(at, internal.Snap(p, plot.Y+plot.H-8), 11), k.AccentText) + } + case NoteText: + px, py := x.Map(plot, n.Value), y.Map(plot, n.End) + p.FillPath(internal.Diamond(internal.Snap(p, px), internal.Snap(p, py), 8), k.Text) + chartText(p, n.Text, px+8, py, a.size, k.Text, 0, 0.5) + } + } +} + +// chartDash draws a dashed horizontal or vertical line. +func chartDash(p *ui.Painter, x0, y0, x1, y1 float32, col ui.Color) { + const on, off = 6, 4 + if y0 == y1 { + for x := x0; x < x1; x += on + off { + p.Line(x, y0, min(x+on, x1), y0, 1.5, col) + } + return + } + for y := y0; y < y1; y += on + off { + p.Line(x0, y, x0, min(y+on, y1), 1.5, col) + } +} + +// chartNoteLabel formats a line's label and value. +func chartNoteLabel(n ChartNote, a *ChartAxisPart) string { + if n.Text != "" { + return n.Text + " " + a.Label(n.Value) + } + return a.Label(n.Value) +} + +type noteDrag struct { + on bool + note int +} + +// Drag handles movement; plot uses element coordinates. +func (a ChartAnnotationPart) Drag(plotElement *ui.Element, plot ui.Rect) bool { + if plotElement.IsDisabled() { + return false + } + if a.keyboard(plotElement) { + return true + } + st := ui.Local(plotElement, "mujica.notes.drag", func() noteDrag { return noteDrag{} }) + dx, dy, held := plotElement.Dragged() + if !held || plotElement.IsDisabled() { + *st = noteDrag{} + return false + } + px, py, _ := plotElement.PointerPosition() + x, y := a.opts.X, a.opts.Y + if !st.on { + *st = noteDrag{on: true, note: -1} + for i, n := range a.notes { + if !n.Draggable { + continue + } + var d float32 = 1e9 + switch n.Kind { + case NoteHLine: + d = abs32(y.Map(plot, n.Value) - (py - dy)) + case NoteVLine: + d = abs32(x.Map(plot, n.Value) - (px - dx)) + case NoteBand: + if x0, x1 := x.Map(plot, n.Value), x.Map(plot, n.End); px-dx >= x0 && px-dx <= x1 { + d = 0 + } + case NoteText: + d = max(abs32(x.Map(plot, n.Value)-(px-dx)), abs32(y.Map(plot, n.End)-(py-dy))) + } + if d <= 6 { + st.note = i + } + } + } + if st.note < 0 || dx == 0 && dy == 0 { + return false + } + n := &a.notes[st.note] + shift := func(ax *ChartAxisPart, v float64, at, d float32) float64 { + return v + ax.Invert(plot, at) - ax.Invert(plot, at-d) + } + switch n.Kind { + case NoteHLine: + n.Value = shift(y, n.Value, py, dy) + case NoteVLine: + n.Value = shift(x, n.Value, px, dx) + case NoteBand: + w := n.End - n.Value + n.Value = shift(x, n.Value, px, dx) + n.End = n.Value + w + case NoteText: + n.Value, n.End = shift(x, n.Value, px, dx), shift(y, n.End, py, dy) + } + slog.Debug("mujica: chart_annotation drag", "note", st.note, "value", n.Value) + return true +} + +func (a ChartAnnotationPart) keyboard(e *ui.Element) bool { + index := ui.Local(e, "mujica.notes.key", func() int { return 0 }) + dir := 0 + if e.Shortcut(ui.Alt, ui.KeyPageDown) { + dir = 1 + } + if e.Shortcut(ui.Alt, ui.KeyPageUp) { + dir = -1 + } + var editable []int + for i, n := range a.notes { + if n.Draggable { + editable = append(editable, i) + } + } + if len(editable) == 0 { + return false + } + *index = (*index + dir + len(editable)) % len(editable) + n := &a.notes[editable[*index]] + dx, dy := 0.0, 0.0 + if e.Shortcut(ui.Alt, ui.KeyRight) { + dx = 1 + } + if e.Shortcut(ui.Alt, ui.KeyLeft) { + dx = -1 + } + if e.Shortcut(ui.Alt, ui.KeyUp) { + dy = 1 + } + if e.Shortcut(ui.Alt, ui.KeyDown) { + dy = -1 + } + xstep := (a.opts.X.hi - a.opts.X.lo) / 100 + ystep := (a.opts.Y.hi - a.opts.Y.lo) / 100 + before := *n + switch n.Kind { + case NoteHLine: + n.Value += dy * ystep + case NoteVLine: + n.Value += dx * xstep + case NoteBand: + n.Value, n.End = n.Value+dx*xstep, n.End+dx*xstep + case NoteText: + n.Value, n.End = n.Value+dx*xstep, n.End+dy*ystep + } + if dir != 0 || before != *n { + key := "chart_annotation.x" + value := a.opts.X.Label(n.Value) + switch n.Kind { + case NoteHLine: + key, value = "chart_annotation.y", a.opts.Y.Label(n.Value) + case NoteBand: + value += "–" + a.opts.X.Label(n.End) + case NoteText: + value += " · " + fmt.Sprintf(msg(a.c, "chart_annotation.y"), a.opts.Y.Label(n.End)) + } + text := fmt.Sprintf(msg(a.c, "chart_annotation.readout"), editable[*index]+1, fmt.Sprintf(msg(a.c, key), value)) + e.Description(text) + a.c.Announce(text) + slog.Debug("mujica: chart_annotation keyboard", "note", editable[*index], "value", n.Value) + } + return before != *n +} diff --git a/chart_annotation_test.go b/chart_annotation_test.go new file mode 100644 index 0000000..8eecf18 --- /dev/null +++ b/chart_annotation_test.go @@ -0,0 +1,121 @@ +package mujica + +import ( + "math" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestChartAnnotationDrag(t *testing.T) { + notes := []ChartNote{ + {Kind: NoteHLine, Value: 50, Text: "Goal", Draggable: true}, + {Kind: NoteVLine, Value: 3}, + {Kind: NoteBand, Value: 6, End: 8, Text: "Lent", Draggable: true}, + {Kind: NoteText, Value: 1, End: 90, Text: "Feast"}, + } + h := &chartHarness{build: func(c *ui.Context) ChartResult { return partsChart(c, notes, false) }} + tt := ui.NewTester(h.view, 520, 420) + b := h.res.Element.Bounds() + plot := chartInset(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: 240}, 52, 26) + yAt := func(v float32) float32 { return plot.Y + plot.H*(1-v/100) } + xAt := func(v float32) float32 { return plot.X + plot.W*v/10 } + // Drag Goal from 50 to 70. + tt.Press(xAt(4), yAt(50)) + tt.Move(xAt(4), yAt(60)) + tt.Move(xAt(4), yAt(70)) + tt.Release(xAt(4), yAt(70)) + if math.Abs(notes[0].Value-70) > 0.5 { + t.Fatalf("Goal at %v", notes[0].Value) + } + // The band moves as a whole and keeps its width. + tt.Press(xAt(7), yAt(20)) + tt.Move(xAt(8), yAt(20)) + tt.Release(xAt(8), yAt(20)) + if math.Abs(notes[2].Value-7) > 0.1 || math.Abs(notes[2].End-notes[2].Value-2) > 1e-9 { + t.Fatalf("band %v..%v", notes[2].Value, notes[2].End) + } + // The fixed line stays. + tt.Press(xAt(3), yAt(20)) + tt.Move(xAt(5), yAt(20)) + tt.Release(xAt(5), yAt(20)) + if notes[1].Value != 3 { + t.Fatalf("fixed line moved to %v", notes[1].Value) + } +} + +func TestChartAnnotationDisabledAndBad(t *testing.T) { + notes := []ChartNote{{Kind: NoteHLine, Value: 50, Draggable: true}} + h := &chartHarness{disabled: true, build: func(c *ui.Context) ChartResult { return partsChart(c, notes, false) }} + tt := ui.NewTester(h.view, 520, 420) + b := h.res.Element.Bounds() + plot := chartInset(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: 240}, 52, 26) + tt.Press(plot.X+100, plot.Y+plot.H/2) + tt.Move(plot.X+100, plot.Y+20) + tt.Release(plot.X+100, plot.Y+20) + if notes[0].Value != 50 { + t.Fatalf("disabled chart dragged a note to %v", notes[0].Value) + } + defer func() { + if recover() == nil { + t.Fatal("no panic for an empty band") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + partsChart(c, []ChartNote{{Kind: NoteBand, Value: 2, End: 2}}, false) + }, 300, 300) +} + +func TestChartAnnotationSnapshot(t *testing.T) { + snapshot(t, "chart_annotation", 520, 340, func(c *ui.Context) { + notes := []ChartNote{ + {Kind: NoteHLine, Value: 65, Text: "Goal", Draggable: true}, + {Kind: NoteVLine, Value: 3, Text: "Advent"}, + {Kind: NoteBand, Value: 6, End: 8, Text: "Lent"}, + {Kind: NoteText, Value: 1, End: 90, Text: "Feast"}, + } + ui.Column(c).Padding(16).Children(func() { partsChart(c, notes, false) }) + }) +} + +func TestChartAnnotationKeyboardEditsEveryKind(t *testing.T) { + notes := []ChartNote{ + {Kind: NoteHLine, Value: 50, Draggable: true}, + {Kind: NoteVLine, Value: 3, Draggable: true}, + {Kind: NoteBand, Value: 6, End: 8, Draggable: true}, + {Kind: NoteText, Value: 1, End: 90, Text: "Feast", Draggable: true}, + } + h := &chartHarness{build: func(c *ui.Context) ChartResult { return partsChart(c, notes, false) }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Parts") + tt.Key(ui.Alt, ui.KeyUp) + if notes[0].Value != 51 { + t.Fatalf("horizontal line %v", notes[0].Value) + } + if said := tt.Announcements(); len(said) == 0 || !strings.Contains(said[len(said)-1], "y 51") { + t.Fatalf("horizontal readout %q", said) + } + tt.Key(ui.Alt, ui.KeyPageDown) + tt.Key(ui.Alt, ui.KeyRight) + if notes[1].Value != 3.1 { + t.Fatalf("vertical line %v", notes[1].Value) + } + tt.Key(ui.Alt, ui.KeyPageDown) + tt.Key(ui.Alt, ui.KeyRight) + if math.Abs(notes[2].Value-6.1) > 1e-9 || notes[2].End-notes[2].Value != 2 { + t.Fatalf("band %+v", notes[2]) + } + tt.Key(ui.Alt, ui.KeyPageDown) + tt.Key(ui.Alt, ui.KeyUp) + if notes[3].End != 91 { + t.Fatalf("text %+v", notes[3]) + } + h.disabled = true + tt.Frame() + tt.Key(ui.Alt, ui.KeyUp) + if notes[3].End != 91 { + t.Fatalf("disabled text %+v", notes[3]) + } +} diff --git a/chart_axis.go b/chart_axis.go new file mode 100644 index 0000000..dd27c04 --- /dev/null +++ b/chart_axis.go @@ -0,0 +1,264 @@ +package mujica + +import ( + "math" + "strconv" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartAxisKind is how an axis maps values. +type ChartAxisKind int + +const ( + AxisLinear ChartAxisKind = iota + // AxisLog maps powers of ten; Min must be positive. + AxisLog + // AxisTime maps From..To; values are Unix seconds. + AxisTime + // AxisCategory gives each category a band; values are indices. + AxisCategory +) + +// ChartSide is where an axis runs along the plot. +type ChartSide int + +const ( + SideBottom ChartSide = iota + SideLeft + SideRight + SideTop +) + +// ChartAxisOptions configure a ChartAxis. +type ChartAxisOptions struct { + Kind ChartAxisKind + Side ChartSide + // Min and Max span a linear or log axis. + Min, Max float64 + // From and To span a time axis. + From, To time.Time + Categories []string + // Ticks fixes the tick values; nil picks them. + Ticks []float64 + // Format overrides tick and readout labels. + Format func(v float64) string +} + +// ChartAxisPart maps values and draws an axis. +type ChartAxisPart struct { + opts ChartAxisOptions + lo, hi float64 + ticks []float64 + step float64 + layout string + k theme.Tokens + size float32 +} + +// ChartAxis resolves an axis: its domain, ticks and labels. +func ChartAxis(c *ui.Context, opts ChartAxisOptions) ChartAxisPart { + a := ChartAxisPart{opts: opts, k: Tokens(c), size: FontSize(c, theme.CaptionSize)} + switch opts.Kind { + case AxisLinear, AxisLog: + a.lo, a.hi = opts.Min, opts.Max + if !(a.hi > a.lo) || math.IsInf(a.lo, 0) || math.IsInf(a.hi, 0) || opts.Kind == AxisLog && a.lo <= 0 { + panic("mujica: ChartAxis needs Min < Max, and Min > 0 for a log axis") + } + case AxisTime: + if !opts.To.After(opts.From) { + panic("mujica: ChartAxis time axis needs From before To") + } + a.lo, a.hi = chartSeconds(opts.From), chartSeconds(opts.To) + case AxisCategory: + if len(opts.Categories) == 0 { + panic("mujica: ChartAxis category axis needs Categories") + } + a.lo, a.hi = -0.5, float64(len(opts.Categories))-0.5 + default: + panic("mujica: unknown ChartAxisKind") + } + if opts.Side < SideBottom || opts.Side > SideTop { + panic("mujica: unknown ChartSide") + } + a.ticks = opts.Ticks + for i, v := range a.ticks { + if math.IsNaN(v) || math.IsInf(v, 0) || v < a.lo || v > a.hi || i > 0 && v <= a.ticks[i-1] { + panic("mujica: ChartAxis ticks must ascend inside the domain") + } + } + if a.ticks == nil { + a.ticks = a.autoTicks() + } + return a +} + +func chartSeconds(t time.Time) float64 { return float64(t.UnixNano()) / 1e9 } + +// autoTicks picks round ticks inside the domain. +func (a *ChartAxisPart) autoTicks() []float64 { + var out []float64 + switch a.opts.Kind { + case AxisLog: + // Short spans include powers of two and five. + mants := []float64{1} + if a.hi/a.lo < 1000 { + mants = []float64{1, 2, 5} + } + for e := math.Floor(math.Log10(a.lo)); math.Pow(10, e) <= a.hi*(1+1e-9); e++ { + for _, m := range mants { + if v := m * math.Pow(10, e); v >= a.lo*(1-1e-9) && v <= a.hi*(1+1e-9) { + out = append(out, v) + } + } + } + case AxisTime: + ts, layout := internal.TimeTicks(a.opts.From, a.opts.To, 5) + a.layout = layout + for _, t := range ts { + out = append(out, chartSeconds(t)) + } + case AxisCategory: + n := len(a.opts.Categories) + step := max(1, (n+63)/64) + for i := 0; i < n; i += step { + out = append(out, float64(i)) + } + if out[len(out)-1] != float64(n-1) { + out = append(out, float64(n-1)) + } + default: + lo, hi, step := internal.Nice(a.lo, a.hi, 5) + a.step = step + for _, t := range internal.Ticks(lo, hi, step) { + if t >= a.lo-step*1e-9 && t <= a.hi+step*1e-9 { + out = append(out, t) + } + } + } + return out +} + +// horizontal reports whether the axis runs across the plot. +func (a ChartAxisPart) horizontal() bool { + return a.opts.Side == SideBottom || a.opts.Side == SideTop +} + +// fraction returns the normalized position along the axis. +func (a ChartAxisPart) fraction(v float64) float64 { + if a.opts.Kind == AxisLog { + return (math.Log10(v) - math.Log10(a.lo)) / (math.Log10(a.hi) - math.Log10(a.lo)) + } + return (v - a.lo) / (a.hi - a.lo) +} + +// Map returns the pixel position of v. +func (a ChartAxisPart) Map(plot ui.Rect, v float64) float32 { + f := float32(a.fraction(v)) + if a.horizontal() { + return plot.X + f*plot.W + } + return plot.Y + plot.H - f*plot.H +} + +// Invert returns the value at a pixel position. +func (a ChartAxisPart) Invert(plot ui.Rect, px float32) float64 { + f := float64((px - plot.X) / plot.W) + if !a.horizontal() { + f = float64((plot.Y + plot.H - px) / plot.H) + } + if a.opts.Kind == AxisLog { + return math.Pow(10, math.Log10(a.lo)+f*(math.Log10(a.hi)-math.Log10(a.lo))) + } + return a.lo + f*(a.hi-a.lo) +} + +// Ticks returns the tick values. +func (a ChartAxisPart) Ticks() []float64 { return a.ticks } + +// Label writes v as the axis does. +func (a ChartAxisPart) Label(v float64) string { + switch { + case a.opts.Format != nil: + return a.opts.Format(v) + case a.opts.Kind == AxisCategory: + return a.opts.Categories[max(0, min(len(a.opts.Categories)-1, int(math.Round(v))))] + case a.opts.Kind == AxisTime: + layout := a.layout + if layout == "" { + layout = "2006-01-02 15:04" + } + return chartTime(v).Format(layout) + case a.opts.Kind == AxisLog: + return strconv.FormatFloat(v, 'f', -1, 64) + case a.step > 0: + return internal.FormatTick(v, a.step) + } + return strconv.FormatFloat(v, 'g', 6, 64) +} + +// readout uses finer precision than ticks. +func (a ChartAxisPart) readout(v float64) string { + switch { + case a.opts.Format != nil, a.opts.Kind == AxisCategory, a.opts.Kind == AxisTime: + return a.Label(v) + case a.opts.Kind == AxisLog: + return strconv.FormatFloat(v, 'g', 3, 64) + case a.step > 0: + return internal.FormatTick(v, a.step/10) + } + return strconv.FormatFloat(v, 'g', 6, 64) +} + +// Draw paints the axis line and tick labels. +func (a ChartAxisPart) Draw(p *ui.Painter, plot ui.Rect) { + spacing := a.size * 2 + if a.horizontal() { + spacing = a.size * 4 + } + ticks := a.drawTicks(plot, spacing) + pos := make([]float32, len(ticks)) + labels := make([]string, len(ticks)) + for i, t := range ticks { + pos[i], labels[i] = a.Map(plot, t), a.Label(t) + } + k := a.k + switch a.opts.Side { + case SideBottom: + internal.Rule(p, plot.X, plot.X+plot.W, plot.Y+plot.H, k.ControlBorder) + internal.LabelsX(p, plot, plot.Y+plot.H+6, pos, labels, false, a.size, k) + case SideTop: + internal.Rule(p, plot.X, plot.X+plot.W, plot.Y, k.ControlBorder) + internal.LabelsX(p, plot, plot.Y-6, pos, labels, true, a.size, k) + case SideLeft: + internal.RuleV(p, plot.X, plot.Y, plot.Y+plot.H, k.ControlBorder) + internal.LabelsY(p, plot.X-6, pos, labels, false, a.size, k) + case SideRight: + internal.RuleV(p, plot.X+plot.W, plot.Y, plot.Y+plot.H, k.ControlBorder) + internal.LabelsY(p, plot.X+plot.W+6, pos, labels, true, a.size, k) + } +} + +func (a ChartAxisPart) drawTicks(plot ui.Rect, spacing float32) []float64 { + length := plot.H + if a.horizontal() { + length = plot.W + } + limit := max(2, int(length/max(spacing, 1))) + if len(a.ticks) <= limit { + return a.ticks + } + step := (len(a.ticks) + limit - 1) / limit + out := make([]float64, 0, limit+1) + for i := 0; i < len(a.ticks); i += step { + out = append(out, a.ticks[i]) + } + if out[len(out)-1] != a.ticks[len(a.ticks)-1] { + out = append(out, a.ticks[len(a.ticks)-1]) + } + return out +} diff --git a/chart_axis_test.go b/chart_axis_test.go new file mode 100644 index 0000000..447f1cb --- /dev/null +++ b/chart_axis_test.go @@ -0,0 +1,157 @@ +package mujica + +import ( + "fmt" + "math" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" +) + +func axisIn(opts ChartAxisOptions) (a ChartAxisPart) { + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); a = ChartAxis(c, opts) }, 100, 100) + return a +} + +func TestChartAxisLinearAndLog(t *testing.T) { + plot := ui.Rect{X: 10, Y: 0, W: 100, H: 50} + lin := axisIn(ChartAxisOptions{Min: 0, Max: 97}) + if fmt.Sprint(lin.Ticks()) != "[0 20 40 60 80]" || lin.Map(plot, 48.5) != 60 || lin.Label(40) != "40" { + t.Fatalf("linear %v %v", lin.Ticks(), lin.Map(plot, 48.5)) + } + if v := lin.Invert(plot, 60); math.Abs(v-48.5) > 1e-9 { + t.Fatalf("invert %v", v) + } + log := axisIn(ChartAxisOptions{Kind: AxisLog, Side: SideLeft, Min: 1, Max: 10000}) + if fmt.Sprint(log.Ticks()) != "[1 10 100 1000 10000]" || log.Map(plot, 100) != 25 || log.Label(1000) != "1000" { + t.Fatalf("log %v %v", log.Ticks(), log.Map(plot, 100)) + } + if short := axisIn(ChartAxisOptions{Kind: AxisLog, Min: 1, Max: 50}); fmt.Sprint(short.Ticks()) != "[1 2 5 10 20 50]" { + t.Fatalf("short log %v", short.Ticks()) + } + fixed := axisIn(ChartAxisOptions{Min: 0, Max: 1, Ticks: []float64{0, 0.25, 1}, Format: func(v float64) string { return fmt.Sprintf("%.0f%%", v*100) }}) + if fmt.Sprint(fixed.Ticks()) != "[0 0.25 1]" || fixed.Label(0.25) != "25%" { + t.Fatalf("fixed %v", fixed.Ticks()) + } +} + +func TestChartAxisTimeAndCategory(t *testing.T) { + t0 := time.Date(2026, 10, 5, 9, 7, 0, 0, time.UTC) + tm := axisIn(ChartAxisOptions{Kind: AxisTime, From: t0, To: t0.Add(2 * time.Hour)}) + if len(tm.Ticks()) == 0 || tm.Label(tm.Ticks()[0]) != "09:30" { + t.Fatalf("time ticks %v", tm.Ticks()) + } + cat := axisIn(ChartAxisOptions{Kind: AxisCategory, Categories: []string{"Nave", "Choir", "Crypt", "Bells"}}) + plot := ui.Rect{W: 400, H: 10} + if cat.Map(plot, 0) != 50 || cat.Map(plot, 3) != 350 || cat.Label(cat.Invert(plot, 260)) != "Crypt" { + t.Fatalf("category %v %v", cat.Map(plot, 0), cat.Label(cat.Invert(plot, 260))) + } +} + +func TestChartAxisBad(t *testing.T) { + for name, o := range map[string]ChartAxisOptions{ + "empty": {}, + "log zero": {Kind: AxisLog, Min: 0, Max: 10}, + "time": {Kind: AxisTime}, + "category": {Kind: AxisCategory}, + "side": {Min: 0, Max: 1, Side: 9}, + "negative side": {Min: 0, Max: 1, Side: -1}, + "infinite domain": {Min: 0, Max: math.Inf(1)}, + "outside tick": {Min: 0, Max: 1, Ticks: []float64{2}}, + "infinite tick": {Min: 0, Max: 1, Ticks: []float64{math.Inf(1)}}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + axisIn(o) + }() + } +} + +// partsChart composes public parts over three points. +func partsChart(c *ui.Context, notes []ChartNote, crosshair bool) ChartResult { + pts := [][2]float64{{2, 30}, {5, 80}, {8, 55}} + x := ChartAxis(c, ChartAxisOptions{Min: 0, Max: 10}) + y := ChartAxis(c, ChartAxisOptions{Side: SideLeft, Min: 0, Max: 100}) + grid := ChartGrid(c, ChartGridOptions{X: &x, Y: &y, Minor: 2}) + hair := ChartCrosshair(c, ChartCrosshairOptions{X: &x, Y: &y}) + notesPart := ChartAnnotation(c, notes, ChartAnnotationOptions{X: &x, Y: &y}) + plotOf := func(r ui.Rect) ui.Rect { return chartInset(r, 52, 26) } + ink := Tokens(c).AccentText + at := func(r ui.Rect, i int) (float32, float32) { + plot := plotOf(r) + return x.Map(plot, pts[i][0]), y.Map(plot, pts[i][1]) + } + return Chart(c, ChartFrame{Label: "Parts"}, ChartSpec{ + Marks: len(pts), + Readout: func(i int) string { return fmt.Sprintf("x %s · y %s", x.Label(pts[i][0]), y.Label(pts[i][1])) }, + Pick: func(r ui.Rect, px, py float32) int { + return hair.Snap(len(pts), func(i int) (float32, float32) { return at(r, i) }, px, py) + }, + Anchor: at, + Input: func(plot *ui.Element, r ui.Rect) { notesPart.Drag(plot, plotOf(r)) }, + Draw: func(p *ui.Painter, r ui.Rect, active int) { + plot := plotOf(r) + grid.Draw(p, plot) + x.Draw(p, plot) + y.Draw(p, plot) + notesPart.Draw(p, plot) + for i := range pts { + px, py := at(r, i) + internal.Marker(p, 0, px, py, 10, ink) + } + if crosshair && active >= 0 { + px, py := at(r, active) + hair.Draw(p, plot, px, py) + } + }, + }) +} + +func TestChartAxisSnapshot(t *testing.T) { + t0 := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + snapshot(t, "chart_axis", 560, 300, func(c *ui.Context) { + axes := []ChartAxisPart{ + ChartAxis(c, ChartAxisOptions{Min: 0, Max: 97}), + ChartAxis(c, ChartAxisOptions{Side: SideLeft, Kind: AxisLog, Min: 1, Max: 10000}), + ChartAxis(c, ChartAxisOptions{Side: SideTop, Kind: AxisTime, From: t0, To: t0.Add(3 * time.Hour)}), + ChartAxis(c, ChartAxisOptions{Side: SideRight, Kind: AxisCategory, Categories: []string{"Nave", "Choir", "Crypt"}}), + } + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + plot := ui.Rect{X: r.X + 60, Y: r.Y + 40, W: r.W - 140, H: r.H - 80} + for _, a := range axes { + a.Draw(p, plot) + } + }) + }) +} + +func TestChartAxisLargeCategoriesBounded(t *testing.T) { + categories := make([]string, 100_000) + for i := range categories { + categories[i] = fmt.Sprint(i) + } + a := axisIn(ChartAxisOptions{Kind: AxisCategory, Categories: categories}) + if n := len(a.Ticks()); n > 65 || n == 0 { + t.Fatalf("built %d category ticks", n) + } + if a.Label(99999) != "99999" || a.Map(ui.Rect{W: 1000}, 99999) < 999 { + t.Fatal("last category mapping") + } +} + +func charts2MustPanic(t *testing.T, view func(*ui.Context)) { + t.Helper() + defer func() { + if recover() == nil { + t.Error("invalid options accepted") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); view(c) }, 320, 240) +} diff --git a/chart_brush.go b/chart_brush.go new file mode 100644 index 0000000..d4a0da3 --- /dev/null +++ b/chart_brush.go @@ -0,0 +1,239 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartBrushOptions configure a ChartBrush. +type ChartBrushOptions struct { + // Label names the brush for assistive technology; required. + Label string + // Min and Max span the track; Min < Max. + Min, Max float64 + // Step defaults to one hundredth of the span. + Step float64 + // Values draws a preview line across the track. + Values []float64 + // Height is the track height; 0 means 48 DIPs. + Height float32 + Format func(v float64) string + Disabled bool +} + +type brushDrag struct { + part int + grab float64 + start float64 +} + +// ChartBrush selects a range with dragging and keyboard controls. +func ChartBrush(c *ui.Context, value *[2]float64, opts ChartBrushOptions) ChoiceResult { + if opts.Label == "" || !(opts.Max > opts.Min) || math.IsInf(opts.Min, 0) || math.IsInf(opts.Max, 0) || math.IsNaN(opts.Step) || math.IsInf(opts.Step, 0) || opts.Step < 0 || math.IsNaN(float64(opts.Height)) || math.IsInf(float64(opts.Height), 0) || opts.Height != 0 && opts.Height < theme.MinHitSize { + panic("mujica: ChartBrush needs a Label, finite domain and step, and Height ≥ 28") + } + step := opts.Step + if step == 0 { + step = (opts.Max - opts.Min) / 100 + } + spec := numberInputResolve(NumberInputOptions{Min: opts.Min, Max: opts.Max, Step: step}) + if value[0] < opts.Min || value[1] > opts.Max || !(value[1] > value[0]) { + panic("mujica: ChartBrush value must be an ascending range inside Min..Max") + } + h := opts.Height + if h == 0 { + h = 48 + } + k := Tokens(c) + u := Density(c).Unit() + val := func(v float64) string { return chartValue(c, v, opts.Format) } + snap := func(v float64) float64 { + return max(spec.lo, min(spec.hi, spec.round(spec.lo+math.Round((v-spec.lo)/spec.step)*spec.step))) + } + before := *value + var res ChoiceResult + res.Element = ui.Column(c).Gap(u).MinWidth(0).Disabled(opts.Disabled) + res.Element.Children(func() { + track := ui.Box(c).Height(h).FillWidth().MinWidth(0).Radius(theme.ControlRadius).Background(k.SurfaceHover) + b := track.Bounds() + internal.Remeasure(c, track, b.W, b.H) + at := func(v float64) float32 { return float32((v-opts.Min)/(opts.Max-opts.Min)) * b.W } + from := func(px float32) float64 { return opts.Min + float64(px/max(b.W, 1))*(opts.Max-opts.Min) } + drag := ui.Local(track, "mujica.brush.drag", func() brushDrag { return brushDrag{part: -1} }) + set := func(lo, hi float64) { + lo, hi = snap(lo), snap(hi) + if hi-lo >= spec.step-1e-9 { + *value = [2]float64{lo, hi} + } + } + move := func(lo, hi float64) { + w := hi - lo + lo = max(spec.lo, min(lo, spec.hi-w)) + set(lo, lo+w) + } + preview := ui.Local(track, "mujica.brush.preview", func() brushPreview { return brushPreview{} }) + if id := chartID(opts.Values); preview.id != id { + *preview = brushPreview{id: id, lo: math.Inf(1), hi: math.Inf(-1)} + for _, v := range opts.Values { + if math.IsInf(v, 0) { + panic("mujica: ChartBrush preview values must be finite or NaN") + } + if !math.IsNaN(v) { + preview.lo, preview.hi = min(preview.lo, v), max(preview.hi, v) + } + } + } + track.Draw(func(p *ui.Painter, r ui.Rect) { brushPaint(p, r, *value, opts, k, preview) }) + track.Children(func() { + x0, x1 := at(value[0]), at(value[1]) + end := func(i int, x float32) *ui.Element { + e := ui.Box(c).Absolute().Left(x-theme.MinHitSize/2).Top(0).Size(theme.MinHitSize, h).Focusable(). + Role(ui.RoleSlider).Label(fmt.Sprintf(msg(c, []string{"chart_brush.start", "chart_brush.end"}[i]), val(value[i]))) + internal.Ring(e, k.Focus, theme.ControlRadius) + return e.Draw(func(p *ui.Painter, r ui.Rect) { + cx := internal.Snap(p, r.X+r.W/2) + p.Fill(ui.Rect{X: cx - 3, Y: r.Y + r.H/2 - 10, W: 6, H: 20}, k.Text, 2) + }) + } + // Tab order runs start, window, end. + ends := [2]*ui.Element{end(0, x0)} + window := ui.Box(c).Absolute().Left(x0 + theme.MinHitSize/2).Top(0).Width(max(x1-x0-theme.MinHitSize, theme.MinHitSize)).Height(h).Focusable(). + Role(ui.RoleGroup).Label(fmt.Sprintf(msg(c, "chart_brush.window"), opts.Label, val(value[0]), val(value[1]))) + internal.Ring(window, k.Focus, theme.ControlRadius) + ends[1] = end(1, x1) + if res.Element.IsDisabled() { + drag.part = -1 + return + } + for i, end := range ends { + v, ok := brushKey(end, value[i], spec) + switch { + case !ok: + case i == 0: + set(min(v, value[1]-spec.step), value[1]) + default: + set(value[0], max(v, value[0]+spec.step)) + } + } + w := value[1] - value[0] + switch { + case window.Shortcut(0, ui.KeyLeft): + lo := max(spec.lo, spec.stepBy(value[0], -1)) + set(lo, lo+w) + case window.Shortcut(0, ui.KeyRight): + hi := min(spec.hi, spec.stepBy(value[1], 1)) + set(hi-w, hi) + case window.Shortcut(0, ui.KeyHome): + set(spec.lo, spec.lo+w) + case window.Shortcut(0, ui.KeyEnd): + set(spec.hi-w, spec.hi) + } + brushPointer(track, window, ends, drag, value, from, set, move) + }) + ui.Text(c, val(value[0])+" – "+val(value[1])).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleStatus) + }) + res.changed = *value != before + if res.changed { + c.Announce(fmt.Sprintf(msg(c, "chart_brush.window"), opts.Label, val(value[0]), val(value[1]))) + slog.Debug("mujica: chart_brush range", "from", value[0], "to", value[1]) + } + return res +} + +// brushKey handles arrow, Page, Home and End keys. +func brushKey(e *ui.Element, v float64, spec numberInputSpec) (float64, bool) { + if e.IsDisabled() { + return v, false + } + switch { + case e.Shortcut(0, ui.KeyLeft), e.Shortcut(0, ui.KeyDown): + return spec.stepBy(v, -1), true + case e.Shortcut(0, ui.KeyRight), e.Shortcut(0, ui.KeyUp): + return spec.stepBy(v, 1), true + case e.Shortcut(0, ui.KeyPageDown): + return spec.stepBy(v, -10), true + case e.Shortcut(0, ui.KeyPageUp): + return spec.stepBy(v, 10), true + case e.Shortcut(0, ui.KeyHome): + return spec.lo, true + case e.Shortcut(0, ui.KeyEnd): + return spec.hi, true + } + return v, false +} + +// brushPointer drags handles, the window, or a new range. +func brushPointer(track, window *ui.Element, ends [2]*ui.Element, drag *brushDrag, value *[2]float64, from func(float32) float64, set, move func(lo, hi float64)) { + px, _, _ := track.PointerPosition() + at := from(px) + parts := []*ui.Element{ends[0], ends[1], window, track} + held := -1 + for i, e := range parts { + if _, _, ok := e.Dragged(); ok && held < 0 { + held = i + } + } + if held < 0 { + drag.part = -1 + return + } + if drag.part < 0 { + *drag = brushDrag{part: held, grab: at - value[0], start: at} + } + w := value[1] - value[0] + switch drag.part { + case 0: + set(min(at, value[1]), value[1]) + case 1: + set(value[0], max(at, value[0])) + case 2: + lo := at - drag.grab + // Shift both ends together at a boundary. + move(lo, lo+w) + default: + set(min(drag.start, at), max(drag.start, at)) + } +} + +// brushPaint draws the preview and frames the selected range. +type brushPreview struct { + id string + lo, hi float64 + width float32 + runs [][]int +} + +func brushPaint(p *ui.Painter, r ui.Rect, value [2]float64, opts ChartBrushOptions, k theme.Tokens, preview *brushPreview) { + lo, hi := preview.lo, preview.hi + if n := len(opts.Values); n > 1 && hi > lo { + x := func(i int) float32 { return r.W * float32(i) / float32(n-1) } + if preview.width != r.W || preview.runs == nil { + preview.width, preview.runs = r.W, nil + chartRuns(opts.Values, x, func(idx []int) { preview.runs = append(preview.runs, idx) }) + } + for _, idx := range preview.runs { + var line ui.Path + for j, i := range idx { + px := internal.Snap(p, r.X+x(i)) + py := internal.Snap(p, r.Y+4+(r.H-8)*float32(1-(opts.Values[i]-lo)/(hi-lo))) + if j == 0 { + line.MoveTo(px, py) + } else { + line.LineTo(px, py) + } + } + p.StrokePath(&line, 1.25, k.TextMuted) + } + } + x0 := r.X + r.W*float32((value[0]-opts.Min)/(opts.Max-opts.Min)) + x1 := r.X + r.W*float32((value[1]-opts.Min)/(opts.Max-opts.Min)) + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: x0 - r.X, H: r.H}, k.Background.Alpha(0.6), 0) + p.Fill(ui.Rect{X: x1, Y: r.Y, W: r.X + r.W - x1, H: r.H}, k.Background.Alpha(0.6), 0) + p.Stroke(ui.Rect{X: x0, Y: r.Y, W: x1 - x0, H: r.H}, k.AccentText, 0, 1.5) +} diff --git a/chart_brush_test.go b/chart_brush_test.go new file mode 100644 index 0000000..86d83b8 --- /dev/null +++ b/chart_brush_test.go @@ -0,0 +1,166 @@ +package mujica + +import ( + "fmt" + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +type brushHarness struct { + value [2]float64 + changes int + disabled bool + el *ui.Element +} + +func (h *brushHarness) view(c *ui.Context) { + Use(c, Settings{Mode: Light}) + ui.Column(c).Padding(10).Width(420).Children(func() { + r := ChartBrush(c, &h.value, ChartBrushOptions{Label: "Months", Min: 0, Max: 100, Step: 1, Disabled: h.disabled}) + h.el = r.Element + if r.Changed() { + h.changes++ + } + }) +} + +func TestChartBrushKeyboard(t *testing.T) { + h := &brushHarness{value: [2]float64{20, 40}} + tt := ui.NewTester(h.view, 460, 200) + tabTo(t, tt, "Months: 20 to 40") + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + if h.value != [2]float64{22, 42} || h.changes != 2 { + t.Fatalf("window moved to %v (%d)", h.value, h.changes) + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Range end 42") { + t.Fatal("Tab did not reach the end handle") + } + tt.Key(0, ui.KeyPageUp) + tt.Key(0, ui.KeyEnd) + if h.value != [2]float64{22, 100} { + t.Fatalf("end handle: %v", h.value) + } + tt.Key(ui.Shift, ui.KeyTab) + tt.Key(ui.Shift, ui.KeyTab) + tt.Key(0, ui.KeyEnd) // the start cannot pass the end + if h.value != [2]float64{99, 100} || !tt.HasText("99 – 100") { + t.Fatalf("start to End: %v %q", h.value, tt.Texts()) + } +} + +func TestChartBrushDrag(t *testing.T) { + h := &brushHarness{value: [2]float64{20, 40}} + tt := ui.NewTester(h.view, 460, 200) + b := h.el.Bounds() + x := func(v float32) float32 { return b.X + 400*v/100 } + y := b.Y + 24 + tt.Press(x(30), y) // inside the window + tt.Move(x(40), y) + tt.Move(x(50), y) + tt.Release(x(50), y) + if h.value != [2]float64{40, 60} { + t.Fatalf("window drag: %v", h.value) + } + tt.Press(x(60), y) // the end handle + tt.Move(x(70), y) + tt.Move(x(75), y) + tt.Release(x(75), y) + if h.value != [2]float64{40, 75} { + t.Fatalf("end drag: %v", h.value) + } + tt.Press(x(85), y) // outside: a new range + tt.Move(x(90), y) + tt.Move(x(95), y) + tt.Release(x(95), y) + if h.value != [2]float64{85, 95} { + t.Fatalf("new range: %v", h.value) + } +} + +func TestChartBrushDisabledAndBad(t *testing.T) { + h := &brushHarness{value: [2]float64{20, 40}, disabled: true} + tt := ui.NewTester(h.view, 460, 200) + b := h.el.Bounds() + tt.Press(b.X+120, b.Y+24) + tt.Move(b.X+200, b.Y+24) + tt.Release(b.X+200, b.Y+24) + for range 3 { + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyRight) + } + if h.value != [2]float64{20, 40} || h.changes != 0 { + t.Fatalf("disabled brush moved: %v", h.value) + } + for _, v := range [][2]float64{{50, 40}, {-1, 10}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%v: no panic", v) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ChartBrush(c, &v, ChartBrushOptions{Label: "x", Max: 100}) + }, 300, 100) + }() + } +} + +func TestChartBrushSnapshot(t *testing.T) { + vals := make([]float64, 60) + for i := range vals { + vals[i] = float64((i*7)%13) + float64(i)/6 + } + snapshot(t, "chart_brush", 460, 110, func(c *ui.Context) { + v := [2]float64{18, 42} + ui.Column(c).Padding(16).Children(func() { + ChartBrush(c, &v, ChartBrushOptions{Label: "Months", Min: 0, Max: 59, Step: 1, Values: vals, Format: func(f float64) string { return fmt.Sprintf("week %.0f", f) }}) + }) + }) +} + +func TestChartBrushWindowDragPreservesSpanAtEdges(t *testing.T) { + h := &brushHarness{value: [2]float64{20, 60}} + tt := ui.NewTester(h.view, 460, 200) + b := h.el.Bounds() + tt.Press(b.X+160, b.Y+24) + tt.Move(b.X+380, b.Y+24) + tt.Release(b.X+380, b.Y+24) + if h.value != [2]float64{60, 100} { + t.Fatalf("right edge shrank window: %v", h.value) + } + tt.Press(b.X+320, b.Y+24) + tt.Move(b.X-50, b.Y+24) + tt.Release(b.X-50, b.Y+24) + if h.value != [2]float64{0, 40} { + t.Fatalf("left edge shrank window: %v", h.value) + } +} + +func TestChartBrushInvalidMetrics(t *testing.T) { + for _, o := range []ChartBrushOptions{ + {Label: "Range", Max: 100, Step: math.Inf(1)}, + {Label: "Range", Max: math.Inf(1)}, + {Label: "Range", Max: 100, Height: 10}, + } { + charts2MustPanic(t, func(c *ui.Context) { v := [2]float64{20, 40}; ChartBrush(c, &v, o) }) + } +} + +func TestChartBrushNarrowRangeHitAndAnnouncement(t *testing.T) { + h := &brushHarness{value: [2]float64{40, 41}} + tt := ui.NewTester(h.view, 460, 200) + b, ok := tt.Find("Months: 40 to 41") + if !ok || b.W < 28 || b.H < 28 { + t.Fatalf("window hit area %v", b) + } + tabTo(t, tt, "Months: 40 to 41") + tt.Key(0, ui.KeyRight) + if said := tt.Announcements(); len(said) == 0 || said[len(said)-1] != "Months: 41 to 42" { + t.Fatalf("window announcement %q", said) + } +} diff --git a/chart_crosshair.go b/chart_crosshair.go new file mode 100644 index 0000000..d25ba55 --- /dev/null +++ b/chart_crosshair.go @@ -0,0 +1,82 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "math" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartCrosshairOptions configure a ChartCrosshair. +type ChartCrosshairOptions struct { + // X draws vertical lines; Y draws horizontal lines. + X, Y *ChartAxisPart + // Reach defaults to 24 DIPs for point snapping. + Reach float32 +} + +// ChartCrosshairPart draws and snaps the crosshair. +type ChartCrosshairPart struct { + opts ChartCrosshairOptions + k theme.Tokens + size float32 + pad float32 +} + +// ChartCrosshair draws crossed lines and axis readouts. +func ChartCrosshair(c *ui.Context, opts ChartCrosshairOptions) ChartCrosshairPart { + if opts.X == nil && opts.Y == nil || opts.Reach < 0 || math.IsNaN(float64(opts.Reach)) || math.IsInf(float64(opts.Reach), 0) { + panic("mujica: ChartCrosshair needs an axis and Reach ≥ 0") + } + if opts.Reach == 0 { + opts.Reach = 24 + } + return ChartCrosshairPart{opts: opts, k: Tokens(c), size: FontSize(c, theme.CaptionSize), pad: Density(c).Unit()} +} + +// Snap returns the nearest point within Reach, or -1. +func (h ChartCrosshairPart) Snap(n int, at func(i int) (x, y float32), x, y float32) int { + return internal.Nearest(n, at, x, y, h.opts.Reach, false) +} + +// Draw paints crossed lines and value labels. +func (h ChartCrosshairPart) Draw(p *ui.Painter, plot ui.Rect, x, y float32) { + k := h.k + if a := h.opts.X; a != nil { + xx := internal.Snap(p, x) + p.Line(xx, plot.Y, xx, plot.Y+plot.H, 1, k.TextMuted) + at := plot.Y + plot.H + if a.opts.Side == SideTop { + at = plot.Y + } + h.pill(p, a.readout(a.Invert(plot, x)), x, at, true, a.opts.Side == SideTop) + } + if a := h.opts.Y; a != nil { + yy := internal.Snap(p, y) + p.Line(plot.X, yy, plot.X+plot.W, yy, 1, k.TextMuted) + at := plot.X + if a.opts.Side == SideRight { + at = plot.X + plot.W + } + h.pill(p, a.readout(a.Invert(plot, y)), at, y, false, a.opts.Side == SideRight) + } +} + +// pill paints a value tag outside the plot. +func (h ChartCrosshairPart) pill(p *ui.Painter, s string, x, y float32, across, far bool) { + w, ht := p.MeasureText(0, ui.Span{Text: s, Size: h.size}) + box := ui.Rect{W: w + 2*h.pad, H: ht + h.pad} + switch { + case across && far: + box.X, box.Y = x-box.W/2, y-box.H-2 + case across: + box.X, box.Y = x-box.W/2, y+2 + case far: + box.X, box.Y = x+2, y-box.H/2 + default: + box.X, box.Y = x-box.W-2, y-box.H/2 + } + p.Fill(box, h.k.Text, theme.ControlRadius) + p.Text(box.X+h.pad, box.Y+h.pad/2, s, h.size, h.k.Surface) +} diff --git a/chart_crosshair_test.go b/chart_crosshair_test.go new file mode 100644 index 0000000..45cfc20 --- /dev/null +++ b/chart_crosshair_test.go @@ -0,0 +1,89 @@ +package mujica + +import ( + "image/color" + "math" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +func TestChartCrosshairSnapsAndFollowsKeys(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { return partsChart(c, nil, true) }} + tt := ui.NewTester(h.view, 520, 420) + b := h.res.Element.Bounds() + plot := chartInset(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: 240}, 52, 26) + // Point 1 is ten DIPs from the pointer. + px, py := plot.X+plot.W/2, plot.Y+plot.H*0.2 + tt.Move(px+10, py-6) + if h.res.Point != 1 || chartReadout(tt) != "x 5 · y 80" { + t.Fatalf("snap: point %d %q", h.res.Point, chartReadout(tt)) + } + if !crosshairAt(tt, int(px), int(plot.Y+plot.H-2)) { + t.Fatal("vertical line does not pass through the snapped point") + } + tt.Move(px+60, py+60) + if h.res.Point != -1 { + t.Fatalf("snapped from too far: %d", h.res.Point) + } + tabTo(t, tt, "Parts") + tt.Key(0, ui.KeyEnd) + if !crosshairAt(tt, int(plot.X+plot.W*0.8), int(plot.Y+plot.H-2)) { + t.Fatal("keyboard point has no crosshair") + } +} + +// crosshairAt checks a crosshair pixel. +func crosshairAt(tt *ui.Tester, x, y int) bool { + want := toRGBA(theme.Light().TextMuted) + for dx := -1; dx <= 1; dx++ { + if color.RGBAModel.Convert(tt.Image().At(x+dx, y)).(color.RGBA) == want { + return true + } + } + return false +} + +func TestChartCrosshairBad(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic without axes") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); ChartCrosshair(c, ChartCrosshairOptions{}) }, 100, 100) +} + +func TestChartCrosshairSnapshot(t *testing.T) { + snapshot(t, "chart_crosshair", 420, 240, func(c *ui.Context) { + x := ChartAxis(c, ChartAxisOptions{Min: 0, Max: 10}) + y := ChartAxis(c, ChartAxisOptions{Side: SideLeft, Min: 0, Max: 100}) + hair := ChartCrosshair(c, ChartCrosshairOptions{X: &x, Y: &y}) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + plot := ui.Rect{X: r.X + 48, Y: r.Y + 16, W: r.W - 64, H: r.H - 48} + x.Draw(p, plot) + y.Draw(p, plot) + hair.Draw(p, plot, x.Map(plot, 6.5), y.Map(plot, 42)) + }) + }) +} + +func TestChartCrosshairDisablesFocusedReadout(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { return partsChart(c, nil, true) }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Parts") + tt.Key(0, ui.KeyRight) + h.disabled = true + tt.Frame() + if h.res.Point != -1 { + t.Fatalf("disabled readout %d", h.res.Point) + } +} + +func TestChartCrosshairFiniteReach(t *testing.T) { + charts2MustPanic(t, func(c *ui.Context) { + a := ChartAxis(c, ChartAxisOptions{Max: 1}) + ChartCrosshair(c, ChartCrosshairOptions{X: &a, Reach: float32(math.NaN())}) + }) +} diff --git a/chart_empty_state.go b/chart_empty_state.go new file mode 100644 index 0000000..3b83d4f --- /dev/null +++ b/chart_empty_state.go @@ -0,0 +1,32 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" +) + +// ChartEmptyStateOptions configure a ChartEmptyState. +type ChartEmptyStateOptions struct { + // Height keeps the chart's size; 0 means 240. + Height float32 + // Error describes a DataFailed status; empty uses the built-in text. + Error string + // Empty replaces the built-in "No data" text. + Empty string +} + +// ChartEmptyState preserves plot size and reports retry clicks. +func ChartEmptyState(c *ui.Context, status DataStatus, opts ChartEmptyStateOptions) ChoiceResult { + if opts.Height < 0 { + panic("mujica: ChartEmptyState Height must be ≥ 0") + } + h := opts.Height + if h == 0 { + h = 240 + } + var res ChoiceResult + res.Element = ui.Column(c).Height(h).FillWidth().MinWidth(0) + res.Element.Children(func() { + res.changed = dataPlaceholder(c, status, opts.Error, opts.Empty, msg(c, "chart_empty_state.empty")) && !res.Element.IsDisabled() + }) + return res +} diff --git a/chart_empty_state_test.go b/chart_empty_state_test.go new file mode 100644 index 0000000..2a94b95 --- /dev/null +++ b/chart_empty_state_test.go @@ -0,0 +1,61 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestChartEmptyStateKeepsSizeAndRetries(t *testing.T) { + status := DataReady + retries, disabled := 0, false + var heights []float32 + var el *ui.Element + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Width(400).Disabled(disabled).Children(func() { + r := ChartEmptyState(c, status, ChartEmptyStateOptions{Height: 180, Empty: "No hymns this week", Error: "The choir did not answer"}) + el = r.Element + if r.Changed() { + retries++ + } + }) + }, 440, 300) + heights = append(heights, el.Bounds().H) + if !tt.HasText("No hymns this week") { + t.Fatalf("empty: %q", tt.Texts()) + } + status = DataLoading + tt.Frame() + heights = append(heights, el.Bounds().H) + if !tt.HasText("Loading") { + t.Fatalf("loading: %q", tt.Texts()) + } + status = DataFailed + tt.Frame() + heights = append(heights, el.Bounds().H) + if err := tt.Click("Retry"); err != nil || retries != 1 || !tt.HasText("The choir did not answer") { + t.Fatalf("retry: %v %d", err, retries) + } + if heights[0] != 180 || heights[1] != 180 || heights[2] != 180 { + t.Fatalf("heights %v", heights) + } + disabled = true + tt.Frame() + _ = tt.Click("Retry") + if retries != 1 { + t.Fatal("disabled retry fired") + } +} + +func TestChartEmptyStateSnapshot(t *testing.T) { + snapshot(t, "chart_empty_state", 640, 220, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(16).Children(func() { + for _, st := range []DataStatus{DataReady, DataLoading, DataFailed} { + ui.Column(c).Width(192).Border(1, Tokens(c).Border).Children(func() { + ChartEmptyState(c, st, ChartEmptyStateOptions{Height: 180}) + }) + } + }) + }) +} diff --git a/chart_export.go b/chart_export.go new file mode 100644 index 0000000..901b888 --- /dev/null +++ b/chart_export.go @@ -0,0 +1,59 @@ +package mujica + +import ( + "bytes" + "image/png" + "log/slog" + "math" + + "github.com/egoist/mygo/ui" +) + +// ChartExportOptions configure a ChartExport. +type ChartExportOptions struct { + // Width and Height are the image size in DIPs; required. + Width, Height int + // Scale is device pixels per DIP; 0 means 2. + Scale float32 +} + +// ChartExport returns a PNG rendered in the current theme. +func ChartExport(c *ui.Context, chart func(c *ui.Context), opts ChartExportOptions) ([]byte, error) { + if chart == nil || opts.Width <= 0 || opts.Height <= 0 || opts.Scale < 0 || math.IsNaN(float64(opts.Scale)) || math.IsInf(float64(opts.Scale), 0) { + panic("mujica: ChartExport needs a chart, a positive size and Scale ≥ 0") + } + scale := opts.Scale + if scale == 0 { + scale = 2 + } + w := current(c) + settings := Settings{Mode: Light, Density: w.density, Locale: w.locale, Messages: w.messages} + if w.tokens.Dark { + settings.Mode = Dark + } + tokens := w.tokens + ready := false + tester := ui.NewTester(func(c *ui.Context) { + if !ready { + return + } + if settings.Mode == Dark { + settings.Dark = &tokens + } else { + settings.Light = &tokens + } + Use(c, settings) + ui.Column(c).Fill().Background(tokens.Surface).Padding(Density(c).Unit() * 4).Children(func() { chart(c) }) + }, opts.Width, opts.Height) + tester.SetScale(scale) + tester.SetPreferences(c.Preferences()) + ready = true + tester.Frame() + img := tester.Image() + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + slog.Debug("mujica: chart_export png", "bytes", buf.Len(), "width", opts.Width, "height", opts.Height) + return buf.Bytes(), nil +} diff --git a/chart_export_test.go b/chart_export_test.go new file mode 100644 index 0000000..b608e9c --- /dev/null +++ b/chart_export_test.go @@ -0,0 +1,94 @@ +package mujica + +import ( + "bytes" + "image/color" + "image/png" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +func TestChartExportPNGInTheme(t *testing.T) { + for _, mode := range []Mode{Light, Dark} { + var out []byte + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: mode}) + if out != nil { + return + } + b, err := ChartExport(c, func(c *ui.Context) { + BarChart(c, barCats, barSeries(), BarChartOptions{Frame: ChartFrame{Label: "Tithes", Height: 160}}) + }, ChartExportOptions{Width: 300, Height: 220, Scale: 1.5}) + if err != nil { + t.Fatal(err) + } + out = b + }, 100, 100) + img, err := png.Decode(bytes.NewReader(out)) + if err != nil { + t.Fatal(err) + } + if b := img.Bounds(); b.Dx() != 450 || b.Dy() != 330 { + t.Fatalf("%v: size %v", mode, b) + } + k := theme.Light() + if mode == Dark { + k = theme.Dark() + } + if got := color.RGBAModel.Convert(img.At(3, 3)).(color.RGBA); got != toRGBA(k.Surface) { + t.Fatalf("%v: corner %v, want the theme's Surface", mode, got) + } + } +} + +func TestChartExportBad(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for a zero size") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + _, _ = ChartExport(c, func(*ui.Context) {}, ChartExportOptions{}) + }, 100, 100) +} + +func TestChartExportSnapshot(t *testing.T) { + snapshot(t, "chart_export", 340, 300, func(c *ui.Context) { + b, err := ChartExport(c, func(c *ui.Context) { + PieChart(c, pieSlices, PieChartOptions{Frame: ChartFrame{Label: "Stores", Height: 150}, Donut: true, MinAngle: 20}) + }, ChartExportOptions{Width: 300, Height: 260, Scale: 1}) + if err != nil { + t.Fatal(err) + } + bmp, err := ui.DecodeBitmap(b) + if err != nil { + t.Fatal(err) + } + ui.Column(c).Padding(20).Children(func() { ui.Image(c, bmp).Size(300, 260) }) + }) +} + +func TestChartExportPreservesPreferences(t *testing.T) { + var data []byte + var got ui.Preferences + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Dark, Locale: ZhCN}) + if data != nil { + return + } + var err error + data, err = ChartExport(c, func(c *ui.Context) { got = c.Preferences(); ui.Text(c, "Scale") }, ChartExportOptions{Width: 100, Height: 80, Scale: 1}) + if err != nil { + t.Fatal(err) + } + }, 100, 80) + data = nil + tt.SetPreferences(ui.Preferences{TextScale: 2, ReduceMotion: true, HighContrast: true}) + if got.TextScale != 2 || !got.ReduceMotion || !got.HighContrast { + t.Fatalf("export preferences %+v", got) + } +} diff --git a/chart_frame.go b/chart_frame.go index 9fdad75..e69bae0 100644 --- a/chart_frame.go +++ b/chart_frame.go @@ -44,28 +44,39 @@ 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 a chart's marks and drawing, for Chart; r is the chart box in each callback. +type ChartSpec struct { + // Marks counts what the keys step through; 0 shows the empty state. + Marks int + // Legend lists series keys above the plot. + Legend []ChartLegendItem + // Hidden, when set, makes legend keys check boxes switching series off. + Hidden *map[string]bool + // Draw paints the plot; active is the read mark, or -1. + Draw func(p *ui.Painter, r ui.Rect, active int) + // Pick returns the mark at (x, y), or -1; required. + Pick func(r ui.Rect, x, y float32) int + // Readout describes mark i; it is shown, announced and the tooltip's default. + Readout func(i int) string + // Move steps from mark i for an arrow key; nil steps in order. + Move func(i int, key ui.Key) int + // Activate runs on Enter, Space or a click on a mark. + Activate func(i int) + // Anchor places mark i's tooltip for the keyboard; nil uses the top middle. + Anchor func(r ui.Rect, i int) (x, y float32) + // Tooltip writes mark i's tooltip; nil splits Readout at " · ". + Tooltip func(i int) []ChartTooltipLine + // Input takes the plot's other input, as a ChartAnnotation's drags. + Input func(plot *ui.Element, r ui.Rect) } -// 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) +// Chart frames a custom chart: legend, focus, keys, readout, tooltip and empty states. +func Chart(c *ui.Context, frame ChartFrame, spec ChartSpec) ChartResult { + if spec.Marks < 0 || spec.Draw == nil || spec.Pick == nil || spec.Readout == nil { + panic("mujica: Chart needs Marks ≥ 0, Draw, Pick and Readout") + } + res, _ := newChart(c, frame).build(spec) + return res } type chartState struct { @@ -95,47 +106,70 @@ func newChart(c *ui.Context, frame ChartFrame) *chart { return ch } +// toggles returns the legend's hidden set when series can be switched off. +func (ch *chart) toggles(on bool) *map[string]bool { + if on { + return &ch.st.hidden + } + return nil +} + // 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) { +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) + if len(spec.Legend) > 0 { + if name, ok := ChartLegend(c, spec.Hidden, spec.Legend, ChartLegendOptions{}).Toggled(); ok { + ch.res.toggled = name + } } - box := ui.Box(c).Height(ch.frame.Height).FillWidth().MinWidth(0) - if ch.frame.Status == DataLoading || ch.frame.Status == DataFailed || spec.n == 0 { + if ch.frame.Status == DataLoading || ch.frame.Status == DataFailed || spec.Marks == 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.retry = ChartEmptyState(c, ch.frame.Status, ChartEmptyStateOptions{Height: ch.frame.Height, Error: ch.frame.Error, Empty: ch.frame.Empty}).Changed() ch.res.Point = -1 ui.Box(c).Height(u * 5) return } + box := ui.Box(c).Height(ch.frame.Height).FillWidth().MinWidth(0) 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) + st.focus = min(st.focus, spec.Marks-1) ch.input(plot, spec) - active := -1 - if x, y, over := plot.PointerPosition(); over && !plot.IsDisabled() { + if spec.Input != nil && !plot.IsDisabled() { b := plot.Bounds() - active = spec.pick(ui.Rect{W: b.W, H: b.H}, x, y) + spec.Input(plot, ui.Rect{W: b.W, H: b.H}) } - if active < 0 && plot.Focused() { + active := -1 + b := plot.Bounds() + local := ui.Rect{W: b.W, H: b.H} + x, y, over := plot.PointerPosition() + if over && !plot.IsDisabled() { + active = spec.Pick(local, x, y) + } + if active < 0 && plot.Focused() && st.focus >= 0 && !plot.IsDisabled() { active = st.focus + x, y = local.W/2, 0 + if spec.Anchor != nil { + x, y = spec.Anchor(local, active) + } } ch.res.Point = active - plot.Draw(func(p *ui.Painter, r ui.Rect) { spec.draw(p, r, active) }) + if active >= 0 && b.W > 0 { + lines := chartTipLines(spec.Readout(active)) + if spec.Tooltip != nil { + lines = spec.Tooltip(active) + } + ChartTooltip(c, b.X+x, b.Y+y, lines) + } + 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 + 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) }) @@ -145,7 +179,7 @@ func (ch *chart) build(spec chartSpec) (ChartResult, *ui.Element) { } // input moves the keyboard mark, and takes clicks on marks. -func (ch *chart) input(plot *ui.Element, spec chartSpec) { +func (ch *chart) input(plot *ui.Element, spec ChartSpec) { st := ch.st if plot.IsDisabled() { return @@ -158,19 +192,19 @@ func (ch *chart) input(plot *ui.Element, spec chartSpec) { switch { case st.focus < 0: to = 0 - case spec.move != nil: - to = spec.move(st.focus, key) + 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) + to = min(st.focus+1, spec.Marks-1) } } if plot.Shortcut(0, ui.KeyHome) { to = 0 } if plot.Shortcut(0, ui.KeyEnd) { - to = spec.n - 1 + to = spec.Marks - 1 } if plot.Shortcut(0, ui.KeyEscape) { to = -1 @@ -178,7 +212,7 @@ func (ch *chart) input(plot *ui.Element, spec chartSpec) { if to != st.focus { st.focus = to if to >= 0 { - ch.c.Announce(spec.readout(to)) + ch.c.Announce(spec.Readout(to)) } slog.Debug("mujica: chart focus", "label", ch.frame.Label, "mark", to) } @@ -187,58 +221,15 @@ func (ch *chart) input(plot *ui.Element, spec chartSpec) { 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 { + 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) + 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}) diff --git a/chart_grid.go b/chart_grid.go new file mode 100644 index 0000000..e64556d --- /dev/null +++ b/chart_grid.go @@ -0,0 +1,68 @@ +package mujica + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" +) + +// ChartGridOptions configure a ChartGrid. +type ChartGridOptions struct { + // X and Y provide grid ticks; either may be nil. + X, Y *ChartAxisPart + // Minor subdivides major intervals with fainter lines. + Minor int +} + +// ChartGridPart is a grid ready to draw in a plot. +type ChartGridPart struct { + opts ChartGridOptions + major, minor ui.Color +} + +// ChartGrid draws pixel-aligned major and minor lines. +func ChartGrid(c *ui.Context, opts ChartGridOptions) ChartGridPart { + if opts.Minor < 0 || opts.X == nil && opts.Y == nil { + panic("mujica: ChartGrid needs an axis and Minor ≥ 0") + } + for _, a := range []*ChartAxisPart{opts.X, opts.Y} { + if a != nil && a.opts.Kind == AxisCategory && opts.Minor > 0 { + panic("mujica: ChartGrid has no minor lines on a category axis") + } + } + k := Tokens(c) + return ChartGridPart{opts: opts, major: k.Border, minor: k.Border.Mix(k.Surface, 0.55)} +} + +// Draw paints minor lines, then major ones, inside plot. +func (g ChartGridPart) Draw(p *ui.Painter, plot ui.Rect) { + for _, a := range []*ChartAxisPart{g.opts.X, g.opts.Y} { + if a == nil { + continue + } + line := func(v float64, col ui.Color) { + at := a.Map(plot, v) + if a.horizontal() { + internal.RuleV(p, at, plot.Y, plot.Y+plot.H, col) + } else { + internal.Rule(p, plot.X, plot.X+plot.W, at, col) + } + } + ticks := a.drawTicks(plot, 4) + for i := 1; i < len(ticks) && g.opts.Minor > 0; i++ { + for j := 1; j < g.opts.Minor; j++ { + f := float64(j) / float64(g.opts.Minor) + v := ticks[i-1] + (ticks[i]-ticks[i-1])*f + if a.opts.Kind == AxisLog { + v = math.Pow(10, math.Log10(ticks[i-1])+(math.Log10(ticks[i])-math.Log10(ticks[i-1]))*f) + } + line(v, g.minor) + } + } + for _, t := range ticks { + line(t, g.major) + } + } +} diff --git a/chart_grid_test.go b/chart_grid_test.go new file mode 100644 index 0000000..b81c0e3 --- /dev/null +++ b/chart_grid_test.go @@ -0,0 +1,66 @@ +package mujica + +import ( + "image/color" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +func TestChartGridPixelAlignedWithMinor(t *testing.T) { + for _, scale := range []float32{1, 2} { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + y := ChartAxis(c, ChartAxisOptions{Side: SideLeft, Min: 0, Max: 100, Ticks: []float64{0, 50, 100}}) + grid := ChartGrid(c, ChartGridOptions{Y: &y, Minor: 2}) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { grid.Draw(p, ui.Rect{X: 0, Y: 10.3, W: 200, H: 100}) }) + }, 200, 130) + tt.SetScale(scale) + img := tt.Image() + major, minor := toRGBA(theme.Light().Border), toRGBA(theme.Light().Border.Mix(theme.Light().Surface, 0.55)) + count := map[color.RGBA]int{} + for py := range img.Bounds().Dy() { + c := color.RGBAModel.Convert(img.At(100, py)).(color.RGBA) + count[c]++ + } + // Three major and two minor lines stay pixel-aligned. + if count[major] != 3*int(scale) || count[minor] != 2*int(scale) { + t.Fatalf("scale %v: %d major and %d minor pixel rows", scale, count[major], count[minor]) + } + } +} + +func TestChartGridBad(t *testing.T) { + for name, build := range map[string]func(c *ui.Context){ + "no axis": func(c *ui.Context) { ChartGrid(c, ChartGridOptions{}) }, + "category minor": func(c *ui.Context) { + a := ChartAxis(c, ChartAxisOptions{Kind: AxisCategory, Categories: []string{"a"}}) + ChartGrid(c, ChartGridOptions{X: &a, Minor: 2}) + }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); build(c) }, 100, 100) + }() + } +} + +func TestChartGridSnapshot(t *testing.T) { + snapshot(t, "chart_grid", 420, 240, func(c *ui.Context) { + x := ChartAxis(c, ChartAxisOptions{Min: 0, Max: 10}) + y := ChartAxis(c, ChartAxisOptions{Side: SideLeft, Kind: AxisLog, Min: 1, Max: 1000}) + grid := ChartGrid(c, ChartGridOptions{X: &x, Y: &y, Minor: 4}) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + plot := ui.Rect{X: r.X + 48, Y: r.Y + 16, W: r.W - 64, H: r.H - 48} + grid.Draw(p, plot) + x.Draw(p, plot) + y.Draw(p, plot) + }) + }) +} diff --git a/chart_legend.go b/chart_legend.go new file mode 100644 index 0000000..b269165 --- /dev/null +++ b/chart_legend.go @@ -0,0 +1,141 @@ +package mujica + +import ( + "fmt" + "log/slog" + "maps" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartLegendItem is a series key: name, color and marker shape. +type ChartLegendItem struct { + Name string + Color ui.Color + // Shape selects circle, square, diamond, triangle, or inverted triangle. + Shape int +} + +// ChartLegendOptions configure a ChartLegend. +type ChartLegendOptions struct { + Vertical bool + // MaxItems folds overflow; zero shows all items. + MaxItems int +} + +// ChartLegendResult exposes the element and legend events. +type ChartLegendResult struct { + Element *ui.Element + toggled string + expanded, folded bool +} + +// Toggled returns the series switched in this frame. +func (r ChartLegendResult) Toggled() (string, bool) { return r.toggled, r.toggled != "" } + +// Expanded reports the new state when expansion changes. +func (r ChartLegendResult) Expanded() (bool, bool) { return r.expanded, r.folded } + +// ChartLegend lists series; hidden enables visibility toggles. +func ChartLegend(c *ui.Context, hidden *map[string]bool, items []ChartLegendItem, opts ChartLegendOptions) ChartLegendResult { + if opts.MaxItems < 0 { + panic("mujica: ChartLegend MaxItems must be ≥ 0") + } + seen := map[string]bool{} + for _, it := range items { + if it.Name == "" || seen[it.Name] { + panic("mujica: ChartLegend items need unique names") + } + seen[it.Name] = true + } + k := Tokens(c) + u := Density(c).Unit() + var res ChartLegendResult + res.Element = ui.Row(c).Gap(u * 3).Wrap() + orient := ui.Horizontal + if opts.Vertical { + res.Element.Column().Gap(u).AlignItems(ui.Start) + orient = ui.Vertical + } + open := ui.Local(res.Element, "mujica.legend.open", func() bool { return false }) + res.Element.FocusGroup(orient).Children(func() { + shown := items + if opts.MaxItems > 0 && len(items) > opts.MaxItems && !*open { + shown = items[:opts.MaxItems] + } + for _, it := range shown { + ui.Row(c).Key(it.Name).Children(func() { + if name, ok := chartLegendEntry(c, hidden, it, res.Element.IsDisabled()); ok { + res.toggled = name + } + }) + } + if opts.MaxItems > 0 && len(items) > opts.MaxItems { + label := fmt.Sprintf(msg(c, "chart_legend.more"), len(items)-opts.MaxItems) + if *open { + label = msg(c, "chart_legend.fewer") + } + b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*2).Radius(theme.ControlRadius).Disabled(res.Element.IsDisabled()).TextColor(k.AccentText) + internal.Ring(b, k.Focus, theme.ControlRadius) + if b.Hovered() { + b.Background(k.SurfaceHover) + } + b.Children(func() { ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() }) + if b.Clicked() && !b.IsDisabled() { + *open = !*open + res.expanded, res.folded = *open, true + slog.Debug("mujica: chart_legend fold", "open", *open) + } + } + }) + return res +} + +// chartLegendEntry builds a series visibility control. +func chartLegendEntry(c *ui.Context, hidden *map[string]bool, it ChartLegendItem, disabled bool) (string, bool) { + k := Tokens(c) + u := Density(c).Unit() + on := hidden == nil || !(*hidden)[it.Name] + var e *ui.Element + switched := false + if hidden != nil { + e = ui.CheckboxBase(c, &on).Disabled(disabled).Label(it.Name) + internal.Ring(e, k.Focus, theme.ControlRadius) + if e.Hovered() && !disabled { + e.Background(k.SurfaceHover) + } + if e.Changed() && !e.IsDisabled() { + m := maps.Clone(*hidden) + if m == nil { + m = map[string]bool{} + } + m[it.Name] = !on + *hidden, switched = m, true + slog.Debug("mujica: chart_legend toggle", "series", it.Name, "visible", on) + } + } else { + e = ui.Row(c) + } + e.MinWidth(theme.MinHitSize).MinHeight(Density(c).ControlHeight()).Gap(u*2).AlignItems(ui.Center).Padding(0, u).Radius(theme.ControlRadius) + col := it.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, it.Shape, r.X+6, r.Y+6, 10, col) + } else { + p.Stroke(r, col, 2, 1) + } + }) + t := ui.Text(c, it.Name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text) + if !on { + t.Strikethrough().TextColor(k.TextMuted) + } + }) + return it.Name, switched +} diff --git a/chart_legend_test.go b/chart_legend_test.go new file mode 100644 index 0000000..7fa3572 --- /dev/null +++ b/chart_legend_test.go @@ -0,0 +1,125 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +func legendItems() []ChartLegendItem { + pal := internal.Palette(theme.Light()) + var out []ChartLegendItem + for i, n := range []string{"Matins", "Lauds", "Prime", "Terce", "Sext"} { + out = append(out, ChartLegendItem{Name: n, Color: pal[i], Shape: i}) + } + return out +} + +func TestChartLegendToggleKeyboard(t *testing.T) { + hidden := map[string]bool{} + var toggled []string + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + if s, ok := ChartLegend(c, &hidden, legendItems(), ChartLegendOptions{}).Toggled(); ok { + toggled = append(toggled, s) + } + }, 500, 200) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Matins") { + t.Fatal("Tab did not enter the legend") + } + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeyRight) + tt.Key(0, ui.KeySpace) + if !hidden["Prime"] || len(toggled) != 1 || toggled[0] != "Prime" { + t.Fatalf("arrows and Space: hidden %v toggled %v", hidden, toggled) + } + if err := tt.Click("Prime"); err != nil || hidden["Prime"] { + t.Fatalf("click did not show Prime again: %v", hidden) + } + tt.Key(0, ui.KeyTab) + if tt.Focused("Lauds") || tt.Focused("Sext") { + t.Fatal("legend entries are more than one Tab stop") + } +} + +func TestChartLegendFoldAndStatic(t *testing.T) { + vertical := true + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + ChartLegend(c, nil, legendItems(), ChartLegendOptions{Vertical: vertical, MaxItems: 2}) + }, 500, 400) + if tt.HasText("Prime") || !tt.HasText("+3 more") { + t.Fatalf("fold: %q", tt.Texts()) + } + a, _ := tt.Find("Matins") + b, _ := tt.Find("Lauds") + if b.Y <= a.Y || b.X != a.X { + t.Fatalf("vertical layout %v %v", a, b) + } + if err := tt.Click("+3 more"); err != nil || !tt.HasText("Sext") || !tt.HasText("Show fewer") { + t.Fatalf("unfold: %v %q", err, tt.Texts()) + } + if _, ok := tt.Find("Matins"); !ok || tt.Focused("Matins") { + t.Fatal("static legend took focus") + } +} + +func TestChartLegendBad(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for duplicate names") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ChartLegend(c, nil, []ChartLegendItem{{Name: "a"}, {Name: "a"}}, ChartLegendOptions{}) + }, 100, 100) +} + +func TestChartLegendSnapshot(t *testing.T) { + snapshot(t, "chart_legend", 520, 120, func(c *ui.Context) { + hidden := map[string]bool{"Lauds": true} + items := legendItems() + for i := range items { + items[i].Color = internal.Palette(Tokens(c))[i] + } + ui.Column(c).Padding(16).Gap(12).Children(func() { + ChartLegend(c, &hidden, items, ChartLegendOptions{}) + ChartLegend(c, nil, items, ChartLegendOptions{MaxItems: 3}) + }) + }) +} + +func TestChartLegendDisabledAndExpansionEvents(t *testing.T) { + hidden := map[string]bool{} + disabled := false + events := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + r := ChartLegend(c, &hidden, legendItems(), ChartLegendOptions{MaxItems: 2}) + if _, ok := r.Expanded(); ok { + events++ + } + }) + }, 500, 200) + if err := tt.Click("+3 more"); err != nil { + t.Fatal(err) + } + tt.Frame() + if events != 1 || !tt.HasText("Sext") { + t.Fatalf("expansion events %d", events) + } + disabled = true + tt.Frame() + _ = tt.Click("Matins") + _ = tt.Click("Show fewer") + tt.Key(0, ui.KeySpace) + if hidden["Matins"] || events != 1 || !tt.HasText("Sext") { + t.Fatalf("disabled legend %v events %d", hidden, events) + } +} diff --git a/chart_series.go b/chart_series.go index 2e992bc..0856a04 100644 --- a/chart_series.go +++ b/chart_series.go @@ -204,3 +204,15 @@ func chartRuns(vals []float64, x func(i int) float32, f func(idx []int)) { i = j } } + +func chartVisibleIndices(n, limit, active int) []int { + step := max(1, (n+limit-1)/limit) + out := make([]int, 0, min(n, limit+1)) + for i := 0; i < n; i += step { + out = append(out, i) + } + if active >= 0 && active < n && active%step != 0 { + out = append(out, active) + } + return out +} diff --git a/chart_tooltip.go b/chart_tooltip.go new file mode 100644 index 0000000..d760884 --- /dev/null +++ b/chart_tooltip.go @@ -0,0 +1,96 @@ +package mujica + +import ( + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartTooltipLine pairs readout text with an optional marker. +type ChartTooltipLine struct { + Text string + Color ui.Color + Shape int +} + +// ChartTooltip shows readouts beside a window point, avoiding edges. +func ChartTooltip(c *ui.Context, x, y float32, lines []ChartTooltipLine) *ui.Element { + if len(lines) == 0 { + panic("mujica: ChartTooltip needs a line") + } + k := Tokens(c) + u := Density(c).Unit() + size := FontSize(c, theme.CaptionSize) + pad, gap := u*2, float32(12) + w, h := float32(0), pad*2 + lh := make([]float32, len(lines)) + for i, l := range lines { + tw, th := c.MeasureText(0, ui.Span{Text: l.Text, Size: size, Weight: chartTipWeight(i)}) + if l.Color.A > 0 { + tw += 14 + } + w, lh[i] = max(w, tw), th + h += th + } + w += pad * 2 + ww, wh := c.Size() + w, h = min(w, max(1, ww-8)), min(h, max(1, wh-8)) + left, top := overlayPlace(c, ui.Rect{X: x, Y: y}, w, h, gap) + if x+gap+w <= ww-4 { + left = max(4, x+gap) + } else { + left = max(4, min(x-gap-w, ww-4-w)) + } + var tip *ui.Element + ui.Overlay(c, func() { + tip = ui.Column(c).Absolute().Left(left).Top(top).Size(w, h).Role(ui.RoleNone) + overlayStyle(c, tip, overlayEnter(c, tip)) + tip.Children(func() { + inner := max(1, w-2*pad) + content := float32(0) + for i, l := range lines { + width := inner + if l.Color.A > 0 { + width = max(1, width-14) + } + _, lh[i] = c.MeasureText(width, ui.Span{Text: l.Text, Size: size, Weight: chartTipWeight(i)}) + content += lh[i] + } + ui.Scroll(c).Fill().Padding(pad).Children(func() { + ui.Box(c).Width(inner).Height(content).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + at := r.Y + for i, l := range lines { + tx, width := r.X, inner + if l.Color.A > 0 { + internal.Marker(p, l.Shape, tx+5, at+lh[i]/2, 9, l.Color) + tx, width = tx+14, max(1, width-14) + } + p.RichText(tx, at, width, ui.Span{Text: l.Text, Size: size, Weight: chartTipWeight(i), Color: k.Text}) + at += lh[i] + } + }) + }) + }) + }) + return tip +} + +// chartTipWeight sets the first line, the heading, in semibold. +func chartTipWeight(i int) int { + if i == 0 { + return 600 + } + return 400 +} + +// chartTipLines splits a readout into tooltip lines. +func chartTipLines(readout string) []ChartTooltipLine { + var out []ChartTooltipLine + for _, s := range strings.Split(readout, " · ") { + out = append(out, ChartTooltipLine{Text: s}) + } + return out +} diff --git a/chart_tooltip_test.go b/chart_tooltip_test.go new file mode 100644 index 0000000..08f47d0 --- /dev/null +++ b/chart_tooltip_test.go @@ -0,0 +1,103 @@ +package mujica + +import ( + "image/color" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +func TestChartTooltipFlipsAtEdges(t *testing.T) { + var at [2]float32 + var tip ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + tip = ChartTooltip(c, at[0], at[1], []ChartTooltipLine{{Text: "Lauds"}, {Text: "Pews 5", Color: theme.Light().Info, Shape: 1}}).Bounds() + }, 400, 300) + at = [2]float32{50, 50} + tt.Frame() + tt.Frame() + if tip.X != 62 || tip.Y != 62 || tip.W < 40 { + t.Fatalf("tooltip at %v", tip) + } + at = [2]float32{395, 295} + tt.Frame() + tt.Frame() + if tip.X+tip.W > 395-12+0.5 || tip.Y+tip.H > 295-12+0.5 { + t.Fatalf("tooltip did not flip: %v", tip) + } + defer func() { + if recover() == nil { + t.Fatal("no panic without lines") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); ChartTooltip(c, 0, 0, nil) }, 100, 100) +} + +func TestChartTooltipFollowsFocusAndPointer(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) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + tabTo(t, tt, "Chapel") + tt.Key(0, ui.KeyEnd) + if !tooltipShown(tt) { + t.Fatal("no tooltip for the keyboard point") + } + tt.Key(0, ui.KeyEscape) + if tooltipShown(tt) { + t.Fatal("tooltip stayed after Escape") + } + b := h.res.Element.Bounds() + tt.Move(b.X+b.W/2, b.Y+b.H/2) + if !tooltipShown(tt) { + t.Fatal("no tooltip under the pointer") + } +} + +// tooltipShown finds Surface runs longer than marker rings. +func tooltipShown(tt *ui.Tester) bool { + img := tt.Image() + want := toRGBA(theme.Light().Surface) + r := img.Bounds() + for y := r.Min.Y; y < r.Max.Y; y += 2 { + run := 0 + for x := r.Min.X; x < r.Max.X; x++ { + if color.RGBAModel.Convert(img.At(x, y)).(color.RGBA) == want { + if run++; run >= 30 { + return true + } + } else { + run = 0 + } + } + } + return false +} + +func TestChartTooltipSnapshot(t *testing.T) { + snapshot(t, "chart_tooltip", 320, 140, func(c *ui.Context) { + pal := internal.Palette(Tokens(c)) + ChartTooltip(c, 20, 20, []ChartTooltipLine{{Text: "Lauds"}, {Text: "Pews 5", Color: pal[0]}, {Text: "Candles 2", Color: pal[1], Shape: 1}}) + }) +} + +func TestChartTooltipFitsLongContent(t *testing.T) { + var b ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + lines := make([]ChartTooltipLine, 40) + for i := range lines { + lines[i].Text = "A very long series name that wraps across the tooltip panel" + } + b = ChartTooltip(c, 159, 99, lines).Bounds() + }, 160, 100) + tt.Frame() + if b.X < 4 || b.Y < 4 || b.X+b.W > 156 || b.Y+b.H > 96 { + t.Fatalf("tooltip outside window: %v", b) + } +} diff --git a/chord_diagram.go b/chord_diagram.go new file mode 100644 index 0000000..078e4c9 --- /dev/null +++ b/chord_diagram.go @@ -0,0 +1,181 @@ +package mujica + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChordDiagramOptions configure a ChordDiagram. +type ChordDiagramOptions struct { + Frame ChartFrame + Format func(v float64) string +} + +// chordLayout stores group arcs and relation endpoints. +type chordLayout struct { + arcs [][2]float64 + sub [][][2]float64 + pairs [][2]int + total float64 +} + +// chordGap is the angle left between group arcs. +const chordGap = 0.04 + +func chordPlace(matrix [][]float64) chordLayout { + n := len(matrix) + for _, row := range matrix { + if len(row) != n { + panic("mujica: ChordDiagram matrix must be square") + } + } + l := chordLayout{arcs: make([][2]float64, n), sub: make([][][2]float64, n)} + sums := make([]float64, n) + for i, row := range matrix { + if len(row) != n { + panic("mujica: ChordDiagram matrix must be square") + } + for j, v := range row { + if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) { + panic("mujica: ChordDiagram values must be finite and ≥ 0") + } + sums[i] += v + if j >= i && v+matrix[j][i] > 0 { + l.pairs = append(l.pairs, [2]int{i, j}) + } + } + l.total += sums[i] + } + if math.IsInf(l.total, 0) { + panic("mujica: ChordDiagram total must be finite") + } + if l.total == 0 { + return l + } + gap := min(chordGap, math.Pi/float64(n)) + span := 2*math.Pi - gap*float64(n) + a := 0.0 + for i := range n { + l.arcs[i] = [2]float64{a, a + span*sums[i]/l.total} + l.sub[i] = make([][2]float64, n) + at := a + for j := range n { + w := span * matrix[i][j] / l.total + l.sub[i][j] = [2]float64{at, at + w} + at += w + } + a = l.arcs[i][1] + gap + } + return l +} + +// ChordDiagram connects group arcs and highlights read relationships. +func ChordDiagram(c *ui.Context, names []string, matrix [][]float64, opts ChordDiagramOptions) ChartResult { + if len(names) != len(matrix) { + panic("mujica: ChordDiagram needs a name per matrix row") + } + seen := map[string]bool{} + for _, name := range names { + if name == "" || seen[name] { + panic("mujica: ChordDiagram groups need unique names") + } + seen[name] = true + } + ch := newChart(c, opts.Frame) + l := chartMemo(ch, "chords", chartID(matrix), func() chordLayout { return chordPlace(matrix) }) + k := Tokens(c) + pal := internal.Palette(k) + size := FontSize(c, theme.CaptionSize) + val := func(v float64) string { return chartValue(c, v, opts.Format) } + n := len(names) + len(l.pairs) + if l.total == 0 { + n = 0 + } + geom := func(r ui.Rect) (cx, cy, rad float32) { + return r.X + r.W/2, r.Y + r.H/2, min(r.W, r.H)/2 - 30 + } + visible := ui.Local(ch.res.Element, "mujica.chord.visible", func() []int { return nil }) + spec := ChartSpec{Marks: n} + spec.Readout = func(i int) string { + if i < len(names) { + out, in := 0.0, 0.0 + for j := range names { + out += matrix[i][j] + in += matrix[j][i] + } + return names[i] + " · " + msg(c, "chord_diagram.out") + " " + val(out) + " · " + msg(c, "chord_diagram.in") + " " + val(in) + } + p := l.pairs[i-len(names)] + return names[p[0]] + " → " + names[p[1]] + " " + val(matrix[p[0]][p[1]]) + " · " + names[p[1]] + " → " + names[p[0]] + " " + val(matrix[p[1]][p[0]]) + } + 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))) + a := math.Atan2(float64(x-cx), float64(cy-y)) + if a < 0 { + a += 2 * math.Pi + } + if d >= rad-2 && d <= rad+14 { + for i, arc := range l.arcs { + if a >= arc[0] && a < arc[1] { + return i + } + } + } + return -1 + } + spec.Draw = func(p *ui.Painter, r ui.Rect, active int) { + cx, cy, rad := geom(r) + lit := func(pair [2]int) bool { + switch { + case active < 0: + return true + case active < len(names): + return pair[0] == active || pair[1] == active + } + return l.pairs[active-len(names)] == pair + } + *visible = chartVisibleIndices(len(l.pairs), max(1, int(rad*4)), active-len(names)) + for _, pi := range *visible { + pair := l.pairs[pi] + i, j := pair[0], pair[1] + s, t := l.sub[i][j], l.sub[j][i] + var path ui.Path + internal.Arc(&path, cx, cy, rad, s[0], s[1], true, p) + tx, ty := internal.Polar(cx, cy, rad, t[0]) + path.QuadTo(internal.Snap(p, cx), internal.Snap(p, cy), internal.Snap(p, tx), internal.Snap(p, ty)) + internal.Arc(&path, cx, cy, rad, t[0], t[1], false, p) + sx, sy := internal.Polar(cx, cy, rad, s[0]) + path.QuadTo(internal.Snap(p, cx), internal.Snap(p, cy), internal.Snap(p, sx), internal.Snap(p, sy)).Close() + alpha := float32(0.45) + if !lit(pair) { + alpha = 0.08 + } + p.FillPath(&path, pal[i%len(pal)].Alpha(alpha)) + if lit(pair) { + p.StrokePath(&path, 1, pal[i%len(pal)]) + } + if active == len(names)+pi { + p.StrokePath(&path, 1.5, k.Text) + } + } + for i, arc := range l.arcs { + grow := float32(0) + if i == active { + grow = 4 + } + p.FillPath(internal.Sector(cx, cy, rad+2, rad+12+grow, arc[0], arc[1], p), pal[i%len(pal)]) + if arc[1]-arc[0] > 0.15 { + lx, ly := internal.Polar(cx, cy, rad+20, (arc[0]+arc[1])/2) + ax := 0.5 - 0.5*float32(math.Sin((arc[0]+arc[1])/2)) + chartText(p, names[i], lx, ly, size, k.Text, ax, 0.5+0.5*float32(math.Cos((arc[0]+arc[1])/2))) + } + } + } + res, _ := ch.build(spec) + return res +} diff --git a/chord_diagram_test.go b/chord_diagram_test.go new file mode 100644 index 0000000..156f6d4 --- /dev/null +++ b/chord_diagram_test.go @@ -0,0 +1,111 @@ +package mujica + +import ( + "fmt" + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +var chordNames = []string{"Nave", "Choir", "Crypt", "Cloister"} +var chordMatrix = [][]float64{{0, 5, 2, 3}, {4, 0, 1, 0}, {1, 2, 0, 6}, {2, 0, 3, 0}} + +func TestChordPlace(t *testing.T) { + l := chordPlace(chordMatrix) + if l.total != 29 || len(l.pairs) != 5 { + t.Fatalf("total %v pairs %v", l.total, l.pairs) + } + span := 2*math.Pi - 4*chordGap + if got := l.arcs[0][1] - l.arcs[0][0]; math.Abs(got-span*10/29) > 1e-9 { + t.Fatalf("Nave arc %v", got) + } + if l.sub[0][1][1]-l.sub[0][1][0] <= 0 || l.sub[1][3][1] != l.sub[1][3][0] { + t.Fatal("sub-arcs not sized by the matrix") + } +} + +func TestChordDiagramReadout(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return ChordDiagram(c, chordNames, chordMatrix, ChordDiagramOptions{Frame: ChartFrame{Label: "Paths"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Paths") + tt.Key(0, ui.KeyRight) + if got := chartReadout(tt); got != "Nave · out 10 · in 7" { + t.Fatalf("group %q", got) + } + tt.Key(0, ui.KeyEnd) + if got := chartReadout(tt); got != "Crypt → Cloister 6 · Cloister → Crypt 3" { + t.Fatalf("chord %q", got) + } + tt.Key(0, ui.KeyEscape) + b := h.res.Element.Bounds() + cx, cy, rad := b.X+b.W/2, b.Y+120, float32(240)/2-30 + tt.Move(cx+(rad+6)*0.5, cy-(rad+6)*0.866) // 30°: inside the first arc + if h.res.Point != 0 { + t.Fatalf("hover %d", h.res.Point) + } +} + +func TestChordDiagramBad(t *testing.T) { + for name, m := range map[string][][]float64{"ragged": {{1, 2}, {1}}, "negative": {{0, -1}, {1, 0}}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ChordDiagram(c, []string{"a", "b"}, m, ChordDiagramOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) + }() + } +} + +func TestChordDiagramSnapshot(t *testing.T) { + snapshot(t, "chord_diagram", 520, 340, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ChordDiagram(c, chordNames, chordMatrix, ChordDiagramOptions{Frame: ChartFrame{Label: "Paths"}}) + }) + }) +} + +func TestChordPlaceSelfRelation(t *testing.T) { + l := chordPlace([][]float64{{3}}) + if len(l.pairs) != 1 || l.pairs[0] != [2]int{0, 0} { + t.Fatalf("self relation %v", l.pairs) + } +} + +func TestChordDiagramDenseDataBoundedAndDisabled(t *testing.T) { + const n = 450 + names := make([]string, n) + matrix := make([][]float64, n) + for i := range matrix { + names[i] = fmt.Sprint(i) + matrix[i] = make([]float64, n) + for j := range matrix[i] { + matrix[i][j] = 1 + } + } + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return ChordDiagram(c, names, matrix, ChordDiagramOptions{Frame: ChartFrame{Label: "Dense chords"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + shown := ui.Local(h.res.Element, "mujica.chord.visible", func() []int { return nil }) + if len(*shown) == 0 || len(*shown) > 360 { + t.Fatalf("built %d chords", len(*shown)) + } + tabTo(t, tt, "Dense chords") + tt.Key(0, ui.KeyEnd) + if h.res.Point != n+n*(n+1)/2-1 || len(*shown) > 361 { + t.Fatalf("last relation %d, built %d", h.res.Point, len(*shown)) + } + h.disabled = true + tt.Frame() + if h.res.Point != -1 { + t.Fatalf("disabled chord %d", h.res.Point) + } +} diff --git a/density_plot.go b/density_plot.go new file mode 100644 index 0000000..5ae92ec --- /dev/null +++ b/density_plot.go @@ -0,0 +1,152 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" +) + +// DensityPlotOptions configure a DensityPlot. +type DensityPlotOptions struct { + Frame ChartFrame + // Bandwidth defaults to the per-group Gaussian estimate. + Bandwidth float64 + // Points defaults to 128 samples per curve. + Points int + Format func(v float64) string +} + +// densitySample is finite values, sorted out of NaN and infinities. +func densitySample(values []float64) []float64 { + var out []float64 + for _, v := range values { + if math.IsInf(v, 0) { + panic("mujica: density values must be finite or NaN") + } + if !math.IsNaN(v) { + out = append(out, v) + } + } + return out +} + +// densityBandwidth estimates spread, using 1 for flat samples. +func densityBandwidth(v []float64) float64 { + n := float64(len(v)) + mean := 0.0 + for _, x := range v { + mean += x + } + mean /= n + sd := 0.0 + for _, x := range v { + sd += (x - mean) * (x - mean) + } + sd = math.Sqrt(sd / max(n-1, 1)) + if sd == 0 { + return 1 + } + return 1.06 * sd * math.Pow(n, -0.2) +} + +// densityKDE estimates Gaussian density at xs. +func densityKDE(v []float64, h float64, xs []float64) []float64 { + out := make([]float64, len(xs)) + norm := 1 / (float64(len(v)) * h * math.Sqrt(2*math.Pi)) + for i, x := range xs { + s := 0.0 + for _, p := range v { + u := (x - p) / h + s += math.Exp(-0.5 * u * u) + } + out[i] = s * norm + } + return out +} + +// densityCurves samples groups on a shared padded grid. +func densityCurves(groups []BoxGroup, bandwidth float64, points int) (xs []float64, series []ChartSeries) { + lo, hi := math.Inf(1), math.Inf(-1) + samples := make([][]float64, len(groups)) + widths := make([]float64, len(groups)) + for i, g := range groups { + samples[i] = densitySample(g.Values) + if len(samples[i]) == 0 { + continue + } + widths[i] = bandwidth + if widths[i] == 0 { + widths[i] = densityBandwidth(samples[i]) + } + for _, v := range samples[i] { + lo, hi = min(lo, v-3*widths[i]), max(hi, v+3*widths[i]) + } + } + if math.IsInf(lo, 1) { + return nil, nil + } + xs = make([]float64, points) + for i := range xs { + xs[i] = lo + (hi-lo)*float64(i)/float64(points-1) + } + for i, g := range groups { + ys := make([]float64, points) + if len(samples[i]) > 0 { + ys = densityKDE(samples[i], widths[i], xs) + } + series = append(series, ChartSeries{Name: g.Name, Values: ys}) + } + return xs, series +} + +// DensityPlot overlays groups on a shared density axis. +func DensityPlot(c *ui.Context, groups []BoxGroup, opts DensityPlotOptions) ChartResult { + if opts.Bandwidth < 0 || math.IsNaN(opts.Bandwidth) || math.IsInf(opts.Bandwidth, 0) || opts.Points < 0 || opts.Points == 1 { + panic("mujica: DensityPlot needs Bandwidth ≥ 0 and Points of 0 or 2 or more") + } + points := opts.Points + if points == 0 { + points = 128 + } + ch := newChart(c, opts.Frame) + ids := "" + seen := map[string]bool{} + for _, g := range groups { + if g.Name == "" || seen[g.Name] { + panic("mujica: DensityPlot groups need unique names") + } + seen[g.Name] = true + ids += g.Name + chartID(g.Values) + ";" + } + type curves struct { + xs []float64 + series []ChartSeries + } + cv := chartMemo(ch, "kde", fmt.Sprint(ids, opts.Bandwidth, points), func() curves { + xs, s := densityCurves(groups, opts.Bandwidth, points) + return curves{xs, s} + }) + n := len(cv.xs) + series := cv.series + if n == 0 { + series = nil + } + tops, bases := make([][]float64, len(series)), make([][]float64, len(series)) + for i, s := range series { + tops[i], bases[i] = s.Values, make([]float64, n) + } + var ax chartX + if n > 0 { + ax = chartMemo(ch, "x", ids+fmt.Sprint(opts.Bandwidth, points), func() chartX { return newChartX(n, cv.xs, nil) }) + } + lo, hi, ok := chartYRange(ch, series, true, nil) + if !ok { + n = 0 + } + format := opts.Format + if format == nil { + format = func(v float64) string { return fmt.Sprintf("%.3g", v) } + } + return chartLines(ch, chartLineData{series: series, tops: tops, bases: bases, ax: ax, lo: lo, hi: hi, n: n, format: format}) +} diff --git a/density_plot_test.go b/density_plot_test.go new file mode 100644 index 0000000..4bd901d --- /dev/null +++ b/density_plot_test.go @@ -0,0 +1,121 @@ +package mujica + +import ( + "math" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func densityGroups() []BoxGroup { + a, b := make([]float64, 200), make([]float64, 200) + for i := range a { + u := float64(i)/200 - 0.5 + a[i] = 40 + 30*u*math.Abs(u) + b[i] = 60 + 12*math.Sin(float64(i)) + } + return []BoxGroup{{Name: "Matins", Values: a}, {Name: "Vespers", Values: b}} +} + +func TestDensityKDEIntegratesToOne(t *testing.T) { + v := []float64{1, 2, 2, 3, 7} + xs := make([]float64, 2001) + for i := range xs { + xs[i] = -10 + 30*float64(i)/2000 + } + d := densityKDE(v, densityBandwidth(v), xs) + sum := 0.0 + for _, y := range d { + sum += y * 30 / 2000 + } + if math.Abs(sum-1) > 0.01 { + t.Fatalf("density integrates to %v", sum) + } + if bw := densityBandwidth([]float64{4, 4}); bw != 1 { + t.Fatalf("flat bandwidth %v", bw) + } +} + +func TestDensityPlotReadoutAndBandwidth(t *testing.T) { + bw := 0.0 + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return DensityPlot(c, densityGroups(), DensityPlotOptions{Frame: ChartFrame{Label: "Ages"}, Bandwidth: bw, Points: 64}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Ages") + tt.Key(0, ui.KeyRight) + for range 31 { + tt.Key(0, ui.KeyRight) + } + first := chartReadout(tt) + if !strings.Contains(first, "Matins ") || !strings.Contains(first, "Vespers ") { + t.Fatalf("readout %q", first) + } + bw = 1 + tt.Frame() + if chartReadout(tt) == first { + t.Fatal("bandwidth did not change the curve") + } + if err := tt.Click("Vespers"); err != nil || strings.Contains(chartReadout(tt), "Vespers") { + t.Fatalf("legend did not hide a group: %q", chartReadout(tt)) + } +} + +func TestDensityPlotBadAndEmpty(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return DensityPlot(c, []BoxGroup{{Name: "a", Values: []float64{math.NaN()}}}, DensityPlotOptions{Frame: ChartFrame{Label: "Ages"}}) + }} + if tt := ui.NewTester(h.view, 520, 420); !tt.HasText("No data") { + t.Fatal("NaN sample is not empty") + } + defer func() { + if recover() == nil { + t.Fatal("no panic for Points 1") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + DensityPlot(c, densityGroups(), DensityPlotOptions{Frame: ChartFrame{Label: "x"}, Points: 1}) + }, 300, 300) +} + +func TestDensityPlotSnapshot(t *testing.T) { + snapshot(t, "density_plot", 520, 340, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + DensityPlot(c, densityGroups(), DensityPlotOptions{Frame: ChartFrame{Label: "Ages"}}) + }) + }) +} + +func TestDensityPlotFiniteBandwidthAndNames(t *testing.T) { + charts2MustPanic(t, func(c *ui.Context) { + DensityPlot(c, densityGroups(), DensityPlotOptions{Frame: ChartFrame{Label: "Density"}, Bandwidth: math.Inf(1)}) + }) + charts2MustPanic(t, func(c *ui.Context) { + DensityPlot(c, []BoxGroup{{Name: "A"}, {Name: "A"}}, DensityPlotOptions{Frame: ChartFrame{Label: "Density"}}) + }) +} + +func TestDensityPlotLargeDataBoundedAndDisabled(t *testing.T) { + values := make([]float64, 100_000) + for i := range values { + values[i] = float64(i % 100) + } + groups := []BoxGroup{{Name: "Sample", Values: values}} + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return DensityPlot(c, groups, DensityPlotOptions{Frame: ChartFrame{Label: "Large density"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Large density") + tt.Key(0, ui.KeyEnd) + if h.res.Point != 127 { + t.Fatalf("built sample index %d", h.res.Point) + } + h.disabled = true + tt.Frame() + tt.Key(0, ui.KeyLeft) + if h.res.Point != -1 { + t.Fatalf("disabled density %d", h.res.Point) + } +} diff --git a/docs/components/121-chart-legend.md b/docs/components/121-chart-legend.md new file mode 100644 index 0000000..00648b1 --- /dev/null +++ b/docs/components/121-chart-legend.md @@ -0,0 +1,38 @@ +# 121 ChartLegend 图例 + +## 用途 + +展示序列名称与形状;点击或键盘切换序列显隐,支持横纵布局和折叠。 + +## 最小示例 + +```go +hidden := map[string]bool{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + k := mujica.Tokens(c) + items := []mujica.ChartLegendItem{{Name: "Pews", Color: k.Info}, {Name: "Candles", Color: k.Success, Shape: 1}} + mujica.ChartLegend(c, &hidden, items, mujica.ChartLegendOptions{MaxItems: 1}) +} +ui.Render(view, 400, 200, 1) +``` + +## 参数 + +`hidden *map[string]bool`:名称映射到隐藏状态,nil 为静态图例。`items []ChartLegendItem`:唯一非空 `Name`、`Color`、`Shape`(0–4)。`Vertical` 默认 false;`MaxItems` 默认 0,显示全部,负值 panic。 + +## 状态 + +可见项显示形状,隐藏项显示空框与删除线;支持祖先禁用。折叠后显示展开按钮。 + +## 事件 + +`Toggled()` 返回本帧切换的序列名称和是否发生事件。 + +## 键盘操作 + +Tab 进入图例;方向键在项之间移动;Space 切换显隐;展开按钮支持 Enter 和 Space。 + +## 限制 + +调用方依据 hidden 控制数据序列。图例颜色来自调用方,应使用主题色;形状提供颜色之外的识别。 diff --git a/docs/components/122-chart-tooltip.md b/docs/components/122-chart-tooltip.md new file mode 100644 index 0000000..9d3e9bc --- /dev/null +++ b/docs/components/122-chart-tooltip.md @@ -0,0 +1,36 @@ +# 122 ChartTooltip 读数浮层 + +## 用途 + +在指针或键盘数据点旁显示多序列读数,自动避让窗口边缘。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + k := mujica.Tokens(c) + mujica.ChartTooltip(c, 40, 40, []mujica.ChartTooltipLine{{Text: "Lauds"}, {Text: "Pews 5", Color: k.Info}, {Text: "Candles 2", Color: k.Success, Shape: 1}}) +} +ui.Render(view, 400, 200, 1) +``` + +## 参数 + +`x, y float32`:窗口 DIP 坐标。`lines []ChartTooltipLine` 必须非空;`Text` 为读数,`Color` 为标记色,透明色隐藏标记;`Shape` 为标记形状。 + +## 状态 + +160 ms 进入过渡;减少动画直接出现。宽度和高度受窗口限制,长文字换行,内容可滚动。 + +## 事件 + +返回浮层 `*ui.Element`;读数事件由所属 Chart 提供。 + +## 键盘操作 + +通过 `Chart` 使用时,方向键改变锚点,Escape 清除浮层;浮层内容沿用滚动容器操作。 + +## 限制 + +调用方传入当前读数和锚点,负责显示时机;所属图表应同时显示并朗读读数。 diff --git a/docs/components/123-chart-crosshair.md b/docs/components/123-chart-crosshair.md new file mode 100644 index 0000000..93c7be7 --- /dev/null +++ b/docs/components/123-chart-crosshair.md @@ -0,0 +1,43 @@ +# 123 ChartCrosshair 十字光标 + +## 用途 + +绘制横纵十字线和轴侧读数标签,提供最近数据点吸附。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10}) + y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100}) + hair := mujica.ChartCrosshair(c, mujica.ChartCrosshairOptions{X: &x, Y: &y}) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60} + x.Draw(p, plot) + y.Draw(p, plot) + hair.Draw(p, plot, x.Map(plot, 5), y.Map(plot, 70)) + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`X, Y *ChartAxisPart`:至少提供一条轴。`Reach` 默认 24 DIP,负值 panic。返回 `ChartCrosshairPart`;`Snap(n, at, x, y)` 返回范围内最近索引,范围外为 -1。 + +## 状态 + +静态部件;随调用方传入的坐标变化。移动路径使用像素网格对齐。 + +## 事件 + +吸附返回索引;读数变化使用所属 `ChartResult.Changed()`。 + +## 键盘操作 + +与 Chart 的 `Pick`、`Anchor`、`Readout` 组合,方向键逐点显示并朗读数值。 + +## 限制 + +`Draw` 使用窗口坐标,`Snap` 和 at 必须采用相同坐标系;轴部件在每帧创建。 diff --git a/docs/components/124-chart-axis.md b/docs/components/124-chart-axis.md new file mode 100644 index 0000000..62a17c6 --- /dev/null +++ b/docs/components/124-chart-axis.md @@ -0,0 +1,38 @@ +# 124 ChartAxis 坐标轴 + +## 用途 + +提供数值、时间、类别和对数刻度,以及值和像素之间的映射。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + axis := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Kind: mujica.AxisLog, Min: 1, Max: 1000}) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + axis.Draw(p, ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}) + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`Kind` 默认 `AxisLinear`;`Side` 默认 `SideBottom`。数值与对数轴需要有限 `Min < Max`,对数轴 `Min > 0`。时间轴需要 `From < To`。类别轴需要非空 `Categories`。`Ticks` 为递增且位于范围内的指定刻度,nil 自动生成。`Format func(float64) string` 自定义标签。 + +## 状态 + +展示部件,继承当前主题、文本缩放和字体。 + +## 事件 + +`Map`、`Invert`、`Ticks`、`Label` 返回映射与标签;组件自身没有业务事件。 + +## 键盘操作 + +所属 Chart 负责聚焦、方向键和读数。坐标轴作为绘图部件随图表展示。 + +## 限制 + +为标签预留边距。时间值采用 Unix 秒,默认格式使用 UTC;需要本地时区时通过 Format 格式化。数据和刻度切片在使用期间保持稳定。 diff --git a/docs/components/125-chart-grid.md b/docs/components/125-chart-grid.md new file mode 100644 index 0000000..490bdf8 --- /dev/null +++ b/docs/components/125-chart-grid.md @@ -0,0 +1,43 @@ +# 125 ChartGrid 图表网格 + +## 用途 + +按坐标轴主刻度和次刻度绘制对齐设备像素的网格线。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10}) + y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100}) + grid := mujica.ChartGrid(c, mujica.ChartGridOptions{X: &x, Y: &y, Minor: 2}) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60} + grid.Draw(p, plot) + x.Draw(p, plot) + y.Draw(p, plot) + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`X, Y *ChartAxisPart` 至少一条轴;`Minor` 默认 0,正整数表示每个主刻度间隔的等分数。类别轴的 Minor 必须为 0。负值 panic。 + +## 状态 + +展示部件;主网格使用 Border,次网格使用较淡的 Border。 + +## 事件 + +返回 `ChartGridPart`,由 `Draw(p, plot)` 绘制。 + +## 键盘操作 + +所属图表的键盘操作保持生效;网格随图表展示。 + +## 限制 + +对数轴次刻度按对数距离等分。大范围刻度按绘图像素密度抽样绘制。 diff --git a/docs/components/126-chart-brush.md b/docs/components/126-chart-brush.md new file mode 100644 index 0000000..6c35456 --- /dev/null +++ b/docs/components/126-chart-brush.md @@ -0,0 +1,36 @@ +# 126 ChartBrush 区间刷选 + +## 用途 + +选择图表可见区间,支持移动窗口、调整两端和拖动新建范围。 + +## 最小示例 + +```go +value := [2]float64{20, 60} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChartBrush(c, &value, mujica.ChartBrushOptions{Label: "Months", Max: 100, Step: 1, Values: []float64{2, 4, 3, 8, 5}}) +} +ui.Render(view, 400, 160, 1) +``` + +## 参数 + +`value *[2]float64` 为域内严格递增区间;`Label` 必填;有限 `Min < Max`。`Step` 默认跨度的 1/100,负值 panic。`Values` 为预览折线。`Height` 默认 48 DIP,显式高度至少 28 DIP。`Format` 自定义值文本。`Disabled` 默认 false。 + +## 状态 + +显示选区边框与两端手柄;自身或祖先禁用时保持区间。拖动窗口到边缘保持跨度。 + +## 事件 + +返回 `ChoiceResult`;`Changed()` 表示区间变化;新范围会被朗读,调用方据此调整图表范围。 + +## 键盘操作 + +Tab 按起点、窗口、终点顺序聚焦;方向键移动一个步长;手柄 PageUp/PageDown 移动十个步长;Home/End 到域边界。 + +## 限制 + +范围至少保留一个 Step。预览切片按身份缓存,更新数据时替换切片。调用方负责图表缩放和查询。 diff --git a/docs/components/127-chart-annotation.md b/docs/components/127-chart-annotation.md new file mode 100644 index 0000000..682bd16 --- /dev/null +++ b/docs/components/127-chart-annotation.md @@ -0,0 +1,47 @@ +# 127 ChartAnnotation 图表标注 + +## 用途 + +在图表上显示水平线、垂直线、区间带和文字标注,可选择开启移动。 + +## 最小示例 + +```go +notes := []mujica.ChartNote{{Kind: mujica.NoteHLine, Value: 50, Text: "Goal", Draggable: true}, {Kind: mujica.NoteBand, Value: 6, End: 8, Text: "Lent"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10}) + y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100}) + annotation := mujica.ChartAnnotation(c, notes, mujica.ChartAnnotationOptions{X: &x, Y: &y}) + box := ui.Box(c).Fill().Focusable().Label("Annotations") + b := box.Bounds() + annotation.Drag(box, ui.Rect{X: 60, Y: 20, W: b.W - 80, H: b.H - 60}) + box.Draw(func(p *ui.Painter, r ui.Rect) { + plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60} + x.Draw(p, plot) + y.Draw(p, plot) + annotation.Draw(p, plot) + }) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`notes []ChartNote`:`Kind` 默认为 `NoteHLine`;`Value` 是水平线 Y 或其他标注 X;`End` 是带末端 X 或文字 Y;`Text` 是文案;`Draggable` 默认 false。`X, Y` 均必填;坐标必须有限,带需要 End > Value,文字需要非空 Text。 + +## 状态 + +先绘制区间带,再绘制线和文字;可移动线带有菱形手柄。祖先禁用阻止移动。 + +## 事件 + +`Drag(element, plot)` 处理指针和键盘,修改原切片并返回是否发生变化。`Draw` 绘制当前标注。 + +## 键盘操作 + +Tab 聚焦宿主图表;Alt+PageUp/PageDown 选择可移动标注;Alt+方向键每次移动轴跨度的 1/100。选中说明和变化会被朗读。 + +## 限制 + +Drag 的 plot 是元素局部坐标;Draw 的 plot 是窗口坐标。标注切片由调用方持有;移动后调用方负责业务保存。 diff --git a/docs/components/128-chart-export.md b/docs/components/128-chart-export.md new file mode 100644 index 0000000..9286b82 --- /dev/null +++ b/docs/components/128-chart-export.md @@ -0,0 +1,45 @@ +# 128 ChartExport 图表导出 + +## 用途 + +以当前窗口主题离屏绘制图表,返回 PNG 字节,供调用方保存或传输。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + data, err := mujica.ChartExport(c, func(c *ui.Context) { + mujica.SparkBar(c, []float64{3, -2, 5}, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}}) + }, mujica.ChartExportOptions{Width: 300, Height: 100, Scale: 2}) + if err != nil { + panic(err) + } + bitmap, err := ui.DecodeBitmap(data) + if err != nil { + panic(err) + } + ui.Image(c, bitmap).Size(300, 100) +} +ui.Render(view, 400, 160, 1) +``` + +## 参数 + +`chart func(*ui.Context)` 必填;`Width, Height int` 为正 DIP 尺寸;有限 `Scale float32` 默认 2,负值 panic。 + +## 状态 + +沿用当前主题、密度、本地化、自定义 token 和桌面偏好。导出过程为同步渲染。 + +## 事件 + +返回 `([]byte, error)`;PNG 编码错误直接返回调用方。 + +## 键盘操作 + +导出触发按钮由调用方提供,支持 Enter 和 Space。 + +## 限制 + +图表回调应独立渲染,复用调用方数据;调用方在用户触发导出时调用以控制开销。组件只返回字节。 diff --git a/docs/components/129-violin-plot.md b/docs/components/129-violin-plot.md new file mode 100644 index 0000000..25e5c94 --- /dev/null +++ b/docs/components/129-violin-plot.md @@ -0,0 +1,36 @@ +# 129 ViolinPlot 小提琴图 + +## 用途 + +比较多组样本的核密度轮廓,中位数以菱形标记,四分位以粗条表示。 + +## 最小示例 + +```go +groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 7, 9}}, {Name: "Nave", Values: []float64{2, 4, 5, 6, 8}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ViolinPlot(c, groups, mujica.ViolinPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}, Bandwidth: 1}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`groups []BoxGroup`:唯一非空 Name 与样本 Values;NaN 为缺值,无穷值 panic。`Frame` 指定名称、高度、状态和占位文案。有限 `Bandwidth` 默认 0,按组自动估计;负值 panic。`Format` 自定义数值读数。 + +## 状态 + +默认、悬停、键盘读数、加载、错误、空数据、祖先禁用。单值或相同值样本保留密度轮廓。 + +## 事件 + +`ChartResult.Point` 为组索引;`Changed()` 表示读数组变化;`Retried()` 表示重试。 + +## 键盘操作 + +Tab 聚焦;方向键逐组读取中位数和四分位;Home/End 首尾;Escape 清除读数。 + +## 限制 + +每组轮廓使用 64 个采样点;大量组按像素位置抽样绘制,当前读数组保留。排序和核密度在样本切片身份或带宽变化时计算;更新数据需替换切片。 diff --git a/docs/components/130-parallel-coordinates.md b/docs/components/130-parallel-coordinates.md new file mode 100644 index 0000000..220d642 --- /dev/null +++ b/docs/components/130-parallel-coordinates.md @@ -0,0 +1,38 @@ +# 130 ParallelCoordinates 平行坐标 + +## 用途 + +在多条平行轴间展示每行数值;轴上刷选筛除行,当前读数行用粗线高亮。 + +## 最小示例 + +```go +filters := map[string][2]float64{} +axes := []string{"Age", "Hymns", "Vigils"} +rows := [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ParallelCoordinates(c, &filters, axes, rows, mujica.ParallelCoordinatesOptions{Frame: mujica.ChartFrame{Label: "Monks"}, Names: []string{"Agnes", "Clare", "Hild"}}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`filters *map[string][2]float64` 必填,键为轴名,值为有限递增范围。`axes []string` 至少两个唯一非空名称;每行长度与轴数一致。NaN 为缺值。`Names` 为逐行名称,nil 显示序号。`Frame` 与 `Format` 同其他图表。 + +## 状态 + +默认、高亮、筛除、加载、错误、空数据、祖先禁用;筛除行的读数附带本地化文字。 + +## 事件 + +`Changed()` 表示读数行变化;`ValueChanged()` 表示刷选范围变化;`Retried()` 表示重试。 + +## 键盘操作 + +Tab 聚焦;方向键逐行读数;Home/End 首尾。Alt+PageUp/PageDown 选择轴;Alt+上下调整下端;Alt+Shift+上下调整上端;Alt+Delete 清除该轴刷选。 + +## 限制 + +指针拖动轴设置范围,轴上点击清除范围。100,000 行绘制数量受图表高度约束,键盘可读取全部行。范围与过滤仅在数据或筛选变化时计算;更新数据需替换外层 rows 切片。 diff --git a/docs/components/131-chord-diagram.md b/docs/components/131-chord-diagram.md new file mode 100644 index 0000000..3dc1776 --- /dev/null +++ b/docs/components/131-chord-diagram.md @@ -0,0 +1,37 @@ +# 131 ChordDiagram 弦图 + +## 用途 + +以组弧和弦带展示关系矩阵,悬停或键盘读数组时高亮相关连线。 + +## 最小示例 + +```go +names := []string{"Nave", "Choir", "Crypt"} +matrix := [][]float64{{0, 5, 2}, {4, 0, 1}, {1, 2, 0}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChordDiagram(c, names, matrix, mujica.ChordDiagramOptions{Frame: mujica.ChartFrame{Label: "Paths"}}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`names []string` 为唯一非空组名;`matrix [][]float64` 为同阶方阵,数值须有限且非负。`Frame` 控制名称、尺寸和加载状态。`Format` 自定义关系数值。 + +## 状态 + +默认、组高亮、弦高亮、加载、错误、空矩阵、祖先禁用;对角线值形成自身关系。 + +## 事件 + +`Point` 先枚举组,再枚举关系;`Changed()` 表示读数变化;`Retried()` 表示重试。 + +## 键盘操作 + +Tab 聚焦;方向键依次读取组的流入流出与关系值;Home/End 首尾;Escape 清除。 + +## 限制 + +高密度矩阵按绘图区半径抽样绘制弦带,当前键盘关系保留;布局仅在外层 matrix 切片身份变化时计算。更新数据需替换外层切片。组弧标签在有足够空间时展示,完整名称始终包含在键盘读数中。 diff --git a/docs/components/132-spark-bar.md b/docs/components/132-spark-bar.md new file mode 100644 index 0000000..dd6644a --- /dev/null +++ b/docs/components/132-spark-bar.md @@ -0,0 +1,36 @@ +# 132 SparkBar 迷你柱 + +## 用途 + +行内展示正负数值柱,零线区分方向,末项以强调颜色和菱形标记。 + +## 最小示例 + +```go +values := []float64{3, -2, 4, 1, -3} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SparkBar(c, values, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}, BarWidth: 10}) +} +ui.Render(view, 400, 100, 1) +``` + +## 参数 + +`values []float64`:有限数值,NaN 表示缺值。`Frame.Label` 必填;`Frame.Height` 默认 28 DIP。`Frame.Status`、`Error`、`Empty` 控制占位。`BarWidth` 默认 6 DIP,负值 panic;整体宽度受窗口限制。`Format` 自定义读数。 + +## 状态 + +默认显示末值;悬停或聚焦显示当前点。加载、错误与空数据保持绘图区尺寸;祖先禁用阻止操作。 + +## 事件 + +返回 `ChartResult`,`Point` 为当前索引,`Changed()` 表示读数变化,`Retried()` 表示重试。 + +## 键盘操作 + +Tab 聚焦;左右箭头逐点读数并朗读;Home/End 到首尾;Escape 恢复末值。 + +## 限制 + +大数据按像素列保留首、极值和末项,绘制数量受宽度约束。数据范围与抽样缓存按切片身份更新;更换数据时替换切片。 diff --git a/docs/components/133-trend-indicator.md b/docs/components/133-trend-indicator.md new file mode 100644 index 0000000..173ccde --- /dev/null +++ b/docs/components/133-trend-indicator.md @@ -0,0 +1,35 @@ +# 133 TrendIndicator 趋势指示 + +## 用途 + +以方向箭头、带符号数值和无障碍方向文字展示指标变化。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TrendIndicator(c, 12.5, mujica.TrendIndicatorOptions{Precision: 1, Unit: "%", Note: "vs Lent"}) +} +ui.Render(view, 400, 100, 1) +``` + +## 参数 + +`change float64` 必须有限;`Precision` 默认 0,负值 panic;`Unit` 和 `Note` 默认空;`Invert` 默认 false,开启后下降采用成功语义;`Format` 替换数值格式。 + +## 状态 + +上升、下降、持平;正负号与箭头提供颜色之外的区分;方向词支持中英文和消息覆盖。 + +## 事件 + +返回 `*ui.Element`,展示调用方提供的变化值。 + +## 键盘操作 + +展示部件;辅助技术读取方向和完整变化值。 + +## 限制 + +变化值由调用方计算;组件提供显示和语义颜色。 diff --git a/docs/components/138-density-plot.md b/docs/components/138-density-plot.md new file mode 100644 index 0000000..a97920c --- /dev/null +++ b/docs/components/138-density-plot.md @@ -0,0 +1,36 @@ +# 138 DensityPlot 密度图 + +## 用途 + +将多组样本的高斯核密度曲线叠加在同一坐标域,以形状和颜色区分序列。 + +## 最小示例 + +```go +groups := []mujica.BoxGroup{{Name: "Matins", Values: []float64{1, 2, 2, 3, 7}}, {Name: "Vespers", Values: []float64{3, 4, 5, 6, 6}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DensityPlot(c, groups, mujica.DensityPlotOptions{Frame: mujica.ChartFrame{Label: "Ages"}, Bandwidth: 1, Points: 128}) +} +ui.Render(view, 400, 300, 1) +``` + +## 参数 + +`groups []BoxGroup` 为唯一非空组名和样本;NaN 为缺值。有限 `Bandwidth` 默认 0,按组自动估计;负值 panic。`Points` 默认 128,显式值须至少 2。`Frame` 和 `Format` 控制图表与密度读数。 + +## 状态 + +默认、序列显隐、悬停、键盘读数、加载、错误、空数据、祖先禁用。 + +## 事件 + +`Changed()` 表示采样位置变化;`Toggled()` 表示序列显隐;`Retried()` 表示重试。 + +## 键盘操作 + +Tab 先到图例,再到图表;图例 Space 切换显隐;图表方向键读取共享 X 位置的各组密度;Home/End 首尾;Escape 清除。 + +## 限制 + +核密度计算在数据、带宽或 Points 变化时执行;绘制曲线按像素列抽样。100,000 个样本默认生成每组 128 个密度点。更新数据需替换样本切片。 diff --git a/docs/components/140-chart-empty-state.md b/docs/components/140-chart-empty-state.md new file mode 100644 index 0000000..54dc64b --- /dev/null +++ b/docs/components/140-chart-empty-state.md @@ -0,0 +1,35 @@ +# 140 ChartEmptyState 图表占位 + +## 用途 + +在图表无数据、加载或失败时保持图表尺寸,并在失败时提供重试。 + +## 最小示例 + +```go +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChartEmptyState(c, mujica.DataFailed, mujica.ChartEmptyStateOptions{Height: 180, Error: "The archive did not answer"}) +} +ui.Render(view, 400, 240, 1) +``` + +## 参数 + +`status DataStatus`:DataReady 显示空数据,DataLoading 显示加载,DataFailed 显示错误和重试;其他有效加载状态同样显示加载。`Height` 默认 240 DIP,负值 panic;`Error`、`Empty` 默认内置本地化文案。 + +## 状态 + +空数据、加载、错误;祖先禁用保持展示并阻止重试;减少动画显示静态加载说明。 + +## 事件 + +返回 `ChoiceResult`,`Changed()` 表示点击重试,数据请求由调用方执行。 + +## 键盘操作 + +Tab 聚焦重试;Enter 或 Space 触发一次重试。 + +## 限制 + +只管理占位展示和事件;数据和异步状态由调用方维护。 diff --git a/example_charts2_test.go b/example_charts2_test.go new file mode 100644 index 0000000..b568723 --- /dev/null +++ b/example_charts2_test.go @@ -0,0 +1,180 @@ +package mujica_test + +import ( + "github.com/egoist/mygo/ui" + mujica "mujicaui" +) + +func ExampleChartLegend() { + hidden := map[string]bool{} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + k := mujica.Tokens(c) + items := []mujica.ChartLegendItem{{Name: "Pews", Color: k.Info}, {Name: "Candles", Color: k.Success, Shape: 1}} + mujica.ChartLegend(c, &hidden, items, mujica.ChartLegendOptions{MaxItems: 1}) + } + ui.Render(view, 400, 200, 1) +} + +func ExampleChartTooltip() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + k := mujica.Tokens(c) + mujica.ChartTooltip(c, 40, 40, []mujica.ChartTooltipLine{{Text: "Lauds"}, {Text: "Pews 5", Color: k.Info}, {Text: "Candles 2", Color: k.Success, Shape: 1}}) + } + ui.Render(view, 400, 200, 1) +} + +func ExampleChartCrosshair() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10}) + y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100}) + hair := mujica.ChartCrosshair(c, mujica.ChartCrosshairOptions{X: &x, Y: &y}) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60} + x.Draw(p, plot) + y.Draw(p, plot) + hair.Draw(p, plot, x.Map(plot, 5), y.Map(plot, 70)) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleChartAxis() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + axis := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Kind: mujica.AxisLog, Min: 1, Max: 1000}) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + axis.Draw(p, ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleChartGrid() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10}) + y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100}) + grid := mujica.ChartGrid(c, mujica.ChartGridOptions{X: &x, Y: &y, Minor: 2}) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60} + grid.Draw(p, plot) + x.Draw(p, plot) + y.Draw(p, plot) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleChartBrush() { + value := [2]float64{20, 60} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChartBrush(c, &value, mujica.ChartBrushOptions{Label: "Months", Max: 100, Step: 1, Values: []float64{2, 4, 3, 8, 5}}) + } + ui.Render(view, 400, 160, 1) +} + +func ExampleChartAnnotation() { + notes := []mujica.ChartNote{{Kind: mujica.NoteHLine, Value: 50, Text: "Goal", Draggable: true}, {Kind: mujica.NoteBand, Value: 6, End: 8, Text: "Lent"}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10}) + y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100}) + annotation := mujica.ChartAnnotation(c, notes, mujica.ChartAnnotationOptions{X: &x, Y: &y}) + box := ui.Box(c).Fill().Focusable().Label("Annotations") + b := box.Bounds() + annotation.Drag(box, ui.Rect{X: 60, Y: 20, W: b.W - 80, H: b.H - 60}) + box.Draw(func(p *ui.Painter, r ui.Rect) { + plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60} + x.Draw(p, plot) + y.Draw(p, plot) + annotation.Draw(p, plot) + }) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleChartExport() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + data, err := mujica.ChartExport(c, func(c *ui.Context) { + mujica.SparkBar(c, []float64{3, -2, 5}, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}}) + }, mujica.ChartExportOptions{Width: 300, Height: 100, Scale: 2}) + if err != nil { + panic(err) + } + bitmap, err := ui.DecodeBitmap(data) + if err != nil { + panic(err) + } + ui.Image(c, bitmap).Size(300, 100) + } + ui.Render(view, 400, 160, 1) +} + +func ExampleSparkBar() { + values := []float64{3, -2, 4, 1, -3} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SparkBar(c, values, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}, BarWidth: 10}) + } + ui.Render(view, 400, 100, 1) +} + +func ExampleTrendIndicator() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TrendIndicator(c, 12.5, mujica.TrendIndicatorOptions{Precision: 1, Unit: "%", Note: "vs Lent"}) + } + ui.Render(view, 400, 100, 1) +} + +func ExampleChartEmptyState() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChartEmptyState(c, mujica.DataFailed, mujica.ChartEmptyStateOptions{Height: 180, Error: "The archive did not answer"}) + } + ui.Render(view, 400, 240, 1) +} + +func ExampleViolinPlot() { + groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 7, 9}}, {Name: "Nave", Values: []float64{2, 4, 5, 6, 8}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ViolinPlot(c, groups, mujica.ViolinPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}, Bandwidth: 1}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleParallelCoordinates() { + filters := map[string][2]float64{} + axes := []string{"Age", "Hymns", "Vigils"} + rows := [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ParallelCoordinates(c, &filters, axes, rows, mujica.ParallelCoordinatesOptions{Frame: mujica.ChartFrame{Label: "Monks"}, Names: []string{"Agnes", "Clare", "Hild"}}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleChordDiagram() { + names := []string{"Nave", "Choir", "Crypt"} + matrix := [][]float64{{0, 5, 2}, {4, 0, 1}, {1, 2, 0}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChordDiagram(c, names, matrix, mujica.ChordDiagramOptions{Frame: mujica.ChartFrame{Label: "Paths"}}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleDensityPlot() { + groups := []mujica.BoxGroup{{Name: "Matins", Values: []float64{1, 2, 2, 3, 7}}, {Name: "Vespers", Values: []float64{3, 4, 5, 6, 6}}} + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DensityPlot(c, groups, mujica.DensityPlotOptions{Frame: mujica.ChartFrame{Label: "Ages"}, Bandwidth: 1, Points: 128}) + } + ui.Render(view, 400, 300, 1) +} diff --git a/examples/gallery/pages_charts2.go b/examples/gallery/pages_charts2.go index 27e0197..ef6aa5f 100644 --- a/examples/gallery/pages_charts2.go +++ b/examples/gallery/pages_charts2.go @@ -1,3 +1,70 @@ package main -var charts2Pages = []page{} +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +const c2Category = "Charts II" + +var charts2Pages = []page{ + {ID: "121", Name: "ChartLegend", ZhName: "图例", Category: c2Category, Demo: c2LegendDemo, Code: c2ChartLegendCode, Keys: "Tab enters; arrows move; Space toggles; Enter expands."}, + {ID: "122", Name: "ChartTooltip", ZhName: "读数浮层", Category: c2Category, Demo: c2TooltipDemo, Code: c2ChartTooltipCode, Keys: c1Keys}, + {ID: "123", Name: "ChartCrosshair", ZhName: "十字光标", Category: c2Category, Demo: c2CrosshairDemo, Code: c2ChartCrosshairCode, Keys: c1Keys}, + {ID: "124", Name: "ChartAxis", ZhName: "坐标轴", Category: c2Category, Demo: c2AxisDemo, Code: c2ChartAxisCode, Keys: c1Keys}, + {ID: "125", Name: "ChartGrid", ZhName: "图表网格", Category: c2Category, Demo: c2GridDemo, Code: c2ChartGridCode, Keys: c1Keys}, + {ID: "126", Name: "ChartBrush", ZhName: "区间刷选", Category: c2Category, Demo: c2BrushDemo, Code: c2ChartBrushCode, Keys: "Tab: start, window, end; arrows step; PageUp/Down step ten; Home/End reach bounds."}, + {ID: "127", Name: "ChartAnnotation", ZhName: "图表标注", Category: c2Category, Demo: c2AnnotationDemo, Code: c2ChartAnnotationCode, Keys: c1Keys + " Alt+PageUp/Down selects a movable note; Alt+arrows move it."}, + {ID: "128", Name: "ChartExport", ZhName: "图表导出", Category: c2Category, Demo: c2ExportDemo, Code: c2ChartExportCode, Keys: "Tab reaches Export PNG; Enter or Space renders bytes in the current theme."}, + {ID: "129", Name: "ViolinPlot", ZhName: "小提琴图", Category: c2Category, Demo: c2ViolinDemo, Code: c2ViolinPlotCode, Keys: c1Keys}, + {ID: "130", Name: "ParallelCoordinates", ZhName: "平行坐标", Category: c2Category, Demo: c2ParallelDemo, Code: c2ParallelCoordinatesCode, Keys: c1Keys + " Alt+PageUp/Down selects an axis; Alt+Up/Down adjusts its lower bound; Alt+Shift+Up/Down adjusts its upper bound; Alt+Delete clears."}, + {ID: "131", Name: "ChordDiagram", ZhName: "弦图", Category: c2Category, Demo: c2ChordDemo, Code: c2ChordDiagramCode, Keys: c1Keys}, + {ID: "132", Name: "SparkBar", ZhName: "迷你柱", Category: c2Category, Demo: c2SparkBarDemo, Code: c2SparkBarCode, Keys: "Tab focuses bars; Left/Right read points; Home/End jump; Escape restores the last value."}, + {ID: "133", Name: "TrendIndicator", ZhName: "趋势指示", Category: c2Category, Demo: c2TrendDemo, Code: c2TrendIndicatorCode, Keys: "Change and Invert controls use the keyboard; the indicator is read as text."}, + {ID: "138", Name: "DensityPlot", ZhName: "密度图", Category: c2Category, Demo: c2DensityDemo, Code: c2DensityPlotCode, Keys: c1Keys + " Space on legend entries hides or shows a group."}, + {ID: "140", Name: "ChartEmptyState", ZhName: "图表占位", Category: c2Category, Demo: c2EmptyDemo, Code: c2ChartEmptyStateCode, Keys: "Tab reaches Retry; Enter or Space retries."}, +} + +func c2Check(c *ui.Context, value *bool, label string, log func(string)) { + if mujica.Checkbox(c, value, label, mujica.CheckboxOptions{}).Changed() { + log(fmt.Sprintf("%s = %v", label, *value)) + } +} + +func c2Number(c *ui.Context, value *float64, label string, lo, hi, step float64, log func(string)) { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Text(c, label) + r := mujica.NumberInput(c, value, mujica.NumberInputOptions{Label: label, Min: lo, Max: hi, Step: step}) + r.Element.Width(150) + if r.Changed() { + log(fmt.Sprintf("%s = %g", label, *value)) + } + }) +} + +func c2Select(c *ui.Context, value *string, label string, choices []string, log func(string)) { + items := make([]mujica.SelectOption[string], len(choices)) + for i, s := range choices { + items[i] = mujica.SelectOption[string]{Value: s, Label: s} + } + r := mujica.Select(c, value, items, mujica.SelectOptions{Label: label}) + r.Element.Width(160) + if r.Changed() { + log(label + " = " + *value) + } +} + +func c2Frame(c *ui.Context, label string, status *string, log func(string)) (mujica.ChartFrame, bool) { + c2Select(c, status, "Status", c1Statuses, log) + f := mujica.ChartFrame{Label: label} + if *status == "Loading" { + f.Status = mujica.DataLoading + } + if *status == "Failed" { + f.Status, f.Error = mujica.DataFailed, "The archive did not answer" + } + return f, *status != "Empty" +} diff --git a/examples/gallery/pages_charts2_code.go b/examples/gallery/pages_charts2_code.go new file mode 100644 index 0000000..e74b2a5 --- /dev/null +++ b/examples/gallery/pages_charts2_code.go @@ -0,0 +1,145 @@ +package main + +const c2ChartLegendCode = `hidden := map[string]bool{} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + k := mujica.Tokens(c) + items := []mujica.ChartLegendItem{{Name: "Pews", Color: k.Info}, {Name: "Candles", Color: k.Success, Shape: 1}} + mujica.ChartLegend(c, &hidden, items, mujica.ChartLegendOptions{MaxItems: 1}) +} +ui.Render(view, 400, 200, 1)` + +const c2ChartTooltipCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + k := mujica.Tokens(c) + mujica.ChartTooltip(c, 40, 40, []mujica.ChartTooltipLine{{Text: "Lauds"}, {Text: "Pews 5", Color: k.Info}, {Text: "Candles 2", Color: k.Success, Shape: 1}}) +} +ui.Render(view, 400, 200, 1)` + +const c2ChartCrosshairCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10}) + y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100}) + hair := mujica.ChartCrosshair(c, mujica.ChartCrosshairOptions{X: &x, Y: &y}) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60} + x.Draw(p, plot) + y.Draw(p, plot) + hair.Draw(p, plot, x.Map(plot, 5), y.Map(plot, 70)) + }) +} +ui.Render(view, 400, 300, 1)` + +const c2ChartAxisCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + axis := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Kind: mujica.AxisLog, Min: 1, Max: 1000}) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + axis.Draw(p, ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}) + }) +} +ui.Render(view, 400, 300, 1)` + +const c2ChartGridCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10}) + y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100}) + grid := mujica.ChartGrid(c, mujica.ChartGridOptions{X: &x, Y: &y, Minor: 2}) + ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { + plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60} + grid.Draw(p, plot) + x.Draw(p, plot) + y.Draw(p, plot) + }) +} +ui.Render(view, 400, 300, 1)` + +const c2ChartBrushCode = `value := [2]float64{20, 60} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChartBrush(c, &value, mujica.ChartBrushOptions{Label: "Months", Max: 100, Step: 1, Values: []float64{2, 4, 3, 8, 5}}) +} +ui.Render(view, 400, 160, 1)` + +const c2ChartAnnotationCode = `notes := []mujica.ChartNote{{Kind: mujica.NoteHLine, Value: 50, Text: "Goal", Draggable: true}, {Kind: mujica.NoteBand, Value: 6, End: 8, Text: "Lent"}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10}) + y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100}) + annotation := mujica.ChartAnnotation(c, notes, mujica.ChartAnnotationOptions{X: &x, Y: &y}) + box := ui.Box(c).Fill().Focusable().Label("Annotations") + b := box.Bounds() + annotation.Drag(box, ui.Rect{X: 60, Y: 20, W: b.W - 80, H: b.H - 60}) + box.Draw(func(p *ui.Painter, r ui.Rect) { + plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60} + x.Draw(p, plot) + y.Draw(p, plot) + annotation.Draw(p, plot) + }) +} +ui.Render(view, 400, 300, 1)` + +const c2ChartExportCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + data, err := mujica.ChartExport(c, func(c *ui.Context) { + mujica.SparkBar(c, []float64{3, -2, 5}, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}}) + }, mujica.ChartExportOptions{Width: 300, Height: 100, Scale: 2}) + if err != nil { + panic(err) + } + bitmap, err := ui.DecodeBitmap(data) + if err != nil { + panic(err) + } + ui.Image(c, bitmap).Size(300, 100) +} +ui.Render(view, 400, 160, 1)` + +const c2SparkBarCode = `values := []float64{3, -2, 4, 1, -3} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.SparkBar(c, values, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}, BarWidth: 10}) +} +ui.Render(view, 400, 100, 1)` + +const c2TrendIndicatorCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.TrendIndicator(c, 12.5, mujica.TrendIndicatorOptions{Precision: 1, Unit: "%", Note: "vs Lent"}) +} +ui.Render(view, 400, 100, 1)` + +const c2ChartEmptyStateCode = `view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChartEmptyState(c, mujica.DataFailed, mujica.ChartEmptyStateOptions{Height: 180, Error: "The archive did not answer"}) +} +ui.Render(view, 400, 240, 1)` + +const c2ViolinPlotCode = `groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 7, 9}}, {Name: "Nave", Values: []float64{2, 4, 5, 6, 8}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ViolinPlot(c, groups, mujica.ViolinPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}, Bandwidth: 1}) +} +ui.Render(view, 400, 300, 1)` + +const c2ParallelCoordinatesCode = `filters := map[string][2]float64{} +axes := []string{"Age", "Hymns", "Vigils"} +rows := [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ParallelCoordinates(c, &filters, axes, rows, mujica.ParallelCoordinatesOptions{Frame: mujica.ChartFrame{Label: "Monks"}, Names: []string{"Agnes", "Clare", "Hild"}}) +} +ui.Render(view, 400, 300, 1)` + +const c2ChordDiagramCode = `names := []string{"Nave", "Choir", "Crypt"} +matrix := [][]float64{{0, 5, 2}, {4, 0, 1}, {1, 2, 0}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.ChordDiagram(c, names, matrix, mujica.ChordDiagramOptions{Frame: mujica.ChartFrame{Label: "Paths"}}) +} +ui.Render(view, 400, 300, 1)` + +const c2DensityPlotCode = `groups := []mujica.BoxGroup{{Name: "Matins", Values: []float64{1, 2, 2, 3, 7}}, {Name: "Vespers", Values: []float64{3, 4, 5, 6, 6}}} +view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.DensityPlot(c, groups, mujica.DensityPlotOptions{Frame: mujica.ChartFrame{Label: "Ages"}, Bandwidth: 1, Points: 128}) +} +ui.Render(view, 400, 300, 1)` diff --git a/examples/gallery/pages_charts2_distribution.go b/examples/gallery/pages_charts2_distribution.go new file mode 100644 index 0000000..6dae3f8 --- /dev/null +++ b/examples/gallery/pages_charts2_distribution.go @@ -0,0 +1,105 @@ +package main + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func c2ViolinDemo(c *ui.Context, log func(string)) { c2DistributionDemo(c, log, "129", "ViolinPlot") } +func c2DensityDemo(c *ui.Context, log func(string)) { c2DistributionDemo(c, log, "138", "DensityPlot") } + +func c2DistributionDemo(c *ui.Context, log func(string), id, name string) { + type state struct { + status string + bandwidth float64 + disabled bool + groups []mujica.BoxGroup + } + s := ui.Local(c.Root(), id, func() state { + return state{status: "Ready", groups: []mujica.BoxGroup{ + {Name: "Matins", Values: []float64{1, 2, 2, 3, 4, 5, 8, 9}}, + {Name: "Vespers", Values: []float64{3, 4, 5, 6, 6, 7, 8, 11}}, + }} + }) + c1Section(c, name, func() { + c2Number(c, &s.bandwidth, "Bandwidth", 0, 5, 0.5, log) + c2Check(c, &s.disabled, "Disabled", log) + }, func() { + f, show := c2Frame(c, name, &s.status, log) + groups := s.groups + if !show { + groups = nil + } + ui.Column(c).Disabled(s.disabled).Children(func() { + if id == "129" { + c1Log(log, name, mujica.ViolinPlot(c, groups, mujica.ViolinPlotOptions{Frame: f, Bandwidth: s.bandwidth})) + } else { + c1Log(log, name, mujica.DensityPlot(c, groups, mujica.DensityPlotOptions{Frame: f, Bandwidth: s.bandwidth})) + } + }) + }) +} + +func c2ParallelDemo(c *ui.Context, log func(string)) { + type state struct { + status string + disabled bool + filters map[string][2]float64 + rows [][]float64 + } + s := ui.Local(c.Root(), "130", func() state { + return state{status: "Ready", filters: map[string][2]float64{}, rows: [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}, {65, 1, 4}}} + }) + c1Section(c, "ParallelCoordinates", func() { + c2Check(c, &s.disabled, "Disabled", log) + if mujica.Button(c, "Clear filters", mujica.ButtonOptions{Disabled: s.disabled}).Clicked() { + s.filters = map[string][2]float64{} + log("ParallelCoordinates: clear filters") + } + }, func() { + f, show := c2Frame(c, "ParallelCoordinates", &s.status, log) + rows := s.rows + names := []string{"Agnes", "Clare", "Hild", "Bede"} + if !show { + rows = nil + names = nil + } + ui.Column(c).Disabled(s.disabled).Children(func() { + c1Log(log, "ParallelCoordinates", mujica.ParallelCoordinates(c, &s.filters, []string{"Age", "Hymns", "Vigils"}, rows, mujica.ParallelCoordinatesOptions{Frame: f, Names: names})) + }) + ui.Text(c, fmt.Sprintf("Filters: %v", s.filters)) + }) +} + +func c2ChordDemo(c *ui.Context, log func(string)) { + type state struct { + status string + disabled bool + amount float64 + matrix [][]float64 + } + s := ui.Local(c.Root(), "131", func() state { + return state{status: "Ready", amount: 5, matrix: [][]float64{{0, 5, 2}, {4, 0, 1}, {1, 2, 0}}} + }) + c1Section(c, "ChordDiagram", func() { + old := s.amount + c2Number(c, &s.amount, "Nave to Choir", 0, 20, 1, log) + if old != s.amount { + s.matrix = [][]float64{{0, s.amount, 2}, {4, 0, 1}, {1, 2, 0}} + } + c2Check(c, &s.disabled, "Disabled", log) + }, func() { + f, show := c2Frame(c, "ChordDiagram", &s.status, log) + names, matrix := []string{"Nave", "Choir", "Crypt"}, s.matrix + if !show { + names = nil + matrix = nil + } + ui.Column(c).Disabled(s.disabled).Children(func() { + c1Log(log, "ChordDiagram", mujica.ChordDiagram(c, names, matrix, mujica.ChordDiagramOptions{Frame: f})) + }) + }) +} diff --git a/examples/gallery/pages_charts2_parts.go b/examples/gallery/pages_charts2_parts.go new file mode 100644 index 0000000..1390f5d --- /dev/null +++ b/examples/gallery/pages_charts2_parts.go @@ -0,0 +1,138 @@ +package main + +import ( + "fmt" + "time" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/internal" +) + +type c2PartsState struct { + status, kind string + disabled, crosshair, draggable, fixed bool + minor float64 + notes []mujica.ChartNote +} + +func c2TooltipDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "122", "ChartTooltip") } +func c2CrosshairDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "123", "ChartCrosshair") } +func c2AxisDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "124", "ChartAxis") } +func c2GridDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "125", "ChartGrid") } +func c2AnnotationDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "127", "ChartAnnotation") } + +func c2PartsDemo(c *ui.Context, log func(string), id, name string) { + s := ui.Local(c.Root(), id, func() c2PartsState { + return c2PartsState{status: "Ready", kind: "Linear", crosshair: true, minor: 2, draggable: true, notes: []mujica.ChartNote{ + {Kind: mujica.NoteHLine, Value: 50, Text: "Goal"}, + {Kind: mujica.NoteVLine, Value: 3, Text: "Advent"}, + {Kind: mujica.NoteBand, Value: 6, End: 8, Text: "Lent"}, + {Kind: mujica.NoteText, Value: 1, End: 90, Text: "Feast"}, + }} + }) + c1Section(c, name, func() { + c2Check(c, &s.disabled, "Disabled", log) + switch id { + case "123": + c2Check(c, &s.crosshair, "Crosshair", log) + case "124": + c2Select(c, &s.kind, "Axis kind", []string{"Linear", "Log", "Time", "Category"}, log) + c2Check(c, &s.fixed, "Fixed ticks", log) + case "125": + c2Number(c, &s.minor, "Minor", 0, 8, 1, log) + case "127": + c2Check(c, &s.draggable, "Draggable", log) + } + }, func() { + f, show := c2Frame(c, name, &s.status, log) + ui.Column(c).Disabled(s.disabled).Children(func() { + c1Log(log, name, c2PartsChart(c, f, show, s, id, log)) + }) + if id == "127" { + ui.Text(c, fmt.Sprintf("Goal %.1f · Advent %.1f · Lent %.1f–%.1f · Feast %.1f, %.1f", s.notes[0].Value, s.notes[1].Value, s.notes[2].Value, s.notes[2].End, s.notes[3].Value, s.notes[3].End)) + } + }) +} + +func c2PartsChart(c *ui.Context, frame mujica.ChartFrame, show bool, s *c2PartsState, id string, log func(string)) mujica.ChartResult { + xo := mujica.ChartAxisOptions{Max: 10} + yo := mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100} + pts := [][2]float64{{2, 30}, {5, 80}, {8, 55}} + if id == "124" { + switch s.kind { + case "Log": + yo.Kind, yo.Min = mujica.AxisLog, 1 + case "Time": + xo.Kind = mujica.AxisTime + xo.From = time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC) + xo.To = xo.From.Add(10 * time.Hour) + for i := range pts { + pts[i][0] = float64(xo.From.Unix()) + pts[i][0]*3600 + } + case "Category": + xo.Kind, xo.Categories = mujica.AxisCategory, []string{"Nave", "Choir", "Crypt"} + for i := range pts { + pts[i][0] = float64(i) + } + } + if s.fixed { + yo.Ticks = []float64{10, 50, 90} + } + } + x, y := mujica.ChartAxis(c, xo), mujica.ChartAxis(c, yo) + grid := mujica.ChartGrid(c, mujica.ChartGridOptions{Y: &y, Minor: int(s.minor)}) + hair := mujica.ChartCrosshair(c, mujica.ChartCrosshairOptions{X: &x, Y: &y}) + var notes []mujica.ChartNote + if id == "127" { + notes = s.notes + for i := range notes { + notes[i].Draggable = s.draggable + } + } + annotation := mujica.ChartAnnotation(c, notes, mujica.ChartAnnotationOptions{X: &x, Y: &y}) + plotOf := func(r ui.Rect) ui.Rect { + return ui.Rect{X: r.X + 64, Y: r.Y + 16, W: max(1, r.W-100), H: max(1, r.H-56)} + } + at := func(r ui.Rect, i int) (float32, float32) { + plot := plotOf(r) + return x.Map(plot, pts[i][0]), y.Map(plot, pts[i][1]) + } + marks := len(pts) + if !show { + marks = 0 + } + colors := internal.Palette(mujica.Tokens(c)) + return mujica.Chart(c, frame, mujica.ChartSpec{ + Marks: marks, + Pick: func(r ui.Rect, px, py float32) int { + return hair.Snap(len(pts), func(i int) (float32, float32) { return at(r, i) }, px, py) + }, + Anchor: at, + Readout: func(i int) string { return fmt.Sprintf("x %s · y %s", x.Label(pts[i][0]), y.Label(pts[i][1])) }, + Tooltip: func(i int) []mujica.ChartTooltipLine { + return []mujica.ChartTooltipLine{{Text: x.Label(pts[i][0])}, {Text: "Pews " + y.Label(pts[i][1]), Color: colors[0]}, {Text: "Candles " + y.Label(pts[i][1]/2), Color: colors[1], Shape: 1}} + }, + Input: func(el *ui.Element, r ui.Rect) { + if annotation.Drag(el, plotOf(r)) { + log("ChartAnnotation: moved") + } + }, + Draw: func(p *ui.Painter, r ui.Rect, active int) { + plot := plotOf(r) + grid.Draw(p, plot) + x.Draw(p, plot) + y.Draw(p, plot) + annotation.Draw(p, plot) + for i := range pts { + px, py := at(r, i) + internal.Marker(p, i, px, py, 10, colors[i]) + } + if id == "123" && s.crosshair && active >= 0 { + px, py := at(r, active) + hair.Draw(p, plot, px, py) + } + }, + }) +} diff --git a/examples/gallery/pages_charts2_stats.go b/examples/gallery/pages_charts2_stats.go new file mode 100644 index 0000000..95f8c7a --- /dev/null +++ b/examples/gallery/pages_charts2_stats.go @@ -0,0 +1,194 @@ +package main + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/internal" +) + +func c2LegendDemo(c *ui.Context, log func(string)) { + type state struct { + hidden map[string]bool + vertical, disabled bool + max float64 + } + s := ui.Local(c.Root(), "121", func() state { return state{hidden: map[string]bool{}, max: 3} }) + c1Section(c, "ChartLegend", func() { + c2Check(c, &s.vertical, "Vertical", log) + c2Check(c, &s.disabled, "Disabled", log) + c2Number(c, &s.max, "Max items", 0, 6, 1, log) + }, func() { + pal := internal.Palette(mujica.Tokens(c)) + names := []string{"Matins", "Lauds", "Prime", "Terce", "Sext", "None"} + items := make([]mujica.ChartLegendItem, len(names)) + for i, n := range names { + items[i] = mujica.ChartLegendItem{Name: n, Color: pal[i], Shape: i % 5} + } + ui.Column(c).Disabled(s.disabled).Children(func() { + r := mujica.ChartLegend(c, &s.hidden, items, mujica.ChartLegendOptions{Vertical: s.vertical, MaxItems: int(s.max)}) + if expanded, ok := r.Expanded(); ok { + log(fmt.Sprintf("ChartLegend: expanded %v", expanded)) + } + if n, ok := r.Toggled(); ok { + log("ChartLegend: toggled " + n) + } + }) + ui.Text(c, fmt.Sprintf("Hidden: %v", s.hidden)) + }) +} + +func c2BrushDemo(c *ui.Context, log func(string)) { + type state struct { + value [2]float64 + disabled bool + step float64 + } + s := ui.Local(c.Root(), "126", func() state { return state{value: [2]float64{20, 60}, step: 1} }) + c1Section(c, "ChartBrush", func() { + c2Check(c, &s.disabled, "Disabled", log) + c2Number(c, &s.step, "Step", 1, 10, 1, log) + }, func() { + vals := []float64{1, 4, 2, 7, 3, 8, 4, 9} + r := mujica.ChartBrush(c, &s.value, mujica.ChartBrushOptions{Label: "Months", Max: 100, Step: s.step, Values: vals, Disabled: s.disabled}) + if r.Changed() { + log(fmt.Sprintf("ChartBrush: range %g–%g", s.value[0], s.value[1])) + } + x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Min: s.value[0], Max: s.value[1]}) + y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 10}) + xs := []float64{0, 15, 30, 45, 60, 75, 90, 100} + var visible []int + for i, v := range xs { + if v >= s.value[0] && v <= s.value[1] { + visible = append(visible, i) + } + } + plotOf := func(r ui.Rect) ui.Rect { return ui.Rect{X: r.X + 40, Y: r.Y + 8, W: max(1, r.W-60), H: max(1, r.H-40)} } + at := func(r ui.Rect, i int) (float32, float32) { + plot := plotOf(r) + return x.Map(plot, xs[visible[i]]), y.Map(plot, vals[visible[i]]) + } + c1Log(log, "Zoom preview", mujica.Chart(c, mujica.ChartFrame{Label: "Zoom preview", Height: 140}, mujica.ChartSpec{ + Marks: len(visible), Anchor: at, + Pick: func(r ui.Rect, px, py float32) int { + return internal.Nearest(len(visible), func(i int) (float32, float32) { return at(r, i) }, px, py, 24, false) + }, + Readout: func(i int) string { return fmt.Sprintf("x %g · y %g", xs[visible[i]], vals[visible[i]]) }, + Draw: func(p *ui.Painter, r ui.Rect, active int) { + plot := plotOf(r) + x.Draw(p, plot) + y.Draw(p, plot) + for i := range visible { + px, py := at(r, i) + internal.Marker(p, 0, px, py, 8, mujica.Tokens(c).AccentText) + } + }, + })) + }) +} + +func c2ExportDemo(c *ui.Context, log func(string)) { + type state struct { + width, height, scale float64 + disabled bool + bitmap *ui.Bitmap + bytes int + error string + } + s := ui.Local(c.Root(), "128", func() state { return state{width: 400, height: 240, scale: 2} }) + c1Section(c, "ChartExport", func() { + c2Number(c, &s.width, "Width", 200, 600, 50, log) + c2Number(c, &s.height, "Height", 160, 400, 40, log) + c2Number(c, &s.scale, "Scale", 1, 3, 0.5, log) + c2Check(c, &s.disabled, "Disabled", log) + }, func() { + if mujica.Button(c, "Export PNG", mujica.ButtonOptions{Disabled: s.disabled}).Clicked() { + data, err := mujica.ChartExport(c, func(c *ui.Context) { + mujica.BarChart(c, []string{"Nave", "Choir", "Crypt"}, []mujica.ChartSeries{{Name: "Pews", Values: []float64{3, 5, 2}}}, mujica.BarChartOptions{Frame: mujica.ChartFrame{Label: "Exported chart", Height: float32(s.height) - 80}}) + }, mujica.ChartExportOptions{Width: int(s.width), Height: int(s.height), Scale: float32(s.scale)}) + if err == nil { + s.bitmap, err = ui.DecodeBitmap(data) + } + if err != nil { + s.error = err.Error() + log("ChartExport: " + s.error) + } else { + s.error = "" + s.bytes = len(data) + log(fmt.Sprintf("ChartExport: %d bytes", s.bytes)) + } + } + if s.error != "" { + ui.Text(c, s.error).TextColor(mujica.Tokens(c).Danger).Role(ui.RoleStatus) + } + if s.bitmap != nil { + ui.Image(c, s.bitmap).Size(float32(s.width), float32(s.height)) + ui.Text(c, fmt.Sprintf("PNG: %d bytes", s.bytes)) + } + }) +} + +func c2SparkBarDemo(c *ui.Context, log func(string)) { + type state struct { + status string + width float64 + disabled bool + } + s := ui.Local(c.Root(), "132", func() state { return state{status: "Ready", width: 10} }) + c1Section(c, "SparkBar", func() { + c2Number(c, &s.width, "Bar width", 2, 20, 1, log) + c2Check(c, &s.disabled, "Disabled", log) + }, func() { + f, show := c2Frame(c, "Alms", &s.status, log) + vals := []float64{3, -2, 4, 1, -3} + if !show { + vals = nil + } + ui.Column(c).Disabled(s.disabled).Children(func() { + c1Log(log, "SparkBar", mujica.SparkBar(c, vals, mujica.SparkBarOptions{Frame: f, BarWidth: float32(s.width)})) + }) + }) +} + +func c2TrendDemo(c *ui.Context, log func(string)) { + type state struct { + change, precision float64 + invert bool + } + s := ui.Local(c.Root(), "133", func() state { return state{change: 12.5, precision: 1} }) + c1Section(c, "TrendIndicator", func() { + c2Number(c, &s.change, "Change", -100, 100, 0.5, log) + c2Number(c, &s.precision, "Precision", 0, 3, 1, log) + c2Check(c, &s.invert, "Invert", log) + }, func() { + mujica.TrendIndicator(c, s.change, mujica.TrendIndicatorOptions{Precision: int(s.precision), Unit: "%", Note: "vs Lent", Invert: s.invert}) + ui.Row(c).Gap(24).Children(func() { + mujica.TrendIndicator(c, 3, mujica.TrendIndicatorOptions{}) + mujica.TrendIndicator(c, -3, mujica.TrendIndicatorOptions{}) + mujica.TrendIndicator(c, 0, mujica.TrendIndicatorOptions{}) + }) + }) +} + +func c2EmptyDemo(c *ui.Context, log func(string)) { + type state struct { + status string + height float64 + disabled bool + } + s := ui.Local(c.Root(), "140", func() state { return state{status: "Failed", height: 180} }) + c1Section(c, "ChartEmptyState", func() { + c2Number(c, &s.height, "Height", 160, 320, 40, log) + c2Check(c, &s.disabled, "Disabled", log) + }, func() { + f, _ := c2Frame(c, "ChartEmptyState", &s.status, log) + ui.Column(c).Disabled(s.disabled).Border(1, mujica.Tokens(c).Border).Children(func() { + if mujica.ChartEmptyState(c, f.Status, mujica.ChartEmptyStateOptions{Height: float32(s.height), Error: f.Error}).Changed() { + log("ChartEmptyState: retry") + s.status = "Loading" + } + }) + }) +} diff --git a/examples/gallery/pages_charts2_test.go b/examples/gallery/pages_charts2_test.go new file mode 100644 index 0000000..bd9dc8d --- /dev/null +++ b/examples/gallery/pages_charts2_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "slices" + "strings" + "testing" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" +) + +func c2Demo(t *testing.T, id string) (*ui.Tester, *[]string) { + t.Helper() + i := slices.IndexFunc(charts2Pages, func(p page) bool { return p.ID == id }) + if i < 0 { + t.Fatalf("missing 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() { charts2Pages[i].Demo(c, func(s string) { *events = append(*events, s) }) }) + }, 900, 760) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + return tt, events +} + +func TestCharts2ExistingSet(t *testing.T) { + want := []string{"121", "122", "123", "124", "125", "126", "127", "128", "129", "130", "131", "132", "133", "138", "140"} + var ids []string + for _, p := range charts2Pages { + ids = append(ids, p.ID) + if p.Category != "Charts II" || p.Code == "" || p.Keys == "" { + t.Fatalf("incomplete page %s", p.ID) + } + c2Demo(t, p.ID) + } + if !slices.Equal(ids, want) { + t.Fatalf("page IDs %v", ids) + } +} + +func TestCharts2ReadoutsAndControlsLog(t *testing.T) { + for _, id := range []string{"122", "123", "124", "125", "127", "129", "130", "131", "132", "138"} { + tt, events := c2Demo(t, id) + p := charts2Pages[slices.IndexFunc(charts2Pages, func(p page) bool { return p.ID == id })] + label := p.Name + if id == "132" { + label = "Alms" + } + tabUntil(t, tt, label) + tt.Key(0, ui.KeyRight) + if !slices.Contains(*events, p.Name+": point 0") { + t.Errorf("%s readout events %q", id, *events) + } + if err := tt.Click("Disabled"); err != nil { + t.Fatal(err) + } + if !slices.Contains(*events, "Disabled = true") { + t.Errorf("%s disabled events %q", id, *events) + } + } +} + +func TestCharts2LegendBrushAnnotationAndRetry(t *testing.T) { + tt, events := c2Demo(t, "121") + if err := tt.Click("+3 more"); err != nil { + t.Fatal(err) + } + if err := tt.Click("Matins"); err != nil { + t.Fatal(err) + } + if !slices.Contains(*events, "ChartLegend: expanded true") || !slices.Contains(*events, "ChartLegend: toggled Matins") { + t.Fatalf("legend events %q", *events) + } + tt, events = c2Demo(t, "126") + tabUntil(t, tt, "Months: 20 to 60") + tt.Key(0, ui.KeyRight) + if !slices.Contains(*events, "ChartBrush: range 21–61") { + t.Fatalf("brush events %q", *events) + } + tt, events = c2Demo(t, "127") + tabUntil(t, tt, "ChartAnnotation") + tt.Key(ui.Alt, ui.KeyUp) + if !slices.Contains(*events, "ChartAnnotation: moved") || !tt.HasText("Goal 51.0 · Advent 3.0 · Lent 6.0–8.0 · Feast 1.0, 90.0") { + t.Fatalf("annotation events %q text %q", *events, tt.Texts()) + } + tt, events = c2Demo(t, "140") + if err := tt.Click("Retry"); err != nil { + t.Fatal(err) + } + if !slices.Contains(*events, "ChartEmptyState: retry") || !tt.HasText("Loading") { + t.Fatalf("retry events %q", *events) + } +} + +func TestCharts2ExportAndTrendControls(t *testing.T) { + tt, events := c2Demo(t, "128") + tabUntil(t, tt, "Export PNG") + tt.Key(0, ui.KeyEnter) + if len(*events) == 0 || !strings.HasPrefix((*events)[len(*events)-1], "ChartExport: ") || !strings.HasSuffix((*events)[len(*events)-1], " bytes") { + t.Fatalf("export events %q", *events) + } + tt, events = c2Demo(t, "133") + if err := tt.Click("Invert"); err != nil { + t.Fatal(err) + } + if !slices.Contains(*events, "Invert = true") { + t.Fatalf("trend events %q", *events) + } +} diff --git a/funnel_chart.go b/funnel_chart.go index afac694..dd7bd18 100644 --- a/funnel_chart.go +++ b/funnel_chart.go @@ -49,19 +49,19 @@ func FunnelChart(c *ui.Context, stages []ChartSlice, opts FunnelChartOptions) Ch 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 { + spec := ChartSpec{Marks: 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 { + 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) { + 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) } diff --git a/gauge.go b/gauge.go index 21915df..b2f3745 100644 --- a/gauge.go +++ b/gauge.go @@ -84,8 +84,8 @@ func Gauge(c *ui.Context, value float64, opts GaugeOptions) ChartResult { if seg >= 0 { reading += " · " + opts.Segments[seg].Label } - spec := chartSpec{n: 1 + len(opts.Segments)} - spec.readout = func(i int) string { + spec := ChartSpec{Marks: 1 + len(opts.Segments)} + spec.Readout = func(i int) string { if i == 0 { return reading } @@ -95,7 +95,7 @@ func Gauge(c *ui.Context, value float64, opts GaugeOptions) ChartResult { } return fmt.Sprintf("%s: %s–%s", s.Label, val(from), val(s.To)) } - spec.pick = func(r ui.Rect, x, y float32) int { + 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 { @@ -114,7 +114,7 @@ func Gauge(c *ui.Context, value float64, opts GaugeOptions) ChartResult { } return -1 } - spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + spec.Draw = func(p *ui.Painter, r ui.Rect, active int) { cx, cy, rad := geom(r) from := a0 if len(opts.Segments) == 0 { diff --git a/heatmap_chart.go b/heatmap_chart.go index 321dbf0..04880b3 100644 --- a/heatmap_chart.go +++ b/heatmap_chart.go @@ -55,11 +55,11 @@ func HeatmapChart(c *ui.Context, rows, cols []string, values [][]float64, opts H 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 { + spec := ChartSpec{Marks: 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 { + spec.Move = func(i int, key ui.Key) int { r, col := i/len(cols), i%len(cols) switch key { case ui.KeyLeft: @@ -73,7 +73,7 @@ func HeatmapChart(c *ui.Context, rows, cols []string, values [][]float64, opts H } return r*len(cols) + col } - spec.pick = func(r ui.Rect, x, y float32) int { + spec.Pick = func(r ui.Rect, x, y float32) int { plot, xb, yb := geom(r) if !plot.Contains(x, y) { return -1 @@ -82,7 +82,7 @@ func HeatmapChart(c *ui.Context, rows, cols []string, values [][]float64, opts H 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) { + 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) diff --git a/histogram.go b/histogram.go index b36e96d..a864b72 100644 --- a/histogram.go +++ b/histogram.go @@ -116,8 +116,8 @@ func Histogram(c *ui.Context, values []float64, opts HistogramOptions) ChartResu 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 { + spec := ChartSpec{Marks: len(b.counts)} + spec.Readout = func(i int) string { if opts.Cumulative { return fmt.Sprintf("≤ %s · %d", edge(i+1, 100), b.counts[i]) } @@ -127,14 +127,14 @@ func Histogram(c *ui.Context, values []float64, opts HistogramOptions) ChartResu } 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 { + 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) { + 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 diff --git a/internal/chart.go b/internal/chart.go index 47df0ea..9598c7b 100644 --- a/internal/chart.go +++ b/internal/chart.go @@ -53,7 +53,7 @@ func Nice(lo, hi float64, n int) (nlo, nhi, step float64) { 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) + out = append(out, i*step+0) // +0 turns -0 into 0 } return out } @@ -159,10 +159,13 @@ func Marker(p *ui.Painter, i int, cx, cy, size float32, col ui.Color) { } // Arc appends a polyline from a0 to a1, clockwise radians. -func Arc(path *ui.Path, cx, cy, r float32, a0, a1 float64, move bool) { +func Arc(path *ui.Path, cx, cy, r float32, a0, a1 float64, move bool, painters ...*ui.Painter) { 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 len(painters) > 0 { + x, y = Snap(painters[0], x), Snap(painters[0], y) + } if i == 0 && move { path.MoveTo(x, y) } else { @@ -177,11 +180,11 @@ func Polar(cx, cy, r float32, a float64) (float32, float32) { } // Sector returns a ring slice; zero r0 makes a wedge. -func Sector(cx, cy, r0, r1 float32, a0, a1 float64) *ui.Path { +func Sector(cx, cy, r0, r1 float32, a0, a1 float64, painters ...*ui.Painter) *ui.Path { var path ui.Path - Arc(&path, cx, cy, r1, a0, a1, true) + Arc(&path, cx, cy, r1, a0, a1, true, painters...) if r0 > 0 { - Arc(&path, cx, cy, r0, a1, a0, false) + Arc(&path, cx, cy, r0, a1, a0, false, painters...) } else { path.LineTo(cx, cy) } @@ -191,27 +194,53 @@ func Sector(cx, cy, r0, r1 float32, a0, a1 float64) *ui.Path { // 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) + ys := make([]float32, len(ticks)) + labels := make([]string, len(ticks)) + for i, t := range ticks { + ys[i], labels[i] = s.Map(t), label(t) + Rule(p, plot.X, plot.X+plot.W, ys[i], k.Border) } + LabelsY(p, plot.X-6, ys, labels, false, size, k) } // 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) + LabelsX(p, plot, plot.Y+plot.H+6, xs, labels, false, size, k) +} + +// RuleV draws a pixel-aligned 1 DIP vertical line at x. +func RuleV(p *ui.Painter, x, y0, y1 float32, c ui.Color) { + xx := ruleY(p, x) + p.Line(xx, y0, xx, y1, 1, c) +} + +// LabelsY writes labels centered on ys, ending at x, or starting there if after. +func LabelsY(p *ui.Painter, x float32, ys []float32, labels []string, after bool, size float32, k theme.Tokens) { + for i, y := range ys { + w, h := p.MeasureText(0, ui.Span{Text: labels[i], Size: size}) + at := x - w + if after { + at = x + } + p.Text(at, y-h/2, labels[i], size, k.TextMuted) + } +} + +// LabelsX writes labels centered at xs from y, or ending at y if above; overlaps are skipped. +func LabelsX(p *ui.Painter, plot ui.Rect, y float32, xs []float32, labels []string, above bool, size float32, k theme.Tokens) { right := float32(math.Inf(-1)) for i, x := range xs { - w, _ := p.MeasureText(0, ui.Span{Text: labels[i], Size: size}) + w, h := 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) + top := y + if above { + top = y - h + } + p.Text(at, top, labels[i], size, k.TextMuted) right = at + w } } diff --git a/line_chart.go b/line_chart.go index 44a09c2..a51e83e 100644 --- a/line_chart.go +++ b/line_chart.go @@ -71,9 +71,9 @@ 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)) + keys := make([]ChartLegendItem, len(d.series)) for i, s := range d.series { - keys[i] = chartKey{Name: s.Name, Color: pal[i%len(pal)], Shape: i} + keys[i] = ChartLegendItem{Name: s.Name, Color: pal[i%len(pal)], Shape: i} } var ticks []float64 var step float64 @@ -86,15 +86,15 @@ func chartLines(ch *chart, d chartLineData) ChartResult { 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 { + spec := ChartSpec{Marks: d.n, Legend: keys, Hidden: ch.toggles(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 { + spec.Readout = func(i int) string { parts := []string{d.ax.label(i)} for _, s := range d.series { if !ch.hidden(s.Name) { @@ -103,7 +103,20 @@ func chartLines(ch *chart, d chartLineData) ChartResult { } return strings.Join(parts, " · ") } - spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + spec.Tooltip = func(i int) []ChartTooltipLine { + lines := []ChartTooltipLine{{Text: d.ax.label(i)}} + for si, s := range d.series { + if !ch.hidden(s.Name) { + lines = append(lines, ChartTooltipLine{Text: s.Name + " " + chartValue(c, s.Values[i], d.format), Color: keys[si].Color, Shape: si}) + } + } + return lines + } + spec.Anchor = func(r ui.Rect, i int) (float32, float32) { + plot, xs, _ := geom(r) + return xs.Map(d.ax.vals[i]), plot.Y + } + 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) diff --git a/messages_charts2.go b/messages_charts2.go new file mode 100644 index 0000000..a214257 --- /dev/null +++ b/messages_charts2.go @@ -0,0 +1,24 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "violin_plot.readout": {"%s · median %s · quartiles %s–%s · %d values", "%s · 中位数 %s · 四分位 %s–%s · %d 个值"}, + "parallel_coordinates.filtered": {"filtered out", "已筛除"}, + "parallel_coordinates.brush": {"%s filter %s–%s", "%s 筛选 %s–%s"}, + "chord_diagram.out": {"out", "流出"}, + "chord_diagram.in": {"in", "流入"}, + "chart_annotation.readout": {"Annotation %d · %s", "标注 %d · %s"}, + "chart_annotation.x": {"x %s", "x %s"}, + "chart_annotation.y": {"y %s", "y %s"}, + "spark_bar.empty": {"No data", "暂无数据"}, + "chart_empty_state.empty": {"No data", "暂无数据"}, + "chart_legend.more": {"+%d more", "另有 %d 项"}, + "chart_legend.fewer": {"Show fewer", "收起"}, + "chart_brush.window": {"%s: %s to %s", "%s:%s 至 %s"}, + "chart_brush.start": {"Range start %s", "区间起点 %s"}, + "chart_brush.end": {"Range end %s", "区间终点 %s"}, + "trend_indicator.up": {"up", "上升"}, + "trend_indicator.down": {"down", "下降"}, + "trend_indicator.flat": {"unchanged", "持平"}, + }) +} diff --git a/network_graph.go b/network_graph.go index c9f368e..5c5e66a 100644 --- a/network_graph.go +++ b/network_graph.go @@ -106,8 +106,8 @@ func NetworkGraph(c *ui.Context, s *NetworkGraphState, nodes []GraphNode, edges 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 { + spec := ChartSpec{Marks: len(nodes)} + spec.Readout = func(i int) string { var near []string for _, l := range links { switch i { @@ -119,10 +119,10 @@ func NetworkGraph(c *ui.Context, s *NetworkGraphState, nodes []GraphNode, edges } 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 { + 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) { + 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]) @@ -154,7 +154,7 @@ func NetworkGraph(c *ui.Context, s *NetworkGraphState, nodes []GraphNode, edges } 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 { + if i := spec.Pick(ui.Rect{W: b.W, H: b.H}, x, y); i >= 0 { return nodes[i].ID } return "" diff --git a/parallel_coordinates.go b/parallel_coordinates.go new file mode 100644 index 0000000..e2fd991 --- /dev/null +++ b/parallel_coordinates.go @@ -0,0 +1,275 @@ +package mujica + +import ( + "fmt" + "log/slog" + "maps" + "math" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ParallelCoordinatesOptions configure a ParallelCoordinates chart. +type ParallelCoordinatesOptions struct { + Frame ChartFrame + // Names labels rows; nil uses row numbers. + Names []string + Format func(v float64) string +} + +// pcDrag is a brush being dragged on one axis. +type pcDrag struct { + axis int + y0, y1 float32 +} + +// ParallelCoordinates draws rows across parallel axes; dragging an axis filters. +func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []string, rows [][]float64, opts ParallelCoordinatesOptions) ChartResult { + if filters == nil || len(axes) < 2 || opts.Names != nil && len(opts.Names) != len(rows) { + panic("mujica: ParallelCoordinates needs filters, two or more axes and a name per row") + } + seen := map[string]bool{} + for _, name := range axes { + if name == "" || seen[name] { + panic("mujica: ParallelCoordinates axes need unique names") + } + seen[name] = true + } + for name, f := range *filters { + if !seen[name] || math.IsNaN(f[0]) || math.IsNaN(f[1]) || math.IsInf(f[0], 0) || math.IsInf(f[1], 0) || f[0] > f[1] { + panic("mujica: ParallelCoordinates filters need known axes and finite ascending ranges") + } + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + size := FontSize(c, theme.CaptionSize) + ink := internal.Palette(k)[0] + type scales struct{ lo, hi []float64 } + sc := chartMemo(ch, "scales", [2]string{chartID(rows), chartID(axes)}, func() scales { + for _, r := range rows { + if len(r) != len(axes) { + panic("mujica: ParallelCoordinates rows need one value per axis") + } + for _, v := range r { + if math.IsInf(v, 0) { + panic("mujica: ParallelCoordinates values must be finite or NaN") + } + } + } + s := scales{lo: make([]float64, len(axes)), hi: make([]float64, len(axes))} + for a := range axes { + lo, hi := math.Inf(1), math.Inf(-1) + for _, r := range rows { + if !math.IsNaN(r[a]) { + lo, hi = min(lo, r[a]), max(hi, r[a]) + } + } + if math.IsInf(lo, 1) { + lo, hi = 0, 1 + } + s.lo[a], s.hi[a], _ = internal.Nice(lo, hi, 4) + } + return s + }) + keep := func(r []float64) bool { + for a, name := range axes { + if f, ok := (*filters)[name]; ok && (math.IsNaN(r[a]) || r[a] < f[0] || r[a] > f[1]) { + return false + } + } + return true + } + passing := chartMemo(ch, "pc_filter", [2]string{chartID(rows), fmt.Sprint(*filters)}, func() []bool { + out := make([]bool, len(rows)) + for i, row := range rows { + out[i] = keep(row) + } + return out + }) + visible := ui.Local(ch.res.Element, "mujica.pc.visible", func() []int { return nil }) + geom := func(r ui.Rect) (ui.Rect, func(a int) float32, func(a int, v float64) float32) { + plot := ui.Rect{X: r.X + 36, Y: r.Y + 24, W: r.W - 72, H: r.H - 32} + ax := func(a int) float32 { return plot.X + plot.W*float32(a)/float32(len(axes)-1) } + y := func(a int, v float64) float32 { + return plot.Y + plot.H*float32(1-(v-sc.lo[a])/(sc.hi[a]-sc.lo[a])) + } + return plot, ax, y + } + name := func(i int) string { + if opts.Names != nil { + return opts.Names[i] + } + return "#" + chartValue(c, float64(i+1), nil) + } + spec := ChartSpec{Marks: len(rows)} + spec.Readout = func(i int) string { + parts := []string{name(i)} + for a, n := range axes { + parts = append(parts, n+" "+chartValue(c, rows[i][a], opts.Format)) + } + if !passing[i] { + parts = append(parts, msg(c, "parallel_coordinates.filtered")) + } + return strings.Join(parts, " · ") + } + spec.Pick = func(r ui.Rect, x, y float32) int { + _, ax, ys := geom(r) + best, dist := -1, float32(8) + for _, i := range *visible { + row := rows[i] + for a := 0; a+1 < len(axes); a++ { + x0, x1 := ax(a), ax(a+1) + if x < x0 || x > x1 || math.IsNaN(row[a]) || math.IsNaN(row[a+1]) { + continue + } + t := (x - x0) / (x1 - x0) + if d := abs32(ys(a, row[a]) + t*(ys(a+1, row[a+1])-ys(a, row[a])) - y); d < dist { + best, dist = i, d + } + } + } + return best + } + drag := ui.Local(ch.res.Element, "mujica.pc.drag", func() pcDrag { return pcDrag{axis: -1} }) + keyAxis := ui.Local(ch.res.Element, "mujica.pc.axis", func() int { return 0 }) + spec.Input = func(plot *ui.Element, r ui.Rect) { + if plot.IsDisabled() { + *drag = pcDrag{axis: -1} + return + } + if plot.Shortcut(ui.Alt, ui.KeyPageDown) { + *keyAxis = (*keyAxis + 1) % len(axes) + } + if plot.Shortcut(ui.Alt, ui.KeyPageUp) { + *keyAxis = (*keyAxis + len(axes) - 1) % len(axes) + } + *keyAxis = min(*keyAxis, len(axes)-1) + a := *keyAxis + f, ok := (*filters)[axes[a]] + if !ok { + f = [2]float64{sc.lo[a], sc.hi[a]} + } + before := f + step := (sc.hi[a] - sc.lo[a]) / 100 + if plot.Shortcut(ui.Alt, ui.KeyUp) { + f[0] = min(f[1], f[0]+step) + } + if plot.Shortcut(ui.Alt, ui.KeyDown) { + f[0] = max(sc.lo[a], f[0]-step) + } + if plot.Shortcut(ui.Alt|ui.Shift, ui.KeyUp) { + f[1] = min(sc.hi[a], f[1]+step) + } + if plot.Shortcut(ui.Alt|ui.Shift, ui.KeyDown) { + f[1] = max(f[0], f[1]-step) + } + clear := plot.Shortcut(ui.Alt, ui.KeyDelete) + if f != before || clear && ok { + m := maps.Clone(*filters) + if m == nil { + m = map[string][2]float64{} + } + if clear { + delete(m, axes[a]) + } else { + m[axes[a]] = f + } + *filters, ch.res.value = m, true + c.Invalidate() + c.Announce(fmt.Sprintf(msg(c, "parallel_coordinates.brush"), axes[a], chartValue(c, f[0], opts.Format), chartValue(c, f[1], opts.Format))) + slog.Debug("mujica: parallel_coordinates keyboard", "axis", axes[a]) + } + box, ax, _ := geom(r) + px, py, _ := plot.PointerPosition() + _, _, held := plot.Dragged() + switch { + case held && drag.axis < 0: + for a := range axes { + if abs32(px-ax(a)) <= 12 { + *drag = pcDrag{axis: a, y0: py, y1: py} + } + } + case held: + drag.y1 = max(box.Y, min(box.Y+box.H, py)) + case drag.axis >= 0: + a := drag.axis + m := maps.Clone(*filters) + if m == nil { + m = map[string][2]float64{} + } + if abs32(drag.y1-drag.y0) < 4 { + delete(m, axes[a]) + } else { + v := func(y float32) float64 { + return sc.lo[a] + float64((box.Y+box.H-y)/box.H)*(sc.hi[a]-sc.lo[a]) + } + m[axes[a]] = [2]float64{min(v(drag.y0), v(drag.y1)), max(v(drag.y0), v(drag.y1))} + } + *filters, ch.res.value = m, true + c.Invalidate() + *drag = pcDrag{axis: -1} + slog.Debug("mujica: parallel_coordinates brush", "axis", axes[a], "filters", len(m)) + } + } + shownDrag := *drag + spec.Draw = func(p *ui.Painter, r ui.Rect, active int) { + plot, ax, ys := geom(r) + *visible = chartVisibleIndices(len(rows), max(1, int(plot.H*2)), active) + line := func(row []float64, w float32, col ui.Color) { + var path ui.Path + started := false + for a := range axes { + if math.IsNaN(row[a]) { + started = false + continue + } + x, y := internal.Snap(p, ax(a)), internal.Snap(p, ys(a, row[a])) + if started { + path.LineTo(x, y) + } else { + path.MoveTo(x, y) + } + started = true + } + p.StrokePath(&path, w, col) + } + for _, i := range *visible { + if !passing[i] { + line(rows[i], 1, k.Border) + } + } + for _, i := range *visible { + if passing[i] { + line(rows[i], 1.25, ink) + } + } + if active >= 0 { + line(rows[active], 3, k.Text) + } + for a, n := range axes { + x := internal.Snap(p, ax(a)) + p.Line(x, plot.Y, x, plot.Y+plot.H, 1.5, k.ControlBorder) + if f, ok := (*filters)[n]; ok { + y0, y1 := max(plot.Y, ys(a, f[1])), min(plot.Y+plot.H, ys(a, f[0])) + p.Fill(ui.Rect{X: x - 6, Y: y0, W: 12, H: max(y1-y0, 1)}, k.Selection, 2) + p.Stroke(ui.Rect{X: x - 6, Y: y0, W: 12, H: max(y1-y0, 1)}, k.AccentText, 2, 1.5) + } + chartText(p, n, x, plot.Y-8, size, k.Text, 0.5, 1) + chartText(p, internal.FormatTick(sc.hi[a], (sc.hi[a]-sc.lo[a])/4), x+10, plot.Y, size, k.TextMuted, 0, 0) + chartText(p, internal.FormatTick(sc.lo[a], (sc.hi[a]-sc.lo[a])/4), x+10, plot.Y+plot.H, size, k.TextMuted, 0, 1) + } + if shownDrag.axis >= 0 { + x := ax(shownDrag.axis) + p.Stroke(ui.Rect{X: x - 6, Y: min(shownDrag.y0, shownDrag.y1), W: 12, H: abs32(shownDrag.y1 - shownDrag.y0)}, k.AccentText, 2, 1.5) + } + } + res, _ := ch.build(spec) + if res.Element.IsDisabled() { + *drag = pcDrag{axis: -1} + } + return res +} diff --git a/parallel_coordinates_test.go b/parallel_coordinates_test.go new file mode 100644 index 0000000..1cb158d --- /dev/null +++ b/parallel_coordinates_test.go @@ -0,0 +1,132 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +var pcAxes = []string{"Age", "Hymns", "Vigils"} +var pcRows = [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}, {65, 1, math.NaN()}} +var pcNames = []string{"Agnès", "Clare", "Hild", "Bede"} + +func TestParallelCoordinatesReadoutAndBrush(t *testing.T) { + filters := map[string][2]float64{} + values := 0 + h := &chartHarness{build: func(c *ui.Context) ChartResult { + r := ParallelCoordinates(c, &filters, pcAxes, pcRows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Monks"}, Names: pcNames}) + if r.ValueChanged() { + values++ + } + return r + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Monks") + tt.Key(0, ui.KeyEnd) + if got := chartReadout(tt); got != "Bede · Age 65 · Hymns 1 · Vigils no value" { + t.Fatalf("readout %q", got) + } + b := h.res.Element.Bounds() + plot := ui.Rect{X: b.X + 36, Y: b.Y + 24, W: b.W - 72, H: 240 - 32} + // Age runs 20..80 on the first axis: brush 30..55. + y := func(v float32) float32 { return plot.Y + plot.H*(1-(v-20)/60) } + tt.Press(plot.X, y(55)) + tt.Move(plot.X, y(40)) + tt.Move(plot.X, y(30)) + tt.Release(plot.X, y(30)) + f, ok := filters["Age"] + if !ok || math.Abs(f[0]-30) > 0.5 || math.Abs(f[1]-55) > 0.5 || values != 1 { + t.Fatalf("brush %v %v (%d)", f, ok, values) + } + tt.Key(0, ui.KeyHome) + if got := chartReadout(tt); got != "Agnès · Age 20 · Hymns 3 · Vigils 9 · filtered out" { + t.Fatalf("filtered readout %q", got) + } + tt.Press(plot.X, y(45)) // a click on the axis clears it + tt.Release(plot.X, y(45)) + if _, ok := filters["Age"]; ok { + t.Fatal("click did not clear the brush") + } +} + +func TestParallelCoordinatesHoverAndBad(t *testing.T) { + filters := map[string][2]float64{} + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return ParallelCoordinates(c, &filters, pcAxes, pcRows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Monks"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + b := h.res.Element.Bounds() + plot := ui.Rect{X: b.X + 36, Y: b.Y + 24, W: b.W - 72, H: 240 - 32} + // Row 2 crosses this point between the first axes. + tt.Move(plot.X+plot.W/8, plot.Y+plot.H*(1-0.53125)) + if h.res.Point != 2 { + t.Fatalf("hover picked %d", h.res.Point) + } + defer func() { + if recover() == nil { + t.Fatal("no panic for a short row") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ParallelCoordinates(c, &filters, pcAxes, [][]float64{{1}}, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) +} + +func TestParallelCoordinatesSnapshot(t *testing.T) { + snapshot(t, "parallel_coordinates", 520, 320, func(c *ui.Context) { + filters := map[string][2]float64{"Hymns": {2, 9}} + ui.Column(c).Padding(16).Children(func() { + ParallelCoordinates(c, &filters, pcAxes, pcRows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Monks"}, Names: pcNames}) + }) + }) +} + +func TestParallelCoordinatesKeyboardBrushAndDisabled(t *testing.T) { + filters := map[string][2]float64{} + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return ParallelCoordinates(c, &filters, pcAxes, pcRows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Monks"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Monks") + tt.Key(ui.Alt, ui.KeyUp) + f, ok := filters["Age"] + if !ok || math.Abs(f[0]-20.6) > 1e-9 || f[1] != 80 { + t.Fatalf("keyboard brush %v", filters) + } + h.disabled = true + tt.Frame() + tt.Key(ui.Alt, ui.KeyUp) + if filters["Age"] != f { + t.Fatal("disabled keyboard brush") + } + h.disabled = false + tt.Frame() + tabTo(t, tt, "Monks") + tt.Key(ui.Alt, ui.KeyDelete) + if len(filters) != 0 { + t.Fatalf("clear brush %v", filters) + } +} + +func TestParallelCoordinatesLargeDataBounded(t *testing.T) { + rows := make([][]float64, 100_000) + for i := range rows { + rows[i] = []float64{float64(i % 70), float64(i % 11), float64(i % 17)} + } + filters := map[string][2]float64{} + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return ParallelCoordinates(c, &filters, pcAxes, rows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Large parallel"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + shown := ui.Local(h.res.Element, "mujica.pc.visible", func() []int { return nil }) + if len(*shown) == 0 || len(*shown) > 480 { + t.Fatalf("built %d rows", len(*shown)) + } + tabTo(t, tt, "Large parallel") + tt.Key(0, ui.KeyEnd) + if h.res.Point != 99_999 || len(*shown) > 481 { + t.Fatalf("last row %d, built %d", h.res.Point, len(*shown)) + } +} diff --git a/pie_chart.go b/pie_chart.go index 7024b6b..ae5b479 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -46,9 +46,9 @@ func PieChart(c *ui.Context, slices []ChartSlice, opts PieChartOptions) ChartRes if total == 0 { n = 0 } - keys := make([]chartKey, len(parts)) + keys := make([]ChartLegendItem, len(parts)) for i, s := range parts { - keys[i] = chartKey{Name: s.Label, Color: pal[i%len(pal)], Shape: i} + keys[i] = ChartLegendItem{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) { @@ -66,11 +66,11 @@ func PieChart(c *ui.Context, slices []ChartSlice, opts PieChartOptions) ChartRes 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 { + spec := ChartSpec{Marks: n, Legend: 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 { + 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 { @@ -87,7 +87,7 @@ func PieChart(c *ui.Context, slices []ChartSlice, opts PieChartOptions) ChartRes } return -1 } - spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + 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) @@ -105,7 +105,10 @@ func PieChart(c *ui.Context, slices []ChartSlice, opts PieChartOptions) ChartRes 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) + // The percent shows where the ring is wide enough to hold it. + if w, _ := p.MeasureText(0, ui.Span{Text: share(i), Size: size}); r1-r0 >= w+6 { + chartText(p, share(i), mx, my+2, size, ink, 0.5, 0) + } } } if opts.Donut { diff --git a/pie_chart_test.go b/pie_chart_test.go index addb559..19328dd 100644 --- a/pie_chart_test.go +++ b/pie_chart_test.go @@ -69,7 +69,7 @@ func TestPieChartBad(t *testing.T) { } func TestPieChartSnapshot(t *testing.T) { - snapshot(t, "pie_chart", 640, 320, func(c *ui.Context) { + snapshot(t, "pie_chart", 640, 360, 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}}) diff --git a/radar_chart.go b/radar_chart.go index d78b869..d554784 100644 --- a/radar_chart.go +++ b/radar_chart.go @@ -27,10 +27,10 @@ func RadarChart(c *ui.Context, axes []string, series []ChartSeries, opts RadarCh k := Tokens(c) pal := internal.Palette(k) size := FontSize(c, theme.CaptionSize) - keys := make([]chartKey, len(series)) + keys := make([]ChartLegendItem, len(series)) top := opts.Max for i, s := range series { - keys[i] = chartKey{Name: s.Name, Color: pal[i%len(pal)], Shape: i} + keys[i] = ChartLegendItem{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") @@ -54,8 +54,8 @@ func RadarChart(c *ui.Context, axes []string, series []ChartSeries, opts RadarCh 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 { + spec := ChartSpec{Marks: n, Legend: keys, Hidden: ch.toggles(len(series) > 1)} + spec.Readout = func(i int) string { parts := []string{axes[i]} for _, s := range series { if !ch.hidden(s.Name) { @@ -64,14 +64,14 @@ func RadarChart(c *ui.Context, axes []string, series []ChartSeries, opts RadarCh } return strings.Join(parts, " · ") } - spec.pick = func(r ui.Rect, x, y float32) int { + 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) { + 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 diff --git a/sankey_chart.go b/sankey_chart.go index edfe561..f66c4b2 100644 --- a/sankey_chart.go +++ b/sankey_chart.go @@ -127,15 +127,15 @@ func SankeyChart(c *ui.Context, nodes []SankeyNode, links []SankeyLink, opts San if len(links) == 0 { n = 0 } - spec := chartSpec{n: n} - spec.readout = func(i int) string { + spec := ChartSpec{Marks: 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 { + 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) { @@ -154,7 +154,7 @@ func SankeyChart(c *ui.Context, nodes []SankeyNode, links []SankeyLink, opts San } return -1 } - spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + 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 { diff --git a/scatter_chart.go b/scatter_chart.go index 1703011..20152a4 100644 --- a/scatter_chart.go +++ b/scatter_chart.go @@ -43,11 +43,11 @@ func ScatterChart(c *ui.Context, selected *[]ScatterKey, groups []ScatterGroup, k := Tokens(c) pal := internal.Palette(k) size := FontSize(c, theme.CaptionSize) - keys := make([]chartKey, len(groups)) + keys := make([]ChartLegendItem, 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} + keys[gi] = ChartLegendItem{Name: g.Name, Color: pal[gi%len(pal)], Shape: gi} if g.Name == "" || ch.hidden(g.Name) { continue } @@ -87,8 +87,8 @@ func ScatterChart(c *ui.Context, selected *[]ScatterKey, groups []ScatterGroup, 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 { + spec := ChartSpec{Marks: len(marks), Legend: keys, Hidden: ch.toggles(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 { @@ -99,14 +99,14 @@ func ScatterChart(c *ui.Context, selected *[]ScatterKey, groups []ScatterGroup, } return s } - spec.pick = func(r ui.Rect, x, y float32) int { + 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]) } + 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) { + 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 diff --git a/spark_bar.go b/spark_bar.go new file mode 100644 index 0000000..431e83f --- /dev/null +++ b/spark_bar.go @@ -0,0 +1,31 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + "math" +) + +// SparkBarOptions configure a SparkBar; Frame.Height 0 means 28 DIPs. +type SparkBarOptions struct { + Frame ChartFrame + // BarWidth defaults to 6 DIPs; width fits the window. + BarWidth float32 + Format func(v float64) string +} + +// SparkBar draws signed inline bars and highlights the last. +func SparkBar(c *ui.Context, values []float64, opts SparkBarOptions) ChartResult { + if opts.BarWidth < 0 || math.IsNaN(float64(opts.BarWidth)) || math.IsInf(float64(opts.BarWidth), 0) { + panic("mujica: SparkBar BarWidth must be ≥ 0") + } + bw := opts.BarWidth + if bw == 0 { + bw = 6 + } + w, _ := c.Size() + frame := opts.Frame + if frame.Empty == "" { + frame.Empty = msg(c, "spark_bar.empty") + } + return Sparkline(c, values, SparklineOptions{Frame: frame, Kind: SparklineBar, Width: min(max(28, float32(len(values))*(bw+1)+6), max(28, w-48)), Format: opts.Format}) +} diff --git a/spark_bar_test.go b/spark_bar_test.go new file mode 100644 index 0000000..4e468c3 --- /dev/null +++ b/spark_bar_test.go @@ -0,0 +1,161 @@ +package mujica + +import ( + "image/color" + "math" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +var sparkBarValues = []float64{3, -2, 4, 1, -3} + +func TestSparkBarWidthAndColors(t *testing.T) { + values := sparkBarValues + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "Alms"}, BarWidth: 10}) + }} + tt := ui.NewTester(h.view, 300, 200) + r, _ := tt.Find("Alms") + if r.W != 5*11+6 || chartReadout(tt) != "-3" { + t.Fatalf("box %v readout %q", r, chartReadout(tt)) + } + k := theme.Light() + at := func(i int, below bool) color.RGBA { + x := r.X + 3 + (r.W-6)*float32(i)/4 + y := r.Y + r.H*0.3 + if below { + y = r.Y + r.H*0.65 + } + return color.RGBAModel.Convert(tt.Image().At(int(x), int(y))).(color.RGBA) + } + if at(4, true) != toRGBA(k.Danger) || at(0, false) != toRGBA(k.TextMuted) { + t.Fatalf("last fall %v, first rise %v", at(4, true), at(0, false)) + } + tabTo(t, tt, "Alms") + tt.Key(0, ui.KeyHome) + if chartReadout(tt) != "3" || at(0, false) != toRGBA(internal.Palette(k)[0]) { + t.Fatalf("read bar not highlighted: %q", chartReadout(tt)) + } + defer func() { + if recover() == nil { + t.Fatal("no panic for a negative width") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "x"}, BarWidth: -1}) + }, 100, 100) +} + +func TestSparkBarSnapshot(t *testing.T) { + snapshot(t, "spark_bar", 360, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(24).Children(func() { + SparkBar(c, sparkBarValues, SparkBarOptions{Frame: ChartFrame{Label: "Alms"}}) + SparkBar(c, []float64{1, 3, 2, 5, 4, 6, 8}, SparkBarOptions{Frame: ChartFrame{Label: "Bells"}, BarWidth: 8}) + }) + }) +} + +func TestSparkBarShrinkingAndDisabled(t *testing.T) { + values := []float64{1, 2, 3, 4} + disabled := false + var r ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Disabled(disabled).Children(func() { + r = SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "Bars"}}) + }) + }, 320, 100) + tabTo(t, tt, "Bars") + tt.Key(0, ui.KeyEnd) + values = []float64{7} + tt.Frame() + if r.Point != 0 || !tt.HasText("7") { + t.Fatalf("shortened data: point %d, text %q", r.Point, tt.Texts()) + } + disabled = true + tt.Frame() + tt.Key(0, ui.KeyLeft) + if r.Point != -1 { + t.Fatalf("disabled point %d", r.Point) + } +} + +func TestSparkBarLargeData(t *testing.T) { + values := make([]float64, 100_000) + for i := range values { + values[i] = float64(i%17 - 8) + } + var built int + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + res := SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "Large bars"}}) + memo := ui.Local(res.Element, "mujica.sparkline.data", func() sparkMemo { return sparkMemo{} }) + built = len(memo.indices) + }, 320, 100) + tt.Frame() + r, _ := tt.Find("Large bars") + if built == 0 || built > 4*int(r.W)+4 { + t.Fatalf("built %d bars for width %v", built, r.W) + } + if r.W > 320 { + t.Fatalf("unbounded plot width %v", r.W) + } + tabTo(t, tt, "Large bars") + tt.Key(0, ui.KeyEnd) + if !tt.HasText("-3") { + t.Fatalf("last value %q", tt.Texts()) + } +} + +func TestSparkBarFiniteWidthAndStates(t *testing.T) { + charts2MustPanic(t, func(c *ui.Context) { + SparkBar(c, []float64{1}, SparkBarOptions{Frame: ChartFrame{Label: "Bars"}, BarWidth: float32(math.Inf(1))}) + }) + status := DataReady + retries := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := SparkBar(c, []float64{2, -1, 3}, SparkBarOptions{Frame: ChartFrame{Label: "Bars", Status: status, Height: 40}}) + if r.Retried() { + retries++ + } + }, 320, 100) + ready, _ := tt.Find("Bars") + status = DataFailed + tt.Frame() + if err := tt.Click("Retry"); err != nil || retries != 1 { + t.Fatalf("retry %v count %d", err, retries) + } + status = DataLoading + tt.Frame() + if ready.W != 28 || ready.H != 40 { + t.Fatalf("ready plot %v", ready) + } + status = DataReady + tt.Frame() + tt.Move(319, 99) + tabTo(t, tt, "Bars") + tt.Key(0, ui.KeyEnd) + if !tt.HasText("3") { + t.Fatalf("restored data %q", tt.Texts()) + } +} + +func TestSparkBarMinimumHitWidth(t *testing.T) { + for _, values := range [][]float64{nil, {1}} { + var result ChartResult + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + result = SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "Bars"}}) + }, 200, 100) + tt.Frame() + if result.Element.Bounds().W < 28 { + t.Fatalf("hit width %v", result.Element.Bounds().W) + } + } +} diff --git a/sparkline.go b/sparkline.go index 7048ebe..43c25a7 100644 --- a/sparkline.go +++ b/sparkline.go @@ -32,7 +32,7 @@ type SparklineOptions struct { // 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 { + if f.Label == "" || f.Height < 0 || opts.Width < 0 || opts.Kind < SparklineLine || opts.Kind > SparklineArea { panic("mujica: Sparkline needs a Label, sizes ≥ 0 and a known Kind") } if f.Height == 0 { @@ -46,16 +46,23 @@ func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResu 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).MinWidth(0) + memo := ui.Local(res.Element, "mujica.sparkline.data", func() sparkMemo { return sparkMemo{} }) + if id := chartID(values); memo.id != id { + *memo = sparkMemo{id: id, lo: math.Inf(1), hi: math.Inf(-1), last: -1} + for i, v := range values { + if math.IsInf(v, 0) { + panic("mujica: Sparkline values must be finite or NaN") + } + if !math.IsNaN(v) { + memo.lo, memo.hi, memo.last = min(memo.lo, v), max(memo.hi, v), i + } } } - res := ChartResult{Point: -1} - res.Element = ui.Row(c).Gap(u * 2).AlignItems(ui.Center) + lo, hi, last := memo.lo, memo.hi, memo.last st := ui.Local(res.Element, "mujica.sparkline", func() chartState { return chartState{focus: -1, last: -1} }) + st.focus = min(st.focus, len(values)-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) @@ -115,22 +122,18 @@ func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResu 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() { + } else if plot.Focused() && !plot.IsDisabled() { 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) + if memo.width != r.W || memo.indices == nil { + memo.width = r.W + memo.indices = internal.Columns(n, func(i int) float32 { return x(r, i) }, func(i int) float64 { return values[i] }) } + sparkBars(p, r, values, memo.indices, func(i int) float32 { return x(r, i) }, s, last, active, k) } else { chartRuns(values, func(i int) float32 { return x(r, i) }, func(idx []int) { var line ui.Path @@ -168,3 +171,34 @@ func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResu st.last = res.Point return res } + +type sparkMemo struct { + id string + lo, hi float64 + last int + width float32 + indices []int +} + +// sparkBars highlights the last and active bars. +func sparkBars(p *ui.Painter, r ui.Rect, values []float64, indices []int, x func(int) float32, s internal.Linear, last, active int, k theme.Tokens) { + n := len(values) + bw := max((r.W-6)/float32(n)-1, 1) + zero := s.Map(0) + internal.Rule(p, r.X, r.X+r.W, zero, k.Border) + for _, i := range indices { + v := values[i] + if math.IsNaN(v) { + continue + } + col := k.TextMuted + if i == last || i == active { + col = internal.Palette(k)[0] + if v < 0 { + col = k.Danger + } + } + b := s.Map(v) + p.Fill(ui.Rect{X: x(i) - bw/2, Y: min(zero, b), W: bw, H: max(abs32(zero-b), 1)}, col, 0) + } +} diff --git a/sunburst.go b/sunburst.go index 6ee6712..d6e17de 100644 --- a/sunburst.go +++ b/sunburst.go @@ -82,8 +82,8 @@ func Sunburst(c *ui.Context, path *[]string, root ChartNode, opts SunburstOption 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 { + spec := ChartSpec{Marks: 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 { @@ -91,7 +91,7 @@ func Sunburst(c *ui.Context, path *[]string, root ChartNode, opts SunburstOption } return s } - spec.pick = func(r ui.Rect, x, y float32) int { + 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)) @@ -106,7 +106,7 @@ func Sunburst(c *ui.Context, path *[]string, root ChartNode, opts SunburstOption } return -1 } - spec.activate = func(i int) { + spec.Activate = func(i int) { if len(arcs[i].node.Children) > 0 { *path = append(slices.Clone(*path), arcs[i].path...) res.value = true @@ -121,7 +121,7 @@ func Sunburst(c *ui.Context, path *[]string, root ChartNode, opts SunburstOption } return col } - spec.draw = func(p *ui.Painter, r ui.Rect, active int) { + 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) diff --git a/testdata/snapshots/darwin/area_chart-dark.png b/testdata/snapshots/darwin/area_chart-dark.png index 26128f5..7bf10dd 100644 Binary files a/testdata/snapshots/darwin/area_chart-dark.png 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 index 101392a..1e2f471 100644 Binary files a/testdata/snapshots/darwin/area_chart-light.png 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 index 118d393..de88c4d 100644 Binary files a/testdata/snapshots/darwin/bar_chart-dark.png 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 index 17580fc..de023b7 100644 Binary files a/testdata/snapshots/darwin/bar_chart-light.png and b/testdata/snapshots/darwin/bar_chart-light.png differ diff --git a/testdata/snapshots/darwin/chart_annotation-dark.png b/testdata/snapshots/darwin/chart_annotation-dark.png new file mode 100644 index 0000000..7373a68 Binary files /dev/null and b/testdata/snapshots/darwin/chart_annotation-dark.png differ diff --git a/testdata/snapshots/darwin/chart_annotation-light.png b/testdata/snapshots/darwin/chart_annotation-light.png new file mode 100644 index 0000000..3483dde Binary files /dev/null and b/testdata/snapshots/darwin/chart_annotation-light.png differ diff --git a/testdata/snapshots/darwin/chart_axis-dark.png b/testdata/snapshots/darwin/chart_axis-dark.png new file mode 100644 index 0000000..a2b3a33 Binary files /dev/null and b/testdata/snapshots/darwin/chart_axis-dark.png differ diff --git a/testdata/snapshots/darwin/chart_axis-light.png b/testdata/snapshots/darwin/chart_axis-light.png new file mode 100644 index 0000000..51f2aba Binary files /dev/null and b/testdata/snapshots/darwin/chart_axis-light.png differ diff --git a/testdata/snapshots/darwin/chart_brush-dark.png b/testdata/snapshots/darwin/chart_brush-dark.png new file mode 100644 index 0000000..675e292 Binary files /dev/null and b/testdata/snapshots/darwin/chart_brush-dark.png differ diff --git a/testdata/snapshots/darwin/chart_brush-light.png b/testdata/snapshots/darwin/chart_brush-light.png new file mode 100644 index 0000000..d28c948 Binary files /dev/null and b/testdata/snapshots/darwin/chart_brush-light.png differ diff --git a/testdata/snapshots/darwin/chart_crosshair-dark.png b/testdata/snapshots/darwin/chart_crosshair-dark.png new file mode 100644 index 0000000..73a0f9b Binary files /dev/null and b/testdata/snapshots/darwin/chart_crosshair-dark.png differ diff --git a/testdata/snapshots/darwin/chart_crosshair-light.png b/testdata/snapshots/darwin/chart_crosshair-light.png new file mode 100644 index 0000000..e7efc3c Binary files /dev/null and b/testdata/snapshots/darwin/chart_crosshair-light.png differ diff --git a/testdata/snapshots/darwin/chart_empty_state-dark.png b/testdata/snapshots/darwin/chart_empty_state-dark.png new file mode 100644 index 0000000..719b5e1 Binary files /dev/null and b/testdata/snapshots/darwin/chart_empty_state-dark.png differ diff --git a/testdata/snapshots/darwin/chart_empty_state-light.png b/testdata/snapshots/darwin/chart_empty_state-light.png new file mode 100644 index 0000000..c8c2add Binary files /dev/null and b/testdata/snapshots/darwin/chart_empty_state-light.png differ diff --git a/testdata/snapshots/darwin/chart_export-dark.png b/testdata/snapshots/darwin/chart_export-dark.png new file mode 100644 index 0000000..bbb754f Binary files /dev/null and b/testdata/snapshots/darwin/chart_export-dark.png differ diff --git a/testdata/snapshots/darwin/chart_export-light.png b/testdata/snapshots/darwin/chart_export-light.png new file mode 100644 index 0000000..e699b43 Binary files /dev/null and b/testdata/snapshots/darwin/chart_export-light.png differ diff --git a/testdata/snapshots/darwin/chart_grid-dark.png b/testdata/snapshots/darwin/chart_grid-dark.png new file mode 100644 index 0000000..740fa75 Binary files /dev/null and b/testdata/snapshots/darwin/chart_grid-dark.png differ diff --git a/testdata/snapshots/darwin/chart_grid-light.png b/testdata/snapshots/darwin/chart_grid-light.png new file mode 100644 index 0000000..18eb005 Binary files /dev/null and b/testdata/snapshots/darwin/chart_grid-light.png differ diff --git a/testdata/snapshots/darwin/chart_legend-dark.png b/testdata/snapshots/darwin/chart_legend-dark.png new file mode 100644 index 0000000..a250fad Binary files /dev/null and b/testdata/snapshots/darwin/chart_legend-dark.png differ diff --git a/testdata/snapshots/darwin/chart_legend-light.png b/testdata/snapshots/darwin/chart_legend-light.png new file mode 100644 index 0000000..69142d4 Binary files /dev/null and b/testdata/snapshots/darwin/chart_legend-light.png differ diff --git a/testdata/snapshots/darwin/chart_tooltip-dark.png b/testdata/snapshots/darwin/chart_tooltip-dark.png new file mode 100644 index 0000000..df9c018 Binary files /dev/null and b/testdata/snapshots/darwin/chart_tooltip-dark.png differ diff --git a/testdata/snapshots/darwin/chart_tooltip-light.png b/testdata/snapshots/darwin/chart_tooltip-light.png new file mode 100644 index 0000000..692cfc2 Binary files /dev/null and b/testdata/snapshots/darwin/chart_tooltip-light.png differ diff --git a/testdata/snapshots/darwin/chord_diagram-dark.png b/testdata/snapshots/darwin/chord_diagram-dark.png new file mode 100644 index 0000000..d56cf01 Binary files /dev/null and b/testdata/snapshots/darwin/chord_diagram-dark.png differ diff --git a/testdata/snapshots/darwin/chord_diagram-light.png b/testdata/snapshots/darwin/chord_diagram-light.png new file mode 100644 index 0000000..8a442a5 Binary files /dev/null and b/testdata/snapshots/darwin/chord_diagram-light.png differ diff --git a/testdata/snapshots/darwin/density_plot-dark.png b/testdata/snapshots/darwin/density_plot-dark.png new file mode 100644 index 0000000..40e8501 Binary files /dev/null and b/testdata/snapshots/darwin/density_plot-dark.png differ diff --git a/testdata/snapshots/darwin/density_plot-light.png b/testdata/snapshots/darwin/density_plot-light.png new file mode 100644 index 0000000..8ee0cf9 Binary files /dev/null and b/testdata/snapshots/darwin/density_plot-light.png differ diff --git a/testdata/snapshots/darwin/line_chart-dark.png b/testdata/snapshots/darwin/line_chart-dark.png index 92043c4..86d8b4f 100644 Binary files a/testdata/snapshots/darwin/line_chart-dark.png 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 index 4de7657..50f041c 100644 Binary files a/testdata/snapshots/darwin/line_chart-light.png and b/testdata/snapshots/darwin/line_chart-light.png differ diff --git a/testdata/snapshots/darwin/parallel_coordinates-dark.png b/testdata/snapshots/darwin/parallel_coordinates-dark.png new file mode 100644 index 0000000..edc2f33 Binary files /dev/null and b/testdata/snapshots/darwin/parallel_coordinates-dark.png differ diff --git a/testdata/snapshots/darwin/parallel_coordinates-light.png b/testdata/snapshots/darwin/parallel_coordinates-light.png new file mode 100644 index 0000000..ee101f4 Binary files /dev/null and b/testdata/snapshots/darwin/parallel_coordinates-light.png differ diff --git a/testdata/snapshots/darwin/pie_chart-dark.png b/testdata/snapshots/darwin/pie_chart-dark.png index 8629a99..3e78c2d 100644 Binary files a/testdata/snapshots/darwin/pie_chart-dark.png 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 index 8237cec..78ad896 100644 Binary files a/testdata/snapshots/darwin/pie_chart-light.png 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 index aa910f8..71da4b5 100644 Binary files a/testdata/snapshots/darwin/radar_chart-dark.png 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 index 3ce2f97..cc06ff9 100644 Binary files a/testdata/snapshots/darwin/radar_chart-light.png 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 index 08f61cf..da146e0 100644 Binary files a/testdata/snapshots/darwin/realtime_chart-dark.png 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 index acfc8c4..1a59f85 100644 Binary files a/testdata/snapshots/darwin/realtime_chart-light.png and b/testdata/snapshots/darwin/realtime_chart-light.png differ diff --git a/testdata/snapshots/darwin/scatter_chart-dark.png b/testdata/snapshots/darwin/scatter_chart-dark.png index 35cea09..b59173d 100644 Binary files a/testdata/snapshots/darwin/scatter_chart-dark.png 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 index 2838b97..17f56bd 100644 Binary files a/testdata/snapshots/darwin/scatter_chart-light.png and b/testdata/snapshots/darwin/scatter_chart-light.png differ diff --git a/testdata/snapshots/darwin/spark_bar-dark.png b/testdata/snapshots/darwin/spark_bar-dark.png new file mode 100644 index 0000000..01ff97a Binary files /dev/null and b/testdata/snapshots/darwin/spark_bar-dark.png differ diff --git a/testdata/snapshots/darwin/spark_bar-light.png b/testdata/snapshots/darwin/spark_bar-light.png new file mode 100644 index 0000000..566ada6 Binary files /dev/null and b/testdata/snapshots/darwin/spark_bar-light.png differ diff --git a/testdata/snapshots/darwin/sparkline-dark.png b/testdata/snapshots/darwin/sparkline-dark.png index f5e0358..28e8936 100644 Binary files a/testdata/snapshots/darwin/sparkline-dark.png 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 index e17c629..d30712d 100644 Binary files a/testdata/snapshots/darwin/sparkline-light.png and b/testdata/snapshots/darwin/sparkline-light.png differ diff --git a/testdata/snapshots/darwin/trend_indicator-dark.png b/testdata/snapshots/darwin/trend_indicator-dark.png new file mode 100644 index 0000000..88f5b88 Binary files /dev/null and b/testdata/snapshots/darwin/trend_indicator-dark.png differ diff --git a/testdata/snapshots/darwin/trend_indicator-light.png b/testdata/snapshots/darwin/trend_indicator-light.png new file mode 100644 index 0000000..3ebf0ee Binary files /dev/null and b/testdata/snapshots/darwin/trend_indicator-light.png differ diff --git a/testdata/snapshots/darwin/violin_plot-dark.png b/testdata/snapshots/darwin/violin_plot-dark.png new file mode 100644 index 0000000..62bd862 Binary files /dev/null and b/testdata/snapshots/darwin/violin_plot-dark.png differ diff --git a/testdata/snapshots/darwin/violin_plot-light.png b/testdata/snapshots/darwin/violin_plot-light.png new file mode 100644 index 0000000..46e353b Binary files /dev/null and b/testdata/snapshots/darwin/violin_plot-light.png differ diff --git a/treemap.go b/treemap.go index 9b072af..8740523 100644 --- a/treemap.go +++ b/treemap.go @@ -46,15 +46,15 @@ func Treemap(c *ui.Context, path *[]string, root ChartNode, opts TreemapOptions) } 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 { + spec := ChartSpec{Marks: 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 { + spec.Pick = func(r ui.Rect, x, y float32) int { for i, t := range tiles(r) { if t.Contains(x, y) { return i @@ -62,14 +62,14 @@ func Treemap(c *ui.Context, path *[]string, root ChartNode, opts TreemapOptions) } return -1 } - spec.activate = func(i int) { + 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) { + 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) diff --git a/trend_indicator.go b/trend_indicator.go new file mode 100644 index 0000000..397143d --- /dev/null +++ b/trend_indicator.go @@ -0,0 +1,70 @@ +package mujica + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// TrendIndicatorOptions configure a TrendIndicator. +type TrendIndicatorOptions struct { + // Precision is the decimals shown, 0 or more. + Precision int + // Unit follows the number, as "%". + Unit string + // Note explains the change, as "vs last week". + Note string + // Invert marks falls as good, as for costs or errors. + Invert bool + // Format replaces the built-in signed number. + Format func(v float64) string +} + +// TrendIndicator shows signed change, direction, and semantic color. +func TrendIndicator(c *ui.Context, change float64, opts TrendIndicatorOptions) *ui.Element { + if opts.Precision < 0 || math.IsNaN(change) || math.IsInf(change, 0) { + panic("mujica: TrendIndicator needs a number and Precision ≥ 0") + } + k := Tokens(c) + u := Density(c).Unit() + word, text := trendWords(c, change, opts) + col, icon := k.TextMuted, "minus" + switch { + case change > 0: + col, icon = k.Success, "arrow-up" + case change < 0: + col, icon = k.Danger, "arrow-down" + } + if opts.Invert && change != 0 { + col = map[bool]ui.Color{true: k.Danger, false: k.Success}[change > 0] + } + e := ui.Row(c).Gap(u).AlignItems(ui.Center).Wrap().Role(ui.RoleText).Label(word + " " + text) + e.Children(func() { + ui.Row(c).Gap(u / 2).AlignItems(ui.Center).TextColor(col).Children(func() { + ui.Icon(c, icons.Must(icon)).Size(14, 14) + ui.Text(c, text).FontWeight(600).SingleLine() + }) + if opts.Note != "" { + ui.Text(c, opts.Note).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted) + } + }) + return e +} + +// trendWords formats direction and signed change. +func trendWords(c *ui.Context, change float64, opts TrendIndicatorOptions) (word, text string) { + word = msg(c, "trend_indicator.flat") + switch { + case change > 0: + word = msg(c, "trend_indicator.up") + case change < 0: + word = msg(c, "trend_indicator.down") + } + if opts.Format != nil { + return word, opts.Format(change) + } + return word, statisticSigned(change, opts.Precision) + opts.Unit +} diff --git a/trend_indicator_test.go b/trend_indicator_test.go new file mode 100644 index 0000000..f2dce8a --- /dev/null +++ b/trend_indicator_test.go @@ -0,0 +1,90 @@ +package mujica + +import ( + "image/color" + "math" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +func TestTrendIndicatorWordsAndColors(t *testing.T) { + for _, tc := range []struct { + change float64 + invert bool + label string + good bool + }{ + {12.5, false, "up +12.5%", true}, + {-3, false, "down −3.0%", false}, + {-3, true, "down −3.0%", true}, + {0, false, "unchanged 0.0%", false}, + } { + var r ui.Rect + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Light}) + ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() { + r = TrendIndicator(c, tc.change, TrendIndicatorOptions{Precision: 1, Unit: "%", Invert: tc.invert, Note: "vs Lent"}).Bounds() + }) + }, 300, 100) + tt.Frame() + if _, ok := tt.Find(tc.label); !ok || !tt.HasText("vs Lent") { + t.Fatalf("%v: no %q in %q", tc.change, tc.label, tt.Texts()) + } + k := theme.Light() + want := map[bool]color.RGBA{true: toRGBA(k.Success), false: toRGBA(k.Danger)}[tc.good] + if tc.change == 0 { + continue + } + found := false + img := tt.Image() + for x := int(r.X); x < int(r.X+16) && !found; x++ { + for y := int(r.Y); y < int(r.Y+r.H) && !found; y++ { + found = color.RGBAModel.Convert(img.At(x, y)).(color.RGBA) == want + } + } + if !found { + t.Fatalf("%v invert=%v: arrow not in %v", tc.change, tc.invert, want) + } + } +} + +func TestTrendIndicatorFormatAndBad(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TrendIndicator(c, 2, TrendIndicatorOptions{Format: func(v float64) string { return "two more" }}) + }, 200, 100) + if !tt.HasText("two more") { + t.Fatalf("format %q", tt.Texts()) + } + defer func() { + if recover() == nil { + t.Fatal("no panic for NaN") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); TrendIndicator(c, math.NaN(), TrendIndicatorOptions{}) }, 100, 100) +} + +func TestTrendIndicatorSnapshot(t *testing.T) { + snapshot(t, "trend_indicator", 420, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(24).Children(func() { + TrendIndicator(c, 12.5, TrendIndicatorOptions{Precision: 1, Unit: "%", Note: "vs Lent"}) + TrendIndicator(c, -3, TrendIndicatorOptions{Unit: " souls"}) + TrendIndicator(c, -4, TrendIndicatorOptions{Unit: "%", Invert: true}) + TrendIndicator(c, 0, TrendIndicatorOptions{}) + }) + }) +} + +func TestTrendIndicatorFiniteAndChinese(t *testing.T) { + charts2MustPanic(t, func(c *ui.Context) { TrendIndicator(c, math.Inf(1), TrendIndicatorOptions{}) }) + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: ZhCN}) + TrendIndicator(c, -2, TrendIndicatorOptions{Unit: "%"}) + }, 200, 100) + if _, ok := tt.Find("下降 −2%"); !ok { + t.Fatalf("Chinese readout %q", tt.Texts()) + } +} diff --git a/violin_plot.go b/violin_plot.go new file mode 100644 index 0000000..1f4884c --- /dev/null +++ b/violin_plot.go @@ -0,0 +1,159 @@ +package mujica + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ViolinPlotOptions configure a ViolinPlot. +type ViolinPlotOptions struct { + Frame ChartFrame + // Bandwidth defaults to the per-group estimate. + Bandwidth float64 + Format func(v float64) string +} + +// violinShape stores density samples and quartiles. +type violinShape struct { + ys, dens []float64 + stats boxStats +} + +// violinPoints is how many values each outline is sampled at. +const violinPoints = 64 + +// ViolinPlot compares density outlines, medians, and quartiles. +func ViolinPlot(c *ui.Context, groups []BoxGroup, opts ViolinPlotOptions) ChartResult { + if opts.Bandwidth < 0 || math.IsNaN(opts.Bandwidth) || math.IsInf(opts.Bandwidth, 0) { + panic("mujica: ViolinPlot Bandwidth must be ≥ 0") + } + ch := newChart(c, opts.Frame) + k := Tokens(c) + pal := internal.Palette(k) + size := FontSize(c, theme.CaptionSize) + ids := "" + seen := map[string]bool{} + for _, g := range groups { + if g.Name == "" || seen[g.Name] { + panic("mujica: ViolinPlot groups need names") + } + seen[g.Name] = true + ids += chartID(g.Values) + ";" + } + shapes := chartMemo(ch, "violins", fmt.Sprint(ids, opts.Bandwidth), func() []violinShape { + out := make([]violinShape, len(groups)) + for i, g := range groups { + v := densitySample(g.Values) + if len(v) == 0 { + continue + } + h := opts.Bandwidth + if h == 0 { + h = densityBandwidth(v) + } + s := boxSummary(v) + lo, hi := s.lo, s.hi + for _, o := range s.outliers { + lo, hi = min(lo, o), max(hi, o) + } + if lo == hi { + lo, hi = lo-3*h, hi+3*h + } + ys := make([]float64, violinPoints) + for j := range ys { + ys[j] = lo + (hi-lo)*float64(j)/float64(violinPoints-1) + } + out[i] = violinShape{ys: ys, dens: densityKDE(v, h, ys), stats: s} + } + return out + }) + lo, hi := math.Inf(1), math.Inf(-1) + for _, s := range shapes { + if s.stats.n > 0 { + lo, hi = min(lo, s.ys[0]), max(hi, s.ys[len(s.ys)-1]) + } + } + var step float64 + n := len(groups) + if math.IsInf(lo, 1) { + n = 0 + } else { + lo, hi, step = internal.Nice(lo, hi, 4) + } + 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.15}, + internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)} + } + visible := ui.Local(ch.res.Element, "mujica.violin.visible", func() []int { return nil }) + spec := ChartSpec{Marks: n} + spec.Readout = func(i int) string { + s := shapes[i].stats + if s.n == 0 { + return groups[i].Name + " · " + msg(c, "chart.missing") + } + return fmt.Sprintf(msg(c, "violin_plot.readout"), groups[i].Name, val(s.median), val(s.q1), val(s.q3), s.n) + } + 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 + *visible = chartVisibleIndices(len(groups), max(1, int(plot.W)), active) + for _, i := range *visible { + xs, names = append(xs, band.Center(i)), append(names, groups[i].Name) + } + internal.AxisX(p, plot, xs, names, size, k) + for _, i := range *visible { + s := shapes[i] + if s.stats.n == 0 { + continue + } + // Each violin fills its band at its own widest point. + cx, half, top := band.Center(i), band.Width()/2, 0.0 + for _, d := range s.dens { + top = max(top, d) + } + var outline ui.Path + for j, y := range s.ys { + x := internal.Snap(p, cx+half*float32(s.dens[j]/top)) + py := internal.Snap(p, ys.Map(y)) + if j == 0 { + outline.MoveTo(x, py) + } else { + outline.LineTo(x, py) + } + } + for j := len(s.ys) - 1; j >= 0; j-- { + outline.LineTo(internal.Snap(p, cx-half*float32(s.dens[j]/top)), internal.Snap(p, ys.Map(s.ys[j]))) + } + outline.Close() + col := pal[i%len(pal)] + p.FillPath(&outline, col.Alpha(0.22)) + w := float32(1.5) + if i == active { + w = 3 + } + p.StrokePath(&outline, w, col) + // Quartile bars and median diamonds distinguish values. + p.Fill(ui.Rect{X: cx - 3, Y: ys.Map(s.stats.q3), W: 6, H: max(ys.Map(s.stats.q1)-ys.Map(s.stats.q3), 1)}, k.Text, 1) + p.FillPath(internal.Diamond(internal.Snap(p, cx), internal.Snap(p, ys.Map(s.stats.median)), 10), k.Surface) + p.FillPath(internal.Diamond(internal.Snap(p, cx), internal.Snap(p, ys.Map(s.stats.median)), 7), k.Text) + } + } + res, _ := ch.build(spec) + return res +} diff --git a/violin_plot_test.go b/violin_plot_test.go new file mode 100644 index 0000000..a6c5d6c --- /dev/null +++ b/violin_plot_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestViolinPlotReadout(t *testing.T) { + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return ViolinPlot(c, append(densityGroups(), boxGroups[0]), ViolinPlotOptions{Frame: ChartFrame{Label: "Hymns"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Hymns") + tt.Key(0, ui.KeyEnd) + if got := chartReadout(tt); got != "Choir · median 5.5 · quartiles 3.25–7.75 · 10 values" { + t.Fatalf("readout %q", got) + } + tt.Key(0, ui.KeyEscape) + b := h.res.Element.Bounds() + tt.Move(b.X+52+(b.W-64)/6, b.Y+120) + if h.res.Point != 0 { + t.Fatalf("hover %d", h.res.Point) + } +} + +func TestViolinPlotBad(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for an unnamed group") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ViolinPlot(c, []BoxGroup{{Values: []float64{1}}}, ViolinPlotOptions{Frame: ChartFrame{Label: "x"}}) + }, 300, 300) +} + +func TestViolinPlotSnapshot(t *testing.T) { + snapshot(t, "violin_plot", 520, 320, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + ViolinPlot(c, densityGroups(), ViolinPlotOptions{Frame: ChartFrame{Label: "Hymns"}}) + }) + }) +} + +func TestViolinPlotFiniteBandwidthAndFlatShape(t *testing.T) { + charts2MustPanic(t, func(c *ui.Context) { + ViolinPlot(c, []BoxGroup{{Name: "Flat", Values: []float64{4, 4}}}, ViolinPlotOptions{Frame: ChartFrame{Label: "Flat"}, Bandwidth: math.NaN()}) + }) +} + +func TestViolinPlotLargeDataAndDisabled(t *testing.T) { + values := make([]float64, 100_000) + for i := range values { + values[i] = float64(i % 100) + } + groups := []BoxGroup{{Name: "Sample", Values: values}} + h := &chartHarness{build: func(c *ui.Context) ChartResult { + return ViolinPlot(c, groups, ViolinPlotOptions{Frame: ChartFrame{Label: "Large violin"}}) + }} + tt := ui.NewTester(h.view, 520, 420) + tabTo(t, tt, "Large violin") + tt.Key(0, ui.KeyRight) + if chartReadout(tt) != "Sample · median 49.5 · quartiles 24.75–74.25 · 100000 values" { + t.Fatalf("large readout %q", chartReadout(tt)) + } + shown := ui.Local(h.res.Element, "mujica.violin.visible", func() []int { return nil }) + if len(*shown) != 1 { + t.Fatalf("built %d violins", len(*shown)) + } + h.disabled = true + tt.Frame() + if h.res.Point != -1 { + t.Fatalf("disabled violin %d", h.res.Point) + } +} diff --git a/waterfall_chart.go b/waterfall_chart.go index e7bf0b7..9010ecb 100644 --- a/waterfall_chart.go +++ b/waterfall_chart.go @@ -69,21 +69,21 @@ func WaterfallChart(c *ui.Context, steps []WaterfallStep, opts WaterfallChartOpt 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 { + spec := ChartSpec{Marks: 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 { + 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) { + 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