WIP charts2 frame parts
This commit is contained in:
10 files changed
+312
-71
No files matched your search
+2
-2
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+30
-61
@@ -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})
|
||||
|
||||
+136
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+15
-2
@@ -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)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"chart_legend.more": {"+%d more", "另有 %d 项"},
|
||||
"chart_legend.fewer": {"Show fewer", "收起"},
|
||||
})
|
||||
}
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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")
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user