WIP charts2 parts
No files matched your search
@@ -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,176 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChartNoteKind is what a ChartNote draws.
|
||||
type ChartNoteKind int
|
||||
|
||||
const (
|
||||
// NoteHLine is a horizontal line at Y value Value.
|
||||
NoteHLine ChartNoteKind = iota
|
||||
// NoteVLine is a vertical line at X value Value.
|
||||
NoteVLine
|
||||
// NoteBand shades X values Value to End.
|
||||
NoteBand
|
||||
// NoteText writes Text at X Value, Y End.
|
||||
NoteText
|
||||
)
|
||||
|
||||
// ChartNote is one annotation; Draggable lets the pointer move it.
|
||||
type ChartNote struct {
|
||||
Kind ChartNoteKind
|
||||
Value, End float64
|
||||
Text string
|
||||
Draggable bool
|
||||
}
|
||||
|
||||
// ChartAnnotationOptions configure a ChartAnnotation; both axes are required.
|
||||
type ChartAnnotationOptions struct {
|
||||
X, Y *ChartAxisPart
|
||||
}
|
||||
|
||||
// ChartAnnotationPart is notes ready to draw, and to drag.
|
||||
type ChartAnnotationPart struct {
|
||||
notes []ChartNote
|
||||
opts ChartAnnotationOptions
|
||||
k theme.Tokens
|
||||
size float32
|
||||
}
|
||||
|
||||
// ChartAnnotation marks lines, bands and text on a plot; dragging writes into notes.
|
||||
func ChartAnnotation(c *ui.Context, notes []ChartNote, opts ChartAnnotationOptions) ChartAnnotationPart {
|
||||
if opts.X == nil || opts.Y == nil {
|
||||
panic("mujica: ChartAnnotation needs X and Y axes")
|
||||
}
|
||||
for _, n := range notes {
|
||||
if n.Kind > NoteText || n.Kind == NoteBand && n.End <= n.Value || n.Kind == NoteText && n.Text == "" {
|
||||
panic("mujica: ChartAnnotation note needs a known kind, a band End above Value, and text")
|
||||
}
|
||||
}
|
||||
return ChartAnnotationPart{notes: notes, opts: opts, k: Tokens(c), size: FontSize(c, theme.CaptionSize)}
|
||||
}
|
||||
|
||||
// Draw paints bands first, then lines and text, inside plot.
|
||||
func (a ChartAnnotationPart) Draw(p *ui.Painter, plot ui.Rect) {
|
||||
k, x, y := a.k, a.opts.X, a.opts.Y
|
||||
for _, n := range a.notes {
|
||||
if n.Kind == NoteBand {
|
||||
x0, x1 := x.Map(plot, n.Value), x.Map(plot, n.End)
|
||||
p.Fill(ui.Rect{X: x0, Y: plot.Y, W: x1 - x0, H: plot.H}, k.Info.Alpha(0.12), 0)
|
||||
chartText(p, n.Text, x0+4, plot.Y+4, a.size, k.Text, 0, 0)
|
||||
}
|
||||
}
|
||||
for _, n := range a.notes {
|
||||
switch n.Kind {
|
||||
case NoteHLine:
|
||||
at := internal.Snap(p, y.Map(plot, n.Value))
|
||||
chartDash(p, plot.X, at, plot.X+plot.W, at, k.Text)
|
||||
chartText(p, chartNoteLabel(n, y), plot.X+plot.W-4, at-3, a.size, k.Text, 1, 1)
|
||||
if n.Draggable {
|
||||
p.FillPath(internal.Diamond(internal.Snap(p, plot.X+8), at, 11), k.AccentText)
|
||||
}
|
||||
case NoteVLine:
|
||||
at := internal.Snap(p, x.Map(plot, n.Value))
|
||||
chartDash(p, at, plot.Y, at, plot.Y+plot.H, k.Text)
|
||||
chartText(p, chartNoteLabel(n, x), at+4, plot.Y+4, a.size, k.Text, 0, 0)
|
||||
if n.Draggable {
|
||||
p.FillPath(internal.Diamond(at, internal.Snap(p, plot.Y+plot.H-8), 11), k.AccentText)
|
||||
}
|
||||
case NoteText:
|
||||
px, py := x.Map(plot, n.Value), y.Map(plot, n.End)
|
||||
p.FillPath(internal.Diamond(internal.Snap(p, px), internal.Snap(p, py), 8), k.Text)
|
||||
chartText(p, n.Text, px+8, py, a.size, k.Text, 0, 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// chartDash draws a dashed horizontal or vertical line.
|
||||
func chartDash(p *ui.Painter, x0, y0, x1, y1 float32, col ui.Color) {
|
||||
const on, off = 6, 4
|
||||
if y0 == y1 {
|
||||
for x := x0; x < x1; x += on + off {
|
||||
p.Line(x, y0, min(x+on, x1), y0, 1.5, col)
|
||||
}
|
||||
return
|
||||
}
|
||||
for y := y0; y < y1; y += on + off {
|
||||
p.Line(x0, y, x0, min(y+on, y1), 1.5, col)
|
||||
}
|
||||
}
|
||||
|
||||
// chartNoteLabel is a line's text, or its value when it has none.
|
||||
func chartNoteLabel(n ChartNote, a *ChartAxisPart) string {
|
||||
if n.Text != "" {
|
||||
return n.Text + " " + a.Label(n.Value)
|
||||
}
|
||||
return a.Label(n.Value)
|
||||
}
|
||||
|
||||
type noteDrag struct {
|
||||
on bool
|
||||
note int
|
||||
}
|
||||
|
||||
// Drag moves the draggable note under the pointer while plot is dragged; r is plot's box.
|
||||
func (a ChartAnnotationPart) Drag(plotElement *ui.Element, plot ui.Rect) bool {
|
||||
st := ui.Local(plotElement, "mujica.notes.drag", func() noteDrag { return noteDrag{} })
|
||||
dx, dy, held := plotElement.Dragged()
|
||||
if !held || plotElement.IsDisabled() {
|
||||
*st = noteDrag{}
|
||||
return false
|
||||
}
|
||||
px, py, _ := plotElement.PointerPosition()
|
||||
x, y := a.opts.X, a.opts.Y
|
||||
if !st.on {
|
||||
*st = noteDrag{on: true, note: -1}
|
||||
for i, n := range a.notes {
|
||||
if !n.Draggable {
|
||||
continue
|
||||
}
|
||||
var d float32 = 1e9
|
||||
switch n.Kind {
|
||||
case NoteHLine:
|
||||
d = abs32(y.Map(plot, n.Value) - (py - dy))
|
||||
case NoteVLine:
|
||||
d = abs32(x.Map(plot, n.Value) - (px - dx))
|
||||
case NoteBand:
|
||||
if x0, x1 := x.Map(plot, n.Value), x.Map(plot, n.End); px-dx >= x0 && px-dx <= x1 {
|
||||
d = 0
|
||||
}
|
||||
case NoteText:
|
||||
d = max(abs32(x.Map(plot, n.Value)-(px-dx)), abs32(y.Map(plot, n.End)-(py-dy)))
|
||||
}
|
||||
if d <= 6 {
|
||||
st.note = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if st.note < 0 || dx == 0 && dy == 0 {
|
||||
return false
|
||||
}
|
||||
n := &a.notes[st.note]
|
||||
shift := func(ax *ChartAxisPart, v float64, at, d float32) float64 {
|
||||
return v + ax.Invert(plot, at) - ax.Invert(plot, at-d)
|
||||
}
|
||||
switch n.Kind {
|
||||
case NoteHLine:
|
||||
n.Value = shift(y, n.Value, py, dy)
|
||||
case NoteVLine:
|
||||
n.Value = shift(x, n.Value, px, dx)
|
||||
case NoteBand:
|
||||
w := n.End - n.Value
|
||||
n.Value = shift(x, n.Value, px, dx)
|
||||
n.End = n.Value + w
|
||||
case NoteText:
|
||||
n.Value, n.End = shift(x, n.Value, px, dx), shift(y, n.End, py, dy)
|
||||
}
|
||||
slog.Debug("mujica: chart_annotation drag", "note", st.note, "value", n.Value)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestChartAnnotationDrag(t *testing.T) {
|
||||
notes := []ChartNote{
|
||||
{Kind: NoteHLine, Value: 50, Text: "Goal", Draggable: true},
|
||||
{Kind: NoteVLine, Value: 3},
|
||||
{Kind: NoteBand, Value: 6, End: 8, Text: "Lent", Draggable: true},
|
||||
{Kind: NoteText, Value: 1, End: 90, Text: "Feast"},
|
||||
}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult { return partsChart(c, notes, false) }}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
b := h.res.Element.Bounds()
|
||||
plot := chartInset(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: 240}, 52, 26)
|
||||
yAt := func(v float32) float32 { return plot.Y + plot.H*(1-v/100) }
|
||||
xAt := func(v float32) float32 { return plot.X + plot.W*v/10 }
|
||||
// Drag the Goal line up by a fifth of the plot: 50 becomes 70.
|
||||
tt.Press(xAt(4), yAt(50))
|
||||
tt.Move(xAt(4), yAt(60))
|
||||
tt.Move(xAt(4), yAt(70))
|
||||
tt.Release(xAt(4), yAt(70))
|
||||
if math.Abs(notes[0].Value-70) > 0.5 {
|
||||
t.Fatalf("Goal at %v", notes[0].Value)
|
||||
}
|
||||
// The band moves as a whole and keeps its width.
|
||||
tt.Press(xAt(7), yAt(20))
|
||||
tt.Move(xAt(8), yAt(20))
|
||||
tt.Release(xAt(8), yAt(20))
|
||||
if math.Abs(notes[2].Value-7) > 0.1 || math.Abs(notes[2].End-notes[2].Value-2) > 1e-9 {
|
||||
t.Fatalf("band %v..%v", notes[2].Value, notes[2].End)
|
||||
}
|
||||
// The fixed line stays.
|
||||
tt.Press(xAt(3), yAt(20))
|
||||
tt.Move(xAt(5), yAt(20))
|
||||
tt.Release(xAt(5), yAt(20))
|
||||
if notes[1].Value != 3 {
|
||||
t.Fatalf("fixed line moved to %v", notes[1].Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartAnnotationDisabledAndBad(t *testing.T) {
|
||||
notes := []ChartNote{{Kind: NoteHLine, Value: 50, Draggable: true}}
|
||||
h := &chartHarness{disabled: true, build: func(c *ui.Context) ChartResult { return partsChart(c, notes, false) }}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
b := h.res.Element.Bounds()
|
||||
plot := chartInset(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: 240}, 52, 26)
|
||||
tt.Press(plot.X+100, plot.Y+plot.H/2)
|
||||
tt.Move(plot.X+100, plot.Y+20)
|
||||
tt.Release(plot.X+100, plot.Y+20)
|
||||
if notes[0].Value != 50 {
|
||||
t.Fatalf("disabled chart dragged a note to %v", notes[0].Value)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for an empty band")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
partsChart(c, []ChartNote{{Kind: NoteBand, Value: 2, End: 2}}, false)
|
||||
}, 300, 300)
|
||||
}
|
||||
|
||||
func TestChartAnnotationSnapshot(t *testing.T) {
|
||||
snapshot(t, "chart_annotation", 520, 340, func(c *ui.Context) {
|
||||
notes := []ChartNote{
|
||||
{Kind: NoteHLine, Value: 65, Text: "Goal", Draggable: true},
|
||||
{Kind: NoteVLine, Value: 3, Text: "Advent"},
|
||||
{Kind: NoteBand, Value: 6, End: 8, Text: "Lent"},
|
||||
{Kind: NoteText, Value: 1, End: 90, Text: "Feast"},
|
||||
}
|
||||
ui.Column(c).Padding(16).Children(func() { partsChart(c, notes, false) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChartAxisKind is how an axis maps values.
|
||||
type ChartAxisKind int
|
||||
|
||||
const (
|
||||
AxisLinear ChartAxisKind = iota
|
||||
// AxisLog maps powers of ten evenly; Min must be above zero.
|
||||
AxisLog
|
||||
// AxisTime maps From..To; values are Unix seconds.
|
||||
AxisTime
|
||||
// AxisCategory gives each category a band; values are indices.
|
||||
AxisCategory
|
||||
)
|
||||
|
||||
// ChartSide is where an axis runs along the plot.
|
||||
type ChartSide int
|
||||
|
||||
const (
|
||||
SideBottom ChartSide = iota
|
||||
SideLeft
|
||||
SideRight
|
||||
SideTop
|
||||
)
|
||||
|
||||
// ChartAxisOptions configure a ChartAxis.
|
||||
type ChartAxisOptions struct {
|
||||
Kind ChartAxisKind
|
||||
Side ChartSide
|
||||
// Min and Max span a linear or log axis.
|
||||
Min, Max float64
|
||||
// From and To span a time axis.
|
||||
From, To time.Time
|
||||
Categories []string
|
||||
// Ticks fixes the tick values; nil picks them.
|
||||
Ticks []float64
|
||||
// Format writes tick and readout labels; nil picks one per kind.
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// ChartAxisPart is an axis ready to map values and draw itself in a plot.
|
||||
type ChartAxisPart struct {
|
||||
opts ChartAxisOptions
|
||||
lo, hi float64
|
||||
ticks []float64
|
||||
step float64
|
||||
layout string
|
||||
k theme.Tokens
|
||||
size float32
|
||||
}
|
||||
|
||||
// ChartAxis resolves an axis: its domain, ticks and labels.
|
||||
func ChartAxis(c *ui.Context, opts ChartAxisOptions) ChartAxisPart {
|
||||
a := ChartAxisPart{opts: opts, k: Tokens(c), size: FontSize(c, theme.CaptionSize)}
|
||||
switch opts.Kind {
|
||||
case AxisLinear, AxisLog:
|
||||
a.lo, a.hi = opts.Min, opts.Max
|
||||
if !(a.hi > a.lo) || opts.Kind == AxisLog && a.lo <= 0 {
|
||||
panic("mujica: ChartAxis needs Min < Max, and Min > 0 for a log axis")
|
||||
}
|
||||
case AxisTime:
|
||||
if !opts.To.After(opts.From) {
|
||||
panic("mujica: ChartAxis time axis needs From before To")
|
||||
}
|
||||
a.lo, a.hi = chartSeconds(opts.From), chartSeconds(opts.To)
|
||||
case AxisCategory:
|
||||
if len(opts.Categories) == 0 {
|
||||
panic("mujica: ChartAxis category axis needs Categories")
|
||||
}
|
||||
a.lo, a.hi = -0.5, float64(len(opts.Categories))-0.5
|
||||
default:
|
||||
panic("mujica: unknown ChartAxisKind")
|
||||
}
|
||||
if opts.Side > SideTop {
|
||||
panic("mujica: unknown ChartSide")
|
||||
}
|
||||
a.ticks = opts.Ticks
|
||||
if a.ticks == nil {
|
||||
a.ticks = a.autoTicks()
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func chartSeconds(t time.Time) float64 { return float64(t.UnixNano()) / 1e9 }
|
||||
|
||||
// autoTicks picks round ticks inside the domain.
|
||||
func (a *ChartAxisPart) autoTicks() []float64 {
|
||||
var out []float64
|
||||
switch a.opts.Kind {
|
||||
case AxisLog:
|
||||
// Powers of ten, with 2 and 5 between them on short spans.
|
||||
mants := []float64{1}
|
||||
if a.hi/a.lo < 1000 {
|
||||
mants = []float64{1, 2, 5}
|
||||
}
|
||||
for e := math.Floor(math.Log10(a.lo)); math.Pow(10, e) <= a.hi*(1+1e-9); e++ {
|
||||
for _, m := range mants {
|
||||
if v := m * math.Pow(10, e); v >= a.lo*(1-1e-9) && v <= a.hi*(1+1e-9) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
case AxisTime:
|
||||
ts, layout := internal.TimeTicks(a.opts.From, a.opts.To, 5)
|
||||
a.layout = layout
|
||||
for _, t := range ts {
|
||||
out = append(out, chartSeconds(t))
|
||||
}
|
||||
case AxisCategory:
|
||||
for i := range a.opts.Categories {
|
||||
out = append(out, float64(i))
|
||||
}
|
||||
default:
|
||||
lo, hi, step := internal.Nice(a.lo, a.hi, 5)
|
||||
a.step = step
|
||||
for _, t := range internal.Ticks(lo, hi, step) {
|
||||
if t >= a.lo-step*1e-9 && t <= a.hi+step*1e-9 {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// horizontal reports whether the axis runs across the plot.
|
||||
func (a ChartAxisPart) horizontal() bool {
|
||||
return a.opts.Side == SideBottom || a.opts.Side == SideTop
|
||||
}
|
||||
|
||||
// fraction is how far along the axis v lies, 0 to 1.
|
||||
func (a ChartAxisPart) fraction(v float64) float64 {
|
||||
if a.opts.Kind == AxisLog {
|
||||
return (math.Log10(v) - math.Log10(a.lo)) / (math.Log10(a.hi) - math.Log10(a.lo))
|
||||
}
|
||||
return (v - a.lo) / (a.hi - a.lo)
|
||||
}
|
||||
|
||||
// Map returns the pixel of v along the axis inside plot.
|
||||
func (a ChartAxisPart) Map(plot ui.Rect, v float64) float32 {
|
||||
f := float32(a.fraction(v))
|
||||
if a.horizontal() {
|
||||
return plot.X + f*plot.W
|
||||
}
|
||||
return plot.Y + plot.H - f*plot.H
|
||||
}
|
||||
|
||||
// Invert returns the value at pixel px along the axis inside plot.
|
||||
func (a ChartAxisPart) Invert(plot ui.Rect, px float32) float64 {
|
||||
f := float64((px - plot.X) / plot.W)
|
||||
if !a.horizontal() {
|
||||
f = float64((plot.Y + plot.H - px) / plot.H)
|
||||
}
|
||||
if a.opts.Kind == AxisLog {
|
||||
return math.Pow(10, math.Log10(a.lo)+f*(math.Log10(a.hi)-math.Log10(a.lo)))
|
||||
}
|
||||
return a.lo + f*(a.hi-a.lo)
|
||||
}
|
||||
|
||||
// Ticks returns the tick values.
|
||||
func (a ChartAxisPart) Ticks() []float64 { return a.ticks }
|
||||
|
||||
// Label writes v as the axis does.
|
||||
func (a ChartAxisPart) Label(v float64) string {
|
||||
switch {
|
||||
case a.opts.Format != nil:
|
||||
return a.opts.Format(v)
|
||||
case a.opts.Kind == AxisCategory:
|
||||
return a.opts.Categories[max(0, min(len(a.opts.Categories)-1, int(math.Round(v))))]
|
||||
case a.opts.Kind == AxisTime:
|
||||
layout := a.layout
|
||||
if layout == "" {
|
||||
layout = "2006-01-02 15:04"
|
||||
}
|
||||
return chartTime(v).Format(layout)
|
||||
case a.opts.Kind == AxisLog:
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case a.step > 0:
|
||||
return internal.FormatTick(v, a.step)
|
||||
}
|
||||
return strconv.FormatFloat(v, 'g', 6, 64)
|
||||
}
|
||||
|
||||
// readout writes v one decimal finer than ticks, for crosshair labels.
|
||||
func (a ChartAxisPart) readout(v float64) string {
|
||||
switch {
|
||||
case a.opts.Format != nil, a.opts.Kind == AxisCategory, a.opts.Kind == AxisTime:
|
||||
return a.Label(v)
|
||||
case a.opts.Kind == AxisLog:
|
||||
return strconv.FormatFloat(v, 'g', 3, 64)
|
||||
case a.step > 0:
|
||||
return internal.FormatTick(v, a.step/10)
|
||||
}
|
||||
return strconv.FormatFloat(v, 'g', 6, 64)
|
||||
}
|
||||
|
||||
// Draw paints the axis line and tick labels on its side of plot.
|
||||
func (a ChartAxisPart) Draw(p *ui.Painter, plot ui.Rect) {
|
||||
pos := make([]float32, len(a.ticks))
|
||||
labels := make([]string, len(a.ticks))
|
||||
for i, t := range a.ticks {
|
||||
pos[i], labels[i] = a.Map(plot, t), a.Label(t)
|
||||
}
|
||||
k := a.k
|
||||
switch a.opts.Side {
|
||||
case SideBottom:
|
||||
internal.Rule(p, plot.X, plot.X+plot.W, plot.Y+plot.H, k.ControlBorder)
|
||||
internal.LabelsX(p, plot, plot.Y+plot.H+6, pos, labels, false, a.size, k)
|
||||
case SideTop:
|
||||
internal.Rule(p, plot.X, plot.X+plot.W, plot.Y, k.ControlBorder)
|
||||
internal.LabelsX(p, plot, plot.Y-6, pos, labels, true, a.size, k)
|
||||
case SideLeft:
|
||||
internal.RuleV(p, plot.X, plot.Y, plot.Y+plot.H, k.ControlBorder)
|
||||
internal.LabelsY(p, plot.X-6, pos, labels, false, a.size, k)
|
||||
case SideRight:
|
||||
internal.RuleV(p, plot.X+plot.W, plot.Y, plot.Y+plot.H, k.ControlBorder)
|
||||
internal.LabelsY(p, plot.X+plot.W+6, pos, labels, true, a.size, k)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
func axisIn(opts ChartAxisOptions) (a ChartAxisPart) {
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); a = ChartAxis(c, opts) }, 100, 100)
|
||||
return a
|
||||
}
|
||||
|
||||
func TestChartAxisLinearAndLog(t *testing.T) {
|
||||
plot := ui.Rect{X: 10, Y: 0, W: 100, H: 50}
|
||||
lin := axisIn(ChartAxisOptions{Min: 0, Max: 97})
|
||||
if fmt.Sprint(lin.Ticks()) != "[0 20 40 60 80]" || lin.Map(plot, 48.5) != 60 || lin.Label(40) != "40" {
|
||||
t.Fatalf("linear %v %v", lin.Ticks(), lin.Map(plot, 48.5))
|
||||
}
|
||||
if v := lin.Invert(plot, 60); math.Abs(v-48.5) > 1e-9 {
|
||||
t.Fatalf("invert %v", v)
|
||||
}
|
||||
log := axisIn(ChartAxisOptions{Kind: AxisLog, Side: SideLeft, Min: 1, Max: 10000})
|
||||
if fmt.Sprint(log.Ticks()) != "[1 10 100 1000 10000]" || log.Map(plot, 100) != 25 || log.Label(1000) != "1000" {
|
||||
t.Fatalf("log %v %v", log.Ticks(), log.Map(plot, 100))
|
||||
}
|
||||
if short := axisIn(ChartAxisOptions{Kind: AxisLog, Min: 1, Max: 50}); fmt.Sprint(short.Ticks()) != "[1 2 5 10 20 50]" {
|
||||
t.Fatalf("short log %v", short.Ticks())
|
||||
}
|
||||
fixed := axisIn(ChartAxisOptions{Min: 0, Max: 1, Ticks: []float64{0, 0.25, 1}, Format: func(v float64) string { return fmt.Sprintf("%.0f%%", v*100) }})
|
||||
if fmt.Sprint(fixed.Ticks()) != "[0 0.25 1]" || fixed.Label(0.25) != "25%" {
|
||||
t.Fatalf("fixed %v", fixed.Ticks())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartAxisTimeAndCategory(t *testing.T) {
|
||||
t0 := time.Date(2026, 10, 5, 9, 7, 0, 0, time.UTC)
|
||||
tm := axisIn(ChartAxisOptions{Kind: AxisTime, From: t0, To: t0.Add(2 * time.Hour)})
|
||||
if len(tm.Ticks()) == 0 || tm.Label(tm.Ticks()[0]) != "09:30" {
|
||||
t.Fatalf("time ticks %v", tm.Ticks())
|
||||
}
|
||||
cat := axisIn(ChartAxisOptions{Kind: AxisCategory, Categories: []string{"Nave", "Choir", "Crypt", "Bells"}})
|
||||
plot := ui.Rect{W: 400, H: 10}
|
||||
if cat.Map(plot, 0) != 50 || cat.Map(plot, 3) != 350 || cat.Label(cat.Invert(plot, 260)) != "Crypt" {
|
||||
t.Fatalf("category %v %v", cat.Map(plot, 0), cat.Label(cat.Invert(plot, 260)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartAxisBad(t *testing.T) {
|
||||
for name, o := range map[string]ChartAxisOptions{
|
||||
"empty": {},
|
||||
"log zero": {Kind: AxisLog, Min: 0, Max: 10},
|
||||
"time": {Kind: AxisTime},
|
||||
"category": {Kind: AxisCategory},
|
||||
"side": {Min: 0, Max: 1, Side: 9},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
axisIn(o)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// partsChart composes the public parts into a custom chart over three points.
|
||||
func partsChart(c *ui.Context, notes []ChartNote, crosshair bool) ChartResult {
|
||||
pts := [][2]float64{{2, 30}, {5, 80}, {8, 55}}
|
||||
x := ChartAxis(c, ChartAxisOptions{Min: 0, Max: 10})
|
||||
y := ChartAxis(c, ChartAxisOptions{Side: SideLeft, Min: 0, Max: 100})
|
||||
grid := ChartGrid(c, ChartGridOptions{X: &x, Y: &y, Minor: 2})
|
||||
hair := ChartCrosshair(c, ChartCrosshairOptions{X: &x, Y: &y})
|
||||
notesPart := ChartAnnotation(c, notes, ChartAnnotationOptions{X: &x, Y: &y})
|
||||
plotOf := func(r ui.Rect) ui.Rect { return chartInset(r, 52, 26) }
|
||||
ink := Tokens(c).AccentText
|
||||
at := func(r ui.Rect, i int) (float32, float32) {
|
||||
plot := plotOf(r)
|
||||
return x.Map(plot, pts[i][0]), y.Map(plot, pts[i][1])
|
||||
}
|
||||
return Chart(c, ChartFrame{Label: "Parts"}, ChartSpec{
|
||||
Marks: len(pts),
|
||||
Readout: func(i int) string { return fmt.Sprintf("x %s · y %s", x.Label(pts[i][0]), y.Label(pts[i][1])) },
|
||||
Pick: func(r ui.Rect, px, py float32) int {
|
||||
return hair.Snap(len(pts), func(i int) (float32, float32) { return at(r, i) }, px, py)
|
||||
},
|
||||
Anchor: at,
|
||||
Input: func(plot *ui.Element, r ui.Rect) { notesPart.Drag(plot, plotOf(r)) },
|
||||
Draw: func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot := plotOf(r)
|
||||
grid.Draw(p, plot)
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
notesPart.Draw(p, plot)
|
||||
for i := range pts {
|
||||
px, py := at(r, i)
|
||||
internal.Marker(p, 0, px, py, 10, ink)
|
||||
}
|
||||
if crosshair && active >= 0 {
|
||||
px, py := at(r, active)
|
||||
hair.Draw(p, plot, px, py)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestChartAxisSnapshot(t *testing.T) {
|
||||
t0 := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
snapshot(t, "chart_axis", 560, 300, func(c *ui.Context) {
|
||||
axes := []ChartAxisPart{
|
||||
ChartAxis(c, ChartAxisOptions{Min: 0, Max: 97}),
|
||||
ChartAxis(c, ChartAxisOptions{Side: SideLeft, Kind: AxisLog, Min: 1, Max: 10000}),
|
||||
ChartAxis(c, ChartAxisOptions{Side: SideTop, Kind: AxisTime, From: t0, To: t0.Add(3 * time.Hour)}),
|
||||
ChartAxis(c, ChartAxisOptions{Side: SideRight, Kind: AxisCategory, Categories: []string{"Nave", "Choir", "Crypt"}}),
|
||||
}
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 60, Y: r.Y + 40, W: r.W - 140, H: r.H - 80}
|
||||
for _, a := range axes {
|
||||
a.Draw(p, plot)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChartBrushOptions configure a ChartBrush.
|
||||
type ChartBrushOptions struct {
|
||||
// Label names the brush for assistive technology; required.
|
||||
Label string
|
||||
// Min and Max span the track; Min < Max.
|
||||
Min, Max float64
|
||||
// Step is the grid both ends snap to; 0 means a hundredth of the span.
|
||||
Step float64
|
||||
// Values draws a preview line across the track.
|
||||
Values []float64
|
||||
// Height is the track height; 0 means 48 DIPs.
|
||||
Height float32
|
||||
Format func(v float64) string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type brushDrag struct {
|
||||
part int
|
||||
grab float64
|
||||
start float64
|
||||
}
|
||||
|
||||
// ChartBrush selects the range *value on a track: drag the window or its ends, or use the keys.
|
||||
func ChartBrush(c *ui.Context, value *[2]float64, opts ChartBrushOptions) ChoiceResult {
|
||||
if opts.Label == "" || !(opts.Max > opts.Min) || opts.Step < 0 || opts.Height < 0 {
|
||||
panic("mujica: ChartBrush needs a Label, Min < Max, Step ≥ 0 and Height ≥ 0")
|
||||
}
|
||||
step := opts.Step
|
||||
if step == 0 {
|
||||
step = (opts.Max - opts.Min) / 100
|
||||
}
|
||||
spec := numberInputResolve(NumberInputOptions{Min: opts.Min, Max: opts.Max, Step: step})
|
||||
if value[0] < opts.Min || value[1] > opts.Max || !(value[1] > value[0]) {
|
||||
panic("mujica: ChartBrush value must be an ascending range inside Min..Max")
|
||||
}
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 48
|
||||
}
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
val := func(v float64) string { return chartValue(c, v, opts.Format) }
|
||||
snap := func(v float64) float64 {
|
||||
return max(spec.lo, min(spec.hi, spec.round(spec.lo+math.Round((v-spec.lo)/spec.step)*spec.step)))
|
||||
}
|
||||
before := *value
|
||||
var res ChoiceResult
|
||||
res.Element = ui.Column(c).Gap(u).MinWidth(0).Disabled(opts.Disabled)
|
||||
res.Element.Children(func() {
|
||||
track := ui.Box(c).Height(h).FillWidth().MinWidth(0).Radius(theme.ControlRadius).Background(k.SurfaceHover)
|
||||
b := track.Bounds()
|
||||
internal.Remeasure(c, track, b.W, b.H)
|
||||
at := func(v float64) float32 { return float32((v - opts.Min) / (opts.Max - opts.Min)) * b.W }
|
||||
from := func(px float32) float64 { return opts.Min + float64(px/max(b.W, 1))*(opts.Max-opts.Min) }
|
||||
drag := ui.Local(track, "mujica.brush.drag", func() brushDrag { return brushDrag{part: -1} })
|
||||
set := func(lo, hi float64) {
|
||||
lo, hi = snap(lo), snap(hi)
|
||||
if hi-lo >= spec.step-1e-9 {
|
||||
*value = [2]float64{lo, hi}
|
||||
}
|
||||
}
|
||||
track.Draw(func(p *ui.Painter, r ui.Rect) { brushPaint(p, r, *value, opts, k) })
|
||||
track.Children(func() {
|
||||
x0, x1 := at(value[0]), at(value[1])
|
||||
window := ui.Box(c).Absolute().Left(x0).Top(0).Width(max(x1-x0, 1)).Height(h).Focusable().
|
||||
Role(ui.RoleGroup).Label(fmt.Sprintf(msg(c, "chart_brush.window"), opts.Label, val(value[0]), val(value[1])))
|
||||
internal.Ring(window, k.Focus, theme.ControlRadius)
|
||||
ends := [2]*ui.Element{}
|
||||
for i, x := range []float32{x0, x1} {
|
||||
end := ui.Box(c).Absolute().Left(x - theme.MinHitSize/2).Top(0).Size(theme.MinHitSize, h).Focusable().
|
||||
Role(ui.RoleSlider).Label(fmt.Sprintf(msg(c, []string{"chart_brush.start", "chart_brush.end"}[i]), val(value[i])))
|
||||
internal.Ring(end, k.Focus, theme.ControlRadius)
|
||||
end.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
cx := internal.Snap(p, r.X+r.W/2)
|
||||
p.Fill(ui.Rect{X: cx - 3, Y: r.Y + r.H/2 - 10, W: 6, H: 20}, k.Text, 2)
|
||||
})
|
||||
ends[i] = end
|
||||
}
|
||||
if res.Element.IsDisabled() {
|
||||
return
|
||||
}
|
||||
for i, end := range ends {
|
||||
v, ok := brushKey(end, value[i], spec)
|
||||
switch {
|
||||
case !ok:
|
||||
case i == 0:
|
||||
set(min(v, value[1]-spec.step), value[1])
|
||||
default:
|
||||
set(value[0], max(v, value[0]+spec.step))
|
||||
}
|
||||
}
|
||||
w := value[1] - value[0]
|
||||
switch {
|
||||
case window.Shortcut(0, ui.KeyLeft):
|
||||
lo := max(spec.lo, spec.stepBy(value[0], -1))
|
||||
set(lo, lo+w)
|
||||
case window.Shortcut(0, ui.KeyRight):
|
||||
hi := min(spec.hi, spec.stepBy(value[1], 1))
|
||||
set(hi-w, hi)
|
||||
case window.Shortcut(0, ui.KeyHome):
|
||||
set(spec.lo, spec.lo+w)
|
||||
case window.Shortcut(0, ui.KeyEnd):
|
||||
set(spec.hi-w, spec.hi)
|
||||
}
|
||||
brushPointer(track, window, ends, drag, value, from, set)
|
||||
})
|
||||
ui.Text(c, val(value[0])+" – "+val(value[1])).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleStatus)
|
||||
})
|
||||
res.changed = *value != before
|
||||
if res.changed {
|
||||
slog.Debug("mujica: chart_brush range", "from", value[0], "to", value[1])
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// brushKey steps an end for the arrow, Page, Home and End keys.
|
||||
func brushKey(e *ui.Element, v float64, spec numberInputSpec) (float64, bool) {
|
||||
switch {
|
||||
case e.Shortcut(0, ui.KeyLeft), e.Shortcut(0, ui.KeyDown):
|
||||
return spec.stepBy(v, -1), true
|
||||
case e.Shortcut(0, ui.KeyRight), e.Shortcut(0, ui.KeyUp):
|
||||
return spec.stepBy(v, 1), true
|
||||
case e.Shortcut(0, ui.KeyPageDown):
|
||||
return spec.stepBy(v, -10), true
|
||||
case e.Shortcut(0, ui.KeyPageUp):
|
||||
return spec.stepBy(v, 10), true
|
||||
case e.Shortcut(0, ui.KeyHome):
|
||||
return spec.lo, true
|
||||
case e.Shortcut(0, ui.KeyEnd):
|
||||
return spec.hi, true
|
||||
}
|
||||
return v, false
|
||||
}
|
||||
|
||||
// brushPointer drags an end, the window, or a new range across the track.
|
||||
func brushPointer(track, window *ui.Element, ends [2]*ui.Element, drag *brushDrag, value *[2]float64, from func(float32) float64, set func(lo, hi float64)) {
|
||||
px, _, _ := track.PointerPosition()
|
||||
at := from(px)
|
||||
parts := []*ui.Element{ends[0], ends[1], window, track}
|
||||
held := -1
|
||||
for i, e := range parts {
|
||||
if _, _, ok := e.Dragged(); ok && held < 0 {
|
||||
held = i
|
||||
}
|
||||
}
|
||||
if held < 0 {
|
||||
drag.part = -1
|
||||
return
|
||||
}
|
||||
if drag.part < 0 {
|
||||
*drag = brushDrag{part: held, grab: at - value[0], start: at}
|
||||
}
|
||||
w := value[1] - value[0]
|
||||
switch drag.part {
|
||||
case 0:
|
||||
set(min(at, value[1]), value[1])
|
||||
case 1:
|
||||
set(value[0], max(at, value[0]))
|
||||
case 2:
|
||||
set(at-drag.grab, at-drag.grab+w)
|
||||
default:
|
||||
set(min(drag.start, at), max(drag.start, at))
|
||||
}
|
||||
}
|
||||
|
||||
// brushPaint draws the preview line, dims outside the range and frames the window.
|
||||
func brushPaint(p *ui.Painter, r ui.Rect, value [2]float64, opts ChartBrushOptions, k theme.Tokens) {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for _, v := range opts.Values {
|
||||
if !math.IsNaN(v) {
|
||||
lo, hi = min(lo, v), max(hi, v)
|
||||
}
|
||||
}
|
||||
if n := len(opts.Values); n > 1 && hi > lo {
|
||||
x := func(i int) float32 { return r.X + r.W*float32(i)/float32(n-1) }
|
||||
chartRuns(opts.Values, x, func(idx []int) {
|
||||
var line ui.Path
|
||||
for j, i := range idx {
|
||||
y := r.Y + 4 + (r.H-8)*float32(1-(opts.Values[i]-lo)/(hi-lo))
|
||||
if j == 0 {
|
||||
line.MoveTo(x(i), y)
|
||||
} else {
|
||||
line.LineTo(x(i), y)
|
||||
}
|
||||
}
|
||||
p.StrokePath(&line, 1.25, k.TextMuted)
|
||||
})
|
||||
}
|
||||
x0 := r.X + r.W*float32((value[0]-opts.Min)/(opts.Max-opts.Min))
|
||||
x1 := r.X + r.W*float32((value[1]-opts.Min)/(opts.Max-opts.Min))
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y, W: x0 - r.X, H: r.H}, k.Background.Alpha(0.6), 0)
|
||||
p.Fill(ui.Rect{X: x1, Y: r.Y, W: r.X + r.W - x1, H: r.H}, k.Background.Alpha(0.6), 0)
|
||||
p.Stroke(ui.Rect{X: x0, Y: r.Y, W: x1 - x0, H: r.H}, k.AccentText, 0, 1.5)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChartCrosshairOptions configure a ChartCrosshair.
|
||||
type ChartCrosshairOptions struct {
|
||||
// X draws the vertical line and its label on X's side; Y the horizontal one.
|
||||
X, Y *ChartAxisPart
|
||||
// Reach is how far, in DIPs, Snap looks for a point; 0 means 24.
|
||||
Reach float32
|
||||
}
|
||||
|
||||
// ChartCrosshairPart is a crosshair ready to snap to points and draw.
|
||||
type ChartCrosshairPart struct {
|
||||
opts ChartCrosshairOptions
|
||||
k theme.Tokens
|
||||
size float32
|
||||
pad float32
|
||||
}
|
||||
|
||||
// ChartCrosshair crosses lines through a point with value labels on the axes.
|
||||
func ChartCrosshair(c *ui.Context, opts ChartCrosshairOptions) ChartCrosshairPart {
|
||||
if opts.X == nil && opts.Y == nil || opts.Reach < 0 {
|
||||
panic("mujica: ChartCrosshair needs an axis and Reach ≥ 0")
|
||||
}
|
||||
if opts.Reach == 0 {
|
||||
opts.Reach = 24
|
||||
}
|
||||
return ChartCrosshairPart{opts: opts, k: Tokens(c), size: FontSize(c, theme.CaptionSize), pad: Density(c).Unit()}
|
||||
}
|
||||
|
||||
// Snap returns the point nearest (x, y) within Reach, or -1.
|
||||
func (h ChartCrosshairPart) Snap(n int, at func(i int) (x, y float32), x, y float32) int {
|
||||
return internal.Nearest(n, at, x, y, h.opts.Reach, false)
|
||||
}
|
||||
|
||||
// Draw paints the lines through (x, y) inside plot and each axis's value label.
|
||||
func (h ChartCrosshairPart) Draw(p *ui.Painter, plot ui.Rect, x, y float32) {
|
||||
k := h.k
|
||||
if a := h.opts.X; a != nil {
|
||||
xx := internal.Snap(p, x)
|
||||
p.Line(xx, plot.Y, xx, plot.Y+plot.H, 1, k.TextMuted)
|
||||
at := plot.Y + plot.H
|
||||
if a.opts.Side == SideTop {
|
||||
at = plot.Y
|
||||
}
|
||||
h.pill(p, a.readout(a.Invert(plot, x)), x, at, true, a.opts.Side == SideTop)
|
||||
}
|
||||
if a := h.opts.Y; a != nil {
|
||||
yy := internal.Snap(p, y)
|
||||
p.Line(plot.X, yy, plot.X+plot.W, yy, 1, k.TextMuted)
|
||||
at := plot.X
|
||||
if a.opts.Side == SideRight {
|
||||
at = plot.X + plot.W
|
||||
}
|
||||
h.pill(p, a.readout(a.Invert(plot, y)), at, y, false, a.opts.Side == SideRight)
|
||||
}
|
||||
}
|
||||
|
||||
// pill writes s on a Text-colored tag at the axis, outside the plot.
|
||||
func (h ChartCrosshairPart) pill(p *ui.Painter, s string, x, y float32, across, far bool) {
|
||||
w, ht := p.MeasureText(0, ui.Span{Text: s, Size: h.size})
|
||||
box := ui.Rect{W: w + 2*h.pad, H: ht + h.pad}
|
||||
switch {
|
||||
case across && far:
|
||||
box.X, box.Y = x-box.W/2, y-box.H-2
|
||||
case across:
|
||||
box.X, box.Y = x-box.W/2, y+2
|
||||
case far:
|
||||
box.X, box.Y = x+2, y-box.H/2
|
||||
default:
|
||||
box.X, box.Y = x-box.W-2, y-box.H/2
|
||||
}
|
||||
p.Fill(box, h.k.Text, theme.ControlRadius)
|
||||
p.Text(box.X+h.pad, box.Y+h.pad/2, s, h.size, h.k.Surface)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
func TestChartCrosshairSnapsAndFollowsKeys(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult { return partsChart(c, nil, true) }}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
b := h.res.Element.Bounds()
|
||||
plot := chartInset(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: 240}, 52, 26)
|
||||
// Point 1 is (5, 80); the pointer rests 10 DIPs off it.
|
||||
px, py := plot.X+plot.W/2, plot.Y+plot.H*0.2
|
||||
tt.Move(px+10, py-6)
|
||||
if h.res.Point != 1 || chartReadout(tt) != "x 5 · y 80" {
|
||||
t.Fatalf("snap: point %d %q", h.res.Point, chartReadout(tt))
|
||||
}
|
||||
if !crosshairAt(tt, int(px), int(plot.Y+plot.H-2)) {
|
||||
t.Fatal("vertical line does not pass through the snapped point")
|
||||
}
|
||||
tt.Move(px+60, py+60)
|
||||
if h.res.Point != -1 {
|
||||
t.Fatalf("snapped from too far: %d", h.res.Point)
|
||||
}
|
||||
tabTo(t, tt, "Parts")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if !crosshairAt(tt, int(plot.X+plot.W*0.8), int(plot.Y+plot.H-2)) {
|
||||
t.Fatal("keyboard point has no crosshair")
|
||||
}
|
||||
}
|
||||
|
||||
// crosshairAt reports a TextMuted pixel at (x, y), where the crosshair runs.
|
||||
func crosshairAt(tt *ui.Tester, x, y int) bool {
|
||||
want := toRGBA(theme.Light().TextMuted)
|
||||
for dx := -1; dx <= 1; dx++ {
|
||||
if color.RGBAModel.Convert(tt.Image().At(x+dx, y)).(color.RGBA) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestChartCrosshairBad(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic without axes")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); ChartCrosshair(c, ChartCrosshairOptions{}) }, 100, 100)
|
||||
}
|
||||
|
||||
func TestChartCrosshairSnapshot(t *testing.T) {
|
||||
snapshot(t, "chart_crosshair", 420, 240, func(c *ui.Context) {
|
||||
x := ChartAxis(c, ChartAxisOptions{Min: 0, Max: 10})
|
||||
y := ChartAxis(c, ChartAxisOptions{Side: SideLeft, Min: 0, Max: 100})
|
||||
hair := ChartCrosshair(c, ChartCrosshairOptions{X: &x, Y: &y})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 48, Y: r.Y + 16, W: r.W - 64, H: r.H - 48}
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
hair.Draw(p, plot, x.Map(plot, 6.5), y.Map(plot, 42))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// ChartExportOptions configure a ChartExport.
|
||||
type ChartExportOptions struct {
|
||||
// Width and Height are the image size in DIPs; required.
|
||||
Width, Height int
|
||||
// Scale is device pixels per DIP; 0 means 2.
|
||||
Scale float32
|
||||
}
|
||||
|
||||
// ChartExport renders chart off screen in this window's theme and returns PNG bytes; it writes no file.
|
||||
func ChartExport(c *ui.Context, chart func(c *ui.Context), opts ChartExportOptions) ([]byte, error) {
|
||||
if chart == nil || opts.Width <= 0 || opts.Height <= 0 || opts.Scale < 0 {
|
||||
panic("mujica: ChartExport needs a chart, a positive size and Scale ≥ 0")
|
||||
}
|
||||
scale := opts.Scale
|
||||
if scale == 0 {
|
||||
scale = 2
|
||||
}
|
||||
w := current(c)
|
||||
settings := Settings{Mode: Light, Density: w.density, Locale: w.locale, Messages: w.messages}
|
||||
if w.tokens.Dark {
|
||||
settings.Mode = Dark
|
||||
}
|
||||
tokens := w.tokens
|
||||
img := ui.Render(func(c *ui.Context) {
|
||||
if settings.Mode == Dark {
|
||||
settings.Dark = &tokens
|
||||
} else {
|
||||
settings.Light = &tokens
|
||||
}
|
||||
Use(c, settings)
|
||||
ui.Column(c).Fill().Background(tokens.Surface).Padding(Density(c).Unit() * 4).Children(func() { chart(c) })
|
||||
}, opts.Width, opts.Height, scale)
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Debug("mujica: chart_export png", "bytes", buf.Len(), "width", opts.Width, "height", opts.Height)
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
@@ -44,25 +44,39 @@ func (r ChartResult) ValueChanged() bool { return r.value }
|
||||
// Toggled returns the legend series switched in this frame.
|
||||
func (r ChartResult) Toggled() (string, bool) { return r.toggled, r.toggled != "" }
|
||||
|
||||
// chartSpec is one chart's drawing and behavior.
|
||||
type chartSpec struct {
|
||||
// n counts the marks keys step through; 0 is empty.
|
||||
n int
|
||||
keys []ChartLegendItem
|
||||
// toggle lets legend entries hide and show series.
|
||||
toggle bool
|
||||
draw func(p *ui.Painter, r ui.Rect, active int)
|
||||
// pick returns the mark at (x, y), or -1.
|
||||
pick func(r ui.Rect, x, y float32) int
|
||||
readout func(i int) string
|
||||
// move steps from mark i; nil steps in order.
|
||||
move func(i int, key ui.Key) int
|
||||
// activate runs on Enter, Space or a click.
|
||||
activate func(i int)
|
||||
// anchor places mark i's tooltip in plot space; nil uses the plot's top middle.
|
||||
anchor func(r ui.Rect, i int) (x, y float32)
|
||||
// tip writes mark i's tooltip; nil splits the readout.
|
||||
tip func(i int) []ChartTooltipLine
|
||||
// ChartSpec is a chart's marks and drawing, for Chart; r is the chart box in each callback.
|
||||
type ChartSpec struct {
|
||||
// Marks counts what the keys step through; 0 shows the empty state.
|
||||
Marks int
|
||||
// Legend lists series keys above the plot.
|
||||
Legend []ChartLegendItem
|
||||
// Hidden, when set, makes legend keys check boxes switching series off.
|
||||
Hidden *map[string]bool
|
||||
// Draw paints the plot; active is the read mark, or -1.
|
||||
Draw func(p *ui.Painter, r ui.Rect, active int)
|
||||
// Pick returns the mark at (x, y), or -1; required.
|
||||
Pick func(r ui.Rect, x, y float32) int
|
||||
// Readout describes mark i; it is shown, announced and the tooltip's default.
|
||||
Readout func(i int) string
|
||||
// Move steps from mark i for an arrow key; nil steps in order.
|
||||
Move func(i int, key ui.Key) int
|
||||
// Activate runs on Enter, Space or a click on a mark.
|
||||
Activate func(i int)
|
||||
// Anchor places mark i's tooltip for the keyboard; nil uses the top middle.
|
||||
Anchor func(r ui.Rect, i int) (x, y float32)
|
||||
// Tooltip writes mark i's tooltip; nil splits Readout at " · ".
|
||||
Tooltip func(i int) []ChartTooltipLine
|
||||
// Input takes the plot's other input, as a ChartAnnotation's drags.
|
||||
Input func(plot *ui.Element, r ui.Rect)
|
||||
}
|
||||
|
||||
// Chart frames a custom chart: legend, focus, keys, readout, tooltip and empty states.
|
||||
func Chart(c *ui.Context, frame ChartFrame, spec ChartSpec) ChartResult {
|
||||
if spec.Marks < 0 || spec.Draw == nil || spec.Pick == nil || spec.Readout == nil {
|
||||
panic("mujica: Chart needs Marks ≥ 0, Draw, Pick and Readout")
|
||||
}
|
||||
res, _ := newChart(c, frame).build(spec)
|
||||
return res
|
||||
}
|
||||
|
||||
type chartState struct {
|
||||
@@ -92,25 +106,29 @@ func newChart(c *ui.Context, frame ChartFrame) *chart {
|
||||
return ch
|
||||
}
|
||||
|
||||
// toggles returns the legend's hidden set when series can be switched off.
|
||||
func (ch *chart) toggles(on bool) *map[string]bool {
|
||||
if on {
|
||||
return &ch.st.hidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hidden reports whether the legend switched series name off.
|
||||
func (ch *chart) hidden(name string) bool { return ch.st.hidden[name] }
|
||||
|
||||
// build lays out legend, plot and readout; returns the plot.
|
||||
func (ch *chart) build(spec chartSpec) (ChartResult, *ui.Element) {
|
||||
func (ch *chart) build(spec ChartSpec) (ChartResult, *ui.Element) {
|
||||
c, k, st := ch.c, Tokens(ch.c), ch.st
|
||||
u := Density(c).Unit()
|
||||
var plot *ui.Element
|
||||
ch.res.Element.Children(func() {
|
||||
if len(spec.keys) > 0 {
|
||||
var hidden *map[string]bool
|
||||
if spec.toggle {
|
||||
hidden = &st.hidden
|
||||
}
|
||||
if name, ok := ChartLegend(c, hidden, spec.keys, ChartLegendOptions{}).Toggled(); ok {
|
||||
if len(spec.Legend) > 0 {
|
||||
if name, ok := ChartLegend(c, spec.Hidden, spec.Legend, ChartLegendOptions{}).Toggled(); ok {
|
||||
ch.res.toggled = name
|
||||
}
|
||||
}
|
||||
if ch.frame.Status == DataLoading || ch.frame.Status == DataFailed || spec.n == 0 {
|
||||
if ch.frame.Status == DataLoading || ch.frame.Status == DataFailed || spec.Marks == 0 {
|
||||
st.focus = -1
|
||||
ch.res.retry = ChartEmptyState(c, ch.frame.Status, ChartEmptyStateOptions{Height: ch.frame.Height, Error: ch.frame.Error, Empty: ch.frame.Empty}).Changed()
|
||||
ch.res.Point = -1
|
||||
@@ -120,34 +138,38 @@ func (ch *chart) build(spec chartSpec) (ChartResult, *ui.Element) {
|
||||
box := ui.Box(c).Height(ch.frame.Height).FillWidth().MinWidth(0)
|
||||
plot = box.Focusable().Label(ch.frame.Label).Role(ui.RoleGroup)
|
||||
internal.Ring(plot, k.Focus, theme.ControlRadius)
|
||||
st.focus = min(st.focus, spec.n-1)
|
||||
st.focus = min(st.focus, spec.Marks-1)
|
||||
ch.input(plot, spec)
|
||||
if spec.Input != nil && !plot.IsDisabled() {
|
||||
b := plot.Bounds()
|
||||
spec.Input(plot, ui.Rect{W: b.W, H: b.H})
|
||||
}
|
||||
active := -1
|
||||
b := plot.Bounds()
|
||||
local := ui.Rect{W: b.W, H: b.H}
|
||||
x, y, over := plot.PointerPosition()
|
||||
if over && !plot.IsDisabled() {
|
||||
active = spec.pick(local, x, y)
|
||||
active = spec.Pick(local, x, y)
|
||||
}
|
||||
if active < 0 && plot.Focused() {
|
||||
if active < 0 && plot.Focused() && st.focus >= 0 {
|
||||
active = st.focus
|
||||
x, y = local.W/2, 0
|
||||
if spec.anchor != nil {
|
||||
x, y = spec.anchor(local, active)
|
||||
if spec.Anchor != nil {
|
||||
x, y = spec.Anchor(local, active)
|
||||
}
|
||||
}
|
||||
ch.res.Point = active
|
||||
if active >= 0 && b.W > 0 {
|
||||
lines := chartTipLines(spec.readout(active))
|
||||
if spec.tip != nil {
|
||||
lines = spec.tip(active)
|
||||
lines := chartTipLines(spec.Readout(active))
|
||||
if spec.Tooltip != nil {
|
||||
lines = spec.Tooltip(active)
|
||||
}
|
||||
ChartTooltip(c, b.X+x, b.Y+y, lines)
|
||||
}
|
||||
plot.Draw(func(p *ui.Painter, r ui.Rect) { spec.draw(p, r, active) })
|
||||
plot.Draw(func(p *ui.Painter, r ui.Rect) { spec.Draw(p, r, active) })
|
||||
text, col := msg(c, "chart.hint"), k.TextMuted
|
||||
if active >= 0 {
|
||||
text, col = spec.readout(active), k.Text
|
||||
text, col = spec.Readout(active), k.Text
|
||||
}
|
||||
ui.Text(c, text).FontSize(FontSize(c, theme.CaptionSize)).TextColor(col).Role(ui.RoleStatus).SingleLine().Height(u * 5)
|
||||
})
|
||||
@@ -157,7 +179,7 @@ func (ch *chart) build(spec chartSpec) (ChartResult, *ui.Element) {
|
||||
}
|
||||
|
||||
// input moves the keyboard mark, and takes clicks on marks.
|
||||
func (ch *chart) input(plot *ui.Element, spec chartSpec) {
|
||||
func (ch *chart) input(plot *ui.Element, spec ChartSpec) {
|
||||
st := ch.st
|
||||
if plot.IsDisabled() {
|
||||
return
|
||||
@@ -170,19 +192,19 @@ func (ch *chart) input(plot *ui.Element, spec chartSpec) {
|
||||
switch {
|
||||
case st.focus < 0:
|
||||
to = 0
|
||||
case spec.move != nil:
|
||||
to = spec.move(st.focus, key)
|
||||
case spec.Move != nil:
|
||||
to = spec.Move(st.focus, key)
|
||||
case key == ui.KeyLeft || key == ui.KeyUp:
|
||||
to = max(st.focus-1, 0)
|
||||
default:
|
||||
to = min(st.focus+1, spec.n-1)
|
||||
to = min(st.focus+1, spec.Marks-1)
|
||||
}
|
||||
}
|
||||
if plot.Shortcut(0, ui.KeyHome) {
|
||||
to = 0
|
||||
}
|
||||
if plot.Shortcut(0, ui.KeyEnd) {
|
||||
to = spec.n - 1
|
||||
to = spec.Marks - 1
|
||||
}
|
||||
if plot.Shortcut(0, ui.KeyEscape) {
|
||||
to = -1
|
||||
@@ -190,7 +212,7 @@ func (ch *chart) input(plot *ui.Element, spec chartSpec) {
|
||||
if to != st.focus {
|
||||
st.focus = to
|
||||
if to >= 0 {
|
||||
ch.c.Announce(spec.readout(to))
|
||||
ch.c.Announce(spec.Readout(to))
|
||||
}
|
||||
slog.Debug("mujica: chart focus", "label", ch.frame.Label, "mark", to)
|
||||
}
|
||||
@@ -199,12 +221,12 @@ func (ch *chart) input(plot *ui.Element, spec chartSpec) {
|
||||
plot.Focus()
|
||||
x, y, _ := plot.PointerPosition()
|
||||
b := plot.Bounds()
|
||||
if i := spec.pick(ui.Rect{W: b.W, H: b.H}, x, y); i >= 0 {
|
||||
if i := spec.Pick(ui.Rect{W: b.W, H: b.H}, x, y); i >= 0 {
|
||||
st.focus, act = i, true
|
||||
}
|
||||
}
|
||||
if act && spec.activate != nil {
|
||||
spec.activate(st.focus)
|
||||
if act && spec.Activate != nil {
|
||||
spec.Activate(st.focus)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
)
|
||||
|
||||
// ChartGridOptions configure a ChartGrid.
|
||||
type ChartGridOptions struct {
|
||||
// X and Y draw lines at their ticks; either may be nil.
|
||||
X, Y *ChartAxisPart
|
||||
// Minor splits each major interval into this many parts with fainter lines.
|
||||
Minor int
|
||||
}
|
||||
|
||||
// ChartGridPart is a grid ready to draw in a plot.
|
||||
type ChartGridPart struct {
|
||||
opts ChartGridOptions
|
||||
major, minor ui.Color
|
||||
}
|
||||
|
||||
// ChartGrid draws pixel-aligned lines at the axes' ticks, and minor lines between them.
|
||||
func ChartGrid(c *ui.Context, opts ChartGridOptions) ChartGridPart {
|
||||
if opts.Minor < 0 || opts.X == nil && opts.Y == nil {
|
||||
panic("mujica: ChartGrid needs an axis and Minor ≥ 0")
|
||||
}
|
||||
for _, a := range []*ChartAxisPart{opts.X, opts.Y} {
|
||||
if a != nil && a.opts.Kind == AxisCategory && opts.Minor > 0 {
|
||||
panic("mujica: ChartGrid has no minor lines on a category axis")
|
||||
}
|
||||
}
|
||||
k := Tokens(c)
|
||||
return ChartGridPart{opts: opts, major: k.Border, minor: k.Border.Mix(k.Surface, 0.55)}
|
||||
}
|
||||
|
||||
// Draw paints minor lines, then major ones, inside plot.
|
||||
func (g ChartGridPart) Draw(p *ui.Painter, plot ui.Rect) {
|
||||
for _, a := range []*ChartAxisPart{g.opts.X, g.opts.Y} {
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
line := func(v float64, col ui.Color) {
|
||||
at := a.Map(plot, v)
|
||||
if a.horizontal() {
|
||||
internal.RuleV(p, at, plot.Y, plot.Y+plot.H, col)
|
||||
} else {
|
||||
internal.Rule(p, plot.X, plot.X+plot.W, at, col)
|
||||
}
|
||||
}
|
||||
ticks := a.Ticks()
|
||||
for i := 1; i < len(ticks) && g.opts.Minor > 0; i++ {
|
||||
for j := 1; j < g.opts.Minor; j++ {
|
||||
f := float64(j) / float64(g.opts.Minor)
|
||||
v := ticks[i-1] + (ticks[i]-ticks[i-1])*f
|
||||
if a.opts.Kind == AxisLog {
|
||||
v = math.Pow(10, math.Log10(ticks[i-1])+(math.Log10(ticks[i])-math.Log10(ticks[i-1]))*f)
|
||||
}
|
||||
line(v, g.minor)
|
||||
}
|
||||
}
|
||||
for _, t := range ticks {
|
||||
line(t, g.major)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
func TestChartGridPixelAlignedWithMinor(t *testing.T) {
|
||||
for _, scale := range []float32{1, 2} {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
y := ChartAxis(c, ChartAxisOptions{Side: SideLeft, Min: 0, Max: 100, Ticks: []float64{0, 50, 100}})
|
||||
grid := ChartGrid(c, ChartGridOptions{Y: &y, Minor: 2})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) { grid.Draw(p, ui.Rect{X: 0, Y: 10.3, W: 200, H: 100}) })
|
||||
}, 200, 130)
|
||||
tt.SetScale(scale)
|
||||
img := tt.Image()
|
||||
major, minor := toRGBA(theme.Light().Border), toRGBA(theme.Light().Border.Mix(theme.Light().Surface, 0.55))
|
||||
count := map[color.RGBA]int{}
|
||||
for py := range img.Bounds().Dy() {
|
||||
c := color.RGBAModel.Convert(img.At(100, py)).(color.RGBA)
|
||||
count[c]++
|
||||
}
|
||||
// Three major and two minor lines, each exactly one device pixel per DIP, no blur.
|
||||
if count[major] != 3*int(scale) || count[minor] != 2*int(scale) {
|
||||
t.Fatalf("scale %v: %d major and %d minor pixel rows", scale, count[major], count[minor])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartGridBad(t *testing.T) {
|
||||
for name, build := range map[string]func(c *ui.Context){
|
||||
"no axis": func(c *ui.Context) { ChartGrid(c, ChartGridOptions{}) },
|
||||
"category minor": func(c *ui.Context) {
|
||||
a := ChartAxis(c, ChartAxisOptions{Kind: AxisCategory, Categories: []string{"a"}})
|
||||
ChartGrid(c, ChartGridOptions{X: &a, Minor: 2})
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); build(c) }, 100, 100)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartGridSnapshot(t *testing.T) {
|
||||
snapshot(t, "chart_grid", 420, 240, func(c *ui.Context) {
|
||||
x := ChartAxis(c, ChartAxisOptions{Min: 0, Max: 10})
|
||||
y := ChartAxis(c, ChartAxisOptions{Side: SideLeft, Kind: AxisLog, Min: 1, Max: 1000})
|
||||
grid := ChartGrid(c, ChartGridOptions{X: &x, Y: &y, Minor: 4})
|
||||
ui.Box(c).Fill().Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
plot := ui.Rect{X: r.X + 48, Y: r.Y + 16, W: r.W - 64, H: r.H - 48}
|
||||
grid.Draw(p, plot)
|
||||
x.Draw(p, plot)
|
||||
y.Draw(p, plot)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func ChartLegend(c *ui.Context, hidden *map[string]bool, items []ChartLegendItem
|
||||
res.Element = ui.Row(c).Gap(u * 3).Wrap()
|
||||
orient := ui.Horizontal
|
||||
if opts.Vertical {
|
||||
res.Element.Column().Gap(u)
|
||||
res.Element.Column().Gap(u).AlignItems(ui.Start)
|
||||
orient = ui.Vertical
|
||||
}
|
||||
open := ui.Local(res.Element, "mujica.legend.open", func() bool { return false })
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
func legendItems() []ChartLegendItem {
|
||||
pal := internal.Palette(theme.Light())
|
||||
var out []ChartLegendItem
|
||||
for i, n := range []string{"Matins", "Lauds", "Prime", "Terce", "Sext"} {
|
||||
out = append(out, ChartLegendItem{Name: n, Color: pal[i], Shape: i})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestChartLegendToggleKeyboard(t *testing.T) {
|
||||
hidden := map[string]bool{}
|
||||
var toggled []string
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
if s, ok := ChartLegend(c, &hidden, legendItems(), ChartLegendOptions{}).Toggled(); ok {
|
||||
toggled = append(toggled, s)
|
||||
}
|
||||
}, 500, 200)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Matins") {
|
||||
t.Fatal("Tab did not enter the legend")
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if !hidden["Prime"] || len(toggled) != 1 || toggled[0] != "Prime" {
|
||||
t.Fatalf("arrows and Space: hidden %v toggled %v", hidden, toggled)
|
||||
}
|
||||
if err := tt.Click("Prime"); err != nil || hidden["Prime"] {
|
||||
t.Fatalf("click did not show Prime again: %v", hidden)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if tt.Focused("Lauds") || tt.Focused("Sext") {
|
||||
t.Fatal("legend entries are more than one Tab stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartLegendFoldAndStatic(t *testing.T) {
|
||||
vertical := true
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
ChartLegend(c, nil, legendItems(), ChartLegendOptions{Vertical: vertical, MaxItems: 2})
|
||||
}, 500, 400)
|
||||
if tt.HasText("Prime") || !tt.HasText("+3 more") {
|
||||
t.Fatalf("fold: %q", tt.Texts())
|
||||
}
|
||||
a, _ := tt.Find("Matins")
|
||||
b, _ := tt.Find("Lauds")
|
||||
if b.Y <= a.Y || b.X != a.X {
|
||||
t.Fatalf("vertical layout %v %v", a, b)
|
||||
}
|
||||
if err := tt.Click("+3 more"); err != nil || !tt.HasText("Sext") || !tt.HasText("Show fewer") {
|
||||
t.Fatalf("unfold: %v %q", err, tt.Texts())
|
||||
}
|
||||
if _, ok := tt.Find("Matins"); !ok || tt.Focused("Matins") {
|
||||
t.Fatal("static legend took focus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartLegendBad(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for duplicate names")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ChartLegend(c, nil, []ChartLegendItem{{Name: "a"}, {Name: "a"}}, ChartLegendOptions{})
|
||||
}, 100, 100)
|
||||
}
|
||||
|
||||
func TestChartLegendSnapshot(t *testing.T) {
|
||||
snapshot(t, "chart_legend", 520, 120, func(c *ui.Context) {
|
||||
hidden := map[string]bool{"Lauds": true}
|
||||
items := legendItems()
|
||||
for i := range items {
|
||||
items[i].Color = internal.Palette(Tokens(c))[i]
|
||||
}
|
||||
ui.Column(c).Padding(16).Gap(12).Children(func() {
|
||||
ChartLegend(c, &hidden, items, ChartLegendOptions{})
|
||||
ChartLegend(c, nil, items, ChartLegendOptions{MaxItems: 3})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
func TestChartTooltipFlipsAtEdges(t *testing.T) {
|
||||
var at [2]float32
|
||||
var tip ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
tip = ChartTooltip(c, at[0], at[1], []ChartTooltipLine{{Text: "Lauds"}, {Text: "Pews 5", Color: theme.Light().Info, Shape: 1}}).Bounds()
|
||||
}, 400, 300)
|
||||
at = [2]float32{50, 50}
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if tip.X != 62 || tip.Y != 62 || tip.W < 40 {
|
||||
t.Fatalf("tooltip at %v", tip)
|
||||
}
|
||||
at = [2]float32{395, 295}
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if tip.X+tip.W > 395-12+0.5 || tip.Y+tip.H > 295-12+0.5 {
|
||||
t.Fatalf("tooltip did not flip: %v", tip)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic without lines")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); ChartTooltip(c, 0, 0, nil) }, 100, 100)
|
||||
}
|
||||
|
||||
func TestChartTooltipFollowsFocusAndPointer(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "Chapel"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Chapel")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if !tooltipShown(tt) {
|
||||
t.Fatal("no tooltip for the keyboard point")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tooltipShown(tt) {
|
||||
t.Fatal("tooltip stayed after Escape")
|
||||
}
|
||||
b := h.res.Element.Bounds()
|
||||
tt.Move(b.X+b.W/2, b.Y+b.H/2)
|
||||
if !tooltipShown(tt) {
|
||||
t.Fatal("no tooltip under the pointer")
|
||||
}
|
||||
}
|
||||
|
||||
// tooltipShown looks for a run of Surface pixels too long for a marker ring.
|
||||
func tooltipShown(tt *ui.Tester) bool {
|
||||
img := tt.Image()
|
||||
want := toRGBA(theme.Light().Surface)
|
||||
r := img.Bounds()
|
||||
for y := r.Min.Y; y < r.Max.Y; y += 2 {
|
||||
run := 0
|
||||
for x := r.Min.X; x < r.Max.X; x++ {
|
||||
if color.RGBAModel.Convert(img.At(x, y)).(color.RGBA) == want {
|
||||
if run++; run >= 30 {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
run = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestChartTooltipSnapshot(t *testing.T) {
|
||||
snapshot(t, "chart_tooltip", 320, 140, func(c *ui.Context) {
|
||||
pal := internal.Palette(Tokens(c))
|
||||
ChartTooltip(c, 20, 20, []ChartTooltipLine{{Text: "Lauds"}, {Text: "Pews 5", Color: pal[0]}, {Text: "Candles 2", Color: pal[1], Shape: 1}})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -191,27 +191,53 @@ func Sector(cx, cy, r0, r1 float32, a0, a1 float64) *ui.Path {
|
||||
|
||||
// AxisY draws grid lines and tick labels left of plot.
|
||||
func AxisY(p *ui.Painter, plot ui.Rect, s Linear, ticks []float64, label func(float64) string, size float32, k theme.Tokens) {
|
||||
for _, t := range ticks {
|
||||
y := s.Map(t)
|
||||
Rule(p, plot.X, plot.X+plot.W, y, k.Border)
|
||||
txt := label(t)
|
||||
w, h := p.MeasureText(0, ui.Span{Text: txt, Size: size})
|
||||
p.Text(plot.X-w-6, y-h/2, txt, size, k.TextMuted)
|
||||
ys := make([]float32, len(ticks))
|
||||
labels := make([]string, len(ticks))
|
||||
for i, t := range ticks {
|
||||
ys[i], labels[i] = s.Map(t), label(t)
|
||||
Rule(p, plot.X, plot.X+plot.W, ys[i], k.Border)
|
||||
}
|
||||
LabelsY(p, plot.X-6, ys, labels, false, size, k)
|
||||
}
|
||||
|
||||
// AxisX draws a baseline and labels centered at xs.
|
||||
func AxisX(p *ui.Painter, plot ui.Rect, xs []float32, labels []string, size float32, k theme.Tokens) {
|
||||
Rule(p, plot.X, plot.X+plot.W, plot.Y+plot.H, k.ControlBorder)
|
||||
LabelsX(p, plot, plot.Y+plot.H+6, xs, labels, false, size, k)
|
||||
}
|
||||
|
||||
// RuleV draws a pixel-aligned 1 DIP vertical line at x.
|
||||
func RuleV(p *ui.Painter, x, y0, y1 float32, c ui.Color) {
|
||||
xx := ruleY(p, x)
|
||||
p.Line(xx, y0, xx, y1, 1, c)
|
||||
}
|
||||
|
||||
// LabelsY writes labels centered on ys, ending at x, or starting there if after.
|
||||
func LabelsY(p *ui.Painter, x float32, ys []float32, labels []string, after bool, size float32, k theme.Tokens) {
|
||||
for i, y := range ys {
|
||||
w, h := p.MeasureText(0, ui.Span{Text: labels[i], Size: size})
|
||||
at := x - w
|
||||
if after {
|
||||
at = x
|
||||
}
|
||||
p.Text(at, y-h/2, labels[i], size, k.TextMuted)
|
||||
}
|
||||
}
|
||||
|
||||
// LabelsX writes labels centered at xs from y, or ending at y if above; overlaps are skipped.
|
||||
func LabelsX(p *ui.Painter, plot ui.Rect, y float32, xs []float32, labels []string, above bool, size float32, k theme.Tokens) {
|
||||
right := float32(math.Inf(-1))
|
||||
for i, x := range xs {
|
||||
w, _ := p.MeasureText(0, ui.Span{Text: labels[i], Size: size})
|
||||
w, h := p.MeasureText(0, ui.Span{Text: labels[i], Size: size})
|
||||
at := min(max(x-w/2, plot.X-12), plot.X+plot.W+12-w)
|
||||
// Labels that would overlap the last one drawn are skipped.
|
||||
if at < right+6 {
|
||||
continue
|
||||
}
|
||||
p.Text(at, plot.Y+plot.H+6, labels[i], size, k.TextMuted)
|
||||
top := y
|
||||
if above {
|
||||
top = y - h
|
||||
}
|
||||
p.Text(at, top, labels[i], size, k.TextMuted)
|
||||
right = at + w
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,15 +86,15 @@ func chartLines(ch *chart, d chartLineData) ChartResult {
|
||||
plot := chartInset(r, 52, 26)
|
||||
return plot, d.ax.scale(plot), internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
|
||||
}
|
||||
spec := chartSpec{n: d.n, keys: keys, toggle: true}
|
||||
spec.pick = func(r ui.Rect, x, y float32) int {
|
||||
spec := ChartSpec{Marks: d.n, Legend: keys, Hidden: ch.toggles(true)}
|
||||
spec.Pick = func(r ui.Rect, x, y float32) int {
|
||||
plot, xs, _ := geom(r)
|
||||
if !(ui.Rect{X: plot.X - 6, Y: plot.Y, W: plot.W + 12, H: plot.H}).Contains(x, y) {
|
||||
return -1
|
||||
}
|
||||
return d.ax.nearest(xs.Invert(x))
|
||||
}
|
||||
spec.readout = func(i int) string {
|
||||
spec.Readout = func(i int) string {
|
||||
parts := []string{d.ax.label(i)}
|
||||
for _, s := range d.series {
|
||||
if !ch.hidden(s.Name) {
|
||||
@@ -103,7 +103,7 @@ func chartLines(ch *chart, d chartLineData) ChartResult {
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
spec.tip = func(i int) []ChartTooltipLine {
|
||||
spec.Tooltip = func(i int) []ChartTooltipLine {
|
||||
lines := []ChartTooltipLine{{Text: d.ax.label(i)}}
|
||||
for si, s := range d.series {
|
||||
if !ch.hidden(s.Name) {
|
||||
@@ -112,11 +112,11 @@ func chartLines(ch *chart, d chartLineData) ChartResult {
|
||||
}
|
||||
return lines
|
||||
}
|
||||
spec.anchor = func(r ui.Rect, i int) (float32, float32) {
|
||||
spec.Anchor = func(r ui.Rect, i int) (float32, float32) {
|
||||
plot, xs, _ := geom(r)
|
||||
return xs.Map(d.ax.vals[i]), plot.Y
|
||||
}
|
||||
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot, xs, ys := geom(r)
|
||||
internal.AxisY(p, plot, ys, ticks, func(v float64) string { return internal.FormatTick(v, step) }, size, k)
|
||||
tx, tl := d.ax.ticks(plot)
|
||||
|
||||
@@ -4,5 +4,8 @@ func init() {
|
||||
addMessages(map[string][2]string{
|
||||
"chart_legend.more": {"+%d more", "另有 %d 项"},
|
||||
"chart_legend.fewer": {"Show fewer", "收起"},
|
||||
"chart_brush.window": {"%s: %s to %s", "%s:%s 至 %s"},
|
||||
"chart_brush.start": {"Range start %s", "区间起点 %s"},
|
||||
"chart_brush.end": {"Range end %s", "区间终点 %s"},
|
||||
})
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
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: 6.6 KiB |
|
After Width: | Height: | Size: 6.5 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 |
@@ -46,15 +46,15 @@ func Treemap(c *ui.Context, path *[]string, root ChartNode, opts TreemapOptions)
|
||||
}
|
||||
ch := newChart(c, opts.Frame)
|
||||
tiles := func(r ui.Rect) []ui.Rect { return squarify(values, r) }
|
||||
spec := chartSpec{n: len(kids)}
|
||||
spec.readout = func(i int) string {
|
||||
spec := ChartSpec{Marks: len(kids)}
|
||||
spec.Readout = func(i int) string {
|
||||
s := fmt.Sprintf("%s · %s · %.1f%%", kids[i].Name, chartValue(c, values[i], opts.Format), 100*values[i]/sum)
|
||||
if len(kids[i].Children) > 0 {
|
||||
s += " · " + msg(c, "chart.opens")
|
||||
}
|
||||
return s
|
||||
}
|
||||
spec.pick = func(r ui.Rect, x, y float32) int {
|
||||
spec.Pick = func(r ui.Rect, x, y float32) int {
|
||||
for i, t := range tiles(r) {
|
||||
if t.Contains(x, y) {
|
||||
return i
|
||||
@@ -62,14 +62,14 @@ func Treemap(c *ui.Context, path *[]string, root ChartNode, opts TreemapOptions)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
spec.activate = func(i int) {
|
||||
spec.Activate = func(i int) {
|
||||
if len(kids[i].Children) > 0 {
|
||||
*path = append(slices.Clone(*path), kids[i].Name)
|
||||
res.value = true
|
||||
slog.Debug("mujica: treemap drill", "path", strings.Join(*path, "/"))
|
||||
}
|
||||
}
|
||||
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
for i, t := range tiles(r) {
|
||||
bg := pal[i%len(pal)]
|
||||
p.Fill(t, bg, 2)
|
||||
|
||||
@@ -69,21 +69,21 @@ func WaterfallChart(c *ui.Context, steps []WaterfallStep, opts WaterfallChartOpt
|
||||
return plot, internal.Band{N: len(steps), R0: plot.X, R1: plot.X + plot.W, Pad: 0.3},
|
||||
internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
|
||||
}
|
||||
spec := chartSpec{n: len(steps)}
|
||||
spec.readout = func(i int) string {
|
||||
spec := ChartSpec{Marks: len(steps)}
|
||||
spec.Readout = func(i int) string {
|
||||
if steps[i].Kind != WaterfallDelta {
|
||||
return steps[i].Label + " · " + val(to[i])
|
||||
}
|
||||
return steps[i].Label + " · " + signed(steps[i].Value) + " · " + msg(c, "waterfall_chart.running") + " " + val(to[i])
|
||||
}
|
||||
spec.pick = func(r ui.Rect, x, y float32) int {
|
||||
spec.Pick = func(r ui.Rect, x, y float32) int {
|
||||
plot, band, _ := geom(r)
|
||||
if !plot.Contains(x, y) {
|
||||
return -1
|
||||
}
|
||||
return min(len(steps)-1, int((x-plot.X)/band.Step()))
|
||||
}
|
||||
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot, band, ys := geom(r)
|
||||
internal.AxisY(p, plot, ys, internal.Ticks(lo, hi, step), func(v float64) string { return internal.FormatTick(v, step) }, size, k)
|
||||
var xs []float32
|
||||
|
||||