diff --git a/bar_chart.go b/bar_chart.go index be5f1de..e8aaabd 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -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..75145d3 --- /dev/null +++ b/chart_annotation.go @@ -0,0 +1,176 @@ +package mujica + +import ( + "log/slog" + + "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 { + notes []ChartNote + opts ChartAnnotationOptions + k theme.Tokens + size float32 +} + +// ChartAnnotation marks lines, bands and text on a plot; dragging writes into notes. +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 > NoteText || 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{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 is a line's text, or its value when it has none. +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 moves the draggable note under the pointer while plot is dragged; r is plot's box. +func (a ChartAnnotationPart) Drag(plotElement *ui.Element, plot ui.Rect) bool { + 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 +} diff --git a/chart_annotation_test.go b/chart_annotation_test.go new file mode 100644 index 0000000..8558c69 --- /dev/null +++ b/chart_annotation_test.go @@ -0,0 +1,80 @@ +package mujica + +import ( + "math" + "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 the Goal line up by a fifth of the plot: 50 becomes 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) }) + }) +} diff --git a/chart_axis.go b/chart_axis.go new file mode 100644 index 0000000..7c2c613 --- /dev/null +++ b/chart_axis.go @@ -0,0 +1,229 @@ +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 evenly; Min must be above zero. + 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 writes tick and readout labels; nil picks one per kind. + Format func(v float64) string +} + +// ChartAxisPart is an axis ready to map values and draw itself in a plot. +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) || 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 > SideTop { + panic("mujica: unknown ChartSide") + } + a.ticks = opts.Ticks + 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: + // Powers of ten, with 2 and 5 between them on short spans. + 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: + for i := range a.opts.Categories { + out = append(out, float64(i)) + } + 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 is how far along the axis v lies, 0 to 1. +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 of v along the axis inside plot. +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 pixel px along the axis inside plot. +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 writes v one decimal finer than ticks, for crosshair labels. +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 on its side of plot. +func (a ChartAxisPart) Draw(p *ui.Painter, plot ui.Rect) { + pos := make([]float32, len(a.ticks)) + labels := make([]string, len(a.ticks)) + for i, t := range a.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) + } +} diff --git a/chart_axis_test.go b/chart_axis_test.go new file mode 100644 index 0000000..595df10 --- /dev/null +++ b/chart_axis_test.go @@ -0,0 +1,129 @@ +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}, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + axisIn(o) + }() + } +} + +// partsChart composes the public parts into a custom chart 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) + } + }) + }) +} diff --git a/chart_brush.go b/chart_brush.go new file mode 100644 index 0000000..edaad4f --- /dev/null +++ b/chart_brush.go @@ -0,0 +1,207 @@ +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 is the grid both ends snap to; 0 means a 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 the range *value on a track: drag the window or its ends, or use the keys. +func ChartBrush(c *ui.Context, value *[2]float64, opts ChartBrushOptions) ChoiceResult { + if opts.Label == "" || !(opts.Max > opts.Min) || opts.Step < 0 || opts.Height < 0 { + panic("mujica: ChartBrush needs a Label, Min < Max, Step ≥ 0 and Height ≥ 0") + } + 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} + } + } + track.Draw(func(p *ui.Painter, r ui.Rect) { brushPaint(p, r, *value, opts, k) }) + track.Children(func() { + x0, x1 := at(value[0]), at(value[1]) + window := ui.Box(c).Absolute().Left(x0).Top(0).Width(max(x1-x0, 1)).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 := [2]*ui.Element{} + for i, x := range []float32{x0, x1} { + end := 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(end, k.Focus, theme.ControlRadius) + end.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) + }) + ends[i] = end + } + if res.Element.IsDisabled() { + 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) + }) + 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 { + slog.Debug("mujica: chart_brush range", "from", value[0], "to", value[1]) + } + return res +} + +// brushKey steps an end for the arrow, Page, Home and End keys. +func brushKey(e *ui.Element, v float64, spec numberInputSpec) (float64, bool) { + 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 an end, the window, or a new range across the track. +func brushPointer(track, window *ui.Element, ends [2]*ui.Element, drag *brushDrag, value *[2]float64, from func(float32) float64, set 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: + set(at-drag.grab, at-drag.grab+w) + default: + set(min(drag.start, at), max(drag.start, at)) + } +} + +// brushPaint draws the preview line, dims outside the range and frames the window. +func brushPaint(p *ui.Painter, r ui.Rect, value [2]float64, opts ChartBrushOptions, k theme.Tokens) { + lo, hi := math.Inf(1), math.Inf(-1) + for _, v := range opts.Values { + if !math.IsNaN(v) { + lo, hi = min(lo, v), max(hi, v) + } + } + if n := len(opts.Values); n > 1 && hi > lo { + x := func(i int) float32 { return r.X + r.W*float32(i)/float32(n-1) } + chartRuns(opts.Values, x, func(idx []int) { + var line ui.Path + for j, i := range idx { + y := r.Y + 4 + (r.H-8)*float32(1-(opts.Values[i]-lo)/(hi-lo)) + if j == 0 { + line.MoveTo(x(i), y) + } else { + line.LineTo(x(i), y) + } + } + 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_crosshair.go b/chart_crosshair.go new file mode 100644 index 0000000..f8b9a1d --- /dev/null +++ b/chart_crosshair.go @@ -0,0 +1,81 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartCrosshairOptions configure a ChartCrosshair. +type ChartCrosshairOptions struct { + // X draws the vertical line and its label on X's side; Y the horizontal one. + X, Y *ChartAxisPart + // Reach is how far, in DIPs, Snap looks for a point; 0 means 24. + Reach float32 +} + +// ChartCrosshairPart is a crosshair ready to snap to points and draw. +type ChartCrosshairPart struct { + opts ChartCrosshairOptions + k theme.Tokens + size float32 + pad float32 +} + +// ChartCrosshair crosses lines through a point with value labels on the axes. +func ChartCrosshair(c *ui.Context, opts ChartCrosshairOptions) ChartCrosshairPart { + if opts.X == nil && opts.Y == nil || 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 point nearest (x, y) 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 the lines through (x, y) inside plot and each axis's value label. +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 writes s on a Text-colored tag at the axis, 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..7f9a43c --- /dev/null +++ b/chart_crosshair_test.go @@ -0,0 +1,69 @@ +package mujica + +import ( + "image/color" + "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 (5, 80); the pointer rests 10 DIPs off it. + 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 reports a TextMuted pixel at (x, y), where the crosshair runs. +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)) + }) + }) +} diff --git a/chart_export.go b/chart_export.go new file mode 100644 index 0000000..48421cd --- /dev/null +++ b/chart_export.go @@ -0,0 +1,49 @@ +package mujica + +import ( + "bytes" + "image/png" + "log/slog" + + "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 renders chart off screen in this window's theme and returns PNG bytes; it writes no file. +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 { + 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 + img := ui.Render(func(c *ui.Context) { + 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, scale) + 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_frame.go b/chart_frame.go index c3ea645..4f4dfea 100644 --- a/chart_frame.go +++ b/chart_frame.go @@ -44,25 +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 != "" } -// chartSpec is one chart's drawing and behavior. -type chartSpec struct { - // n counts the marks keys step through; 0 is empty. - n int - keys []ChartLegendItem - // 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) - // anchor places mark i's tooltip in plot space; nil uses the plot's top middle. - anchor func(r ui.Rect, i int) (x, y float32) - // tip writes mark i's tooltip; nil splits the readout. - tip func(i int) []ChartTooltipLine +// 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) +} + +// 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 { @@ -92,25 +106,29 @@ 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 { - var hidden *map[string]bool - if spec.toggle { - hidden = &st.hidden - } - if name, ok := ChartLegend(c, hidden, spec.keys, ChartLegendOptions{}).Toggled(); ok { + if len(spec.Legend) > 0 { + if name, ok := ChartLegend(c, spec.Hidden, spec.Legend, ChartLegendOptions{}).Toggled(); ok { ch.res.toggled = name } } - 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 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 @@ -120,34 +138,38 @@ func (ch *chart) build(spec chartSpec) (ChartResult, *ui.Element) { 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) + if spec.Input != nil && !plot.IsDisabled() { + b := plot.Bounds() + spec.Input(plot, ui.Rect{W: b.W, H: b.H}) + } 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) + active = spec.Pick(local, x, y) } - if active < 0 && plot.Focused() { + if active < 0 && plot.Focused() && st.focus >= 0 { active = st.focus x, y = local.W/2, 0 - if spec.anchor != nil { - x, y = spec.anchor(local, active) + if spec.Anchor != nil { + x, y = spec.Anchor(local, active) } } ch.res.Point = active if active >= 0 && b.W > 0 { - lines := chartTipLines(spec.readout(active)) - if spec.tip != nil { - lines = spec.tip(active) + 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) }) + 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) }) @@ -157,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 @@ -170,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 @@ -190,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) } @@ -199,12 +221,12 @@ 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) } } diff --git a/chart_grid.go b/chart_grid.go new file mode 100644 index 0000000..dd80ea5 --- /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 draw lines at their ticks; either may be nil. + X, Y *ChartAxisPart + // Minor splits each major interval into this many parts 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 lines at the axes' ticks, and minor lines between them. +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.Ticks() + 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..a62c041 --- /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, each exactly one device pixel per DIP, no blur. + 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 index 7712d7e..0e5bcd4 100644 --- a/chart_legend.go +++ b/chart_legend.go @@ -53,7 +53,7 @@ func ChartLegend(c *ui.Context, hidden *map[string]bool, items []ChartLegendItem res.Element = ui.Row(c).Gap(u * 3).Wrap() orient := ui.Horizontal if opts.Vertical { - res.Element.Column().Gap(u) + res.Element.Column().Gap(u).AlignItems(ui.Start) orient = ui.Vertical } open := ui.Local(res.Element, "mujica.legend.open", func() bool { return false }) diff --git a/chart_legend_test.go b/chart_legend_test.go new file mode 100644 index 0000000..b588a79 --- /dev/null +++ b/chart_legend_test.go @@ -0,0 +1,95 @@ +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}) + }) + }) +} diff --git a/chart_tooltip_test.go b/chart_tooltip_test.go new file mode 100644 index 0000000..f0a13cf --- /dev/null +++ b/chart_tooltip_test.go @@ -0,0 +1,86 @@ +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) + 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 looks for a run of Surface pixels too long for a marker ring. +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}}) + }) +} 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..d7f49e2 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 } @@ -191,27 +191,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 71f56d9..a51e83e 100644 --- a/line_chart.go +++ b/line_chart.go @@ -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,7 @@ func chartLines(ch *chart, d chartLineData) ChartResult { } return strings.Join(parts, " · ") } - spec.tip = func(i int) []ChartTooltipLine { + spec.Tooltip = func(i int) []ChartTooltipLine { lines := []ChartTooltipLine{{Text: d.ax.label(i)}} for si, s := range d.series { if !ch.hidden(s.Name) { @@ -112,11 +112,11 @@ func chartLines(ch *chart, d chartLineData) ChartResult { } return lines } - spec.anchor = func(r ui.Rect, i int) (float32, float32) { + 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) { + 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 index a8193bf..35545ad 100644 --- a/messages_charts2.go +++ b/messages_charts2.go @@ -4,5 +4,8 @@ func init() { addMessages(map[string][2]string{ "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"}, }) } 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/pie_chart.go b/pie_chart.go index b4ce86e..dba6d1d 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -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) diff --git a/radar_chart.go b/radar_chart.go index 14f6b0a..d554784 100644 --- a/radar_chart.go +++ b/radar_chart.go @@ -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 bbf31b6..20152a4 100644 --- a/scatter_chart.go +++ b/scatter_chart.go @@ -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/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/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_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_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..c1e1cd7 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..a90050f 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..01939fd 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..6f39ef4 Binary files /dev/null and b/testdata/snapshots/darwin/chart_tooltip-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/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