From bb93648d86ab57b3cdbfe109cbb42aacc51479e9 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:44:30 -0400 Subject: [PATCH] WIP charts2 frame parts --- bar_chart.go | 4 +- chart_empty_state.go | 32 ++++++++++ chart_frame.go | 91 ++++++++++------------------- chart_legend.go | 136 +++++++++++++++++++++++++++++++++++++++++++ chart_tooltip.go | 83 ++++++++++++++++++++++++++ line_chart.go | 17 +++++- messages_charts2.go | 8 +++ pie_chart.go | 4 +- radar_chart.go | 4 +- scatter_chart.go | 4 +- 10 files changed, 312 insertions(+), 71 deletions(-) create mode 100644 chart_empty_state.go create mode 100644 chart_legend.go create mode 100644 chart_tooltip.go create mode 100644 messages_charts2.go diff --git a/bar_chart.go b/bar_chart.go index 7989ad2..be5f1de 100644 --- a/bar_chart.go +++ b/bar_chart.go @@ -32,10 +32,10 @@ func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts Bar k := Tokens(c) pal := internal.Palette(k) size := FontSize(c, theme.CaptionSize) - keys := make([]chartKey, len(series)) + keys := make([]ChartLegendItem, len(series)) var vis []int for i, s := range series { - keys[i] = chartKey{Name: s.Name, Color: pal[i%len(pal)], Shape: i} + keys[i] = ChartLegendItem{Name: s.Name, Color: pal[i%len(pal)], Shape: i} if !ch.hidden(s.Name) { vis = append(vis, i) } diff --git a/chart_empty_state.go b/chart_empty_state.go new file mode 100644 index 0000000..d13ed66 --- /dev/null +++ b/chart_empty_state.go @@ -0,0 +1,32 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" +) + +// ChartEmptyStateOptions configure a ChartEmptyState. +type ChartEmptyStateOptions struct { + // Height keeps the chart's size; 0 means 240. + Height float32 + // Error describes a DataFailed status; empty uses the built-in text. + Error string + // Empty replaces the built-in "No data" text. + Empty string +} + +// ChartEmptyState holds a chart's place while it loads, fails or has no data; Changed reports Retry. +func ChartEmptyState(c *ui.Context, status DataStatus, opts ChartEmptyStateOptions) ChoiceResult { + if opts.Height < 0 { + panic("mujica: ChartEmptyState Height must be ≥ 0") + } + h := opts.Height + if h == 0 { + h = 240 + } + var res ChoiceResult + res.Element = ui.Column(c).Height(h).FillWidth().MinWidth(0) + res.Element.Children(func() { + res.changed = dataPlaceholder(c, status, opts.Error, opts.Empty, msg(c, "chart.empty")) && !res.Element.IsDisabled() + }) + return res +} diff --git a/chart_frame.go b/chart_frame.go index 9fdad75..c3ea645 100644 --- a/chart_frame.go +++ b/chart_frame.go @@ -44,18 +44,11 @@ func (r ChartResult) ValueChanged() bool { return r.value } // Toggled returns the legend series switched in this frame. func (r ChartResult) Toggled() (string, bool) { return r.toggled, r.toggled != "" } -// chartKey is a legend entry: name, color and shape. -type chartKey struct { - Name string - Color ui.Color - Shape int -} - // chartSpec is one chart's drawing and behavior. type chartSpec struct { // n counts the marks keys step through; 0 is empty. n int - keys []chartKey + keys []ChartLegendItem // toggle lets legend entries hide and show series. toggle bool draw func(p *ui.Painter, r ui.Rect, active int) @@ -66,6 +59,10 @@ type chartSpec struct { 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 } type chartState struct { @@ -105,33 +102,48 @@ func (ch *chart) build(spec chartSpec) (ChartResult, *ui.Element) { var plot *ui.Element ch.res.Element.Children(func() { if len(spec.keys) > 0 { - ch.legend(spec) + var hidden *map[string]bool + if spec.toggle { + hidden = &st.hidden + } + if name, ok := ChartLegend(c, hidden, spec.keys, ChartLegendOptions{}).Toggled(); ok { + ch.res.toggled = name + } } - box := ui.Box(c).Height(ch.frame.Height).FillWidth().MinWidth(0) if ch.frame.Status == DataLoading || ch.frame.Status == DataFailed || spec.n == 0 { st.focus = -1 - box.Children(func() { - ui.Column(c).Fill().Children(func() { - ch.res.retry = dataPlaceholder(c, ch.frame.Status, ch.frame.Error, ch.frame.Empty, msg(c, "chart.empty")) - }) - }) + ch.res.retry = ChartEmptyState(c, ch.frame.Status, ChartEmptyStateOptions{Height: ch.frame.Height, Error: ch.frame.Error, Empty: ch.frame.Empty}).Changed() ch.res.Point = -1 ui.Box(c).Height(u * 5) return } + box := ui.Box(c).Height(ch.frame.Height).FillWidth().MinWidth(0) plot = box.Focusable().Label(ch.frame.Label).Role(ui.RoleGroup) internal.Ring(plot, k.Focus, theme.ControlRadius) st.focus = min(st.focus, spec.n-1) ch.input(plot, spec) active := -1 - if x, y, over := plot.PointerPosition(); over && !plot.IsDisabled() { - b := plot.Bounds() - active = spec.pick(ui.Rect{W: b.W, H: b.H}, x, y) + b := plot.Bounds() + local := ui.Rect{W: b.W, H: b.H} + x, y, over := plot.PointerPosition() + if over && !plot.IsDisabled() { + active = spec.pick(local, x, y) } if active < 0 && plot.Focused() { active = st.focus + x, y = local.W/2, 0 + 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) + } + ChartTooltip(c, b.X+x, b.Y+y, lines) + } plot.Draw(func(p *ui.Painter, r ui.Rect) { spec.draw(p, r, active) }) text, col := msg(c, "chart.hint"), k.TextMuted if active >= 0 { @@ -196,49 +208,6 @@ func (ch *chart) input(plot *ui.Element, spec chartSpec) { } } -// legend shows the series keys; toggling ones are check boxes. -func (ch *chart) legend(spec chartSpec) { - c, k, st := ch.c, Tokens(ch.c), ch.st - u := Density(c).Unit() - ui.Row(c).Wrap().Gap(u * 3).Children(func() { - for _, key := range spec.keys { - ui.Row(c).Key(key.Name).Children(func() { - on := !st.hidden[key.Name] - var e *ui.Element - if spec.toggle { - e = ui.CheckboxBase(c, &on).Disabled(ch.res.Element.IsDisabled()).Label(key.Name) - internal.Ring(e, k.Focus, theme.ControlRadius) - if e.Changed() && !e.IsDisabled() { - st.hidden[key.Name] = !on - ch.res.toggled = key.Name - slog.Debug("mujica: chart toggle", "series", key.Name, "visible", on) - } - } else { - e = ui.Row(c) - } - e.MinHeight(theme.MinHitSize).Gap(u*2).AlignItems(ui.Center).Padding(0, u).Radius(theme.ControlRadius) - col := key.Color - if !on { - col = k.TextMuted - } - e.Children(func() { - ui.Box(c).Size(12, 12).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { - if on { - internal.Marker(p, key.Shape, r.X+6, r.Y+6, 10, col) - } else { - p.Stroke(r, col, 2, 1) - } - }) - t := ui.Text(c, key.Name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text) - if !on { - t.Strikethrough().TextColor(k.TextMuted) - } - }) - }) - } - }) -} - // chartText draws s anchored at (x, y) by ax, ay. func chartText(p *ui.Painter, s string, x, y, size float32, col ui.Color, ax, ay float32) (w, h float32) { w, h = p.MeasureText(0, ui.Span{Text: s, Size: size}) diff --git a/chart_legend.go b/chart_legend.go new file mode 100644 index 0000000..7712d7e --- /dev/null +++ b/chart_legend.go @@ -0,0 +1,136 @@ +package mujica + +import ( + "fmt" + "log/slog" + "maps" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartLegendItem is a series key: name, color and marker shape. +type ChartLegendItem struct { + Name string + Color ui.Color + // Shape picks the marker: 0 circle, 1 square, 2 diamond, 3 triangle, 4 inverted. + Shape int +} + +// ChartLegendOptions configure a ChartLegend. +type ChartLegendOptions struct { + Vertical bool + // MaxItems folds the rest behind a "+N more" button; 0 shows all. + MaxItems int +} + +// ChartLegendResult is a built legend and the series switched this frame. +type ChartLegendResult struct { + Element *ui.Element + toggled string +} + +// Toggled returns the series switched in this frame. +func (r ChartLegendResult) Toggled() (string, bool) { return r.toggled, r.toggled != "" } + +// ChartLegend lists series keys; with hidden set, each is a check box hiding its series. +func ChartLegend(c *ui.Context, hidden *map[string]bool, items []ChartLegendItem, opts ChartLegendOptions) ChartLegendResult { + if opts.MaxItems < 0 { + panic("mujica: ChartLegend MaxItems must be ≥ 0") + } + seen := map[string]bool{} + for _, it := range items { + if it.Name == "" || seen[it.Name] { + panic("mujica: ChartLegend items need unique names") + } + seen[it.Name] = true + } + k := Tokens(c) + u := Density(c).Unit() + var res ChartLegendResult + res.Element = ui.Row(c).Gap(u * 3).Wrap() + orient := ui.Horizontal + if opts.Vertical { + res.Element.Column().Gap(u) + orient = ui.Vertical + } + open := ui.Local(res.Element, "mujica.legend.open", func() bool { return false }) + res.Element.FocusGroup(orient).Children(func() { + shown := items + if opts.MaxItems > 0 && len(items) > opts.MaxItems && !*open { + shown = items[:opts.MaxItems] + } + for _, it := range shown { + ui.Row(c).Key(it.Name).Children(func() { + if name, ok := chartLegendEntry(c, hidden, it, res.Element.IsDisabled()); ok { + res.toggled = name + } + }) + } + if opts.MaxItems > 0 && len(items) > opts.MaxItems { + label := fmt.Sprintf(msg(c, "chart_legend.more"), len(items)-opts.MaxItems) + if *open { + label = msg(c, "chart_legend.fewer") + } + b := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u*2).Radius(theme.ControlRadius).Disabled(res.Element.IsDisabled()).TextColor(k.AccentText) + internal.Ring(b, k.Focus, theme.ControlRadius) + if b.Hovered() { + b.Background(k.SurfaceHover) + } + b.Children(func() { ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).SingleLine() }) + if b.Clicked() && !b.IsDisabled() { + *open = !*open + slog.Debug("mujica: chart_legend fold", "open", *open) + } + } + }) + return res +} + +// chartLegendEntry builds one key; a check box when hidden is set. It reports a switch. +func chartLegendEntry(c *ui.Context, hidden *map[string]bool, it ChartLegendItem, disabled bool) (string, bool) { + k := Tokens(c) + u := Density(c).Unit() + on := hidden == nil || !(*hidden)[it.Name] + var e *ui.Element + switched := false + if hidden != nil { + e = ui.CheckboxBase(c, &on).Disabled(disabled).Label(it.Name) + internal.Ring(e, k.Focus, theme.ControlRadius) + if e.Hovered() && !disabled { + e.Background(k.SurfaceHover) + } + if e.Changed() && !e.IsDisabled() { + m := maps.Clone(*hidden) + if m == nil { + m = map[string]bool{} + } + m[it.Name] = !on + *hidden, switched = m, true + slog.Debug("mujica: chart_legend toggle", "series", it.Name, "visible", on) + } + } else { + e = ui.Row(c) + } + e.MinHeight(theme.MinHitSize).Gap(u*2).AlignItems(ui.Center).Padding(0, u).Radius(theme.ControlRadius) + col := it.Color + if !on { + col = k.TextMuted + } + e.Children(func() { + ui.Box(c).Size(12, 12).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) { + if on { + internal.Marker(p, it.Shape, r.X+6, r.Y+6, 10, col) + } else { + p.Stroke(r, col, 2, 1) + } + }) + t := ui.Text(c, it.Name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text) + if !on { + t.Strikethrough().TextColor(k.TextMuted) + } + }) + return it.Name, switched +} diff --git a/chart_tooltip.go b/chart_tooltip.go new file mode 100644 index 0000000..166b3dc --- /dev/null +++ b/chart_tooltip.go @@ -0,0 +1,83 @@ +package mujica + +import ( + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ChartTooltipLine is one row of a tooltip; a zero Color shows no marker. +type ChartTooltipLine struct { + Text string + Color ui.Color + Shape int +} + +// ChartTooltip floats painted lines beside window point (x, y), avoiding edges. +func ChartTooltip(c *ui.Context, x, y float32, lines []ChartTooltipLine) *ui.Element { + if len(lines) == 0 { + panic("mujica: ChartTooltip needs a line") + } + k := Tokens(c) + u := Density(c).Unit() + size := FontSize(c, theme.CaptionSize) + pad, gap := u*2, float32(12) + w, h := float32(0), pad*2 + lh := make([]float32, len(lines)) + for i, l := range lines { + tw, th := c.MeasureText(0, ui.Span{Text: l.Text, Size: size, Weight: chartTipWeight(i)}) + if l.Color.A > 0 { + tw += 14 + } + w, lh[i] = max(w, tw), th + h += th + } + w += pad * 2 + ww, wh := c.Size() + left, top := x+gap, y+gap + if left+w > ww-4 { + left = x - gap - w + } + if top+h > wh-4 { + top = y - gap - h + } + left, top = max(4, left), max(4, top) + var tip *ui.Element + ui.Overlay(c, func() { + tip = ui.Box(c).Absolute().Left(left).Top(top).Size(w, h).PassThrough().Role(ui.RoleNone) + overlayStyle(c, tip, 1) + tip.Draw(func(p *ui.Painter, r ui.Rect) { + at := r.Y + pad + for i, l := range lines { + tx := r.X + pad + if l.Color.A > 0 { + internal.Marker(p, l.Shape, tx+5, at+lh[i]/2, 9, l.Color) + tx += 14 + } + p.RichText(tx, at, 0, ui.Span{Text: l.Text, Size: size, Weight: chartTipWeight(i), Color: k.Text}) + at += lh[i] + } + }) + }) + return tip +} + +// chartTipWeight sets the first line, the heading, in semibold. +func chartTipWeight(i int) int { + if i == 0 { + return 600 + } + return 400 +} + +// chartTipLines splits a readout at " · " into a heading and rows. +func chartTipLines(readout string) []ChartTooltipLine { + var out []ChartTooltipLine + for _, s := range strings.Split(readout, " · ") { + out = append(out, ChartTooltipLine{Text: s}) + } + return out +} diff --git a/line_chart.go b/line_chart.go index 44a09c2..71f56d9 100644 --- a/line_chart.go +++ b/line_chart.go @@ -71,9 +71,9 @@ func chartLines(ch *chart, d chartLineData) ChartResult { c, k := ch.c, Tokens(ch.c) pal := internal.Palette(k) size := FontSize(c, theme.CaptionSize) - keys := make([]chartKey, len(d.series)) + keys := make([]ChartLegendItem, len(d.series)) for i, s := range d.series { - keys[i] = chartKey{Name: s.Name, Color: pal[i%len(pal)], Shape: i} + keys[i] = ChartLegendItem{Name: s.Name, Color: pal[i%len(pal)], Shape: i} } var ticks []float64 var step float64 @@ -103,6 +103,19 @@ func chartLines(ch *chart, d chartLineData) ChartResult { } return strings.Join(parts, " · ") } + spec.tip = func(i int) []ChartTooltipLine { + lines := []ChartTooltipLine{{Text: d.ax.label(i)}} + for si, s := range d.series { + if !ch.hidden(s.Name) { + lines = append(lines, ChartTooltipLine{Text: s.Name + " " + chartValue(c, s.Values[i], d.format), Color: keys[si].Color, Shape: si}) + } + } + return lines + } + spec.anchor = func(r ui.Rect, i int) (float32, float32) { + plot, xs, _ := geom(r) + return xs.Map(d.ax.vals[i]), plot.Y + } spec.draw = func(p *ui.Painter, r ui.Rect, active int) { plot, xs, ys := geom(r) internal.AxisY(p, plot, ys, ticks, func(v float64) string { return internal.FormatTick(v, step) }, size, k) diff --git a/messages_charts2.go b/messages_charts2.go new file mode 100644 index 0000000..a8193bf --- /dev/null +++ b/messages_charts2.go @@ -0,0 +1,8 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "chart_legend.more": {"+%d more", "另有 %d 项"}, + "chart_legend.fewer": {"Show fewer", "收起"}, + }) +} diff --git a/pie_chart.go b/pie_chart.go index 7024b6b..b4ce86e 100644 --- a/pie_chart.go +++ b/pie_chart.go @@ -46,9 +46,9 @@ func PieChart(c *ui.Context, slices []ChartSlice, opts PieChartOptions) ChartRes if total == 0 { n = 0 } - keys := make([]chartKey, len(parts)) + keys := make([]ChartLegendItem, len(parts)) for i, s := range parts { - keys[i] = chartKey{Name: s.Label, Color: pal[i%len(pal)], Shape: i} + keys[i] = ChartLegendItem{Name: s.Label, Color: pal[i%len(pal)], Shape: i} } // angle returns slice i's start and end, clockwise. angle := func(i int) (float64, float64) { diff --git a/radar_chart.go b/radar_chart.go index d78b869..14f6b0a 100644 --- a/radar_chart.go +++ b/radar_chart.go @@ -27,10 +27,10 @@ func RadarChart(c *ui.Context, axes []string, series []ChartSeries, opts RadarCh k := Tokens(c) pal := internal.Palette(k) size := FontSize(c, theme.CaptionSize) - keys := make([]chartKey, len(series)) + keys := make([]ChartLegendItem, len(series)) top := opts.Max for i, s := range series { - keys[i] = chartKey{Name: s.Name, Color: pal[i%len(pal)], Shape: i} + keys[i] = ChartLegendItem{Name: s.Name, Color: pal[i%len(pal)], Shape: i} for _, v := range s.Values { if v < 0 { panic("mujica: RadarChart values must be ≥ 0") diff --git a/scatter_chart.go b/scatter_chart.go index 1703011..bbf31b6 100644 --- a/scatter_chart.go +++ b/scatter_chart.go @@ -43,11 +43,11 @@ func ScatterChart(c *ui.Context, selected *[]ScatterKey, groups []ScatterGroup, k := Tokens(c) pal := internal.Palette(k) size := FontSize(c, theme.CaptionSize) - keys := make([]chartKey, len(groups)) + keys := make([]ChartLegendItem, len(groups)) var marks []ScatterKey b := [6]float64{math.Inf(1), math.Inf(-1), math.Inf(1), math.Inf(-1), math.Inf(1), math.Inf(-1)} for gi, g := range groups { - keys[gi] = chartKey{Name: g.Name, Color: pal[gi%len(pal)], Shape: gi} + keys[gi] = ChartLegendItem{Name: g.Name, Color: pal[gi%len(pal)], Shape: gi} if g.Name == "" || ch.hidden(g.Name) { continue }