Merge branch 'worktree-agent-acdf7464c15aa7d35'
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"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 {
|
||||
c *ui.Context
|
||||
notes []ChartNote
|
||||
opts ChartAnnotationOptions
|
||||
k theme.Tokens
|
||||
size float32
|
||||
}
|
||||
|
||||
// ChartAnnotation draws notes and enables optional movement.
|
||||
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 < NoteHLine || n.Kind > NoteText || math.IsNaN(n.Value) || math.IsInf(n.Value, 0) || math.IsNaN(n.End) || math.IsInf(n.End, 0) || 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{c: c, 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 formats a line's label and value.
|
||||
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 handles movement; plot uses element coordinates.
|
||||
func (a ChartAnnotationPart) Drag(plotElement *ui.Element, plot ui.Rect) bool {
|
||||
if plotElement.IsDisabled() {
|
||||
return false
|
||||
}
|
||||
if a.keyboard(plotElement) {
|
||||
return true
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func (a ChartAnnotationPart) keyboard(e *ui.Element) bool {
|
||||
index := ui.Local(e, "mujica.notes.key", func() int { return 0 })
|
||||
dir := 0
|
||||
if e.Shortcut(ui.Alt, ui.KeyPageDown) {
|
||||
dir = 1
|
||||
}
|
||||
if e.Shortcut(ui.Alt, ui.KeyPageUp) {
|
||||
dir = -1
|
||||
}
|
||||
var editable []int
|
||||
for i, n := range a.notes {
|
||||
if n.Draggable {
|
||||
editable = append(editable, i)
|
||||
}
|
||||
}
|
||||
if len(editable) == 0 {
|
||||
return false
|
||||
}
|
||||
*index = (*index + dir + len(editable)) % len(editable)
|
||||
n := &a.notes[editable[*index]]
|
||||
dx, dy := 0.0, 0.0
|
||||
if e.Shortcut(ui.Alt, ui.KeyRight) {
|
||||
dx = 1
|
||||
}
|
||||
if e.Shortcut(ui.Alt, ui.KeyLeft) {
|
||||
dx = -1
|
||||
}
|
||||
if e.Shortcut(ui.Alt, ui.KeyUp) {
|
||||
dy = 1
|
||||
}
|
||||
if e.Shortcut(ui.Alt, ui.KeyDown) {
|
||||
dy = -1
|
||||
}
|
||||
xstep := (a.opts.X.hi - a.opts.X.lo) / 100
|
||||
ystep := (a.opts.Y.hi - a.opts.Y.lo) / 100
|
||||
before := *n
|
||||
switch n.Kind {
|
||||
case NoteHLine:
|
||||
n.Value += dy * ystep
|
||||
case NoteVLine:
|
||||
n.Value += dx * xstep
|
||||
case NoteBand:
|
||||
n.Value, n.End = n.Value+dx*xstep, n.End+dx*xstep
|
||||
case NoteText:
|
||||
n.Value, n.End = n.Value+dx*xstep, n.End+dy*ystep
|
||||
}
|
||||
if dir != 0 || before != *n {
|
||||
key := "chart_annotation.x"
|
||||
value := a.opts.X.Label(n.Value)
|
||||
switch n.Kind {
|
||||
case NoteHLine:
|
||||
key, value = "chart_annotation.y", a.opts.Y.Label(n.Value)
|
||||
case NoteBand:
|
||||
value += "–" + a.opts.X.Label(n.End)
|
||||
case NoteText:
|
||||
value += " · " + fmt.Sprintf(msg(a.c, "chart_annotation.y"), a.opts.Y.Label(n.End))
|
||||
}
|
||||
text := fmt.Sprintf(msg(a.c, "chart_annotation.readout"), editable[*index]+1, fmt.Sprintf(msg(a.c, key), value))
|
||||
e.Description(text)
|
||||
a.c.Announce(text)
|
||||
slog.Debug("mujica: chart_annotation keyboard", "note", editable[*index], "value", n.Value)
|
||||
}
|
||||
return before != *n
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"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 Goal from 50 to 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) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartAnnotationKeyboardEditsEveryKind(t *testing.T) {
|
||||
notes := []ChartNote{
|
||||
{Kind: NoteHLine, Value: 50, Draggable: true},
|
||||
{Kind: NoteVLine, Value: 3, Draggable: true},
|
||||
{Kind: NoteBand, Value: 6, End: 8, Draggable: true},
|
||||
{Kind: NoteText, Value: 1, End: 90, Text: "Feast", Draggable: true},
|
||||
}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult { return partsChart(c, notes, false) }}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Parts")
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if notes[0].Value != 51 {
|
||||
t.Fatalf("horizontal line %v", notes[0].Value)
|
||||
}
|
||||
if said := tt.Announcements(); len(said) == 0 || !strings.Contains(said[len(said)-1], "y 51") {
|
||||
t.Fatalf("horizontal readout %q", said)
|
||||
}
|
||||
tt.Key(ui.Alt, ui.KeyPageDown)
|
||||
tt.Key(ui.Alt, ui.KeyRight)
|
||||
if notes[1].Value != 3.1 {
|
||||
t.Fatalf("vertical line %v", notes[1].Value)
|
||||
}
|
||||
tt.Key(ui.Alt, ui.KeyPageDown)
|
||||
tt.Key(ui.Alt, ui.KeyRight)
|
||||
if math.Abs(notes[2].Value-6.1) > 1e-9 || notes[2].End-notes[2].Value != 2 {
|
||||
t.Fatalf("band %+v", notes[2])
|
||||
}
|
||||
tt.Key(ui.Alt, ui.KeyPageDown)
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if notes[3].End != 91 {
|
||||
t.Fatalf("text %+v", notes[3])
|
||||
}
|
||||
h.disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if notes[3].End != 91 {
|
||||
t.Fatalf("disabled text %+v", notes[3])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
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; Min must be positive.
|
||||
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 overrides tick and readout labels.
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// ChartAxisPart maps values and draws an axis.
|
||||
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) || math.IsInf(a.lo, 0) || math.IsInf(a.hi, 0) || 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 < SideBottom || opts.Side > SideTop {
|
||||
panic("mujica: unknown ChartSide")
|
||||
}
|
||||
a.ticks = opts.Ticks
|
||||
for i, v := range a.ticks {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) || v < a.lo || v > a.hi || i > 0 && v <= a.ticks[i-1] {
|
||||
panic("mujica: ChartAxis ticks must ascend inside the domain")
|
||||
}
|
||||
}
|
||||
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:
|
||||
// Short spans include powers of two and five.
|
||||
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:
|
||||
n := len(a.opts.Categories)
|
||||
step := max(1, (n+63)/64)
|
||||
for i := 0; i < n; i += step {
|
||||
out = append(out, float64(i))
|
||||
}
|
||||
if out[len(out)-1] != float64(n-1) {
|
||||
out = append(out, float64(n-1))
|
||||
}
|
||||
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 returns the normalized position along the axis.
|
||||
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 position of v.
|
||||
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 a pixel position.
|
||||
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 uses finer precision than ticks.
|
||||
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.
|
||||
func (a ChartAxisPart) Draw(p *ui.Painter, plot ui.Rect) {
|
||||
spacing := a.size * 2
|
||||
if a.horizontal() {
|
||||
spacing = a.size * 4
|
||||
}
|
||||
ticks := a.drawTicks(plot, spacing)
|
||||
pos := make([]float32, len(ticks))
|
||||
labels := make([]string, len(ticks))
|
||||
for i, t := range 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)
|
||||
}
|
||||
}
|
||||
|
||||
func (a ChartAxisPart) drawTicks(plot ui.Rect, spacing float32) []float64 {
|
||||
length := plot.H
|
||||
if a.horizontal() {
|
||||
length = plot.W
|
||||
}
|
||||
limit := max(2, int(length/max(spacing, 1)))
|
||||
if len(a.ticks) <= limit {
|
||||
return a.ticks
|
||||
}
|
||||
step := (len(a.ticks) + limit - 1) / limit
|
||||
out := make([]float64, 0, limit+1)
|
||||
for i := 0; i < len(a.ticks); i += step {
|
||||
out = append(out, a.ticks[i])
|
||||
}
|
||||
if out[len(out)-1] != a.ticks[len(a.ticks)-1] {
|
||||
out = append(out, a.ticks[len(a.ticks)-1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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},
|
||||
"negative side": {Min: 0, Max: 1, Side: -1},
|
||||
"infinite domain": {Min: 0, Max: math.Inf(1)},
|
||||
"outside tick": {Min: 0, Max: 1, Ticks: []float64{2}},
|
||||
"infinite tick": {Min: 0, Max: 1, Ticks: []float64{math.Inf(1)}},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
axisIn(o)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// partsChart composes public parts 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartAxisLargeCategoriesBounded(t *testing.T) {
|
||||
categories := make([]string, 100_000)
|
||||
for i := range categories {
|
||||
categories[i] = fmt.Sprint(i)
|
||||
}
|
||||
a := axisIn(ChartAxisOptions{Kind: AxisCategory, Categories: categories})
|
||||
if n := len(a.Ticks()); n > 65 || n == 0 {
|
||||
t.Fatalf("built %d category ticks", n)
|
||||
}
|
||||
if a.Label(99999) != "99999" || a.Map(ui.Rect{W: 1000}, 99999) < 999 {
|
||||
t.Fatal("last category mapping")
|
||||
}
|
||||
}
|
||||
|
||||
func charts2MustPanic(t *testing.T, view func(*ui.Context)) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("invalid options accepted")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); view(c) }, 320, 240)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
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 defaults to one 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 a range with dragging and keyboard controls.
|
||||
func ChartBrush(c *ui.Context, value *[2]float64, opts ChartBrushOptions) ChoiceResult {
|
||||
if opts.Label == "" || !(opts.Max > opts.Min) || math.IsInf(opts.Min, 0) || math.IsInf(opts.Max, 0) || math.IsNaN(opts.Step) || math.IsInf(opts.Step, 0) || opts.Step < 0 || math.IsNaN(float64(opts.Height)) || math.IsInf(float64(opts.Height), 0) || opts.Height != 0 && opts.Height < theme.MinHitSize {
|
||||
panic("mujica: ChartBrush needs a Label, finite domain and step, and Height ≥ 28")
|
||||
}
|
||||
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}
|
||||
}
|
||||
}
|
||||
move := func(lo, hi float64) {
|
||||
w := hi - lo
|
||||
lo = max(spec.lo, min(lo, spec.hi-w))
|
||||
set(lo, lo+w)
|
||||
}
|
||||
preview := ui.Local(track, "mujica.brush.preview", func() brushPreview { return brushPreview{} })
|
||||
if id := chartID(opts.Values); preview.id != id {
|
||||
*preview = brushPreview{id: id, lo: math.Inf(1), hi: math.Inf(-1)}
|
||||
for _, v := range opts.Values {
|
||||
if math.IsInf(v, 0) {
|
||||
panic("mujica: ChartBrush preview values must be finite or NaN")
|
||||
}
|
||||
if !math.IsNaN(v) {
|
||||
preview.lo, preview.hi = min(preview.lo, v), max(preview.hi, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
track.Draw(func(p *ui.Painter, r ui.Rect) { brushPaint(p, r, *value, opts, k, preview) })
|
||||
track.Children(func() {
|
||||
x0, x1 := at(value[0]), at(value[1])
|
||||
end := func(i int, x float32) *ui.Element {
|
||||
e := 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(e, k.Focus, theme.ControlRadius)
|
||||
return e.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)
|
||||
})
|
||||
}
|
||||
// Tab order runs start, window, end.
|
||||
ends := [2]*ui.Element{end(0, x0)}
|
||||
window := ui.Box(c).Absolute().Left(x0 + theme.MinHitSize/2).Top(0).Width(max(x1-x0-theme.MinHitSize, theme.MinHitSize)).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[1] = end(1, x1)
|
||||
if res.Element.IsDisabled() {
|
||||
drag.part = -1
|
||||
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, move)
|
||||
})
|
||||
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 {
|
||||
c.Announce(fmt.Sprintf(msg(c, "chart_brush.window"), opts.Label, val(value[0]), val(value[1])))
|
||||
slog.Debug("mujica: chart_brush range", "from", value[0], "to", value[1])
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// brushKey handles arrow, Page, Home and End keys.
|
||||
func brushKey(e *ui.Element, v float64, spec numberInputSpec) (float64, bool) {
|
||||
if e.IsDisabled() {
|
||||
return v, false
|
||||
}
|
||||
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 handles, the window, or a new range.
|
||||
func brushPointer(track, window *ui.Element, ends [2]*ui.Element, drag *brushDrag, value *[2]float64, from func(float32) float64, set, move 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:
|
||||
lo := at - drag.grab
|
||||
// Shift both ends together at a boundary.
|
||||
move(lo, lo+w)
|
||||
default:
|
||||
set(min(drag.start, at), max(drag.start, at))
|
||||
}
|
||||
}
|
||||
|
||||
// brushPaint draws the preview and frames the selected range.
|
||||
type brushPreview struct {
|
||||
id string
|
||||
lo, hi float64
|
||||
width float32
|
||||
runs [][]int
|
||||
}
|
||||
|
||||
func brushPaint(p *ui.Painter, r ui.Rect, value [2]float64, opts ChartBrushOptions, k theme.Tokens, preview *brushPreview) {
|
||||
lo, hi := preview.lo, preview.hi
|
||||
if n := len(opts.Values); n > 1 && hi > lo {
|
||||
x := func(i int) float32 { return r.W * float32(i) / float32(n-1) }
|
||||
if preview.width != r.W || preview.runs == nil {
|
||||
preview.width, preview.runs = r.W, nil
|
||||
chartRuns(opts.Values, x, func(idx []int) { preview.runs = append(preview.runs, idx) })
|
||||
}
|
||||
for _, idx := range preview.runs {
|
||||
var line ui.Path
|
||||
for j, i := range idx {
|
||||
px := internal.Snap(p, r.X+x(i))
|
||||
py := internal.Snap(p, r.Y+4+(r.H-8)*float32(1-(opts.Values[i]-lo)/(hi-lo)))
|
||||
if j == 0 {
|
||||
line.MoveTo(px, py)
|
||||
} else {
|
||||
line.LineTo(px, py)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
type brushHarness struct {
|
||||
value [2]float64
|
||||
changes int
|
||||
disabled bool
|
||||
el *ui.Element
|
||||
}
|
||||
|
||||
func (h *brushHarness) view(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
ui.Column(c).Padding(10).Width(420).Children(func() {
|
||||
r := ChartBrush(c, &h.value, ChartBrushOptions{Label: "Months", Min: 0, Max: 100, Step: 1, Disabled: h.disabled})
|
||||
h.el = r.Element
|
||||
if r.Changed() {
|
||||
h.changes++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartBrushKeyboard(t *testing.T) {
|
||||
h := &brushHarness{value: [2]float64{20, 40}}
|
||||
tt := ui.NewTester(h.view, 460, 200)
|
||||
tabTo(t, tt, "Months: 20 to 40")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if h.value != [2]float64{22, 42} || h.changes != 2 {
|
||||
t.Fatalf("window moved to %v (%d)", h.value, h.changes)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Range end 42") {
|
||||
t.Fatal("Tab did not reach the end handle")
|
||||
}
|
||||
tt.Key(0, ui.KeyPageUp)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if h.value != [2]float64{22, 100} {
|
||||
t.Fatalf("end handle: %v", h.value)
|
||||
}
|
||||
tt.Key(ui.Shift, ui.KeyTab)
|
||||
tt.Key(ui.Shift, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd) // the start cannot pass the end
|
||||
if h.value != [2]float64{99, 100} || !tt.HasText("99 – 100") {
|
||||
t.Fatalf("start to End: %v %q", h.value, tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartBrushDrag(t *testing.T) {
|
||||
h := &brushHarness{value: [2]float64{20, 40}}
|
||||
tt := ui.NewTester(h.view, 460, 200)
|
||||
b := h.el.Bounds()
|
||||
x := func(v float32) float32 { return b.X + 400*v/100 }
|
||||
y := b.Y + 24
|
||||
tt.Press(x(30), y) // inside the window
|
||||
tt.Move(x(40), y)
|
||||
tt.Move(x(50), y)
|
||||
tt.Release(x(50), y)
|
||||
if h.value != [2]float64{40, 60} {
|
||||
t.Fatalf("window drag: %v", h.value)
|
||||
}
|
||||
tt.Press(x(60), y) // the end handle
|
||||
tt.Move(x(70), y)
|
||||
tt.Move(x(75), y)
|
||||
tt.Release(x(75), y)
|
||||
if h.value != [2]float64{40, 75} {
|
||||
t.Fatalf("end drag: %v", h.value)
|
||||
}
|
||||
tt.Press(x(85), y) // outside: a new range
|
||||
tt.Move(x(90), y)
|
||||
tt.Move(x(95), y)
|
||||
tt.Release(x(95), y)
|
||||
if h.value != [2]float64{85, 95} {
|
||||
t.Fatalf("new range: %v", h.value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartBrushDisabledAndBad(t *testing.T) {
|
||||
h := &brushHarness{value: [2]float64{20, 40}, disabled: true}
|
||||
tt := ui.NewTester(h.view, 460, 200)
|
||||
b := h.el.Bounds()
|
||||
tt.Press(b.X+120, b.Y+24)
|
||||
tt.Move(b.X+200, b.Y+24)
|
||||
tt.Release(b.X+200, b.Y+24)
|
||||
for range 3 {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
}
|
||||
if h.value != [2]float64{20, 40} || h.changes != 0 {
|
||||
t.Fatalf("disabled brush moved: %v", h.value)
|
||||
}
|
||||
for _, v := range [][2]float64{{50, 40}, {-1, 10}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%v: no panic", v)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ChartBrush(c, &v, ChartBrushOptions{Label: "x", Max: 100})
|
||||
}, 300, 100)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartBrushSnapshot(t *testing.T) {
|
||||
vals := make([]float64, 60)
|
||||
for i := range vals {
|
||||
vals[i] = float64((i*7)%13) + float64(i)/6
|
||||
}
|
||||
snapshot(t, "chart_brush", 460, 110, func(c *ui.Context) {
|
||||
v := [2]float64{18, 42}
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ChartBrush(c, &v, ChartBrushOptions{Label: "Months", Min: 0, Max: 59, Step: 1, Values: vals, Format: func(f float64) string { return fmt.Sprintf("week %.0f", f) }})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartBrushWindowDragPreservesSpanAtEdges(t *testing.T) {
|
||||
h := &brushHarness{value: [2]float64{20, 60}}
|
||||
tt := ui.NewTester(h.view, 460, 200)
|
||||
b := h.el.Bounds()
|
||||
tt.Press(b.X+160, b.Y+24)
|
||||
tt.Move(b.X+380, b.Y+24)
|
||||
tt.Release(b.X+380, b.Y+24)
|
||||
if h.value != [2]float64{60, 100} {
|
||||
t.Fatalf("right edge shrank window: %v", h.value)
|
||||
}
|
||||
tt.Press(b.X+320, b.Y+24)
|
||||
tt.Move(b.X-50, b.Y+24)
|
||||
tt.Release(b.X-50, b.Y+24)
|
||||
if h.value != [2]float64{0, 40} {
|
||||
t.Fatalf("left edge shrank window: %v", h.value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartBrushInvalidMetrics(t *testing.T) {
|
||||
for _, o := range []ChartBrushOptions{
|
||||
{Label: "Range", Max: 100, Step: math.Inf(1)},
|
||||
{Label: "Range", Max: math.Inf(1)},
|
||||
{Label: "Range", Max: 100, Height: 10},
|
||||
} {
|
||||
charts2MustPanic(t, func(c *ui.Context) { v := [2]float64{20, 40}; ChartBrush(c, &v, o) })
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartBrushNarrowRangeHitAndAnnouncement(t *testing.T) {
|
||||
h := &brushHarness{value: [2]float64{40, 41}}
|
||||
tt := ui.NewTester(h.view, 460, 200)
|
||||
b, ok := tt.Find("Months: 40 to 41")
|
||||
if !ok || b.W < 28 || b.H < 28 {
|
||||
t.Fatalf("window hit area %v", b)
|
||||
}
|
||||
tabTo(t, tt, "Months: 40 to 41")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if said := tt.Announcements(); len(said) == 0 || said[len(said)-1] != "Months: 41 to 42" {
|
||||
t.Fatalf("window announcement %q", said)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"math"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChartCrosshairOptions configure a ChartCrosshair.
|
||||
type ChartCrosshairOptions struct {
|
||||
// X draws vertical lines; Y draws horizontal lines.
|
||||
X, Y *ChartAxisPart
|
||||
// Reach defaults to 24 DIPs for point snapping.
|
||||
Reach float32
|
||||
}
|
||||
|
||||
// ChartCrosshairPart draws and snaps the crosshair.
|
||||
type ChartCrosshairPart struct {
|
||||
opts ChartCrosshairOptions
|
||||
k theme.Tokens
|
||||
size float32
|
||||
pad float32
|
||||
}
|
||||
|
||||
// ChartCrosshair draws crossed lines and axis readouts.
|
||||
func ChartCrosshair(c *ui.Context, opts ChartCrosshairOptions) ChartCrosshairPart {
|
||||
if opts.X == nil && opts.Y == nil || opts.Reach < 0 || math.IsNaN(float64(opts.Reach)) || math.IsInf(float64(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 nearest point 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 crossed lines and value labels.
|
||||
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 paints a value tag 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)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"math"
|
||||
"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 ten DIPs from the pointer.
|
||||
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 checks a crosshair pixel.
|
||||
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))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartCrosshairDisablesFocusedReadout(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult { return partsChart(c, nil, true) }}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Parts")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
h.disabled = true
|
||||
tt.Frame()
|
||||
if h.res.Point != -1 {
|
||||
t.Fatalf("disabled readout %d", h.res.Point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartCrosshairFiniteReach(t *testing.T) {
|
||||
charts2MustPanic(t, func(c *ui.Context) {
|
||||
a := ChartAxis(c, ChartAxisOptions{Max: 1})
|
||||
ChartCrosshair(c, ChartCrosshairOptions{X: &a, Reach: float32(math.NaN())})
|
||||
})
|
||||
}
|
||||
@@ -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 preserves plot size and reports retry clicks.
|
||||
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_state.empty")) && !res.Element.IsDisabled()
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestChartEmptyStateKeepsSizeAndRetries(t *testing.T) {
|
||||
status := DataReady
|
||||
retries, disabled := 0, false
|
||||
var heights []float32
|
||||
var el *ui.Element
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Width(400).Disabled(disabled).Children(func() {
|
||||
r := ChartEmptyState(c, status, ChartEmptyStateOptions{Height: 180, Empty: "No hymns this week", Error: "The choir did not answer"})
|
||||
el = r.Element
|
||||
if r.Changed() {
|
||||
retries++
|
||||
}
|
||||
})
|
||||
}, 440, 300)
|
||||
heights = append(heights, el.Bounds().H)
|
||||
if !tt.HasText("No hymns this week") {
|
||||
t.Fatalf("empty: %q", tt.Texts())
|
||||
}
|
||||
status = DataLoading
|
||||
tt.Frame()
|
||||
heights = append(heights, el.Bounds().H)
|
||||
if !tt.HasText("Loading") {
|
||||
t.Fatalf("loading: %q", tt.Texts())
|
||||
}
|
||||
status = DataFailed
|
||||
tt.Frame()
|
||||
heights = append(heights, el.Bounds().H)
|
||||
if err := tt.Click("Retry"); err != nil || retries != 1 || !tt.HasText("The choir did not answer") {
|
||||
t.Fatalf("retry: %v %d", err, retries)
|
||||
}
|
||||
if heights[0] != 180 || heights[1] != 180 || heights[2] != 180 {
|
||||
t.Fatalf("heights %v", heights)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Retry")
|
||||
if retries != 1 {
|
||||
t.Fatal("disabled retry fired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartEmptyStateSnapshot(t *testing.T) {
|
||||
snapshot(t, "chart_empty_state", 640, 220, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(16).Children(func() {
|
||||
for _, st := range []DataStatus{DataReady, DataLoading, DataFailed} {
|
||||
ui.Column(c).Width(192).Border(1, Tokens(c).Border).Children(func() {
|
||||
ChartEmptyState(c, st, ChartEmptyStateOptions{Height: 180})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"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 returns a PNG rendered in the current theme.
|
||||
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 || math.IsNaN(float64(opts.Scale)) || math.IsInf(float64(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
|
||||
ready := false
|
||||
tester := ui.NewTester(func(c *ui.Context) {
|
||||
if !ready {
|
||||
return
|
||||
}
|
||||
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)
|
||||
tester.SetScale(scale)
|
||||
tester.SetPreferences(c.Preferences())
|
||||
ready = true
|
||||
tester.Frame()
|
||||
img := tester.Image()
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
func TestChartExportPNGInTheme(t *testing.T) {
|
||||
for _, mode := range []Mode{Light, Dark} {
|
||||
var out []byte
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: mode})
|
||||
if out != nil {
|
||||
return
|
||||
}
|
||||
b, err := ChartExport(c, func(c *ui.Context) {
|
||||
BarChart(c, barCats, barSeries(), BarChartOptions{Frame: ChartFrame{Label: "Tithes", Height: 160}})
|
||||
}, ChartExportOptions{Width: 300, Height: 220, Scale: 1.5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = b
|
||||
}, 100, 100)
|
||||
img, err := png.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b := img.Bounds(); b.Dx() != 450 || b.Dy() != 330 {
|
||||
t.Fatalf("%v: size %v", mode, b)
|
||||
}
|
||||
k := theme.Light()
|
||||
if mode == Dark {
|
||||
k = theme.Dark()
|
||||
}
|
||||
if got := color.RGBAModel.Convert(img.At(3, 3)).(color.RGBA); got != toRGBA(k.Surface) {
|
||||
t.Fatalf("%v: corner %v, want the theme's Surface", mode, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartExportBad(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a zero size")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
_, _ = ChartExport(c, func(*ui.Context) {}, ChartExportOptions{})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestChartExportSnapshot(t *testing.T) {
|
||||
snapshot(t, "chart_export", 340, 300, func(c *ui.Context) {
|
||||
b, err := ChartExport(c, func(c *ui.Context) {
|
||||
PieChart(c, pieSlices, PieChartOptions{Frame: ChartFrame{Label: "Stores", Height: 150}, Donut: true, MinAngle: 20})
|
||||
}, ChartExportOptions{Width: 300, Height: 260, Scale: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bmp, err := ui.DecodeBitmap(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ui.Column(c).Padding(20).Children(func() { ui.Image(c, bmp).Size(300, 260) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartExportPreservesPreferences(t *testing.T) {
|
||||
var data []byte
|
||||
var got ui.Preferences
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Dark, Locale: ZhCN})
|
||||
if data != nil {
|
||||
return
|
||||
}
|
||||
var err error
|
||||
data, err = ChartExport(c, func(c *ui.Context) { got = c.Preferences(); ui.Text(c, "Scale") }, ChartExportOptions{Width: 100, Height: 80, Scale: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, 100, 80)
|
||||
data = nil
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 2, ReduceMotion: true, HighContrast: true})
|
||||
if got.TextScale != 2 || !got.ReduceMotion || !got.HighContrast {
|
||||
t.Fatalf("export preferences %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -44,28 +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 != "" }
|
||||
|
||||
// chartKey is a legend entry: name, color and shape.
|
||||
type chartKey struct {
|
||||
Name string
|
||||
Color ui.Color
|
||||
Shape int
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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)
|
||||
// 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 {
|
||||
@@ -95,47 +106,70 @@ 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 {
|
||||
ch.legend(spec)
|
||||
if len(spec.Legend) > 0 {
|
||||
if name, ok := ChartLegend(c, spec.Hidden, spec.Legend, 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 {
|
||||
if ch.frame.Status == DataLoading || ch.frame.Status == DataFailed || spec.Marks == 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)
|
||||
st.focus = min(st.focus, spec.Marks-1)
|
||||
ch.input(plot, spec)
|
||||
active := -1
|
||||
if x, y, over := plot.PointerPosition(); over && !plot.IsDisabled() {
|
||||
if spec.Input != nil && !plot.IsDisabled() {
|
||||
b := plot.Bounds()
|
||||
active = spec.pick(ui.Rect{W: b.W, H: b.H}, x, y)
|
||||
spec.Input(plot, ui.Rect{W: b.W, H: b.H})
|
||||
}
|
||||
if active < 0 && plot.Focused() {
|
||||
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)
|
||||
}
|
||||
if active < 0 && plot.Focused() && st.focus >= 0 && !plot.IsDisabled() {
|
||||
active = st.focus
|
||||
x, y = local.W/2, 0
|
||||
if spec.Anchor != nil {
|
||||
x, y = spec.Anchor(local, active)
|
||||
}
|
||||
}
|
||||
ch.res.Point = active
|
||||
plot.Draw(func(p *ui.Painter, r ui.Rect) { spec.draw(p, r, active) })
|
||||
if active >= 0 && b.W > 0 {
|
||||
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) })
|
||||
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)
|
||||
})
|
||||
@@ -145,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
|
||||
@@ -158,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
|
||||
@@ -178,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)
|
||||
}
|
||||
@@ -187,58 +221,15 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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})
|
||||
|
||||
@@ -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 provide grid ticks; either may be nil.
|
||||
X, Y *ChartAxisPart
|
||||
// Minor subdivides major intervals 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 major and minor lines.
|
||||
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.drawTicks(plot, 4)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 stay pixel-aligned.
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
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 selects circle, square, diamond, triangle, or inverted triangle.
|
||||
Shape int
|
||||
}
|
||||
|
||||
// ChartLegendOptions configure a ChartLegend.
|
||||
type ChartLegendOptions struct {
|
||||
Vertical bool
|
||||
// MaxItems folds overflow; zero shows all items.
|
||||
MaxItems int
|
||||
}
|
||||
|
||||
// ChartLegendResult exposes the element and legend events.
|
||||
type ChartLegendResult struct {
|
||||
Element *ui.Element
|
||||
toggled string
|
||||
expanded, folded bool
|
||||
}
|
||||
|
||||
// Toggled returns the series switched in this frame.
|
||||
func (r ChartLegendResult) Toggled() (string, bool) { return r.toggled, r.toggled != "" }
|
||||
|
||||
// Expanded reports the new state when expansion changes.
|
||||
func (r ChartLegendResult) Expanded() (bool, bool) { return r.expanded, r.folded }
|
||||
|
||||
// ChartLegend lists series; hidden enables visibility toggles.
|
||||
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).AlignItems(ui.Start)
|
||||
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
|
||||
res.expanded, res.folded = *open, true
|
||||
slog.Debug("mujica: chart_legend fold", "open", *open)
|
||||
}
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
// chartLegendEntry builds a series visibility control.
|
||||
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.MinWidth(theme.MinHitSize).MinHeight(Density(c).ControlHeight()).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,125 @@
|
||||
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})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartLegendDisabledAndExpansionEvents(t *testing.T) {
|
||||
hidden := map[string]bool{}
|
||||
disabled := false
|
||||
events := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
r := ChartLegend(c, &hidden, legendItems(), ChartLegendOptions{MaxItems: 2})
|
||||
if _, ok := r.Expanded(); ok {
|
||||
events++
|
||||
}
|
||||
})
|
||||
}, 500, 200)
|
||||
if err := tt.Click("+3 more"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Frame()
|
||||
if events != 1 || !tt.HasText("Sext") {
|
||||
t.Fatalf("expansion events %d", events)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
_ = tt.Click("Matins")
|
||||
_ = tt.Click("Show fewer")
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if hidden["Matins"] || events != 1 || !tt.HasText("Sext") {
|
||||
t.Fatalf("disabled legend %v events %d", hidden, events)
|
||||
}
|
||||
}
|
||||
@@ -204,3 +204,15 @@ func chartRuns(vals []float64, x func(i int) float32, f func(idx []int)) {
|
||||
i = j
|
||||
}
|
||||
}
|
||||
|
||||
func chartVisibleIndices(n, limit, active int) []int {
|
||||
step := max(1, (n+limit-1)/limit)
|
||||
out := make([]int, 0, min(n, limit+1))
|
||||
for i := 0; i < n; i += step {
|
||||
out = append(out, i)
|
||||
}
|
||||
if active >= 0 && active < n && active%step != 0 {
|
||||
out = append(out, active)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChartTooltipLine pairs readout text with an optional marker.
|
||||
type ChartTooltipLine struct {
|
||||
Text string
|
||||
Color ui.Color
|
||||
Shape int
|
||||
}
|
||||
|
||||
// ChartTooltip shows readouts beside a window point, 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()
|
||||
w, h = min(w, max(1, ww-8)), min(h, max(1, wh-8))
|
||||
left, top := overlayPlace(c, ui.Rect{X: x, Y: y}, w, h, gap)
|
||||
if x+gap+w <= ww-4 {
|
||||
left = max(4, x+gap)
|
||||
} else {
|
||||
left = max(4, min(x-gap-w, ww-4-w))
|
||||
}
|
||||
var tip *ui.Element
|
||||
ui.Overlay(c, func() {
|
||||
tip = ui.Column(c).Absolute().Left(left).Top(top).Size(w, h).Role(ui.RoleNone)
|
||||
overlayStyle(c, tip, overlayEnter(c, tip))
|
||||
tip.Children(func() {
|
||||
inner := max(1, w-2*pad)
|
||||
content := float32(0)
|
||||
for i, l := range lines {
|
||||
width := inner
|
||||
if l.Color.A > 0 {
|
||||
width = max(1, width-14)
|
||||
}
|
||||
_, lh[i] = c.MeasureText(width, ui.Span{Text: l.Text, Size: size, Weight: chartTipWeight(i)})
|
||||
content += lh[i]
|
||||
}
|
||||
ui.Scroll(c).Fill().Padding(pad).Children(func() {
|
||||
ui.Box(c).Width(inner).Height(content).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
at := r.Y
|
||||
for i, l := range lines {
|
||||
tx, width := r.X, inner
|
||||
if l.Color.A > 0 {
|
||||
internal.Marker(p, l.Shape, tx+5, at+lh[i]/2, 9, l.Color)
|
||||
tx, width = tx+14, max(1, width-14)
|
||||
}
|
||||
p.RichText(tx, at, width, 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 into tooltip lines.
|
||||
func chartTipLines(readout string) []ChartTooltipLine {
|
||||
var out []ChartTooltipLine
|
||||
for _, s := range strings.Split(readout, " · ") {
|
||||
out = append(out, ChartTooltipLine{Text: s})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
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)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
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 finds Surface runs longer than marker rings.
|
||||
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}})
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartTooltipFitsLongContent(t *testing.T) {
|
||||
var b ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
lines := make([]ChartTooltipLine, 40)
|
||||
for i := range lines {
|
||||
lines[i].Text = "A very long series name that wraps across the tooltip panel"
|
||||
}
|
||||
b = ChartTooltip(c, 159, 99, lines).Bounds()
|
||||
}, 160, 100)
|
||||
tt.Frame()
|
||||
if b.X < 4 || b.Y < 4 || b.X+b.W > 156 || b.Y+b.H > 96 {
|
||||
t.Fatalf("tooltip outside window: %v", b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChordDiagramOptions configure a ChordDiagram.
|
||||
type ChordDiagramOptions struct {
|
||||
Frame ChartFrame
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// chordLayout stores group arcs and relation endpoints.
|
||||
type chordLayout struct {
|
||||
arcs [][2]float64
|
||||
sub [][][2]float64
|
||||
pairs [][2]int
|
||||
total float64
|
||||
}
|
||||
|
||||
// chordGap is the angle left between group arcs.
|
||||
const chordGap = 0.04
|
||||
|
||||
func chordPlace(matrix [][]float64) chordLayout {
|
||||
n := len(matrix)
|
||||
for _, row := range matrix {
|
||||
if len(row) != n {
|
||||
panic("mujica: ChordDiagram matrix must be square")
|
||||
}
|
||||
}
|
||||
l := chordLayout{arcs: make([][2]float64, n), sub: make([][][2]float64, n)}
|
||||
sums := make([]float64, n)
|
||||
for i, row := range matrix {
|
||||
if len(row) != n {
|
||||
panic("mujica: ChordDiagram matrix must be square")
|
||||
}
|
||||
for j, v := range row {
|
||||
if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
panic("mujica: ChordDiagram values must be finite and ≥ 0")
|
||||
}
|
||||
sums[i] += v
|
||||
if j >= i && v+matrix[j][i] > 0 {
|
||||
l.pairs = append(l.pairs, [2]int{i, j})
|
||||
}
|
||||
}
|
||||
l.total += sums[i]
|
||||
}
|
||||
if math.IsInf(l.total, 0) {
|
||||
panic("mujica: ChordDiagram total must be finite")
|
||||
}
|
||||
if l.total == 0 {
|
||||
return l
|
||||
}
|
||||
gap := min(chordGap, math.Pi/float64(n))
|
||||
span := 2*math.Pi - gap*float64(n)
|
||||
a := 0.0
|
||||
for i := range n {
|
||||
l.arcs[i] = [2]float64{a, a + span*sums[i]/l.total}
|
||||
l.sub[i] = make([][2]float64, n)
|
||||
at := a
|
||||
for j := range n {
|
||||
w := span * matrix[i][j] / l.total
|
||||
l.sub[i][j] = [2]float64{at, at + w}
|
||||
at += w
|
||||
}
|
||||
a = l.arcs[i][1] + gap
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// ChordDiagram connects group arcs and highlights read relationships.
|
||||
func ChordDiagram(c *ui.Context, names []string, matrix [][]float64, opts ChordDiagramOptions) ChartResult {
|
||||
if len(names) != len(matrix) {
|
||||
panic("mujica: ChordDiagram needs a name per matrix row")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, name := range names {
|
||||
if name == "" || seen[name] {
|
||||
panic("mujica: ChordDiagram groups need unique names")
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
ch := newChart(c, opts.Frame)
|
||||
l := chartMemo(ch, "chords", chartID(matrix), func() chordLayout { return chordPlace(matrix) })
|
||||
k := Tokens(c)
|
||||
pal := internal.Palette(k)
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
val := func(v float64) string { return chartValue(c, v, opts.Format) }
|
||||
n := len(names) + len(l.pairs)
|
||||
if l.total == 0 {
|
||||
n = 0
|
||||
}
|
||||
geom := func(r ui.Rect) (cx, cy, rad float32) {
|
||||
return r.X + r.W/2, r.Y + r.H/2, min(r.W, r.H)/2 - 30
|
||||
}
|
||||
visible := ui.Local(ch.res.Element, "mujica.chord.visible", func() []int { return nil })
|
||||
spec := ChartSpec{Marks: n}
|
||||
spec.Readout = func(i int) string {
|
||||
if i < len(names) {
|
||||
out, in := 0.0, 0.0
|
||||
for j := range names {
|
||||
out += matrix[i][j]
|
||||
in += matrix[j][i]
|
||||
}
|
||||
return names[i] + " · " + msg(c, "chord_diagram.out") + " " + val(out) + " · " + msg(c, "chord_diagram.in") + " " + val(in)
|
||||
}
|
||||
p := l.pairs[i-len(names)]
|
||||
return names[p[0]] + " → " + names[p[1]] + " " + val(matrix[p[0]][p[1]]) + " · " + names[p[1]] + " → " + names[p[0]] + " " + val(matrix[p[1]][p[0]])
|
||||
}
|
||||
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)))
|
||||
a := math.Atan2(float64(x-cx), float64(cy-y))
|
||||
if a < 0 {
|
||||
a += 2 * math.Pi
|
||||
}
|
||||
if d >= rad-2 && d <= rad+14 {
|
||||
for i, arc := range l.arcs {
|
||||
if a >= arc[0] && a < arc[1] {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
cx, cy, rad := geom(r)
|
||||
lit := func(pair [2]int) bool {
|
||||
switch {
|
||||
case active < 0:
|
||||
return true
|
||||
case active < len(names):
|
||||
return pair[0] == active || pair[1] == active
|
||||
}
|
||||
return l.pairs[active-len(names)] == pair
|
||||
}
|
||||
*visible = chartVisibleIndices(len(l.pairs), max(1, int(rad*4)), active-len(names))
|
||||
for _, pi := range *visible {
|
||||
pair := l.pairs[pi]
|
||||
i, j := pair[0], pair[1]
|
||||
s, t := l.sub[i][j], l.sub[j][i]
|
||||
var path ui.Path
|
||||
internal.Arc(&path, cx, cy, rad, s[0], s[1], true, p)
|
||||
tx, ty := internal.Polar(cx, cy, rad, t[0])
|
||||
path.QuadTo(internal.Snap(p, cx), internal.Snap(p, cy), internal.Snap(p, tx), internal.Snap(p, ty))
|
||||
internal.Arc(&path, cx, cy, rad, t[0], t[1], false, p)
|
||||
sx, sy := internal.Polar(cx, cy, rad, s[0])
|
||||
path.QuadTo(internal.Snap(p, cx), internal.Snap(p, cy), internal.Snap(p, sx), internal.Snap(p, sy)).Close()
|
||||
alpha := float32(0.45)
|
||||
if !lit(pair) {
|
||||
alpha = 0.08
|
||||
}
|
||||
p.FillPath(&path, pal[i%len(pal)].Alpha(alpha))
|
||||
if lit(pair) {
|
||||
p.StrokePath(&path, 1, pal[i%len(pal)])
|
||||
}
|
||||
if active == len(names)+pi {
|
||||
p.StrokePath(&path, 1.5, k.Text)
|
||||
}
|
||||
}
|
||||
for i, arc := range l.arcs {
|
||||
grow := float32(0)
|
||||
if i == active {
|
||||
grow = 4
|
||||
}
|
||||
p.FillPath(internal.Sector(cx, cy, rad+2, rad+12+grow, arc[0], arc[1], p), pal[i%len(pal)])
|
||||
if arc[1]-arc[0] > 0.15 {
|
||||
lx, ly := internal.Polar(cx, cy, rad+20, (arc[0]+arc[1])/2)
|
||||
ax := 0.5 - 0.5*float32(math.Sin((arc[0]+arc[1])/2))
|
||||
chartText(p, names[i], lx, ly, size, k.Text, ax, 0.5+0.5*float32(math.Cos((arc[0]+arc[1])/2)))
|
||||
}
|
||||
}
|
||||
}
|
||||
res, _ := ch.build(spec)
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var chordNames = []string{"Nave", "Choir", "Crypt", "Cloister"}
|
||||
var chordMatrix = [][]float64{{0, 5, 2, 3}, {4, 0, 1, 0}, {1, 2, 0, 6}, {2, 0, 3, 0}}
|
||||
|
||||
func TestChordPlace(t *testing.T) {
|
||||
l := chordPlace(chordMatrix)
|
||||
if l.total != 29 || len(l.pairs) != 5 {
|
||||
t.Fatalf("total %v pairs %v", l.total, l.pairs)
|
||||
}
|
||||
span := 2*math.Pi - 4*chordGap
|
||||
if got := l.arcs[0][1] - l.arcs[0][0]; math.Abs(got-span*10/29) > 1e-9 {
|
||||
t.Fatalf("Nave arc %v", got)
|
||||
}
|
||||
if l.sub[0][1][1]-l.sub[0][1][0] <= 0 || l.sub[1][3][1] != l.sub[1][3][0] {
|
||||
t.Fatal("sub-arcs not sized by the matrix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChordDiagramReadout(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return ChordDiagram(c, chordNames, chordMatrix, ChordDiagramOptions{Frame: ChartFrame{Label: "Paths"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Paths")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); got != "Nave · out 10 · in 7" {
|
||||
t.Fatalf("group %q", got)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if got := chartReadout(tt); got != "Crypt → Cloister 6 · Cloister → Crypt 3" {
|
||||
t.Fatalf("chord %q", got)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
b := h.res.Element.Bounds()
|
||||
cx, cy, rad := b.X+b.W/2, b.Y+120, float32(240)/2-30
|
||||
tt.Move(cx+(rad+6)*0.5, cy-(rad+6)*0.866) // 30°: inside the first arc
|
||||
if h.res.Point != 0 {
|
||||
t.Fatalf("hover %d", h.res.Point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChordDiagramBad(t *testing.T) {
|
||||
for name, m := range map[string][][]float64{"ragged": {{1, 2}, {1}}, "negative": {{0, -1}, {1, 0}}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ChordDiagram(c, []string{"a", "b"}, m, ChordDiagramOptions{Frame: ChartFrame{Label: "x"}})
|
||||
}, 300, 300)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestChordDiagramSnapshot(t *testing.T) {
|
||||
snapshot(t, "chord_diagram", 520, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ChordDiagram(c, chordNames, chordMatrix, ChordDiagramOptions{Frame: ChartFrame{Label: "Paths"}})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestChordPlaceSelfRelation(t *testing.T) {
|
||||
l := chordPlace([][]float64{{3}})
|
||||
if len(l.pairs) != 1 || l.pairs[0] != [2]int{0, 0} {
|
||||
t.Fatalf("self relation %v", l.pairs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChordDiagramDenseDataBoundedAndDisabled(t *testing.T) {
|
||||
const n = 450
|
||||
names := make([]string, n)
|
||||
matrix := make([][]float64, n)
|
||||
for i := range matrix {
|
||||
names[i] = fmt.Sprint(i)
|
||||
matrix[i] = make([]float64, n)
|
||||
for j := range matrix[i] {
|
||||
matrix[i][j] = 1
|
||||
}
|
||||
}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return ChordDiagram(c, names, matrix, ChordDiagramOptions{Frame: ChartFrame{Label: "Dense chords"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
shown := ui.Local(h.res.Element, "mujica.chord.visible", func() []int { return nil })
|
||||
if len(*shown) == 0 || len(*shown) > 360 {
|
||||
t.Fatalf("built %d chords", len(*shown))
|
||||
}
|
||||
tabTo(t, tt, "Dense chords")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if h.res.Point != n+n*(n+1)/2-1 || len(*shown) > 361 {
|
||||
t.Fatalf("last relation %d, built %d", h.res.Point, len(*shown))
|
||||
}
|
||||
h.disabled = true
|
||||
tt.Frame()
|
||||
if h.res.Point != -1 {
|
||||
t.Fatalf("disabled chord %d", h.res.Point)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// DensityPlotOptions configure a DensityPlot.
|
||||
type DensityPlotOptions struct {
|
||||
Frame ChartFrame
|
||||
// Bandwidth defaults to the per-group Gaussian estimate.
|
||||
Bandwidth float64
|
||||
// Points defaults to 128 samples per curve.
|
||||
Points int
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// densitySample is finite values, sorted out of NaN and infinities.
|
||||
func densitySample(values []float64) []float64 {
|
||||
var out []float64
|
||||
for _, v := range values {
|
||||
if math.IsInf(v, 0) {
|
||||
panic("mujica: density values must be finite or NaN")
|
||||
}
|
||||
if !math.IsNaN(v) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// densityBandwidth estimates spread, using 1 for flat samples.
|
||||
func densityBandwidth(v []float64) float64 {
|
||||
n := float64(len(v))
|
||||
mean := 0.0
|
||||
for _, x := range v {
|
||||
mean += x
|
||||
}
|
||||
mean /= n
|
||||
sd := 0.0
|
||||
for _, x := range v {
|
||||
sd += (x - mean) * (x - mean)
|
||||
}
|
||||
sd = math.Sqrt(sd / max(n-1, 1))
|
||||
if sd == 0 {
|
||||
return 1
|
||||
}
|
||||
return 1.06 * sd * math.Pow(n, -0.2)
|
||||
}
|
||||
|
||||
// densityKDE estimates Gaussian density at xs.
|
||||
func densityKDE(v []float64, h float64, xs []float64) []float64 {
|
||||
out := make([]float64, len(xs))
|
||||
norm := 1 / (float64(len(v)) * h * math.Sqrt(2*math.Pi))
|
||||
for i, x := range xs {
|
||||
s := 0.0
|
||||
for _, p := range v {
|
||||
u := (x - p) / h
|
||||
s += math.Exp(-0.5 * u * u)
|
||||
}
|
||||
out[i] = s * norm
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// densityCurves samples groups on a shared padded grid.
|
||||
func densityCurves(groups []BoxGroup, bandwidth float64, points int) (xs []float64, series []ChartSeries) {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
samples := make([][]float64, len(groups))
|
||||
widths := make([]float64, len(groups))
|
||||
for i, g := range groups {
|
||||
samples[i] = densitySample(g.Values)
|
||||
if len(samples[i]) == 0 {
|
||||
continue
|
||||
}
|
||||
widths[i] = bandwidth
|
||||
if widths[i] == 0 {
|
||||
widths[i] = densityBandwidth(samples[i])
|
||||
}
|
||||
for _, v := range samples[i] {
|
||||
lo, hi = min(lo, v-3*widths[i]), max(hi, v+3*widths[i])
|
||||
}
|
||||
}
|
||||
if math.IsInf(lo, 1) {
|
||||
return nil, nil
|
||||
}
|
||||
xs = make([]float64, points)
|
||||
for i := range xs {
|
||||
xs[i] = lo + (hi-lo)*float64(i)/float64(points-1)
|
||||
}
|
||||
for i, g := range groups {
|
||||
ys := make([]float64, points)
|
||||
if len(samples[i]) > 0 {
|
||||
ys = densityKDE(samples[i], widths[i], xs)
|
||||
}
|
||||
series = append(series, ChartSeries{Name: g.Name, Values: ys})
|
||||
}
|
||||
return xs, series
|
||||
}
|
||||
|
||||
// DensityPlot overlays groups on a shared density axis.
|
||||
func DensityPlot(c *ui.Context, groups []BoxGroup, opts DensityPlotOptions) ChartResult {
|
||||
if opts.Bandwidth < 0 || math.IsNaN(opts.Bandwidth) || math.IsInf(opts.Bandwidth, 0) || opts.Points < 0 || opts.Points == 1 {
|
||||
panic("mujica: DensityPlot needs Bandwidth ≥ 0 and Points of 0 or 2 or more")
|
||||
}
|
||||
points := opts.Points
|
||||
if points == 0 {
|
||||
points = 128
|
||||
}
|
||||
ch := newChart(c, opts.Frame)
|
||||
ids := ""
|
||||
seen := map[string]bool{}
|
||||
for _, g := range groups {
|
||||
if g.Name == "" || seen[g.Name] {
|
||||
panic("mujica: DensityPlot groups need unique names")
|
||||
}
|
||||
seen[g.Name] = true
|
||||
ids += g.Name + chartID(g.Values) + ";"
|
||||
}
|
||||
type curves struct {
|
||||
xs []float64
|
||||
series []ChartSeries
|
||||
}
|
||||
cv := chartMemo(ch, "kde", fmt.Sprint(ids, opts.Bandwidth, points), func() curves {
|
||||
xs, s := densityCurves(groups, opts.Bandwidth, points)
|
||||
return curves{xs, s}
|
||||
})
|
||||
n := len(cv.xs)
|
||||
series := cv.series
|
||||
if n == 0 {
|
||||
series = nil
|
||||
}
|
||||
tops, bases := make([][]float64, len(series)), make([][]float64, len(series))
|
||||
for i, s := range series {
|
||||
tops[i], bases[i] = s.Values, make([]float64, n)
|
||||
}
|
||||
var ax chartX
|
||||
if n > 0 {
|
||||
ax = chartMemo(ch, "x", ids+fmt.Sprint(opts.Bandwidth, points), func() chartX { return newChartX(n, cv.xs, nil) })
|
||||
}
|
||||
lo, hi, ok := chartYRange(ch, series, true, nil)
|
||||
if !ok {
|
||||
n = 0
|
||||
}
|
||||
format := opts.Format
|
||||
if format == nil {
|
||||
format = func(v float64) string { return fmt.Sprintf("%.3g", v) }
|
||||
}
|
||||
return chartLines(ch, chartLineData{series: series, tops: tops, bases: bases, ax: ax, lo: lo, hi: hi, n: n, format: format})
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func densityGroups() []BoxGroup {
|
||||
a, b := make([]float64, 200), make([]float64, 200)
|
||||
for i := range a {
|
||||
u := float64(i)/200 - 0.5
|
||||
a[i] = 40 + 30*u*math.Abs(u)
|
||||
b[i] = 60 + 12*math.Sin(float64(i))
|
||||
}
|
||||
return []BoxGroup{{Name: "Matins", Values: a}, {Name: "Vespers", Values: b}}
|
||||
}
|
||||
|
||||
func TestDensityKDEIntegratesToOne(t *testing.T) {
|
||||
v := []float64{1, 2, 2, 3, 7}
|
||||
xs := make([]float64, 2001)
|
||||
for i := range xs {
|
||||
xs[i] = -10 + 30*float64(i)/2000
|
||||
}
|
||||
d := densityKDE(v, densityBandwidth(v), xs)
|
||||
sum := 0.0
|
||||
for _, y := range d {
|
||||
sum += y * 30 / 2000
|
||||
}
|
||||
if math.Abs(sum-1) > 0.01 {
|
||||
t.Fatalf("density integrates to %v", sum)
|
||||
}
|
||||
if bw := densityBandwidth([]float64{4, 4}); bw != 1 {
|
||||
t.Fatalf("flat bandwidth %v", bw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDensityPlotReadoutAndBandwidth(t *testing.T) {
|
||||
bw := 0.0
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return DensityPlot(c, densityGroups(), DensityPlotOptions{Frame: ChartFrame{Label: "Ages"}, Bandwidth: bw, Points: 64})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Ages")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
for range 31 {
|
||||
tt.Key(0, ui.KeyRight)
|
||||
}
|
||||
first := chartReadout(tt)
|
||||
if !strings.Contains(first, "Matins ") || !strings.Contains(first, "Vespers ") {
|
||||
t.Fatalf("readout %q", first)
|
||||
}
|
||||
bw = 1
|
||||
tt.Frame()
|
||||
if chartReadout(tt) == first {
|
||||
t.Fatal("bandwidth did not change the curve")
|
||||
}
|
||||
if err := tt.Click("Vespers"); err != nil || strings.Contains(chartReadout(tt), "Vespers") {
|
||||
t.Fatalf("legend did not hide a group: %q", chartReadout(tt))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDensityPlotBadAndEmpty(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return DensityPlot(c, []BoxGroup{{Name: "a", Values: []float64{math.NaN()}}}, DensityPlotOptions{Frame: ChartFrame{Label: "Ages"}})
|
||||
}}
|
||||
if tt := ui.NewTester(h.view, 520, 420); !tt.HasText("No data") {
|
||||
t.Fatal("NaN sample is not empty")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for Points 1")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
DensityPlot(c, densityGroups(), DensityPlotOptions{Frame: ChartFrame{Label: "x"}, Points: 1})
|
||||
}, 300, 300)
|
||||
}
|
||||
|
||||
func TestDensityPlotSnapshot(t *testing.T) {
|
||||
snapshot(t, "density_plot", 520, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
DensityPlot(c, densityGroups(), DensityPlotOptions{Frame: ChartFrame{Label: "Ages"}})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDensityPlotFiniteBandwidthAndNames(t *testing.T) {
|
||||
charts2MustPanic(t, func(c *ui.Context) {
|
||||
DensityPlot(c, densityGroups(), DensityPlotOptions{Frame: ChartFrame{Label: "Density"}, Bandwidth: math.Inf(1)})
|
||||
})
|
||||
charts2MustPanic(t, func(c *ui.Context) {
|
||||
DensityPlot(c, []BoxGroup{{Name: "A"}, {Name: "A"}}, DensityPlotOptions{Frame: ChartFrame{Label: "Density"}})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDensityPlotLargeDataBoundedAndDisabled(t *testing.T) {
|
||||
values := make([]float64, 100_000)
|
||||
for i := range values {
|
||||
values[i] = float64(i % 100)
|
||||
}
|
||||
groups := []BoxGroup{{Name: "Sample", Values: values}}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return DensityPlot(c, groups, DensityPlotOptions{Frame: ChartFrame{Label: "Large density"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Large density")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if h.res.Point != 127 {
|
||||
t.Fatalf("built sample index %d", h.res.Point)
|
||||
}
|
||||
h.disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if h.res.Point != -1 {
|
||||
t.Fatalf("disabled density %d", h.res.Point)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# 121 ChartLegend 图例
|
||||
|
||||
## 用途
|
||||
|
||||
展示序列名称与形状;点击或键盘切换序列显隐,支持横纵布局和折叠。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
hidden := map[string]bool{}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
k := mujica.Tokens(c)
|
||||
items := []mujica.ChartLegendItem{{Name: "Pews", Color: k.Info}, {Name: "Candles", Color: k.Success, Shape: 1}}
|
||||
mujica.ChartLegend(c, &hidden, items, mujica.ChartLegendOptions{MaxItems: 1})
|
||||
}
|
||||
ui.Render(view, 400, 200, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`hidden *map[string]bool`:名称映射到隐藏状态,nil 为静态图例。`items []ChartLegendItem`:唯一非空 `Name`、`Color`、`Shape`(0–4)。`Vertical` 默认 false;`MaxItems` 默认 0,显示全部,负值 panic。
|
||||
|
||||
## 状态
|
||||
|
||||
可见项显示形状,隐藏项显示空框与删除线;支持祖先禁用。折叠后显示展开按钮。
|
||||
|
||||
## 事件
|
||||
|
||||
`Toggled()` 返回本帧切换的序列名称和是否发生事件。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 进入图例;方向键在项之间移动;Space 切换显隐;展开按钮支持 Enter 和 Space。
|
||||
|
||||
## 限制
|
||||
|
||||
调用方依据 hidden 控制数据序列。图例颜色来自调用方,应使用主题色;形状提供颜色之外的识别。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 122 ChartTooltip 读数浮层
|
||||
|
||||
## 用途
|
||||
|
||||
在指针或键盘数据点旁显示多序列读数,自动避让窗口边缘。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
k := mujica.Tokens(c)
|
||||
mujica.ChartTooltip(c, 40, 40, []mujica.ChartTooltipLine{{Text: "Lauds"}, {Text: "Pews 5", Color: k.Info}, {Text: "Candles 2", Color: k.Success, Shape: 1}})
|
||||
}
|
||||
ui.Render(view, 400, 200, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`x, y float32`:窗口 DIP 坐标。`lines []ChartTooltipLine` 必须非空;`Text` 为读数,`Color` 为标记色,透明色隐藏标记;`Shape` 为标记形状。
|
||||
|
||||
## 状态
|
||||
|
||||
160 ms 进入过渡;减少动画直接出现。宽度和高度受窗口限制,长文字换行,内容可滚动。
|
||||
|
||||
## 事件
|
||||
|
||||
返回浮层 `*ui.Element`;读数事件由所属 Chart 提供。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
通过 `Chart` 使用时,方向键改变锚点,Escape 清除浮层;浮层内容沿用滚动容器操作。
|
||||
|
||||
## 限制
|
||||
|
||||
调用方传入当前读数和锚点,负责显示时机;所属图表应同时显示并朗读读数。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 123 ChartCrosshair 十字光标
|
||||
|
||||
## 用途
|
||||
|
||||
绘制横纵十字线和轴侧读数标签,提供最近数据点吸附。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
hair := mujica.ChartCrosshair(c, mujica.ChartCrosshairOptions{X: &x, Y: &y})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
hair.Draw(p, plot, x.Map(plot, 5), y.Map(plot, 70))
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`X, Y *ChartAxisPart`:至少提供一条轴。`Reach` 默认 24 DIP,负值 panic。返回 `ChartCrosshairPart`;`Snap(n, at, x, y)` 返回范围内最近索引,范围外为 -1。
|
||||
|
||||
## 状态
|
||||
|
||||
静态部件;随调用方传入的坐标变化。移动路径使用像素网格对齐。
|
||||
|
||||
## 事件
|
||||
|
||||
吸附返回索引;读数变化使用所属 `ChartResult.Changed()`。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
与 Chart 的 `Pick`、`Anchor`、`Readout` 组合,方向键逐点显示并朗读数值。
|
||||
|
||||
## 限制
|
||||
|
||||
`Draw` 使用窗口坐标,`Snap` 和 at 必须采用相同坐标系;轴部件在每帧创建。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 124 ChartAxis 坐标轴
|
||||
|
||||
## 用途
|
||||
|
||||
提供数值、时间、类别和对数刻度,以及值和像素之间的映射。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
axis := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Kind: mujica.AxisLog, Min: 1, Max: 1000})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
axis.Draw(p, ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`Kind` 默认 `AxisLinear`;`Side` 默认 `SideBottom`。数值与对数轴需要有限 `Min < Max`,对数轴 `Min > 0`。时间轴需要 `From < To`。类别轴需要非空 `Categories`。`Ticks` 为递增且位于范围内的指定刻度,nil 自动生成。`Format func(float64) string` 自定义标签。
|
||||
|
||||
## 状态
|
||||
|
||||
展示部件,继承当前主题、文本缩放和字体。
|
||||
|
||||
## 事件
|
||||
|
||||
`Map`、`Invert`、`Ticks`、`Label` 返回映射与标签;组件自身没有业务事件。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
所属 Chart 负责聚焦、方向键和读数。坐标轴作为绘图部件随图表展示。
|
||||
|
||||
## 限制
|
||||
|
||||
为标签预留边距。时间值采用 Unix 秒,默认格式使用 UTC;需要本地时区时通过 Format 格式化。数据和刻度切片在使用期间保持稳定。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 125 ChartGrid 图表网格
|
||||
|
||||
## 用途
|
||||
|
||||
按坐标轴主刻度和次刻度绘制对齐设备像素的网格线。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
grid := mujica.ChartGrid(c, mujica.ChartGridOptions{X: &x, Y: &y, Minor: 2})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
grid.Draw(p, plot)
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`X, Y *ChartAxisPart` 至少一条轴;`Minor` 默认 0,正整数表示每个主刻度间隔的等分数。类别轴的 Minor 必须为 0。负值 panic。
|
||||
|
||||
## 状态
|
||||
|
||||
展示部件;主网格使用 Border,次网格使用较淡的 Border。
|
||||
|
||||
## 事件
|
||||
|
||||
返回 `ChartGridPart`,由 `Draw(p, plot)` 绘制。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
所属图表的键盘操作保持生效;网格随图表展示。
|
||||
|
||||
## 限制
|
||||
|
||||
对数轴次刻度按对数距离等分。大范围刻度按绘图像素密度抽样绘制。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 126 ChartBrush 区间刷选
|
||||
|
||||
## 用途
|
||||
|
||||
选择图表可见区间,支持移动窗口、调整两端和拖动新建范围。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
value := [2]float64{20, 60}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartBrush(c, &value, mujica.ChartBrushOptions{Label: "Months", Max: 100, Step: 1, Values: []float64{2, 4, 3, 8, 5}})
|
||||
}
|
||||
ui.Render(view, 400, 160, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`value *[2]float64` 为域内严格递增区间;`Label` 必填;有限 `Min < Max`。`Step` 默认跨度的 1/100,负值 panic。`Values` 为预览折线。`Height` 默认 48 DIP,显式高度至少 28 DIP。`Format` 自定义值文本。`Disabled` 默认 false。
|
||||
|
||||
## 状态
|
||||
|
||||
显示选区边框与两端手柄;自身或祖先禁用时保持区间。拖动窗口到边缘保持跨度。
|
||||
|
||||
## 事件
|
||||
|
||||
返回 `ChoiceResult`;`Changed()` 表示区间变化;新范围会被朗读,调用方据此调整图表范围。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 按起点、窗口、终点顺序聚焦;方向键移动一个步长;手柄 PageUp/PageDown 移动十个步长;Home/End 到域边界。
|
||||
|
||||
## 限制
|
||||
|
||||
范围至少保留一个 Step。预览切片按身份缓存,更新数据时替换切片。调用方负责图表缩放和查询。
|
||||
@@ -0,0 +1,47 @@
|
||||
# 127 ChartAnnotation 图表标注
|
||||
|
||||
## 用途
|
||||
|
||||
在图表上显示水平线、垂直线、区间带和文字标注,可选择开启移动。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
notes := []mujica.ChartNote{{Kind: mujica.NoteHLine, Value: 50, Text: "Goal", Draggable: true}, {Kind: mujica.NoteBand, Value: 6, End: 8, Text: "Lent"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
annotation := mujica.ChartAnnotation(c, notes, mujica.ChartAnnotationOptions{X: &x, Y: &y})
|
||||
box := ui.Box(c).Fill().Focusable().Label("Annotations")
|
||||
b := box.Bounds()
|
||||
annotation.Drag(box, ui.Rect{X: 60, Y: 20, W: b.W - 80, H: b.H - 60})
|
||||
box.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
annotation.Draw(p, plot)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`notes []ChartNote`:`Kind` 默认为 `NoteHLine`;`Value` 是水平线 Y 或其他标注 X;`End` 是带末端 X 或文字 Y;`Text` 是文案;`Draggable` 默认 false。`X, Y` 均必填;坐标必须有限,带需要 End > Value,文字需要非空 Text。
|
||||
|
||||
## 状态
|
||||
|
||||
先绘制区间带,再绘制线和文字;可移动线带有菱形手柄。祖先禁用阻止移动。
|
||||
|
||||
## 事件
|
||||
|
||||
`Drag(element, plot)` 处理指针和键盘,修改原切片并返回是否发生变化。`Draw` 绘制当前标注。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦宿主图表;Alt+PageUp/PageDown 选择可移动标注;Alt+方向键每次移动轴跨度的 1/100。选中说明和变化会被朗读。
|
||||
|
||||
## 限制
|
||||
|
||||
Drag 的 plot 是元素局部坐标;Draw 的 plot 是窗口坐标。标注切片由调用方持有;移动后调用方负责业务保存。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 128 ChartExport 图表导出
|
||||
|
||||
## 用途
|
||||
|
||||
以当前窗口主题离屏绘制图表,返回 PNG 字节,供调用方保存或传输。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
data, err := mujica.ChartExport(c, func(c *ui.Context) {
|
||||
mujica.SparkBar(c, []float64{3, -2, 5}, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}})
|
||||
}, mujica.ChartExportOptions{Width: 300, Height: 100, Scale: 2})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
bitmap, err := ui.DecodeBitmap(data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ui.Image(c, bitmap).Size(300, 100)
|
||||
}
|
||||
ui.Render(view, 400, 160, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`chart func(*ui.Context)` 必填;`Width, Height int` 为正 DIP 尺寸;有限 `Scale float32` 默认 2,负值 panic。
|
||||
|
||||
## 状态
|
||||
|
||||
沿用当前主题、密度、本地化、自定义 token 和桌面偏好。导出过程为同步渲染。
|
||||
|
||||
## 事件
|
||||
|
||||
返回 `([]byte, error)`;PNG 编码错误直接返回调用方。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
导出触发按钮由调用方提供,支持 Enter 和 Space。
|
||||
|
||||
## 限制
|
||||
|
||||
图表回调应独立渲染,复用调用方数据;调用方在用户触发导出时调用以控制开销。组件只返回字节。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 129 ViolinPlot 小提琴图
|
||||
|
||||
## 用途
|
||||
|
||||
比较多组样本的核密度轮廓,中位数以菱形标记,四分位以粗条表示。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 7, 9}}, {Name: "Nave", Values: []float64{2, 4, 5, 6, 8}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ViolinPlot(c, groups, mujica.ViolinPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}, Bandwidth: 1})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`groups []BoxGroup`:唯一非空 Name 与样本 Values;NaN 为缺值,无穷值 panic。`Frame` 指定名称、高度、状态和占位文案。有限 `Bandwidth` 默认 0,按组自动估计;负值 panic。`Format` 自定义数值读数。
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停、键盘读数、加载、错误、空数据、祖先禁用。单值或相同值样本保留密度轮廓。
|
||||
|
||||
## 事件
|
||||
|
||||
`ChartResult.Point` 为组索引;`Changed()` 表示读数组变化;`Retried()` 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦;方向键逐组读取中位数和四分位;Home/End 首尾;Escape 清除读数。
|
||||
|
||||
## 限制
|
||||
|
||||
每组轮廓使用 64 个采样点;大量组按像素位置抽样绘制,当前读数组保留。排序和核密度在样本切片身份或带宽变化时计算;更新数据需替换切片。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 130 ParallelCoordinates 平行坐标
|
||||
|
||||
## 用途
|
||||
|
||||
在多条平行轴间展示每行数值;轴上刷选筛除行,当前读数行用粗线高亮。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
filters := map[string][2]float64{}
|
||||
axes := []string{"Age", "Hymns", "Vigils"}
|
||||
rows := [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ParallelCoordinates(c, &filters, axes, rows, mujica.ParallelCoordinatesOptions{Frame: mujica.ChartFrame{Label: "Monks"}, Names: []string{"Agnes", "Clare", "Hild"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`filters *map[string][2]float64` 必填,键为轴名,值为有限递增范围。`axes []string` 至少两个唯一非空名称;每行长度与轴数一致。NaN 为缺值。`Names` 为逐行名称,nil 显示序号。`Frame` 与 `Format` 同其他图表。
|
||||
|
||||
## 状态
|
||||
|
||||
默认、高亮、筛除、加载、错误、空数据、祖先禁用;筛除行的读数附带本地化文字。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()` 表示读数行变化;`ValueChanged()` 表示刷选范围变化;`Retried()` 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦;方向键逐行读数;Home/End 首尾。Alt+PageUp/PageDown 选择轴;Alt+上下调整下端;Alt+Shift+上下调整上端;Alt+Delete 清除该轴刷选。
|
||||
|
||||
## 限制
|
||||
|
||||
指针拖动轴设置范围,轴上点击清除范围。100,000 行绘制数量受图表高度约束,键盘可读取全部行。范围与过滤仅在数据或筛选变化时计算;更新数据需替换外层 rows 切片。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 131 ChordDiagram 弦图
|
||||
|
||||
## 用途
|
||||
|
||||
以组弧和弦带展示关系矩阵,悬停或键盘读数组时高亮相关连线。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
names := []string{"Nave", "Choir", "Crypt"}
|
||||
matrix := [][]float64{{0, 5, 2}, {4, 0, 1}, {1, 2, 0}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChordDiagram(c, names, matrix, mujica.ChordDiagramOptions{Frame: mujica.ChartFrame{Label: "Paths"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`names []string` 为唯一非空组名;`matrix [][]float64` 为同阶方阵,数值须有限且非负。`Frame` 控制名称、尺寸和加载状态。`Format` 自定义关系数值。
|
||||
|
||||
## 状态
|
||||
|
||||
默认、组高亮、弦高亮、加载、错误、空矩阵、祖先禁用;对角线值形成自身关系。
|
||||
|
||||
## 事件
|
||||
|
||||
`Point` 先枚举组,再枚举关系;`Changed()` 表示读数变化;`Retried()` 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦;方向键依次读取组的流入流出与关系值;Home/End 首尾;Escape 清除。
|
||||
|
||||
## 限制
|
||||
|
||||
高密度矩阵按绘图区半径抽样绘制弦带,当前键盘关系保留;布局仅在外层 matrix 切片身份变化时计算。更新数据需替换外层切片。组弧标签在有足够空间时展示,完整名称始终包含在键盘读数中。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 132 SparkBar 迷你柱
|
||||
|
||||
## 用途
|
||||
|
||||
行内展示正负数值柱,零线区分方向,末项以强调颜色和菱形标记。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
values := []float64{3, -2, 4, 1, -3}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.SparkBar(c, values, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}, BarWidth: 10})
|
||||
}
|
||||
ui.Render(view, 400, 100, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`values []float64`:有限数值,NaN 表示缺值。`Frame.Label` 必填;`Frame.Height` 默认 28 DIP。`Frame.Status`、`Error`、`Empty` 控制占位。`BarWidth` 默认 6 DIP,负值 panic;整体宽度受窗口限制。`Format` 自定义读数。
|
||||
|
||||
## 状态
|
||||
|
||||
默认显示末值;悬停或聚焦显示当前点。加载、错误与空数据保持绘图区尺寸;祖先禁用阻止操作。
|
||||
|
||||
## 事件
|
||||
|
||||
返回 `ChartResult`,`Point` 为当前索引,`Changed()` 表示读数变化,`Retried()` 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦;左右箭头逐点读数并朗读;Home/End 到首尾;Escape 恢复末值。
|
||||
|
||||
## 限制
|
||||
|
||||
大数据按像素列保留首、极值和末项,绘制数量受宽度约束。数据范围与抽样缓存按切片身份更新;更换数据时替换切片。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 133 TrendIndicator 趋势指示
|
||||
|
||||
## 用途
|
||||
|
||||
以方向箭头、带符号数值和无障碍方向文字展示指标变化。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TrendIndicator(c, 12.5, mujica.TrendIndicatorOptions{Precision: 1, Unit: "%", Note: "vs Lent"})
|
||||
}
|
||||
ui.Render(view, 400, 100, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`change float64` 必须有限;`Precision` 默认 0,负值 panic;`Unit` 和 `Note` 默认空;`Invert` 默认 false,开启后下降采用成功语义;`Format` 替换数值格式。
|
||||
|
||||
## 状态
|
||||
|
||||
上升、下降、持平;正负号与箭头提供颜色之外的区分;方向词支持中英文和消息覆盖。
|
||||
|
||||
## 事件
|
||||
|
||||
返回 `*ui.Element`,展示调用方提供的变化值。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
展示部件;辅助技术读取方向和完整变化值。
|
||||
|
||||
## 限制
|
||||
|
||||
变化值由调用方计算;组件提供显示和语义颜色。
|
||||
@@ -0,0 +1,36 @@
|
||||
# 138 DensityPlot 密度图
|
||||
|
||||
## 用途
|
||||
|
||||
将多组样本的高斯核密度曲线叠加在同一坐标域,以形状和颜色区分序列。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
groups := []mujica.BoxGroup{{Name: "Matins", Values: []float64{1, 2, 2, 3, 7}}, {Name: "Vespers", Values: []float64{3, 4, 5, 6, 6}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DensityPlot(c, groups, mujica.DensityPlotOptions{Frame: mujica.ChartFrame{Label: "Ages"}, Bandwidth: 1, Points: 128})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`groups []BoxGroup` 为唯一非空组名和样本;NaN 为缺值。有限 `Bandwidth` 默认 0,按组自动估计;负值 panic。`Points` 默认 128,显式值须至少 2。`Frame` 和 `Format` 控制图表与密度读数。
|
||||
|
||||
## 状态
|
||||
|
||||
默认、序列显隐、悬停、键盘读数、加载、错误、空数据、祖先禁用。
|
||||
|
||||
## 事件
|
||||
|
||||
`Changed()` 表示采样位置变化;`Toggled()` 表示序列显隐;`Retried()` 表示重试。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 先到图例,再到图表;图例 Space 切换显隐;图表方向键读取共享 X 位置的各组密度;Home/End 首尾;Escape 清除。
|
||||
|
||||
## 限制
|
||||
|
||||
核密度计算在数据、带宽或 Points 变化时执行;绘制曲线按像素列抽样。100,000 个样本默认生成每组 128 个密度点。更新数据需替换样本切片。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 140 ChartEmptyState 图表占位
|
||||
|
||||
## 用途
|
||||
|
||||
在图表无数据、加载或失败时保持图表尺寸,并在失败时提供重试。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```go
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartEmptyState(c, mujica.DataFailed, mujica.ChartEmptyStateOptions{Height: 180, Error: "The archive did not answer"})
|
||||
}
|
||||
ui.Render(view, 400, 240, 1)
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
`status DataStatus`:DataReady 显示空数据,DataLoading 显示加载,DataFailed 显示错误和重试;其他有效加载状态同样显示加载。`Height` 默认 240 DIP,负值 panic;`Error`、`Empty` 默认内置本地化文案。
|
||||
|
||||
## 状态
|
||||
|
||||
空数据、加载、错误;祖先禁用保持展示并阻止重试;减少动画显示静态加载说明。
|
||||
|
||||
## 事件
|
||||
|
||||
返回 `ChoiceResult`,`Changed()` 表示点击重试,数据请求由调用方执行。
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦重试;Enter 或 Space 触发一次重试。
|
||||
|
||||
## 限制
|
||||
|
||||
只管理占位展示和事件;数据和异步状态由调用方维护。
|
||||
@@ -0,0 +1,180 @@
|
||||
package mujica_test
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func ExampleChartLegend() {
|
||||
hidden := map[string]bool{}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
k := mujica.Tokens(c)
|
||||
items := []mujica.ChartLegendItem{{Name: "Pews", Color: k.Info}, {Name: "Candles", Color: k.Success, Shape: 1}}
|
||||
mujica.ChartLegend(c, &hidden, items, mujica.ChartLegendOptions{MaxItems: 1})
|
||||
}
|
||||
ui.Render(view, 400, 200, 1)
|
||||
}
|
||||
|
||||
func ExampleChartTooltip() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
k := mujica.Tokens(c)
|
||||
mujica.ChartTooltip(c, 40, 40, []mujica.ChartTooltipLine{{Text: "Lauds"}, {Text: "Pews 5", Color: k.Info}, {Text: "Candles 2", Color: k.Success, Shape: 1}})
|
||||
}
|
||||
ui.Render(view, 400, 200, 1)
|
||||
}
|
||||
|
||||
func ExampleChartCrosshair() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
hair := mujica.ChartCrosshair(c, mujica.ChartCrosshairOptions{X: &x, Y: &y})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
hair.Draw(p, plot, x.Map(plot, 5), y.Map(plot, 70))
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleChartAxis() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
axis := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Kind: mujica.AxisLog, Min: 1, Max: 1000})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
axis.Draw(p, ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleChartGrid() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
grid := mujica.ChartGrid(c, mujica.ChartGridOptions{X: &x, Y: &y, Minor: 2})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
grid.Draw(p, plot)
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleChartBrush() {
|
||||
value := [2]float64{20, 60}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartBrush(c, &value, mujica.ChartBrushOptions{Label: "Months", Max: 100, Step: 1, Values: []float64{2, 4, 3, 8, 5}})
|
||||
}
|
||||
ui.Render(view, 400, 160, 1)
|
||||
}
|
||||
|
||||
func ExampleChartAnnotation() {
|
||||
notes := []mujica.ChartNote{{Kind: mujica.NoteHLine, Value: 50, Text: "Goal", Draggable: true}, {Kind: mujica.NoteBand, Value: 6, End: 8, Text: "Lent"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
annotation := mujica.ChartAnnotation(c, notes, mujica.ChartAnnotationOptions{X: &x, Y: &y})
|
||||
box := ui.Box(c).Fill().Focusable().Label("Annotations")
|
||||
b := box.Bounds()
|
||||
annotation.Drag(box, ui.Rect{X: 60, Y: 20, W: b.W - 80, H: b.H - 60})
|
||||
box.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
annotation.Draw(p, plot)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleChartExport() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
data, err := mujica.ChartExport(c, func(c *ui.Context) {
|
||||
mujica.SparkBar(c, []float64{3, -2, 5}, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}})
|
||||
}, mujica.ChartExportOptions{Width: 300, Height: 100, Scale: 2})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
bitmap, err := ui.DecodeBitmap(data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ui.Image(c, bitmap).Size(300, 100)
|
||||
}
|
||||
ui.Render(view, 400, 160, 1)
|
||||
}
|
||||
|
||||
func ExampleSparkBar() {
|
||||
values := []float64{3, -2, 4, 1, -3}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.SparkBar(c, values, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}, BarWidth: 10})
|
||||
}
|
||||
ui.Render(view, 400, 100, 1)
|
||||
}
|
||||
|
||||
func ExampleTrendIndicator() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TrendIndicator(c, 12.5, mujica.TrendIndicatorOptions{Precision: 1, Unit: "%", Note: "vs Lent"})
|
||||
}
|
||||
ui.Render(view, 400, 100, 1)
|
||||
}
|
||||
|
||||
func ExampleChartEmptyState() {
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartEmptyState(c, mujica.DataFailed, mujica.ChartEmptyStateOptions{Height: 180, Error: "The archive did not answer"})
|
||||
}
|
||||
ui.Render(view, 400, 240, 1)
|
||||
}
|
||||
|
||||
func ExampleViolinPlot() {
|
||||
groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 7, 9}}, {Name: "Nave", Values: []float64{2, 4, 5, 6, 8}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ViolinPlot(c, groups, mujica.ViolinPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}, Bandwidth: 1})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleParallelCoordinates() {
|
||||
filters := map[string][2]float64{}
|
||||
axes := []string{"Age", "Hymns", "Vigils"}
|
||||
rows := [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ParallelCoordinates(c, &filters, axes, rows, mujica.ParallelCoordinatesOptions{Frame: mujica.ChartFrame{Label: "Monks"}, Names: []string{"Agnes", "Clare", "Hild"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleChordDiagram() {
|
||||
names := []string{"Nave", "Choir", "Crypt"}
|
||||
matrix := [][]float64{{0, 5, 2}, {4, 0, 1}, {1, 2, 0}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChordDiagram(c, names, matrix, mujica.ChordDiagramOptions{Frame: mujica.ChartFrame{Label: "Paths"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDensityPlot() {
|
||||
groups := []mujica.BoxGroup{{Name: "Matins", Values: []float64{1, 2, 2, 3, 7}}, {Name: "Vespers", Values: []float64{3, 4, 5, 6, 6}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DensityPlot(c, groups, mujica.DensityPlotOptions{Frame: mujica.ChartFrame{Label: "Ages"}, Bandwidth: 1, Points: 128})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -1,3 +1,70 @@
|
||||
package main
|
||||
|
||||
var charts2Pages = []page{}
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
const c2Category = "Charts II"
|
||||
|
||||
var charts2Pages = []page{
|
||||
{ID: "121", Name: "ChartLegend", ZhName: "图例", Category: c2Category, Demo: c2LegendDemo, Code: c2ChartLegendCode, Keys: "Tab enters; arrows move; Space toggles; Enter expands."},
|
||||
{ID: "122", Name: "ChartTooltip", ZhName: "读数浮层", Category: c2Category, Demo: c2TooltipDemo, Code: c2ChartTooltipCode, Keys: c1Keys},
|
||||
{ID: "123", Name: "ChartCrosshair", ZhName: "十字光标", Category: c2Category, Demo: c2CrosshairDemo, Code: c2ChartCrosshairCode, Keys: c1Keys},
|
||||
{ID: "124", Name: "ChartAxis", ZhName: "坐标轴", Category: c2Category, Demo: c2AxisDemo, Code: c2ChartAxisCode, Keys: c1Keys},
|
||||
{ID: "125", Name: "ChartGrid", ZhName: "图表网格", Category: c2Category, Demo: c2GridDemo, Code: c2ChartGridCode, Keys: c1Keys},
|
||||
{ID: "126", Name: "ChartBrush", ZhName: "区间刷选", Category: c2Category, Demo: c2BrushDemo, Code: c2ChartBrushCode, Keys: "Tab: start, window, end; arrows step; PageUp/Down step ten; Home/End reach bounds."},
|
||||
{ID: "127", Name: "ChartAnnotation", ZhName: "图表标注", Category: c2Category, Demo: c2AnnotationDemo, Code: c2ChartAnnotationCode, Keys: c1Keys + " Alt+PageUp/Down selects a movable note; Alt+arrows move it."},
|
||||
{ID: "128", Name: "ChartExport", ZhName: "图表导出", Category: c2Category, Demo: c2ExportDemo, Code: c2ChartExportCode, Keys: "Tab reaches Export PNG; Enter or Space renders bytes in the current theme."},
|
||||
{ID: "129", Name: "ViolinPlot", ZhName: "小提琴图", Category: c2Category, Demo: c2ViolinDemo, Code: c2ViolinPlotCode, Keys: c1Keys},
|
||||
{ID: "130", Name: "ParallelCoordinates", ZhName: "平行坐标", Category: c2Category, Demo: c2ParallelDemo, Code: c2ParallelCoordinatesCode, Keys: c1Keys + " Alt+PageUp/Down selects an axis; Alt+Up/Down adjusts its lower bound; Alt+Shift+Up/Down adjusts its upper bound; Alt+Delete clears."},
|
||||
{ID: "131", Name: "ChordDiagram", ZhName: "弦图", Category: c2Category, Demo: c2ChordDemo, Code: c2ChordDiagramCode, Keys: c1Keys},
|
||||
{ID: "132", Name: "SparkBar", ZhName: "迷你柱", Category: c2Category, Demo: c2SparkBarDemo, Code: c2SparkBarCode, Keys: "Tab focuses bars; Left/Right read points; Home/End jump; Escape restores the last value."},
|
||||
{ID: "133", Name: "TrendIndicator", ZhName: "趋势指示", Category: c2Category, Demo: c2TrendDemo, Code: c2TrendIndicatorCode, Keys: "Change and Invert controls use the keyboard; the indicator is read as text."},
|
||||
{ID: "138", Name: "DensityPlot", ZhName: "密度图", Category: c2Category, Demo: c2DensityDemo, Code: c2DensityPlotCode, Keys: c1Keys + " Space on legend entries hides or shows a group."},
|
||||
{ID: "140", Name: "ChartEmptyState", ZhName: "图表占位", Category: c2Category, Demo: c2EmptyDemo, Code: c2ChartEmptyStateCode, Keys: "Tab reaches Retry; Enter or Space retries."},
|
||||
}
|
||||
|
||||
func c2Check(c *ui.Context, value *bool, label string, log func(string)) {
|
||||
if mujica.Checkbox(c, value, label, mujica.CheckboxOptions{}).Changed() {
|
||||
log(fmt.Sprintf("%s = %v", label, *value))
|
||||
}
|
||||
}
|
||||
|
||||
func c2Number(c *ui.Context, value *float64, label string, lo, hi, step float64, log func(string)) {
|
||||
ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() {
|
||||
ui.Text(c, label)
|
||||
r := mujica.NumberInput(c, value, mujica.NumberInputOptions{Label: label, Min: lo, Max: hi, Step: step})
|
||||
r.Element.Width(150)
|
||||
if r.Changed() {
|
||||
log(fmt.Sprintf("%s = %g", label, *value))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func c2Select(c *ui.Context, value *string, label string, choices []string, log func(string)) {
|
||||
items := make([]mujica.SelectOption[string], len(choices))
|
||||
for i, s := range choices {
|
||||
items[i] = mujica.SelectOption[string]{Value: s, Label: s}
|
||||
}
|
||||
r := mujica.Select(c, value, items, mujica.SelectOptions{Label: label})
|
||||
r.Element.Width(160)
|
||||
if r.Changed() {
|
||||
log(label + " = " + *value)
|
||||
}
|
||||
}
|
||||
|
||||
func c2Frame(c *ui.Context, label string, status *string, log func(string)) (mujica.ChartFrame, bool) {
|
||||
c2Select(c, status, "Status", c1Statuses, log)
|
||||
f := mujica.ChartFrame{Label: label}
|
||||
if *status == "Loading" {
|
||||
f.Status = mujica.DataLoading
|
||||
}
|
||||
if *status == "Failed" {
|
||||
f.Status, f.Error = mujica.DataFailed, "The archive did not answer"
|
||||
}
|
||||
return f, *status != "Empty"
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
const c2ChartLegendCode = `hidden := map[string]bool{}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
k := mujica.Tokens(c)
|
||||
items := []mujica.ChartLegendItem{{Name: "Pews", Color: k.Info}, {Name: "Candles", Color: k.Success, Shape: 1}}
|
||||
mujica.ChartLegend(c, &hidden, items, mujica.ChartLegendOptions{MaxItems: 1})
|
||||
}
|
||||
ui.Render(view, 400, 200, 1)`
|
||||
|
||||
const c2ChartTooltipCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
k := mujica.Tokens(c)
|
||||
mujica.ChartTooltip(c, 40, 40, []mujica.ChartTooltipLine{{Text: "Lauds"}, {Text: "Pews 5", Color: k.Info}, {Text: "Candles 2", Color: k.Success, Shape: 1}})
|
||||
}
|
||||
ui.Render(view, 400, 200, 1)`
|
||||
|
||||
const c2ChartCrosshairCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
hair := mujica.ChartCrosshair(c, mujica.ChartCrosshairOptions{X: &x, Y: &y})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
hair.Draw(p, plot, x.Map(plot, 5), y.Map(plot, 70))
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const c2ChartAxisCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
axis := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Kind: mujica.AxisLog, Min: 1, Max: 1000})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
axis.Draw(p, ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const c2ChartGridCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
grid := mujica.ChartGrid(c, mujica.ChartGridOptions{X: &x, Y: &y, Minor: 2})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
grid.Draw(p, plot)
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const c2ChartBrushCode = `value := [2]float64{20, 60}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartBrush(c, &value, mujica.ChartBrushOptions{Label: "Months", Max: 100, Step: 1, Values: []float64{2, 4, 3, 8, 5}})
|
||||
}
|
||||
ui.Render(view, 400, 160, 1)`
|
||||
|
||||
const c2ChartAnnotationCode = `notes := []mujica.ChartNote{{Kind: mujica.NoteHLine, Value: 50, Text: "Goal", Draggable: true}, {Kind: mujica.NoteBand, Value: 6, End: 8, Text: "Lent"}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Max: 10})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100})
|
||||
annotation := mujica.ChartAnnotation(c, notes, mujica.ChartAnnotationOptions{X: &x, Y: &y})
|
||||
box := ui.Box(c).Fill().Focusable().Label("Annotations")
|
||||
b := box.Bounds()
|
||||
annotation.Drag(box, ui.Rect{X: 60, Y: 20, W: b.W - 80, H: b.H - 60})
|
||||
box.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 20, W: r.W - 80, H: r.H - 60}
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
annotation.Draw(p, plot)
|
||||
})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const c2ChartExportCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
data, err := mujica.ChartExport(c, func(c *ui.Context) {
|
||||
mujica.SparkBar(c, []float64{3, -2, 5}, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}})
|
||||
}, mujica.ChartExportOptions{Width: 300, Height: 100, Scale: 2})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
bitmap, err := ui.DecodeBitmap(data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ui.Image(c, bitmap).Size(300, 100)
|
||||
}
|
||||
ui.Render(view, 400, 160, 1)`
|
||||
|
||||
const c2SparkBarCode = `values := []float64{3, -2, 4, 1, -3}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.SparkBar(c, values, mujica.SparkBarOptions{Frame: mujica.ChartFrame{Label: "Alms"}, BarWidth: 10})
|
||||
}
|
||||
ui.Render(view, 400, 100, 1)`
|
||||
|
||||
const c2TrendIndicatorCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.TrendIndicator(c, 12.5, mujica.TrendIndicatorOptions{Precision: 1, Unit: "%", Note: "vs Lent"})
|
||||
}
|
||||
ui.Render(view, 400, 100, 1)`
|
||||
|
||||
const c2ChartEmptyStateCode = `view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChartEmptyState(c, mujica.DataFailed, mujica.ChartEmptyStateOptions{Height: 180, Error: "The archive did not answer"})
|
||||
}
|
||||
ui.Render(view, 400, 240, 1)`
|
||||
|
||||
const c2ViolinPlotCode = `groups := []mujica.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 7, 9}}, {Name: "Nave", Values: []float64{2, 4, 5, 6, 8}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ViolinPlot(c, groups, mujica.ViolinPlotOptions{Frame: mujica.ChartFrame{Label: "Hymns"}, Bandwidth: 1})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const c2ParallelCoordinatesCode = `filters := map[string][2]float64{}
|
||||
axes := []string{"Age", "Hymns", "Vigils"}
|
||||
rows := [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ParallelCoordinates(c, &filters, axes, rows, mujica.ParallelCoordinatesOptions{Frame: mujica.ChartFrame{Label: "Monks"}, Names: []string{"Agnes", "Clare", "Hild"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const c2ChordDiagramCode = `names := []string{"Nave", "Choir", "Crypt"}
|
||||
matrix := [][]float64{{0, 5, 2}, {4, 0, 1}, {1, 2, 0}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.ChordDiagram(c, names, matrix, mujica.ChordDiagramOptions{Frame: mujica.ChartFrame{Label: "Paths"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
const c2DensityPlotCode = `groups := []mujica.BoxGroup{{Name: "Matins", Values: []float64{1, 2, 2, 3, 7}}, {Name: "Vespers", Values: []float64{3, 4, 5, 6, 6}}}
|
||||
view := func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
mujica.DensityPlot(c, groups, mujica.DensityPlotOptions{Frame: mujica.ChartFrame{Label: "Ages"}, Bandwidth: 1, Points: 128})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func c2ViolinDemo(c *ui.Context, log func(string)) { c2DistributionDemo(c, log, "129", "ViolinPlot") }
|
||||
func c2DensityDemo(c *ui.Context, log func(string)) { c2DistributionDemo(c, log, "138", "DensityPlot") }
|
||||
|
||||
func c2DistributionDemo(c *ui.Context, log func(string), id, name string) {
|
||||
type state struct {
|
||||
status string
|
||||
bandwidth float64
|
||||
disabled bool
|
||||
groups []mujica.BoxGroup
|
||||
}
|
||||
s := ui.Local(c.Root(), id, func() state {
|
||||
return state{status: "Ready", groups: []mujica.BoxGroup{
|
||||
{Name: "Matins", Values: []float64{1, 2, 2, 3, 4, 5, 8, 9}},
|
||||
{Name: "Vespers", Values: []float64{3, 4, 5, 6, 6, 7, 8, 11}},
|
||||
}}
|
||||
})
|
||||
c1Section(c, name, func() {
|
||||
c2Number(c, &s.bandwidth, "Bandwidth", 0, 5, 0.5, log)
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
}, func() {
|
||||
f, show := c2Frame(c, name, &s.status, log)
|
||||
groups := s.groups
|
||||
if !show {
|
||||
groups = nil
|
||||
}
|
||||
ui.Column(c).Disabled(s.disabled).Children(func() {
|
||||
if id == "129" {
|
||||
c1Log(log, name, mujica.ViolinPlot(c, groups, mujica.ViolinPlotOptions{Frame: f, Bandwidth: s.bandwidth}))
|
||||
} else {
|
||||
c1Log(log, name, mujica.DensityPlot(c, groups, mujica.DensityPlotOptions{Frame: f, Bandwidth: s.bandwidth}))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func c2ParallelDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
status string
|
||||
disabled bool
|
||||
filters map[string][2]float64
|
||||
rows [][]float64
|
||||
}
|
||||
s := ui.Local(c.Root(), "130", func() state {
|
||||
return state{status: "Ready", filters: map[string][2]float64{}, rows: [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}, {65, 1, 4}}}
|
||||
})
|
||||
c1Section(c, "ParallelCoordinates", func() {
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
if mujica.Button(c, "Clear filters", mujica.ButtonOptions{Disabled: s.disabled}).Clicked() {
|
||||
s.filters = map[string][2]float64{}
|
||||
log("ParallelCoordinates: clear filters")
|
||||
}
|
||||
}, func() {
|
||||
f, show := c2Frame(c, "ParallelCoordinates", &s.status, log)
|
||||
rows := s.rows
|
||||
names := []string{"Agnes", "Clare", "Hild", "Bede"}
|
||||
if !show {
|
||||
rows = nil
|
||||
names = nil
|
||||
}
|
||||
ui.Column(c).Disabled(s.disabled).Children(func() {
|
||||
c1Log(log, "ParallelCoordinates", mujica.ParallelCoordinates(c, &s.filters, []string{"Age", "Hymns", "Vigils"}, rows, mujica.ParallelCoordinatesOptions{Frame: f, Names: names}))
|
||||
})
|
||||
ui.Text(c, fmt.Sprintf("Filters: %v", s.filters))
|
||||
})
|
||||
}
|
||||
|
||||
func c2ChordDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
status string
|
||||
disabled bool
|
||||
amount float64
|
||||
matrix [][]float64
|
||||
}
|
||||
s := ui.Local(c.Root(), "131", func() state {
|
||||
return state{status: "Ready", amount: 5, matrix: [][]float64{{0, 5, 2}, {4, 0, 1}, {1, 2, 0}}}
|
||||
})
|
||||
c1Section(c, "ChordDiagram", func() {
|
||||
old := s.amount
|
||||
c2Number(c, &s.amount, "Nave to Choir", 0, 20, 1, log)
|
||||
if old != s.amount {
|
||||
s.matrix = [][]float64{{0, s.amount, 2}, {4, 0, 1}, {1, 2, 0}}
|
||||
}
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
}, func() {
|
||||
f, show := c2Frame(c, "ChordDiagram", &s.status, log)
|
||||
names, matrix := []string{"Nave", "Choir", "Crypt"}, s.matrix
|
||||
if !show {
|
||||
names = nil
|
||||
matrix = nil
|
||||
}
|
||||
ui.Column(c).Disabled(s.disabled).Children(func() {
|
||||
c1Log(log, "ChordDiagram", mujica.ChordDiagram(c, names, matrix, mujica.ChordDiagramOptions{Frame: f}))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
type c2PartsState struct {
|
||||
status, kind string
|
||||
disabled, crosshair, draggable, fixed bool
|
||||
minor float64
|
||||
notes []mujica.ChartNote
|
||||
}
|
||||
|
||||
func c2TooltipDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "122", "ChartTooltip") }
|
||||
func c2CrosshairDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "123", "ChartCrosshair") }
|
||||
func c2AxisDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "124", "ChartAxis") }
|
||||
func c2GridDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "125", "ChartGrid") }
|
||||
func c2AnnotationDemo(c *ui.Context, log func(string)) { c2PartsDemo(c, log, "127", "ChartAnnotation") }
|
||||
|
||||
func c2PartsDemo(c *ui.Context, log func(string), id, name string) {
|
||||
s := ui.Local(c.Root(), id, func() c2PartsState {
|
||||
return c2PartsState{status: "Ready", kind: "Linear", crosshair: true, minor: 2, draggable: true, notes: []mujica.ChartNote{
|
||||
{Kind: mujica.NoteHLine, Value: 50, Text: "Goal"},
|
||||
{Kind: mujica.NoteVLine, Value: 3, Text: "Advent"},
|
||||
{Kind: mujica.NoteBand, Value: 6, End: 8, Text: "Lent"},
|
||||
{Kind: mujica.NoteText, Value: 1, End: 90, Text: "Feast"},
|
||||
}}
|
||||
})
|
||||
c1Section(c, name, func() {
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
switch id {
|
||||
case "123":
|
||||
c2Check(c, &s.crosshair, "Crosshair", log)
|
||||
case "124":
|
||||
c2Select(c, &s.kind, "Axis kind", []string{"Linear", "Log", "Time", "Category"}, log)
|
||||
c2Check(c, &s.fixed, "Fixed ticks", log)
|
||||
case "125":
|
||||
c2Number(c, &s.minor, "Minor", 0, 8, 1, log)
|
||||
case "127":
|
||||
c2Check(c, &s.draggable, "Draggable", log)
|
||||
}
|
||||
}, func() {
|
||||
f, show := c2Frame(c, name, &s.status, log)
|
||||
ui.Column(c).Disabled(s.disabled).Children(func() {
|
||||
c1Log(log, name, c2PartsChart(c, f, show, s, id, log))
|
||||
})
|
||||
if id == "127" {
|
||||
ui.Text(c, fmt.Sprintf("Goal %.1f · Advent %.1f · Lent %.1f–%.1f · Feast %.1f, %.1f", s.notes[0].Value, s.notes[1].Value, s.notes[2].Value, s.notes[2].End, s.notes[3].Value, s.notes[3].End))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func c2PartsChart(c *ui.Context, frame mujica.ChartFrame, show bool, s *c2PartsState, id string, log func(string)) mujica.ChartResult {
|
||||
xo := mujica.ChartAxisOptions{Max: 10}
|
||||
yo := mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 100}
|
||||
pts := [][2]float64{{2, 30}, {5, 80}, {8, 55}}
|
||||
if id == "124" {
|
||||
switch s.kind {
|
||||
case "Log":
|
||||
yo.Kind, yo.Min = mujica.AxisLog, 1
|
||||
case "Time":
|
||||
xo.Kind = mujica.AxisTime
|
||||
xo.From = time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
xo.To = xo.From.Add(10 * time.Hour)
|
||||
for i := range pts {
|
||||
pts[i][0] = float64(xo.From.Unix()) + pts[i][0]*3600
|
||||
}
|
||||
case "Category":
|
||||
xo.Kind, xo.Categories = mujica.AxisCategory, []string{"Nave", "Choir", "Crypt"}
|
||||
for i := range pts {
|
||||
pts[i][0] = float64(i)
|
||||
}
|
||||
}
|
||||
if s.fixed {
|
||||
yo.Ticks = []float64{10, 50, 90}
|
||||
}
|
||||
}
|
||||
x, y := mujica.ChartAxis(c, xo), mujica.ChartAxis(c, yo)
|
||||
grid := mujica.ChartGrid(c, mujica.ChartGridOptions{Y: &y, Minor: int(s.minor)})
|
||||
hair := mujica.ChartCrosshair(c, mujica.ChartCrosshairOptions{X: &x, Y: &y})
|
||||
var notes []mujica.ChartNote
|
||||
if id == "127" {
|
||||
notes = s.notes
|
||||
for i := range notes {
|
||||
notes[i].Draggable = s.draggable
|
||||
}
|
||||
}
|
||||
annotation := mujica.ChartAnnotation(c, notes, mujica.ChartAnnotationOptions{X: &x, Y: &y})
|
||||
plotOf := func(r ui.Rect) ui.Rect {
|
||||
return ui.Rect{X: r.X + 64, Y: r.Y + 16, W: max(1, r.W-100), H: max(1, r.H-56)}
|
||||
}
|
||||
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])
|
||||
}
|
||||
marks := len(pts)
|
||||
if !show {
|
||||
marks = 0
|
||||
}
|
||||
colors := internal.Palette(mujica.Tokens(c))
|
||||
return mujica.Chart(c, frame, mujica.ChartSpec{
|
||||
Marks: marks,
|
||||
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,
|
||||
Readout: func(i int) string { return fmt.Sprintf("x %s · y %s", x.Label(pts[i][0]), y.Label(pts[i][1])) },
|
||||
Tooltip: func(i int) []mujica.ChartTooltipLine {
|
||||
return []mujica.ChartTooltipLine{{Text: x.Label(pts[i][0])}, {Text: "Pews " + y.Label(pts[i][1]), Color: colors[0]}, {Text: "Candles " + y.Label(pts[i][1]/2), Color: colors[1], Shape: 1}}
|
||||
},
|
||||
Input: func(el *ui.Element, r ui.Rect) {
|
||||
if annotation.Drag(el, plotOf(r)) {
|
||||
log("ChartAnnotation: moved")
|
||||
}
|
||||
},
|
||||
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)
|
||||
annotation.Draw(p, plot)
|
||||
for i := range pts {
|
||||
px, py := at(r, i)
|
||||
internal.Marker(p, i, px, py, 10, colors[i])
|
||||
}
|
||||
if id == "123" && s.crosshair && active >= 0 {
|
||||
px, py := at(r, active)
|
||||
hair.Draw(p, plot, px, py)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
func c2LegendDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
hidden map[string]bool
|
||||
vertical, disabled bool
|
||||
max float64
|
||||
}
|
||||
s := ui.Local(c.Root(), "121", func() state { return state{hidden: map[string]bool{}, max: 3} })
|
||||
c1Section(c, "ChartLegend", func() {
|
||||
c2Check(c, &s.vertical, "Vertical", log)
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
c2Number(c, &s.max, "Max items", 0, 6, 1, log)
|
||||
}, func() {
|
||||
pal := internal.Palette(mujica.Tokens(c))
|
||||
names := []string{"Matins", "Lauds", "Prime", "Terce", "Sext", "None"}
|
||||
items := make([]mujica.ChartLegendItem, len(names))
|
||||
for i, n := range names {
|
||||
items[i] = mujica.ChartLegendItem{Name: n, Color: pal[i], Shape: i % 5}
|
||||
}
|
||||
ui.Column(c).Disabled(s.disabled).Children(func() {
|
||||
r := mujica.ChartLegend(c, &s.hidden, items, mujica.ChartLegendOptions{Vertical: s.vertical, MaxItems: int(s.max)})
|
||||
if expanded, ok := r.Expanded(); ok {
|
||||
log(fmt.Sprintf("ChartLegend: expanded %v", expanded))
|
||||
}
|
||||
if n, ok := r.Toggled(); ok {
|
||||
log("ChartLegend: toggled " + n)
|
||||
}
|
||||
})
|
||||
ui.Text(c, fmt.Sprintf("Hidden: %v", s.hidden))
|
||||
})
|
||||
}
|
||||
|
||||
func c2BrushDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
value [2]float64
|
||||
disabled bool
|
||||
step float64
|
||||
}
|
||||
s := ui.Local(c.Root(), "126", func() state { return state{value: [2]float64{20, 60}, step: 1} })
|
||||
c1Section(c, "ChartBrush", func() {
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
c2Number(c, &s.step, "Step", 1, 10, 1, log)
|
||||
}, func() {
|
||||
vals := []float64{1, 4, 2, 7, 3, 8, 4, 9}
|
||||
r := mujica.ChartBrush(c, &s.value, mujica.ChartBrushOptions{Label: "Months", Max: 100, Step: s.step, Values: vals, Disabled: s.disabled})
|
||||
if r.Changed() {
|
||||
log(fmt.Sprintf("ChartBrush: range %g–%g", s.value[0], s.value[1]))
|
||||
}
|
||||
x := mujica.ChartAxis(c, mujica.ChartAxisOptions{Min: s.value[0], Max: s.value[1]})
|
||||
y := mujica.ChartAxis(c, mujica.ChartAxisOptions{Side: mujica.SideLeft, Max: 10})
|
||||
xs := []float64{0, 15, 30, 45, 60, 75, 90, 100}
|
||||
var visible []int
|
||||
for i, v := range xs {
|
||||
if v >= s.value[0] && v <= s.value[1] {
|
||||
visible = append(visible, i)
|
||||
}
|
||||
}
|
||||
plotOf := func(r ui.Rect) ui.Rect { return ui.Rect{X: r.X + 40, Y: r.Y + 8, W: max(1, r.W-60), H: max(1, r.H-40)} }
|
||||
at := func(r ui.Rect, i int) (float32, float32) {
|
||||
plot := plotOf(r)
|
||||
return x.Map(plot, xs[visible[i]]), y.Map(plot, vals[visible[i]])
|
||||
}
|
||||
c1Log(log, "Zoom preview", mujica.Chart(c, mujica.ChartFrame{Label: "Zoom preview", Height: 140}, mujica.ChartSpec{
|
||||
Marks: len(visible), Anchor: at,
|
||||
Pick: func(r ui.Rect, px, py float32) int {
|
||||
return internal.Nearest(len(visible), func(i int) (float32, float32) { return at(r, i) }, px, py, 24, false)
|
||||
},
|
||||
Readout: func(i int) string { return fmt.Sprintf("x %g · y %g", xs[visible[i]], vals[visible[i]]) },
|
||||
Draw: func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot := plotOf(r)
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
for i := range visible {
|
||||
px, py := at(r, i)
|
||||
internal.Marker(p, 0, px, py, 8, mujica.Tokens(c).AccentText)
|
||||
}
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
func c2ExportDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
width, height, scale float64
|
||||
disabled bool
|
||||
bitmap *ui.Bitmap
|
||||
bytes int
|
||||
error string
|
||||
}
|
||||
s := ui.Local(c.Root(), "128", func() state { return state{width: 400, height: 240, scale: 2} })
|
||||
c1Section(c, "ChartExport", func() {
|
||||
c2Number(c, &s.width, "Width", 200, 600, 50, log)
|
||||
c2Number(c, &s.height, "Height", 160, 400, 40, log)
|
||||
c2Number(c, &s.scale, "Scale", 1, 3, 0.5, log)
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
}, func() {
|
||||
if mujica.Button(c, "Export PNG", mujica.ButtonOptions{Disabled: s.disabled}).Clicked() {
|
||||
data, err := mujica.ChartExport(c, func(c *ui.Context) {
|
||||
mujica.BarChart(c, []string{"Nave", "Choir", "Crypt"}, []mujica.ChartSeries{{Name: "Pews", Values: []float64{3, 5, 2}}}, mujica.BarChartOptions{Frame: mujica.ChartFrame{Label: "Exported chart", Height: float32(s.height) - 80}})
|
||||
}, mujica.ChartExportOptions{Width: int(s.width), Height: int(s.height), Scale: float32(s.scale)})
|
||||
if err == nil {
|
||||
s.bitmap, err = ui.DecodeBitmap(data)
|
||||
}
|
||||
if err != nil {
|
||||
s.error = err.Error()
|
||||
log("ChartExport: " + s.error)
|
||||
} else {
|
||||
s.error = ""
|
||||
s.bytes = len(data)
|
||||
log(fmt.Sprintf("ChartExport: %d bytes", s.bytes))
|
||||
}
|
||||
}
|
||||
if s.error != "" {
|
||||
ui.Text(c, s.error).TextColor(mujica.Tokens(c).Danger).Role(ui.RoleStatus)
|
||||
}
|
||||
if s.bitmap != nil {
|
||||
ui.Image(c, s.bitmap).Size(float32(s.width), float32(s.height))
|
||||
ui.Text(c, fmt.Sprintf("PNG: %d bytes", s.bytes))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func c2SparkBarDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
status string
|
||||
width float64
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "132", func() state { return state{status: "Ready", width: 10} })
|
||||
c1Section(c, "SparkBar", func() {
|
||||
c2Number(c, &s.width, "Bar width", 2, 20, 1, log)
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
}, func() {
|
||||
f, show := c2Frame(c, "Alms", &s.status, log)
|
||||
vals := []float64{3, -2, 4, 1, -3}
|
||||
if !show {
|
||||
vals = nil
|
||||
}
|
||||
ui.Column(c).Disabled(s.disabled).Children(func() {
|
||||
c1Log(log, "SparkBar", mujica.SparkBar(c, vals, mujica.SparkBarOptions{Frame: f, BarWidth: float32(s.width)}))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func c2TrendDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
change, precision float64
|
||||
invert bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "133", func() state { return state{change: 12.5, precision: 1} })
|
||||
c1Section(c, "TrendIndicator", func() {
|
||||
c2Number(c, &s.change, "Change", -100, 100, 0.5, log)
|
||||
c2Number(c, &s.precision, "Precision", 0, 3, 1, log)
|
||||
c2Check(c, &s.invert, "Invert", log)
|
||||
}, func() {
|
||||
mujica.TrendIndicator(c, s.change, mujica.TrendIndicatorOptions{Precision: int(s.precision), Unit: "%", Note: "vs Lent", Invert: s.invert})
|
||||
ui.Row(c).Gap(24).Children(func() {
|
||||
mujica.TrendIndicator(c, 3, mujica.TrendIndicatorOptions{})
|
||||
mujica.TrendIndicator(c, -3, mujica.TrendIndicatorOptions{})
|
||||
mujica.TrendIndicator(c, 0, mujica.TrendIndicatorOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func c2EmptyDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
status string
|
||||
height float64
|
||||
disabled bool
|
||||
}
|
||||
s := ui.Local(c.Root(), "140", func() state { return state{status: "Failed", height: 180} })
|
||||
c1Section(c, "ChartEmptyState", func() {
|
||||
c2Number(c, &s.height, "Height", 160, 320, 40, log)
|
||||
c2Check(c, &s.disabled, "Disabled", log)
|
||||
}, func() {
|
||||
f, _ := c2Frame(c, "ChartEmptyState", &s.status, log)
|
||||
ui.Column(c).Disabled(s.disabled).Border(1, mujica.Tokens(c).Border).Children(func() {
|
||||
if mujica.ChartEmptyState(c, f.Status, mujica.ChartEmptyStateOptions{Height: float32(s.height), Error: f.Error}).Changed() {
|
||||
log("ChartEmptyState: retry")
|
||||
s.status = "Loading"
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
func c2Demo(t *testing.T, id string) (*ui.Tester, *[]string) {
|
||||
t.Helper()
|
||||
i := slices.IndexFunc(charts2Pages, func(p page) bool { return p.ID == id })
|
||||
if i < 0 {
|
||||
t.Fatalf("missing page %s", id)
|
||||
}
|
||||
events := &[]string{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{})
|
||||
ui.Scroll(c).Fill().Padding(24).Children(func() { charts2Pages[i].Demo(c, func(s string) { *events = append(*events, s) }) })
|
||||
}, 900, 760)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
return tt, events
|
||||
}
|
||||
|
||||
func TestCharts2ExistingSet(t *testing.T) {
|
||||
want := []string{"121", "122", "123", "124", "125", "126", "127", "128", "129", "130", "131", "132", "133", "138", "140"}
|
||||
var ids []string
|
||||
for _, p := range charts2Pages {
|
||||
ids = append(ids, p.ID)
|
||||
if p.Category != "Charts II" || p.Code == "" || p.Keys == "" {
|
||||
t.Fatalf("incomplete page %s", p.ID)
|
||||
}
|
||||
c2Demo(t, p.ID)
|
||||
}
|
||||
if !slices.Equal(ids, want) {
|
||||
t.Fatalf("page IDs %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharts2ReadoutsAndControlsLog(t *testing.T) {
|
||||
for _, id := range []string{"122", "123", "124", "125", "127", "129", "130", "131", "132", "138"} {
|
||||
tt, events := c2Demo(t, id)
|
||||
p := charts2Pages[slices.IndexFunc(charts2Pages, func(p page) bool { return p.ID == id })]
|
||||
label := p.Name
|
||||
if id == "132" {
|
||||
label = "Alms"
|
||||
}
|
||||
tabUntil(t, tt, label)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !slices.Contains(*events, p.Name+": point 0") {
|
||||
t.Errorf("%s readout events %q", id, *events)
|
||||
}
|
||||
if err := tt.Click("Disabled"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(*events, "Disabled = true") {
|
||||
t.Errorf("%s disabled events %q", id, *events)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharts2LegendBrushAnnotationAndRetry(t *testing.T) {
|
||||
tt, events := c2Demo(t, "121")
|
||||
if err := tt.Click("+3 more"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tt.Click("Matins"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(*events, "ChartLegend: expanded true") || !slices.Contains(*events, "ChartLegend: toggled Matins") {
|
||||
t.Fatalf("legend events %q", *events)
|
||||
}
|
||||
tt, events = c2Demo(t, "126")
|
||||
tabUntil(t, tt, "Months: 20 to 60")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !slices.Contains(*events, "ChartBrush: range 21–61") {
|
||||
t.Fatalf("brush events %q", *events)
|
||||
}
|
||||
tt, events = c2Demo(t, "127")
|
||||
tabUntil(t, tt, "ChartAnnotation")
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if !slices.Contains(*events, "ChartAnnotation: moved") || !tt.HasText("Goal 51.0 · Advent 3.0 · Lent 6.0–8.0 · Feast 1.0, 90.0") {
|
||||
t.Fatalf("annotation events %q text %q", *events, tt.Texts())
|
||||
}
|
||||
tt, events = c2Demo(t, "140")
|
||||
if err := tt.Click("Retry"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(*events, "ChartEmptyState: retry") || !tt.HasText("Loading") {
|
||||
t.Fatalf("retry events %q", *events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharts2ExportAndTrendControls(t *testing.T) {
|
||||
tt, events := c2Demo(t, "128")
|
||||
tabUntil(t, tt, "Export PNG")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(*events) == 0 || !strings.HasPrefix((*events)[len(*events)-1], "ChartExport: ") || !strings.HasSuffix((*events)[len(*events)-1], " bytes") {
|
||||
t.Fatalf("export events %q", *events)
|
||||
}
|
||||
tt, events = c2Demo(t, "133")
|
||||
if err := tt.Click("Invert"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Contains(*events, "Invert = true") {
|
||||
t.Fatalf("trend events %q", *events)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -159,10 +159,13 @@ func Marker(p *ui.Painter, i int, cx, cy, size float32, col ui.Color) {
|
||||
}
|
||||
|
||||
// Arc appends a polyline from a0 to a1, clockwise radians.
|
||||
func Arc(path *ui.Path, cx, cy, r float32, a0, a1 float64, move bool) {
|
||||
func Arc(path *ui.Path, cx, cy, r float32, a0, a1 float64, move bool, painters ...*ui.Painter) {
|
||||
n := max(2, int(math.Ceil(math.Abs(a1-a0)/(math.Pi/48))))
|
||||
for i := 0; i <= n; i++ {
|
||||
x, y := Polar(cx, cy, r, a0+(a1-a0)*float64(i)/float64(n))
|
||||
if len(painters) > 0 {
|
||||
x, y = Snap(painters[0], x), Snap(painters[0], y)
|
||||
}
|
||||
if i == 0 && move {
|
||||
path.MoveTo(x, y)
|
||||
} else {
|
||||
@@ -177,11 +180,11 @@ func Polar(cx, cy, r float32, a float64) (float32, float32) {
|
||||
}
|
||||
|
||||
// Sector returns a ring slice; zero r0 makes a wedge.
|
||||
func Sector(cx, cy, r0, r1 float32, a0, a1 float64) *ui.Path {
|
||||
func Sector(cx, cy, r0, r1 float32, a0, a1 float64, painters ...*ui.Painter) *ui.Path {
|
||||
var path ui.Path
|
||||
Arc(&path, cx, cy, r1, a0, a1, true)
|
||||
Arc(&path, cx, cy, r1, a0, a1, true, painters...)
|
||||
if r0 > 0 {
|
||||
Arc(&path, cx, cy, r0, a1, a0, false)
|
||||
Arc(&path, cx, cy, r0, a1, a0, false, painters...)
|
||||
} else {
|
||||
path.LineTo(cx, cy)
|
||||
}
|
||||
@@ -191,27 +194,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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,20 @@ func chartLines(ch *chart, d chartLineData) ChartResult {
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
spec.Tooltip = 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)
|
||||
tx, tl := d.ax.ticks(plot)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package mujica
|
||||
|
||||
func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"violin_plot.readout": {"%s · median %s · quartiles %s–%s · %d values", "%s · 中位数 %s · 四分位 %s–%s · %d 个值"},
|
||||
"parallel_coordinates.filtered": {"filtered out", "已筛除"},
|
||||
"parallel_coordinates.brush": {"%s filter %s–%s", "%s 筛选 %s–%s"},
|
||||
"chord_diagram.out": {"out", "流出"},
|
||||
"chord_diagram.in": {"in", "流入"},
|
||||
"chart_annotation.readout": {"Annotation %d · %s", "标注 %d · %s"},
|
||||
"chart_annotation.x": {"x %s", "x %s"},
|
||||
"chart_annotation.y": {"y %s", "y %s"},
|
||||
"spark_bar.empty": {"No data", "暂无数据"},
|
||||
"chart_empty_state.empty": {"No data", "暂无数据"},
|
||||
"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"},
|
||||
"trend_indicator.up": {"up", "上升"},
|
||||
"trend_indicator.down": {"down", "下降"},
|
||||
"trend_indicator.flat": {"unchanged", "持平"},
|
||||
})
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ParallelCoordinatesOptions configure a ParallelCoordinates chart.
|
||||
type ParallelCoordinatesOptions struct {
|
||||
Frame ChartFrame
|
||||
// Names labels rows; nil uses row numbers.
|
||||
Names []string
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// pcDrag is a brush being dragged on one axis.
|
||||
type pcDrag struct {
|
||||
axis int
|
||||
y0, y1 float32
|
||||
}
|
||||
|
||||
// ParallelCoordinates draws rows across parallel axes; dragging an axis filters.
|
||||
func ParallelCoordinates(c *ui.Context, filters *map[string][2]float64, axes []string, rows [][]float64, opts ParallelCoordinatesOptions) ChartResult {
|
||||
if filters == nil || len(axes) < 2 || opts.Names != nil && len(opts.Names) != len(rows) {
|
||||
panic("mujica: ParallelCoordinates needs filters, two or more axes and a name per row")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, name := range axes {
|
||||
if name == "" || seen[name] {
|
||||
panic("mujica: ParallelCoordinates axes need unique names")
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
for name, f := range *filters {
|
||||
if !seen[name] || math.IsNaN(f[0]) || math.IsNaN(f[1]) || math.IsInf(f[0], 0) || math.IsInf(f[1], 0) || f[0] > f[1] {
|
||||
panic("mujica: ParallelCoordinates filters need known axes and finite ascending ranges")
|
||||
}
|
||||
}
|
||||
ch := newChart(c, opts.Frame)
|
||||
k := Tokens(c)
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
ink := internal.Palette(k)[0]
|
||||
type scales struct{ lo, hi []float64 }
|
||||
sc := chartMemo(ch, "scales", [2]string{chartID(rows), chartID(axes)}, func() scales {
|
||||
for _, r := range rows {
|
||||
if len(r) != len(axes) {
|
||||
panic("mujica: ParallelCoordinates rows need one value per axis")
|
||||
}
|
||||
for _, v := range r {
|
||||
if math.IsInf(v, 0) {
|
||||
panic("mujica: ParallelCoordinates values must be finite or NaN")
|
||||
}
|
||||
}
|
||||
}
|
||||
s := scales{lo: make([]float64, len(axes)), hi: make([]float64, len(axes))}
|
||||
for a := range axes {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for _, r := range rows {
|
||||
if !math.IsNaN(r[a]) {
|
||||
lo, hi = min(lo, r[a]), max(hi, r[a])
|
||||
}
|
||||
}
|
||||
if math.IsInf(lo, 1) {
|
||||
lo, hi = 0, 1
|
||||
}
|
||||
s.lo[a], s.hi[a], _ = internal.Nice(lo, hi, 4)
|
||||
}
|
||||
return s
|
||||
})
|
||||
keep := func(r []float64) bool {
|
||||
for a, name := range axes {
|
||||
if f, ok := (*filters)[name]; ok && (math.IsNaN(r[a]) || r[a] < f[0] || r[a] > f[1]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
passing := chartMemo(ch, "pc_filter", [2]string{chartID(rows), fmt.Sprint(*filters)}, func() []bool {
|
||||
out := make([]bool, len(rows))
|
||||
for i, row := range rows {
|
||||
out[i] = keep(row)
|
||||
}
|
||||
return out
|
||||
})
|
||||
visible := ui.Local(ch.res.Element, "mujica.pc.visible", func() []int { return nil })
|
||||
geom := func(r ui.Rect) (ui.Rect, func(a int) float32, func(a int, v float64) float32) {
|
||||
plot := ui.Rect{X: r.X + 36, Y: r.Y + 24, W: r.W - 72, H: r.H - 32}
|
||||
ax := func(a int) float32 { return plot.X + plot.W*float32(a)/float32(len(axes)-1) }
|
||||
y := func(a int, v float64) float32 {
|
||||
return plot.Y + plot.H*float32(1-(v-sc.lo[a])/(sc.hi[a]-sc.lo[a]))
|
||||
}
|
||||
return plot, ax, y
|
||||
}
|
||||
name := func(i int) string {
|
||||
if opts.Names != nil {
|
||||
return opts.Names[i]
|
||||
}
|
||||
return "#" + chartValue(c, float64(i+1), nil)
|
||||
}
|
||||
spec := ChartSpec{Marks: len(rows)}
|
||||
spec.Readout = func(i int) string {
|
||||
parts := []string{name(i)}
|
||||
for a, n := range axes {
|
||||
parts = append(parts, n+" "+chartValue(c, rows[i][a], opts.Format))
|
||||
}
|
||||
if !passing[i] {
|
||||
parts = append(parts, msg(c, "parallel_coordinates.filtered"))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
spec.Pick = func(r ui.Rect, x, y float32) int {
|
||||
_, ax, ys := geom(r)
|
||||
best, dist := -1, float32(8)
|
||||
for _, i := range *visible {
|
||||
row := rows[i]
|
||||
for a := 0; a+1 < len(axes); a++ {
|
||||
x0, x1 := ax(a), ax(a+1)
|
||||
if x < x0 || x > x1 || math.IsNaN(row[a]) || math.IsNaN(row[a+1]) {
|
||||
continue
|
||||
}
|
||||
t := (x - x0) / (x1 - x0)
|
||||
if d := abs32(ys(a, row[a]) + t*(ys(a+1, row[a+1])-ys(a, row[a])) - y); d < dist {
|
||||
best, dist = i, d
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
drag := ui.Local(ch.res.Element, "mujica.pc.drag", func() pcDrag { return pcDrag{axis: -1} })
|
||||
keyAxis := ui.Local(ch.res.Element, "mujica.pc.axis", func() int { return 0 })
|
||||
spec.Input = func(plot *ui.Element, r ui.Rect) {
|
||||
if plot.IsDisabled() {
|
||||
*drag = pcDrag{axis: -1}
|
||||
return
|
||||
}
|
||||
if plot.Shortcut(ui.Alt, ui.KeyPageDown) {
|
||||
*keyAxis = (*keyAxis + 1) % len(axes)
|
||||
}
|
||||
if plot.Shortcut(ui.Alt, ui.KeyPageUp) {
|
||||
*keyAxis = (*keyAxis + len(axes) - 1) % len(axes)
|
||||
}
|
||||
*keyAxis = min(*keyAxis, len(axes)-1)
|
||||
a := *keyAxis
|
||||
f, ok := (*filters)[axes[a]]
|
||||
if !ok {
|
||||
f = [2]float64{sc.lo[a], sc.hi[a]}
|
||||
}
|
||||
before := f
|
||||
step := (sc.hi[a] - sc.lo[a]) / 100
|
||||
if plot.Shortcut(ui.Alt, ui.KeyUp) {
|
||||
f[0] = min(f[1], f[0]+step)
|
||||
}
|
||||
if plot.Shortcut(ui.Alt, ui.KeyDown) {
|
||||
f[0] = max(sc.lo[a], f[0]-step)
|
||||
}
|
||||
if plot.Shortcut(ui.Alt|ui.Shift, ui.KeyUp) {
|
||||
f[1] = min(sc.hi[a], f[1]+step)
|
||||
}
|
||||
if plot.Shortcut(ui.Alt|ui.Shift, ui.KeyDown) {
|
||||
f[1] = max(f[0], f[1]-step)
|
||||
}
|
||||
clear := plot.Shortcut(ui.Alt, ui.KeyDelete)
|
||||
if f != before || clear && ok {
|
||||
m := maps.Clone(*filters)
|
||||
if m == nil {
|
||||
m = map[string][2]float64{}
|
||||
}
|
||||
if clear {
|
||||
delete(m, axes[a])
|
||||
} else {
|
||||
m[axes[a]] = f
|
||||
}
|
||||
*filters, ch.res.value = m, true
|
||||
c.Invalidate()
|
||||
c.Announce(fmt.Sprintf(msg(c, "parallel_coordinates.brush"), axes[a], chartValue(c, f[0], opts.Format), chartValue(c, f[1], opts.Format)))
|
||||
slog.Debug("mujica: parallel_coordinates keyboard", "axis", axes[a])
|
||||
}
|
||||
box, ax, _ := geom(r)
|
||||
px, py, _ := plot.PointerPosition()
|
||||
_, _, held := plot.Dragged()
|
||||
switch {
|
||||
case held && drag.axis < 0:
|
||||
for a := range axes {
|
||||
if abs32(px-ax(a)) <= 12 {
|
||||
*drag = pcDrag{axis: a, y0: py, y1: py}
|
||||
}
|
||||
}
|
||||
case held:
|
||||
drag.y1 = max(box.Y, min(box.Y+box.H, py))
|
||||
case drag.axis >= 0:
|
||||
a := drag.axis
|
||||
m := maps.Clone(*filters)
|
||||
if m == nil {
|
||||
m = map[string][2]float64{}
|
||||
}
|
||||
if abs32(drag.y1-drag.y0) < 4 {
|
||||
delete(m, axes[a])
|
||||
} else {
|
||||
v := func(y float32) float64 {
|
||||
return sc.lo[a] + float64((box.Y+box.H-y)/box.H)*(sc.hi[a]-sc.lo[a])
|
||||
}
|
||||
m[axes[a]] = [2]float64{min(v(drag.y0), v(drag.y1)), max(v(drag.y0), v(drag.y1))}
|
||||
}
|
||||
*filters, ch.res.value = m, true
|
||||
c.Invalidate()
|
||||
*drag = pcDrag{axis: -1}
|
||||
slog.Debug("mujica: parallel_coordinates brush", "axis", axes[a], "filters", len(m))
|
||||
}
|
||||
}
|
||||
shownDrag := *drag
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot, ax, ys := geom(r)
|
||||
*visible = chartVisibleIndices(len(rows), max(1, int(plot.H*2)), active)
|
||||
line := func(row []float64, w float32, col ui.Color) {
|
||||
var path ui.Path
|
||||
started := false
|
||||
for a := range axes {
|
||||
if math.IsNaN(row[a]) {
|
||||
started = false
|
||||
continue
|
||||
}
|
||||
x, y := internal.Snap(p, ax(a)), internal.Snap(p, ys(a, row[a]))
|
||||
if started {
|
||||
path.LineTo(x, y)
|
||||
} else {
|
||||
path.MoveTo(x, y)
|
||||
}
|
||||
started = true
|
||||
}
|
||||
p.StrokePath(&path, w, col)
|
||||
}
|
||||
for _, i := range *visible {
|
||||
if !passing[i] {
|
||||
line(rows[i], 1, k.Border)
|
||||
}
|
||||
}
|
||||
for _, i := range *visible {
|
||||
if passing[i] {
|
||||
line(rows[i], 1.25, ink)
|
||||
}
|
||||
}
|
||||
if active >= 0 {
|
||||
line(rows[active], 3, k.Text)
|
||||
}
|
||||
for a, n := range axes {
|
||||
x := internal.Snap(p, ax(a))
|
||||
p.Line(x, plot.Y, x, plot.Y+plot.H, 1.5, k.ControlBorder)
|
||||
if f, ok := (*filters)[n]; ok {
|
||||
y0, y1 := max(plot.Y, ys(a, f[1])), min(plot.Y+plot.H, ys(a, f[0]))
|
||||
p.Fill(ui.Rect{X: x - 6, Y: y0, W: 12, H: max(y1-y0, 1)}, k.Selection, 2)
|
||||
p.Stroke(ui.Rect{X: x - 6, Y: y0, W: 12, H: max(y1-y0, 1)}, k.AccentText, 2, 1.5)
|
||||
}
|
||||
chartText(p, n, x, plot.Y-8, size, k.Text, 0.5, 1)
|
||||
chartText(p, internal.FormatTick(sc.hi[a], (sc.hi[a]-sc.lo[a])/4), x+10, plot.Y, size, k.TextMuted, 0, 0)
|
||||
chartText(p, internal.FormatTick(sc.lo[a], (sc.hi[a]-sc.lo[a])/4), x+10, plot.Y+plot.H, size, k.TextMuted, 0, 1)
|
||||
}
|
||||
if shownDrag.axis >= 0 {
|
||||
x := ax(shownDrag.axis)
|
||||
p.Stroke(ui.Rect{X: x - 6, Y: min(shownDrag.y0, shownDrag.y1), W: 12, H: abs32(shownDrag.y1 - shownDrag.y0)}, k.AccentText, 2, 1.5)
|
||||
}
|
||||
}
|
||||
res, _ := ch.build(spec)
|
||||
if res.Element.IsDisabled() {
|
||||
*drag = pcDrag{axis: -1}
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var pcAxes = []string{"Age", "Hymns", "Vigils"}
|
||||
var pcRows = [][]float64{{20, 3, 9}, {35, 8, 2}, {50, 5, 5}, {65, 1, math.NaN()}}
|
||||
var pcNames = []string{"Agnès", "Clare", "Hild", "Bede"}
|
||||
|
||||
func TestParallelCoordinatesReadoutAndBrush(t *testing.T) {
|
||||
filters := map[string][2]float64{}
|
||||
values := 0
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
r := ParallelCoordinates(c, &filters, pcAxes, pcRows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Monks"}, Names: pcNames})
|
||||
if r.ValueChanged() {
|
||||
values++
|
||||
}
|
||||
return r
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Monks")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if got := chartReadout(tt); got != "Bede · Age 65 · Hymns 1 · Vigils no value" {
|
||||
t.Fatalf("readout %q", got)
|
||||
}
|
||||
b := h.res.Element.Bounds()
|
||||
plot := ui.Rect{X: b.X + 36, Y: b.Y + 24, W: b.W - 72, H: 240 - 32}
|
||||
// Age runs 20..80 on the first axis: brush 30..55.
|
||||
y := func(v float32) float32 { return plot.Y + plot.H*(1-(v-20)/60) }
|
||||
tt.Press(plot.X, y(55))
|
||||
tt.Move(plot.X, y(40))
|
||||
tt.Move(plot.X, y(30))
|
||||
tt.Release(plot.X, y(30))
|
||||
f, ok := filters["Age"]
|
||||
if !ok || math.Abs(f[0]-30) > 0.5 || math.Abs(f[1]-55) > 0.5 || values != 1 {
|
||||
t.Fatalf("brush %v %v (%d)", f, ok, values)
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if got := chartReadout(tt); got != "Agnès · Age 20 · Hymns 3 · Vigils 9 · filtered out" {
|
||||
t.Fatalf("filtered readout %q", got)
|
||||
}
|
||||
tt.Press(plot.X, y(45)) // a click on the axis clears it
|
||||
tt.Release(plot.X, y(45))
|
||||
if _, ok := filters["Age"]; ok {
|
||||
t.Fatal("click did not clear the brush")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelCoordinatesHoverAndBad(t *testing.T) {
|
||||
filters := map[string][2]float64{}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return ParallelCoordinates(c, &filters, pcAxes, pcRows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Monks"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
b := h.res.Element.Bounds()
|
||||
plot := ui.Rect{X: b.X + 36, Y: b.Y + 24, W: b.W - 72, H: 240 - 32}
|
||||
// Row 2 crosses this point between the first axes.
|
||||
tt.Move(plot.X+plot.W/8, plot.Y+plot.H*(1-0.53125))
|
||||
if h.res.Point != 2 {
|
||||
t.Fatalf("hover picked %d", h.res.Point)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a short row")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ParallelCoordinates(c, &filters, pcAxes, [][]float64{{1}}, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "x"}})
|
||||
}, 300, 300)
|
||||
}
|
||||
|
||||
func TestParallelCoordinatesSnapshot(t *testing.T) {
|
||||
snapshot(t, "parallel_coordinates", 520, 320, func(c *ui.Context) {
|
||||
filters := map[string][2]float64{"Hymns": {2, 9}}
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
ParallelCoordinates(c, &filters, pcAxes, pcRows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Monks"}, Names: pcNames})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestParallelCoordinatesKeyboardBrushAndDisabled(t *testing.T) {
|
||||
filters := map[string][2]float64{}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return ParallelCoordinates(c, &filters, pcAxes, pcRows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Monks"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Monks")
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
f, ok := filters["Age"]
|
||||
if !ok || math.Abs(f[0]-20.6) > 1e-9 || f[1] != 80 {
|
||||
t.Fatalf("keyboard brush %v", filters)
|
||||
}
|
||||
h.disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if filters["Age"] != f {
|
||||
t.Fatal("disabled keyboard brush")
|
||||
}
|
||||
h.disabled = false
|
||||
tt.Frame()
|
||||
tabTo(t, tt, "Monks")
|
||||
tt.Key(ui.Alt, ui.KeyDelete)
|
||||
if len(filters) != 0 {
|
||||
t.Fatalf("clear brush %v", filters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelCoordinatesLargeDataBounded(t *testing.T) {
|
||||
rows := make([][]float64, 100_000)
|
||||
for i := range rows {
|
||||
rows[i] = []float64{float64(i % 70), float64(i % 11), float64(i % 17)}
|
||||
}
|
||||
filters := map[string][2]float64{}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return ParallelCoordinates(c, &filters, pcAxes, rows, ParallelCoordinatesOptions{Frame: ChartFrame{Label: "Large parallel"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
shown := ui.Local(h.res.Element, "mujica.pc.visible", func() []int { return nil })
|
||||
if len(*shown) == 0 || len(*shown) > 480 {
|
||||
t.Fatalf("built %d rows", len(*shown))
|
||||
}
|
||||
tabTo(t, tt, "Large parallel")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if h.res.Point != 99_999 || len(*shown) > 481 {
|
||||
t.Fatalf("last row %d, built %d", h.res.Point, len(*shown))
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -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)
|
||||
@@ -105,7 +105,10 @@ func PieChart(c *ui.Context, slices []ChartSlice, opts PieChartOptions) ChartRes
|
||||
mx, my := internal.Polar(cx, cy, (r0+r1)/2+float32(chartBool(r0 == 0))*r1*0.12, (a0+a1)/2)
|
||||
ink := chartInk(k, keys[i].Color)
|
||||
internal.Marker(p, i, mx, my-8, 9, ink)
|
||||
chartText(p, share(i), mx, my+2, size, ink, 0.5, 0)
|
||||
// The percent shows where the ring is wide enough to hold it.
|
||||
if w, _ := p.MeasureText(0, ui.Span{Text: share(i), Size: size}); r1-r0 >= w+6 {
|
||||
chartText(p, share(i), mx, my+2, size, ink, 0.5, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
if opts.Donut {
|
||||
|
||||
@@ -69,7 +69,7 @@ func TestPieChartBad(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPieChartSnapshot(t *testing.T) {
|
||||
snapshot(t, "pie_chart", 640, 320, func(c *ui.Context) {
|
||||
snapshot(t, "pie_chart", 640, 360, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(16).Children(func() {
|
||||
ui.Column(c).Width(296).Children(func() {
|
||||
PieChart(c, pieSlices, PieChartOptions{Frame: ChartFrame{Label: "Pie", Height: 220}})
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
"math"
|
||||
)
|
||||
|
||||
// SparkBarOptions configure a SparkBar; Frame.Height 0 means 28 DIPs.
|
||||
type SparkBarOptions struct {
|
||||
Frame ChartFrame
|
||||
// BarWidth defaults to 6 DIPs; width fits the window.
|
||||
BarWidth float32
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// SparkBar draws signed inline bars and highlights the last.
|
||||
func SparkBar(c *ui.Context, values []float64, opts SparkBarOptions) ChartResult {
|
||||
if opts.BarWidth < 0 || math.IsNaN(float64(opts.BarWidth)) || math.IsInf(float64(opts.BarWidth), 0) {
|
||||
panic("mujica: SparkBar BarWidth must be ≥ 0")
|
||||
}
|
||||
bw := opts.BarWidth
|
||||
if bw == 0 {
|
||||
bw = 6
|
||||
}
|
||||
w, _ := c.Size()
|
||||
frame := opts.Frame
|
||||
if frame.Empty == "" {
|
||||
frame.Empty = msg(c, "spark_bar.empty")
|
||||
}
|
||||
return Sparkline(c, values, SparklineOptions{Frame: frame, Kind: SparklineBar, Width: min(max(28, float32(len(values))*(bw+1)+6), max(28, w-48)), Format: opts.Format})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
var sparkBarValues = []float64{3, -2, 4, 1, -3}
|
||||
|
||||
func TestSparkBarWidthAndColors(t *testing.T) {
|
||||
values := sparkBarValues
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "Alms"}, BarWidth: 10})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 300, 200)
|
||||
r, _ := tt.Find("Alms")
|
||||
if r.W != 5*11+6 || chartReadout(tt) != "-3" {
|
||||
t.Fatalf("box %v readout %q", r, chartReadout(tt))
|
||||
}
|
||||
k := theme.Light()
|
||||
at := func(i int, below bool) color.RGBA {
|
||||
x := r.X + 3 + (r.W-6)*float32(i)/4
|
||||
y := r.Y + r.H*0.3
|
||||
if below {
|
||||
y = r.Y + r.H*0.65
|
||||
}
|
||||
return color.RGBAModel.Convert(tt.Image().At(int(x), int(y))).(color.RGBA)
|
||||
}
|
||||
if at(4, true) != toRGBA(k.Danger) || at(0, false) != toRGBA(k.TextMuted) {
|
||||
t.Fatalf("last fall %v, first rise %v", at(4, true), at(0, false))
|
||||
}
|
||||
tabTo(t, tt, "Alms")
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if chartReadout(tt) != "3" || at(0, false) != toRGBA(internal.Palette(k)[0]) {
|
||||
t.Fatalf("read bar not highlighted: %q", chartReadout(tt))
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a negative width")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "x"}, BarWidth: -1})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestSparkBarSnapshot(t *testing.T) {
|
||||
snapshot(t, "spark_bar", 360, 80, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(24).Children(func() {
|
||||
SparkBar(c, sparkBarValues, SparkBarOptions{Frame: ChartFrame{Label: "Alms"}})
|
||||
SparkBar(c, []float64{1, 3, 2, 5, 4, 6, 8}, SparkBarOptions{Frame: ChartFrame{Label: "Bells"}, BarWidth: 8})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestSparkBarShrinkingAndDisabled(t *testing.T) {
|
||||
values := []float64{1, 2, 3, 4}
|
||||
disabled := false
|
||||
var r ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
r = SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "Bars"}})
|
||||
})
|
||||
}, 320, 100)
|
||||
tabTo(t, tt, "Bars")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
values = []float64{7}
|
||||
tt.Frame()
|
||||
if r.Point != 0 || !tt.HasText("7") {
|
||||
t.Fatalf("shortened data: point %d, text %q", r.Point, tt.Texts())
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if r.Point != -1 {
|
||||
t.Fatalf("disabled point %d", r.Point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparkBarLargeData(t *testing.T) {
|
||||
values := make([]float64, 100_000)
|
||||
for i := range values {
|
||||
values[i] = float64(i%17 - 8)
|
||||
}
|
||||
var built int
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res := SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "Large bars"}})
|
||||
memo := ui.Local(res.Element, "mujica.sparkline.data", func() sparkMemo { return sparkMemo{} })
|
||||
built = len(memo.indices)
|
||||
}, 320, 100)
|
||||
tt.Frame()
|
||||
r, _ := tt.Find("Large bars")
|
||||
if built == 0 || built > 4*int(r.W)+4 {
|
||||
t.Fatalf("built %d bars for width %v", built, r.W)
|
||||
}
|
||||
if r.W > 320 {
|
||||
t.Fatalf("unbounded plot width %v", r.W)
|
||||
}
|
||||
tabTo(t, tt, "Large bars")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if !tt.HasText("-3") {
|
||||
t.Fatalf("last value %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparkBarFiniteWidthAndStates(t *testing.T) {
|
||||
charts2MustPanic(t, func(c *ui.Context) {
|
||||
SparkBar(c, []float64{1}, SparkBarOptions{Frame: ChartFrame{Label: "Bars"}, BarWidth: float32(math.Inf(1))})
|
||||
})
|
||||
status := DataReady
|
||||
retries := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r := SparkBar(c, []float64{2, -1, 3}, SparkBarOptions{Frame: ChartFrame{Label: "Bars", Status: status, Height: 40}})
|
||||
if r.Retried() {
|
||||
retries++
|
||||
}
|
||||
}, 320, 100)
|
||||
ready, _ := tt.Find("Bars")
|
||||
status = DataFailed
|
||||
tt.Frame()
|
||||
if err := tt.Click("Retry"); err != nil || retries != 1 {
|
||||
t.Fatalf("retry %v count %d", err, retries)
|
||||
}
|
||||
status = DataLoading
|
||||
tt.Frame()
|
||||
if ready.W != 28 || ready.H != 40 {
|
||||
t.Fatalf("ready plot %v", ready)
|
||||
}
|
||||
status = DataReady
|
||||
tt.Frame()
|
||||
tt.Move(319, 99)
|
||||
tabTo(t, tt, "Bars")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if !tt.HasText("3") {
|
||||
t.Fatalf("restored data %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparkBarMinimumHitWidth(t *testing.T) {
|
||||
for _, values := range [][]float64{nil, {1}} {
|
||||
var result ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
result = SparkBar(c, values, SparkBarOptions{Frame: ChartFrame{Label: "Bars"}})
|
||||
}, 200, 100)
|
||||
tt.Frame()
|
||||
if result.Element.Bounds().W < 28 {
|
||||
t.Fatalf("hit width %v", result.Element.Bounds().W)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ type SparklineOptions struct {
|
||||
// Sparkline is an inline chart without axes.
|
||||
func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResult {
|
||||
f := opts.Frame
|
||||
if f.Label == "" || f.Height < 0 || opts.Width < 0 || opts.Kind > SparklineArea {
|
||||
if f.Label == "" || f.Height < 0 || opts.Width < 0 || opts.Kind < SparklineLine || opts.Kind > SparklineArea {
|
||||
panic("mujica: Sparkline needs a Label, sizes ≥ 0 and a known Kind")
|
||||
}
|
||||
if f.Height == 0 {
|
||||
@@ -46,16 +46,23 @@ func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResu
|
||||
u := Density(c).Unit()
|
||||
col := internal.Palette(k)[0]
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
last := -1
|
||||
for i, v := range values {
|
||||
if !math.IsNaN(v) {
|
||||
lo, hi, last = min(lo, v), max(hi, v), i
|
||||
res := ChartResult{Point: -1}
|
||||
res.Element = ui.Row(c).Gap(u * 2).AlignItems(ui.Center).MinWidth(0)
|
||||
memo := ui.Local(res.Element, "mujica.sparkline.data", func() sparkMemo { return sparkMemo{} })
|
||||
if id := chartID(values); memo.id != id {
|
||||
*memo = sparkMemo{id: id, lo: math.Inf(1), hi: math.Inf(-1), last: -1}
|
||||
for i, v := range values {
|
||||
if math.IsInf(v, 0) {
|
||||
panic("mujica: Sparkline values must be finite or NaN")
|
||||
}
|
||||
if !math.IsNaN(v) {
|
||||
memo.lo, memo.hi, memo.last = min(memo.lo, v), max(memo.hi, v), i
|
||||
}
|
||||
}
|
||||
}
|
||||
res := ChartResult{Point: -1}
|
||||
res.Element = ui.Row(c).Gap(u * 2).AlignItems(ui.Center)
|
||||
lo, hi, last := memo.lo, memo.hi, memo.last
|
||||
st := ui.Local(res.Element, "mujica.sparkline", func() chartState { return chartState{focus: -1, last: -1} })
|
||||
st.focus = min(st.focus, len(values)-1)
|
||||
res.Element.Children(func() {
|
||||
if f.Status == DataLoading || f.Status == DataFailed || last < 0 {
|
||||
box := ui.Row(c).Size(w, f.Height).AlignItems(ui.Center).Gap(u)
|
||||
@@ -115,22 +122,18 @@ func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResu
|
||||
active := -1
|
||||
if px, _, over := plot.PointerPosition(); over && !plot.IsDisabled() {
|
||||
active = max(0, min(n-1, int(math.Round(float64((px-3)/(w-6)*float32(max(n-1, 1)))))))
|
||||
} else if plot.Focused() {
|
||||
} else if plot.Focused() && !plot.IsDisabled() {
|
||||
active = st.focus
|
||||
}
|
||||
res.Point = active
|
||||
plot.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
s := ys(r)
|
||||
if opts.Kind == SparklineBar {
|
||||
bw := max((r.W-6)/float32(n)-1, 1)
|
||||
for i, v := range values {
|
||||
if math.IsNaN(v) {
|
||||
continue
|
||||
}
|
||||
a, b := s.Map(0), s.Map(v)
|
||||
cx := x(r, i)
|
||||
p.Fill(ui.Rect{X: cx - bw/2, Y: min(a, b), W: bw, H: max(abs32(a-b), 1)}, col, 0)
|
||||
if memo.width != r.W || memo.indices == nil {
|
||||
memo.width = r.W
|
||||
memo.indices = internal.Columns(n, func(i int) float32 { return x(r, i) }, func(i int) float64 { return values[i] })
|
||||
}
|
||||
sparkBars(p, r, values, memo.indices, func(i int) float32 { return x(r, i) }, s, last, active, k)
|
||||
} else {
|
||||
chartRuns(values, func(i int) float32 { return x(r, i) }, func(idx []int) {
|
||||
var line ui.Path
|
||||
@@ -168,3 +171,34 @@ func Sparkline(c *ui.Context, values []float64, opts SparklineOptions) ChartResu
|
||||
st.last = res.Point
|
||||
return res
|
||||
}
|
||||
|
||||
type sparkMemo struct {
|
||||
id string
|
||||
lo, hi float64
|
||||
last int
|
||||
width float32
|
||||
indices []int
|
||||
}
|
||||
|
||||
// sparkBars highlights the last and active bars.
|
||||
func sparkBars(p *ui.Painter, r ui.Rect, values []float64, indices []int, x func(int) float32, s internal.Linear, last, active int, k theme.Tokens) {
|
||||
n := len(values)
|
||||
bw := max((r.W-6)/float32(n)-1, 1)
|
||||
zero := s.Map(0)
|
||||
internal.Rule(p, r.X, r.X+r.W, zero, k.Border)
|
||||
for _, i := range indices {
|
||||
v := values[i]
|
||||
if math.IsNaN(v) {
|
||||
continue
|
||||
}
|
||||
col := k.TextMuted
|
||||
if i == last || i == active {
|
||||
col = internal.Palette(k)[0]
|
||||
if v < 0 {
|
||||
col = k.Danger
|
||||
}
|
||||
}
|
||||
b := s.Map(v)
|
||||
p.Fill(ui.Rect{X: x(i) - bw/2, Y: min(zero, b), W: bw, H: max(abs32(zero-b), 1)}, col, 0)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 45 KiB |