From e90a1528db83b2283e27f774405af11e17055e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:54:57 -0400 Subject: [PATCH] WIP charts2 parts --- bar_chart.go | 8 +- box_plot.go | 8 +- bullet_chart.go | 8 +- calendar_heatmap.go | 10 +- chart_annotation.go | 176 ++++++++++++++ chart_annotation_test.go | 80 ++++++ chart_axis.go | 229 ++++++++++++++++++ chart_axis_test.go | 129 ++++++++++ chart_brush.go | 207 ++++++++++++++++ chart_crosshair.go | 81 +++++++ chart_crosshair_test.go | 69 ++++++ chart_export.go | 49 ++++ chart_frame.go | 114 +++++---- chart_grid.go | 68 ++++++ chart_grid_test.go | 66 +++++ chart_legend.go | 2 +- chart_legend_test.go | 95 ++++++++ chart_tooltip_test.go | 86 +++++++ funnel_chart.go | 8 +- gauge.go | 8 +- heatmap_chart.go | 10 +- histogram.go | 8 +- internal/chart.go | 46 +++- line_chart.go | 12 +- messages_charts2.go | 3 + network_graph.go | 10 +- pie_chart.go | 8 +- radar_chart.go | 8 +- sankey_chart.go | 8 +- scatter_chart.go | 10 +- sunburst.go | 10 +- .../darwin/chart_annotation-dark.png | Bin 0 -> 17174 bytes .../darwin/chart_annotation-light.png | Bin 0 -> 16523 bytes testdata/snapshots/darwin/chart_axis-dark.png | Bin 0 -> 10536 bytes .../snapshots/darwin/chart_axis-light.png | Bin 0 -> 10076 bytes .../snapshots/darwin/chart_crosshair-dark.png | Bin 0 -> 6783 bytes .../darwin/chart_crosshair-light.png | Bin 0 -> 6688 bytes testdata/snapshots/darwin/chart_grid-dark.png | Bin 0 -> 5619 bytes .../snapshots/darwin/chart_grid-light.png | Bin 0 -> 5354 bytes .../snapshots/darwin/chart_legend-dark.png | Bin 0 -> 7727 bytes .../snapshots/darwin/chart_legend-light.png | Bin 0 -> 7271 bytes .../snapshots/darwin/chart_tooltip-dark.png | Bin 0 -> 5005 bytes .../snapshots/darwin/chart_tooltip-light.png | Bin 0 -> 6785 bytes treemap.go | 10 +- waterfall_chart.go | 8 +- 45 files changed, 1519 insertions(+), 133 deletions(-) create mode 100644 chart_annotation.go create mode 100644 chart_annotation_test.go create mode 100644 chart_axis.go create mode 100644 chart_axis_test.go create mode 100644 chart_brush.go create mode 100644 chart_crosshair.go create mode 100644 chart_crosshair_test.go create mode 100644 chart_export.go create mode 100644 chart_grid.go create mode 100644 chart_grid_test.go create mode 100644 chart_legend_test.go create mode 100644 chart_tooltip_test.go create mode 100644 testdata/snapshots/darwin/chart_annotation-dark.png create mode 100644 testdata/snapshots/darwin/chart_annotation-light.png create mode 100644 testdata/snapshots/darwin/chart_axis-dark.png create mode 100644 testdata/snapshots/darwin/chart_axis-light.png create mode 100644 testdata/snapshots/darwin/chart_crosshair-dark.png create mode 100644 testdata/snapshots/darwin/chart_crosshair-light.png create mode 100644 testdata/snapshots/darwin/chart_grid-dark.png create mode 100644 testdata/snapshots/darwin/chart_grid-light.png create mode 100644 testdata/snapshots/darwin/chart_legend-dark.png create mode 100644 testdata/snapshots/darwin/chart_legend-light.png create mode 100644 testdata/snapshots/darwin/chart_tooltip-dark.png create mode 100644 testdata/snapshots/darwin/chart_tooltip-light.png 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 0000000000000000000000000000000000000000..7373a68064d8111e5c82efa812d29e8782765e38 GIT binary patch literal 17174 zcmeIZbx>SQ*DpFi0t5yL1Q^^UxVt1ka0%`b+zG)wxJz(%cOTqcgFA%aHn`iFJkRsK z-+TT#r_Q}!-8xlwx@ym!-qWkQm;Bc1wYx(;%ZZ~P;UNJ402E0HQAGd%77+k|QAUJ? z){OHiQ33$MtCFH0m0i+~R*|)*RbCKInSdag70H4UK`pCQH$7C4nPkem9C9|KaPfF_%eASHo_7L~fK(WfhLF z&HBVyeuuqR2OH>Mt;BG60{p+~>ki znzT8)f$mKaxu0ofIk=oDj-Q^o;LbtZ2Ayx=B0*n!zmvo3;IeQpp)F#76UJ&-W?lix z=o!{Slg74nbP2)L4eihAcR%1pM*$@IzEM#@%i~*5_zl+J>gq@rdtO(dKg^d8Aar>D zk3stS%L{Q|M>ac_WbyPz>2i7bu*6xtn&($MNEp&T=C*Mga06VLd2#GVhG{Ei=4Oe7 zk`GzN11|q+{)dVKVqnbFxt};2FE5`YTvgKCW6RCfHy%K6FGjZdr~xa-@#s?Xa*A|T zLq&!ZM>VLIBpurE-~H?OY(%jTb63gIQgWDC#-bYhL;(Up4of{wAv=J-M+o!9MT{Br zMBa{!A(VssHme*$=6FjL=>nuk19u-<|`&3@dn0B?<#<wHPAwt$!qMVpD#< #sIxJCq$MN2#K$t5U4I!B()Wt|dN}L5dz)Vrc^l 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 aB?fn7Ee-P8YES&#P)0&gyh_yg!+!y=TzJg@ literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/chart_axis-dark.png b/testdata/snapshots/darwin/chart_axis-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a2b3a33d93466dfbb085de0e64b09027c2e88ee6 GIT binary patch literal 10536 zcmd^lWl&s8w`dZQU_pXA3=kk_AV?T|u)!_31s$B=4hfmy0fGiUL4$_@28RT9_d$aD zOwhr3ob#RJ`+mIp>eZ{d@78zk>{UJ8d+%Dk_Uh%`;p(dLcsNuzckbN5Qvl0o-nnz{ z*_}IgIkE2D-qCcXlf83?4W%F>_0l_icP>CZ9(@<{r)ehZ>ibwx1uP#I9VYcbw;f?p z(_vS6L*b8#936wy?o~S>)fw~1TrUsZrcy(M{wT~^o1b3N zKZjX4Xngud)vl-)4@uRR2M=mu>zy1m%mPbZ0@sgC*jkQGQqX!yvI72%pmR^NW_7m9 z>zF{-?fFV!{S^BaSO_5uwcllKkr!$zett<{$^zx`ZG1xr8aw)q+Er3R(7f45d*EkJ zB_4$AjzRRV7i4;K>agS0P!ZYcEF9 zH^>aQJDdWSFMnJ{V;r|*alYE(;>(!A7(b3dTyU@d0zsTV= z^GYvUIg2_TN-`q8r@0ZhmQlF@tal1H-CDCZy|h=__D#*#UCeMtwqoX=M3*@!ZDkD= zFh8*q*yoq!5~AR@BL!IQiP?*0R%qp+^c~`?TVSrOP_SNQk5!0gbg)u8Ldy zGeo4|xn;#Ag~j%bqt<-MS8L1bcsl_tC~G_BIT=eSzC4zQY?}z*rMxVC$EIsyHXCdl z94DzlOFQcFgsbCh3-8q&HJ(aN`(gW>##BjxM!z1_auo(>Rm#9MlWUu$s)Fi~ZDJ_IAU0vgAHlEJ_NRZF-t<`)d5I*FWv%qydj}i{Q;g zaB-8;)bRv(Sw&A!d{s)nc?CGn@*MMFfipC=c!|Xom&t7?Ayt%aC{o^Z<$6TO$Vmvt zVD*-xUuS(yZsw_1f~FnkB(ZEApUe6PytVVjjMMBP1nuI7-gJ9OcRb_l6Ew1&y?T{| z*mLolixF>(BxFofWL1lf?_se%)A=fr5XHD^H)kAMC(l6m4Ckz!SY)+@cMZa1$yAvV z#;K}497kT|pRyEQ9#nd{5^=i-3A)5zLcDlpj*rn7i=Sw3*l2UqkIskE{dbNQ?WySo z7b-+ViZ54iKw+g_Xm%)=nX0@C`RVemXo$uU-Xo|XUJ67nX^)=5lH_1tg6VR|-+A#{ z)Cc9~e<|Fuf)sm*5AxeJHH%x12t-}w52}T?B&WMZqwG01Va%*lT-VRTJ4qqEgA&I6 zn==~(X+Yo=x=OKq*+Sm5cX7pUtQuiwY(1>Ny!@@9TJe3WdG-6-8H4Ie;XP`*n_t{s z+t_r?fU9l`e#xI{B?|py(7)fZ;6p1V`U2?crZe8>@X5P z3q2i$q49u@aV~j^J-0UJt({F>l$YsOySjz?CmsPNswb%l4U0F1XL%8dpzaT8`Bf@~ zKEg9whJqgMo>%LZ;fOtJeRzG@=cdX?LO$U3?%vLjF9Q=RhZZx@)yW5m1(;r2X-Gep zX1L|X$%Xi3uKDY3Cp#2)xLpFqadCa*D1;D+ZaGWpT1io10Q;)Cbu z`}(FDHcLt==?%-^_9asupjSJw-rG_OSGtSpq8l-CrVQ58L=sPaV$wq)x(rEOz+ zJCvdO-fM32}-nzm4-?*Ra4XFs5%00HZJ~E8kAJZHm_jrEmM<+OKhTqIG z4z@117Su{gR2EBiiu2SPwGk$Wh@Ne7xcj$1<9Y`E?k6E%HRCysw8H({v)R6tDk>_y zY1`67=b|j%&t(dF?>IH7QU~%FEXX^9cNdes7TGXG_AX&# z^@mX#95>a9L)YcYTWLHRyC3Qpp;Rx%zbg@k9v>9lyYCFnyU>JgTo4xE=3PPp zg{qkE=-St`-rl&LRo$k5WNy<5eid<^7id8m0*>CFHj)c>ele>TBezRU+J1} zfEmc$!_4W+*3YTiTc+0QpwHA^x{Yqv*@W5W>qQ%x!dJa%&QlHP^7sfHUEMto_?9W8 z%m{rZ-N&9&i|L6Sj}38f?5pchVk>vo-2KJY zS*+YTbugCMf>s?j_@j-(UMOKv{xKf@#3HiFp#K7`U*EEKbGY_Bwx|dc){(bpeu%3R zso(5wcp&13rbE0u>vFl=dcPZEu9>%K>!u1rIYjo}gzfi1(t zOJROzg+JDtDdhgUNQRobeDB`84Fj+8JrML7!H(}UV%?QPcr+S92-_3F%sp+qXA&eT`-Q7^`10#rOvfB8ICcV5gdk?h4}P5apqPo@h#v zj6~k^Egv@{?i$X%UXJ-fVci{fAibZZ>sBH13s7_7aMV@Mv$44@X&gQS#4#sozjT~8W`xFC z>2H6JV^%2W<;WLJcPrY(%wwTSGnC8EQ%w`?nRMcrHE=lb0L!Z-aHos-O&nM$CAX9{ zVu9b>{|k=17<`zwa#Rcjs8k&K86rEjb>QvXrdLS^5$KUG6X9|e9s~Nt1Nr07c_f>^ zR|YrZ+{g%}O;ImLaUVyqfSrJrr}7N1RaXM-pPdIHR^;BIbZS~|N|HGyYBJuZA?PU< zR=lS(=se8^x`v#U&Zpur%ugk@VmE(Z!JDzcKK1qWIE(raxSmKueM0Js*PA~-m&dT@ z`0@Tt=lGQ50a&wbjidLAh-$J@tx@l$$)kUn`=;q>gE-~?$qa7CFvwCqGH^saAYo5r zX8{^e8mrlW!&J#XM_~b=N0D=9+MWct_@+i*zpZ(;oOB{UVV^cPlTD2h_*caX`aDiI zE9U*s7rHZ65Ucf}z}7#eOa%-h;G|ROrN9MFP3Ko)LeSV2TDr~J(0Yd?uAc#nl0?R{JruU8)5Zj z3spBimi8^4U;p&@#l~C)DfU@dPD!>RCANi831Rl2<-v#cJm2^cud5JVZg@rEOc0lR zTIsx9CFRQHac%wiLsT(nd?F15x+W*JRTqEwOUy|4!A+T9dREU+X<^Bp$5uae7+c#g zWKp=D^0`vwnuU*ijsI9cUflilzB{HQOs@%&Mt$VF2&Opltt=mP^12_fK>o;A$uNzx zZ&EN#s(h!jA00e18gQ5KFGapp#nHWY4ih%Et};ns?pn9rpHmyZ&JVZ(Aqw+`+-JqO8D7VTDp#jJBwXercjQf%rzgkb=)F7HrKXk9J$DBzqQcLGSP>2tPM7WtXbJ;B zbYftZp#OB7>;tL~^(U=)8CfVT@!J?CH%2~@5b`o`u|q~)7I+)|_if+PD4yUQ(I~Z6 zDlg7{i2rEJu4>P-L31-BQ3)4k%2`JaN;oPsws>7Ww$R~>4(srZuf~pJt~L_j$l2N2 zU3Gt`3ii2oV7)Tz=epeQf=#PEMXu+|N;nqVI-x2T(5hh2zVNO=r-tJI~=lp}D)14eb zfy==7E}M-h`iol|HHj}PR>4|&UI#gg<+iwTO2GBTyN!T=A=^=ctK-+H82{Gwp8xEU z)sX~-0yB~AOLibtM53a!bl1pZ5Z33V@DB3~owjF>G8ihfSgDVIKwrJbD72a~a{FiL?4ksA$5r$<|_=MFi)xwS^oFQfg z_Eq`n*i233}32_cC6`1?&i~Dc!xu*1Q9A*^P&~S<`9s3T<^ilB$WL+*jGCqxI z`pPh$KoPWmQ0~t7sVtU7PHwk%(T;z!<1H3|D$&l^#Hp=A3AKu99GQgM?kUzO%VFt% z;tje^pSi#%aY9^Gn3NCn^<-~IVgXj2GlabcIkiT@VKs}0z#p$&>|8Y$B7)Ew`Y+E1 zF6jMbd>Y!#m(tlzI$954n4RYC1h+e^`qH-l!rOrsMvnx2;u zN!EW;k$rG1;G<=p9jVycn$UZDm%FB*5$0QBRQ8e`3*cJk-+5#!;BgEIEL(oTBewP& zZ?xNSZn`29=>%k?Q}g($fG#1FM2j(l!A&bp$k{;RHG!=jenI@k*>}W~?Vmrn8?N4x z=9Cm*Xll7dGGMLmayIESunQrOui++5s#t}kHMuOTd7p@Ka#%R^FZ~?@FvRWwpuwU+ zG+o*+r#OfFUCcT~KNuvYrdO>`q&JJJ65g-3WEfzj;iv4NSlYCLN$Fr!e zW^vgVxmztjb?fHwB?&+v6+W7o8(ic(wBja;SW6GgVxQcY{Alwu{hpjAfK-P1Pu?(^V=0?C#`AR1g`tu&@@OUizj}{PB-M*;v9&BVpT};dyh>tf7MLXeH#e}~fjf3_+A|GKf^O9Bq6 z@G7rRnYfkWAP8iB`e5P+W$v&8?51 zEBD$@bH)ac$foETT&_OLDJm*Z%`!e*dWb&WIWYK_sanTkStP^)x9o9&m)BtJ398Z4 z5hnR)3V5K!8g;P_D=V5ig;&J{_gAz_A{cvqq7YDSKGuv1ip6XvmOjL%t1#Hv-oxAQ zOg)KuhW`=rJ5+6bINHf&$OX4>MXk3ail+m19|d=k~1_qmCLR zPXOYB6O&|f#DhN`{)*)^b>ER--7kYzv9x#DGCb+*!UdG^*K{BLoow9)8{dl~Y#o_S z;AK!j(0$7ETnQUx}9gG#GY!nR@20AGWar^GmR1M;hsnbOO`%)gu5*W#hM zD%+QDC0}dNd=X0!U|U&(2^UehZ1Z0e@|!ju&2ta#TFG<$I31kbYknFl1sJa zUbo8oI}`c&&K)x6v@rM$LFp}m;SBk)_h^23@6pY?z@K63yx?zC|IZx)QSiu!Mw*14 zA)iIPC_@PKp}4_e^c=>oy2dt9EQxG*SlFje5`y;Xou%|HvRjv%g3flQ=*96rBL@0W z|7xp+!^-5umc4!NHQvY6)X!_Yt&u-=qobpl;PqVE2neaFiZWQQWyt~{)A{Ba*OZ+j z$jdS9Vixy_Y@P57SLvYhNkGj_mw^w)zlamE4qzLkEnwQ&dBm1_?Xd9}(B6LOQunU1 z#sSf6k6yUs&580kAA{^k6zti3P&=}*5&qA>zNff zZSvssjZLHf6UBnVi>n=W!3lba&jH6(ZK!0lg=JTF^e-AgG&8nAs>#K$qVpRuV&{oS zaCOuC#_`#Kyu__f&7Fu`VGTO;Zj5JLy(JAwXp)60X)>FA9YiU+x9J$>7SX|` zgKwl8TPj93?0HP#HfPzoJTJHd{C=?dpf2d22t1W_l6a=y{7UugT(M3b3qSw?U6pE) zJP~ABPepJM%n4`!Gt^1Sr~EFkCu8-b&GEtonE&kHc;Iaf-frFcjeyhUdsM#v^Lr^~ z-iyNgYB7EpoPX4Q{(}}xpW8B1bMvXF_eJ!|kQ;JMDAZ4?)+is_woXnkU(%pVqW`n2 zP;YBzaD9rcofaZarI?q)F3DCcEzcci4<9jLS05~!O1PKIz*Q#Pgzw_6T%Xc zz$Z18m#}@$Zz-$k*~0ToxQnai=n4mY&nJIl)1aWS>vD-wXZ5U4w}lc|l3%2Sg?ZT{ z{J=WYx9A~H$`C-F5t6H!O9)|InQ>m%v^?O&W87SVd&ZaC5HJ|8c%Rgg=)?Q7fFLy-JU}Jy2t^e#zu+FL$iu98`U1J=& zr?$|zx%s8J`MNVd;P``IC`YfI-(A3m=GY{k$UV=C_#wxlvQi$sPssveS^?(kGIBDa z@NvH%ultHTsNtxR!_|3V0VZQCUUamuoAsKGjw9l9Ica=6A?^aH;q^)I!P%h}3$wyg z0hP)h9Q&KnPkjCT&o~UHBo2cUMh&aHYvbs-p8^lA2=c7fnk8R##9^9Sn2*z1_l-v# z`QeHRG@wh2O?4U?D!zWRm}!y(d~gMHbal-8k5#3fi?w8kp(j4C$4gxCi9qtthx=WC zw2QR%j?)PD3iRl;^gxJNZk;hGY)1IGK{Sn+Z^^_40l)tUKL=t}tu14_UL@#ymq1oA z?xgP%cQJ8$-*tJB9sg^|ua)*%vTLl>(lLxXN&T-+6+Pz54!{2h6|tkO7vBqcy3JDg zWMqr~(aI~gzW14#?*_O+%$R|+WOKMAqnqS$h(t!{&j3(k+PppilJMES@~11-f|QR zkTtE(I=zRCXoO-Ock#5hU8u98NphZ#7|j!AGax8%IJBH(Ha*O>N%NRZXp>0ii{lb9 zV(nUHEK{EGe3Wf2prtF)-cSC=653B(_+S;E;i`{ee+DO>^vOj;cLbw%hPKc+YziHDbp_S8p$1&NV>sf?YI z-8m-{7VaoXYeLI4loFi-@%(T{IC!o8)x}DTP6B4fewZ1B>dpgBJ3XREDxw}{KzpCd zKD0VsnOnTO`EldIRR-;{%&LrYcVoGxtMz1`&vklgT|W(pTK}{&J|pzmqe?df@uKmy zI9W`?yfBn7$;z0!Q{#!5YHgRFcpcwqyzr8ft^GiM3rzE+`YT%5Mw7utSC|#uOk-hL z{`s-cA{`|$NAJ4}5fOM*b*At|wVUr<1}@Ur>@i*t__bpSw&oeFRZX_mWT;>aBI8gI z5*n8(a=P%i?HSoa=2|#sWisstci%vrxRU1%q?s!?x*OzWgU+^g{moJ)(+#Zd>ybPy zO+V7(!md(%-oWy0*D(^bwJmH?v%hQ}9Z7Rh_iICZ#Oa4zw8knrSP@V1p4@d|9?BG1^xJ;ELo8B|JAKn>Uv5y&huAO;I5gX=s>81>Trvsy9NM=0woJMu zZ)_6aIauLE&^+O>*_9QVu0wR)8cdEB)7|A^ghHL8=)E_-azd_SrpDD+g G3jSaC^ELVa literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/chart_axis-light.png b/testdata/snapshots/darwin/chart_axis-light.png new file mode 100644 index 0000000000000000000000000000000000000000..51f2abaaf8e7cbf97f4f40a10dd3abc4316d48ca GIT binary patch literal 10076 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/chart_crosshair-dark.png b/testdata/snapshots/darwin/chart_crosshair-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..73a0f9be6c745382c4ddb4fb31e92feac708fe9f GIT binary patch literal 6783 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 yUf}t%6x)D7F<0o1~H#$R*D2eim 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 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/chart_grid-dark.png b/testdata/snapshots/darwin/chart_grid-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..740fa7567cf73858f8d74b26924d72075f6dffb4 GIT binary patch literal 5619 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 W*~E$YTgRWdG`d=bn$_wKk^cZu@r@e* literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/chart_grid-light.png b/testdata/snapshots/darwin/chart_grid-light.png new file mode 100644 index 0000000000000000000000000000000000000000..18eb005bc507543983da735d3677bf6e473f0263 GIT binary patch literal 5354 zcmd^DcTm&Yvq$VA2nZrz5CjCgbbDDM9H?Lg<$w2okDv5(EW>TuKN% z(xpg?ln@X??}QRcsKVpwZ|1(4-z)R(oBPju-|xG#GZ+Ed>6FcL^Z!-CONRWg8Qk;kl_vlw%>Q=a)?+j_#0WLKL_ z$MI53vXKoU>hd}8+5KlvaxDcl@=TNjH3)iSghrmh3M9|iCMTYbNh~8bw_h*p%#zwy zjv|OM-ZHiXbB8mQ z4bN2SG%}F?mn8YQSN!UL)ap>ubK8u-1HLF#8ylNuS0?2STK+DDed8M7i;~qxnPTl# z>y7nNULVYK)OmH?QmQI*DvL3q(pqcV<2CTL+#Jr3r%w+-qu^%#FO~`51yRxwB_C>< zCziT4RyVM+^9?xvB~}20BE!qPx|2u5 zY$rA09M0f_0xK))sT~XIokJ-VNCz+<^PT?h`nyHNW`~O3f;W=FBJ9h9M_ngw{>VYE z<}iYD#$RDr-_jK2{3BffYuVeiSDp=AN)37~-Nk zIyO4WnxUepVAW5SZsLmnkV{;?&7Ll6LfnSSyG~sF8J5IwkK7B$t|&$OWCEhgVlj}^ zWkTxE^(5=&3P$Ji``p`>u=rxA7nsNu&xOBmo|DD-ajDW&HGOn?WX6ZP(@}eN0h-jr z_t4tn`GPys#@|)KRH%Y|(htPMbW{g_b6jkjp*QbwuQ4wy?ETirnvm2USK&PFTy=?O zHvMu#?T`XehfLe_+vsZS!`!K4Yo@4&xjCWnufjJ~h@cW32&9Iz_C-AjV5^u1NkY0Z5S*b)FSjg)&26qINKzVCf(pK9s(CwVL4iQT!EF20vI*LXM|GEyg>-n8={ekaaoCmz9lBfRj^%jw|Bun?KA zxlcbn4iGf@_JrAiiAPWH^0)kgnq5X`v88002dtwC!%{^+%Hb;iuBT4w)+WVbttWEC z8kPpI|CITj)g=`L~m2Ue4Tpd~0uk_`Rz^6wPbRVXvbT$EbX5vHy+#uTt;x z9cqiw=wpd4#RWopFS|@@{;+`)aA%@LbdWP69W^pOzP7yl?Z^wOR}k)}g4oKlS<0xs z8!pHS4kDez94?-GuU-{dh^*=r791KQdhO+Bm=O{(9bFC8T?;RVRvpX0Ci6o4>J+|{ zUesA;AK6PXUNsLm&Sjj|B6pH^vFL^(Eujzf$XX6up^^C2maK#Fak4xKg>xhIyuUwE zf(;^N`^9_Km@B_p?CLX-m)RV7B|_E+o;BJ`d2)vQ8{jkeO_#B120?TJz#>~p~JlKQm?Ut@s8)s|Zi8cJAVlZ)kwCfZPT zv1XNyuQ!H9&b4M7`Y}uxc%kJy^?QnzJ0KpMT`s~^n6WlbPXBEe;h%{AcL9+a!bw)- zu?BFa@2y5kiTl*ob72cns4?a>8Cg{bw|i1&!c^lFEH(31xeBg&M{JSrg(rFXt6J@) z^XdhP_dJ#bLvvn%lhiNXmv1ef$f*FUK$5n1d{g9onYVGD_B*%sm_eB9e~ob3pJSi3 zV`oMvWH?M7gaY9Ik54Uta9EH1*1>@sirw~6I38&;{Q}IkD=slcZozPiH@#x*F{0#OOpot?Xu(Mn?W^7>qz9ybF6*q3*O2M@;f7 z+p-Y|G9rK0CVwt|X6ut|ULUo^++)*^T*fo3KQP0g5Tql; zt)vE>Htguk=B8(W%@Nh`()9+2)G{J7GZU`+(;Cp$$A@E`V`NNy_^BX>Mfv$4CncAY z#r1Ku($obt{P^2Fa!1^3(!+i~_C!BPaq)Qk!c42q{C1CKz=)!)?hUB!1WcF2iR-gc zcUq{Z5oc5Wh$KdAwJ1XCj|Lis#+QTOXxq0Y{tcF?Vuf{XJ9D|;4vnlt3lX6{Yxv>x z>F%cSrq{@wYIc{#dR>=G3T$esDR#W{4pVW>Hc$ymrXQuaLK#l( z%+sr3!8i8@gOh`Q>9N0`+SR)IM{cyqeu^-kg@vKd*vY=@aVYrq=XV0%sf$r9Q|5w6cY3q!T5?+e9V$Y3m;KX^gFk-hMl1|gdXUi*DU?mkiKb~ zR$rO{6Hg8Y)PFExPHa8$<=y*NbAL+K1~Isk%n$IuSNrf_{~%vPLyUxdU{G*Ann%s zn6z@rfHIM%;?x;?V?_dG^QXcHxr{`Pkq}4=ruK^F7i`7NMAmC{z5bQ%Xdf*5U^1;U zo*ls(ziH%+aAOzsV6&|LEPM`N=Oya#I_j<&3}feE$_PlD1ea;pzxl$*tl6E_6mr*$ zqx4N|>Wygq-@l^5QmEU)+PB5V5U~3oJmqj|ULK$NT7m8R?Cy&1ro5Ohbk`WVD=Zbb zFY9Z-$R-*uAbjO@bMEm=wbk)mA>a4@Upkv#o7nH$u>P!{&@hBdX0*acr* zmM=9&GPJLOs{9rJ-ovtsdp)qJ6`?#Cb>VpO96yh(BV>C%HDL6eqx3w>aeW(yvb09l*3y#OYi)YD(VWnteh{>VwzPsM*Dm4u z*{(#C%Za4F@z3Uvs?Y3 zsHf;<*Oi`h`$YY=iEEGiEFC&>5v*z+UuCO81qe3Oja8D=`^;<^nG;Axd#;%J^Kb?T_F zp1F|twWpPiEtO5g_}8WHvu^X$m@m$bhm|gNa6W$NJbRU%4B$|FyphQ-nOMFBgOviP zT31At3fME%#Dwnh(oDhGr0Mtw;d?lB`UIoHY5_?OM9$M!;e))IdWTEY^6hd%C!++} zXbNQS0x~RCgh4Xroc(%F?FEY^_9et^V2a@zq?000LPkkB8dG|M7>};{;IlXH?rJHT zZba*$p>iYHVtIP7L)nh!)7ya+u39k`EsHEI<>gb9P}`8TefK!6yu`lt=+xdVWaT|G zEypYAk7+N2HKo~&9Z-!Ttjo%T!)&`ou3Z6uil{G*eg|H ziOZOMaXnJXjt5u1EtRY_m%WL-)g>Mi+?j3N%gsZ&YRLN)t0pG zyH53lg&m=Xus@bn5uug$WdEQOOiFFO*8C2oOMHvAGrW9R$lebAe#W`Ia&;ZA2UgEVvX`kEyT6Se6ipt|cO(LU(10SW;OcLvuo$&L;99{2r1-eUAGB zP78t`Kg~{sx7Q`|`L@@gm~@KOWRcFdPJ2#?CX~Az;FL+#K@*k>y$zG8T8WPz4H!cd zO5$SDvQ8GH$WGk6{O-?Ba**q*25kzU1-D-81d`P@mrpZUI++Sw<;zJn5gyu6F%GtB zxXDW3*!k99aSH9XV6cOWs|Y;rZEpUZ)6!cROsW}?eb@J~&++Ddz}=00?r57ySMCSA z77}KTTy?i+R08xo6KSa$Cn^pdVA$z(V(4QbYo>UB-Ng|l*xxyjz&tp`i!=^jP{hmL TT6Cv(m^7Md+N#CMPecC=V#@zg literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/chart_legend-dark.png b/testdata/snapshots/darwin/chart_legend-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..c1e1cd7d32b91270781d1032075f87613493910e GIT binary patch literal 7727 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ð}~)MBQofnGvWp9yx})5oix@5JXn ztE%3r$y>FG8mbLJ&N0mym?g9;&u=>WSgw+d=hFE1Pquw0O|issdW96*Jb1A!*WU7a zbe0{ougyBCs+zoiqoS`r#Ud7CR>GTO5l9u{u&n-)+uzroJ9o8N7-VBc{N9Ka7FMPx z5V?)-?#ee$$u-3F3p_J0bK{5i(xGMN=M$lRYXSHfP40yS2G{qPuv>-9+702q6d^K6 zw*VP3!Dr=6(c5c`3E_fQrbz#fWp}^trH`4bdiknE`oRu8h~y69Frh;EU;V%KKv3^E Yl8`c48p*xp)PKAyE2St|E^g%aUowp@umAu6 literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/chart_legend-light.png b/testdata/snapshots/darwin/chart_legend-light.png new file mode 100644 index 0000000000000000000000000000000000000000..a90050f68d0112ad4870c269e58cf3d7f788f080 GIT binary patch literal 7271 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 L6`67=v%voVgAgF} literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/chart_tooltip-dark.png b/testdata/snapshots/darwin/chart_tooltip-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..01939fd5384e5923ba1a908cbc6be19e19d7b5d5 GIT binary patch literal 5005 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 literal 0 HcmV?d00001 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