WIP charts2 parts

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 14:54:57 -04:00
1 parent bb93648d86
commit e90a1528db
45 files changed
+1519 -133

No files matched your search

+4 -4
View File
@@ -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
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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
+5 -5
View File
@@ -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)
+176
View File
@@ -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
}
+80
View File
@@ -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) })
})
}
+229
View File
@@ -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)
}
}
+129
View File
@@ -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)
}
})
})
}
+207
View File
@@ -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)
}
+81
View File
@@ -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)
}
+69
View File
@@ -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))
})
})
}
+49
View File
@@ -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
}
+68 -46
View File
@@ -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)
}
}
+68
View File
@@ -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)
}
}
}
+66
View File
@@ -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)
})
})
}
+1 -1
View File
@@ -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 })
+95
View File
@@ -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})
})
})
}
+86
View File
@@ -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}})
})
}
+4 -4
View File
@@ -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) }
+4 -4
View File
@@ -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 {
+5 -5
View File
@@ -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)
+4 -4
View File
@@ -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
+36 -10
View File
@@ -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
}
}
+6 -6
View File
@@ -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)
+3
View File
@@ -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"},
})
}
+5 -5
View File
@@ -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 ""
+4 -4
View File
@@ -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)
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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 {
+5 -5
View File
@@ -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
+5 -5
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+5 -5
View File
@@ -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)
+4 -4
View File
@@ -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