Split into category packages; module github.com/ZacharyZhang-NY/MujicaUI
This commit is contained in:
1822 files changed
+24568
-21314
No files matched your search
@@ -0,0 +1,74 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// AreaChartOptions configure an AreaChart.
|
||||
type AreaChartOptions struct {
|
||||
Frame ChartFrame
|
||||
// X places point i at ascending X[i]; nil means i.
|
||||
X []float64
|
||||
// Times makes a time axis; set X or Times.
|
||||
Times []time.Time
|
||||
// Stacked piles visible series on each other.
|
||||
Stacked bool
|
||||
// Baseline is where areas start; zero by default.
|
||||
Baseline float64
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// AreaChart fills series to the baseline, or stacked.
|
||||
func AreaChart(c *ui.Context, series []ChartSeries, opts AreaChartOptions) ChartResult {
|
||||
n := chartCheckSeries(series)
|
||||
ch := NewChart(c, opts.Frame)
|
||||
ax := ChartMemo(ch, "x", [3]string{ChartID(opts.X), ChartID(opts.Times), ChartID(series)}, func() ChartX { return NewChartX(n, opts.X, opts.Times) })
|
||||
deps := struct {
|
||||
id string
|
||||
stacked bool
|
||||
base float64
|
||||
}{chartSeriesID(ch, series), opts.Stacked, opts.Baseline}
|
||||
bands := ChartMemo(ch, "bands", deps, func() [2][][]float64 { return areaBands(series, ch.Hidden, opts.Stacked, opts.Baseline) })
|
||||
lo, hi, ok := ChartYRange(ch, series, false, bands[0])
|
||||
if !ok {
|
||||
n = 0
|
||||
}
|
||||
lo, hi = min(lo, opts.Baseline), max(hi, opts.Baseline)
|
||||
return chartLines(ch, chartLineData{series: series, tops: bands[0], bases: bands[1], ax: ax, lo: lo, hi: hi, n: n, format: opts.Format})
|
||||
}
|
||||
|
||||
// areaBands returns each series' top and bottom edges.
|
||||
func areaBands(series []ChartSeries, hidden func(string) bool, stacked bool, base float64) [2][][]float64 {
|
||||
var tops, bases [][]float64
|
||||
var run []float64
|
||||
if len(series) > 0 {
|
||||
run = make([]float64, len(series[0].Values))
|
||||
for i := range run {
|
||||
run[i] = base
|
||||
}
|
||||
}
|
||||
for _, s := range series {
|
||||
top, bot := make([]float64, len(s.Values)), make([]float64, len(s.Values))
|
||||
for i, v := range s.Values {
|
||||
bot[i], top[i] = base, v
|
||||
if stacked {
|
||||
bot[i], top[i] = run[i], run[i]+v
|
||||
}
|
||||
if stacked && !math.IsNaN(v) && !hidden(s.Name) {
|
||||
run[i] += v
|
||||
}
|
||||
}
|
||||
tops, bases = append(tops, top), append(bases, bot)
|
||||
}
|
||||
return [2][][]float64{tops, bases}
|
||||
}
|
||||
|
||||
func chartBool(b bool) float64 {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestAreaBands(t *testing.T) {
|
||||
series := []ChartSeries{{Name: "a", Values: []float64{1, math.NaN(), 3}}, {Name: "b", Values: []float64{2, 2, 2}}}
|
||||
none := func(string) bool { return false }
|
||||
st := areaBands(series, none, true, 10)
|
||||
if st[0][0][0] != 11 || st[1][1][0] != 11 || st[0][1][0] != 13 || !math.IsNaN(st[0][0][1]) || st[0][1][1] != 12 {
|
||||
t.Fatalf("stacked %v", st)
|
||||
}
|
||||
hideA := func(s string) bool { return s == "a" }
|
||||
st = areaBands(series, hideA, true, 0)
|
||||
if st[0][1][2] != 2 || st[1][1][2] != 0 {
|
||||
t.Fatalf("hidden series still stacked: %v", st)
|
||||
}
|
||||
flat := areaBands(series, none, false, -1)
|
||||
if flat[0][0][2] != 3 || flat[1][0][2] != -1 || flat[1][1][2] != -1 {
|
||||
t.Fatalf("baseline %v", flat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaChartStackedReadout(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return AreaChart(c, lineSeries(), AreaChartOptions{Frame: ChartFrame{Label: "Nave"}, Stacked: true, Baseline: 1})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Nave")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); got != "0 · Pews 3 · Candles 5" {
|
||||
t.Fatalf("readout shows the series values, not the stack: %q", got)
|
||||
}
|
||||
b := h.res.Element.Bounds()
|
||||
tt.Move(b.X+52+(b.W-64)*0.5, b.Y+b.H/2)
|
||||
if got := chartReadout(tt); got != "2 · Pews no value · Candles 6" || h.res.Point != 2 {
|
||||
t.Fatalf("hover %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAreaChartSnapshot(t *testing.T) {
|
||||
snapshot(t, "area_chart", 520, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
AreaChart(c, []ChartSeries{
|
||||
{Name: "Matins", Values: []float64{3, 4, 6, 5, 7, 8}},
|
||||
{Name: "Vespers", Values: []float64{2, 3, 2, 4, 3, 5}},
|
||||
}, AreaChartOptions{Frame: ChartFrame{Label: "Attendance"}, Stacked: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// BarChartOptions configure a BarChart.
|
||||
type BarChartOptions struct {
|
||||
Frame ChartFrame
|
||||
// Horizontal lays categories down the side and bars across.
|
||||
Horizontal bool
|
||||
// Stacked piles series into one bar per category.
|
||||
Stacked bool
|
||||
// Labels writes each bar's value at its end.
|
||||
Labels bool
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
type barMark struct{ cat, series int }
|
||||
|
||||
// BarChart draws grouped or stacked bars; NaN draws none.
|
||||
func BarChart(c *ui.Context, categories []string, series []ChartSeries, opts BarChartOptions) ChartResult {
|
||||
if n := chartCheckSeries(series); n != len(categories) && len(series) > 0 {
|
||||
panic("mujica: BarChart series length differs from categories")
|
||||
}
|
||||
ch := NewChart(c, opts.Frame)
|
||||
k := core.Tokens(c)
|
||||
pal := internal.Palette(k)
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
keys := make([]ChartLegendItem, len(series))
|
||||
var vis []int
|
||||
for i, s := range series {
|
||||
keys[i] = ChartLegendItem{Name: s.Name, Color: pal[i%len(pal)], Shape: i}
|
||||
if !ch.Hidden(s.Name) {
|
||||
vis = append(vis, i)
|
||||
}
|
||||
}
|
||||
// Bars stacked or not; lo and hi always include zero.
|
||||
type layout struct {
|
||||
marks []barMark
|
||||
from []float64
|
||||
lo, hi float64
|
||||
}
|
||||
lay := ChartMemo(ch, "bars", [2]any{chartSeriesID(ch, series), opts.Stacked}, func() layout {
|
||||
var l layout
|
||||
for ci := range categories {
|
||||
pos, neg := 0.0, 0.0
|
||||
for _, si := range vis {
|
||||
v := series[si].Values[ci]
|
||||
if math.IsNaN(v) {
|
||||
continue
|
||||
}
|
||||
from := 0.0
|
||||
if opts.Stacked && v >= 0 {
|
||||
from, pos = pos, pos+v
|
||||
} else if opts.Stacked {
|
||||
from, neg = neg, neg+v
|
||||
}
|
||||
l.marks, l.from = append(l.marks, barMark{ci, si}), append(l.from, from)
|
||||
l.lo, l.hi = min(l.lo, from, from+v), max(l.hi, from, from+v)
|
||||
}
|
||||
}
|
||||
return l
|
||||
})
|
||||
var lo, hi, step float64
|
||||
var ticks []float64
|
||||
if len(lay.marks) > 0 {
|
||||
lo, hi = lay.lo, lay.hi
|
||||
if opts.Labels {
|
||||
// Headroom keeps value labels inside the plot.
|
||||
pad := (hi - lo) * 0.15
|
||||
lo, hi = lo-pad*chartBool(lo < 0), hi+pad*chartBool(hi > 0)
|
||||
}
|
||||
lo, hi, step = internal.Nice(lo, hi, 4)
|
||||
ticks = internal.Ticks(lo, hi, step)
|
||||
}
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Linear) {
|
||||
if opts.Horizontal {
|
||||
plot := ChartInset(r, 96, 26)
|
||||
return plot, internal.Band{N: len(categories), R0: plot.Y, R1: plot.Y + plot.H, Pad: 0.3},
|
||||
internal.Linear{D0: lo, D1: hi, R0: float64(plot.X), R1: float64(plot.X + plot.W)}
|
||||
}
|
||||
plot := ChartInset(r, 52, 26)
|
||||
return plot, internal.Band{N: len(categories), 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)}
|
||||
}
|
||||
// rect is bar i's box in plot space.
|
||||
rect := func(band internal.Band, vs internal.Linear, i int) ui.Rect {
|
||||
m := lay.marks[i]
|
||||
at, w := band.Start(m.cat), band.Width()
|
||||
if !opts.Stacked && len(vis) > 0 {
|
||||
slot := 0
|
||||
for j, si := range vis {
|
||||
if si == m.series {
|
||||
slot = j
|
||||
}
|
||||
}
|
||||
w /= float32(len(vis))
|
||||
at += w * float32(slot)
|
||||
}
|
||||
a, b := vs.Map(lay.from[i]), vs.Map(lay.from[i]+series[m.series].Values[m.cat])
|
||||
a, b = min(a, b), max(a, b)
|
||||
if opts.Horizontal {
|
||||
return ui.Rect{X: a, Y: at, W: max(b-a, 1), H: w}
|
||||
}
|
||||
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{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 {
|
||||
_, band, vs := geom(r)
|
||||
for i := range lay.marks {
|
||||
b := rect(band, vs, i)
|
||||
// Thin bars still take a 6 DIP target.
|
||||
if (ui.Rect{X: b.X - 3, Y: b.Y - 3, W: b.W + 6, H: b.H + 6}).Contains(x, y) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
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
|
||||
for i := range categories {
|
||||
pos = append(pos, band.Center(i))
|
||||
}
|
||||
zero := vs.Map(0)
|
||||
if opts.Horizontal {
|
||||
for _, t := range ticks {
|
||||
x := internal.Snap(p, vs.Map(t))
|
||||
p.Line(x, plot.Y, x, plot.Y+plot.H, 1, k.Border)
|
||||
ChartText(p, label(t), x, plot.Y+plot.H+6, size, k.TextMuted, 0.5, 0)
|
||||
}
|
||||
for i, y := range pos {
|
||||
ChartText(p, categories[i], plot.X-8, y, size, k.TextMuted, 1, 0.5)
|
||||
}
|
||||
p.Line(internal.Snap(p, zero), plot.Y, internal.Snap(p, zero), plot.Y+plot.H, 1, k.ControlBorder)
|
||||
} else {
|
||||
internal.AxisY(p, plot, vs, ticks, label, size, k)
|
||||
internal.AxisX(p, plot, pos, categories, size, k)
|
||||
internal.Rule(p, plot.X, plot.X+plot.W, zero, k.ControlBorder)
|
||||
}
|
||||
for i, m := range lay.marks {
|
||||
b := rect(band, vs, i)
|
||||
col := keys[m.series].Color
|
||||
p.Fill(b, col, 1)
|
||||
if i == active {
|
||||
p.Stroke(ui.Rect{X: b.X - 2, Y: b.Y - 2, W: b.W + 4, H: b.H + 4}, k.Text, 2, 1.5)
|
||||
}
|
||||
// The series shape marks the bar besides its color.
|
||||
if min(b.W, b.H) >= 10 && max(b.W, b.H) >= 14 {
|
||||
internal.Marker(p, m.series, b.X+b.W/2, b.Y+b.H/2, min(b.W, b.H, 14)*0.6, k.Surface)
|
||||
}
|
||||
if opts.Labels && !opts.Stacked {
|
||||
barLabel(p, ChartValue(c, value(i), opts.Format), b, value(i) < 0, opts.Horizontal, size, k.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
res, _ := ch.Build(spec)
|
||||
return res
|
||||
}
|
||||
|
||||
// barLabel writes a value just past the bar's far end.
|
||||
func barLabel(p *ui.Painter, s string, b ui.Rect, neg, horizontal bool, size float32, col ui.Color) {
|
||||
switch {
|
||||
case horizontal && neg:
|
||||
ChartText(p, s, b.X-4, b.Y+b.H/2, size, col, 1, 0.5)
|
||||
case horizontal:
|
||||
ChartText(p, s, b.X+b.W+4, b.Y+b.H/2, size, col, 0, 0.5)
|
||||
case neg:
|
||||
ChartText(p, s, b.X+b.W/2, b.Y+b.H+2, size, col, 0.5, 0)
|
||||
default:
|
||||
ChartText(p, s, b.X+b.W/2, b.Y-2, size, col, 0.5, 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var barCats = []string{"Nave", "Choir", "Crypt"}
|
||||
|
||||
func barSeries() []ChartSeries {
|
||||
return []ChartSeries{
|
||||
{Name: "Gold", Values: []float64{4, -2, 3}},
|
||||
{Name: "Silver", Values: []float64{2, 5, math.NaN()}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBarChartKeyboardPerBar(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return BarChart(c, barCats, barSeries(), BarChartOptions{Frame: ChartFrame{Label: "Tithes"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Tithes")
|
||||
want := []string{"Nave · Gold 4", "Nave · Silver 2", "Choir · Gold -2", "Choir · Silver 5", "Crypt · Gold 3"}
|
||||
for i, w := range want {
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); got != w {
|
||||
t.Fatalf("bar %d readout %q, want %q", i, got, w)
|
||||
}
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if h.res.Point != 4 {
|
||||
t.Fatalf("stepped past the missing bar: %d", h.res.Point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBarChartHoverStackedHorizontal(t *testing.T) {
|
||||
for _, o := range []BarChartOptions{{Stacked: true}, {Horizontal: true, Labels: true}, {Horizontal: true, Stacked: true}} {
|
||||
o.Frame = ChartFrame{Label: "Tithes"}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult { return BarChart(c, barCats, barSeries(), o) }}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Tithes")
|
||||
found := map[string]bool{}
|
||||
b := h.res.Element.Bounds()
|
||||
for y := b.Y + 30; y < b.Y+b.H-40; y += 4 {
|
||||
for x := b.X + 60; x < b.X+b.W-16; x += 8 {
|
||||
tt.Move(x, y)
|
||||
if h.res.Point >= 0 {
|
||||
found[chartReadout(tt)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(found) != 5 || !found["Choir · Gold -2"] {
|
||||
t.Fatalf("%+v: hover found %v", o, found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBarChartBadAndEmpty(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for mismatched categories")
|
||||
}
|
||||
}()
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return BarChart(c, barCats, nil, BarChartOptions{Frame: ChartFrame{Label: "Tithes"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
if !tt.HasText("No data") {
|
||||
t.Fatalf("empty: %q", tt.Texts())
|
||||
}
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
BarChart(c, barCats[:2], barSeries(), BarChartOptions{Frame: ChartFrame{Label: "x"}})
|
||||
}, 300, 300)
|
||||
}
|
||||
|
||||
func TestBarChartSnapshot(t *testing.T) {
|
||||
snapshot(t, "bar_chart", 640, 320, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(16).Children(func() {
|
||||
ui.Column(c).Width(296).Children(func() {
|
||||
BarChart(c, barCats, barSeries(), BarChartOptions{Frame: ChartFrame{Label: "Grouped", Height: 220}, Labels: true})
|
||||
})
|
||||
ui.Column(c).Width(296).Children(func() {
|
||||
BarChart(c, barCats, barSeries(), BarChartOptions{Frame: ChartFrame{Label: "Stacked", Height: 220}, Horizontal: true, Stacked: true})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// BoxGroup is one named sample of a BoxPlot.
|
||||
type BoxGroup struct {
|
||||
Name string
|
||||
Values []float64
|
||||
}
|
||||
|
||||
// BoxPlotOptions configure a BoxPlot.
|
||||
type BoxPlotOptions struct {
|
||||
Frame ChartFrame
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// boxStats are quartiles, whisker ends and outliers.
|
||||
type boxStats struct {
|
||||
q1, median, q3, lo, hi float64
|
||||
outliers []float64
|
||||
n int
|
||||
}
|
||||
|
||||
// boxSummary summarizes finite values; n is 0 without any.
|
||||
func boxSummary(values []float64) boxStats {
|
||||
var v []float64
|
||||
for _, x := range values {
|
||||
if !math.IsNaN(x) && !math.IsInf(x, 0) {
|
||||
v = append(v, x)
|
||||
}
|
||||
}
|
||||
if len(v) == 0 {
|
||||
return boxStats{}
|
||||
}
|
||||
slices.Sort(v)
|
||||
q := func(p float64) float64 {
|
||||
at := p * float64(len(v)-1)
|
||||
i := int(at)
|
||||
if i+1 >= len(v) {
|
||||
return v[i]
|
||||
}
|
||||
return v[i] + (at-float64(i))*(v[i+1]-v[i])
|
||||
}
|
||||
s := boxStats{q1: q(0.25), median: q(0.5), q3: q(0.75), n: len(v)}
|
||||
fence := 1.5 * (s.q3 - s.q1)
|
||||
s.lo, s.hi = s.q1, s.q3
|
||||
for _, x := range v {
|
||||
if x < s.q1-fence || x > s.q3+fence {
|
||||
s.outliers = append(s.outliers, x)
|
||||
continue
|
||||
}
|
||||
s.lo, s.hi = min(s.lo, x), max(s.hi, x)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// BoxPlot compares groups by quartile box, whiskers and outlier points.
|
||||
func BoxPlot(c *ui.Context, groups []BoxGroup, opts BoxPlotOptions) ChartResult {
|
||||
ch := NewChart(c, opts.Frame)
|
||||
k := core.Tokens(c)
|
||||
pal := internal.Palette(k)
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
ids := ""
|
||||
for _, g := range groups {
|
||||
if g.Name == "" {
|
||||
panic("mujica: BoxPlot groups need names")
|
||||
}
|
||||
ids += ChartID(g.Values) + ";"
|
||||
}
|
||||
stats := ChartMemo(ch, "stats", ids, func() []boxStats {
|
||||
out := make([]boxStats, len(groups))
|
||||
for i, g := range groups {
|
||||
out[i] = boxSummary(g.Values)
|
||||
}
|
||||
return out
|
||||
})
|
||||
lo, hi, n := math.Inf(1), math.Inf(-1), 0
|
||||
for _, s := range stats {
|
||||
if s.n > 0 {
|
||||
n++
|
||||
lo, hi = min(lo, s.lo, slices.Min(append([]float64{s.lo}, s.outliers...))), max(hi, s.hi, slices.Max(append([]float64{s.hi}, s.outliers...)))
|
||||
}
|
||||
}
|
||||
var step float64
|
||||
if n > 0 {
|
||||
lo, hi, step = internal.Nice(lo, hi, 4)
|
||||
} else {
|
||||
groups = nil
|
||||
}
|
||||
val := func(v float64) string { return ChartValue(c, v, opts.Format) }
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Linear) {
|
||||
plot := ChartInset(r, 52, 26)
|
||||
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{Marks: len(groups)}
|
||||
spec.Readout = func(i int) string {
|
||||
s := stats[i]
|
||||
if s.n == 0 {
|
||||
return groups[i].Name + " · " + core.Msg(c, "chart.missing")
|
||||
}
|
||||
return fmt.Sprintf(core.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 {
|
||||
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) {
|
||||
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
|
||||
var names []string
|
||||
for i, g := range groups {
|
||||
xs, names = append(xs, band.Center(i)), append(names, g.Name)
|
||||
}
|
||||
internal.AxisX(p, plot, xs, names, size, k)
|
||||
for i, s := range stats {
|
||||
if s.n == 0 {
|
||||
continue
|
||||
}
|
||||
col := pal[i%len(pal)]
|
||||
x, w := band.Start(i), band.Width()
|
||||
cx := internal.Snap(p, x+w/2)
|
||||
whisker := func(a, b float64) {
|
||||
var path ui.Path
|
||||
path.MoveTo(cx, ys.Map(a)).LineTo(cx, ys.Map(b))
|
||||
p.StrokePath(&path, 1.5, k.TextMuted)
|
||||
p.Line(cx-w/4, ys.Map(b), cx+w/4, ys.Map(b), 1.5, k.TextMuted)
|
||||
}
|
||||
whisker(s.q3, s.hi)
|
||||
whisker(s.q1, s.lo)
|
||||
box := ui.Rect{X: x, Y: ys.Map(s.q3), W: w, H: max(ys.Map(s.q1)-ys.Map(s.q3), 1)}
|
||||
p.Fill(box, col.Alpha(0.22), 2)
|
||||
p.Stroke(box, col, 2, 1.5)
|
||||
p.Line(x, ys.Map(s.median), x+w, ys.Map(s.median), 2.5, k.Text)
|
||||
for _, o := range s.outliers {
|
||||
var dot ui.Path
|
||||
dot.Circle(cx, internal.Snap(p, ys.Map(o)), 3.5)
|
||||
p.StrokePath(&dot, 1.5, col)
|
||||
}
|
||||
if i == active {
|
||||
p.Stroke(ui.Rect{X: x - 6, Y: plot.Y, W: w + 12, H: plot.H}, k.Text, 3, 1.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
res, _ := ch.Build(spec)
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
var boxGroups = []BoxGroup{
|
||||
{Name: "Choir", Values: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9, 40}},
|
||||
{Name: "Nave", Values: []float64{4, 5, 5, 6, 7, math.NaN()}},
|
||||
}
|
||||
|
||||
func TestBoxSummary(t *testing.T) {
|
||||
s := boxSummary(boxGroups[0].Values)
|
||||
if s.q1 != 3.25 || s.median != 5.5 || s.q3 != 7.75 || s.lo != 1 || s.hi != 9 || fmt.Sprint(s.outliers) != "[40]" || s.n != 10 {
|
||||
t.Fatalf("summary %+v", s)
|
||||
}
|
||||
if s := boxSummary([]float64{math.NaN()}); s.n != 0 {
|
||||
t.Fatal("NaN counted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoxPlotReadout(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return BoxPlot(c, boxGroups, BoxPlotOptions{Frame: ChartFrame{Label: "Hymns"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Hymns")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); got != "Choir · median 5.5 · quartiles 3.25–7.75 · whiskers 1–9 · 1 outliers" {
|
||||
t.Fatalf("readout %q", got)
|
||||
}
|
||||
b := h.res.Element.Bounds()
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
tt.Move(b.X+52+(b.W-64)*0.75, b.Y+100)
|
||||
if got := chartReadout(tt); got != "Nave · median 5 · quartiles 5–6 · whiskers 4–7 · 0 outliers" {
|
||||
t.Fatalf("hover %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoxPlotEmpty(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return BoxPlot(c, []BoxGroup{{Name: "a"}}, BoxPlotOptions{Frame: ChartFrame{Label: "Hymns"}})
|
||||
}}
|
||||
if tt := ui.NewTester(h.view, 520, 420); !tt.HasText("No data") {
|
||||
t.Fatalf("empty: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoxPlotSnapshot(t *testing.T) {
|
||||
snapshot(t, "box_plot", 520, 320, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
BoxPlot(c, append(boxGroups, BoxGroup{Name: "Crypt", Values: []float64{12, 14, 15, 18, 22, 25}}), BoxPlotOptions{Frame: ChartFrame{Label: "Hymns"}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// BulletChartOptions configure a BulletChart; Frame.Height 0 means 72 DIPs.
|
||||
type BulletChartOptions struct {
|
||||
Frame ChartFrame
|
||||
// Target, when set, draws a cross line at the goal.
|
||||
Target *float64
|
||||
// Bands are the ascending upper ends of the qualitative ranges.
|
||||
Bands []float64
|
||||
// Max ends the scale; 0 means automatic.
|
||||
Max float64
|
||||
Vertical bool
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// BulletChart shows a value against a target and ranges.
|
||||
func BulletChart(c *ui.Context, value float64, opts BulletChartOptions) ChartResult {
|
||||
if math.IsNaN(value) || value < 0 || opts.Max < 0 || !slices.IsSorted(opts.Bands) || len(opts.Bands) > 0 && opts.Bands[0] <= 0 {
|
||||
panic("mujica: BulletChart needs a value ≥ 0, Max ≥ 0 and positive ascending Bands")
|
||||
}
|
||||
if opts.Frame.Height == 0 {
|
||||
opts.Frame.Height = 72
|
||||
}
|
||||
ch := NewChart(c, opts.Frame)
|
||||
k := core.Tokens(c)
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
top := opts.Max
|
||||
if top == 0 {
|
||||
top = value
|
||||
if opts.Target != nil {
|
||||
top = max(top, *opts.Target)
|
||||
}
|
||||
if len(opts.Bands) > 0 {
|
||||
top = max(top, opts.Bands[len(opts.Bands)-1])
|
||||
}
|
||||
_, top, _ = internal.Nice(0, max(top, 1e-9), 4)
|
||||
}
|
||||
_, _, step := internal.Nice(0, top, 4)
|
||||
val := func(v float64) string { return ChartValue(c, v, opts.Format) }
|
||||
marks := []string{core.Msg(c, "bullet_chart.actual") + " " + val(value)}
|
||||
if opts.Target != nil {
|
||||
marks = append(marks, core.Msg(c, "bullet_chart.target")+" "+val(*opts.Target))
|
||||
}
|
||||
for i, b := range opts.Bands {
|
||||
lo := 0.0
|
||||
if i > 0 {
|
||||
lo = opts.Bands[i-1]
|
||||
}
|
||||
marks = append(marks, fmt.Sprintf(core.Msg(c, "bullet_chart.band"), i+1, val(lo), val(b)))
|
||||
}
|
||||
// geom returns the plot and its value scale.
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Linear) {
|
||||
if opts.Vertical {
|
||||
plot := ui.Rect{X: r.X + r.W/2 - 14, Y: r.Y + 8, W: 28, H: r.H - 16}
|
||||
return plot, internal.Linear{D0: 0, D1: top, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
|
||||
}
|
||||
plot := ui.Rect{X: r.X + 8, Y: r.Y + 6, W: r.W - 24, H: max(r.H-30, 12)}
|
||||
return plot, internal.Linear{D0: 0, D1: top, R0: float64(plot.X), R1: float64(plot.X + plot.W)}
|
||||
}
|
||||
span := func(plot ui.Rect, s internal.Linear, a, b, inset float32) ui.Rect {
|
||||
if opts.Vertical {
|
||||
return ui.Rect{X: plot.X + inset, Y: s.Map(float64(b)), W: plot.W - 2*inset, H: s.Map(float64(a)) - s.Map(float64(b))}
|
||||
}
|
||||
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{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
|
||||
}
|
||||
at := x
|
||||
if opts.Vertical {
|
||||
at = y
|
||||
}
|
||||
v := s.Invert(at)
|
||||
if opts.Target != nil && math.Abs(float64(s.Map(*opts.Target)-at)) < 4 {
|
||||
return 1
|
||||
}
|
||||
if span(plot, s, 0, float32(min(value, top)), bulletInset(plot, opts.Vertical)).Contains(x, y) {
|
||||
return 0
|
||||
}
|
||||
for i, b := range opts.Bands {
|
||||
if v <= b {
|
||||
return len(marks) - len(opts.Bands) + i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot, s := geom(r)
|
||||
inset := bulletInset(plot, opts.Vertical)
|
||||
prev := 0.0
|
||||
for i, b := range opts.Bands {
|
||||
shade := k.SurfaceHover.Mix(k.Border, float32(len(opts.Bands)-1-i)/float32(max(len(opts.Bands)-1, 1)))
|
||||
p.Fill(span(plot, s, float32(prev), float32(min(b, top)), 0), shade, 0)
|
||||
if active == len(marks)-len(opts.Bands)+i {
|
||||
p.Stroke(span(plot, s, float32(prev), float32(min(b, top)), 0), k.Text, 0, 1.5)
|
||||
}
|
||||
prev = b
|
||||
}
|
||||
bar := span(plot, s, 0, float32(min(value, top)), inset)
|
||||
p.Fill(bar, internal.Palette(k)[0], 0)
|
||||
if active == 0 {
|
||||
p.Stroke(ui.Rect{X: bar.X - 2, Y: bar.Y - 2, W: bar.W + 4, H: bar.H + 4}, k.Text, 1, 1.5)
|
||||
}
|
||||
if opts.Target != nil {
|
||||
t := internal.Snap(p, s.Map(min(*opts.Target, top)))
|
||||
w := float32(2.5)
|
||||
if active == 1 {
|
||||
w = 4
|
||||
}
|
||||
if opts.Vertical {
|
||||
p.Line(plot.X+2, t, plot.X+plot.W-2, t, w, k.Text)
|
||||
} else {
|
||||
p.Line(t, plot.Y+2, t, plot.Y+plot.H-2, w, k.Text)
|
||||
}
|
||||
}
|
||||
for _, tk := range internal.Ticks(0, top, step) {
|
||||
if opts.Vertical {
|
||||
ChartText(p, internal.FormatTick(tk, step), plot.X+plot.W+6, s.Map(tk), size, k.TextMuted, 0, 0.5)
|
||||
} else {
|
||||
ChartText(p, internal.FormatTick(tk, step), s.Map(tk), plot.Y+plot.H+4, size, k.TextMuted, 0.5, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
res, _ := ch.Build(spec)
|
||||
return res
|
||||
}
|
||||
|
||||
// bulletInset is a third of the band's thickness.
|
||||
func bulletInset(plot ui.Rect, vertical bool) float32 {
|
||||
if vertical {
|
||||
return plot.W / 3
|
||||
}
|
||||
return plot.H / 3
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestBulletChartReadout(t *testing.T) {
|
||||
target := 250.0
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return BulletChart(c, 270, BulletChartOptions{Frame: ChartFrame{Label: "Alms"}, Target: &target, Bands: []float64{150, 225, 300}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Alms")
|
||||
want := []string{"Actual 270", "Target 250", "Range 1: 0–150", "Range 2: 150–225", "Range 3: 225–300"}
|
||||
for i, w := range want {
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); got != w {
|
||||
t.Fatalf("mark %d %q, want %q", i, got, w)
|
||||
}
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
b := h.res.Element.Bounds()
|
||||
plotW := b.W - 24
|
||||
tt.Move(b.X+8+plotW*0.1, b.Y+6+21) // band 1, inside the value bar
|
||||
if got := chartReadout(tt); got != "Actual 270" {
|
||||
t.Fatalf("hover bar %q", got)
|
||||
}
|
||||
tt.Move(b.X+8+plotW*0.1, b.Y+8) // band 1, above the bar
|
||||
if got := chartReadout(tt); got != "Range 1: 0–150" {
|
||||
t.Fatalf("hover band %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulletChartVerticalAndBad(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return BulletChart(c, 40, BulletChartOptions{Frame: ChartFrame{Label: "Alms", Height: 200}, Vertical: true, Max: 100})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
b := h.res.Element.Bounds()
|
||||
tt.Move(b.X+b.W/2, b.Y+8+184*0.8) // low on the scale: inside the bar
|
||||
if got := chartReadout(tt); got != "Actual 40" {
|
||||
t.Fatalf("vertical hover %q", got)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for unsorted bands")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
BulletChart(c, 1, BulletChartOptions{Frame: ChartFrame{Label: "x"}, Bands: []float64{5, 2}})
|
||||
}, 300, 300)
|
||||
}
|
||||
|
||||
func TestBulletChartSnapshot(t *testing.T) {
|
||||
target := 250.0
|
||||
snapshot(t, "bullet_chart", 520, 300, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(16).Children(func() {
|
||||
ui.Column(c).Width(360).Children(func() {
|
||||
BulletChart(c, 270, BulletChartOptions{Frame: ChartFrame{Label: "Alms"}, Target: &target, Bands: []float64{150, 225, 300}})
|
||||
})
|
||||
ui.Column(c).Width(100).Children(func() {
|
||||
BulletChart(c, 40, BulletChartOptions{Frame: ChartFrame{Label: "Oil", Height: 200}, Vertical: true, Target: &[]float64{70}[0], Bands: []float64{50, 80, 100}})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// CalendarHeatmapOptions configure a CalendarHeatmap.
|
||||
type CalendarHeatmapOptions struct {
|
||||
Frame ChartFrame
|
||||
// WeekStart picks each column's first weekday; zero follows the locale.
|
||||
WeekStart datetime.WeekStart
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// CalendarHeatmap colors each day of *year, a column per week.
|
||||
func CalendarHeatmap(c *ui.Context, year *int, values map[core.Date]float64, opts CalendarHeatmapOptions) ChartResult {
|
||||
if *year < 1 || *year > 9999 {
|
||||
panic("mujica: CalendarHeatmap year out of range")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
first := core.Date{Year: *year, Month: time.January, Day: 1}
|
||||
days := first.AddMonths(12).Civil().Sub(first.Civil()).Hours() / 24
|
||||
n := int(days)
|
||||
lead := (int(first.Weekday()) - int(datetime.CalendarFirstWeekday(c, opts.WeekStart)) + 7) % 7
|
||||
weeks := (lead + n + 6) / 7
|
||||
top := 0.0
|
||||
for i := range n {
|
||||
if v, ok := values[first.AddDays(i)]; ok {
|
||||
top = max(top, v)
|
||||
}
|
||||
}
|
||||
// level is a day's color step, 0 to 4.
|
||||
level := func(i int) int {
|
||||
v, ok := values[first.AddDays(i)]
|
||||
if !ok || v <= 0 || top <= 0 {
|
||||
return 0
|
||||
}
|
||||
return 1 + min(3, int(v/top*4))
|
||||
}
|
||||
var res ChartResult
|
||||
wrap := ui.Column(c).Gap(u * 2).MinWidth(0)
|
||||
wrap.Children(func() {
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).Children(func() {
|
||||
prev := core.IconAction(c, icons.Must("chevron-left"), core.Msg(c, "calendar_heatmap.prev")).Disabled(*year <= 1)
|
||||
ui.Text(c, strconv.Itoa(*year)).Font(theme.SerifFont).FontSize(core.FontSize(c, theme.H3Size)).Width(64).TextAlign(ui.Center)
|
||||
next := core.IconAction(c, icons.Must("chevron-right"), core.Msg(c, "calendar_heatmap.next")).Disabled(*year >= 9999)
|
||||
if prev.Clicked() && !prev.IsDisabled() {
|
||||
*year--
|
||||
res.value = true
|
||||
}
|
||||
if next.Clicked() && !next.IsDisabled() {
|
||||
*year++
|
||||
res.value = true
|
||||
}
|
||||
})
|
||||
ch := NewChart(c, opts.Frame)
|
||||
geom := func(r ui.Rect) (x, y, step float32) {
|
||||
step = min((r.W-36)/float32(weeks), (r.H-48)/7)
|
||||
return r.X + 32, r.Y + 18, step
|
||||
}
|
||||
cell := func(r ui.Rect, i int) ui.Rect {
|
||||
x, y, step := geom(r)
|
||||
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{Marks: n}
|
||||
spec.Readout = func(i int) string {
|
||||
d := first.AddDays(i)
|
||||
v, ok := values[d]
|
||||
s := d.String() + " · "
|
||||
if !ok {
|
||||
return s + core.Msg(c, "chart.missing")
|
||||
}
|
||||
return s + ChartValue(c, v, opts.Format)
|
||||
}
|
||||
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 {
|
||||
x0, y0, step := geom(r)
|
||||
col, row := int((x-x0)/step), int((y-y0)/step)
|
||||
i := col*7 + row - lead
|
||||
if x < x0 || y < y0 || row > 6 || i < 0 || i >= n {
|
||||
return -1
|
||||
}
|
||||
return i
|
||||
}
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
x0, y0, step := geom(r)
|
||||
for i := range n {
|
||||
b := cell(r, i)
|
||||
if lv := level(i); lv == 0 {
|
||||
p.Fill(b, k.SurfaceHover, 2)
|
||||
} else {
|
||||
p.Fill(b, internal.Ramp(k, float64(lv)/4), 2)
|
||||
}
|
||||
if d := first.AddDays(i); d.Day == 1 {
|
||||
ChartText(p, d.Format("Jan"), b.X, y0-4, size, k.TextMuted, 0, 1)
|
||||
}
|
||||
if i == active {
|
||||
p.Stroke(ui.Rect{X: b.X - 2, Y: b.Y - 2, W: b.W + 4, H: b.H + 4}, k.Text, 3, 2)
|
||||
}
|
||||
}
|
||||
for row := 0; row < 7; row += 2 {
|
||||
wd := time.Weekday((int(datetime.CalendarFirstWeekday(c, opts.WeekStart)) + row) % 7)
|
||||
ChartText(p, wd.String()[:3], x0-4, y0+float32(row)*step+step/2, size, k.TextMuted, 1, 0.5)
|
||||
}
|
||||
ly := y0 + 7*step + 8
|
||||
w, _ := ChartText(p, core.Msg(c, "calendar_heatmap.less"), x0, ly, size, k.TextMuted, 0, 0)
|
||||
for lv := range 5 {
|
||||
sw := ui.Rect{X: x0 + w + 6 + float32(lv)*(step+2), Y: ly + 2, W: step - 2, H: step - 2}
|
||||
if lv == 0 {
|
||||
p.Fill(sw, k.SurfaceHover, 2)
|
||||
} else {
|
||||
p.Fill(sw, internal.Ramp(k, float64(lv)/4), 2)
|
||||
}
|
||||
}
|
||||
ChartText(p, core.Msg(c, "calendar_heatmap.more"), x0+w+12+5*(step+2), ly, size, k.TextMuted, 0, 0)
|
||||
}
|
||||
res2, _ := ch.Build(spec)
|
||||
res.Point, res.changed, res.retry = res2.Point, res2.changed, res2.retry
|
||||
})
|
||||
if res.value {
|
||||
slog.Debug("mujica: calendar_heatmap year", "year", *year)
|
||||
}
|
||||
res.Element = wrap
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/datetime"
|
||||
)
|
||||
|
||||
func calendarValues() map[core.Date]float64 {
|
||||
m := map[core.Date]float64{}
|
||||
d := core.Date{Year: 2026, Month: time.January, Day: 1}
|
||||
for i := range 365 {
|
||||
if i%3 == 0 {
|
||||
m[d.AddDays(i)] = float64(i % 11)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestCalendarHeatmapKeysAndYear(t *testing.T) {
|
||||
year := 2026
|
||||
values := 0
|
||||
start := datetime.WeekStartMonday
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
r := CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: ChartFrame{Label: "Prayers"}, WeekStart: start})
|
||||
if r.ValueChanged() {
|
||||
values++
|
||||
}
|
||||
return r
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 560, 420)
|
||||
tabTo(t, tt, "Prayers")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); got != "2026-01-01 · 0" {
|
||||
t.Fatalf("first day %q", got)
|
||||
}
|
||||
tt.Key(0, ui.KeyRight) // a week later
|
||||
tt.Key(0, ui.KeyDown) // a day later
|
||||
if got := chartReadout(tt); got != "2026-01-09 · no value" {
|
||||
t.Fatalf("week and day %q", got)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if got := chartReadout(tt); got != "2026-12-31 · no value" {
|
||||
t.Fatalf("End %q", got)
|
||||
}
|
||||
if err := tt.Click("Next year"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if year != 2027 || values != 1 || !tt.HasText("2027") {
|
||||
t.Fatalf("year %d events %d", year, values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarHeatmapWeekStartHover(t *testing.T) {
|
||||
year := 2026 // 1 January 2026 is a Thursday
|
||||
for _, tc := range []struct {
|
||||
start datetime.WeekStart
|
||||
row int
|
||||
}{{datetime.WeekStartSunday, 4}, {datetime.WeekStartMonday, 3}} {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: ChartFrame{Label: "Prayers"}, WeekStart: tc.start})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 560, 420)
|
||||
b := h.res.Element.Bounds()
|
||||
plotY := b.Y + b.H - 20 - 8 - 240 // above the readout line and gap
|
||||
step := min((b.W-36)/53, float32(240-48)/7)
|
||||
tt.Move(b.X+32+step/2, plotY+18+float32(tc.row)*step+step/2)
|
||||
if got := chartReadout(tt); got != "2026-01-01 · 0" {
|
||||
t.Fatalf("start %d: row %d reads %q", tc.start, tc.row, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarHeatmapBadYear(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic")
|
||||
}
|
||||
}()
|
||||
year := 0
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CalendarHeatmap(c, &year, nil, CalendarHeatmapOptions{Frame: ChartFrame{Label: "x"}})
|
||||
}, 300, 300)
|
||||
}
|
||||
|
||||
func TestCalendarHeatmapSnapshot(t *testing.T) {
|
||||
snapshot(t, "calendar_heatmap", 600, 260, func(c *ui.Context) {
|
||||
year := 2026
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: ChartFrame{Label: "Prayers", Height: 150}, WeekStart: datetime.WeekStartMonday})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// CandleCountdownOptions configure a CandleCountdown.
|
||||
type CandleCountdownOptions struct {
|
||||
// Location defaults to UTC.
|
||||
Location *time.Location
|
||||
// Now freezes the clock; zero uses the frame time.
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
// CandleCountdown displays closing time and schedules visible updates.
|
||||
func CandleCountdown(c *ui.Context, interval Interval, opts CandleCountdownOptions) *ui.Element {
|
||||
loc := opts.Location
|
||||
if loc == nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
now := opts.Now
|
||||
if now.IsZero() {
|
||||
now = c.Now()
|
||||
}
|
||||
left := interval.Close(now, loc).Sub(now)
|
||||
k := core.Tokens(c)
|
||||
text := candleCountdownText(left)
|
||||
e := ui.Row(c).Gap(core.Density(c).Unit()).AlignItems(ui.Center).Role(ui.RoleText).Label(fmt.Sprintf(core.Msg(c, "candle_countdown.label"), string(interval), text))
|
||||
e.Children(func() {
|
||||
ui.Text(c, string(interval)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
ui.Text(c, text).FontFeatures("tnum").FontWeight(500).TextColor(k.Text).SingleLine()
|
||||
})
|
||||
// The next whole second, so the text never skips one.
|
||||
if opts.Now.IsZero() {
|
||||
e.DrawOver(func(_ *ui.Painter, _ ui.Rect) { c.After(left - left.Truncate(time.Second) + time.Millisecond) })
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// candleCountdownText writes d as 59:58, 3:59:58 or 2d 03:59:58.
|
||||
func candleCountdownText(d time.Duration) string {
|
||||
s := int(d.Seconds())
|
||||
days, h, m, sec := s/86400, s/3600%24, s/60%60, s%60
|
||||
switch {
|
||||
case days > 0:
|
||||
return fmt.Sprintf("%dd %02d:%02d:%02d", days, h, m, sec)
|
||||
case h > 0:
|
||||
return fmt.Sprintf("%d:%02d:%02d", h, m, sec)
|
||||
}
|
||||
return fmt.Sprintf("%02d:%02d", m, sec)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestCandleCountdownIntervalsAndClock(t *testing.T) {
|
||||
now := time.Date(2026, 10, 5, 12, 34, 5, 0, time.UTC)
|
||||
iv := Interval("1m")
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CandleCountdown(c, iv, CandleCountdownOptions{Now: now})
|
||||
}, 300, 100)
|
||||
if !tt.HasText("00:55") {
|
||||
t.Fatalf("countdown %q", tt.Texts())
|
||||
}
|
||||
iv = "1h"
|
||||
tt.Frame()
|
||||
if !tt.HasText("25:55") {
|
||||
t.Fatalf("hour %q", tt.Texts())
|
||||
}
|
||||
for _, period := range Intervals {
|
||||
if !period.Close(now, time.UTC).After(now) {
|
||||
t.Fatalf("close %s", period)
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{{time.Second, "00:01"}, {time.Hour + time.Second, "1:00:01"}, {48*time.Hour + time.Hour, "2d 01:00:00"}} {
|
||||
if got := candleCountdownText(tc.d); got != tc.want {
|
||||
t.Fatalf("text %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestCandleCountdownSnapshot(t *testing.T) {
|
||||
snapshot(t, "candle_countdown", 300, 80, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(12).Children(func() {
|
||||
CandleCountdown(c, "1h", CandleCountdownOptions{Now: time.Date(2026, 10, 5, 12, 34, 5, 0, time.UTC)})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ChartType selects the price chart shape.
|
||||
type ChartType int
|
||||
|
||||
const (
|
||||
ChartCandles ChartType = iota
|
||||
ChartOHLC
|
||||
ChartLine
|
||||
ChartArea
|
||||
ChartHeikinAshi
|
||||
)
|
||||
|
||||
// CandlestickOptions configure a CandlestickChart and its layers.
|
||||
type CandlestickOptions struct {
|
||||
Frame ChartFrame
|
||||
Type ChartType
|
||||
// Decimals of prices; zero means 2.
|
||||
Decimals int
|
||||
// Overlays draw indicators over the prices.
|
||||
Overlays []IndicatorOverlay
|
||||
// Compare switches the chart to normalized percent prices.
|
||||
Compare *[]CompareSymbol
|
||||
// Alerts provides draggable lines and price-crossing callbacks.
|
||||
Alerts *[]PriceAlertLine
|
||||
OnAlert func(a PriceAlertLine, last Candle)
|
||||
// Drawings, when set, takes clicks for the current tool.
|
||||
Drawings *DrawingTools
|
||||
// Sync links the window and crosshair under SyncID.
|
||||
Sync *ChartSync
|
||||
SyncID string
|
||||
}
|
||||
|
||||
// mktState is a price chart's own frame-to-frame state.
|
||||
type mktState struct {
|
||||
drawn int // candles painted last frame
|
||||
dragRest float32 // pan drag left over, under a candle
|
||||
lastClose float64
|
||||
alertSeen bool
|
||||
grab int // alert index dragged, -1 none
|
||||
focus int // keyboard mark last frame, to follow only when it moves
|
||||
alertFocus int
|
||||
plot ui.Rect
|
||||
ys internal.Linear // the price scale as last painted
|
||||
}
|
||||
|
||||
// mktCore is one price chart being built.
|
||||
type mktCore struct {
|
||||
c *ui.Context
|
||||
k theme.Tokens
|
||||
ch *chart
|
||||
candles []Candle
|
||||
view *MarketView
|
||||
from, to int
|
||||
lo, hi float64
|
||||
base float64 // percent mode divides by this close; 0 is prices
|
||||
dec int
|
||||
opts CandlestickOptions
|
||||
st *mktState
|
||||
overlays []mktLine
|
||||
}
|
||||
|
||||
// mktLine is one drawn indicator or compare line.
|
||||
type mktLine struct {
|
||||
name string
|
||||
values []float64
|
||||
color ui.Color
|
||||
shape int
|
||||
}
|
||||
|
||||
// CandlestickChart paints visible candles with hollow rises and filled falls.
|
||||
func CandlestickChart(c *ui.Context, candles []Candle, view *MarketView, opts CandlestickOptions) ChartResult {
|
||||
if view == nil {
|
||||
panic("mujica: CandlestickChart needs a view")
|
||||
}
|
||||
if opts.Type < ChartCandles || opts.Type > ChartHeikinAshi {
|
||||
panic(fmt.Sprintf("mujica: unknown ChartType %d", opts.Type))
|
||||
}
|
||||
if opts.Sync != nil && opts.SyncID == "" {
|
||||
panic("mujica: CandlestickChart Sync needs a SyncID")
|
||||
}
|
||||
ch := NewChart(c, opts.Frame)
|
||||
shown := ChartMemo(ch, "candles", [2]any{ChartID(candles), opts.Type}, func() []Candle {
|
||||
marketCheckCandles(candles)
|
||||
if opts.Type == ChartHeikinAshi {
|
||||
return HeikinAshi(candles)
|
||||
}
|
||||
return candles
|
||||
})
|
||||
m := &mktCore{c: c, k: core.Tokens(c), ch: ch, candles: shown, view: view, opts: opts, dec: core.PriceDecimals("CandlestickChart", opts.Decimals)}
|
||||
m.st = *ui.Local(ch.Res.Element, "mujica.mkt", func() *mktState { return &mktState{grab: -1, focus: -1, alertFocus: -1} })
|
||||
if ch.Res.Element.IsDisabled() {
|
||||
copy := *view
|
||||
view = ©
|
||||
m.view = view
|
||||
m.opts.Sync = nil
|
||||
opts.Sync = nil
|
||||
}
|
||||
if s := opts.Sync; s != nil {
|
||||
s.load(opts.SyncID, shown, view)
|
||||
}
|
||||
m.window()
|
||||
m.overlays = mktOverlayLines(ch, m, candles)
|
||||
m.compareLines()
|
||||
m.yDomain()
|
||||
m.alertsCheck()
|
||||
res, plot := ch.Build(m.spec())
|
||||
if plot != nil {
|
||||
m.input(plot)
|
||||
}
|
||||
if s := opts.Sync; s != nil {
|
||||
s.store(opts.SyncID, shown, view, res.Point)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// OHLCChart is CandlestickChart drawn as open-high-low-close bars.
|
||||
func OHLCChart(c *ui.Context, candles []Candle, view *MarketView, opts CandlestickOptions) ChartResult {
|
||||
opts.Type = ChartOHLC
|
||||
return CandlestickChart(c, candles, view, opts)
|
||||
}
|
||||
|
||||
// HeikinAshiChart switches between smoothed candles and raw OHLC.
|
||||
func HeikinAshiChart(c *ui.Context, candles []Candle, view *MarketView, raw *bool, opts CandlestickOptions) ChartResult {
|
||||
if raw == nil {
|
||||
panic("mujica: HeikinAshiChart needs raw")
|
||||
}
|
||||
opts.Type = ChartHeikinAshi
|
||||
if *raw {
|
||||
opts.Type = ChartCandles
|
||||
}
|
||||
var res ChartResult
|
||||
col := ui.Column(c).Gap(core.Density(c).Unit() * 2).MinWidth(0)
|
||||
col.Children(func() {
|
||||
ui.Row(c).Children(func() {
|
||||
v := []bool{*raw}
|
||||
if input.ToggleGroup(c, &v, []input.ToggleItem[bool]{{Value: false, Label: core.Msg(c, "heikin_ashi_chart.smoothed")}, {Value: true, Label: core.Msg(c, "heikin_ashi_chart.raw")}},
|
||||
input.ToggleGroupOptions{Label: core.Msg(c, "heikin_ashi_chart.switch")}).Changed() {
|
||||
*raw = v[0]
|
||||
slog.Debug("mujica: heikin_ashi switch", "raw", *raw)
|
||||
}
|
||||
})
|
||||
res = CandlestickChart(c, candles, view, opts)
|
||||
})
|
||||
res.Element = col
|
||||
return res
|
||||
}
|
||||
|
||||
// window resolves visible candles using the last measured width.
|
||||
func (m *mktCore) window() {
|
||||
m.from, m.to = marketWindow(*m.view, len(m.candles), m.room())
|
||||
}
|
||||
|
||||
// room is how many candles fit, three DIPs each.
|
||||
func (m *mktCore) room() int {
|
||||
w := m.ch.Res.Element.Bounds().W - 64
|
||||
if w <= 0 {
|
||||
return 200
|
||||
}
|
||||
return max(10, int(w/3))
|
||||
}
|
||||
|
||||
// yDomain caches visible prices, indicators, and alerts.
|
||||
func (m *mktCore) yDomain() {
|
||||
if m.to <= m.from {
|
||||
return
|
||||
}
|
||||
var alerts []PriceAlertLine
|
||||
if m.opts.Alerts != nil {
|
||||
alerts = *m.opts.Alerts
|
||||
}
|
||||
ids := fmt.Sprintf("%s|%d|%d|%v|%d|%v", ChartID(m.candles), m.from, m.to, m.base, len(m.overlays), alerts)
|
||||
for _, l := range m.overlays {
|
||||
ids += ChartID(l.values)
|
||||
}
|
||||
d := ChartMemo(m.ch, "ydomain", ids, func() [2]float64 {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for i := m.from; i < m.to; i++ {
|
||||
lo, hi = min(lo, m.val(m.candles[i].Low)), max(hi, m.val(m.candles[i].High))
|
||||
for _, l := range m.overlays {
|
||||
if v := l.values[i]; !math.IsNaN(v) {
|
||||
lo, hi = min(lo, v), max(hi, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.base == 0 {
|
||||
for _, a := range alerts {
|
||||
lo, hi = min(lo, a.Price), max(hi, a.Price)
|
||||
}
|
||||
}
|
||||
return [2]float64{lo, hi}
|
||||
})
|
||||
m.lo, m.hi = d[0], d[1]
|
||||
}
|
||||
|
||||
// val converts price to the current axis unit.
|
||||
func (m *mktCore) val(p float64) float64 {
|
||||
if m.base == 0 {
|
||||
return p
|
||||
}
|
||||
return (p/m.base - 1) * 100
|
||||
}
|
||||
|
||||
// mktGeom places candles and prices in a plot.
|
||||
type mktGeom struct {
|
||||
plot ui.Rect
|
||||
band internal.Band
|
||||
ys internal.Linear
|
||||
ticks []float64
|
||||
step float64
|
||||
}
|
||||
|
||||
func (m *mktCore) geom(r ui.Rect) mktGeom {
|
||||
plot := ChartInset(r, 60, 26)
|
||||
lo, hi, step := internal.Nice(m.lo, m.hi, 4)
|
||||
return mktGeom{
|
||||
plot: plot,
|
||||
band: internal.Band{N: m.to - m.from, R0: plot.X, R1: plot.X + plot.W, Pad: 0.3},
|
||||
ys: internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)},
|
||||
ticks: internal.Ticks(lo, hi, step),
|
||||
step: step,
|
||||
}
|
||||
}
|
||||
|
||||
func (g mktGeom) x(m *mktCore, i int) float32 { return g.band.Center(i - m.from) }
|
||||
|
||||
// index is the candle under x, clamped to the window.
|
||||
func (g mktGeom) index(m *mktCore, x float32) int {
|
||||
i := m.from + int((x-g.band.R0)/g.band.Step())
|
||||
return max(m.from, min(m.to-1, i))
|
||||
}
|
||||
|
||||
func (m *mktCore) spec() ChartSpec {
|
||||
n := len(m.candles)
|
||||
var keys []ChartLegendItem
|
||||
for _, l := range m.overlays {
|
||||
keys = append(keys, ChartLegendItem{Name: l.name, Color: l.color, Shape: l.shape})
|
||||
}
|
||||
if n == 0 && m.opts.Compare != nil {
|
||||
pal := internal.Palette(m.k)
|
||||
for i, s := range *m.opts.Compare {
|
||||
keys = append(keys, ChartLegendItem{Name: s.Name, Color: pal[(i+1)%len(pal)], Shape: i + 1})
|
||||
}
|
||||
}
|
||||
spec := ChartSpec{Marks: n, Legend: keys}
|
||||
if m.to <= m.from {
|
||||
spec.Marks = 0
|
||||
return spec
|
||||
}
|
||||
spec.Pick = func(r ui.Rect, x, y float32) int {
|
||||
g := m.geom(r)
|
||||
if !g.plot.Contains(x, y) {
|
||||
return -1
|
||||
}
|
||||
return g.index(m, x)
|
||||
}
|
||||
spec.Move = func(i int, key ui.Key) int {
|
||||
if key == ui.KeyLeft || key == ui.KeyUp {
|
||||
return max(i-1, 0)
|
||||
}
|
||||
return min(i+1, n-1)
|
||||
}
|
||||
if d := m.opts.Drawings; d != nil {
|
||||
spec.Activate = func(i int) {
|
||||
if !d.Locked && d.Tool != ToolSelect {
|
||||
d.place(m, DrawingPoint{m.candles[i].Time, m.candles[i].Close})
|
||||
}
|
||||
}
|
||||
}
|
||||
spec.Readout = m.readout
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) { m.paint(p, r, active) }
|
||||
return spec
|
||||
}
|
||||
|
||||
// readout describes time, OHLC, volume, direction, and indicators.
|
||||
func (m *mktCore) readout(i int) string {
|
||||
c := m.candles[i]
|
||||
f := func(v float64) string { return core.PriceUngrouped(v, m.dec) }
|
||||
prev := c.Open
|
||||
if i > 0 {
|
||||
prev = m.candles[i-1].Close
|
||||
}
|
||||
sign := core.TrendSign(c.Close - prev)
|
||||
pct := 0.0
|
||||
if prev != 0 {
|
||||
pct = (c.Close/prev - 1) * 100
|
||||
}
|
||||
parts := []string{
|
||||
c.Time.Format("2006-01-02 15:04"),
|
||||
fmt.Sprintf(core.Msg(m.c, "candlestick_chart.ohlc"), f(c.Open), f(c.High), f(c.Low), f(c.Close)),
|
||||
fmt.Sprintf("%+.2f%% %s", pct, core.TrendWord(m.c, sign)),
|
||||
core.Msg(m.c, "candlestick_chart.volume") + " " + data.StatisticFormat(c.Volume, 0),
|
||||
}
|
||||
for _, l := range m.overlays {
|
||||
parts = append(parts, l.name+" "+ChartValue(m.c, l.values[i], f))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
// sampleCandles is a deterministic random walk, hourly from 2026-01-01.
|
||||
func sampleCandles(n int) []Candle {
|
||||
out := make([]Candle, n)
|
||||
price := 100.0
|
||||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for i := range out {
|
||||
o := price
|
||||
c := o + math.Sin(float64(i)*0.7)*1.5 + math.Cos(float64(i)*0.13)*0.8
|
||||
hi, lo := max(o, c)+0.6+math.Abs(math.Sin(float64(i)))*0.5, min(o, c)-0.5-math.Abs(math.Cos(float64(i)))*0.4
|
||||
out[i] = Candle{Time: base.Add(time.Duration(i) * time.Hour), Open: o, High: hi, Low: lo, Close: c, Volume: 1000 + float64(i%17)*130}
|
||||
price = c
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mktState0(r ChartResult) *mktState {
|
||||
return *ui.Local(r.Element, "mujica.mkt", func() *mktState { return nil })
|
||||
}
|
||||
|
||||
func TestCandlestickChartDrawsOnlyVisible(t *testing.T) {
|
||||
candles := sampleCandles(100000)
|
||||
var view MarketView
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}})
|
||||
}, 900, 400)
|
||||
tt.Frame()
|
||||
st := mktState0(res)
|
||||
if st.drawn != 120 {
|
||||
t.Fatalf("drew %d candles of 100000", st.drawn)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
for range 40 {
|
||||
tt.Key(0, ui.KeyMinus)
|
||||
}
|
||||
if st.drawn > 900/3 || view.Count > 900/3 {
|
||||
t.Fatalf("zoomed out: drew %d, view %+v", st.drawn, view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandlestickChartKeyboardPanZoomReadout(t *testing.T) {
|
||||
candles := sampleCandles(500)
|
||||
view := MarketView{Count: 50}
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}})
|
||||
}, 900, 400)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if res.Point != 499 || !tt.HasText("2026-01-21 19:00") {
|
||||
t.Fatalf("End: point %d texts %q", res.Point, tt.Texts())
|
||||
}
|
||||
tt.Key(ui.Shift, ui.KeyLeft)
|
||||
if view.End != 495 || view.Count != 50 {
|
||||
t.Fatalf("Shift+Left: %+v", view)
|
||||
}
|
||||
tt.Key(0, ui.KeyEqual)
|
||||
if view.Count != 40 {
|
||||
t.Fatalf("zoom in: %+v", view)
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Frame()
|
||||
if f, _ := marketWindow(view, 500, 300); f != 0 {
|
||||
t.Fatalf("Home did not bring the first candle into view: %+v", view)
|
||||
}
|
||||
found := false
|
||||
for _, s := range tt.Texts() {
|
||||
found = found || strings.Contains(s, "O 100.00") && strings.Contains(s, "Vol 1,000")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("readout %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandlestickChartDragPansAndRejects(t *testing.T) {
|
||||
candles := sampleCandles(300)
|
||||
view := MarketView{Count: 100}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}}).Element.Width(860)
|
||||
}, 900, 400)
|
||||
tt.Frame()
|
||||
tt.Press(400, 150)
|
||||
tt.Move(560, 150) // about 20 candles of 8 DIPs
|
||||
tt.Release(560, 150)
|
||||
if view.End < 275 || view.End > 285 {
|
||||
t.Fatalf("drag right shows older candles: %+v", view)
|
||||
}
|
||||
for name, f := range map[string]func(c *ui.Context){
|
||||
"type": func(c *ui.Context) {
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}, Type: 9})
|
||||
},
|
||||
"order": func(c *ui.Context) {
|
||||
bad := []Candle{candles[1], candles[0]}
|
||||
CandlestickChart(c, bad, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}})
|
||||
},
|
||||
"prices": func(c *ui.Context) {
|
||||
bad := []Candle{{Time: candles[0].Time, Open: 5, High: 4, Low: 3, Close: 4}}
|
||||
CandlestickChart(c, bad, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}})
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); f(c) }, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandlestickChartTrendColors(t *testing.T) {
|
||||
candles := sampleCandles(30)
|
||||
view := MarketView{Count: 30}
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Locale: core.ZhCN})
|
||||
core.UseTrend(c, core.RedUp)
|
||||
res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "沪深300"}})
|
||||
}, 600, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if res.Point != 0 || !tt.HasText("开 100.00") {
|
||||
t.Fatalf("zh readout %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandlestickChartSnapshot(t *testing.T) {
|
||||
candles := sampleCandles(80)
|
||||
view := MarketView{Count: 60}
|
||||
snapshot(t, "candlestick_chart", 640, 320, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 260}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// paint draws axes, visible candles, layers, and the crosshair.
|
||||
func (m *mktCore) paint(p *ui.Painter, r ui.Rect, active int) {
|
||||
g := m.geom(r)
|
||||
m.st.plot, m.st.ys = g.plot, g.ys
|
||||
k := m.k
|
||||
size := core.FontSize(m.c, theme.CaptionSize)
|
||||
label := func(v float64) string {
|
||||
if m.base != 0 {
|
||||
return internal.FormatTick(v, g.step) + "%"
|
||||
}
|
||||
return internal.FormatTick(v, g.step)
|
||||
}
|
||||
internal.AxisY(p, g.plot, g.ys, g.ticks, label, size, k)
|
||||
m.paintTimeAxis(p, g, size)
|
||||
p.Clip(g.plot, 0, func() {
|
||||
m.st.drawn = m.paintCandles(p, g)
|
||||
for _, l := range m.overlays {
|
||||
mktPaintLine(p, g, m, l.values, l.color, l.shape)
|
||||
}
|
||||
m.paintAlerts(p, g, size)
|
||||
if d := m.opts.Drawings; d != nil {
|
||||
d.paint(p, g, m, size)
|
||||
}
|
||||
})
|
||||
at := active
|
||||
if at < 0 && m.opts.Sync != nil {
|
||||
at = m.opts.Sync.cursorIndex(m.opts.SyncID, m.candles)
|
||||
}
|
||||
if at >= m.from && at < m.to {
|
||||
x := internal.Snap(p, g.x(m, at))
|
||||
y := internal.Snap(p, g.ys.Map(m.val(m.candles[at].Close)))
|
||||
p.Line(x, g.plot.Y, x, g.plot.Y+g.plot.H, 1, k.TextMuted)
|
||||
p.Line(g.plot.X, y, g.plot.X+g.plot.W, y, 1, k.TextMuted.Alpha(0.6))
|
||||
mktTag(p, g.plot.X+g.plot.W, y, label(m.val(m.candles[at].Close)), size, k.Text, k.Surface)
|
||||
}
|
||||
}
|
||||
|
||||
// paintTimeAxis labels candles about 90 DIPs apart.
|
||||
func (m *mktCore) paintTimeAxis(p *ui.Painter, g mktGeom, size float32) {
|
||||
every := max(1, int(math.Ceil(float64(90/g.band.Step()))))
|
||||
layout := core.Msg(m.c, "candlestick_chart.daily_time")
|
||||
if m.to-m.from > 1 && m.candles[m.from+1].Time.Sub(m.candles[m.from].Time) < 24*time.Hour {
|
||||
layout = "15:04"
|
||||
}
|
||||
var xs []float32
|
||||
var labels []string
|
||||
for i := m.from; i < m.to; i += every {
|
||||
xs = append(xs, g.x(m, i))
|
||||
labels = append(labels, m.candles[i].Time.Format(layout))
|
||||
}
|
||||
internal.AxisX(p, g.plot, xs, labels, size, m.k)
|
||||
}
|
||||
|
||||
// paintCandles returns the number of visible candles painted.
|
||||
func (m *mktCore) paintCandles(p *ui.Painter, g mktGeom) int {
|
||||
k := m.k
|
||||
w := max(1, g.band.Width())
|
||||
if m.opts.Type == ChartLine || m.opts.Type == ChartArea {
|
||||
var line, fill ui.Path
|
||||
for i := m.from; i < m.to; i++ {
|
||||
x, y := internal.Snap(p, g.x(m, i)), internal.Snap(p, g.ys.Map(m.val(m.candles[i].Close)))
|
||||
if i == m.from {
|
||||
line.MoveTo(x, y)
|
||||
fill.MoveTo(x, g.plot.Y+g.plot.H).LineTo(x, y)
|
||||
} else {
|
||||
line.LineTo(x, y)
|
||||
fill.LineTo(x, y)
|
||||
}
|
||||
}
|
||||
if m.opts.Type == ChartArea {
|
||||
fill.LineTo(internal.Snap(p, g.x(m, m.to-1)), g.plot.Y+g.plot.H).Close()
|
||||
p.FillPath(&fill, k.AccentText.Alpha(0.18))
|
||||
}
|
||||
p.StrokePath(&line, 2, k.AccentText)
|
||||
return m.to - m.from
|
||||
}
|
||||
for i := m.from; i < m.to; i++ {
|
||||
c := m.candles[i]
|
||||
up := c.Close >= c.Open
|
||||
col := core.TrendColor(m.c, 1)
|
||||
if !up {
|
||||
col = core.TrendColor(m.c, -1)
|
||||
}
|
||||
x := internal.Snap(p, g.x(m, i))
|
||||
yo, yc := g.ys.Map(m.val(c.Open)), g.ys.Map(m.val(c.Close))
|
||||
yh, yl := g.ys.Map(m.val(c.High)), g.ys.Map(m.val(c.Low))
|
||||
if m.opts.Type == ChartOHLC {
|
||||
p.Line(x, yh, x, yl, 1.5, col)
|
||||
p.Line(x-w/2, yo, x, yo, 1.5, col)
|
||||
p.Line(x, yc, x+w/2, yc, 1.5, col)
|
||||
continue
|
||||
}
|
||||
top, bot := min(yo, yc), max(yo, yc)
|
||||
body := ui.Rect{X: x - w/2, Y: top, W: w, H: max(1, bot-top)}
|
||||
p.Line(x, yh, x, top, 1, col)
|
||||
p.Line(x, bot, x, yl, 1, col)
|
||||
// Rises are hollow; falls are filled.
|
||||
if up && w >= 3 {
|
||||
p.Fill(body, k.Surface, 0)
|
||||
p.Stroke(body, col, 0, 1)
|
||||
} else {
|
||||
p.Fill(body, col, 0)
|
||||
}
|
||||
}
|
||||
return m.to - m.from
|
||||
}
|
||||
|
||||
// mktPaintLine draws visible values with distinct series markers.
|
||||
func mktPaintLine(p *ui.Painter, g mktGeom, m *mktCore, values []float64, col ui.Color, shape int) {
|
||||
var path ui.Path
|
||||
open := false
|
||||
for i := m.from; i < m.to; i++ {
|
||||
v := values[i]
|
||||
if math.IsNaN(v) {
|
||||
open = false
|
||||
continue
|
||||
}
|
||||
x, y := internal.Snap(p, g.x(m, i)), internal.Snap(p, g.ys.Map(v))
|
||||
if open {
|
||||
path.LineTo(x, y)
|
||||
} else {
|
||||
path.MoveTo(x, y)
|
||||
open = true
|
||||
}
|
||||
}
|
||||
p.StrokePath(&path, 1.5, col)
|
||||
every := max(1, int(60/max(g.band.Step(), 0.1)))
|
||||
for i := m.from + shape%every; i < m.to; i += every {
|
||||
if v := values[i]; !math.IsNaN(v) {
|
||||
internal.Marker(p, shape, g.x(m, i), g.ys.Map(v), 6, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mktTag centers a price label on the right edge.
|
||||
func mktTag(p *ui.Painter, right, y float32, s string, size float32, fg, bg ui.Color) {
|
||||
w, h := p.MeasureText(0, ui.Span{Text: s, Size: size})
|
||||
b := ui.Rect{X: right - w - 8, Y: y - h/2 - 2, W: w + 8, H: h + 4}
|
||||
p.Fill(b, bg, theme.ControlRadius)
|
||||
p.Stroke(b, fg, theme.ControlRadius, 1)
|
||||
p.Text(b.X+4, b.Y+2, s, size, fg)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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: core.Tokens(c), size: core.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 = core.Abs32(y.Map(plot, n.Value) - (py - dy))
|
||||
case NoteVLine:
|
||||
d = core.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(core.Abs32(x.Map(plot, n.Value)-(px-dx)), core.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(core.Msg(a.c, "chart_annotation.y"), a.opts.Y.Label(n.End))
|
||||
}
|
||||
text := fmt.Sprintf(core.Msg(a.c, "chart_annotation.readout"), editable[*index]+1, fmt.Sprintf(core.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,123 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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,265 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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: core.Tokens(c), size: core.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,158 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
)
|
||||
|
||||
func axisIn(opts ChartAxisOptions) (a ChartAxisPart) {
|
||||
ui.NewTester(func(c *ui.Context) { core.Use(c, core.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 := core.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) { core.Use(c, core.Settings{}); view(c) }, 320, 240)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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) core.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 := input.NumberInputResolve(input.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 := core.Tokens(c)
|
||||
u := core.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 core.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(core.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(core.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(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Role(ui.RoleStatus)
|
||||
})
|
||||
res = core.NewChoiceResult(res.Element, *value != before)
|
||||
if res.Changed() {
|
||||
c.Announce(fmt.Sprintf(core.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 input.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,168 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
type brushHarness struct {
|
||||
value [2]float64
|
||||
changes int
|
||||
disabled bool
|
||||
el *ui.Element
|
||||
}
|
||||
|
||||
func (h *brushHarness) view(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.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) {
|
||||
core.Use(c, core.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,84 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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: core.Tokens(c), size: core.FontSize(c, theme.CaptionSize), pad: core.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,90 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/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) { core.Use(c, core.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,35 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
// 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 data.DataStatus, opts ChartEmptyStateOptions) core.ChoiceResult {
|
||||
if opts.Height < 0 {
|
||||
panic("mujica: ChartEmptyState Height must be ≥ 0")
|
||||
}
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 240
|
||||
}
|
||||
var res core.ChoiceResult
|
||||
res.Element = ui.Column(c).Height(h).FillWidth().MinWidth(0)
|
||||
res.Element.Children(func() {
|
||||
res = core.NewChoiceResult(res.Element, data.DataPlaceholder(c, status, opts.Error, opts.Empty, core.Msg(c, "chart_empty_state.empty")) && !res.Element.IsDisabled())
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
func TestChartEmptyStateKeepsSizeAndRetries(t *testing.T) {
|
||||
status := data.DataReady
|
||||
retries, disabled := 0, false
|
||||
var heights []float32
|
||||
var el *ui.Element
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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 = data.DataLoading
|
||||
tt.Frame()
|
||||
heights = append(heights, el.Bounds().H)
|
||||
if !tt.HasText("Loading") {
|
||||
t.Fatalf("loading: %q", tt.Texts())
|
||||
}
|
||||
status = data.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 []data.DataStatus{data.DataReady, data.DataLoading, data.DataFailed} {
|
||||
ui.Column(c).Width(192).Border(1, core.Tokens(c).Border).Children(func() {
|
||||
ChartEmptyState(c, st, ChartEmptyStateOptions{Height: 180})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image/png"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
settings, tokens := core.CurrentSettings(c), core.Tokens(c)
|
||||
ready := false
|
||||
tester := ui.NewTester(func(c *ui.Context) {
|
||||
if !ready {
|
||||
return
|
||||
}
|
||||
core.Use(c, settings)
|
||||
ui.Column(c).Fill().Background(tokens.Surface).Padding(core.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,95 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestChartExportPNGInTheme(t *testing.T) {
|
||||
for _, mode := range []core.Mode{core.Light, core.Dark} {
|
||||
var out []byte
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.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 == core.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) {
|
||||
core.Use(c, core.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) {
|
||||
core.Use(c, core.Settings{Mode: core.Dark, Locale: core.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ChartFrame is what every chart shares.
|
||||
type ChartFrame struct {
|
||||
// Label names the chart for assistive technology; required.
|
||||
Label string
|
||||
// Height is the plot height in DIPs; 0 means 240.
|
||||
Height float32
|
||||
// Status shows loading or failure in the plot's place.
|
||||
Status data.DataStatus
|
||||
Error string
|
||||
// Empty replaces the built-in "No data" text.
|
||||
Empty string
|
||||
}
|
||||
|
||||
// ChartResult is a built chart and this frame's events.
|
||||
type ChartResult struct {
|
||||
Element *ui.Element
|
||||
// Point is the hovered or focused mark, -1 for none.
|
||||
Point int
|
||||
changed, retry bool
|
||||
toggled string
|
||||
value bool
|
||||
}
|
||||
|
||||
// Changed reports that the readout moved to another mark.
|
||||
func (r ChartResult) Changed() bool { return r.changed }
|
||||
|
||||
// Retried reports a click on a failed load's retry.
|
||||
func (r ChartResult) Retried() bool { return r.retry }
|
||||
|
||||
// ValueChanged reports a changed selection, path, year or pause.
|
||||
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 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 {
|
||||
focus, last int
|
||||
hidden map[string]bool
|
||||
}
|
||||
|
||||
// chart is a chart being built.
|
||||
type chart struct {
|
||||
c *ui.Context
|
||||
frame ChartFrame
|
||||
st *chartState
|
||||
Res ChartResult
|
||||
}
|
||||
|
||||
// NewChart starts a chart; ask it which series are hidden.
|
||||
func NewChart(c *ui.Context, frame ChartFrame) *chart {
|
||||
if frame.Label == "" || frame.Height < 0 {
|
||||
panic("mujica: chart needs a Label and a non-negative Height")
|
||||
}
|
||||
if frame.Height == 0 {
|
||||
frame.Height = 240
|
||||
}
|
||||
ch := &chart{c: c, frame: frame}
|
||||
ch.Res.Element = ui.Column(c).Gap(core.Density(c).Unit() * 2).MinWidth(0)
|
||||
ch.st = ui.Local(ch.Res.Element, "mujica.chart", func() chartState { return chartState{focus: -1, last: -1, hidden: map[string]bool{}} })
|
||||
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) {
|
||||
c, k, st := ch.c, core.Tokens(ch.c), ch.st
|
||||
u := core.Density(c).Unit()
|
||||
var plot *ui.Element
|
||||
ch.Res.Element.Children(func() {
|
||||
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 == data.DataLoading || ch.frame.Status == data.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
|
||||
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.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)
|
||||
}
|
||||
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
|
||||
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 := core.Msg(c, "chart.hint"), k.TextMuted
|
||||
if active >= 0 {
|
||||
text, col = spec.Readout(active), k.Text
|
||||
}
|
||||
ui.Text(c, text).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(col).Role(ui.RoleStatus).SingleLine().Height(u * 5)
|
||||
})
|
||||
ch.Res.changed = ch.Res.Point != st.last
|
||||
st.last = ch.Res.Point
|
||||
return ch.Res, plot
|
||||
}
|
||||
|
||||
// input moves the keyboard mark, and takes clicks on marks.
|
||||
func (ch *chart) input(plot *ui.Element, spec ChartSpec) {
|
||||
st := ch.st
|
||||
if plot.IsDisabled() {
|
||||
return
|
||||
}
|
||||
to := st.focus
|
||||
for _, key := range []ui.Key{ui.KeyLeft, ui.KeyRight, ui.KeyUp, ui.KeyDown} {
|
||||
if !plot.Shortcut(0, key) {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case st.focus < 0:
|
||||
to = 0
|
||||
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.Marks-1)
|
||||
}
|
||||
}
|
||||
if plot.Shortcut(0, ui.KeyHome) {
|
||||
to = 0
|
||||
}
|
||||
if plot.Shortcut(0, ui.KeyEnd) {
|
||||
to = spec.Marks - 1
|
||||
}
|
||||
if plot.Shortcut(0, ui.KeyEscape) {
|
||||
to = -1
|
||||
}
|
||||
if to != st.focus {
|
||||
st.focus = to
|
||||
if to >= 0 {
|
||||
ch.c.Announce(spec.Readout(to))
|
||||
}
|
||||
slog.Debug("mujica: chart focus", "label", ch.frame.Label, "mark", to)
|
||||
}
|
||||
act := st.focus >= 0 && (plot.Shortcut(0, ui.KeyEnter) || plot.Shortcut(0, ui.KeySpace))
|
||||
if plot.Clicked() {
|
||||
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 {
|
||||
st.focus, act = i, true
|
||||
}
|
||||
}
|
||||
if act && spec.Activate != nil {
|
||||
spec.Activate(st.focus)
|
||||
}
|
||||
}
|
||||
|
||||
// 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})
|
||||
p.Text(x-w*ax, y-h*ay, s, size, col)
|
||||
return w, h
|
||||
}
|
||||
|
||||
// ChartInset is r less room for axis labels.
|
||||
func ChartInset(r ui.Rect, left, bottom float32) ui.Rect {
|
||||
return ui.Rect{X: r.X + left, Y: r.Y + 8, W: max(r.W-left-12, 1), H: max(r.H-bottom-8, 1)}
|
||||
}
|
||||
|
||||
// ChartInk is Text or Surface, whichever reads better on bg.
|
||||
func ChartInk(k theme.Tokens, bg ui.Color) ui.Color {
|
||||
if theme.Contrast(k.Text, bg) >= theme.Contrast(k.Surface, bg) {
|
||||
return k.Text
|
||||
}
|
||||
return k.Surface
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// chartBuilders builds every Charts I component with a small data set.
|
||||
func chartBuilders() map[string]func(c *ui.Context) ChartResult {
|
||||
year := 2026
|
||||
var path, path2 []string
|
||||
var sel []ScatterKey
|
||||
var graph NetworkGraphState
|
||||
var rt RealtimeChartState
|
||||
buf := NewRealtimeBuffer(10, "a")
|
||||
buf.Append(rtStart, 1)
|
||||
buf.Append(rtStart.Add(time.Second), 2)
|
||||
target := 3.0
|
||||
f := ChartFrame{Label: "Under test"}
|
||||
return map[string]func(c *ui.Context) ChartResult{
|
||||
"line": func(c *ui.Context) ChartResult { return LineChart(c, lineSeries(), LineChartOptions{Frame: f}) },
|
||||
"area": func(c *ui.Context) ChartResult { return AreaChart(c, lineSeries(), AreaChartOptions{Frame: f}) },
|
||||
"bar": func(c *ui.Context) ChartResult { return BarChart(c, barCats, barSeries(), BarChartOptions{Frame: f}) },
|
||||
"pie": func(c *ui.Context) ChartResult { return PieChart(c, pieSlices, PieChartOptions{Frame: f}) },
|
||||
"scat": func(c *ui.Context) ChartResult {
|
||||
return ScatterChart(c, &sel, scatterGroups, ScatterChartOptions{Frame: f})
|
||||
},
|
||||
"radar": func(c *ui.Context) ChartResult {
|
||||
return RadarChart(c, radarAxes, radarSeries(), RadarChartOptions{Frame: f})
|
||||
},
|
||||
"heat": func(c *ui.Context) ChartResult {
|
||||
return HeatmapChart(c, heatRows, heatCols, heatValues(), HeatmapChartOptions{Frame: f})
|
||||
},
|
||||
"cal": func(c *ui.Context) ChartResult {
|
||||
return CalendarHeatmap(c, &year, calendarValues(), CalendarHeatmapOptions{Frame: f})
|
||||
},
|
||||
"hist": func(c *ui.Context) ChartResult { return Histogram(c, []float64{1, 2, 3}, HistogramOptions{Frame: f}) },
|
||||
"box": func(c *ui.Context) ChartResult { return BoxPlot(c, boxGroups, BoxPlotOptions{Frame: f}) },
|
||||
"fall": func(c *ui.Context) ChartResult {
|
||||
return WaterfallChart(c, waterfallSteps, WaterfallChartOptions{Frame: f})
|
||||
},
|
||||
"funnel": func(c *ui.Context) ChartResult { return FunnelChart(c, funnelStages, FunnelChartOptions{Frame: f}) },
|
||||
"bullet": func(c *ui.Context) ChartResult {
|
||||
return BulletChart(c, 2, BulletChartOptions{Frame: f, Target: &target, Bands: []float64{1, 4}})
|
||||
},
|
||||
"spark": func(c *ui.Context) ChartResult { return Sparkline(c, sparkValues, SparklineOptions{Frame: f}) },
|
||||
"gauge": func(c *ui.Context) ChartResult {
|
||||
return Gauge(c, 72, GaugeOptions{Frame: f, Max: 100, Segments: gaugeSegments})
|
||||
},
|
||||
"tree": func(c *ui.Context) ChartResult { return Treemap(c, &path, chartTree(), TreemapOptions{Frame: f}) },
|
||||
"sun": func(c *ui.Context) ChartResult { return Sunburst(c, &path2, chartTree(), SunburstOptions{Frame: f}) },
|
||||
"sankey": func(c *ui.Context) ChartResult {
|
||||
return SankeyChart(c, sankeyNodes, sankeyLinks, SankeyChartOptions{Frame: f})
|
||||
},
|
||||
"graph": func(c *ui.Context) ChartResult {
|
||||
return NetworkGraph(c, &graph, graphNodes, graphEdges, NetworkGraphOptions{Frame: f})
|
||||
},
|
||||
"rt": func(c *ui.Context) ChartResult {
|
||||
return RealtimeChart(c, &rt, buf, RealtimeChartOptions{Frame: f, Window: time.Minute})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartsIgnoreInputWhenDisabled(t *testing.T) {
|
||||
for name, build := range chartBuilders() {
|
||||
values := 0
|
||||
h := &chartHarness{disabled: true, build: func(c *ui.Context) ChartResult {
|
||||
r := build(c)
|
||||
if r.ValueChanged() || r.Retried() {
|
||||
values++
|
||||
}
|
||||
if _, ok := r.Toggled(); ok {
|
||||
values++
|
||||
}
|
||||
return r
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 560, 480)
|
||||
for range 6 {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
}
|
||||
b := h.res.Element.Bounds()
|
||||
tt.Click("Under test")
|
||||
tt.ClickAt(b.X+b.W/2, b.Y+b.H/2)
|
||||
if h.res.Point != -1 || values != 0 {
|
||||
t.Errorf("%s: disabled chart took input: point %d, events %d", name, h.res.Point, values)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartsEnterAtFirstMark(t *testing.T) {
|
||||
for name, build := range chartBuilders() {
|
||||
h := &chartHarness{build: build}
|
||||
tt := ui.NewTester(h.view, 560, 480)
|
||||
tabTo(t, tt, "Under test")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if h.res.Point != 0 || chartReadout(tt) == msg2(tt) {
|
||||
t.Errorf("%s: first arrow read point %d", name, h.res.Point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// msg2 is the hint line, read back to tell it from a readout.
|
||||
func msg2(*ui.Tester) string { return "Focus the chart and use the arrow keys to read values" }
|
||||
@@ -0,0 +1,69 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/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 := core.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,67 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestChartGridPixelAlignedWithMinor(t *testing.T) {
|
||||
for _, scale := range []float32{1, 2} {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.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) { core.Use(c, core.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,142 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.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(core.Msg(c, "chart_legend.more"), len(items)-opts.MaxItems)
|
||||
if *open {
|
||||
label = core.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(core.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 := core.Tokens(c)
|
||||
u := core.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(core.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(core.FontSize(c, theme.CaptionSize)).TextColor(k.Text)
|
||||
if !on {
|
||||
t.Strikethrough().TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
return it.Name, switched
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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) {
|
||||
core.Use(c, core.Settings{Mode: core.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) {
|
||||
core.Use(c, core.Settings{Mode: core.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) {
|
||||
core.Use(c, core.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(core.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) {
|
||||
core.Use(c, core.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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
)
|
||||
|
||||
// ChartSeries is named values; NaN marks a missing one.
|
||||
type ChartSeries struct {
|
||||
Name string
|
||||
Values []float64
|
||||
}
|
||||
|
||||
type ChartMemoEntry struct {
|
||||
deps any
|
||||
Val any
|
||||
}
|
||||
|
||||
// ChartMemo recomputes f only when the comparable deps change.
|
||||
func ChartMemo[T any](ch *chart, name string, deps any, f func() T) T {
|
||||
m := ui.Local(ch.Res.Element, "mujica.memo."+name, func() ChartMemoEntry { return ChartMemoEntry{} })
|
||||
if m.Val == nil || m.deps != deps {
|
||||
m.deps, m.Val = deps, f()
|
||||
}
|
||||
return m.Val.(T)
|
||||
}
|
||||
|
||||
// ChartID identifies a slice by first element and length.
|
||||
func ChartID[T any](s []T) string {
|
||||
if len(s) == 0 {
|
||||
return "0"
|
||||
}
|
||||
return fmt.Sprintf("%p:%d", &s[0], len(s))
|
||||
}
|
||||
|
||||
// chartSeriesID identifies series data and the hidden set together.
|
||||
func chartSeriesID(ch *chart, series []ChartSeries) string {
|
||||
var b strings.Builder
|
||||
for _, s := range series {
|
||||
fmt.Fprintf(&b, "%s|%s|%v;", s.Name, ChartID(s.Values), ch.Hidden(s.Name))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// chartCheckSeries panics on duplicate names or unequal lengths.
|
||||
func chartCheckSeries(series []ChartSeries) int {
|
||||
if len(series) == 0 {
|
||||
return 0
|
||||
}
|
||||
n := len(series[0].Values)
|
||||
seen := map[string]bool{}
|
||||
for _, s := range series {
|
||||
if s.Name == "" || seen[s.Name] || len(s.Values) != n {
|
||||
panic("mujica: chart series need unique names and equal lengths: " + s.Name)
|
||||
}
|
||||
seen[s.Name] = true
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ChartX is an ascending axis of numbers, times or indices.
|
||||
type ChartX struct {
|
||||
Vals []float64
|
||||
times bool
|
||||
// d0 and d1, when d1 > d0, fix the domain.
|
||||
d0, d1 float64
|
||||
}
|
||||
|
||||
func NewChartX(n int, x []float64, times []time.Time) ChartX {
|
||||
switch {
|
||||
case x != nil && times != nil:
|
||||
panic("mujica: chart takes X or Times, not both")
|
||||
case x != nil && len(x) != n, times != nil && len(times) != n:
|
||||
panic("mujica: chart X or Times length differs from the series")
|
||||
}
|
||||
a := ChartX{Vals: x, times: times != nil}
|
||||
if times != nil {
|
||||
a.Vals = make([]float64, n)
|
||||
for i, t := range times {
|
||||
a.Vals[i] = float64(t.UnixNano()) / 1e9
|
||||
}
|
||||
}
|
||||
if a.Vals == nil {
|
||||
a.Vals = make([]float64, n)
|
||||
for i := range a.Vals {
|
||||
a.Vals[i] = float64(i)
|
||||
}
|
||||
}
|
||||
if !sort.Float64sAreSorted(a.Vals) {
|
||||
panic("mujica: chart X or Times must ascend")
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (a ChartX) Scale(plot ui.Rect) internal.Linear {
|
||||
d0, d1 := a.Vals[0], a.Vals[len(a.Vals)-1]
|
||||
if a.d1 > a.d0 {
|
||||
d0, d1 = a.d0, a.d1
|
||||
}
|
||||
return internal.Linear{D0: d0, D1: d1, R0: float64(plot.X), R1: float64(plot.X + plot.W)}
|
||||
}
|
||||
|
||||
func (a ChartX) label(i int) string {
|
||||
if a.times {
|
||||
return chartTime(a.Vals[i]).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return strconv.FormatFloat(a.Vals[i], 'g', 6, 64)
|
||||
}
|
||||
|
||||
// Ticks returns tick positions and labels along plot.
|
||||
func (a ChartX) Ticks(plot ui.Rect) ([]float32, []string) {
|
||||
s := a.Scale(plot)
|
||||
n := max(2, int(plot.W/90))
|
||||
var xs []float32
|
||||
var labels []string
|
||||
if a.times {
|
||||
ts, layout := internal.TimeTicks(chartTime(s.D0), chartTime(s.D1), n)
|
||||
for _, t := range ts {
|
||||
xs = append(xs, s.Map(float64(t.UnixNano())/1e9))
|
||||
labels = append(labels, t.Format(layout))
|
||||
}
|
||||
return xs, labels
|
||||
}
|
||||
lo, hi, step := internal.Nice(s.D0, s.D1, n)
|
||||
for _, t := range internal.Ticks(lo, hi, step) {
|
||||
if t >= s.D0 && t <= s.D1 {
|
||||
xs = append(xs, s.Map(t))
|
||||
labels = append(labels, internal.FormatTick(t, step))
|
||||
}
|
||||
}
|
||||
return xs, labels
|
||||
}
|
||||
|
||||
// Nearest returns the index whose x is closest to v.
|
||||
func (a ChartX) Nearest(v float64) int {
|
||||
i := sort.SearchFloat64s(a.Vals, v)
|
||||
if i == len(a.Vals) || i > 0 && v-a.Vals[i-1] < a.Vals[i]-v {
|
||||
i--
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func chartTime(sec float64) time.Time {
|
||||
return time.Unix(0, int64(math.Round(sec*1e9))).UTC()
|
||||
}
|
||||
|
||||
// chartYDomain spans visible finite values, optionally with zero.
|
||||
func chartYDomain(series []ChartSeries, hidden func(string) bool, withZero bool) (lo, hi float64, ok bool) {
|
||||
lo, hi = math.Inf(1), math.Inf(-1)
|
||||
for _, s := range series {
|
||||
if hidden(s.Name) {
|
||||
continue
|
||||
}
|
||||
for _, v := range s.Values {
|
||||
if !math.IsNaN(v) {
|
||||
lo, hi = min(lo, v), max(hi, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if math.IsInf(lo, 1) {
|
||||
return 0, 0, false
|
||||
}
|
||||
if withZero {
|
||||
lo, hi = min(lo, 0), max(hi, 0)
|
||||
}
|
||||
return lo, hi, true
|
||||
}
|
||||
|
||||
// ChartValue formats v, or names a missing value.
|
||||
func ChartValue(c *ui.Context, v float64, format func(float64) string) string {
|
||||
if math.IsNaN(v) {
|
||||
return core.Msg(c, "chart.missing")
|
||||
}
|
||||
if format != nil {
|
||||
return format(v)
|
||||
}
|
||||
return strconv.FormatFloat(v, 'g', 6, 64)
|
||||
}
|
||||
|
||||
// chartRuns calls f per finite run, thinned to pixel columns.
|
||||
func chartRuns(vals []float64, x func(i int) float32, f func(idx []int)) {
|
||||
for i := 0; i < len(vals); {
|
||||
if math.IsNaN(vals[i]) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
j := i
|
||||
for j < len(vals) && !math.IsNaN(vals[j]) {
|
||||
j++
|
||||
}
|
||||
run := internal.Columns(j-i, func(k int) float32 { return x(i + k) }, func(k int) float64 { return vals[i+k] })
|
||||
for k := range run {
|
||||
run[k] += i
|
||||
}
|
||||
f(run)
|
||||
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,140 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ChartSync shares time windows and crosshairs across linked charts.
|
||||
type ChartSync struct {
|
||||
// Count is linked zoom; zero defaults to 120.
|
||||
Count int
|
||||
// End is inclusive; zero follows each chart's latest candle.
|
||||
End time.Time
|
||||
// Start is the inclusive left edge across candle intervals.
|
||||
Start time.Time
|
||||
// Cursor is the crosshair time; zero shows none.
|
||||
Cursor time.Time
|
||||
// Unlinked charts keep their own view and crosshair.
|
||||
Unlinked map[string]bool
|
||||
owner string
|
||||
rangeOwner string
|
||||
loaded map[string]MarketView
|
||||
}
|
||||
|
||||
func (s *ChartSync) linked(id string) bool { return !s.Unlinked[id] }
|
||||
|
||||
// load applies the shared time window to linked charts.
|
||||
func (s *ChartSync) load(id string, candles []Candle, view *MarketView) {
|
||||
if !s.linked(id) {
|
||||
return
|
||||
}
|
||||
view.Count, view.End = s.Count, 0
|
||||
if !s.End.IsZero() {
|
||||
if i := marketIndexAt(candles, s.End); i+1 < len(candles) {
|
||||
view.End = max(1, i+1)
|
||||
}
|
||||
}
|
||||
if s.rangeOwner == "" {
|
||||
s.rangeOwner = id
|
||||
}
|
||||
if len(candles) > 0 && (s.Start.IsZero() || s.End.IsZero() && s.rangeOwner == id) {
|
||||
from, _ := marketWindow(*view, len(candles), len(candles))
|
||||
s.Start = candles[from].Time
|
||||
}
|
||||
if !s.Start.IsZero() && len(candles) > 0 {
|
||||
from := sort.Search(len(candles), func(i int) bool { return !candles[i].Time.Before(s.Start) })
|
||||
to := len(candles)
|
||||
if view.End > 0 {
|
||||
to = view.End
|
||||
}
|
||||
if from < to {
|
||||
view.Count = to - from
|
||||
}
|
||||
}
|
||||
if s.loaded == nil {
|
||||
s.loaded = map[string]MarketView{}
|
||||
}
|
||||
s.loaded[id] = *view
|
||||
}
|
||||
|
||||
// store updates the shared window and active crosshair.
|
||||
func (s *ChartSync) store(id string, candles []Candle, view *MarketView, active int) {
|
||||
if !s.linked(id) {
|
||||
return
|
||||
}
|
||||
loaded, ok := s.loaded[id]
|
||||
if !ok || *view != loaded {
|
||||
s.Count, s.End, s.rangeOwner = view.Count, time.Time{}, id
|
||||
if view.End > 0 && view.End <= len(candles) {
|
||||
s.End = candles[view.End-1].Time
|
||||
}
|
||||
if len(candles) > 0 {
|
||||
from, _ := marketWindow(*view, len(candles), len(candles))
|
||||
s.Start = candles[from].Time
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case active >= 0:
|
||||
s.Cursor, s.owner = candles[active].Time, id
|
||||
case s.owner == id:
|
||||
s.Cursor, s.owner = time.Time{}, ""
|
||||
}
|
||||
}
|
||||
|
||||
// cursorIndex is the candle at the linked crosshair, or -1.
|
||||
func (s *ChartSync) cursorIndex(id string, candles []Candle) int {
|
||||
if !s.linked(id) || s.Cursor.IsZero() || s.owner == id {
|
||||
return -1
|
||||
}
|
||||
return marketIndexAt(candles, s.Cursor)
|
||||
}
|
||||
|
||||
// Link toggles synchronization for a stable chart ID.
|
||||
func (s *ChartSync) Link(c *ui.Context, id string) core.ChoiceResult {
|
||||
if id == "" {
|
||||
panic("mujica: ChartSync.Link needs an id")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
on := s.linked(id)
|
||||
pressed := on
|
||||
b := ui.ToggleBase(c, &pressed).MinHeight(theme.MinHitSize).Padding(0, core.Density(c).Unit()*2).Gap(core.Density(c).Unit()).
|
||||
Radius(theme.ControlRadius).Label(core.Msg(c, "chart_sync.link"))
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
changed := b.Changed() && !b.IsDisabled()
|
||||
if changed {
|
||||
if s.Unlinked == nil {
|
||||
s.Unlinked = map[string]bool{}
|
||||
}
|
||||
on = pressed
|
||||
s.Unlinked[id] = !on
|
||||
if s.owner == id {
|
||||
s.Cursor, s.owner = time.Time{}, ""
|
||||
}
|
||||
slog.Debug("mujica: chart_sync link", "chart", id, "linked", on)
|
||||
}
|
||||
fg, bg, word := k.TextMuted, ui.Transparent, core.Msg(c, "chart_sync.unlinked")
|
||||
if on {
|
||||
fg, bg, word = k.AccentText, k.Selection, core.Msg(c, "chart_sync.linked")
|
||||
}
|
||||
if b.Hovered() && !on {
|
||||
bg = k.SurfaceHover
|
||||
}
|
||||
b.Background(bg).TextColor(fg).Border(theme.BorderWidth, fg).Transition(core.ColorFade)
|
||||
b.Children(func() {
|
||||
mark := "circle"
|
||||
if on {
|
||||
mark = "check"
|
||||
}
|
||||
ui.Box(c).Size(14, 14).Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, r, mark, fg) })
|
||||
ui.Text(c, word).FontSize(core.FontSize(c, theme.CaptionSize)).SingleLine()
|
||||
})
|
||||
return core.NewChoiceResult(b, changed)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestChartSyncRangeCursorAndUnlink(t *testing.T) {
|
||||
cs := sampleCandles(300)
|
||||
sync := ChartSync{}
|
||||
v := MarketView{Count: 40, End: 200}
|
||||
sync.store("a", cs, &v, 150)
|
||||
var other MarketView
|
||||
sync.load("b", cs, &other)
|
||||
if other != v || sync.cursorIndex("b", cs) != 150 {
|
||||
t.Fatalf("sync %+v %+v", other, sync)
|
||||
}
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Disabled(disabled).Children(func() { sync.Link(c, "b") })
|
||||
}, 300, 90)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if sync.linked("b") || sync.cursorIndex("b", cs) != -1 {
|
||||
t.Fatal("unlink failed")
|
||||
}
|
||||
other = MarketView{Count: 12}
|
||||
sync.load("b", cs, &other)
|
||||
if other.Count != 12 {
|
||||
t.Fatal("unlinked view overwritten")
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if sync.linked("b") {
|
||||
t.Fatal("disabled link changed")
|
||||
}
|
||||
sync.store("a", cs, &v, -1)
|
||||
if !sync.Cursor.IsZero() {
|
||||
t.Fatal("cursor remained after leaving owner")
|
||||
}
|
||||
}
|
||||
func TestChartSyncSnapshot(t *testing.T) {
|
||||
cs := sampleCandles(50)
|
||||
sync := ChartSync{Cursor: cs[25].Time, owner: "a"}
|
||||
v := MarketView{Count: 50}
|
||||
snapshot(t, "chart_sync", 640, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
sync.Link(c, "b")
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Linked", Height: 230}, Sync: &sync, SyncID: "b"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
size := core.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 := core.PlaceOverlay(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)
|
||||
core.OverlayStyle(c, tip, core.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,104 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestChartTooltipFlipsAtEdges(t *testing.T) {
|
||||
var at [2]float32
|
||||
var tip ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.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) { core.Use(c, core.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(core.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) {
|
||||
core.Use(c, core.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,172 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ChartNode is a hierarchy node; branches sum their leaves.
|
||||
type ChartNode struct {
|
||||
Name string
|
||||
Value float64
|
||||
Children []ChartNode
|
||||
}
|
||||
|
||||
// total is the node's value, or its children's sum.
|
||||
func (n ChartNode) total() float64 {
|
||||
if len(n.Children) == 0 {
|
||||
return n.Value
|
||||
}
|
||||
s := 0.0
|
||||
for _, ch := range n.Children {
|
||||
s += ch.total()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// chartCheckTree panics on unnamed, duplicate-sibling or negative nodes.
|
||||
func chartCheckTree(n ChartNode) {
|
||||
if n.Name == "" || n.Value < 0 || math.IsNaN(n.Value) || math.IsInf(n.Value, 0) {
|
||||
panic("mujica: chart nodes need a name and a finite value ≥ 0")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, ch := range n.Children {
|
||||
if seen[ch.Name] {
|
||||
panic("mujica: chart node has two children named " + ch.Name)
|
||||
}
|
||||
seen[ch.Name] = true
|
||||
chartCheckTree(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// chartDescend returns the node at path, panicking if unknown.
|
||||
func chartDescend(root ChartNode, path []string) ChartNode {
|
||||
n := root
|
||||
for _, name := range path {
|
||||
i := slices.IndexFunc(n.Children, func(ch ChartNode) bool { return ch.Name == name })
|
||||
if i < 0 {
|
||||
panic("mujica: chart path has no node " + name)
|
||||
}
|
||||
n = n.Children[i]
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// chartPath shows Back and the breadcrumb; reports a move up.
|
||||
func chartPath(c *ui.Context, root string, path *[]string, disabled bool) bool {
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
moved := false
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).Wrap().Children(func() {
|
||||
back := core.IconAction(c, icons.Must("chevron-left"), core.Msg(c, "chart.back")).Disabled(disabled || len(*path) == 0)
|
||||
if back.Clicked() && !back.IsDisabled() {
|
||||
*path, moved = (*path)[:len(*path)-1], true
|
||||
}
|
||||
names := append([]string{root}, *path...)
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
ui.Text(c, "›").TextColor(k.TextMuted)
|
||||
}
|
||||
if i == len(names)-1 {
|
||||
ui.Text(c, name).FontWeight(600).SingleLine()
|
||||
continue
|
||||
}
|
||||
crumb := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u).Radius(theme.ControlRadius).Disabled(disabled).TextColor(k.AccentText)
|
||||
internal.Ring(crumb, k.Focus, theme.ControlRadius)
|
||||
crumb.Children(func() { ui.Text(c, name).Underline().SingleLine() })
|
||||
if crumb.Clicked() && !crumb.IsDisabled() {
|
||||
*path, moved = slices.Clone((*path)[:i]), true
|
||||
}
|
||||
}
|
||||
})
|
||||
if moved {
|
||||
slog.Debug("mujica: chart path", "path", strings.Join(*path, "/"))
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
// chartFit shortens s with an ellipsis to fit w.
|
||||
func chartFit(p *ui.Painter, s string, w, size float32) string {
|
||||
r := []rune(s)
|
||||
for len(r) > 0 {
|
||||
t := string(r)
|
||||
if len(r) < len([]rune(s)) {
|
||||
t += "…"
|
||||
}
|
||||
if tw, _ := p.MeasureText(0, ui.Span{Text: t, Size: size}); tw <= w {
|
||||
return t
|
||||
}
|
||||
r = r[:len(r)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// squarify tiles descending values into near-square rectangles.
|
||||
func squarify(values []float64, r ui.Rect) []ui.Rect {
|
||||
out := make([]ui.Rect, len(values))
|
||||
total := 0.0
|
||||
for _, v := range values {
|
||||
total += v
|
||||
}
|
||||
if total <= 0 {
|
||||
return out
|
||||
}
|
||||
scale := float64(r.W*r.H) / total
|
||||
for i := 0; i < len(values); {
|
||||
side := float64(min(r.W, r.H))
|
||||
j, best := i+1, math.Inf(1)
|
||||
for ; j <= len(values); j++ {
|
||||
worst := squarifyWorst(values[i:j], side, scale)
|
||||
if worst > best {
|
||||
break
|
||||
}
|
||||
best = worst
|
||||
}
|
||||
j--
|
||||
row := 0.0
|
||||
for _, v := range values[i:j] {
|
||||
row += v * scale
|
||||
}
|
||||
thick := float32(row / side)
|
||||
at := float32(0)
|
||||
for k := i; k < j; k++ {
|
||||
l := float32(values[k]*scale) / thick
|
||||
if r.W >= r.H {
|
||||
out[k] = ui.Rect{X: r.X, Y: r.Y + at, W: thick, H: l}
|
||||
} else {
|
||||
out[k] = ui.Rect{X: r.X + at, Y: r.Y, W: l, H: thick}
|
||||
}
|
||||
at += l
|
||||
}
|
||||
if r.W >= r.H {
|
||||
r.X, r.W = r.X+thick, r.W-thick
|
||||
} else {
|
||||
r.Y, r.H = r.Y+thick, r.H-thick
|
||||
}
|
||||
i = j
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// squarifyWorst is a row's worst aspect ratio along side.
|
||||
func squarifyWorst(row []float64, side, scale float64) float64 {
|
||||
sum, lo, hi := 0.0, math.Inf(1), 0.0
|
||||
for _, v := range row {
|
||||
a := v * scale
|
||||
sum, lo, hi = sum+a, min(lo, a), max(hi, a)
|
||||
}
|
||||
if lo == 0 {
|
||||
return math.Inf(1)
|
||||
}
|
||||
s2 := side * side
|
||||
return max(s2*hi/(sum*sum), sum*sum/(s2*lo))
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// ChartTypeSwitcherOptions configure a ChartTypeSwitcher.
|
||||
type ChartTypeSwitcherOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// ChartTypeSwitcher chooses candles, OHLC, line, area, or Heikin-Ashi.
|
||||
func ChartTypeSwitcher(c *ui.Context, value *ChartType, opts ChartTypeSwitcherOptions) core.ChoiceResult {
|
||||
if value == nil {
|
||||
panic("mujica: ChartTypeSwitcher needs a value")
|
||||
}
|
||||
if *value < ChartCandles || *value > ChartHeikinAshi {
|
||||
panic(fmt.Sprintf("mujica: unknown ChartType %d", *value))
|
||||
}
|
||||
var items []input.ToggleItem[ChartType]
|
||||
for t := ChartCandles; t <= ChartHeikinAshi; t++ {
|
||||
items = append(items, input.ToggleItem[ChartType]{Value: t, Label: core.Msg(c, fmt.Sprintf("chart_type_switcher.type%d", t))})
|
||||
}
|
||||
sel := []ChartType{*value}
|
||||
var r core.ChoiceResult
|
||||
ui.Row(c).Disabled(opts.Disabled).Children(func() {
|
||||
r = input.ToggleGroup(c, &sel, items, input.ToggleGroupOptions{Label: core.Msg(c, "chart_type_switcher.label")})
|
||||
})
|
||||
if r.Changed() {
|
||||
*value = sel[0]
|
||||
slog.Debug("mujica: chart_type_switcher changed", "type", *value)
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestChartTypeSwitcherKeyboardAndDisabled(t *testing.T) {
|
||||
value := ChartCandles
|
||||
disabled := false
|
||||
changes := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if ChartTypeSwitcher(c, &value, ChartTypeSwitcherOptions{Disabled: disabled}).Changed() {
|
||||
changes++
|
||||
}
|
||||
}, 800, 100)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
for _, want := range []ChartType{ChartOHLC, ChartLine, ChartArea, ChartHeikinAshi} {
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if value != want {
|
||||
t.Fatalf("type %v, want %v", value, want)
|
||||
}
|
||||
}
|
||||
if changes != 4 {
|
||||
t.Fatalf("changes %d", changes)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if value != ChartHeikinAshi || changes != 4 {
|
||||
t.Fatal("disabled type changed")
|
||||
}
|
||||
}
|
||||
func TestChartTypeSwitcherSnapshot(t *testing.T) {
|
||||
value := ChartCandles
|
||||
snapshot(t, "chart_type_switcher", 680, 90, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(12).Children(func() { ChartTypeSwitcher(c, &value, ChartTypeSwitcherOptions{}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
pal := internal.Palette(k)
|
||||
size := core.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] + " · " + core.Msg(c, "chord_diagram.out") + " " + val(out) + " · " + core.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,113 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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,73 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestCompareSymbolPercentAndRemove(t *testing.T) {
|
||||
candles := sampleCandles(100)
|
||||
view := MarketView{Count: 50}
|
||||
eth := make([]float64, 100)
|
||||
for i := range eth {
|
||||
eth[i] = 20 + float64(i)*0.1
|
||||
}
|
||||
cmp := []CompareSymbol{{Name: "ETH", Closes: eth}}
|
||||
removed := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if CompareLegend(c, &cmp).Changed() {
|
||||
removed++
|
||||
}
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}, Compare: &cmp})
|
||||
}, 800, 420)
|
||||
tt.Key(0, ui.KeyTab) // remove ETH
|
||||
tt.Key(0, ui.KeyTab) // the chart
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
// Normalized from the first shown candle, index 50: (29.9/25 - 1) = 19.6%.
|
||||
want := "ETH 19.60"
|
||||
found := false
|
||||
for _, s := range tt.Texts() {
|
||||
found = found || strings.Contains(s, want)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("readout lacks %q: %q", want, tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Remove ETH"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cmp) != 0 || removed != 1 {
|
||||
t.Fatalf("remove: %v %d", cmp, removed)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("short closes accepted")
|
||||
}
|
||||
}()
|
||||
bad := []CompareSymbol{{Name: "X", Closes: []float64{1}}}
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}, Compare: &bad})
|
||||
}, 300, 200)
|
||||
}
|
||||
|
||||
func TestCompareSymbolSnapshot(t *testing.T) {
|
||||
candles := sampleCandles(100)
|
||||
view := MarketView{Count: 80}
|
||||
eth, sol := make([]float64, 100), make([]float64, 100)
|
||||
for i := range eth {
|
||||
eth[i] = 20 + float64(i)*0.08 + float64(i%7)*0.2
|
||||
sol[i] = 5 - float64(i)*0.01 + float64(i%5)*0.05
|
||||
}
|
||||
cmp := []CompareSymbol{{Name: "ETH", Closes: eth}, {Name: "SOL", Closes: sol}}
|
||||
snapshot(t, "compare_symbol", 640, 360, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Gap(8).Children(func() {
|
||||
CompareLegend(c, &cmp)
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 240}, Type: ChartLine, Compare: &cmp})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package chart
|
||||
|
||||
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,123 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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,185 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// DepthLevel is one price level of an order book.
|
||||
type DepthLevel struct {
|
||||
Price, Size float64
|
||||
}
|
||||
|
||||
// DepthChartOptions configure a DepthChart.
|
||||
type DepthChartOptions struct {
|
||||
Frame ChartFrame
|
||||
Decimals int
|
||||
}
|
||||
|
||||
// depthData holds cumulative bids and asks around the midpoint.
|
||||
type depthData struct {
|
||||
price, total []float64
|
||||
nBids int
|
||||
mid, maxSize float64
|
||||
}
|
||||
|
||||
// DepthChart paints cumulative depth and supports keyboard level readouts.
|
||||
func DepthChart(c *ui.Context, bids, asks []DepthLevel, opts DepthChartOptions) ChartResult {
|
||||
dec := core.PriceDecimals("DepthChart", opts.Decimals)
|
||||
ch := NewChart(c, opts.Frame)
|
||||
k := core.Tokens(c)
|
||||
d := ChartMemo(ch, "depth", [2]string{ChartID(bids), ChartID(asks)}, func() depthData { return depthBuild(bids, asks) })
|
||||
bidCol, askCol := core.TrendColor(c, 1), core.TrendColor(c, -1)
|
||||
keys := []ChartLegendItem{{Name: core.Msg(c, "depth_chart.bids"), Color: bidCol, Shape: 1}, {Name: core.Msg(c, "depth_chart.asks"), Color: askCol, Shape: 3}}
|
||||
drawn := ui.Local(ch.Res.Element, "mujica.depth.drawn", func() int { return 0 })
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
|
||||
plot := ChartInset(r, 56, 26)
|
||||
lo, hi := d.price[d.nBids-1], d.price[len(d.price)-1]
|
||||
return plot, internal.Linear{D0: lo, D1: hi, R0: float64(plot.X), R1: float64(plot.X + plot.W)},
|
||||
internal.Linear{D0: 0, D1: d.maxSize * 1.05, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
|
||||
}
|
||||
spec := ChartSpec{Marks: len(d.price), Legend: keys}
|
||||
if len(bids) == 0 || len(asks) == 0 {
|
||||
spec.Marks = 0
|
||||
}
|
||||
// Arrows move by ascending price.
|
||||
order := func(i int) int {
|
||||
if i < d.nBids {
|
||||
return d.nBids - 1 - i
|
||||
}
|
||||
return i
|
||||
}
|
||||
spec.Move = func(i int, key ui.Key) int {
|
||||
pos := order(i)
|
||||
if key == ui.KeyLeft || key == ui.KeyUp {
|
||||
pos = max(pos-1, 0)
|
||||
} else {
|
||||
pos = min(pos+1, len(d.price)-1)
|
||||
}
|
||||
return order(pos)
|
||||
}
|
||||
spec.Pick = func(r ui.Rect, x, y float32) int {
|
||||
plot, xs, _ := geom(r)
|
||||
if !plot.Contains(x, y) {
|
||||
return -1
|
||||
}
|
||||
v := xs.Invert(x)
|
||||
pos := sort.Search(len(d.price), func(i int) bool { return d.price[order(i)] >= v })
|
||||
if pos == len(d.price) || pos > 0 && v-d.price[order(pos-1)] < d.price[order(pos)]-v {
|
||||
pos--
|
||||
}
|
||||
return order(pos)
|
||||
}
|
||||
spec.Readout = func(i int) string {
|
||||
side := core.Msg(c, "depth_chart.bid")
|
||||
if i >= d.nBids {
|
||||
side = core.Msg(c, "depth_chart.ask")
|
||||
}
|
||||
return strings.Join([]string{side + " " + core.PriceUngrouped(d.price[i], dec), fmt.Sprintf(core.Msg(c, "depth_chart.total"), data.StatisticFormat(d.total[i], 0)),
|
||||
core.Msg(c, "depth_chart.mid") + " " + core.PriceUngrouped(d.mid, dec)}, " · ")
|
||||
}
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot, xs, ys := geom(r)
|
||||
_, _, ystep := internal.Nice(0, d.maxSize*1.05, 4)
|
||||
internal.AxisY(p, plot, ys, internal.Ticks(0, d.maxSize*1.05, ystep), func(v float64) string { return volumeShort(v, ystep) }, size, k)
|
||||
_, _, xstep := internal.Nice(xs.D0, xs.D1, max(2, int(plot.W/90)))
|
||||
var tx []float32
|
||||
var tl []string
|
||||
for _, t := range internal.Ticks(xs.D0, xs.D1, xstep) {
|
||||
tx, tl = append(tx, xs.Map(t)), append(tl, internal.FormatTick(t, xstep))
|
||||
}
|
||||
internal.AxisX(p, plot, tx, tl, size, k)
|
||||
base := plot.Y + plot.H
|
||||
*drawn = 0
|
||||
for side, idx := range [][2]int{{0, d.nBids}, {d.nBids, len(d.price)}} {
|
||||
col := []ui.Color{bidCol, askCol}[side]
|
||||
var area, edge ui.Path
|
||||
mx := internal.Snap(p, xs.Map(d.mid))
|
||||
area.MoveTo(mx, base)
|
||||
edge.MoveTo(mx, base)
|
||||
prevY := base
|
||||
indices := ChartMemo(ch, fmt.Sprint("depth.indices.", side), [3]any{ChartID(d.price), plot.X, plot.W}, func() []int {
|
||||
return internal.Columns(idx[1]-idx[0], func(i int) float32 { return xs.Map(d.price[idx[0]+i]) }, func(i int) float64 { return d.total[idx[0]+i] })
|
||||
})
|
||||
*drawn += len(indices)
|
||||
for _, at := range indices {
|
||||
i := idx[0] + at
|
||||
x, y := internal.Snap(p, xs.Map(d.price[i])), internal.Snap(p, ys.Map(d.total[i]))
|
||||
area.LineTo(x, prevY).LineTo(x, y)
|
||||
edge.LineTo(x, prevY).LineTo(x, y)
|
||||
prevY = y
|
||||
}
|
||||
end := plot.X
|
||||
if side == 1 {
|
||||
end = plot.X + plot.W
|
||||
}
|
||||
area.LineTo(end, prevY).LineTo(end, base).Close()
|
||||
edge.LineTo(end, prevY)
|
||||
p.FillPath(&area, col.Alpha(0.2))
|
||||
p.StrokePath(&edge, 2, col)
|
||||
label := []string{core.Msg(c, "depth_chart.bids"), core.Msg(c, "depth_chart.asks")}[side]
|
||||
ChartText(p, label, []float32{plot.X + 6, plot.X + plot.W - 6}[side], plot.Y+4, size, col, []float32{0, 1}[side], 0)
|
||||
}
|
||||
mx := internal.Snap(p, xs.Map(d.mid))
|
||||
for y := plot.Y; y < base; y += 6 {
|
||||
p.Line(mx, y, mx, min(y+3, base), 1, k.TextMuted)
|
||||
}
|
||||
ChartText(p, core.PriceUngrouped(d.mid, dec), mx, plot.Y+4, size, k.Text, 0.5, 0)
|
||||
if active >= 0 {
|
||||
x, y := xs.Map(d.price[active]), ys.Map(d.total[active])
|
||||
p.Line(internal.Snap(p, x), plot.Y, internal.Snap(p, x), base, 1, k.TextMuted)
|
||||
shape := 1
|
||||
if active >= d.nBids {
|
||||
shape = 3
|
||||
}
|
||||
internal.Marker(p, shape, x, y, 10, keys[shape/3].Color)
|
||||
}
|
||||
}
|
||||
res, _ := ch.Build(spec)
|
||||
return res
|
||||
}
|
||||
|
||||
// depthBuild validates price order and computes cumulative volume.
|
||||
func depthBuild(bids, asks []DepthLevel) depthData {
|
||||
for i, l := range bids {
|
||||
if math.IsNaN(l.Price) || math.IsInf(l.Price, 0) || math.IsInf(l.Size, 0) || !(l.Size > 0) || i > 0 && !(l.Price < bids[i-1].Price) {
|
||||
panic(fmt.Sprintf("mujica: DepthChart bid %d needs a positive size and a price below the one before", i))
|
||||
}
|
||||
}
|
||||
for i, l := range asks {
|
||||
if math.IsNaN(l.Price) || math.IsInf(l.Price, 0) || math.IsInf(l.Size, 0) || !(l.Size > 0) || i > 0 && !(l.Price > asks[i-1].Price) {
|
||||
panic(fmt.Sprintf("mujica: DepthChart ask %d needs a positive size and a price above the one before", i))
|
||||
}
|
||||
}
|
||||
if len(bids) > 0 && len(asks) > 0 && !(bids[0].Price < asks[0].Price) {
|
||||
panic("mujica: DepthChart best bid must be below best ask")
|
||||
}
|
||||
var d depthData
|
||||
d.nBids = len(bids)
|
||||
var sum float64
|
||||
for _, l := range bids {
|
||||
sum += l.Size
|
||||
d.price, d.total = append(d.price, l.Price), append(d.total, sum)
|
||||
}
|
||||
d.maxSize = sum
|
||||
sum = 0
|
||||
for _, l := range asks {
|
||||
sum += l.Size
|
||||
d.price, d.total = append(d.price, l.Price), append(d.total, sum)
|
||||
}
|
||||
d.maxSize = max(d.maxSize, sum)
|
||||
if len(bids) > 0 && len(asks) > 0 {
|
||||
d.mid = (bids[0].Price + asks[0].Price) / 2
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func sampleBook() ([]DepthLevel, []DepthLevel) {
|
||||
var bids, asks []DepthLevel
|
||||
for i := range 12 {
|
||||
bids = append(bids, DepthLevel{Price: 99.9 - float64(i)*0.1, Size: float64(10 + i*3)})
|
||||
asks = append(asks, DepthLevel{Price: 100.1 + float64(i)*0.1, Size: float64(8 + i*4)})
|
||||
}
|
||||
return bids, asks
|
||||
}
|
||||
|
||||
func TestDepthChartCumulativeAndKeyboard(t *testing.T) {
|
||||
bids, asks := sampleBook()
|
||||
d := depthBuild(bids, asks)
|
||||
if d.mid != 100 || d.total[0] != 10 || d.total[1] != 23 || d.total[12] != 8 || d.total[13] != 20 {
|
||||
t.Fatalf("cumulative %+v", d.total[:14])
|
||||
}
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
res = DepthChart(c, bids, asks, DepthChartOptions{Frame: ChartFrame{Label: "Depth"}})
|
||||
}, 700, 360)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight) // the best bid first
|
||||
if res.Point != 0 || !tt.HasText("Bid 99.90 · cumulative 10 · Mid 100.00") {
|
||||
t.Fatalf("first level %d %q", res.Point, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft) // outward to lower bids
|
||||
if res.Point != 1 || !tt.HasText("Bid 99.80 · cumulative 23") {
|
||||
t.Fatalf("lower bid %d %q", res.Point, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if res.Point != 12 || !tt.HasText("Ask 100.10 · cumulative 8") {
|
||||
t.Fatalf("crossing to asks %d %q", res.Point, tt.Texts())
|
||||
}
|
||||
for name, book := range map[string][2][]DepthLevel{
|
||||
"bid order": {{{Price: 1, Size: 1}, {Price: 2, Size: 1}}, nil},
|
||||
"crossed": {{{Price: 5, Size: 1}}, {{Price: 4, Size: 1}}},
|
||||
"size": {nil, {{Price: 4, Size: 0}}},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
depthBuild(book[0], book[1])
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepthChartSnapshot(t *testing.T) {
|
||||
bids, asks := sampleBook()
|
||||
snapshot(t, "depth_chart", 640, 320, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
DepthChart(c, bids, asks, DepthChartOptions{Frame: ChartFrame{Label: "Depth", Height: 240}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package chart holds MujicaUI general and market charts with their shared frame.
|
||||
package chart
|
||||
@@ -0,0 +1,129 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// DrawingToolbarOptions configure a DrawingToolbar.
|
||||
type DrawingToolbarOptions struct {
|
||||
// Horizontal uses a row; the default uses a column.
|
||||
Horizontal bool
|
||||
}
|
||||
|
||||
// DrawingToolbar selects tools and manages lock, clear, and undo.
|
||||
func DrawingToolbar(c *ui.Context, d *DrawingTools, opts DrawingToolbarOptions) core.ChoiceResult {
|
||||
if d == nil {
|
||||
panic("mujica: DrawingToolbar needs drawings")
|
||||
}
|
||||
if d.Tool < ToolSelect || d.Tool > ToolText {
|
||||
panic(fmt.Sprintf("mujica: unknown DrawingTool %d", d.Tool))
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
changed := false
|
||||
bar := ui.Column(c).Gap(u).Padding(u).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Role(ui.RoleToolbar).Label(core.Msg(c, "drawing_toolbar.label"))
|
||||
if opts.Horizontal {
|
||||
bar.Row().FocusGroup(ui.Horizontal)
|
||||
} else {
|
||||
bar.FocusGroup(ui.Vertical)
|
||||
}
|
||||
pick := func(t DrawingTool) {
|
||||
if !d.Locked && d.Tool != t && !bar.IsDisabled() {
|
||||
d.Tool, changed = t, true
|
||||
d.pending, d.moving = nil, false
|
||||
slog.Debug("mujica: drawing_toolbar tool", "tool", t)
|
||||
}
|
||||
}
|
||||
digits := []ui.Key{ui.Key1, ui.Key2, ui.Key3, ui.Key4, ui.Key5, ui.Key6}
|
||||
for t := ToolSelect; t <= ToolText; t++ {
|
||||
if c.Shortcut(ui.Alt, digits[t]) {
|
||||
pick(t)
|
||||
}
|
||||
}
|
||||
bar.Children(func() {
|
||||
for t := ToolSelect; t <= ToolText; t++ {
|
||||
name := fmt.Sprintf("%s (Alt+%d)", core.Msg(c, fmt.Sprintf("drawing_toolbar.tool%d", t)), t+1)
|
||||
on := d.Tool == t
|
||||
b := ui.ButtonBase(c).Key(t).Size(theme.MinHitSize+4, theme.MinHitSize+4).Radius(theme.ControlRadius).Label(name).Tooltip(name).Disabled(d.Locked)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
fg := k.Text
|
||||
switch {
|
||||
case on:
|
||||
b.Background(k.Selection).Border(theme.BorderWidth, k.AccentText)
|
||||
fg = k.AccentText
|
||||
case b.Hovered():
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Draw(func(p *ui.Painter, r ui.Rect) { drawingToolGlyph(p, r, t, fg) })
|
||||
if b.Clicked() {
|
||||
pick(t)
|
||||
}
|
||||
}
|
||||
div := ui.Box(c).Background(k.Border).Role(ui.RoleNone)
|
||||
if opts.Horizontal {
|
||||
div.Size(theme.BorderWidth, u*6)
|
||||
} else {
|
||||
div.Size(u*6, theme.BorderWidth)
|
||||
}
|
||||
lockName := core.Msg(c, "drawing_toolbar.lock")
|
||||
if d.Locked {
|
||||
lockName = core.Msg(c, "drawing_toolbar.unlock")
|
||||
}
|
||||
lock := core.IconAction(c, icons.Must("lock"), lockName).Tooltip(lockName)
|
||||
if d.Locked {
|
||||
lock.Background(k.Selection).TextColor(k.AccentText)
|
||||
}
|
||||
if lock.Clicked() && !bar.IsDisabled() {
|
||||
d.Locked, changed = !d.Locked, true
|
||||
d.Tool = ToolSelect
|
||||
slog.Debug("mujica: drawing_toolbar lock", "locked", d.Locked)
|
||||
}
|
||||
undo := core.IconAction(c, icons.Must("rotate-ccw"), core.Msg(c, "drawing_toolbar.undo")).Tooltip(core.Msg(c, "drawing_toolbar.undo")).Disabled(d.Locked || !d.CanUndo())
|
||||
if undo.Clicked() && !bar.IsDisabled() && d.Undo() {
|
||||
changed = true
|
||||
}
|
||||
clear := core.IconAction(c, icons.Must("trash-2"), core.Msg(c, "drawing_toolbar.clear")).Tooltip(core.Msg(c, "drawing_toolbar.clear")).Disabled(d.Locked || len(d.Items) == 0)
|
||||
if clear.Clicked() && !bar.IsDisabled() && d.Clear() {
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
return core.NewChoiceResult(bar, changed)
|
||||
}
|
||||
|
||||
// drawingToolGlyph draws a small picture of tool t in r.
|
||||
func drawingToolGlyph(p *ui.Painter, r ui.Rect, t DrawingTool, col ui.Color) {
|
||||
cx, cy := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2)
|
||||
s := float32(8)
|
||||
switch t {
|
||||
case ToolSelect:
|
||||
var path ui.Path
|
||||
path.MoveTo(cx-5, cy-7).LineTo(cx+5, cy+1).LineTo(cx, cy+1).LineTo(cx+3, cy+7).LineTo(cx+1, cy+8).LineTo(cx-2, cy+2).LineTo(cx-5, cy+5).Close()
|
||||
p.FillPath(&path, col)
|
||||
case ToolTrendLine:
|
||||
drawingStroke(p, cx-s, cy+s, cx+s, cy-s, 1.5, col)
|
||||
p.FillPath(internal.Diamond(cx-s, cy+s, 5), col)
|
||||
p.FillPath(internal.Diamond(cx+s, cy-s, 5), col)
|
||||
case ToolHorizontal:
|
||||
p.Line(cx-s, cy, cx+s, cy, 1.5, col)
|
||||
p.FillPath(internal.Diamond(cx, cy, 5), col)
|
||||
case ToolFibonacci:
|
||||
for i, f := range []float32{-1, -0.4, 0.2, 1} {
|
||||
w := []float32{1.5, 1, 1, 1.5}[i]
|
||||
p.Line(cx-s, cy+f*s, cx+s, cy+f*s, w, col)
|
||||
}
|
||||
case ToolRectangle:
|
||||
p.Stroke(ui.Rect{X: cx - s, Y: cy - s*0.7, W: 2 * s, H: 1.4 * s}, col, 1, 1.5)
|
||||
case ToolText:
|
||||
p.Line(cx-s*0.8, cy-s, cx+s*0.8, cy-s, 2, col)
|
||||
p.Line(cx, cy-s, cx, cy+s, 2, col)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestDrawingToolbarShortcutsLockAndDisabled(t *testing.T) {
|
||||
d := DrawingTools{}
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Disabled(disabled).Children(func() { DrawingToolbar(c, &d, DrawingToolbarOptions{Horizontal: true}) })
|
||||
}, 550, 90)
|
||||
tt.Key(ui.Alt, ui.Key4)
|
||||
if d.Tool != ToolFibonacci {
|
||||
t.Fatalf("tool %v", d.Tool)
|
||||
}
|
||||
if err := tt.Click("Lock drawings"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(ui.Alt, ui.Key3)
|
||||
if !d.Locked || d.Tool != ToolSelect {
|
||||
t.Fatal("locked tool changed")
|
||||
}
|
||||
if err := tt.Click("Unlock drawings"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(ui.Alt, ui.Key2)
|
||||
if d.Tool != ToolSelect {
|
||||
t.Fatal("disabled tool changed")
|
||||
}
|
||||
}
|
||||
func TestDrawingToolbarSnapshot(t *testing.T) {
|
||||
d := DrawingTools{Tool: ToolTrendLine}
|
||||
snapshot(t, "drawing_toolbar", 440, 90, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(12).Children(func() { DrawingToolbar(c, &d, DrawingToolbarOptions{Horizontal: true}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
// DrawingTool selects annotation creation or selection behavior.
|
||||
type DrawingTool int
|
||||
|
||||
const (
|
||||
ToolSelect DrawingTool = iota
|
||||
ToolTrendLine
|
||||
ToolHorizontal
|
||||
ToolFibonacci
|
||||
ToolRectangle
|
||||
ToolText
|
||||
)
|
||||
|
||||
// DrawingPoint anchors annotations to candle time and price.
|
||||
type DrawingPoint struct {
|
||||
Time time.Time
|
||||
Price float64
|
||||
}
|
||||
|
||||
// Drawing is one annotation; one-point tools use A only.
|
||||
type Drawing struct {
|
||||
Tool DrawingTool
|
||||
A, B DrawingPoint
|
||||
Text string
|
||||
}
|
||||
|
||||
// DrawingTools holds annotations, the current tool, and undo history.
|
||||
type DrawingTools struct {
|
||||
Items []Drawing
|
||||
Tool DrawingTool
|
||||
// Text is what the next text annotation says.
|
||||
Text string
|
||||
// Locked stops selecting, moving, deleting and drawing.
|
||||
Locked bool
|
||||
|
||||
sel int // selected item plus one; zero is none
|
||||
pending *Drawing
|
||||
undo [][]Drawing
|
||||
moving bool
|
||||
}
|
||||
|
||||
// fibLevels lists Fibonacci retracement ratios.
|
||||
var fibLevels = []float64{0, 0.236, 0.382, 0.5, 0.618, 0.786, 1}
|
||||
|
||||
// Selected returns the selected drawing's index, or -1.
|
||||
func (d *DrawingTools) Selected() int { return d.sel - 1 }
|
||||
|
||||
// CanUndo reports whether Undo has a step to take back.
|
||||
func (d *DrawingTools) CanUndo() bool { return len(d.undo) > 0 }
|
||||
|
||||
func (d *DrawingTools) save() {
|
||||
d.undo = append(d.undo, slices.Clone(d.Items))
|
||||
}
|
||||
|
||||
// Undo restores the preceding annotations unless locked.
|
||||
func (d *DrawingTools) Undo() bool {
|
||||
n := len(d.undo)
|
||||
if d.Locked || n == 0 {
|
||||
return false
|
||||
}
|
||||
d.Items, d.undo = d.undo[n-1], d.undo[:n-1]
|
||||
d.sel, d.pending = 0, nil
|
||||
slog.Debug("mujica: drawing_tools undo", "items", len(d.Items))
|
||||
return true
|
||||
}
|
||||
|
||||
// Delete removes the selected drawing, undoably.
|
||||
func (d *DrawingTools) Delete() bool {
|
||||
if d.Locked || d.sel == 0 || d.sel > len(d.Items) {
|
||||
return false
|
||||
}
|
||||
d.save()
|
||||
d.Items = slices.Delete(slices.Clone(d.Items), d.sel-1, d.sel)
|
||||
d.sel = 0
|
||||
slog.Debug("mujica: drawing_tools delete")
|
||||
return true
|
||||
}
|
||||
|
||||
// Clear removes every drawing, undoably.
|
||||
func (d *DrawingTools) Clear() bool {
|
||||
if d.Locked || len(d.Items) == 0 {
|
||||
return false
|
||||
}
|
||||
d.save()
|
||||
d.Items, d.sel, d.pending = nil, 0, nil
|
||||
slog.Debug("mujica: drawing_tools clear")
|
||||
return true
|
||||
}
|
||||
|
||||
// twoPoint reports whether t needs a second click.
|
||||
func (t DrawingTool) twoPoint() bool {
|
||||
return t == ToolTrendLine || t == ToolFibonacci || t == ToolRectangle
|
||||
}
|
||||
|
||||
// point maps plot coordinates onto the nearest candle.
|
||||
func (d *DrawingTools) point(m *mktCore, g mktGeom, x, y float32) DrawingPoint {
|
||||
return DrawingPoint{Time: m.candles[g.index(m, x)].Time, Price: m.priceAt(g.ys.Invert(y))}
|
||||
}
|
||||
|
||||
// priceAt resolves an axis value into a rounded price.
|
||||
func (m *mktCore) priceAt(v float64) float64 {
|
||||
if m.base != 0 {
|
||||
v = m.base * (1 + v/100)
|
||||
}
|
||||
p := math.Pow(10, float64(m.dec))
|
||||
return math.Round(v*p) / p
|
||||
}
|
||||
|
||||
// xy places a drawing point in the plot.
|
||||
func (d *DrawingTools) xy(m *mktCore, g mktGeom, pt DrawingPoint) (float32, float32) {
|
||||
i := max(0, marketIndexAt(m.candles, pt.Time))
|
||||
return g.x(m, i), g.ys.Map(m.val(pt.Price))
|
||||
}
|
||||
|
||||
// input places, selects, moves, and deletes chart annotations.
|
||||
func (d *DrawingTools) input(m *mktCore, plot *ui.Element, g mktGeom, x, y float32, over, held bool, dx, dy float32) bool {
|
||||
if d.Tool < ToolSelect || d.Tool > ToolText {
|
||||
panic(fmt.Sprintf("mujica: unknown DrawingTool %d", d.Tool))
|
||||
}
|
||||
if d.pending != nil && d.pending.Tool != d.Tool {
|
||||
d.pending, d.moving = nil, false
|
||||
}
|
||||
if plot.Shortcut(ui.Alt, ui.KeyEnter) && !d.Locked && len(d.Items) > 0 {
|
||||
d.sel = d.sel%len(d.Items) + 1
|
||||
m.st.alertFocus = -1
|
||||
m.c.Announce(fmt.Sprintf(core.Msg(m.c, "drawing_tools.selected"), d.sel))
|
||||
}
|
||||
if plot.Shortcut(ui.Cmd, ui.KeyZ) {
|
||||
d.Undo()
|
||||
}
|
||||
if d.Locked {
|
||||
d.pending, d.moving = nil, false
|
||||
return false
|
||||
}
|
||||
if plot.Shortcut(0, ui.KeyDelete) || plot.Shortcut(0, ui.KeyBackspace) {
|
||||
d.Delete()
|
||||
}
|
||||
if plot.Shortcut(0, ui.KeyEscape) {
|
||||
d.pending, d.sel = nil, 0
|
||||
}
|
||||
if s := d.sel - 1; s >= 0 && s < len(d.Items) {
|
||||
step := map[ui.Key][2]int{ui.KeyLeft: {-1, 0}, ui.KeyRight: {1, 0}, ui.KeyUp: {0, 1}, ui.KeyDown: {0, -1}}
|
||||
for key, v := range step {
|
||||
if plot.Shortcut(ui.Alt, key) {
|
||||
d.save()
|
||||
d.nudge(m, s, v[0], float64(v[1])*g.step/10)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !over && !held {
|
||||
return false
|
||||
}
|
||||
if plot.Clicked() && d.Tool != ToolSelect {
|
||||
pt := d.point(m, g, x, y)
|
||||
d.place(m, pt)
|
||||
return true
|
||||
}
|
||||
if d.pending != nil {
|
||||
d.pending.B = d.point(m, g, x, y)
|
||||
return true
|
||||
}
|
||||
if d.Tool != ToolSelect {
|
||||
return held
|
||||
}
|
||||
switch {
|
||||
case held && !d.moving:
|
||||
hit := d.hit(m, g, x-dx, y-dy)
|
||||
if hit < 0 {
|
||||
d.sel = 0
|
||||
return false
|
||||
}
|
||||
d.sel, d.moving = hit+1, true
|
||||
d.save()
|
||||
case !held:
|
||||
d.moving = false
|
||||
if plot.Clicked() {
|
||||
d.sel = d.hit(m, g, x, y) + 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
if dx != 0 || dy != 0 {
|
||||
steps := int(math.Round(float64(dx / g.band.Step())))
|
||||
d.nudge(m, d.sel-1, steps, m.priceAt(g.ys.Invert(y))-m.priceAt(g.ys.Invert(y-dy)))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// nudge moves annotations by candles and price.
|
||||
func (d *DrawingTools) nudge(m *mktCore, i, candles int, price float64) {
|
||||
it := &d.Items[i]
|
||||
move := func(pt *DrawingPoint) {
|
||||
j := max(0, min(len(m.candles)-1, marketIndexAt(m.candles, pt.Time)+candles))
|
||||
pt.Time, pt.Price = m.candles[j].Time, pt.Price+price
|
||||
}
|
||||
move(&it.A)
|
||||
move(&it.B)
|
||||
}
|
||||
|
||||
// hit finds the newest annotation within 6 DIPs.
|
||||
func (d *DrawingTools) hit(m *mktCore, g mktGeom, x, y float32) int {
|
||||
for i := len(d.Items) - 1; i >= 0; i-- {
|
||||
it := d.Items[i]
|
||||
ax, ay := d.xy(m, g, it.A)
|
||||
bx, by := d.xy(m, g, it.B)
|
||||
near := false
|
||||
switch it.Tool {
|
||||
case ToolHorizontal:
|
||||
near = math.Abs(float64(y-ay)) <= 6
|
||||
case ToolText:
|
||||
near = math.Abs(float64(x-ax)) <= 40 && math.Abs(float64(y-ay)) <= 10
|
||||
case ToolRectangle, ToolFibonacci:
|
||||
l, r, t, b := min(ax, bx)-6, max(ax, bx)+6, min(ay, by)-6, max(ay, by)+6
|
||||
near = x >= l && x <= r && y >= t && y <= b
|
||||
default:
|
||||
near = drawingSegmentDist(x, y, ax, ay, bx, by) <= 6
|
||||
}
|
||||
if near {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// drawingSegmentDist measures distance to a line segment.
|
||||
func drawingSegmentDist(x, y, ax, ay, bx, by float32) float64 {
|
||||
vx, vy := float64(bx-ax), float64(by-ay)
|
||||
t := 0.0
|
||||
if l := vx*vx + vy*vy; l > 0 {
|
||||
t = max(0, min(1, (float64(x-ax)*vx+float64(y-ay)*vy)/l))
|
||||
}
|
||||
return math.Hypot(float64(x-ax)-t*vx, float64(y-ay)-t*vy)
|
||||
}
|
||||
|
||||
func (d *DrawingTools) place(m *mktCore, pt DrawingPoint) {
|
||||
if d.Tool.twoPoint() && d.pending == nil {
|
||||
d.pending = &Drawing{Tool: d.Tool, A: pt, B: pt}
|
||||
} else {
|
||||
item := Drawing{Tool: d.Tool, A: pt, B: pt, Text: d.Text}
|
||||
if d.pending != nil {
|
||||
item.A = d.pending.A
|
||||
}
|
||||
if d.Tool == ToolText && item.Text == "" {
|
||||
item.Text = core.Msg(m.c, "drawing_tools.note")
|
||||
}
|
||||
d.save()
|
||||
d.Items = append(slices.Clone(d.Items), item)
|
||||
d.sel, d.pending, d.Tool = len(d.Items), nil, ToolSelect
|
||||
slog.Debug("mujica: drawing_tools add", "tool", item.Tool)
|
||||
}
|
||||
m.c.Invalidate()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
)
|
||||
|
||||
// paint draws annotations, previews, and selected handles.
|
||||
func (d *DrawingTools) paint(p *ui.Painter, g mktGeom, m *mktCore, size float32) {
|
||||
k := m.k
|
||||
items := d.Items
|
||||
if d.pending != nil {
|
||||
items = append(append([]Drawing(nil), items...), *d.pending)
|
||||
}
|
||||
for i, it := range items {
|
||||
col := k.Info
|
||||
width := float32(1.5)
|
||||
selected := i == d.sel-1
|
||||
if selected {
|
||||
col, width = k.AccentText, 2.5
|
||||
}
|
||||
ax, ay := d.xy(m, g, it.A)
|
||||
bx, by := d.xy(m, g, it.B)
|
||||
ax, ay, bx, by = internal.Snap(p, ax), internal.Snap(p, ay), internal.Snap(p, bx), internal.Snap(p, by)
|
||||
switch it.Tool {
|
||||
case ToolTrendLine:
|
||||
drawingStroke(p, ax, ay, bx, by, width, col)
|
||||
case ToolHorizontal:
|
||||
p.Line(g.plot.X, ay, g.plot.X+g.plot.W, ay, width, col)
|
||||
ChartText(p, core.PriceUngrouped(it.A.Price, m.dec), g.plot.X+4, ay-2, size, col, 0, 1)
|
||||
case ToolRectangle:
|
||||
r := ui.Rect{X: min(ax, bx), Y: min(ay, by), W: max(bx, ax) - min(ax, bx), H: max(by, ay) - min(ay, by)}
|
||||
p.Fill(r, col.Alpha(0.1), 0)
|
||||
p.Stroke(r, col, 0, width)
|
||||
case ToolFibonacci:
|
||||
for _, lv := range fibLevels {
|
||||
price := it.B.Price + (it.A.Price-it.B.Price)*lv
|
||||
y := internal.Snap(p, g.ys.Map(m.val(price)))
|
||||
p.Line(min(ax, bx), y, max(ax, bx), y, 1, col)
|
||||
ChartText(p, strconv.FormatFloat(lv, 'f', -1, 64)+" · "+core.PriceUngrouped(price, m.dec), max(ax, bx)+4, y, size, col, 0, 0.5)
|
||||
}
|
||||
drawingStroke(p, ax, ay, bx, by, 1, col.Alpha(0.5))
|
||||
case ToolText:
|
||||
w, h := ChartText(p, it.Text, ax, ay, size, k.Text, 0.5, 0.5)
|
||||
p.Stroke(ui.Rect{X: ax - w/2 - 4, Y: ay - h/2 - 2, W: w + 8, H: h + 4}, col, 2, width/1.5)
|
||||
}
|
||||
if selected {
|
||||
for _, pt := range [][2]float32{{ax, ay}, {bx, by}} {
|
||||
p.FillPath(internal.Diamond(pt[0], pt[1], 9), k.Surface)
|
||||
p.FillPath(internal.Diamond(pt[0], pt[1], 7), col)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drawingStroke(p *ui.Painter, ax, ay, bx, by, width float32, col ui.Color) {
|
||||
var path ui.Path
|
||||
path.MoveTo(internal.Snap(p, ax), internal.Snap(p, ay)).LineTo(internal.Snap(p, bx), internal.Snap(p, by))
|
||||
p.StrokePath(&path, width, col)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestDrawingToolsPlaceMoveDeleteUndo(t *testing.T) {
|
||||
cs := sampleCandles(60)
|
||||
v := MarketView{Count: 60}
|
||||
d := DrawingTools{Tool: ToolHorizontal}
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawing"}, Drawings: &d})
|
||||
})
|
||||
}, 640, 340)
|
||||
tt.ClickAt(300, 100)
|
||||
if len(d.Items) != 1 || d.Items[0].Tool != ToolHorizontal || d.Selected() != 0 {
|
||||
t.Fatalf("drawings %+v", d)
|
||||
}
|
||||
before := d.Items[0].A.Price
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if d.Items[0].A.Price <= before {
|
||||
t.Fatal("keyboard move failed")
|
||||
}
|
||||
tt.Key(0, ui.KeyDelete)
|
||||
if len(d.Items) != 0 {
|
||||
t.Fatal("delete failed")
|
||||
}
|
||||
tt.Key(ui.Cmd, ui.KeyZ)
|
||||
if len(d.Items) != 1 {
|
||||
t.Fatal("undo failed")
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyDelete)
|
||||
tt.ClickAt(200, 150)
|
||||
if len(d.Items) != 1 {
|
||||
t.Fatal("disabled drawing changed")
|
||||
}
|
||||
}
|
||||
func TestDrawingToolsSnapshot(t *testing.T) {
|
||||
cs := sampleCandles(60)
|
||||
v := MarketView{Count: 60}
|
||||
d := DrawingTools{Items: []Drawing{{Tool: ToolTrendLine, A: DrawingPoint{cs[10].Time, 98}, B: DrawingPoint{cs[45].Time, 105}}, {Tool: ToolHorizontal, A: DrawingPoint{cs[20].Time, 100}}}, sel: 1}
|
||||
snapshot(t, "drawing_tools", 640, 320, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawings", Height: 260}, Drawings: &d})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDrawingToolsTrendLinePaint(t *testing.T) {
|
||||
cs := sampleCandles(2)
|
||||
img := ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.Light})
|
||||
m := &mktCore{c: c, k: core.Tokens(c), candles: cs, from: 0, to: 2}
|
||||
g := mktGeom{plot: ui.Rect{W: 200, H: 200}, band: internal.Band{N: 2, R0: 0, R1: 200}, ys: internal.Linear{D0: 0, D1: 10, R0: 180, R1: 20}}
|
||||
d := DrawingTools{Items: []Drawing{{Tool: ToolTrendLine, A: DrawingPoint{cs[0].Time, 0}, B: DrawingPoint{cs[1].Time, 10}}}}
|
||||
ui.Box(c).Size(200, 200).Draw(func(p *ui.Painter, _ ui.Rect) { d.paint(p, g, m, 12) })
|
||||
}, 200, 200, 1)
|
||||
bg := theme.Light().Background
|
||||
pixel := img.RGBAAt(100, 100)
|
||||
if int(bg.R)-int(pixel.R) < 40 || int(bg.G)-int(pixel.G) < 20 {
|
||||
t.Fatalf("diagonal midpoint lacks the stroke: %v", pixel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawingToolsAllTools(t *testing.T) {
|
||||
for _, tool := range []DrawingTool{ToolTrendLine, ToolHorizontal, ToolFibonacci, ToolRectangle, ToolText} {
|
||||
t.Run(string(rune('0'+tool)), func(t *testing.T) {
|
||||
cs := sampleCandles(30)
|
||||
v := MarketView{Count: 30}
|
||||
d := DrawingTools{Tool: tool, Text: "Note"}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawing"}, Drawings: &d})
|
||||
}, 640, 340)
|
||||
tt.ClickAt(200, 120)
|
||||
if tool.twoPoint() {
|
||||
if d.pending == nil || len(d.Items) != 0 {
|
||||
t.Fatal("missing first anchor")
|
||||
}
|
||||
tt.ClickAt(400, 160)
|
||||
}
|
||||
if len(d.Items) != 1 || d.Items[0].Tool != tool || d.Selected() != 0 {
|
||||
t.Fatalf("created %+v", d.Items)
|
||||
}
|
||||
if tool == ToolText && d.Items[0].Text != "Note" {
|
||||
t.Fatal("annotation text lost")
|
||||
}
|
||||
if !d.Clear() || len(d.Items) != 0 || !d.Undo() || len(d.Items) != 1 {
|
||||
t.Fatal("clear or undo failed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package chart_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func ExampleLineChart() {
|
||||
series := []chart.ChartSeries{
|
||||
{Name: "Pews", Values: []float64{3, 5, math.NaN(), 4, 8}},
|
||||
{Name: "Candles", Values: []float64{5, 2, 6, 7, 1}},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.LineChart(c, series, chart.LineChartOptions{Frame: chart.ChartFrame{Label: "Chapel"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleAreaChart() {
|
||||
series := []chart.ChartSeries{
|
||||
{Name: "Matins", Values: []float64{3, 4, 6, 5}},
|
||||
{Name: "Vespers", Values: []float64{2, 3, 2, 4}},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.AreaChart(c, series, chart.AreaChartOptions{Frame: chart.ChartFrame{Label: "Attendance"}, Stacked: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBarChart() {
|
||||
categories := []string{"Nave", "Choir", "Crypt"}
|
||||
series := []chart.ChartSeries{{Name: "Gold", Values: []float64{4, -2, 3}}, {Name: "Silver", Values: []float64{2, 5, 1}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.BarChart(c, categories, series, chart.BarChartOptions{Frame: chart.ChartFrame{Label: "Tithes"}, Labels: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExamplePieChart() {
|
||||
slices := []chart.ChartSlice{{Label: "Wax", Value: 50}, {Label: "Oil", Value: 30}, {Label: "Salt", Value: 4}, {Label: "Ash", Value: 3}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.PieChart(c, slices, chart.PieChartOptions{Frame: chart.ChartFrame{Label: "Stores"}, Donut: true, MinAngle: 20})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleScatterChart() {
|
||||
var selected []chart.ScatterKey
|
||||
groups := []chart.ScatterGroup{{Name: "Monks", Points: []chart.ScatterPoint{{X: 1, Y: 2, Size: 4}, {X: 3, Y: 5, Size: 9}}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.ScatterChart(c, &selected, groups, chart.ScatterChartOptions{Frame: chart.ChartFrame{Label: "Choir"}, Bubbles: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleRadarChart() {
|
||||
axes := []string{"Voice", "Latin", "Chant", "Organ"}
|
||||
series := []chart.ChartSeries{{Name: "Agnès", Values: []float64{8, 6, 9, 4}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.RadarChart(c, axes, series, chart.RadarChartOptions{Frame: chart.ChartFrame{Label: "Skills"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleHeatmapChart() {
|
||||
rows, cols := []string{"Mon", "Tue"}, []string{"Matins", "Lauds", "Vespers"}
|
||||
values := [][]float64{{1, 4, 9}, {3, math.NaN(), 7}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.HeatmapChart(c, rows, cols, values, chart.HeatmapChartOptions{Frame: chart.ChartFrame{Label: "Hours"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleCalendarHeatmap() {
|
||||
year := 2026
|
||||
values := map[core.Date]float64{{Year: 2026, Month: time.March, Day: 4}: 5}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.CalendarHeatmap(c, &year, values, chart.CalendarHeatmapOptions{Frame: chart.ChartFrame{Label: "Prayers"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleHistogram() {
|
||||
values := []float64{1, 2, 2, 3, 4, 5, 9, 10}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.Histogram(c, values, chart.HistogramOptions{Frame: chart.ChartFrame{Label: "Ages"}, BinWidth: 5})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBoxPlot() {
|
||||
groups := []chart.BoxGroup{{Name: "Choir", Values: []float64{1, 2, 3, 4, 5, 40}}, {Name: "Nave", Values: []float64{4, 5, 6, 7}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.BoxPlot(c, groups, chart.BoxPlotOptions{Frame: chart.ChartFrame{Label: "Hymns"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleWaterfallChart() {
|
||||
steps := []chart.WaterfallStep{
|
||||
{Label: "Tithes", Value: 12},
|
||||
{Label: "Repairs", Value: -5},
|
||||
{Label: "Year", Kind: chart.WaterfallTotal},
|
||||
}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.WaterfallChart(c, steps, chart.WaterfallChartOptions{Frame: chart.ChartFrame{Label: "Ledger"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleFunnelChart() {
|
||||
stages := []chart.ChartSlice{{Label: "Visitors", Value: 1000}, {Label: "Novices", Value: 400}, {Label: "Vows", Value: 100}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.FunnelChart(c, stages, chart.FunnelChartOptions{Frame: chart.ChartFrame{Label: "Order"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleBulletChart() {
|
||||
target := 250.0
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.BulletChart(c, 270, chart.BulletChartOptions{Frame: chart.ChartFrame{Label: "Alms"}, Target: &target, Bands: []float64{150, 225, 300}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSparkline() {
|
||||
values := []float64{3, 5, 4, 7, 6, 9}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.Sparkline(c, values, chart.SparklineOptions{Frame: chart.ChartFrame{Label: "Bells"}, Kind: chart.SparklineArea})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleGauge() {
|
||||
segments := []chart.GaugeSegment{{To: 60, Label: "Calm", Tone: chart.GaugeGood}, {To: 100, Label: "Crowded", Tone: chart.GaugeDanger}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.Gauge(c, 72, chart.GaugeOptions{Frame: chart.ChartFrame{Label: "Nave"}, Max: 100, Unit: "%", Segments: segments})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleTreemap() {
|
||||
var path []string
|
||||
root := chart.ChartNode{Name: "Abbey", Children: []chart.ChartNode{
|
||||
{Name: "Library", Children: []chart.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}},
|
||||
{Name: "Kitchen", Value: 25},
|
||||
}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.Treemap(c, &path, root, chart.TreemapOptions{Frame: chart.ChartFrame{Label: "Rooms"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSunburst() {
|
||||
var path []string
|
||||
root := chart.ChartNode{Name: "Abbey", Children: []chart.ChartNode{
|
||||
{Name: "Library", Children: []chart.ChartNode{{Name: "Psalters", Value: 30}, {Name: "Maps", Value: 10}}},
|
||||
{Name: "Kitchen", Value: 25},
|
||||
}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.Sunburst(c, &path, root, chart.SunburstOptions{Frame: chart.ChartFrame{Label: "Rooms"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleSankeyChart() {
|
||||
nodes := []chart.SankeyNode{{ID: "tithe", Label: "Tithes"}, {ID: "fund", Label: "Fund"}, {ID: "roof", Label: "Roof"}}
|
||||
links := []chart.SankeyLink{{From: "tithe", To: "fund", Value: 60}, {From: "fund", To: "roof", Value: 50}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.SankeyChart(c, nodes, links, chart.SankeyChartOptions{Frame: chart.ChartFrame{Label: "Money"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleNetworkGraph() {
|
||||
var state chart.NetworkGraphState
|
||||
nodes := []chart.GraphNode{{ID: "a", Label: "Abbot"}, {ID: "b", Label: "Prior"}, {ID: "c", Label: "Novice"}}
|
||||
edges := []chart.GraphEdge{{From: "a", To: "b"}, {From: "b", To: "c"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.NetworkGraph(c, &state, nodes, edges, chart.NetworkGraphOptions{Frame: chart.ChartFrame{Label: "House"}, Force: true})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleRealtimeChart() {
|
||||
buf := chart.NewRealtimeBuffer(600, "Bell")
|
||||
start := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
for i := range 60 {
|
||||
buf.Append(start.Add(time.Duration(i)*time.Second), float64(i%7))
|
||||
}
|
||||
var state chart.RealtimeChartState
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.RealtimeChart(c, &state, buf, chart.RealtimeChartOptions{Frame: chart.ChartFrame{Label: "Sound"}, Window: 30 * time.Second})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package chart_test
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
func ExampleChartLegend() {
|
||||
hidden := map[string]bool{}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
k := core.Tokens(c)
|
||||
items := []chart.ChartLegendItem{{Name: "Pews", Color: k.Info}, {Name: "Candles", Color: k.Success, Shape: 1}}
|
||||
chart.ChartLegend(c, &hidden, items, chart.ChartLegendOptions{MaxItems: 1})
|
||||
}
|
||||
ui.Render(view, 400, 200, 1)
|
||||
}
|
||||
|
||||
func ExampleChartTooltip() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
k := core.Tokens(c)
|
||||
chart.ChartTooltip(c, 40, 40, []chart.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
x := chart.ChartAxis(c, chart.ChartAxisOptions{Max: 10})
|
||||
y := chart.ChartAxis(c, chart.ChartAxisOptions{Side: chart.SideLeft, Max: 100})
|
||||
hair := chart.ChartCrosshair(c, chart.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
axis := chart.ChartAxis(c, chart.ChartAxisOptions{Side: chart.SideLeft, Kind: chart.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
x := chart.ChartAxis(c, chart.ChartAxisOptions{Max: 10})
|
||||
y := chart.ChartAxis(c, chart.ChartAxisOptions{Side: chart.SideLeft, Max: 100})
|
||||
grid := chart.ChartGrid(c, chart.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.ChartBrush(c, &value, chart.ChartBrushOptions{Label: "Months", Max: 100, Step: 1, Values: []float64{2, 4, 3, 8, 5}})
|
||||
}
|
||||
ui.Render(view, 400, 160, 1)
|
||||
}
|
||||
|
||||
func ExampleChartAnnotation() {
|
||||
notes := []chart.ChartNote{{Kind: chart.NoteHLine, Value: 50, Text: "Goal", Draggable: true}, {Kind: chart.NoteBand, Value: 6, End: 8, Text: "Lent"}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
x := chart.ChartAxis(c, chart.ChartAxisOptions{Max: 10})
|
||||
y := chart.ChartAxis(c, chart.ChartAxisOptions{Side: chart.SideLeft, Max: 100})
|
||||
annotation := chart.ChartAnnotation(c, notes, chart.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
data, err := chart.ChartExport(c, func(c *ui.Context) {
|
||||
chart.SparkBar(c, []float64{3, -2, 5}, chart.SparkBarOptions{Frame: chart.ChartFrame{Label: "Alms"}})
|
||||
}, chart.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.SparkBar(c, values, chart.SparkBarOptions{Frame: chart.ChartFrame{Label: "Alms"}, BarWidth: 10})
|
||||
}
|
||||
ui.Render(view, 400, 100, 1)
|
||||
}
|
||||
|
||||
func ExampleTrendIndicator() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.TrendIndicator(c, 12.5, chart.TrendIndicatorOptions{Precision: 1, Unit: "%", Note: "vs Lent"})
|
||||
}
|
||||
ui.Render(view, 400, 100, 1)
|
||||
}
|
||||
|
||||
func ExampleChartEmptyState() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.ChartEmptyState(c, data.DataFailed, chart.ChartEmptyStateOptions{Height: 180, Error: "The archive did not answer"})
|
||||
}
|
||||
ui.Render(view, 400, 240, 1)
|
||||
}
|
||||
|
||||
func ExampleViolinPlot() {
|
||||
groups := []chart.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.ViolinPlot(c, groups, chart.ViolinPlotOptions{Frame: chart.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.ParallelCoordinates(c, &filters, axes, rows, chart.ParallelCoordinatesOptions{Frame: chart.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) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.ChordDiagram(c, names, matrix, chart.ChordDiagramOptions{Frame: chart.ChartFrame{Label: "Paths"}})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
|
||||
func ExampleDensityPlot() {
|
||||
groups := []chart.BoxGroup{{Name: "Matins", Values: []float64{1, 2, 2, 3, 7}}, {Name: "Vespers", Values: []float64{3, 4, 5, 6, 6}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.DensityPlot(c, groups, chart.DensityPlotOptions{Frame: chart.ChartFrame{Label: "Ages"}, Bandwidth: 1, Points: 128})
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package chart_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func ExampleMarketHeatmap() {
|
||||
var path []string
|
||||
root := chart.HeatNode{Name: "Market", Children: []chart.HeatNode{{Name: "Technology", Children: []chart.HeatNode{{Name: "AAPL", Cap: 3000, Change: 1.2}, {Name: "MSFT", Cap: 2800, Change: -0.6}}}, {Name: "Energy", Cap: 450, Change: -2.1}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r := chart.MarketHeatmap(c, &path, root, chart.MarketHeatmapOptions{Frame: chart.ChartFrame{Label: "Market"}})
|
||||
if r.Changed() || r.ValueChanged() || r.Retried() {
|
||||
fmt.Println(path, r.Point)
|
||||
}
|
||||
}
|
||||
ui.Render(view, 960, 440, 1)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package chart_test
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/chart"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
func ExampleCandlestickChart() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []chart.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window chart.MarketView
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.CandlestickChart(c, candles, &window, chart.CandlestickOptions{Frame: chart.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleOHLCChart() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []chart.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window chart.MarketView
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.OHLCChart(c, candles, &window, chart.CandlestickOptions{Frame: chart.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleHeikinAshiChart() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []chart.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window chart.MarketView
|
||||
raw := false
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.HeikinAshiChart(c, candles, &window, &raw, chart.CandlestickOptions{Frame: chart.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleLineQuoteChart() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
points := []chart.QuotePoint{{Time: at, Price: 101, Volume: 100}, {Time: at.Add(time.Hour), Price: 103, Volume: 200}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.LineQuoteChart(c, points, chart.LineQuoteChartOptions{Frame: chart.ChartFrame{Label: "BTC", Height: 200}, PrevClose: 100, RegularOpen: at.Add(30 * time.Minute), RegularClose: at.Add(45 * time.Minute)})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleVolumeChart() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []chart.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window chart.MarketView
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.VolumeChart(c, candles, &window, chart.VolumeChartOptions{Frame: chart.ChartFrame{Label: "Volume"}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleDepthChart() {
|
||||
bids := []chart.DepthLevel{{Price: 99, Size: 10}, {Price: 98, Size: 20}}
|
||||
asks := []chart.DepthLevel{{Price: 101, Size: 15}, {Price: 102, Size: 25}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.DepthChart(c, bids, asks, chart.DepthChartOptions{Frame: chart.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleVolumeProfile() {
|
||||
bins := []chart.ProfileBin{{Low: 99, High: 100, Volume: 30}, {Low: 100, High: 101, Volume: 80}, {Low: 101, High: 102, Volume: 40}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.VolumeProfile(c, bins, chart.VolumeProfileOptions{Frame: chart.ChartFrame{Label: "BTC", Height: 200}})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleMultiChartLayout() {
|
||||
state := chart.MultiChartState{Count: 2}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.MultiChartLayout(c, &state, chart.MultiChartLayoutOptions{Height: 200}, func(i int, cell *chart.ChartCell) {
|
||||
input.TextInput(c, &cell.Symbol, input.TextInputOptions{Label: "Symbol"})
|
||||
})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleChartSync() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []chart.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window chart.MarketView
|
||||
var sync chart.ChartSync
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
sync.Link(c, "price")
|
||||
chart.CandlestickChart(c, candles, &window, chart.CandlestickOptions{Frame: chart.ChartFrame{Label: "BTC", Height: 200}, Sync: &sync, SyncID: "price"})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleTimeRangeSelector() {
|
||||
value := chart.MarketRange{Preset: chart.Range1M}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.TimeRangeSelector(c, &value, chart.TimeRangeSelectorOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleIntervalSelector() {
|
||||
value := chart.Interval("1h")
|
||||
favorites := []chart.Interval{"1m", "1h", "1D"}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.IntervalSelector(c, &value, &favorites, chart.IntervalSelectorOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleChartTypeSwitcher() {
|
||||
value := chart.ChartCandles
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.ChartTypeSwitcher(c, &value, chart.ChartTypeSwitcherOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleIndicatorOverlay() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []chart.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window chart.MarketView
|
||||
overlays := []chart.IndicatorOverlay{{Kind: chart.IndicatorMA, Params: []float64{2}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.CandlestickChart(c, candles, &window, chart.CandlestickOptions{Frame: chart.ChartFrame{Label: "BTC", Height: 200}, Overlays: overlays})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleIndicatorPane() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []chart.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window chart.MarketView
|
||||
panes := chart.IndicatorPanes{Panes: []chart.IndicatorPaneSpec{{Kind: chart.IndicatorRSI, Params: []float64{2}}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.IndicatorPane(c, candles, &window, &panes, chart.IndicatorPaneOptions{Height: 200, Label: "BTC"})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleIndicatorSelector() {
|
||||
var overlays []chart.IndicatorOverlay
|
||||
var panes chart.IndicatorPanes
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.IndicatorSelector(c, &overlays, &panes, chart.IndicatorSelectorOptions{})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleDrawingTools() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []chart.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window chart.MarketView
|
||||
drawings := chart.DrawingTools{Tool: chart.ToolTrendLine}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.CandlestickChart(c, candles, &window, chart.CandlestickOptions{Frame: chart.ChartFrame{Label: "BTC", Height: 200}, Drawings: &drawings})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleDrawingToolbar() {
|
||||
var drawings chart.DrawingTools
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.DrawingToolbar(c, &drawings, chart.DrawingToolbarOptions{Horizontal: true})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleCompareSymbol() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []chart.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window chart.MarketView
|
||||
symbols := []chart.CompareSymbol{{Name: "ETH", Closes: []float64{20, 21, 23}}}
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.CompareLegend(c, &symbols)
|
||||
chart.CandlestickChart(c, candles, &window, chart.CandlestickOptions{Frame: chart.ChartFrame{Label: "BTC", Height: 200}, Compare: &symbols})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExamplePriceAlertLine() {
|
||||
at := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
candles := []chart.Candle{
|
||||
{Time: at, Open: 100, High: 103, Low: 99, Close: 102, Volume: 1200},
|
||||
{Time: at.Add(time.Hour), Open: 102, High: 104, Low: 100, Close: 101, Volume: 1600},
|
||||
{Time: at.Add(2 * time.Hour), Open: 101, High: 105, Low: 100, Close: 104, Volume: 900},
|
||||
}
|
||||
var window chart.MarketView
|
||||
alerts := []chart.PriceAlertLine{{ID: "take-profit", Price: 105}}
|
||||
triggered := ""
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.CandlestickChart(c, candles, &window, chart.CandlestickOptions{Frame: chart.ChartFrame{Label: "BTC", Height: 200}, Alerts: &alerts, OnAlert: func(alert chart.PriceAlertLine, last chart.Candle) {
|
||||
triggered = alert.ID
|
||||
}})
|
||||
ui.Text(c, triggered)
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
|
||||
func ExampleCandleCountdown() {
|
||||
view := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
chart.CandleCountdown(c, "1h", chart.CandleCountdownOptions{Location: time.UTC})
|
||||
}
|
||||
ui.Render(view, 640, 400, 1)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// FunnelChartOptions configure a FunnelChart.
|
||||
type FunnelChartOptions struct {
|
||||
Frame ChartFrame
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// FunnelChart narrows by stage, labeled with conversion rates.
|
||||
func FunnelChart(c *ui.Context, stages []ChartSlice, opts FunnelChartOptions) ChartResult {
|
||||
top := 0.0
|
||||
for _, s := range stages {
|
||||
if s.Label == "" || s.Value < 0 || math.IsNaN(s.Value) || math.IsInf(s.Value, 0) {
|
||||
panic("mujica: FunnelChart stages need a label and a finite value ≥ 0")
|
||||
}
|
||||
top = max(top, s.Value)
|
||||
}
|
||||
ch := NewChart(c, opts.Frame)
|
||||
k := core.Tokens(c)
|
||||
col := internal.Palette(k)[0]
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
n := len(stages)
|
||||
if top == 0 {
|
||||
n = 0
|
||||
}
|
||||
pct := func(a, b float64) string {
|
||||
if b == 0 {
|
||||
return "—"
|
||||
}
|
||||
return fmt.Sprintf("%.1f%%", 100*a/b)
|
||||
}
|
||||
rate := func(i int) string {
|
||||
if i == 0 {
|
||||
return pct(stages[0].Value, stages[0].Value)
|
||||
}
|
||||
return pct(stages[i].Value, stages[i-1].Value)
|
||||
}
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Band) {
|
||||
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{Marks: n}
|
||||
spec.Readout = func(i int) string {
|
||||
s := stages[i]
|
||||
return fmt.Sprintf(core.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 {
|
||||
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) {
|
||||
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) }
|
||||
for i, s := range stages {
|
||||
y0, y1 := band.Start(i), band.Start(i)+band.Width()
|
||||
w0, w1 := width(i), width(i+1)
|
||||
var shape ui.Path
|
||||
shape.MoveTo(cx-w0/2, y0).LineTo(cx+w0/2, y0).LineTo(cx+w1/2, y1).LineTo(cx-w1/2, y1).Close()
|
||||
p.FillPath(&shape, col)
|
||||
if i == active {
|
||||
p.StrokePath(&shape, 2, k.Text)
|
||||
}
|
||||
ly := (y0 + y1) / 2
|
||||
lx := plot.X + plot.W + 16
|
||||
ChartText(p, s.Label+" · "+ChartValue(c, s.Value, opts.Format), lx, ly, size, k.Text, 0, 1)
|
||||
ChartText(p, rate(i), lx, ly+2, size, k.TextMuted, 0, 0)
|
||||
}
|
||||
}
|
||||
res, _ := ch.Build(spec)
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var funnelStages = []ChartSlice{{"Visitors", 1000}, {"Novices", 400}, {"Vows", 100}, {"Abbots", 0}}
|
||||
|
||||
func TestFunnelChartReadout(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return FunnelChart(c, funnelStages, FunnelChartOptions{Frame: ChartFrame{Label: "Order"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Order")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); got != "Novices · 400 · 40.0% of the stage before · 40.0% of the first" {
|
||||
t.Fatalf("readout %q", got)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
b := h.res.Element.Bounds()
|
||||
tt.Move(b.X+b.W/3, b.Y+4+232*0.6) // third stage band
|
||||
if got := chartReadout(tt); got != "Vows · 100 · 25.0% of the stage before · 10.0% of the first" {
|
||||
t.Fatalf("hover %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFunnelChartBadAndEmpty(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return FunnelChart(c, []ChartSlice{{"a", 0}}, FunnelChartOptions{Frame: ChartFrame{Label: "Order"}})
|
||||
}}
|
||||
if tt := ui.NewTester(h.view, 520, 420); !tt.HasText("No data") {
|
||||
t.Fatal("all-zero funnel is not empty")
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a negative stage")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
FunnelChart(c, []ChartSlice{{"a", -1}}, FunnelChartOptions{Frame: ChartFrame{Label: "x"}})
|
||||
}, 300, 300)
|
||||
}
|
||||
|
||||
func TestFunnelChartSnapshot(t *testing.T) {
|
||||
snapshot(t, "funnel_chart", 520, 300, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
FunnelChart(c, funnelStages[:3], FunnelChartOptions{Frame: ChartFrame{Label: "Order", Height: 220}})
|
||||
})
|
||||
})
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// GaugeTone colors a Gauge segment; its label still names it.
|
||||
type GaugeTone int
|
||||
|
||||
const (
|
||||
GaugeNeutral GaugeTone = iota
|
||||
GaugeGood
|
||||
GaugeWarning
|
||||
GaugeDanger
|
||||
)
|
||||
|
||||
// GaugeSegment is a named dial range up to To.
|
||||
type GaugeSegment struct {
|
||||
To float64
|
||||
Label string
|
||||
Tone GaugeTone
|
||||
}
|
||||
|
||||
// GaugeOptions configure a Gauge; Frame.Height 0 means 200 DIPs.
|
||||
type GaugeOptions struct {
|
||||
Frame ChartFrame
|
||||
Min, Max float64
|
||||
// Segments split Min..Max ascending; the last ends at Max.
|
||||
Segments []GaugeSegment
|
||||
Unit string
|
||||
// Full draws a whole circle instead of a half.
|
||||
Full bool
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// Gauge points a needle at value on named segments.
|
||||
func Gauge(c *ui.Context, value float64, opts GaugeOptions) ChartResult {
|
||||
if !(opts.Max > opts.Min) || math.IsNaN(value) || value < opts.Min || value > opts.Max {
|
||||
panic("mujica: Gauge needs Min < Max and a value between them")
|
||||
}
|
||||
prev := opts.Min
|
||||
for i, s := range opts.Segments {
|
||||
if s.To <= prev || s.Label == "" || i == len(opts.Segments)-1 && s.To != opts.Max {
|
||||
panic("mujica: Gauge segments need labels and ascending ends finishing at Max")
|
||||
}
|
||||
prev = s.To
|
||||
}
|
||||
if opts.Frame.Height == 0 {
|
||||
opts.Frame.Height = 200
|
||||
}
|
||||
ch := NewChart(c, opts.Frame)
|
||||
k := core.Tokens(c)
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
tone := func(t GaugeTone) ui.Color {
|
||||
return map[GaugeTone]ui.Color{GaugeNeutral: internal.Palette(k)[5], GaugeGood: k.Success, GaugeWarning: k.Warning, GaugeDanger: k.Danger}[t]
|
||||
}
|
||||
val := func(v float64) string { return ChartValue(c, v, opts.Format) }
|
||||
seg := -1
|
||||
for i, s := range opts.Segments {
|
||||
if value <= s.To && seg < 0 {
|
||||
seg = i
|
||||
}
|
||||
}
|
||||
a0, a1 := -math.Pi/2, math.Pi/2
|
||||
if opts.Full {
|
||||
a0, a1 = -math.Pi, math.Pi
|
||||
}
|
||||
angle := func(v float64) float64 { return a0 + (a1-a0)*(v-opts.Min)/(opts.Max-opts.Min) }
|
||||
geom := func(r ui.Rect) (cx, cy, rad float32) {
|
||||
if opts.Full {
|
||||
rad = min(r.W, r.H)/2 - 12
|
||||
return r.X + r.W/2, r.Y + r.H/2, rad
|
||||
}
|
||||
rad = min(r.W/2-12, r.H-36)
|
||||
return r.X + r.W/2, r.Y + 8 + rad, rad
|
||||
}
|
||||
reading := val(value) + " " + opts.Unit
|
||||
if seg >= 0 {
|
||||
reading += " · " + opts.Segments[seg].Label
|
||||
}
|
||||
spec := ChartSpec{Marks: 1 + len(opts.Segments)}
|
||||
spec.Readout = func(i int) string {
|
||||
if i == 0 {
|
||||
return reading
|
||||
}
|
||||
s, from := opts.Segments[i-1], opts.Min
|
||||
if i > 1 {
|
||||
from = opts.Segments[i-2].To
|
||||
}
|
||||
return fmt.Sprintf("%s: %s–%s", s.Label, val(from), val(s.To))
|
||||
}
|
||||
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 {
|
||||
return 0
|
||||
}
|
||||
if d > rad+6 {
|
||||
return -1
|
||||
}
|
||||
a := math.Atan2(float64(x-cx), float64(cy-y))
|
||||
from := a0
|
||||
for i, s := range opts.Segments {
|
||||
if a >= from && a <= angle(s.To) {
|
||||
return i + 1
|
||||
}
|
||||
from = angle(s.To)
|
||||
}
|
||||
return -1
|
||||
}
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
cx, cy, rad := geom(r)
|
||||
from := a0
|
||||
if len(opts.Segments) == 0 {
|
||||
p.FillPath(internal.Sector(cx, cy, rad*0.78, rad, a0, a1), k.SurfaceHover)
|
||||
}
|
||||
for i, s := range opts.Segments {
|
||||
to := angle(s.To)
|
||||
grow := float32(0)
|
||||
if active == i+1 {
|
||||
grow = 4
|
||||
}
|
||||
p.FillPath(internal.Sector(cx, cy, rad*0.78-grow, rad+grow, from, to), tone(s.Tone))
|
||||
p.StrokePath(internal.Sector(cx, cy, rad*0.78-grow, rad+grow, from, to), 1.5, k.Surface)
|
||||
lx, ly := internal.Polar(cx, cy, rad+10, to)
|
||||
if i < len(opts.Segments)-1 {
|
||||
ChartText(p, val(s.To), lx, ly, size, k.TextMuted, 0.5, 1)
|
||||
}
|
||||
from = to
|
||||
}
|
||||
nx, ny := internal.Polar(cx, cy, rad*0.9, angle(value))
|
||||
var needle ui.Path
|
||||
needle.MoveTo(cx, cy).LineTo(internal.Snap(p, nx), internal.Snap(p, ny))
|
||||
w := float32(3)
|
||||
if active == 0 {
|
||||
w = 4.5
|
||||
}
|
||||
p.StrokePath(&needle, w, k.Text)
|
||||
p.FillPath(internal.Diamond(cx, cy, 12), k.Text)
|
||||
ty := cy + 34
|
||||
ChartText(p, val(value)+" "+opts.Unit, cx, ty, core.FontSize(c, theme.H3Size), k.Text, 0.5, 1)
|
||||
if seg >= 0 {
|
||||
ChartText(p, opts.Segments[seg].Label, cx, ty+2, size, k.TextMuted, 0.5, 0)
|
||||
}
|
||||
}
|
||||
res, _ := ch.Build(spec)
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var gaugeSegments = []GaugeSegment{{60, "Calm", GaugeGood}, {85, "Busy", GaugeWarning}, {100, "Crowded", GaugeDanger}}
|
||||
|
||||
func TestGaugeReadoutNamesSegment(t *testing.T) {
|
||||
full := false
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return Gauge(c, 72, GaugeOptions{Frame: ChartFrame{Label: "Nave"}, Max: 100, Unit: "%", Segments: gaugeSegments, Full: full})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Nave")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); got != "72 % · Busy" {
|
||||
t.Fatalf("value %q", got)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if got := chartReadout(tt); got != "Crowded: 85–100" {
|
||||
t.Fatalf("last segment %q", got)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
for _, full = range []bool{false, true} {
|
||||
tt.Frame()
|
||||
b := h.res.Element.Bounds()
|
||||
cx := b.X + b.W/2
|
||||
var cy, rad float32
|
||||
if full {
|
||||
rad = 200/2 - 12
|
||||
cy = b.Y + 100
|
||||
} else {
|
||||
rad = min(b.W/2-12, 200-36)
|
||||
cy = b.Y + 8 + rad
|
||||
}
|
||||
tt.Move(cx-rad*0.7, cy-rad*0.6) // the left of the dial: the first segment
|
||||
if got := chartReadout(tt); got != "Calm: 0–60" {
|
||||
t.Fatalf("full=%v hover %q", full, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGaugeBad(t *testing.T) {
|
||||
for name, o := range map[string]GaugeOptions{
|
||||
"range": {Max: 0},
|
||||
"segments": {Max: 100, Segments: []GaugeSegment{{50, "a", 0}, {40, "b", 0}}},
|
||||
"end": {Max: 100, Segments: []GaugeSegment{{50, "a", 0}}},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
o.Frame.Label = "x"
|
||||
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); Gauge(c, 0, o) }, 300, 300)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGaugeSnapshot(t *testing.T) {
|
||||
snapshot(t, "gauge", 560, 300, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(16).Gap(16).Children(func() {
|
||||
ui.Column(c).Width(256).Children(func() {
|
||||
Gauge(c, 72, GaugeOptions{Frame: ChartFrame{Label: "Half"}, Max: 100, Unit: "%", Segments: gaugeSegments})
|
||||
})
|
||||
ui.Column(c).Width(256).Children(func() {
|
||||
Gauge(c, 30, GaugeOptions{Frame: ChartFrame{Label: "Full"}, Max: 100, Unit: "%", Segments: gaugeSegments, Full: true})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// HeatmapChartOptions configure a HeatmapChart.
|
||||
type HeatmapChartOptions struct {
|
||||
Frame ChartFrame
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// heatSteps is the number of color steps.
|
||||
const heatSteps = 5
|
||||
|
||||
// HeatmapChart colors a matrix by value; NaN cells are dashed.
|
||||
func HeatmapChart(c *ui.Context, rows, cols []string, values [][]float64, opts HeatmapChartOptions) ChartResult {
|
||||
if len(values) != len(rows) {
|
||||
panic("mujica: HeatmapChart needs one value row per row label")
|
||||
}
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for _, r := range values {
|
||||
if len(r) != len(cols) {
|
||||
panic("mujica: HeatmapChart needs one value per column label")
|
||||
}
|
||||
for _, v := range r {
|
||||
if !math.IsNaN(v) {
|
||||
lo, hi = min(lo, v), max(hi, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
ch := NewChart(c, opts.Frame)
|
||||
k := core.Tokens(c)
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
n := len(rows) * len(cols)
|
||||
if math.IsInf(lo, 1) {
|
||||
n = 0
|
||||
}
|
||||
// level is v's color step, 0 to heatSteps-1.
|
||||
level := func(v float64) int {
|
||||
if hi == lo {
|
||||
return heatSteps - 1
|
||||
}
|
||||
return min(heatSteps-1, int((v-lo)/(hi-lo)*heatSteps))
|
||||
}
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Band) {
|
||||
plot := ui.Rect{X: r.X + 72, Y: r.Y + 4, W: max(r.W-72-64, 1), H: max(r.H-28, 1)}
|
||||
return plot, internal.Band{N: len(cols), R0: plot.X, R1: plot.X + plot.W, Pad: 0.06}, internal.Band{N: len(rows), R0: plot.Y, R1: plot.Y + plot.H, Pad: 0.06}
|
||||
}
|
||||
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{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 {
|
||||
r, col := i/len(cols), i%len(cols)
|
||||
switch key {
|
||||
case ui.KeyLeft:
|
||||
col = max(col-1, 0)
|
||||
case ui.KeyRight:
|
||||
col = min(col+1, len(cols)-1)
|
||||
case ui.KeyUp:
|
||||
r = max(r-1, 0)
|
||||
default:
|
||||
r = min(r+1, len(rows)-1)
|
||||
}
|
||||
return r*len(cols) + col
|
||||
}
|
||||
spec.Pick = func(r ui.Rect, x, y float32) int {
|
||||
plot, xb, yb := geom(r)
|
||||
if !plot.Contains(x, y) {
|
||||
return -1
|
||||
}
|
||||
col := min(len(cols)-1, int((x-plot.X)/xb.Step()))
|
||||
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) {
|
||||
plot, xb, yb := geom(r)
|
||||
for i := range len(rows) * len(cols) {
|
||||
b := cell(xb, yb, i)
|
||||
v := values[i/len(cols)][i%len(cols)]
|
||||
if math.IsNaN(v) {
|
||||
p.StrokeDashed(b, k.ControlBorder, 2, 1)
|
||||
} else {
|
||||
bg := internal.Ramp(k, (float64(level(v))+0.5)/heatSteps)
|
||||
p.Fill(b, bg, 2)
|
||||
if b.W >= 30 && b.H >= 18 {
|
||||
ChartText(p, ChartValue(c, v, opts.Format), b.X+b.W/2, b.Y+b.H/2, size, ChartInk(k, bg), 0.5, 0.5)
|
||||
}
|
||||
}
|
||||
if i == active {
|
||||
p.Stroke(ui.Rect{X: b.X - 2, Y: b.Y - 2, W: b.W + 4, H: b.H + 4}, k.Text, 3, 2)
|
||||
}
|
||||
}
|
||||
for i, name := range rows {
|
||||
ChartText(p, name, plot.X-8, yb.Center(i), size, k.TextMuted, 1, 0.5)
|
||||
}
|
||||
for i, name := range cols {
|
||||
ChartText(p, name, xb.Center(i), plot.Y+plot.H+4, size, k.TextMuted, 0.5, 0)
|
||||
}
|
||||
heatLegend(p, ui.Rect{X: plot.X + plot.W + 16, Y: plot.Y, W: 14, H: plot.H}, lo, hi, size, k, opts.Format, c)
|
||||
}
|
||||
res, _ := ch.Build(spec)
|
||||
return res
|
||||
}
|
||||
|
||||
// heatLegend stacks the color steps with the range at ends.
|
||||
func heatLegend(p *ui.Painter, r ui.Rect, lo, hi float64, size float32, k theme.Tokens, format func(float64) string, c *ui.Context) {
|
||||
h := min(r.H, 120) / heatSteps
|
||||
for i := range heatSteps {
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y + float32(heatSteps-1-i)*h, W: r.W, H: h - 1}, internal.Ramp(k, (float64(i)+0.5)/heatSteps), 1)
|
||||
}
|
||||
ChartText(p, ChartValue(c, hi, format), r.X+r.W+4, r.Y, size, k.TextMuted, 0, 0)
|
||||
ChartText(p, ChartValue(c, lo, format), r.X+r.W+4, r.Y+h*heatSteps, size, k.TextMuted, 0, 1)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var heatRows = []string{"Mon", "Tue", "Wed"}
|
||||
var heatCols = []string{"Matins", "Lauds", "Vespers", "Compline"}
|
||||
|
||||
func heatValues() [][]float64 {
|
||||
return [][]float64{{1, 4, 9, 2}, {3, math.NaN(), 7, 5}, {8, 6, 0, 3}}
|
||||
}
|
||||
|
||||
func TestHeatmapChartKeyboard2D(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return HeatmapChart(c, heatRows, heatCols, heatValues(), HeatmapChartOptions{Frame: ChartFrame{Label: "Hours"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Hours")
|
||||
tt.Key(0, ui.KeyRight) // enters at the first cell
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if got := chartReadout(tt); got != "Tue · Lauds · no value" {
|
||||
t.Fatalf("readout %q", got)
|
||||
}
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if got := chartReadout(tt); got != "Wed · Compline · 3" {
|
||||
t.Fatalf("End %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeatmapChartHover(t *testing.T) {
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return HeatmapChart(c, heatRows, heatCols, heatValues(), HeatmapChartOptions{Frame: ChartFrame{Label: "Hours"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
b := h.res.Element.Bounds()
|
||||
plotW := b.W - 72 - 64
|
||||
tt.Move(b.X+72+plotW*0.6, b.Y+4+212*0.2) // third column, first row
|
||||
if got := chartReadout(tt); got != "Mon · Vespers · 9" {
|
||||
t.Fatalf("hover %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeatmapChartBad(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for a short row")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
HeatmapChart(c, heatRows[:1], heatCols, [][]float64{{1}}, HeatmapChartOptions{Frame: ChartFrame{Label: "x"}})
|
||||
}, 300, 300)
|
||||
}
|
||||
|
||||
func TestHeatmapChartSnapshot(t *testing.T) {
|
||||
snapshot(t, "heatmap_chart", 520, 300, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
HeatmapChart(c, heatRows, heatCols, heatValues(), HeatmapChartOptions{Frame: ChartFrame{Label: "Hours", Height: 220}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestHeikinAshiChartSwitchesToRaw(t *testing.T) {
|
||||
candles := sampleCandles(30)
|
||||
view := MarketView{Count: 30}
|
||||
raw := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
HeikinAshiChart(c, candles, &view, &raw, CandlestickOptions{Frame: ChartFrame{Label: "HA"}})
|
||||
}, 600, 360)
|
||||
ha := HeikinAshi(candles)
|
||||
readout := func() string {
|
||||
for _, s := range tt.Texts() {
|
||||
if strings.HasPrefix(s, "2026-01-01 00:00") {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
tt.Key(0, ui.KeyTab) // the switch
|
||||
tt.Key(0, ui.KeyTab) // the plot
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if want := "O " + core.PriceUngrouped(ha[0].Open, 2); !strings.Contains(readout(), want) {
|
||||
t.Fatalf("Heikin-Ashi readout %q, want %q", readout(), want)
|
||||
}
|
||||
if err := tt.Click("Candles"); err != nil || !raw {
|
||||
t.Fatalf("switch: %v %v", err, raw)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if want := "O " + core.PriceUngrouped(candles[0].Open, 2); !strings.Contains(readout(), want) {
|
||||
t.Fatalf("raw readout %q, want %q", readout(), want)
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("nil raw accepted")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
HeikinAshiChart(c, candles, &view, nil, CandlestickOptions{})
|
||||
}, 200, 100)
|
||||
}
|
||||
|
||||
func TestHeikinAshiChartSnapshot(t *testing.T) {
|
||||
candles := sampleCandles(70)
|
||||
view := MarketView{Count: 60}
|
||||
raw := false
|
||||
snapshot(t, "heikin_ashi_chart", 640, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
HeikinAshiChart(c, candles, &view, &raw, CandlestickOptions{Frame: ChartFrame{Label: "HA", Height: 230}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal/snaptest"
|
||||
)
|
||||
|
||||
// tabTo presses Tab until the element labeled label has the focus.
|
||||
func tabTo(t *testing.T, tt *ui.Tester, label string) {
|
||||
t.Helper()
|
||||
for range 60 {
|
||||
if tt.Focused(label) {
|
||||
return
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
t.Fatalf("Tab never reached %q", label)
|
||||
}
|
||||
|
||||
// snapshot compares view in both themes with testdata/snapshots/<os>/<name>-<mode>.png.
|
||||
func snapshot(t *testing.T, name string, w, h int, view func(c *ui.Context)) {
|
||||
t.Helper()
|
||||
snaptest.Run(t, name, w, h, snapshotTheme, view, nil)
|
||||
}
|
||||
|
||||
func toRGBA(c ui.Color) color.RGBA { return color.RGBA{c.R, c.G, c.B, c.A} }
|
||||
|
||||
func encode(t *testing.T, img image.Image) []byte {
|
||||
t.Helper()
|
||||
return snaptest.Encode(t, img)
|
||||
}
|
||||
|
||||
func mktHeat() HeatNode {
|
||||
return HeatNode{Name: "Market", Children: []HeatNode{
|
||||
{Name: "Technology", Children: []HeatNode{{Name: "AAPL", Cap: 3000, Change: 1.2}, {Name: "MSFT", Cap: 2800, Change: -0.6}, {Name: "NVDA", Cap: 2200, Change: 3.4}}},
|
||||
{Name: "Energy", Children: []HeatNode{{Name: "XOM", Cap: 450, Change: -2.1}, {Name: "CVX", Cap: 300, Change: -1.4}}},
|
||||
{Name: "Health", Children: []HeatNode{{Name: "LLY", Cap: 700, Change: 0.4}, {Name: "JNJ", Cap: 380, Change: 0}}},
|
||||
}}
|
||||
}
|
||||
|
||||
func chatTester(w, h int, view func(c *ui.Context)) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Padding(10).Fill().Children(func() { view(c) })
|
||||
}, w, h)
|
||||
}
|
||||
|
||||
func marketsPanic(t *testing.T, view func(*ui.Context)) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic")
|
||||
}
|
||||
}()
|
||||
ui.Render(func(c *ui.Context) { core.Use(c, core.Settings{}); view(c) }, 960, 500, 1)
|
||||
}
|
||||
|
||||
func snapshotTheme(c *ui.Context, mode string) {
|
||||
m := core.Light
|
||||
if mode == "dark" {
|
||||
m = core.Dark
|
||||
}
|
||||
core.Use(c, core.Settings{Mode: m})
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// HistogramOptions configure a Histogram.
|
||||
type HistogramOptions struct {
|
||||
Frame ChartFrame
|
||||
// Bins is the bin count; 0 uses Sturges' rule.
|
||||
Bins int
|
||||
BinWidth float64
|
||||
// Cumulative counts every value up to each bin's upper edge.
|
||||
Cumulative bool
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// histogramMaxBins bounds the bins a width may make.
|
||||
const histogramMaxBins = 10_000
|
||||
|
||||
type histBins struct {
|
||||
start, width float64
|
||||
counts []int
|
||||
}
|
||||
|
||||
// histogramBin counts finite values into equal bins.
|
||||
func histogramBin(values []float64, bins int, width float64, cumulative bool) histBins {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
n := 0
|
||||
for _, v := range values {
|
||||
if !math.IsNaN(v) && !math.IsInf(v, 0) {
|
||||
lo, hi, n = min(lo, v), max(hi, v), n+1
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
return histBins{}
|
||||
}
|
||||
var b histBins
|
||||
switch {
|
||||
case width > 0:
|
||||
b.start, b.width = math.Floor(lo/width)*width, width
|
||||
bins = max(1, int(math.Ceil((hi-b.start)/width-1e-9)))
|
||||
if hi == b.start+float64(bins)*width {
|
||||
bins++
|
||||
}
|
||||
default:
|
||||
if bins == 0 {
|
||||
bins = int(math.Ceil(math.Log2(float64(n)))) + 1
|
||||
}
|
||||
b.start, b.width = lo, (hi-lo)/float64(bins)
|
||||
if b.width == 0 {
|
||||
b.width = 1
|
||||
}
|
||||
}
|
||||
if bins > histogramMaxBins {
|
||||
panic("mujica: Histogram would make more than 10000 bins")
|
||||
}
|
||||
b.counts = make([]int, bins)
|
||||
for _, v := range values {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
continue
|
||||
}
|
||||
b.counts[min(bins-1, int((v-b.start)/b.width))]++
|
||||
}
|
||||
if cumulative {
|
||||
for i := 1; i < bins; i++ {
|
||||
b.counts[i] += b.counts[i-1]
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Histogram counts values into equal bins drawn as bars.
|
||||
func Histogram(c *ui.Context, values []float64, opts HistogramOptions) ChartResult {
|
||||
if opts.Bins < 0 || opts.BinWidth < 0 || math.IsNaN(opts.BinWidth) {
|
||||
panic("mujica: Histogram Bins and BinWidth must be ≥ 0")
|
||||
}
|
||||
ch := NewChart(c, opts.Frame)
|
||||
k := core.Tokens(c)
|
||||
col := internal.Palette(k)[0]
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
b := ChartMemo(ch, "bins", [4]any{ChartID(values), opts.Bins, opts.BinWidth, opts.Cumulative}, func() histBins {
|
||||
return histogramBin(values, opts.Bins, opts.BinWidth, opts.Cumulative)
|
||||
})
|
||||
top := 0
|
||||
for _, n := range b.counts {
|
||||
top = max(top, n)
|
||||
}
|
||||
var ys internal.Linear
|
||||
var hi, step float64
|
||||
if top > 0 {
|
||||
_, hi, step = internal.Nice(0, float64(top), 4)
|
||||
step = max(step, 1)
|
||||
}
|
||||
// edge writes bin edge i at a given precision.
|
||||
edge := func(i int, fine float64) string {
|
||||
v := b.start + float64(i)*b.width
|
||||
if opts.Format != nil {
|
||||
return opts.Format(v)
|
||||
}
|
||||
s := internal.FormatTick(v, b.width/fine)
|
||||
if strings.Contains(s, ".") {
|
||||
s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
|
||||
}
|
||||
return s
|
||||
}
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Band) {
|
||||
plot := ChartInset(r, 52, 26)
|
||||
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{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])
|
||||
}
|
||||
closer := ")"
|
||||
if i == len(b.counts)-1 && opts.BinWidth == 0 {
|
||||
closer = "]"
|
||||
}
|
||||
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 {
|
||||
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) {
|
||||
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
|
||||
var labels []string
|
||||
for i := 0; i <= len(b.counts); i++ {
|
||||
xs, labels = append(xs, plot.X+band.Step()*float32(i)), append(labels, edge(i, 1))
|
||||
}
|
||||
internal.AxisX(p, plot, xs, labels, size, k)
|
||||
for i, n := range b.counts {
|
||||
y := ys.Map(float64(n))
|
||||
bar := ui.Rect{X: band.Start(i), Y: y, W: band.Width(), H: plot.Y + plot.H - y}
|
||||
p.Fill(bar, col, 1)
|
||||
if i == active {
|
||||
p.Stroke(ui.Rect{X: bar.X - 2, Y: bar.Y - 2, W: bar.W + 4, H: bar.H + 4}, k.Text, 2, 1.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
res, _ := ch.Build(spec)
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestHistogramBin(t *testing.T) {
|
||||
vals := []float64{1, 2, 2, 3, 4, 5, 9, 10, math.NaN()}
|
||||
if got := histogramBin(vals, 3, 0, false); fmt.Sprint(got.counts) != "[4 2 2]" || got.start != 1 || got.width != 3 {
|
||||
t.Fatalf("3 bins %+v", got)
|
||||
}
|
||||
if got := histogramBin(vals, 0, 5, false); fmt.Sprint(got.counts) != "[5 2 1]" || got.start != 0 {
|
||||
t.Fatalf("width 5 %+v", got)
|
||||
}
|
||||
if got := histogramBin(vals, 0, 5, true); fmt.Sprint(got.counts) != "[5 7 8]" {
|
||||
t.Fatalf("cumulative %+v", got)
|
||||
}
|
||||
if got := histogramBin(vals, 0, 0, false); len(got.counts) != 4 {
|
||||
t.Fatalf("Sturges for 8 values: %d bins", len(got.counts))
|
||||
}
|
||||
if got := histogramBin([]float64{math.NaN()}, 3, 0, false); got.counts != nil {
|
||||
t.Fatal("no finite values made bins")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistogramReadoutAndModes(t *testing.T) {
|
||||
opts := HistogramOptions{Frame: ChartFrame{Label: "Ages"}, BinWidth: 5}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return Histogram(c, []float64{1, 2, 2, 3, 4, 5, 9, 10}, opts)
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Ages")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); got != "[0, 5) · 5" {
|
||||
t.Fatalf("first bin %q", got)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if got := chartReadout(tt); got != "[10, 15) · 1" {
|
||||
t.Fatalf("last bin %q", got)
|
||||
}
|
||||
opts.Cumulative = true
|
||||
tt.Frame()
|
||||
if got := chartReadout(tt); got != "≤ 15 · 8" {
|
||||
t.Fatalf("cumulative %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistogramBad(t *testing.T) {
|
||||
for _, o := range []HistogramOptions{{Bins: -1}, {BinWidth: 1e-9}} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%+v: no panic", o)
|
||||
}
|
||||
}()
|
||||
o.Frame.Label = "x"
|
||||
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); Histogram(c, []float64{0, 1}, o) }, 300, 300)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistogramSnapshot(t *testing.T) {
|
||||
vals := make([]float64, 400)
|
||||
for i := range vals {
|
||||
vals[i] = 50 + 15*math.Sin(float64(i)*1.7)*math.Cos(float64(i)*0.3)
|
||||
}
|
||||
snapshot(t, "histogram", 520, 320, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
Histogram(c, vals, HistogramOptions{Frame: ChartFrame{Label: "Ages"}, Bins: 12})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
)
|
||||
|
||||
// IndicatorKind names a technical indicator.
|
||||
type IndicatorKind int
|
||||
|
||||
const (
|
||||
IndicatorMA IndicatorKind = iota
|
||||
IndicatorEMA
|
||||
IndicatorBOLL
|
||||
IndicatorVWAP
|
||||
IndicatorMACD
|
||||
IndicatorRSI
|
||||
IndicatorKDJ
|
||||
IndicatorOBV
|
||||
)
|
||||
|
||||
// indicatorInfo describes names, default parameters, and plotting locations.
|
||||
var indicatorInfo = []struct {
|
||||
name string
|
||||
params []float64
|
||||
overlay bool
|
||||
}{
|
||||
{"MA", []float64{20}, true}, {"EMA", []float64{20}, true}, {"BOLL", []float64{20, 2}, true}, {"VWAP", nil, true},
|
||||
{"MACD", []float64{12, 26, 9}, false}, {"RSI", []float64{14}, false}, {"KDJ", []float64{9, 3, 3}, false}, {"OBV", nil, false},
|
||||
}
|
||||
|
||||
func (k IndicatorKind) String() string {
|
||||
if k < IndicatorMA || k > IndicatorOBV {
|
||||
panic(fmt.Sprintf("mujica: unknown IndicatorKind %d", int(k)))
|
||||
}
|
||||
return indicatorInfo[k].name
|
||||
}
|
||||
|
||||
// DefaultParams returns a fresh copy of the kind's usual parameters.
|
||||
func (k IndicatorKind) DefaultParams() []float64 {
|
||||
_ = k.String()
|
||||
return append([]float64(nil), indicatorInfo[k].params...)
|
||||
}
|
||||
|
||||
// IndicatorOverlay configures MA, EMA, BOLL, or VWAP.
|
||||
type IndicatorOverlay struct {
|
||||
Kind IndicatorKind
|
||||
// Params nil uses the indicator's defaults.
|
||||
Params []float64
|
||||
// Values overrides calculation; supply one value per candle.
|
||||
Values []float64
|
||||
}
|
||||
|
||||
// indicatorParams resolves and checks params for kind.
|
||||
func indicatorParams(k IndicatorKind, params []float64) []float64 {
|
||||
_ = k.String()
|
||||
want := indicatorInfo[k].params
|
||||
if params == nil {
|
||||
return want
|
||||
}
|
||||
if len(params) != len(want) {
|
||||
panic(fmt.Sprintf("mujica: %v takes %d parameters, not %d", k, len(want), len(params)))
|
||||
}
|
||||
for i, p := range params {
|
||||
if math.IsInf(p, 0) || !(p > 0) || (k != IndicatorBOLL || i == 0) && p != float64(int(p)) {
|
||||
panic(fmt.Sprintf("mujica: %v parameter %v must be a positive whole number", k, p))
|
||||
}
|
||||
}
|
||||
if k == IndicatorMACD && params[0] >= params[1] {
|
||||
panic("mujica: MACD fast period must be below slow period")
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// indicatorName is the kind with its parameters, as "MACD(12,26,9)".
|
||||
func indicatorName(k IndicatorKind, params []float64) string {
|
||||
if len(params) == 0 {
|
||||
return k.String()
|
||||
}
|
||||
parts := make([]string, len(params))
|
||||
for i, p := range params {
|
||||
parts[i] = strconv.FormatFloat(p, 'f', -1, 64)
|
||||
}
|
||||
return k.String() + "(" + strings.Join(parts, ",") + ")"
|
||||
}
|
||||
|
||||
// indicatorSeries computes named indicator lines from candles.
|
||||
func indicatorSeries(k IndicatorKind, params []float64, candles []Candle) []ChartSeries {
|
||||
p := indicatorParams(k, params)
|
||||
closes := make([]float64, len(candles))
|
||||
for i, c := range candles {
|
||||
closes[i] = c.Close
|
||||
}
|
||||
name := indicatorName(k, p)
|
||||
switch k {
|
||||
case IndicatorMA:
|
||||
return []ChartSeries{{name, SMA(closes, int(p[0]))}}
|
||||
case IndicatorEMA:
|
||||
return []ChartSeries{{name, EMA(closes, int(p[0]))}}
|
||||
case IndicatorBOLL:
|
||||
mid, up, lo := Bollinger(closes, int(p[0]), p[1])
|
||||
return []ChartSeries{{name, mid}, {name + " ↑", up}, {name + " ↓", lo}}
|
||||
case IndicatorVWAP:
|
||||
return []ChartSeries{{name, VWAP(candles)}}
|
||||
case IndicatorMACD:
|
||||
dif, dea, hist := MACD(closes, int(p[0]), int(p[1]), int(p[2]))
|
||||
return []ChartSeries{{"DIF", dif}, {"DEA", dea}, {"MACD", hist}}
|
||||
case IndicatorRSI:
|
||||
return []ChartSeries{{name, RSI(closes, int(p[0]))}}
|
||||
case IndicatorKDJ:
|
||||
kk, d, j := KDJ(candles, int(p[0]), int(p[1]), int(p[2]))
|
||||
return []ChartSeries{{"K", kk}, {"D", d}, {"J", j}}
|
||||
}
|
||||
return []ChartSeries{{name, OBV(candles)}}
|
||||
}
|
||||
|
||||
// mktOverlayLines caches indicators until data or parameters change.
|
||||
func mktOverlayLines(ch *chart, m *mktCore, candles []Candle) []mktLine {
|
||||
pal := internal.Palette(m.k)
|
||||
var out []mktLine
|
||||
for i, o := range m.opts.Overlays {
|
||||
if _ = o.Kind.String(); !indicatorInfo[o.Kind].overlay {
|
||||
panic(fmt.Sprintf("mujica: %v is not an overlay indicator", o.Kind))
|
||||
}
|
||||
col := pal[[]int{3, 1, 5, 2, 4}[i%5]]
|
||||
if o.Values != nil {
|
||||
if len(o.Values) != len(candles) {
|
||||
panic("mujica: IndicatorOverlay Values need one value per candle")
|
||||
}
|
||||
out = append(out, mktLine{name: indicatorName(o.Kind, indicatorParams(o.Kind, o.Params)), values: o.Values, color: col, shape: len(out)})
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%s|%d|%v", ChartID(candles), o.Kind, o.Params)
|
||||
series := ChartMemo(ch, fmt.Sprint("overlay.", i), key, func() []ChartSeries { return indicatorSeries(o.Kind, o.Params, candles) })
|
||||
for _, s := range series {
|
||||
out = append(out, mktLine{name: s.Name, values: s.Values, color: col, shape: len(out)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IndicatorParamsField edits validated indicator parameters in place.
|
||||
func IndicatorParamsField(c *ui.Context, kind IndicatorKind, params *[]float64) core.ChoiceResult {
|
||||
if params == nil {
|
||||
panic("mujica: IndicatorParamsField needs params")
|
||||
}
|
||||
row := ui.Row(c).Gap(core.Density(c).Unit() * 2).AlignItems(ui.Center).Role(ui.RoleGroup).Label(kind.String())
|
||||
if row.IsDisabled() {
|
||||
copy := append([]float64(nil), (*params)...)
|
||||
params = ©
|
||||
}
|
||||
if *params == nil {
|
||||
*params = kind.DefaultParams()
|
||||
}
|
||||
changed := false
|
||||
indicatorParams(kind, *params)
|
||||
row.Children(func() {
|
||||
for i := range *params {
|
||||
v := (*params)[i]
|
||||
step, prec := 1.0, 0
|
||||
if kind == IndicatorBOLL && i == 1 {
|
||||
step, prec = 0.1, 1
|
||||
}
|
||||
if input.NumberInput(c, &v, input.NumberInputOptions{Min: step, Max: 500, Step: step, Precision: prec, Label: fmt.Sprintf("%v %d", kind, i+1)}).Element.Width(110).Key(i).Changed() || v != (*params)[i] {
|
||||
if kind == IndicatorMACD && (i == 0 && v >= (*params)[1] || i == 1 && v <= (*params)[0]) {
|
||||
continue
|
||||
}
|
||||
(*params)[i], changed = v, true
|
||||
}
|
||||
}
|
||||
})
|
||||
if changed {
|
||||
slog.Debug("mujica: indicator_overlay params", "kind", kind)
|
||||
}
|
||||
return core.NewChoiceResult(row, changed)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestIndicatorOverlayValuesInReadoutAndMemo(t *testing.T) {
|
||||
candles := sampleCandles(200)
|
||||
view := MarketView{Count: 60}
|
||||
overlays := []IndicatorOverlay{{Kind: IndicatorMA, Params: []float64{5}}, {Kind: IndicatorBOLL}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC"}, Overlays: overlays})
|
||||
}, 800, 400)
|
||||
if !tt.HasText("MA(5)") || !tt.HasText("BOLL(20,2) ↑") {
|
||||
t.Fatalf("legend %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
ma := SMA(closesOf(candles), 5)
|
||||
want := "MA(5) " + core.PriceUngrouped(ma[199], 2)
|
||||
found := false
|
||||
for _, s := range tt.Texts() {
|
||||
found = found || strings.Contains(s, want)
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("readout lacks %q: %q", want, tt.Texts())
|
||||
}
|
||||
// Caller values replace the computation.
|
||||
own := make([]float64, 200)
|
||||
for i := range own {
|
||||
own[i] = 42
|
||||
}
|
||||
overlays = []IndicatorOverlay{{Kind: IndicatorEMA, Values: own}}
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if !tt.HasText("EMA(20) 42.00") {
|
||||
t.Fatalf("own values %q", tt.Texts())
|
||||
}
|
||||
for name, o := range map[string]IndicatorOverlay{
|
||||
"pane kind": {Kind: IndicatorMACD},
|
||||
"params": {Kind: IndicatorMA, Params: []float64{0}},
|
||||
"values size": {Kind: IndicatorMA, Values: []float64{1}},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "x"}, Overlays: []IndicatorOverlay{o}})
|
||||
}, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func closesOf(cs []Candle) []float64 {
|
||||
out := make([]float64, len(cs))
|
||||
for i, c := range cs {
|
||||
out[i] = c.Close
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestIndicatorParamsField(t *testing.T) {
|
||||
var params []float64
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
IndicatorParamsField(c, IndicatorMACD, ¶ms)
|
||||
}, 500, 100)
|
||||
if len(params) != 3 || params[0] != 12 {
|
||||
t.Fatalf("defaults %v", params)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if params[0] != 13 {
|
||||
t.Fatalf("Up on fast %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorOverlaySnapshot(t *testing.T) {
|
||||
candles := sampleCandles(120)
|
||||
view := MarketView{Count: 90}
|
||||
snapshot(t, "indicator_overlay", 640, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 250},
|
||||
Overlays: []IndicatorOverlay{{Kind: IndicatorMA, Params: []float64{10}}, {Kind: IndicatorBOLL}}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// IndicatorPaneSpec is one lower pane: MACD, RSI, KDJ or OBV.
|
||||
type IndicatorPaneSpec struct {
|
||||
Kind IndicatorKind
|
||||
// Params nil uses the kind's defaults.
|
||||
Params []float64
|
||||
}
|
||||
|
||||
// IndicatorPanes holds stacked indicators and their height shares.
|
||||
type IndicatorPanes struct {
|
||||
Panes []IndicatorPaneSpec
|
||||
// Sizes reset to equal shares when pane count changes.
|
||||
Sizes []float32
|
||||
}
|
||||
|
||||
// IndicatorPaneOptions configure an IndicatorPane.
|
||||
type IndicatorPaneOptions struct {
|
||||
// Height defaults to 140 per pane.
|
||||
Height float32
|
||||
// Label prefixes each pane's accessible name, as the symbol.
|
||||
Label string
|
||||
Disabled bool
|
||||
Status data.DataStatus
|
||||
Error string
|
||||
Empty string
|
||||
}
|
||||
|
||||
// IndicatorPaneResult reports the stack's events.
|
||||
type IndicatorPaneResult struct {
|
||||
Element *ui.Element
|
||||
// Removed reports the closed pane; -1 means none.
|
||||
Removed int
|
||||
// Resized reports finished height changes, for saving.
|
||||
Resized bool
|
||||
Point int
|
||||
Pane int
|
||||
PointChanged bool
|
||||
}
|
||||
|
||||
// IndicatorPane stacks resizable technical indicators using the shared window.
|
||||
func IndicatorPane(c *ui.Context, candles []Candle, view *MarketView, panes *IndicatorPanes, opts IndicatorPaneOptions) IndicatorPaneResult {
|
||||
if view == nil || panes == nil {
|
||||
panic("mujica: IndicatorPane needs a view and panes")
|
||||
}
|
||||
box := ui.Column(c).FillWidth().Disabled(opts.Disabled)
|
||||
if box.IsDisabled() {
|
||||
copy := *panes
|
||||
copy.Panes = append([]IndicatorPaneSpec(nil), panes.Panes...)
|
||||
copy.Sizes = append([]float32(nil), panes.Sizes...)
|
||||
panes = ©
|
||||
}
|
||||
if opts.Height < 0 {
|
||||
panic("mujica: IndicatorPane Height must be non-negative")
|
||||
}
|
||||
res := IndicatorPaneResult{Removed: -1, Point: -1, Pane: -1}
|
||||
n := len(panes.Panes)
|
||||
for _, p := range panes.Panes {
|
||||
if _ = p.Kind.String(); indicatorInfo[p.Kind].overlay {
|
||||
panic(fmt.Sprintf("mujica: %v is an overlay, not a pane indicator", p.Kind))
|
||||
}
|
||||
indicatorParams(p.Kind, p.Params)
|
||||
}
|
||||
if n == 0 {
|
||||
res.Element = box
|
||||
return res
|
||||
}
|
||||
if len(panes.Sizes) != n {
|
||||
panes.Sizes = nil
|
||||
}
|
||||
u := core.Density(c).Unit()
|
||||
lineH := core.FontSize(c, theme.CaptionSize) * theme.LineHeight
|
||||
chrome := 2*max(theme.MinHitSize, lineH) + max(u*5, lineH) + 4*u
|
||||
minimum := chrome + 40
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 140 * float32(n)
|
||||
}
|
||||
build := func(i int) func() {
|
||||
return func() {
|
||||
col := ui.Column(c).Fill().MinHeight(0)
|
||||
b := col.Bounds()
|
||||
internal.Remeasure(c, col, b.W, b.H)
|
||||
plotH := max(40, b.H-chrome)
|
||||
col.Children(func() {
|
||||
closed, point := indicatorPaneOne(c, candles, view, panes.Panes[i], opts, plotH)
|
||||
if point.Point >= 0 {
|
||||
res.Point = point.Point
|
||||
res.Pane = i
|
||||
}
|
||||
res.PointChanged = res.PointChanged || point.Changed()
|
||||
if closed {
|
||||
res.Removed = i
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
box.Height(h).MinHeight(float32(n)*minimum + float32(n-1)*theme.BorderWidth)
|
||||
box.Children(func() {
|
||||
if n == 1 {
|
||||
build(0)()
|
||||
return
|
||||
}
|
||||
list := make([]input.ResizablePanel, n)
|
||||
for i, p := range panes.Panes {
|
||||
list[i] = input.ResizablePanel{Label: indicatorName(p.Kind, indicatorParams(p.Kind, p.Params)), Min: minimum, Build: build(i)}
|
||||
}
|
||||
r := input.ResizablePanelGroup(c, &panes.Sizes, list, input.ResizablePanelGroupOptions{Vertical: true})
|
||||
r.Element.Fill()
|
||||
res.Resized = r.Committed
|
||||
})
|
||||
if res.Removed >= 0 {
|
||||
slog.Debug("mujica: indicator_pane removed", "kind", panes.Panes[res.Removed].Kind)
|
||||
panes.Panes = append(panes.Panes[:res.Removed:res.Removed], panes.Panes[res.Removed+1:]...)
|
||||
panes.Sizes = nil
|
||||
c.Invalidate()
|
||||
}
|
||||
res.Element = box
|
||||
return res
|
||||
}
|
||||
|
||||
// indicatorPaneOne draws one indicator and reports its events.
|
||||
func indicatorPaneOne(c *ui.Context, candles []Candle, view *MarketView, spec IndicatorPaneSpec, opts IndicatorPaneOptions, height float32) (bool, ChartResult) {
|
||||
k := core.Tokens(c)
|
||||
params := indicatorParams(spec.Kind, spec.Params)
|
||||
name := indicatorName(spec.Kind, params)
|
||||
closed := false
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(core.Density(c).Unit()).Children(func() {
|
||||
ui.Text(c, name).FontSize(core.FontSize(c, theme.CaptionSize)).FontWeight(600).TextColor(k.Text).Grow(1)
|
||||
closed = core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "indicator_pane.remove"), name)).Clicked()
|
||||
})
|
||||
label := strings.TrimSpace(opts.Label + " " + name)
|
||||
ch := NewChart(c, ChartFrame{Label: label, Height: height, Status: opts.Status, Error: opts.Error, Empty: opts.Empty})
|
||||
series := ChartMemo(ch, "series", fmt.Sprintf("%s|%v|%v", ChartID(candles), spec.Kind, params), func() []ChartSeries {
|
||||
marketCheckCandles(candles)
|
||||
return indicatorSeries(spec.Kind, params, candles)
|
||||
})
|
||||
m := &mktCore{c: c, k: k, ch: ch, candles: candles, view: view}
|
||||
m.window()
|
||||
pal := internal.Palette(k)
|
||||
lines := make([]mktLine, len(series))
|
||||
keys := make([]ChartLegendItem, len(series))
|
||||
for i, s := range series {
|
||||
lines[i] = mktLine{name: s.Name, values: s.Values, color: pal[(i+1)%len(pal)], shape: i}
|
||||
keys[i] = ChartLegendItem{Name: s.Name, Color: lines[i].color, Shape: i}
|
||||
}
|
||||
yr := ChartMemo(ch, "y", [5]any{ChartID(candles), m.from, m.to, spec.Kind, fmt.Sprint(params)}, func() [2]float64 {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for _, l := range lines {
|
||||
for i := m.from; i < m.to; i++ {
|
||||
if v := l.values[i]; !math.IsNaN(v) {
|
||||
lo, hi = min(lo, v), max(hi, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if spec.Kind == IndicatorRSI {
|
||||
lo, hi = 0, 100
|
||||
}
|
||||
return [2]float64{lo, hi}
|
||||
})
|
||||
lo, hi := yr[0], yr[1]
|
||||
spec2 := ChartSpec{Marks: len(candles), Legend: keys}
|
||||
if m.to <= m.from || math.IsInf(lo, 0) {
|
||||
spec2.Marks = 0
|
||||
}
|
||||
m.lo, m.hi = lo, hi
|
||||
spec2.Pick = func(r ui.Rect, x, y float32) int {
|
||||
g := m.paneGeom(r)
|
||||
if !g.plot.Contains(x, y) {
|
||||
return -1
|
||||
}
|
||||
return g.index(m, x)
|
||||
}
|
||||
spec2.Readout = func(i int) string {
|
||||
parts := []string{candles[i].Time.Format("2006-01-02 15:04")}
|
||||
for _, l := range lines {
|
||||
parts = append(parts, l.name+" "+ChartValue(c, l.values[i], func(v float64) string { return fmt.Sprintf("%.2f", v) }))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
spec2.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
g := m.paneGeom(r)
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
internal.AxisY(p, g.plot, g.ys, g.ticks, func(v float64) string { return volumeShort(v, g.step) }, size, k)
|
||||
p.Clip(g.plot, 0, func() {
|
||||
for i, l := range lines {
|
||||
if spec.Kind == IndicatorMACD && i == 2 {
|
||||
indicatorPaneBars(p, g, m, l.values, c)
|
||||
continue
|
||||
}
|
||||
mktPaintLine(p, g, m, l.values, l.color, i)
|
||||
}
|
||||
})
|
||||
if active >= m.from && active < m.to {
|
||||
x := internal.Snap(p, g.x(m, active))
|
||||
p.Line(x, g.plot.Y, x, g.plot.Y+g.plot.H, 1, k.TextMuted)
|
||||
}
|
||||
}
|
||||
m.st = *ui.Local(ch.Res.Element, "mujica.mkt", func() *mktState { return &mktState{grab: -1, focus: -1, alertFocus: -1} })
|
||||
if ch.Res.Element.IsDisabled() {
|
||||
copy := *view
|
||||
m.view = ©
|
||||
}
|
||||
result, plot := ch.Build(spec2)
|
||||
if plot != nil {
|
||||
m.input(plot)
|
||||
}
|
||||
return closed && !ch.Res.Element.IsDisabled(), result
|
||||
}
|
||||
|
||||
// paneGeom is geom without the time axis, for lower panes.
|
||||
func (m *mktCore) paneGeom(r ui.Rect) mktGeom {
|
||||
plot := ChartInset(r, 60, 4)
|
||||
lo, hi, step := internal.Nice(m.lo, m.hi, 2)
|
||||
return mktGeom{plot: plot, band: internal.Band{N: m.to - m.from, R0: plot.X, R1: plot.X + plot.W, Pad: 0.3},
|
||||
ys: internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}, ticks: internal.Ticks(lo, hi, step), step: step}
|
||||
}
|
||||
|
||||
// indicatorPaneBars draws MACD's histogram: hollow bars above zero, filled below.
|
||||
func indicatorPaneBars(p *ui.Painter, g mktGeom, m *mktCore, vals []float64, c *ui.Context) {
|
||||
k := core.Tokens(c)
|
||||
zero := g.ys.Map(0)
|
||||
w := max(1, g.band.Width())
|
||||
for i := m.from; i < m.to; i++ {
|
||||
v := vals[i]
|
||||
if math.IsNaN(v) {
|
||||
continue
|
||||
}
|
||||
y := g.ys.Map(v)
|
||||
x := internal.Snap(p, g.x(m, i))
|
||||
bar := ui.Rect{X: x - w/2, Y: min(y, zero), W: w, H: max(1, float32(math.Abs(float64(y-zero))))}
|
||||
if v >= 0 && w >= 3 {
|
||||
p.Fill(bar, k.Surface, 0)
|
||||
p.Stroke(bar, core.TrendColor(c, 1), 0, 1)
|
||||
} else {
|
||||
p.Fill(bar, core.TrendColor(c, core.TrendSign(v)), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestIndicatorPaneStackReadAndRemove(t *testing.T) {
|
||||
candles := sampleCandles(300)
|
||||
view := MarketView{Count: 80}
|
||||
panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorMACD}, {Kind: IndicatorRSI, Params: []float64{6}}}}
|
||||
removed := -1
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if r := IndicatorPane(c, candles, &view, &panes, IndicatorPaneOptions{Label: "BTC"}); r.Removed >= 0 {
|
||||
removed = r.Removed
|
||||
}
|
||||
}, 800, 500)
|
||||
tt.Frame()
|
||||
if !tt.HasText("MACD(12,26,9)") || !tt.HasText("RSI(6)") || len(panes.Sizes) != 2 {
|
||||
t.Fatalf("texts %q sizes %v", tt.Texts(), panes.Sizes)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab) // remove MACD
|
||||
tt.Key(0, ui.KeyTab) // MACD plot
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
found := false
|
||||
for _, s := range tt.Texts() {
|
||||
found = found || strings.Contains(s, "DIF ") && strings.Contains(s, "DEA ")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("MACD readout missing: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Remove MACD(12,26,9)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(panes.Panes) != 1 || panes.Panes[0].Kind != IndicatorRSI || removed != 0 {
|
||||
t.Fatalf("after remove %+v (%d)", panes.Panes, removed)
|
||||
}
|
||||
for name, p := range map[string]IndicatorPanes{
|
||||
"overlay kind": {Panes: []IndicatorPaneSpec{{Kind: IndicatorMA}}},
|
||||
"bad params": {Panes: []IndicatorPaneSpec{{Kind: IndicatorRSI, Params: []float64{1, 2}}}},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
IndicatorPane(c, candles, &view, &p, IndicatorPaneOptions{})
|
||||
}, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorPaneSnapshot(t *testing.T) {
|
||||
candles := sampleCandles(120)
|
||||
view := MarketView{Count: 80}
|
||||
panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorMACD}, {Kind: IndicatorKDJ}}}
|
||||
snapshot(t, "indicator_pane", 640, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
IndicatorPane(c, candles, &view, &panes, IndicatorPaneOptions{Height: 316})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// IndicatorSelectorOptions configure an IndicatorSelector.
|
||||
type IndicatorSelectorOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// IndicatorSelector searches, adds, removes, and configures chart indicators.
|
||||
func IndicatorSelector(c *ui.Context, overlays *[]IndicatorOverlay, panes *IndicatorPanes, opts IndicatorSelectorOptions) core.ChoiceResult {
|
||||
if overlays == nil || panes == nil {
|
||||
panic("mujica: IndicatorSelector needs overlays and panes")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
changed := false
|
||||
box := ui.Row(c).Disabled(opts.Disabled)
|
||||
box.Children(func() {
|
||||
open := ui.Local(box, "indicator_selector.open", func() bool { return false })
|
||||
query := ui.Local(box, "indicator_selector.query", func() string { return "" })
|
||||
if box.IsDisabled() {
|
||||
*open = false
|
||||
}
|
||||
label := fmt.Sprintf(core.Msg(c, "indicator_selector.button"), len(*overlays)+len(panes.Panes))
|
||||
trigger := input.Button(c, label, input.ButtonOptions{Variant: input.Secondary, Icon: icons.Must("plus")})
|
||||
if trigger.Clicked() {
|
||||
*open = !*open
|
||||
}
|
||||
overlay.Popover(c, trigger, open, overlay.PopoverOptions{Title: core.Msg(c, "indicator_selector.title"), Width: 340}, func() {
|
||||
input.TextInput(c, query, input.TextInputOptions{Placeholder: core.Msg(c, "indicator_selector.search"), Label: core.Msg(c, "indicator_selector.search"), Clearable: true})
|
||||
ui.Text(c, core.Msg(c, "indicator_selector.available")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Margin(u, 0, 0, 0)
|
||||
found := 0
|
||||
for kind := IndicatorMA; kind <= IndicatorOBV; kind++ {
|
||||
name := kind.String()
|
||||
if q := strings.TrimSpace(*query); q != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(q)) {
|
||||
continue
|
||||
}
|
||||
found++
|
||||
ui.Row(c).Key(name).AlignItems(ui.Center).Gap(u * 2).MinHeight(core.Density(c).ControlHeight()).Children(func() {
|
||||
ui.Text(c, name).FontWeight(600).Width(56)
|
||||
where := "indicator_selector.pane"
|
||||
if indicatorInfo[kind].overlay {
|
||||
where = "indicator_selector.overlay"
|
||||
}
|
||||
ui.Text(c, core.Msg(c, where)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Grow(1)
|
||||
if input.Button(c, core.Msg(c, "indicator_selector.add"), input.ButtonOptions{Variant: input.Ghost, Label: fmt.Sprintf(core.Msg(c, "indicator_selector.add_named"), name)}).Clicked() {
|
||||
if indicatorInfo[kind].overlay {
|
||||
*overlays = append(slices.Clone(*overlays), IndicatorOverlay{Kind: kind, Params: kind.DefaultParams()})
|
||||
} else {
|
||||
panes.Panes = append(slices.Clone(panes.Panes), IndicatorPaneSpec{Kind: kind, Params: kind.DefaultParams()})
|
||||
}
|
||||
changed = true
|
||||
slog.Debug("mujica: indicator_selector add", "kind", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
if found == 0 {
|
||||
ui.Text(c, core.Msg(c, "indicator_selector.none")).TextColor(k.TextMuted)
|
||||
}
|
||||
if len(*overlays)+len(panes.Panes) == 0 {
|
||||
return
|
||||
}
|
||||
ui.Text(c, core.Msg(c, "indicator_selector.active")).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Margin(u*2, 0, 0, 0)
|
||||
remove := -1
|
||||
for i := range *overlays {
|
||||
if indicatorSelectorActive(c, u, fmt.Sprint("o", i), (*overlays)[i].Kind, &(*overlays)[i].Params, &changed) {
|
||||
remove = i
|
||||
}
|
||||
}
|
||||
if remove >= 0 {
|
||||
*overlays = slices.Delete(slices.Clone(*overlays), remove, remove+1)
|
||||
changed = true
|
||||
}
|
||||
remove = -1
|
||||
for i := range panes.Panes {
|
||||
if indicatorSelectorActive(c, u, fmt.Sprint("p", i), panes.Panes[i].Kind, &panes.Panes[i].Params, &changed) {
|
||||
remove = i
|
||||
}
|
||||
}
|
||||
if remove >= 0 {
|
||||
panes.Panes = slices.Delete(slices.Clone(panes.Panes), remove, remove+1)
|
||||
changed = true
|
||||
}
|
||||
})
|
||||
})
|
||||
return core.NewChoiceResult(box, changed)
|
||||
}
|
||||
|
||||
// indicatorSelectorActive edits parameters and reports removal of an indicator.
|
||||
func indicatorSelectorActive(c *ui.Context, u float32, key string, kind IndicatorKind, params *[]float64, changed *bool) bool {
|
||||
removed := false
|
||||
ui.Row(c).Key(key).AlignItems(ui.Center).Gap(u * 2).Wrap().Children(func() {
|
||||
ui.Text(c, kind.String()).FontWeight(600).Width(56)
|
||||
if len(kind.DefaultParams()) > 0 && IndicatorParamsField(c, kind, params).Changed() {
|
||||
*changed = true
|
||||
}
|
||||
ui.Spacer(c)
|
||||
if core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "indicator_pane.remove"), indicatorName(kind, *params))).Clicked() {
|
||||
removed = true
|
||||
slog.Debug("mujica: indicator_selector remove", "kind", kind)
|
||||
}
|
||||
})
|
||||
return removed
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestIndicatorSelectorSearchAddRemoveAndDisabled(t *testing.T) {
|
||||
var overlays []IndicatorOverlay
|
||||
var panes IndicatorPanes
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
IndicatorSelector(c, &overlays, &panes, IndicatorSelectorOptions{Disabled: disabled})
|
||||
}, 640, 740)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if err := tt.Click("Indicators (0)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tt.Click("Search indicators"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Type("RSI")
|
||||
if tt.HasText("Add MA") || !tt.HasText("RSI") {
|
||||
t.Fatalf("search %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Add RSI"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(panes.Panes) != 1 || panes.Panes[0].Kind != IndicatorRSI {
|
||||
t.Fatalf("panes %+v", panes.Panes)
|
||||
}
|
||||
if err := tt.Click("Remove RSI(14)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(panes.Panes) != 0 {
|
||||
t.Fatal("pane not removed")
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
if tt.HasText("Search indicators") {
|
||||
t.Fatal("disabled panel remained open")
|
||||
}
|
||||
}
|
||||
func TestIndicatorSelectorSnapshot(t *testing.T) {
|
||||
overlays := []IndicatorOverlay{{Kind: IndicatorMA}}
|
||||
panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorRSI}}}
|
||||
snapshot(t, "indicator_selector", 360, 100, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(12).Children(func() { IndicatorSelector(c, &overlays, &panes, IndicatorSelectorOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestIndicatorSelectorOverlayParams(t *testing.T) {
|
||||
var overlays []IndicatorOverlay
|
||||
var panes IndicatorPanes
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
IndicatorSelector(c, &overlays, &panes, IndicatorSelectorOptions{})
|
||||
}, 640, 740)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if err := tt.Click("Indicators (0)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tt.Click("Add MA"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(overlays) != 1 || overlays[0].Kind != IndicatorMA || overlays[0].Params[0] != 20 {
|
||||
t.Fatalf("overlay %+v", overlays)
|
||||
}
|
||||
if err := tt.Click("MA 1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if overlays[0].Params[0] != 21 {
|
||||
t.Fatalf("params %v", overlays[0].Params)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// Indicator helpers are pure; NaN marks insufficient data.
|
||||
|
||||
// indicatorCheck panics on a period below one.
|
||||
func indicatorCheck(name string, n int) {
|
||||
if n < 1 {
|
||||
panic(fmt.Sprintf("mujica: %s period %d must be at least 1", name, n))
|
||||
}
|
||||
}
|
||||
|
||||
func indicatorNaN(n int) []float64 {
|
||||
out := make([]float64, n)
|
||||
for i := range out {
|
||||
out[i] = math.NaN()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SMA computes the n-period simple moving average.
|
||||
func SMA(vals []float64, n int) []float64 {
|
||||
indicatorCheck("SMA", n)
|
||||
out := indicatorNaN(len(vals))
|
||||
var sum float64
|
||||
for i, v := range vals {
|
||||
sum += v
|
||||
if i >= n {
|
||||
sum -= vals[i-n]
|
||||
}
|
||||
if i >= n-1 {
|
||||
out[i] = sum / float64(n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EMA uses 2/(n+1), seeded by the first n-period SMA.
|
||||
func EMA(vals []float64, n int) []float64 {
|
||||
indicatorCheck("EMA", n)
|
||||
out := indicatorNaN(len(vals))
|
||||
k := 2 / float64(n+1)
|
||||
var sum, prev float64
|
||||
for i, v := range vals {
|
||||
switch {
|
||||
case i < n-1:
|
||||
sum += v
|
||||
case i == n-1:
|
||||
prev = (sum + v) / float64(n)
|
||||
out[i] = prev
|
||||
default:
|
||||
prev = v*k + prev*(1-k)
|
||||
out[i] = prev
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Bollinger returns SMA and population-deviation bands.
|
||||
func Bollinger(vals []float64, n int, width float64) (mid, upper, lower []float64) {
|
||||
indicatorCheck("Bollinger", n)
|
||||
if !(width > 0) {
|
||||
panic("mujica: Bollinger width must be positive")
|
||||
}
|
||||
mid = SMA(vals, n)
|
||||
upper, lower = indicatorNaN(len(vals)), indicatorNaN(len(vals))
|
||||
for i := n - 1; i < len(vals); i++ {
|
||||
var ss float64
|
||||
for _, v := range vals[i-n+1 : i+1] {
|
||||
ss += (v - mid[i]) * (v - mid[i])
|
||||
}
|
||||
d := width * math.Sqrt(ss/float64(n))
|
||||
upper[i], lower[i] = mid[i]+d, mid[i]-d
|
||||
}
|
||||
return mid, upper, lower
|
||||
}
|
||||
|
||||
// VWAP averages typical prices weighted by cumulative volume.
|
||||
func VWAP(candles []Candle) []float64 {
|
||||
out := indicatorNaN(len(candles))
|
||||
var pv, vol float64
|
||||
for i, c := range candles {
|
||||
pv += (c.High + c.Low + c.Close) / 3 * c.Volume
|
||||
vol += c.Volume
|
||||
if vol > 0 {
|
||||
out[i] = pv / vol
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MACD returns DIF, signal EMA, and their difference.
|
||||
func MACD(vals []float64, fast, slow, signal int) (dif, dea, hist []float64) {
|
||||
if fast >= slow {
|
||||
panic("mujica: MACD fast period must be shorter than slow")
|
||||
}
|
||||
f, s := EMA(vals, fast), EMA(vals, slow)
|
||||
dif = indicatorNaN(len(vals))
|
||||
for i := range vals {
|
||||
dif[i] = f[i] - s[i]
|
||||
}
|
||||
dea = indicatorNaN(len(vals))
|
||||
if len(vals) >= slow {
|
||||
tail := EMA(dif[slow-1:], signal)
|
||||
copy(dea[slow-1:], tail)
|
||||
}
|
||||
hist = indicatorNaN(len(vals))
|
||||
for i := range vals {
|
||||
hist[i] = dif[i] - dea[i]
|
||||
}
|
||||
return dif, dea, hist
|
||||
}
|
||||
|
||||
// RSI computes Wilder's relative strength index.
|
||||
func RSI(vals []float64, n int) []float64 {
|
||||
indicatorCheck("RSI", n)
|
||||
out := indicatorNaN(len(vals))
|
||||
var gain, loss float64
|
||||
for i := 1; i < len(vals); i++ {
|
||||
d := vals[i] - vals[i-1]
|
||||
g, l := max(d, 0), max(-d, 0)
|
||||
if i <= n {
|
||||
gain += g
|
||||
loss += l
|
||||
if i < n {
|
||||
continue
|
||||
}
|
||||
gain, loss = gain/float64(n), loss/float64(n)
|
||||
} else {
|
||||
gain = (gain*float64(n-1) + g) / float64(n)
|
||||
loss = (loss*float64(n-1) + l) / float64(n)
|
||||
}
|
||||
if loss == 0 {
|
||||
out[i] = 100
|
||||
} else {
|
||||
out[i] = 100 - 100/(1+gain/loss)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// KDJ computes stochastic K, D, and J from RSV.
|
||||
func KDJ(candles []Candle, n, m1, m2 int) (k, d, j []float64) {
|
||||
indicatorCheck("KDJ", n)
|
||||
indicatorCheck("KDJ m1", m1)
|
||||
indicatorCheck("KDJ m2", m2)
|
||||
k, d, j = indicatorNaN(len(candles)), indicatorNaN(len(candles)), indicatorNaN(len(candles))
|
||||
pk, pd := 50.0, 50.0
|
||||
for i := n - 1; i < len(candles); i++ {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for _, c := range candles[i-n+1 : i+1] {
|
||||
lo, hi = min(lo, c.Low), max(hi, c.High)
|
||||
}
|
||||
rsv := 50.0
|
||||
if hi > lo {
|
||||
rsv = (candles[i].Close - lo) / (hi - lo) * 100
|
||||
}
|
||||
pk = (float64(m1-1)*pk + rsv) / float64(m1)
|
||||
pd = (float64(m2-1)*pd + pk) / float64(m2)
|
||||
k[i], d[i], j[i] = pk, pd, 3*pk-2*pd
|
||||
}
|
||||
return k, d, j
|
||||
}
|
||||
|
||||
// OBV accumulates signed volume according to close direction.
|
||||
func OBV(candles []Candle) []float64 {
|
||||
out := make([]float64, len(candles))
|
||||
for i := 1; i < len(candles); i++ {
|
||||
out[i] = out[i-1]
|
||||
switch {
|
||||
case candles[i].Close > candles[i-1].Close:
|
||||
out[i] += candles[i].Volume
|
||||
case candles[i].Close < candles[i-1].Close:
|
||||
out[i] -= candles[i].Volume
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HeikinAshi turns candles into Heikin-Ashi candles, keeping times and volumes.
|
||||
func HeikinAshi(candles []Candle) []Candle {
|
||||
out := make([]Candle, len(candles))
|
||||
for i, c := range candles {
|
||||
h := Candle{Time: c.Time, Volume: c.Volume, Close: (c.Open + c.High + c.Low + c.Close) / 4}
|
||||
if i == 0 {
|
||||
h.Open = (c.Open + c.Close) / 2
|
||||
} else {
|
||||
h.Open = (out[i-1].Open + out[i-1].Close) / 2
|
||||
}
|
||||
h.High, h.Low = max(c.High, h.Open, h.Close), min(c.Low, h.Open, h.Close)
|
||||
out[i] = h
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func approx(t *testing.T, name string, got, want []float64) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("%s: len %d, want %d", name, len(got), len(want))
|
||||
}
|
||||
for i := range got {
|
||||
if math.IsNaN(want[i]) != math.IsNaN(got[i]) || !math.IsNaN(want[i]) && math.Abs(got[i]-want[i]) > 1e-9 {
|
||||
t.Errorf("%s[%d] = %v, want %v", name, i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nan = math.NaN()
|
||||
|
||||
func TestSMAAndEMA(t *testing.T) {
|
||||
v := []float64{1, 2, 3, 4, 5, 6}
|
||||
approx(t, "SMA", SMA(v, 3), []float64{nan, nan, 2, 3, 4, 5})
|
||||
approx(t, "EMA", EMA(v, 3), []float64{nan, nan, 2, 3, 4, 5})
|
||||
approx(t, "EMA2", EMA([]float64{10, 20, 10}, 1), []float64{10, 20, 10})
|
||||
approx(t, "EMA3", EMA([]float64{2, 4, 8}, 2), []float64{nan, 3, 3 + (8-3)*2.0/3})
|
||||
}
|
||||
|
||||
func TestBollinger(t *testing.T) {
|
||||
mid, up, lo := Bollinger([]float64{2, 4, 4, 4, 5, 5, 7, 9}, 8, 2)
|
||||
// Mean 5, population deviation 2.
|
||||
approx(t, "mid", mid[7:], []float64{5})
|
||||
approx(t, "upper", up[7:], []float64{9})
|
||||
approx(t, "lower", lo[7:], []float64{1})
|
||||
if !math.IsNaN(up[6]) {
|
||||
t.Fatal("band before the period")
|
||||
}
|
||||
}
|
||||
|
||||
func candlesOf(rows ...[5]float64) []Candle {
|
||||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
out := make([]Candle, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = Candle{Time: base.Add(time.Duration(i) * time.Hour), Open: r[0], High: r[1], Low: r[2], Close: r[3], Volume: r[4]}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestVWAPAndOBV(t *testing.T) {
|
||||
cs := candlesOf([5]float64{10, 12, 9, 12, 100}, [5]float64{12, 15, 12, 12, 300}, [5]float64{12, 12, 6, 9, 100})
|
||||
// Typical prices 11, 13, 9.
|
||||
approx(t, "VWAP", VWAP(cs), []float64{11, (1100 + 3900) / 400.0, (1100 + 3900 + 900) / 500.0})
|
||||
approx(t, "OBV", OBV(cs), []float64{0, 0, -100})
|
||||
}
|
||||
|
||||
func TestMACD(t *testing.T) {
|
||||
v := []float64{1, 2, 3, 4, 5, 6, 7}
|
||||
dif, dea, hist := MACD(v, 2, 3, 2)
|
||||
f, s := EMA(v, 2), EMA(v, 3)
|
||||
for i := 2; i < len(v); i++ {
|
||||
if math.Abs(dif[i]-(f[i]-s[i])) > 1e-12 {
|
||||
t.Fatalf("dif[%d]", i)
|
||||
}
|
||||
}
|
||||
want := EMA(dif[2:], 2)
|
||||
approx(t, "dea", dea[2:], want)
|
||||
if !math.IsNaN(dea[1]) || math.Abs(hist[6]-(dif[6]-dea[6])) > 1e-12 {
|
||||
t.Fatal("hist or warm-up wrong")
|
||||
}
|
||||
approx(t, "dif line", dif[2:], []float64{0.5, 0.5, 0.5, 0.5, 0.5})
|
||||
approx(t, "hist line", hist[3:], []float64{0, 0, 0, 0})
|
||||
}
|
||||
|
||||
func TestRSI(t *testing.T) {
|
||||
r := RSI([]float64{1, 2, 3, 2}, 2)
|
||||
approx(t, "RSI", r, []float64{nan, nan, 100, 50})
|
||||
}
|
||||
|
||||
func TestKDJ(t *testing.T) {
|
||||
cs := candlesOf([5]float64{1, 2, 0, 1, 0}, [5]float64{1, 4, 0, 4, 0}, [5]float64{4, 4, 2, 3, 0})
|
||||
k, d, j := KDJ(cs, 2, 3, 3)
|
||||
k1 := 200.0 / 3
|
||||
d1 := (100 + k1) / 3
|
||||
// i=2: range 0..4, RSV 75.
|
||||
k2 := (2*k1 + 75) / 3
|
||||
d2 := (2*d1 + k2) / 3
|
||||
approx(t, "K", k, []float64{nan, k1, k2})
|
||||
approx(t, "D", d, []float64{nan, d1, d2})
|
||||
approx(t, "J", j, []float64{nan, 3*k1 - 2*d1, 3*k2 - 2*d2})
|
||||
}
|
||||
|
||||
func TestHeikinAshi(t *testing.T) {
|
||||
cs := candlesOf([5]float64{10, 14, 8, 12, 5}, [5]float64{12, 13, 9, 10, 6})
|
||||
h := HeikinAshi(cs)
|
||||
if h[0].Open != 11 || h[0].Close != 11 || h[0].High != 14 || h[0].Low != 8 {
|
||||
t.Fatalf("first %+v", h[0])
|
||||
}
|
||||
if h[1].Open != 11 || h[1].Close != 11 || h[1].High != 13 || h[1].Low != 9 || h[1].Volume != 6 {
|
||||
t.Fatalf("second %+v", h[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorsPanicOnBadPeriods(t *testing.T) {
|
||||
for name, f := range map[string]func(){
|
||||
"SMA": func() { SMA(nil, 0) },
|
||||
"MACD": func() { MACD(nil, 26, 12, 9) },
|
||||
"BOLL": func() { Bollinger(nil, 20, 0) },
|
||||
"KDJ m": func() { KDJ(nil, 9, 0, 3) },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
f()
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketWindowPanZoom(t *testing.T) {
|
||||
v := MarketView{}
|
||||
if f, to := marketWindow(v, 1000, 400); f != 880 || to != 1000 {
|
||||
t.Fatalf("default %d..%d", f, to)
|
||||
}
|
||||
marketPan(&v, 1000, 400, -100)
|
||||
if v.End != 900 || v.Count != 120 {
|
||||
t.Fatalf("pan back %+v", v)
|
||||
}
|
||||
marketZoom(&v, 1000, 400, 0.5)
|
||||
if f, to := marketWindow(v, 1000, 400); f != 840 || to != 900 {
|
||||
t.Fatalf("zoom in %d..%d", f, to)
|
||||
}
|
||||
marketZoom(&v, 1000, 100, 10)
|
||||
if v.Count != 100 {
|
||||
t.Fatalf("zoom out capped by room: %+v", v)
|
||||
}
|
||||
marketPan(&v, 1000, 100, 5000)
|
||||
if v.End != 0 {
|
||||
t.Fatalf("pan to the newest follows: %+v", v)
|
||||
}
|
||||
if f, to := marketWindow(MarketView{Count: 50, End: 10}, 1000, 400); f != 0 || to != 50 {
|
||||
t.Fatalf("start clamp %d..%d", f, to)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntervalClose(t *testing.T) {
|
||||
loc := time.UTC
|
||||
at := time.Date(2026, 10, 7, 13, 47, 10, 0, loc) // a Wednesday
|
||||
for iv, want := range map[Interval]time.Time{
|
||||
"1m": time.Date(2026, 10, 7, 13, 48, 0, 0, loc),
|
||||
"15m": time.Date(2026, 10, 7, 14, 0, 0, 0, loc),
|
||||
"4h": time.Date(2026, 10, 7, 16, 0, 0, 0, loc),
|
||||
"1D": time.Date(2026, 10, 8, 0, 0, 0, 0, loc),
|
||||
"1W": time.Date(2026, 10, 12, 0, 0, 0, 0, loc),
|
||||
"1M": time.Date(2026, 11, 1, 0, 0, 0, 0, loc),
|
||||
} {
|
||||
if got := iv.Close(at, loc); !got.Equal(want) {
|
||||
t.Errorf("%s: %v, want %v", iv, got, want)
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unknown interval accepted")
|
||||
}
|
||||
}()
|
||||
Interval("7m").Close(at, loc)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/overlay"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// intervalGroups sorts Intervals under their headings.
|
||||
var intervalGroups = []struct {
|
||||
key string
|
||||
items []Interval
|
||||
}{
|
||||
{"interval_selector.minutes", []Interval{"1m", "3m", "5m", "15m", "30m"}},
|
||||
{"interval_selector.hours", []Interval{"1h", "2h", "4h", "6h", "12h"}},
|
||||
{"interval_selector.days", []Interval{"1D", "3D", "1W", "1M"}},
|
||||
}
|
||||
|
||||
// IntervalSelectorOptions configure an IntervalSelector.
|
||||
type IntervalSelectorOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// IntervalSelector groups candle periods and preserves caller-owned favorites.
|
||||
func IntervalSelector(c *ui.Context, value *Interval, favorites *[]Interval, opts IntervalSelectorOptions) core.ChoiceResult {
|
||||
if value == nil || favorites == nil {
|
||||
panic("mujica: IntervalSelector needs a value and favorites")
|
||||
}
|
||||
for _, iv := range append([]Interval{*value}, *favorites...) {
|
||||
if !slices.Contains(Intervals, iv) {
|
||||
panic(fmt.Sprintf("mujica: unknown Interval %q", string(iv)))
|
||||
}
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
changed := false
|
||||
row := ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Disabled(opts.Disabled)
|
||||
disabled := row.IsDisabled()
|
||||
row.Children(func() {
|
||||
quick := slices.Clone(*favorites)
|
||||
if !slices.Contains(quick, *value) {
|
||||
quick = append(quick, *value)
|
||||
}
|
||||
slices.SortFunc(quick, func(a, b Interval) int { return slices.Index(Intervals, a) - slices.Index(Intervals, b) })
|
||||
items := make([]input.ToggleItem[Interval], len(quick))
|
||||
for i, iv := range quick {
|
||||
items[i] = input.ToggleItem[Interval]{Value: iv, Label: string(iv)}
|
||||
}
|
||||
sel := []Interval{*value}
|
||||
if input.ToggleGroup(c, &sel, items, input.ToggleGroupOptions{Label: core.Msg(c, "interval_selector.label")}).Changed() && !disabled {
|
||||
*value, changed = sel[0], true
|
||||
}
|
||||
trigger := input.Button(c, "", input.ButtonOptions{Variant: input.Ghost, Icon: icons.Must("chevron-down"), Label: core.Msg(c, "interval_selector.all")})
|
||||
open := ui.Local(row, "interval_selector.open", func() bool { return false })
|
||||
if disabled {
|
||||
*open = false
|
||||
}
|
||||
if trigger.Clicked() && !disabled {
|
||||
*open = !*open
|
||||
}
|
||||
overlay.Popover(c, trigger, open, overlay.PopoverOptions{Label: core.Msg(c, "interval_selector.all"), Width: 220}, func() {
|
||||
for _, g := range intervalGroups {
|
||||
ui.Text(c, core.Msg(c, g.key)).FontSize(core.FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Margin(u, 0, 0, u)
|
||||
for _, iv := range g.items {
|
||||
if intervalSelectorRow(c, k, u, iv, iv == *value, slices.Contains(*favorites, iv), favorites) {
|
||||
*value, changed, *open = iv, true, false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
if changed {
|
||||
slog.Debug("mujica: interval_selector changed", "interval", *value)
|
||||
}
|
||||
return core.NewChoiceResult(row, changed)
|
||||
}
|
||||
|
||||
// intervalSelectorRow shows a selectable period and its favorite toggle.
|
||||
func intervalSelectorRow(c *ui.Context, k theme.Tokens, u float32, iv Interval, on, fav bool, favorites *[]Interval) bool {
|
||||
chosen := false
|
||||
ui.Row(c).Key(string(iv)).AlignItems(ui.Center).Gap(u).Children(func() {
|
||||
b := ui.ButtonBase(c).Grow(1).MinHeight(core.Density(c).ControlHeight()).Padding(0, u*2).Justify(ui.Start).Radius(theme.ControlRadius).Gap(u * 2)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
if on {
|
||||
b.Background(k.Selection).TextColor(k.AccentText)
|
||||
} else if b.Hovered() {
|
||||
b.Background(k.SurfaceHover)
|
||||
}
|
||||
b.Children(func() {
|
||||
ui.Box(c).Size(14, 14).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
if on {
|
||||
internal.Mark(p, r, "check", k.AccentText)
|
||||
}
|
||||
})
|
||||
ui.Text(c, string(iv)).SingleLine()
|
||||
})
|
||||
chosen = b.Clicked()
|
||||
name := fmt.Sprintf(core.Msg(c, "interval_selector.favorite"), iv)
|
||||
if fav {
|
||||
name = fmt.Sprintf(core.Msg(c, "interval_selector.unfavorite"), iv)
|
||||
}
|
||||
star := core.IconAction(c, icons.Must("star"), name)
|
||||
if fav {
|
||||
star.TextColor(k.Warning)
|
||||
}
|
||||
if star.Clicked() && !star.IsDisabled() {
|
||||
if fav {
|
||||
*favorites = slices.DeleteFunc(slices.Clone(*favorites), func(x Interval) bool { return x == iv })
|
||||
} else {
|
||||
*favorites = append(slices.Clone(*favorites), iv)
|
||||
}
|
||||
slog.Debug("mujica: interval_selector favorite", "interval", iv, "on", !fav)
|
||||
}
|
||||
})
|
||||
return chosen
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestIntervalSelectorGroupsFavoritesAndDisabled(t *testing.T) {
|
||||
value := Interval("1h")
|
||||
favorites := []Interval{"1m", "1h"}
|
||||
disabled := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
IntervalSelector(c, &value, &favorites, IntervalSelectorOptions{Disabled: disabled})
|
||||
}, 500, 700)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
if err := tt.Click("All intervals"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText("Minutes") || !tt.HasText("Hours") || !tt.HasText("Days and longer") {
|
||||
t.Fatalf("groups %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Add 4h to favorites"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(favorites) != 3 || favorites[2] != "4h" {
|
||||
t.Fatalf("favorites %v", favorites)
|
||||
}
|
||||
for range 50 {
|
||||
if tt.Focused("1M") {
|
||||
break
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
if !tt.Focused("1M") {
|
||||
t.Fatal("keyboard could not reach 1M")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if value != "1M" {
|
||||
t.Fatalf("interval %v", value)
|
||||
}
|
||||
if err := tt.Click("All intervals"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
if tt.HasText("Days and longer") {
|
||||
t.Fatal("disabled popup remained open")
|
||||
}
|
||||
tt.Key(ui.Alt, ui.KeyDown)
|
||||
if value != "1M" || len(favorites) != 3 {
|
||||
t.Fatal("disabled interval changed")
|
||||
}
|
||||
}
|
||||
func TestIntervalSelectorSnapshot(t *testing.T) {
|
||||
value := Interval("1h")
|
||||
favorites := []Interval{"1m", "5m", "1h", "1D"}
|
||||
snapshot(t, "interval_selector", 460, 90, func(c *ui.Context) {
|
||||
ui.Row(c).Padding(12).Children(func() { IntervalSelector(c, &value, &favorites, IntervalSelectorOptions{}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// LineChartOptions configure a LineChart.
|
||||
type LineChartOptions struct {
|
||||
Frame ChartFrame
|
||||
// X places point i at ascending X[i]; nil means i.
|
||||
X []float64
|
||||
// Times makes a time axis; set X or Times.
|
||||
Times []time.Time
|
||||
// Format writes readout values; nil uses six digits.
|
||||
Format func(v float64) string
|
||||
}
|
||||
|
||||
// LineChart draws series as lines; NaN breaks them.
|
||||
func LineChart(c *ui.Context, series []ChartSeries, opts LineChartOptions) ChartResult {
|
||||
n := chartCheckSeries(series)
|
||||
ch := NewChart(c, opts.Frame)
|
||||
ax := ChartMemo(ch, "x", [3]string{ChartID(opts.X), ChartID(opts.Times), ChartID(series)}, func() ChartX { return NewChartX(n, opts.X, opts.Times) })
|
||||
lo, hi, ok := ChartYRange(ch, series, false, nil)
|
||||
if !ok {
|
||||
n = 0
|
||||
}
|
||||
tops := make([][]float64, len(series))
|
||||
for i, s := range series {
|
||||
tops[i] = s.Values
|
||||
}
|
||||
return chartLines(ch, chartLineData{series: series, tops: tops, ax: ax, lo: lo, hi: hi, n: n, format: opts.Format})
|
||||
}
|
||||
|
||||
// ChartYRange is the memoized y domain of visible series.
|
||||
func ChartYRange(ch *chart, series []ChartSeries, zero bool, tops [][]float64) (lo, hi float64, ok bool) {
|
||||
d := ChartMemo(ch, "y", chartSeriesID(ch, series)+ChartID(tops), func() [3]float64 {
|
||||
src := series
|
||||
if tops != nil {
|
||||
src = make([]ChartSeries, len(series))
|
||||
for i, s := range series {
|
||||
src[i] = ChartSeries{Name: s.Name, Values: tops[i]}
|
||||
}
|
||||
}
|
||||
lo, hi, ok := chartYDomain(src, ch.Hidden, zero)
|
||||
if !ok {
|
||||
return [3]float64{}
|
||||
}
|
||||
return [3]float64{lo, hi, 1}
|
||||
})
|
||||
return d[0], d[1], d[2] == 1
|
||||
}
|
||||
|
||||
// chartLineData is what line and area charts draw.
|
||||
type chartLineData struct {
|
||||
series []ChartSeries
|
||||
tops, bases [][]float64
|
||||
ax ChartX
|
||||
lo, hi float64
|
||||
n int
|
||||
format func(float64) string
|
||||
}
|
||||
|
||||
func chartLines(ch *chart, d chartLineData) ChartResult {
|
||||
c, k := ch.c, core.Tokens(ch.c)
|
||||
pal := internal.Palette(k)
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
keys := make([]ChartLegendItem, len(d.series))
|
||||
for i, s := range d.series {
|
||||
keys[i] = ChartLegendItem{Name: s.Name, Color: pal[i%len(pal)], Shape: i}
|
||||
}
|
||||
var ticks []float64
|
||||
var step float64
|
||||
lo, hi := d.lo, d.hi
|
||||
if d.n > 0 {
|
||||
lo, hi, step = internal.Nice(lo, hi, 4)
|
||||
ticks = internal.Ticks(lo, hi, step)
|
||||
}
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
|
||||
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{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 {
|
||||
parts := []string{d.ax.label(i)}
|
||||
for _, s := range d.series {
|
||||
if !ch.Hidden(s.Name) {
|
||||
parts = append(parts, s.Name+" "+ChartValue(c, s.Values[i], d.format))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
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)
|
||||
internal.AxisX(p, plot, tx, tl, size, k)
|
||||
x := func(i int) float32 { return xs.Map(d.ax.Vals[i]) }
|
||||
pt := func(i int, v float64) (float32, float32) { return internal.Snap(p, x(i)), internal.Snap(p, ys.Map(v)) }
|
||||
for si, s := range d.series {
|
||||
if ch.Hidden(s.Name) {
|
||||
continue
|
||||
}
|
||||
col, top := keys[si].Color, d.tops[si]
|
||||
chartRuns(top, x, func(idx []int) {
|
||||
var line ui.Path
|
||||
for j, i := range idx {
|
||||
if j == 0 {
|
||||
line.MoveTo(pt(i, top[i]))
|
||||
} else {
|
||||
line.LineTo(pt(i, top[i]))
|
||||
}
|
||||
}
|
||||
if d.bases != nil {
|
||||
fill := line
|
||||
for j := len(idx) - 1; j >= 0; j-- {
|
||||
fill.LineTo(pt(idx[j], max(lo, min(hi, d.bases[si][idx[j]]))))
|
||||
}
|
||||
fill.Close()
|
||||
p.FillPath(&fill, col.Alpha(0.22))
|
||||
}
|
||||
p.StrokePath(&line, 2, col)
|
||||
})
|
||||
// Sparse markers name each series by shape, not only color.
|
||||
every := max(1, int(float32(d.n)/max(plot.W/48, 1)))
|
||||
for i := 0; i < d.n; i += every {
|
||||
if !math.IsNaN(top[i]) {
|
||||
internal.Marker(p, si, x(i), ys.Map(top[i]), 7, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
if active < 0 {
|
||||
return
|
||||
}
|
||||
ax0 := internal.Snap(p, x(active))
|
||||
p.Line(ax0, plot.Y, ax0, plot.Y+plot.H, 1, k.TextMuted)
|
||||
for si, s := range d.series {
|
||||
if v := d.tops[si][active]; !ch.Hidden(s.Name) && !math.IsNaN(v) {
|
||||
internal.Marker(p, si, ax0, ys.Map(v), 13, k.Surface)
|
||||
internal.Marker(p, si, ax0, ys.Map(v), 9, keys[si].Color)
|
||||
}
|
||||
}
|
||||
}
|
||||
res, _ := ch.Build(spec)
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
)
|
||||
|
||||
type chartHarness struct {
|
||||
res ChartResult
|
||||
changes int
|
||||
disabled bool
|
||||
build func(c *ui.Context) ChartResult
|
||||
}
|
||||
|
||||
func (h *chartHarness) view(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Mode: core.Light})
|
||||
ui.Column(c).Padding(16).Width(480).Disabled(h.disabled).Children(func() {
|
||||
h.res = h.build(c)
|
||||
if h.res.Changed() {
|
||||
h.changes++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// chartReadout returns the readout line, the text after the plot.
|
||||
func chartReadout(tt *ui.Tester) string {
|
||||
texts := tt.Texts()
|
||||
return texts[len(texts)-1]
|
||||
}
|
||||
|
||||
func lineSeries() []ChartSeries {
|
||||
return []ChartSeries{
|
||||
{Name: "Pews", Values: []float64{3, 5, math.NaN(), 4, 8}},
|
||||
{Name: "Candles", Values: []float64{5, 2, 6, 7, 1}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineChartKeyboardReadout(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)
|
||||
if !strings.Contains(chartReadout(tt), "arrow keys") {
|
||||
t.Fatalf("hint %q", chartReadout(tt))
|
||||
}
|
||||
tabTo(t, tt, "Chapel")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); got != "1 · Pews 5 · Candles 2" || h.res.Point != 1 || h.changes != 2 {
|
||||
t.Fatalf("readout %q point %d", got, h.res.Point)
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); got != "2 · Pews no value · Candles 6" {
|
||||
t.Fatalf("gap readout %q", got)
|
||||
}
|
||||
if a := tt.Announcements(); len(a) == 0 || a[len(a)-1] != "2 · Pews no value · Candles 6" {
|
||||
t.Fatalf("announcements %q", a)
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if h.res.Point != 4 {
|
||||
t.Fatalf("End then Right: %d", h.res.Point)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if h.res.Point != -1 {
|
||||
t.Fatalf("Escape kept point %d", h.res.Point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineChartHoverAndToggle(t *testing.T) {
|
||||
var toggled string
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
r := LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "Chapel"}, X: []float64{0, 10, 20, 30, 40}})
|
||||
if s, ok := r.Toggled(); ok {
|
||||
toggled = s
|
||||
}
|
||||
return r
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
b := h.res.Element.Bounds()
|
||||
// The plot spans 52..W-12; x 30 is three quarters across.
|
||||
px := b.X + 52 + (b.W-64)*0.75
|
||||
tt.Move(px, b.Y+b.H/2)
|
||||
if got := chartReadout(tt); got != "30 · Pews 4 · Candles 7" {
|
||||
t.Fatalf("hover readout %q", got)
|
||||
}
|
||||
if err := tt.Click("Candles"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Move(px, b.Y+b.H/2)
|
||||
if got := chartReadout(tt); got != "30 · Pews 4" || toggled != "Candles" {
|
||||
t.Fatalf("hidden series still read: %q toggled %q", got, toggled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineChartTimeAxisAndStates(t *testing.T) {
|
||||
t0 := time.Date(2026, 10, 5, 9, 0, 0, 0, time.UTC)
|
||||
times := []time.Time{t0, t0.Add(time.Hour), t0.Add(2 * time.Hour), t0.Add(3 * time.Hour), t0.Add(4 * time.Hour)}
|
||||
status := data.DataReady
|
||||
var series []ChartSeries
|
||||
var heights []float32
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
r := LineChart(c, series, LineChartOptions{Frame: ChartFrame{Label: "Chapel", Status: status}, Times: times[:len(series[0].Values)]})
|
||||
return r
|
||||
}}
|
||||
series = lineSeries()
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Chapel")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); !strings.HasPrefix(got, "2026-10-05 10:00:00 · ") {
|
||||
t.Fatalf("time readout %q", got)
|
||||
}
|
||||
heights = append(heights, h.res.Element.Bounds().H)
|
||||
for _, st := range []data.DataStatus{data.DataLoading, data.DataFailed} {
|
||||
status = st
|
||||
tt.Frame()
|
||||
heights = append(heights, h.res.Element.Bounds().H)
|
||||
}
|
||||
if !tt.HasText("Retry") || heights[0] != heights[1] || heights[1] != heights[2] {
|
||||
t.Fatalf("states changed size: %v", heights)
|
||||
}
|
||||
status, series = data.DataReady, []ChartSeries{{Name: "A", Values: []float64{math.NaN()}}}
|
||||
tt.Frame()
|
||||
if !tt.HasText("No data") {
|
||||
t.Fatalf("all-missing chart: %q", tt.Texts())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineChartDisabledIgnoresKeys(t *testing.T) {
|
||||
h := &chartHarness{disabled: true, build: func(c *ui.Context) ChartResult {
|
||||
return LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "Chapel"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
for range 4 {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
}
|
||||
if h.res.Point != -1 {
|
||||
t.Fatalf("disabled chart moved to %d", h.res.Point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineChartBadOptions(t *testing.T) {
|
||||
for name, build := range map[string]func(c *ui.Context){
|
||||
"lengths": func(c *ui.Context) {
|
||||
LineChart(c, []ChartSeries{{Name: "a", Values: []float64{1}}, {Name: "b"}}, LineChartOptions{Frame: ChartFrame{Label: "x"}})
|
||||
},
|
||||
"order": func(c *ui.Context) {
|
||||
LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "x"}, X: []float64{0, 2, 1, 3, 4}})
|
||||
},
|
||||
"no label": func(c *ui.Context) { LineChart(c, lineSeries(), LineChartOptions{}) },
|
||||
"duplicate": func(c *ui.Context) {
|
||||
LineChart(c, []ChartSeries{{Name: "a"}, {Name: "a"}}, LineChartOptions{Frame: ChartFrame{Label: "x"}})
|
||||
},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); build(c) }, 300, 300)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineChartLarge(t *testing.T) {
|
||||
vals := make([]float64, 100_000)
|
||||
for i := range vals {
|
||||
vals[i] = math.Sin(float64(i) / 500)
|
||||
}
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return LineChart(c, []ChartSeries{{Name: "Wave", Values: vals}}, LineChartOptions{Frame: ChartFrame{Label: "Tide"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "Tide")
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if h.res.Point != 99_999 || !strings.HasPrefix(chartReadout(tt), "99999 · Wave ") {
|
||||
t.Fatalf("End: %d %q", h.res.Point, chartReadout(tt))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineChartSnapshot(t *testing.T) {
|
||||
snapshot(t, "line_chart", 520, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
LineChart(c, lineSeries(), LineChartOptions{Frame: ChartFrame{Label: "Chapel", Height: 240}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// QuotePoint holds time, price, and incremental volume.
|
||||
type QuotePoint struct {
|
||||
Time time.Time
|
||||
Price float64
|
||||
Volume float64
|
||||
}
|
||||
|
||||
// LineQuoteChartOptions configure a LineQuoteChart.
|
||||
type LineQuoteChartOptions struct {
|
||||
Frame ChartFrame
|
||||
// PrevClose is the required positive baseline price.
|
||||
PrevClose float64
|
||||
// RegularOpen and RegularClose optionally delimit extended trading sessions.
|
||||
RegularOpen, RegularClose time.Time
|
||||
Decimals int
|
||||
}
|
||||
|
||||
// lineQuoteSession returns pre-market, regular, or after-market.
|
||||
func lineQuoteSession(t time.Time, opts LineQuoteChartOptions) int {
|
||||
switch {
|
||||
case opts.RegularOpen.IsZero():
|
||||
return 0
|
||||
case t.Before(opts.RegularOpen):
|
||||
return -1
|
||||
case !t.Before(opts.RegularClose):
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// LineQuoteChart draws price, weighted average, and extended sessions.
|
||||
func LineQuoteChart(c *ui.Context, points []QuotePoint, opts LineQuoteChartOptions) ChartResult {
|
||||
if math.IsInf(opts.PrevClose, 0) || !(opts.PrevClose > 0) {
|
||||
panic("mujica: LineQuoteChart needs a positive PrevClose")
|
||||
}
|
||||
if opts.RegularOpen.IsZero() != opts.RegularClose.IsZero() || opts.RegularClose.Before(opts.RegularOpen) {
|
||||
panic("mujica: LineQuoteChart needs both session bounds, in order")
|
||||
}
|
||||
dec := core.PriceDecimals("LineQuoteChart", opts.Decimals)
|
||||
ch := NewChart(c, opts.Frame)
|
||||
k := core.Tokens(c)
|
||||
avg := ChartMemo(ch, "avg", ChartID(points), func() []float64 {
|
||||
out := make([]float64, len(points))
|
||||
var pv, v float64
|
||||
for i, p := range points {
|
||||
if p.Time.IsZero() || i > 0 && !p.Time.After(points[i-1].Time) || !(p.Price > 0) || p.Volume < 0 || math.IsNaN(p.Volume) || math.IsInf(p.Volume, 0) || math.IsInf(p.Price, 0) {
|
||||
panic(fmt.Sprintf("mujica: LineQuoteChart point %d is out of order or invalid", i))
|
||||
}
|
||||
pv += p.Price * p.Volume
|
||||
v += p.Volume
|
||||
out[i] = p.Price
|
||||
if v > 0 {
|
||||
out[i] = pv / v
|
||||
}
|
||||
}
|
||||
return out
|
||||
})
|
||||
dom := ChartMemo(ch, "y", [2]any{ChartID(points), opts.PrevClose}, func() [2]float64 {
|
||||
lo, hi := opts.PrevClose, opts.PrevClose
|
||||
for i, p := range points {
|
||||
lo, hi = min(lo, p.Price, avg[i]), max(hi, p.Price, avg[i])
|
||||
}
|
||||
// Center the scale on the previous close.
|
||||
d := max(hi-opts.PrevClose, opts.PrevClose-lo)
|
||||
return [2]float64{opts.PrevClose - d, opts.PrevClose + d}
|
||||
})
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Linear, internal.Linear) {
|
||||
plot := ChartInset(r, 60, 26)
|
||||
plot.W -= 44 // percent axis on the right
|
||||
var t0, t1 float64
|
||||
if len(points) > 0 {
|
||||
t0, t1 = float64(points[0].Time.Unix()), float64(points[len(points)-1].Time.Unix())
|
||||
}
|
||||
return plot, internal.Linear{D0: t0, D1: t1, R0: float64(plot.X), R1: float64(plot.X + plot.W)},
|
||||
internal.Linear{D0: dom[0], D1: dom[1], R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
|
||||
}
|
||||
x := func(xs internal.Linear, i int) float32 { return xs.Map(float64(points[i].Time.Unix())) }
|
||||
spec := ChartSpec{Marks: len(points), Legend: []ChartLegendItem{
|
||||
{Name: core.Msg(c, "line_quote_chart.price"), Color: k.AccentText, Shape: 0},
|
||||
{Name: core.Msg(c, "line_quote_chart.average"), Color: k.Warning, Shape: 3},
|
||||
}}
|
||||
spec.Pick = func(r ui.Rect, px, py float32) int {
|
||||
plot, xs, _ := geom(r)
|
||||
if !plot.Contains(px, py) {
|
||||
return -1
|
||||
}
|
||||
return internal.Nearest(len(points), func(i int) (float32, float32) { return x(xs, i), 0 }, px, 0, plot.W, true)
|
||||
}
|
||||
spec.Readout = func(i int) string {
|
||||
p := points[i]
|
||||
chg := (p.Price/opts.PrevClose - 1) * 100
|
||||
parts := []string{p.Time.Format("15:04"), core.PriceUngrouped(p.Price, dec), fmt.Sprintf("%+.2f%% %s", chg, core.TrendWord(c, core.TrendSign(chg))),
|
||||
core.Msg(c, "line_quote_chart.average") + " " + core.PriceUngrouped(avg[i], dec)}
|
||||
if s := lineQuoteSession(p.Time, opts); s != 0 {
|
||||
parts = append(parts, core.Msg(c, map[int]string{-1: "line_quote_chart.pre", 1: "line_quote_chart.after"}[s]))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot, xs, ys := geom(r)
|
||||
_, _, step := internal.Nice(dom[0], dom[1], 6)
|
||||
ticks := internal.Ticks(dom[0], dom[1], step)
|
||||
internal.AxisY(p, plot, ys, ticks, func(v float64) string { return internal.FormatTick(v, step) }, size, k)
|
||||
for _, t := range ticks {
|
||||
ChartText(p, fmt.Sprintf("%+.2f%%", (t/opts.PrevClose-1)*100), plot.X+plot.W+6, ys.Map(t), size, core.TrendColor(c, core.TrendSign(t-opts.PrevClose)), 0, 0.5)
|
||||
}
|
||||
lineQuoteSessions(p, plot, xs, opts, size, c)
|
||||
var tx []float32
|
||||
var tl []string
|
||||
if len(points) > 1 {
|
||||
ts, layout := internal.TimeTicks(points[0].Time, points[len(points)-1].Time, max(2, int(plot.W/90)))
|
||||
for _, t := range ts {
|
||||
if layout == "Jan 2" {
|
||||
layout = core.Msg(c, "candlestick_chart.daily_time")
|
||||
}
|
||||
tx, tl = append(tx, xs.Map(float64(t.Unix()))), append(tl, t.Format(layout))
|
||||
}
|
||||
}
|
||||
internal.AxisX(p, plot, tx, tl, size, k)
|
||||
by := internal.Snap(p, ys.Map(opts.PrevClose))
|
||||
for xx := plot.X; xx < plot.X+plot.W; xx += 6 {
|
||||
p.Line(xx, by, min(xx+3, plot.X+plot.W), by, 1, k.TextMuted)
|
||||
}
|
||||
price := ChartMemo(ch, "prices", ChartID(points), func() []float64 {
|
||||
out := make([]float64, len(points))
|
||||
for i, pt := range points {
|
||||
out[i] = pt.Price
|
||||
}
|
||||
return out
|
||||
})
|
||||
at := func(i int) float32 { return x(xs, i) }
|
||||
for li, vals := range [][]float64{price, avg} {
|
||||
col := []ui.Color{k.AccentText, k.Warning}[li]
|
||||
runs := ChartMemo(ch, fmt.Sprint("quote.runs.", li), [3]any{ChartID(points), plot.X, plot.W}, func() [][]int {
|
||||
var out [][]int
|
||||
chartRuns(vals, at, func(idx []int) { out = append(out, idx) })
|
||||
return out
|
||||
})
|
||||
for _, idx := range runs {
|
||||
var line ui.Path
|
||||
for j, i := range idx {
|
||||
px, py := internal.Snap(p, at(i)), internal.Snap(p, ys.Map(vals[i]))
|
||||
if j == 0 {
|
||||
line.MoveTo(px, py)
|
||||
} else {
|
||||
line.LineTo(px, py)
|
||||
}
|
||||
}
|
||||
p.StrokePath(&line, []float32{1.75, 1.25}[li], col)
|
||||
for j := 0; j < len(idx); j += max(1, len(idx)/12) {
|
||||
i := idx[j]
|
||||
internal.Marker(p, []int{0, 3}[li], at(i), ys.Map(vals[i]), 5, col)
|
||||
}
|
||||
}
|
||||
}
|
||||
if active >= 0 {
|
||||
ax := internal.Snap(p, at(active))
|
||||
p.Line(ax, plot.Y, ax, plot.Y+plot.H, 1, k.TextMuted)
|
||||
internal.Marker(p, 0, ax, ys.Map(price[active]), 9, k.AccentText)
|
||||
internal.Marker(p, 3, ax, ys.Map(avg[active]), 9, k.Warning)
|
||||
}
|
||||
}
|
||||
res, _ := ch.Build(spec)
|
||||
return res
|
||||
}
|
||||
|
||||
// lineQuoteSessions shades and labels the pre- and after-market spans.
|
||||
func lineQuoteSessions(p *ui.Painter, plot ui.Rect, xs internal.Linear, opts LineQuoteChartOptions, size float32, c *ui.Context) {
|
||||
if opts.RegularOpen.IsZero() {
|
||||
return
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
x0, x1 := max(plot.X, xs.Map(float64(opts.RegularOpen.Unix()))), min(plot.X+plot.W, xs.Map(float64(opts.RegularClose.Unix())))
|
||||
for _, span := range []struct {
|
||||
a, b float32
|
||||
key string
|
||||
}{{plot.X, x0, "line_quote_chart.pre"}, {x1, plot.X + plot.W, "line_quote_chart.after"}} {
|
||||
if span.b-span.a < 1 {
|
||||
continue
|
||||
}
|
||||
p.Fill(ui.Rect{X: span.a, Y: plot.Y, W: span.b - span.a, H: plot.H}, k.SurfaceHover.Alpha(0.6), 0)
|
||||
ChartText(p, core.Msg(c, span.key), span.a+4, plot.Y+2, size, k.TextMuted, 0, 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func samplePoints() ([]QuotePoint, time.Time, time.Time) {
|
||||
day := time.Date(2026, 10, 5, 0, 0, 0, 0, time.UTC)
|
||||
var pts []QuotePoint
|
||||
for m := 8 * 60; m < 18*60; m += 5 {
|
||||
price := 100 + 2*math.Sin(float64(m)/50) + float64(m-480)/200
|
||||
pts = append(pts, QuotePoint{Time: day.Add(time.Duration(m) * time.Minute), Price: math.Round(price*100) / 100, Volume: 100 + float64(m%7)*10})
|
||||
}
|
||||
return pts, day.Add(9*time.Hour + 30*time.Minute), day.Add(16 * time.Hour)
|
||||
}
|
||||
|
||||
func TestLineQuoteChartReadoutAverageSessions(t *testing.T) {
|
||||
pts, open, closeAt := samplePoints()
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
res = LineQuoteChart(c, pts, LineQuoteChartOptions{Frame: ChartFrame{Label: "AAPL"}, PrevClose: 100, RegularOpen: open, RegularClose: closeAt})
|
||||
}, 800, 400)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
var pv, v float64
|
||||
pv, v = pts[0].Price*pts[0].Volume, pts[0].Volume
|
||||
want := "08:00 · " + core.PriceUngrouped(pts[0].Price, 2)
|
||||
found := ""
|
||||
for _, s := range tt.Texts() {
|
||||
if strings.HasPrefix(s, want) {
|
||||
found = s
|
||||
}
|
||||
}
|
||||
if found == "" || !strings.Contains(found, "Average "+core.PriceUngrouped(pv/v, 2)) || !strings.HasSuffix(found, "Pre-market") {
|
||||
t.Fatalf("first readout %q in %q", found, tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
last := ""
|
||||
for _, s := range tt.Texts() {
|
||||
if strings.HasPrefix(s, "17:55") {
|
||||
last = s
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(last, "After hours") || res.Point != len(pts)-1 {
|
||||
t.Fatalf("last readout %q", last)
|
||||
}
|
||||
for name, o := range map[string]LineQuoteChartOptions{
|
||||
"prev close": {Frame: ChartFrame{Label: "x"}},
|
||||
"half bound": {Frame: ChartFrame{Label: "x"}, PrevClose: 1, RegularOpen: open},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { core.Use(c, core.Settings{}); LineQuoteChart(c, pts, o) }, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestLineQuoteChartSnapshot(t *testing.T) {
|
||||
pts, open, closeAt := samplePoints()
|
||||
snapshot(t, "line_quote_chart", 680, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
LineQuoteChart(c, pts, LineQuoteChartOptions{Frame: ChartFrame{Label: "AAPL", Height: 260}, PrevClose: 100, RegularOpen: open, RegularClose: closeAt})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestMarketIndicatorDomainChangesWithParams(t *testing.T) {
|
||||
cs := sampleCandles(300)
|
||||
v := MarketView{Count: 80}
|
||||
panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorRSI}}}
|
||||
build := func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
IndicatorPane(c, cs, &v, &panes, IndicatorPaneOptions{Label: "BTC"})
|
||||
}
|
||||
tt := ui.NewTester(build, 640, 400)
|
||||
panes.Panes[0].Kind = IndicatorOBV
|
||||
tt.Frame()
|
||||
fresh := ui.NewTester(build, 640, 400)
|
||||
if !bytes.Equal(encode(t, tt.Image()), encode(t, fresh.Image())) {
|
||||
t.Fatal("changed indicator retained its previous scale")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestMarketRejectsInvalidLayers(t *testing.T) {
|
||||
for _, kind := range []string{"compare zero", "compare infinity", "alert infinity", "alert duplicate", "profile NaN", "depth NaN", "indicator infinity"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("invalid data accepted")
|
||||
}
|
||||
}()
|
||||
cs := sampleCandles(2)
|
||||
v := MarketView{}
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
o := CandlestickOptions{Frame: ChartFrame{Label: "Price"}}
|
||||
switch kind {
|
||||
case "compare zero":
|
||||
x := []CompareSymbol{{Name: "X", Closes: []float64{0, 1}}}
|
||||
o.Compare = &x
|
||||
case "compare infinity":
|
||||
x := []CompareSymbol{{Name: "X", Closes: []float64{1, math.Inf(1)}}}
|
||||
o.Compare = &x
|
||||
case "alert infinity":
|
||||
x := []PriceAlertLine{{ID: "a", Price: math.Inf(1)}}
|
||||
o.Alerts = &x
|
||||
case "alert duplicate":
|
||||
x := []PriceAlertLine{{ID: "a", Price: 1}, {ID: "a", Price: 2}}
|
||||
o.Alerts = &x
|
||||
case "profile NaN":
|
||||
VolumeProfile(c, []ProfileBin{{1, 2, math.NaN()}}, VolumeProfileOptions{Frame: o.Frame})
|
||||
return
|
||||
case "depth NaN":
|
||||
DepthChart(c, []DepthLevel{{math.NaN(), 1}}, []DepthLevel{{2, 1}}, DepthChartOptions{Frame: o.Frame})
|
||||
return
|
||||
case "indicator infinity":
|
||||
o.Overlays = []IndicatorOverlay{{Kind: IndicatorBOLL, Params: []float64{2, math.Inf(1)}}}
|
||||
}
|
||||
CandlestickChart(c, cs, &v, o)
|
||||
}, 500, 320)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Candle holds OHLC prices, time, and volume.
|
||||
type Candle struct {
|
||||
Time time.Time
|
||||
Open, High, Low, Close float64
|
||||
Volume float64
|
||||
}
|
||||
|
||||
// marketCheckCandles validates chronological candles and finite OHLC values.
|
||||
func marketCheckCandles(candles []Candle) {
|
||||
for i, c := range candles {
|
||||
if c.Time.IsZero() || i > 0 && !c.Time.After(candles[i-1].Time) {
|
||||
panic(fmt.Sprintf("mujica: candle %d time is zero or not after the one before", i))
|
||||
}
|
||||
for _, v := range []float64{c.Open, c.High, c.Low, c.Close, c.Volume} {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
panic("mujica: candle values must be finite")
|
||||
}
|
||||
}
|
||||
if !(c.Low <= min(c.Open, c.Close)) || !(c.High >= max(c.Open, c.Close)) || c.Volume < 0 || math.IsNaN(c.Volume) {
|
||||
panic(fmt.Sprintf("mujica: candle %d has impossible prices or volume", i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MarketView holds the visible candle window.
|
||||
type MarketView struct {
|
||||
// Count is how many candles show; zero shows 120.
|
||||
Count int
|
||||
// End is exclusive; zero follows the newest candle.
|
||||
End int
|
||||
}
|
||||
|
||||
// marketWindow resolves the visible range, bounded by room.
|
||||
func marketWindow(v MarketView, n, room int) (from, to int) {
|
||||
if v.Count < 0 || v.End < 0 {
|
||||
panic("mujica: MarketView Count and End must not be negative")
|
||||
}
|
||||
if n == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
count := v.Count
|
||||
if count == 0 {
|
||||
count = 120
|
||||
}
|
||||
count = max(1, min(count, room, n))
|
||||
to = n
|
||||
if v.End > 0 && v.End < n {
|
||||
to = max(v.End, count)
|
||||
}
|
||||
return to - count, to
|
||||
}
|
||||
|
||||
// marketPan moves the window; the newest edge enables following.
|
||||
func marketPan(v *MarketView, n, room, delta int) {
|
||||
from, to := marketWindow(*v, n, room)
|
||||
v.Count = to - from
|
||||
to = max(v.Count, min(n, to+delta))
|
||||
if to >= n {
|
||||
v.End = 0
|
||||
} else {
|
||||
v.End = to
|
||||
}
|
||||
}
|
||||
|
||||
// marketZoom changes visible count while preserving the right edge.
|
||||
func marketZoom(v *MarketView, n, room int, f float64) {
|
||||
from, to := marketWindow(*v, n, room)
|
||||
count := int(math.Round(float64(to-from) * f))
|
||||
if f > 1 && count == to-from {
|
||||
count++
|
||||
}
|
||||
v.Count = max(min(10, n), min(count, room, n))
|
||||
if to < n {
|
||||
v.End = max(to, v.Count)
|
||||
}
|
||||
}
|
||||
|
||||
// marketIndexAt finds the candle at or before t.
|
||||
func marketIndexAt(candles []Candle, t time.Time) int {
|
||||
return sort.Search(len(candles), func(i int) bool { return candles[i].Time.After(t) }) - 1
|
||||
}
|
||||
|
||||
// Interval names a supported candle period.
|
||||
type Interval string
|
||||
|
||||
// Intervals lists every Interval, shortest first.
|
||||
var Intervals = []Interval{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "12h", "1D", "3D", "1W", "1M"}
|
||||
|
||||
// Close aligns periods locally; weeks start Monday.
|
||||
func (iv Interval) Close(t time.Time, loc *time.Location) time.Time {
|
||||
t = t.In(loc)
|
||||
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
|
||||
step := func(d time.Duration) time.Time {
|
||||
since := t.Sub(day)
|
||||
return day.Add((since/d + 1) * d)
|
||||
}
|
||||
switch iv {
|
||||
case "1m", "3m", "5m", "15m", "30m":
|
||||
m, _ := time.ParseDuration(string(iv))
|
||||
return step(m)
|
||||
case "1h", "2h", "4h", "6h", "12h":
|
||||
h, _ := time.ParseDuration(string(iv))
|
||||
return step(h)
|
||||
case "1D":
|
||||
return day.AddDate(0, 0, 1)
|
||||
case "3D":
|
||||
days := int(day.Sub(time.Date(1970, 1, 1, 0, 0, 0, 0, loc)).Hours()/24 + 0.5)
|
||||
return day.AddDate(0, 0, 3-days%3)
|
||||
case "1W":
|
||||
back := (int(day.Weekday()) + 6) % 7
|
||||
return day.AddDate(0, 0, 7-back)
|
||||
case "1M":
|
||||
return time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, loc)
|
||||
}
|
||||
panic(fmt.Sprintf("mujica: unknown Interval %q", string(iv)))
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
type HeatNode struct {
|
||||
Name string
|
||||
Cap float64
|
||||
Change float64
|
||||
Children []HeatNode
|
||||
}
|
||||
|
||||
// MarketHeatmapOptions configure a MarketHeatmap.
|
||||
type MarketHeatmapOptions struct {
|
||||
Frame ChartFrame
|
||||
Version int
|
||||
Scale float64
|
||||
}
|
||||
|
||||
type MarketHeatMemo struct {
|
||||
id string
|
||||
Tiles []MarketHeatTile
|
||||
Caps []float64
|
||||
width, height float32
|
||||
Rects []ui.Rect
|
||||
Visible []int
|
||||
Small []marketHeatCell
|
||||
}
|
||||
|
||||
type MarketHeatTile struct {
|
||||
Name string
|
||||
Cap, Change float64
|
||||
branch bool
|
||||
}
|
||||
|
||||
func MarketHeatmap(c *ui.Context, path *[]string, root HeatNode, opts MarketHeatmapOptions) ChartResult {
|
||||
if opts.Scale < 0 || math.IsNaN(opts.Scale) || math.IsInf(opts.Scale, 0) || path == nil {
|
||||
panic("mujica: MarketHeatmap Scale is negative")
|
||||
}
|
||||
scale := cmp.Or(opts.Scale, 3)
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
var res ChartResult
|
||||
wrap := ui.Column(c).Gap(u * 2).MinWidth(0)
|
||||
wrap.Children(func() {
|
||||
res.value = chartPath(c, root.Name, path, wrap.IsDisabled())
|
||||
node := marketHeatDescend(root, *path)
|
||||
memo := ui.Local(wrap, "mujica.heat", func() MarketHeatMemo { return MarketHeatMemo{} })
|
||||
if id := strings.Join(*path, "/") + "|" + ChartID(node.Children) + fmt.Sprint(opts.Version); id != memo.id {
|
||||
marketHeatCheck(root)
|
||||
memo.id, memo.Tiles, memo.Caps = id, nil, nil
|
||||
memo.Rects = nil
|
||||
for _, ch := range node.Children {
|
||||
t := MarketHeatTile{ch.Name, ch.total(), ch.change(), len(ch.Children) > 0}
|
||||
if t.Cap > 0 {
|
||||
memo.Tiles = append(memo.Tiles, t)
|
||||
}
|
||||
}
|
||||
slices.SortStableFunc(memo.Tiles, func(a, b MarketHeatTile) int { return cmp.Compare(b.Cap, a.Cap) })
|
||||
for _, t := range memo.Tiles {
|
||||
memo.Caps = append(memo.Caps, t.Cap)
|
||||
}
|
||||
}
|
||||
tiles, caps := memo.Tiles, memo.Caps
|
||||
color := func(ch float64) ui.Color {
|
||||
return k.Surface.Mix(core.TrendColor(c, core.TrendSign(ch)), float32(0.15+0.85*min(math.Abs(ch)/scale, 1)))
|
||||
}
|
||||
ch := NewChart(c, opts.Frame)
|
||||
spec := ChartSpec{Marks: len(tiles)}
|
||||
spec.Readout = func(i int) string {
|
||||
t := tiles[i]
|
||||
s := fmt.Sprintf("%s · %s %s%% · %s", t.Name, core.TrendWord(c, core.TrendSign(t.Change)), data.StatisticSigned(t.Change, 2), data.StatisticFormat(t.Cap, 0))
|
||||
if t.branch {
|
||||
s += " · " + core.Msg(c, "chart.opens")
|
||||
}
|
||||
return s
|
||||
}
|
||||
spec.Pick = func(r ui.Rect, x, y float32) int {
|
||||
memo.Layout(caps, r)
|
||||
for i, t := range memo.Rects {
|
||||
if t.Contains(x, y) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
spec.Activate = func(i int) {
|
||||
if tiles[i].branch {
|
||||
*path = append(slices.Clone(*path), tiles[i].Name)
|
||||
res.value = true
|
||||
slog.Debug("mujica: market_heatmap drill", "path", strings.Join(*path, "/"))
|
||||
}
|
||||
}
|
||||
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
memo.Layout(caps, r)
|
||||
for _, cell := range memo.Small {
|
||||
p.Fill(ui.Rect{X: r.X + cell.x, Y: r.Y + cell.y, W: 4, H: 4}, color(cell.change), 0)
|
||||
}
|
||||
for _, i := range memo.Visible {
|
||||
t := memo.Rects[i]
|
||||
t.X, t.Y = t.X+r.X, t.Y+r.Y
|
||||
bg := color(tiles[i].Change)
|
||||
p.Fill(t, bg, 2)
|
||||
p.Stroke(t, k.Surface, 2, 2)
|
||||
ink := ChartInk(k, bg)
|
||||
if i == active {
|
||||
p.Stroke(ui.Rect{X: t.X + 3, Y: t.Y + 3, W: t.W - 6, H: t.H - 6}, ink, 1, 2)
|
||||
}
|
||||
// Signed percents say the direction without color.
|
||||
if t.W >= 40 && t.H >= 34 {
|
||||
ChartText(p, chartFit(p, tiles[i].Name, t.W-12, size), t.X+t.W/2, t.Y+t.H/2-1, size, ink, 0.5, 1)
|
||||
ChartText(p, data.StatisticSigned(tiles[i].Change, 2)+"%", t.X+t.W/2, t.Y+t.H/2+1, size, ink, 0.5, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
r, plot := ch.Build(spec)
|
||||
if plot != nil && !plot.IsDisabled() && plot.Shortcut(0, ui.KeyBackspace) && len(*path) > 0 {
|
||||
*path, res.value = (*path)[:len(*path)-1], true
|
||||
}
|
||||
res.Element, res.Point, res.changed, res.retry = r.Element, r.Point, r.changed, r.retry
|
||||
marketHeatLegend(c, scale, color)
|
||||
})
|
||||
res.Element = wrap
|
||||
return res
|
||||
}
|
||||
|
||||
func marketHeatLegend(c *ui.Context, scale float64, color func(float64) ui.Color) {
|
||||
k := core.Tokens(c)
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
steps := []float64{-scale, -scale / 2, 0, scale / 2, scale}
|
||||
ui.Row(c).Gap(2).Children(func() {
|
||||
for _, s := range steps {
|
||||
ui.Column(c).Gap(2).AlignItems(ui.Center).Children(func() {
|
||||
ui.Box(c).Size(44, 10).Background(color(s)).Radius(1)
|
||||
ui.Text(c, data.StatisticSigned(s, 1)+"%").FontSize(size).TextColor(k.TextMuted).FontFeatures("tnum")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (n HeatNode) total() float64 {
|
||||
if len(n.Children) == 0 {
|
||||
return n.Cap
|
||||
}
|
||||
s := 0.0
|
||||
for _, ch := range n.Children {
|
||||
s += ch.total()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (n HeatNode) change() float64 {
|
||||
if len(n.Children) == 0 {
|
||||
return n.Change
|
||||
}
|
||||
sum, w := 0.0, 0.0
|
||||
for _, ch := range n.Children {
|
||||
sum += ch.change() * ch.total()
|
||||
w += ch.total()
|
||||
}
|
||||
if w == 0 {
|
||||
return 0
|
||||
}
|
||||
return sum / w
|
||||
}
|
||||
|
||||
// marketHeatCheck panics on unnamed, duplicate-sibling, negative or non-finite nodes.
|
||||
func marketHeatCheck(n HeatNode) {
|
||||
if n.Name == "" || n.Cap < 0 || math.IsNaN(n.Cap) || math.IsInf(n.Cap, 0) || math.IsNaN(n.Change) || math.IsInf(n.Change, 0) {
|
||||
panic("mujica: heat nodes need a name, a finite cap ≥ 0 and a finite change")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, ch := range n.Children {
|
||||
if seen[ch.Name] {
|
||||
panic("mujica: heat node has two children named " + ch.Name)
|
||||
}
|
||||
seen[ch.Name] = true
|
||||
marketHeatCheck(ch)
|
||||
}
|
||||
}
|
||||
|
||||
func marketHeatDescend(root HeatNode, path []string) HeatNode {
|
||||
n := root
|
||||
for _, name := range path {
|
||||
i := slices.IndexFunc(n.Children, func(ch HeatNode) bool { return ch.Name == name })
|
||||
if i < 0 {
|
||||
panic("mujica: heatmap path has no node " + name)
|
||||
}
|
||||
n = n.Children[i]
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type marketHeatCell struct {
|
||||
x, y float32
|
||||
change float64
|
||||
}
|
||||
|
||||
func (m *MarketHeatMemo) Layout(caps []float64, r ui.Rect) {
|
||||
if m.Rects != nil && m.width == r.W && m.height == r.H {
|
||||
return
|
||||
}
|
||||
m.width, m.height = r.W, r.H
|
||||
m.Rects = squarify(caps, ui.Rect{W: r.W, H: r.H})
|
||||
m.Visible, m.Small = nil, nil
|
||||
type aggregate struct{ weight, change float64 }
|
||||
bins := map[[2]int]aggregate{}
|
||||
for i, t := range m.Rects {
|
||||
if t.W >= 4 && t.H >= 4 {
|
||||
m.Visible = append(m.Visible, i)
|
||||
continue
|
||||
}
|
||||
key := [2]int{int((t.X + t.W/2) / 4), int((t.Y + t.H/2) / 4)}
|
||||
a := bins[key]
|
||||
a.weight += caps[i]
|
||||
a.change += caps[i] * m.Tiles[i].Change
|
||||
bins[key] = a
|
||||
}
|
||||
for key, a := range bins {
|
||||
m.Small = append(m.Small, marketHeatCell{float32(key[0]) * 4, float32(key[1]) * 4, a.change / a.weight})
|
||||
}
|
||||
slices.SortFunc(m.Small, func(a, b marketHeatCell) int {
|
||||
if a.y != b.y {
|
||||
return cmp.Compare(a.y, b.y)
|
||||
}
|
||||
return cmp.Compare(a.x, b.x)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestMarketHeatmapAreaDrillAndKeys(t *testing.T) {
|
||||
root := mktHeat()
|
||||
var path []string
|
||||
var got ChartResult
|
||||
tt := chatTester(640, 400, func(c *ui.Context) {
|
||||
got = MarketHeatmap(c, &path, root, MarketHeatmapOptions{Frame: ChartFrame{Label: "Market"}})
|
||||
})
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyHome)
|
||||
if !tt.HasText("Technology · up +1.18% · 8,000 · Enter opens") {
|
||||
t.Fatalf("readout %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(path) != 1 || path[0] != "Technology" {
|
||||
t.Fatalf("path %v", path)
|
||||
}
|
||||
tt.Key(0, ui.KeyBackspace)
|
||||
if len(path) != 0 {
|
||||
t.Fatalf("back %v", path)
|
||||
}
|
||||
if got.Element == nil {
|
||||
t.Fatal("element missing")
|
||||
}
|
||||
if root.total() != 9830 {
|
||||
t.Fatalf("cap %v", root.total())
|
||||
}
|
||||
marketsPanic(t, func(c *ui.Context) {
|
||||
MarketHeatmap(c, &path, HeatNode{Name: "Bad", Cap: -1}, MarketHeatmapOptions{Frame: ChartFrame{Label: "x"}})
|
||||
})
|
||||
}
|
||||
func TestMarketHeatmapSnapshot(t *testing.T) {
|
||||
var path []string
|
||||
snapshot(t, "market_heatmap", 640, 400, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(16).Gap(12).Fill().Children(func() {
|
||||
MarketHeatmap(c, &path, mktHeat(), MarketHeatmapOptions{Frame: ChartFrame{Label: "S&P 500", Height: 280}})
|
||||
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
)
|
||||
|
||||
// input handles pan, zoom, alerts, drawings, and keyboard following.
|
||||
func (m *mktCore) input(plot *ui.Element) {
|
||||
if plot.IsDisabled() {
|
||||
return
|
||||
}
|
||||
n := len(m.candles)
|
||||
room := m.room()
|
||||
changed := false
|
||||
plot.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if ev.Kind != ui.InputScroll || plot.IsDisabled() || ev.DY == 0 {
|
||||
return false
|
||||
}
|
||||
f := 0.9
|
||||
if ev.DY > 0 {
|
||||
f = 1 / 0.9
|
||||
}
|
||||
marketZoom(m.view, n, m.room(), f)
|
||||
return true
|
||||
})
|
||||
switch {
|
||||
case plot.Shortcut(ui.Shift, ui.KeyLeft):
|
||||
marketPan(m.view, n, room, -max(1, (m.to-m.from)/10))
|
||||
changed = true
|
||||
case plot.Shortcut(ui.Shift, ui.KeyRight):
|
||||
marketPan(m.view, n, room, max(1, (m.to-m.from)/10))
|
||||
changed = true
|
||||
case plot.Shortcut(0, ui.KeyEqual):
|
||||
marketZoom(m.view, n, room, 0.8)
|
||||
changed = true
|
||||
case plot.Shortcut(0, ui.KeyMinus):
|
||||
marketZoom(m.view, n, room, 1.25)
|
||||
changed = true
|
||||
}
|
||||
// Keep the keyboard mark visible.
|
||||
f := m.ch.st.focus
|
||||
if f >= 0 && f != m.st.focus && !changed && (f < m.from || f >= m.to) {
|
||||
if f < m.from {
|
||||
marketPan(m.view, n, room, f-m.from)
|
||||
} else {
|
||||
marketPan(m.view, n, room, f-m.to+1)
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
m.st.focus = f
|
||||
b := plot.Bounds()
|
||||
g := m.geom(ui.Rect{X: b.X, Y: b.Y, W: b.W, H: b.H})
|
||||
px, py, over := plot.PointerPosition()
|
||||
x, y := b.X+px, b.Y+py
|
||||
dx, dy, held := plot.Dragged()
|
||||
used := false
|
||||
if d := m.opts.Drawings; d != nil {
|
||||
used = d.input(m, plot, g, x, y, over, held, dx, dy)
|
||||
}
|
||||
if !used {
|
||||
m.alertsKeyboard(plot)
|
||||
used = m.alertsInput(g, x, y, held, dy)
|
||||
}
|
||||
if !used && held && dx != 0 && g.band.Step() > 0 {
|
||||
m.st.dragRest += dx
|
||||
if steps := int(m.st.dragRest / g.band.Step()); steps != 0 {
|
||||
m.st.dragRest -= float32(steps) * g.band.Step()
|
||||
marketPan(m.view, n, room, -steps)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !held {
|
||||
m.st.dragRest = 0
|
||||
}
|
||||
if changed {
|
||||
slog.Debug("mujica: candlestick_chart view", "count", m.view.Count, "end", m.view.End)
|
||||
m.c.Invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mktCore) alertsKeyboard(plot *ui.Element) {
|
||||
if m.opts.Alerts == nil || len(*m.opts.Alerts) == 0 || m.base != 0 {
|
||||
return
|
||||
}
|
||||
a := *m.opts.Alerts
|
||||
m.st.alertFocus = min(m.st.alertFocus, len(a)-1)
|
||||
selected := false
|
||||
if plot.Shortcut(0, ui.KeyPageDown) {
|
||||
m.st.alertFocus = (m.st.alertFocus + 1) % len(a)
|
||||
selected = true
|
||||
}
|
||||
if plot.Shortcut(0, ui.KeyPageUp) {
|
||||
m.st.alertFocus = (max(m.st.alertFocus, 0) - 1 + len(a)) % len(a)
|
||||
selected = true
|
||||
}
|
||||
if selected && m.opts.Drawings != nil {
|
||||
d := m.opts.Drawings
|
||||
d.sel, d.pending, d.Tool = 0, nil, ToolSelect
|
||||
}
|
||||
delta := 0
|
||||
if m.opts.Drawings == nil || m.opts.Drawings.Selected() < 0 {
|
||||
if plot.Shortcut(ui.Alt, ui.KeyUp) {
|
||||
delta = 1
|
||||
}
|
||||
if plot.Shortcut(ui.Alt, ui.KeyDown) {
|
||||
delta = -1
|
||||
}
|
||||
}
|
||||
if i := m.st.alertFocus; i >= 0 && (selected || delta != 0) {
|
||||
spec := input.NumberInputResolve(input.NumberInputOptions{Step: math.Pow10(-m.dec), Precision: m.dec})
|
||||
if delta != 0 {
|
||||
a[i].Price = spec.StepBy(a[i].Price, delta)
|
||||
slog.Debug("mujica: price_alert_line moved", "id", a[i].ID)
|
||||
}
|
||||
m.c.Announce(fmt.Sprintf(core.Msg(m.c, "price_alert_line.tag"), core.PriceUngrouped(a[i].Price, m.dec)))
|
||||
m.c.Invalidate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestDrawingToolsKeyboardCreateAndSelect(t *testing.T) {
|
||||
cs := sampleCandles(30)
|
||||
v := MarketView{Count: 30}
|
||||
d := DrawingTools{Tool: ToolTrendLine}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Drawing"}, Drawings: &d})
|
||||
}, 640, 340)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(d.Items) != 1 || d.Items[0].A.Time != cs[0].Time || d.Items[0].B.Time != cs[1].Time {
|
||||
t.Fatalf("keyboard drawing %+v", d)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
tt.Key(ui.Alt, ui.KeyEnter)
|
||||
if d.Selected() != 0 {
|
||||
t.Fatalf("selection %d", d.Selected())
|
||||
}
|
||||
tt.Key(0, ui.KeyDelete)
|
||||
if len(d.Items) != 0 {
|
||||
t.Fatal("keyboard deletion failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriceAlertLineKeyboard(t *testing.T) {
|
||||
cs := sampleCandles(30)
|
||||
v := MarketView{Count: 30}
|
||||
alerts := []PriceAlertLine{{ID: "a", Price: 100}, {ID: "b", Price: 105}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Alerts"}, Alerts: &alerts})
|
||||
}, 640, 340)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyPageDown)
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
if alerts[0].Price != 100.01 || alerts[1].Price != 105 {
|
||||
t.Fatalf("first alert %+v", alerts)
|
||||
}
|
||||
tt.Key(0, ui.KeyPageDown)
|
||||
tt.Key(ui.Alt, ui.KeyDown)
|
||||
if alerts[1].Price != 104.99 {
|
||||
t.Fatalf("second alert %+v", alerts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestDepthChartLargeDataBounded(t *testing.T) {
|
||||
bids, asks := make([]DepthLevel, 100000), make([]DepthLevel, 100000)
|
||||
for i := range bids {
|
||||
bids[i] = DepthLevel{100 - float64(i)*0.0001, 1}
|
||||
asks[i] = DepthLevel{101 + float64(i)*0.0001, 2}
|
||||
}
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
res = DepthChart(c, bids, asks, DepthChartOptions{Frame: ChartFrame{Label: "Depth"}})
|
||||
}, 640, 340)
|
||||
drawn := *ui.Local(res.Element, "mujica.depth.drawn", func() int { return 0 })
|
||||
if drawn <= 0 || drawn > 4*640+8 {
|
||||
t.Fatalf("painted %d depth vertices", drawn)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if res.Point != 199999 {
|
||||
t.Fatalf("last point %d", res.Point)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumeProfileLargeDataBounded(t *testing.T) {
|
||||
bins := make([]ProfileBin, 100000)
|
||||
for i := range bins {
|
||||
bins[i] = ProfileBin{float64(i), float64(i + 1), float64(i%19 + 1)}
|
||||
}
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
res = VolumeProfile(c, bins, VolumeProfileOptions{Frame: ChartFrame{Label: "Profile"}})
|
||||
}, 640, 340)
|
||||
drawn := *ui.Local(res.Element, "mujica.profile.drawn", func() int { return 0 })
|
||||
if drawn <= 0 || drawn > 4*340+4 {
|
||||
t.Fatalf("painted %d profile bins", drawn)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if res.Point != 99999 {
|
||||
t.Fatalf("last bin %d", res.Point)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/icons"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
)
|
||||
|
||||
// PriceAlertLine holds a draggable, caller-owned price threshold.
|
||||
type PriceAlertLine struct {
|
||||
ID string
|
||||
Price float64
|
||||
// Label shows beside the price; empty shows the price only.
|
||||
Label string
|
||||
}
|
||||
|
||||
// CompareSymbol holds aligned closes for percent comparison.
|
||||
type CompareSymbol struct {
|
||||
Name string
|
||||
Closes []float64
|
||||
}
|
||||
|
||||
// compareLines normalizes prices and compared symbols into percentages.
|
||||
func (m *mktCore) compareLines() {
|
||||
if m.opts.Compare == nil || len(*m.opts.Compare) == 0 || m.to <= m.from {
|
||||
return
|
||||
}
|
||||
m.base = m.candles[m.from].Close
|
||||
if m.base == 0 {
|
||||
panic("mujica: percent comparison needs a nonzero base close")
|
||||
}
|
||||
pal := internal.Palette(m.k)
|
||||
for i := range m.overlays {
|
||||
line := &m.overlays[i]
|
||||
values := line.values
|
||||
base := m.base
|
||||
line.values = ChartMemo(m.ch, fmt.Sprint("compare.overlay.", i), [2]any{ChartID(values), base}, func() []float64 {
|
||||
out := make([]float64, len(values))
|
||||
for j, v := range values {
|
||||
out[j] = (v/base - 1) * 100
|
||||
}
|
||||
return out
|
||||
})
|
||||
}
|
||||
for i, s := range *m.opts.Compare {
|
||||
if s.Name == "" || len(s.Closes) != len(m.candles) {
|
||||
panic(fmt.Sprintf("mujica: CompareSymbol %q needs a Name and one close per candle", s.Name))
|
||||
}
|
||||
ChartMemo(m.ch, "compare.check."+s.Name, ChartID(s.Closes), func() bool {
|
||||
for _, v := range s.Closes {
|
||||
if math.IsInf(v, 0) || (!math.IsNaN(v) && v <= 0) {
|
||||
panic("mujica: compared closes must be positive or NaN")
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
base := math.NaN()
|
||||
for j := m.from; j < m.to && math.IsNaN(base); j++ {
|
||||
base = s.Closes[j]
|
||||
}
|
||||
closes := s.Closes
|
||||
vals := ChartMemo(m.ch, "compare."+s.Name, [2]any{ChartID(closes), math.Float64bits(base)}, func() []float64 {
|
||||
out := make([]float64, len(closes))
|
||||
for j, v := range closes {
|
||||
out[j] = (v/base - 1) * 100
|
||||
}
|
||||
return out
|
||||
})
|
||||
m.overlays = append(m.overlays, mktLine{name: s.Name, values: vals, color: pal[(i+1)%len(pal)], shape: i + 1})
|
||||
}
|
||||
}
|
||||
|
||||
// CompareLegend shows series markers and removal actions.
|
||||
func CompareLegend(c *ui.Context, symbols *[]CompareSymbol) core.ChoiceResult {
|
||||
if symbols == nil {
|
||||
panic("mujica: CompareLegend needs symbols")
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
pal := internal.Palette(k)
|
||||
u := core.Density(c).Unit()
|
||||
removed := -1
|
||||
row := ui.Row(c).Wrap().Gap(u * 2).Role(ui.RoleList).Label(core.Msg(c, "compare_symbol.label"))
|
||||
row.Children(func() {
|
||||
for i, s := range *symbols {
|
||||
ui.Row(c).Key(s.Name).AlignItems(ui.Center).Gap(u).Padding(0, 0, 0, u*2).Radius(6).Border(1, k.Border).Role(ui.RoleListItem).Label(s.Name).Children(func() {
|
||||
col := pal[(i+1)%len(pal)]
|
||||
ui.Box(c).Size(10, 10).Draw(func(p *ui.Painter, r ui.Rect) { internal.Marker(p, i+1, r.X+5, r.Y+5, 9, col) })
|
||||
ui.Text(c, s.Name).SingleLine()
|
||||
if core.IconAction(c, icons.Must("x"), fmt.Sprintf(core.Msg(c, "compare_symbol.remove"), s.Name)).Clicked() && !row.IsDisabled() {
|
||||
removed = i
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
if removed >= 0 {
|
||||
slog.Debug("mujica: compare_symbol removed", "name", (*symbols)[removed].Name)
|
||||
*symbols = append((*symbols)[:removed:removed], (*symbols)[removed+1:]...)
|
||||
}
|
||||
return core.NewChoiceResult(row, removed >= 0)
|
||||
}
|
||||
|
||||
// alertsCheck fires callbacks when the latest close crosses thresholds.
|
||||
func (m *mktCore) alertsCheck() {
|
||||
if m.opts.Alerts != nil {
|
||||
seen := map[string]bool{}
|
||||
for _, a := range *m.opts.Alerts {
|
||||
if a.ID == "" || seen[a.ID] || math.IsNaN(a.Price) || math.IsInf(a.Price, 0) {
|
||||
panic("mujica: alerts need unique IDs and finite prices")
|
||||
}
|
||||
seen[a.ID] = true
|
||||
}
|
||||
}
|
||||
if m.opts.Alerts == nil || len(m.candles) == 0 {
|
||||
return
|
||||
}
|
||||
last := m.candles[len(m.candles)-1]
|
||||
if !m.ch.Res.Element.IsDisabled() && m.st.alertSeen && last.Close != m.st.lastClose && m.opts.OnAlert != nil {
|
||||
for _, a := range *m.opts.Alerts {
|
||||
if (m.st.lastClose-a.Price)*(last.Close-a.Price) <= 0 && m.st.lastClose != a.Price {
|
||||
slog.Debug("mujica: price_alert_line crossed", "id", a.ID)
|
||||
m.opts.OnAlert(a, last)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.st.lastClose, m.st.alertSeen = last.Close, true
|
||||
}
|
||||
|
||||
// alertsInput drags the threshold captured by the initial press.
|
||||
func (m *mktCore) alertsInput(g mktGeom, x, y float32, held bool, dy float32) bool {
|
||||
if m.opts.Alerts == nil || m.base != 0 {
|
||||
return false
|
||||
}
|
||||
alerts := *m.opts.Alerts
|
||||
if !held {
|
||||
m.st.grab = -1
|
||||
return false
|
||||
}
|
||||
if m.st.grab < 0 {
|
||||
for i, a := range alerts {
|
||||
if g.plot.Contains(x, y-dy) && math.Abs(float64(g.ys.Map(a.Price)-(y-dy))) <= 14 {
|
||||
m.st.grab = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.st.grab < 0 || m.st.grab >= len(alerts) {
|
||||
return false
|
||||
}
|
||||
spec := input.NumberInputResolve(input.NumberInputOptions{Step: math.Pow10(-m.dec), Precision: m.dec})
|
||||
alerts[m.st.grab].Price = spec.Round(g.ys.Invert(y))
|
||||
slog.Debug("mujica: price_alert_line moved", "id", alerts[m.st.grab].ID)
|
||||
return true
|
||||
}
|
||||
|
||||
// paintAlerts draws dashed thresholds and price labels.
|
||||
func (m *mktCore) paintAlerts(p *ui.Painter, g mktGeom, size float32) {
|
||||
if m.opts.Alerts == nil || m.base != 0 {
|
||||
return
|
||||
}
|
||||
for i, a := range *m.opts.Alerts {
|
||||
y := internal.Snap(p, g.ys.Map(a.Price))
|
||||
if i == m.st.alertFocus {
|
||||
p.FillPath(internal.Diamond(internal.Snap(p, g.plot.X+8), y, 9), m.k.Warning)
|
||||
}
|
||||
for x := g.plot.X; x < g.plot.X+g.plot.W; x += 8 {
|
||||
p.Line(x, y, min(x+4, g.plot.X+g.plot.W), y, 1, m.k.Warning)
|
||||
}
|
||||
text := core.PriceUngrouped(a.Price, m.dec)
|
||||
if a.Label != "" {
|
||||
text = a.Label + " " + text
|
||||
}
|
||||
mktTag(p, g.plot.X+g.plot.W, y, fmt.Sprintf(core.Msg(m.c, "price_alert_line.tag"), text), size, m.k.Warning, m.k.Surface)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestMarketEmptyWindow(t *testing.T) {
|
||||
from, to := marketWindow(MarketView{}, 0, 200)
|
||||
if from != 0 || to != 0 {
|
||||
t.Fatalf("empty window %d..%d", from, to)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketDisabledAlert(t *testing.T) {
|
||||
cs := sampleCandles(2)
|
||||
v := MarketView{}
|
||||
alerts := []PriceAlertLine{{ID: "a", Price: cs[1].Close + 1}}
|
||||
disabled, fired := false, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "Price"}, Alerts: &alerts,
|
||||
OnAlert: func(PriceAlertLine, Candle) { fired++ }})
|
||||
})
|
||||
}, 500, 320)
|
||||
disabled = true
|
||||
next := cs[1]
|
||||
next.Time = next.Time.Add(3600e9)
|
||||
next.Close += 2
|
||||
next.High = max(next.High, next.Close)
|
||||
cs = append(cs, next)
|
||||
tt.Frame()
|
||||
if fired != 0 {
|
||||
t.Fatalf("disabled alert fired %d times", fired)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketDisabledLayoutCopy(t *testing.T) {
|
||||
state := MultiChartState{Count: 2}
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(true).Children(func() {
|
||||
MultiChartLayout(c, &state, MultiChartLayoutOptions{}, func(_ int, cell *ChartCell) {
|
||||
cell.Symbol = "changed"
|
||||
})
|
||||
})
|
||||
}, 600, 600)
|
||||
if state.Cells[0].Symbol != "" || state.Cells[1].Symbol != "" {
|
||||
t.Fatalf("disabled layout wrote cells: %+v", state.Cells)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketDisabledParamsCopy(t *testing.T) {
|
||||
var params []float64
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Disabled(true).Children(func() { IndicatorParamsField(c, IndicatorMA, ¶ms) })
|
||||
}, 500, 100)
|
||||
if params != nil {
|
||||
t.Fatalf("disabled field initialized caller parameters: %v", params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketInvalidFiniteData(t *testing.T) {
|
||||
for _, v := range []float64{math.Inf(1), math.Inf(-1), math.NaN()} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("nonfinite candle accepted: %v", v)
|
||||
}
|
||||
}()
|
||||
cs := sampleCandles(1)
|
||||
cs[0].High = v
|
||||
marketCheckCandles(cs)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketLockedUndo(t *testing.T) {
|
||||
d := DrawingTools{Items: []Drawing{{Tool: ToolHorizontal}}}
|
||||
d.Clear()
|
||||
d.Locked = true
|
||||
if d.Undo() || len(d.Items) != 0 {
|
||||
t.Fatal("locked drawings accepted undo")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"image"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestMarketSyncRange(t *testing.T) {
|
||||
cs := sampleCandles(300)
|
||||
short := cs[:200]
|
||||
s := ChartSync{Count: 40, End: cs[249].Time}
|
||||
var a, b MarketView
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Children(func() {
|
||||
CandlestickChart(c, cs, &a, CandlestickOptions{Frame: ChartFrame{Label: "A", Height: 100}, Sync: &s, SyncID: "a"})
|
||||
CandlestickChart(c, short, &b, CandlestickOptions{Frame: ChartFrame{Label: "B", Height: 100}, Sync: &s, SyncID: "b"})
|
||||
})
|
||||
}, 640, 400)
|
||||
t.Logf("A=%+v B=%+v sync.End=%v", a, b, s.End)
|
||||
if a.End != 250 || s.End != cs[249].Time {
|
||||
t.Error("B erased A's explicit historical range")
|
||||
}
|
||||
}
|
||||
func TestMarketPaneKeyboardGeometry(t *testing.T) {
|
||||
cs := sampleCandles(300)
|
||||
v := MarketView{Count: 40}
|
||||
panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorOBV}}}
|
||||
var r IndicatorPaneResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r = IndicatorPane(c, cs, &v, &panes, IndicatorPaneOptions{Label: "BTC", Height: 220})
|
||||
}, 640, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Frame()
|
||||
from, to := marketWindow(v, 300, 192)
|
||||
t.Logf("Point=%d visible=%d..%d view=%+v", r.Point, from, to, v)
|
||||
if r.Point < from || r.Point >= to {
|
||||
t.Error("focused readout has no visible crosshair")
|
||||
}
|
||||
}
|
||||
func TestMarketAlertsWithDrawingTools(t *testing.T) {
|
||||
cs := sampleCandles(30)
|
||||
v := MarketView{}
|
||||
a := []PriceAlertLine{{ID: "a", Price: 100}}
|
||||
d := DrawingTools{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "A"}, Alerts: &a, Drawings: &d})
|
||||
}, 640, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyPageDown)
|
||||
tt.Key(ui.Alt, ui.KeyUp)
|
||||
t.Logf("alert=%v", a[0].Price)
|
||||
if a[0].Price != 100.01 {
|
||||
t.Error("alert keyboard adjustment inaccessible with idle drawing tools")
|
||||
}
|
||||
}
|
||||
func TestMarketDrawingSwitchCancelsPending(t *testing.T) {
|
||||
cs := sampleCandles(30)
|
||||
v := MarketView{}
|
||||
d := DrawingTools{Tool: ToolTrendLine}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Row(c).Children(func() {
|
||||
DrawingToolbar(c, &d, DrawingToolbarOptions{})
|
||||
CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "A"}, Drawings: &d}).Element.Grow(1)
|
||||
})
|
||||
}, 640, 450)
|
||||
tt.ClickAt(250, 100)
|
||||
if d.pending == nil {
|
||||
t.Fatal("setup missing pending point")
|
||||
}
|
||||
tt.Key(ui.Alt, ui.Key1)
|
||||
t.Logf("Tool=%d pending=%+v", d.Tool, d.pending)
|
||||
before := v
|
||||
tt.Press(300, 150)
|
||||
tt.Move(400, 150)
|
||||
tt.Release(400, 150)
|
||||
t.Logf("before=%+v after=%+v pending=%+v", before, v, d.pending)
|
||||
if d.Tool == ToolSelect && d.pending != nil {
|
||||
t.Error("Select retains previous tool preview and captures pan input")
|
||||
}
|
||||
}
|
||||
func TestMarketCountdownDST(t *testing.T) {
|
||||
loc, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 3, 8, 23, 30, 0, 0, loc)
|
||||
close := Interval("1h").Close(now, loc)
|
||||
t.Logf("now=%s closes=%s", now, close)
|
||||
if close.Hour() != 0 {
|
||||
t.Error("hourly candle closes off the wall-clock hour")
|
||||
}
|
||||
}
|
||||
func TestMarketPaneResizeGeometry(t *testing.T) {
|
||||
cs := sampleCandles(300)
|
||||
v := MarketView{Count: 40}
|
||||
p := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorMACD}, {Kind: IndicatorRSI}}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
IndicatorPane(c, cs, &v, &p, IndicatorPaneOptions{Label: "BTC", Height: 320})
|
||||
}, 640, 400)
|
||||
for i := 0; i < 12; i++ {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if tt.Focused("Resize MACD(12,26,9) and RSI(14)") {
|
||||
break
|
||||
}
|
||||
}
|
||||
t.Logf("texts=%q", tt.Texts())
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Frame()
|
||||
top, _ := tt.Find("MACD(12,26,9)")
|
||||
plot, _ := tt.Find("BTC MACD(12,26,9)")
|
||||
t.Logf("sizes=%v top=%+v plot=%+v", p.Sizes, top, plot)
|
||||
if plot.H == 0 {
|
||||
t.Error("resizing to advertised minimum completely hides the chart")
|
||||
}
|
||||
}
|
||||
func TestMarketMissingComparisonCache(t *testing.T) {
|
||||
cs := sampleCandles(100000)
|
||||
v := MarketView{}
|
||||
s := []CompareSymbol{{Name: "missing", Closes: make([]float64, len(cs))}}
|
||||
for i := range s[0].Closes {
|
||||
s[0].Closes[i] = math.NaN()
|
||||
}
|
||||
var r ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
r = CandlestickChart(c, cs, &v, CandlestickOptions{Frame: ChartFrame{Label: "A"}, Compare: &s})
|
||||
}, 640, 320)
|
||||
memo := ui.Local(r.Element, "mujica.memo.compare.missing", func() ChartMemoEntry { return ChartMemoEntry{} })
|
||||
before := ChartID(memo.Val.([]float64))
|
||||
tt.Frame()
|
||||
memo = ui.Local(r.Element, "mujica.memo.compare.missing", func() ChartMemoEntry { return ChartMemoEntry{} })
|
||||
after := ChartID(memo.Val.([]float64))
|
||||
t.Logf("before=%s after=%s", before, after)
|
||||
if before != after {
|
||||
t.Error("unchanged missing comparison allocated and recomputed 100000 values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarketDailyAxisLocale(t *testing.T) {
|
||||
candles := sampleCandles(30)
|
||||
for i := range candles {
|
||||
candles[i].Time = candles[0].Time.AddDate(0, 0, i)
|
||||
}
|
||||
render := func(locale core.Locale) *image.RGBA {
|
||||
view := MarketView{}
|
||||
return ui.Render(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Locale: locale})
|
||||
CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "BTC", Height: 240}})
|
||||
}, 640, 320, 1)
|
||||
}
|
||||
en, zh := render(core.EnUS), render(core.ZhCN)
|
||||
for y := 214; y < 240; y++ {
|
||||
for x := 60; x < 620; x++ {
|
||||
if en.RGBAAt(x, y) != zh.RGBAAt(x, y) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatal("Chinese daily time axis retained English labels")
|
||||
}
|
||||
|
||||
func TestMarketSyncDifferentIntervals(t *testing.T) {
|
||||
cs := sampleCandles(300)
|
||||
var slower []Candle
|
||||
for i := 0; i < len(cs); i += 2 {
|
||||
slower = append(slower, cs[i])
|
||||
}
|
||||
s := ChartSync{Count: 40, End: cs[199].Time}
|
||||
var a, b MarketView
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
ui.Column(c).Children(func() {
|
||||
CandlestickChart(c, cs, &a, CandlestickOptions{Frame: ChartFrame{Label: "A", Height: 100}, Sync: &s, SyncID: "a"})
|
||||
CandlestickChart(c, slower, &b, CandlestickOptions{Frame: ChartFrame{Label: "B", Height: 100}, Sync: &s, SyncID: "b"})
|
||||
})
|
||||
}, 640, 400)
|
||||
tt.Frame()
|
||||
from, to := marketWindow(b, len(slower), 200)
|
||||
if b.Count != 20 || slower[from].Time != cs[160].Time || slower[to-1].Time != cs[198].Time || s.End != cs[199].Time {
|
||||
t.Fatalf("intervals drifted: a=%+v b=%+v sync=%+v", a, b, s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/data"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
func TestMarketChartsStatesDisabledAndBoundaries(t *testing.T) {
|
||||
for _, name := range []string{"Candlestick", "OHLC", "HeikinAshi", "Quote", "Volume", "Depth", "Profile", "Overlay", "Compare", "Alerts", "Drawings"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cs := sampleCandles(30)
|
||||
v := MarketView{Count: 30}
|
||||
raw := false
|
||||
bids, asks := sampleBook()
|
||||
bins := VolumeProfileOf(cs, 12)
|
||||
quotes, _, _ := samplePoints()
|
||||
symbols := []CompareSymbol{{Name: "Other", Closes: closesOf(cs)}}
|
||||
alerts := []PriceAlertLine{{ID: "a", Price: 100}}
|
||||
drawings := DrawingTools{}
|
||||
status := data.DataReady
|
||||
disabled, empty := false, false
|
||||
var r ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{Density: theme.Compact})
|
||||
ui.Column(c).Disabled(disabled).Width(600).Children(func() {
|
||||
candles := cs
|
||||
if empty {
|
||||
candles = nil
|
||||
}
|
||||
frame := ChartFrame{Label: "Market", Height: 180, Status: status, Error: "Archive unavailable"}
|
||||
opts := CandlestickOptions{Frame: frame}
|
||||
switch name {
|
||||
case "OHLC":
|
||||
r = OHLCChart(c, candles, &v, opts)
|
||||
case "HeikinAshi":
|
||||
r = HeikinAshiChart(c, candles, &v, &raw, opts)
|
||||
case "Quote":
|
||||
q := quotes
|
||||
if empty {
|
||||
q = nil
|
||||
}
|
||||
r = LineQuoteChart(c, q, LineQuoteChartOptions{Frame: frame, PrevClose: 100})
|
||||
case "Volume":
|
||||
r = VolumeChart(c, candles, &v, VolumeChartOptions{Frame: frame})
|
||||
case "Depth":
|
||||
b, a := bids, asks
|
||||
if empty {
|
||||
b = nil
|
||||
a = nil
|
||||
}
|
||||
r = DepthChart(c, b, a, DepthChartOptions{Frame: frame})
|
||||
case "Profile":
|
||||
b := bins
|
||||
if empty {
|
||||
b = nil
|
||||
}
|
||||
r = VolumeProfile(c, b, VolumeProfileOptions{Frame: frame})
|
||||
default:
|
||||
if name == "Overlay" {
|
||||
opts.Overlays = []IndicatorOverlay{{Kind: IndicatorMA}}
|
||||
}
|
||||
if name == "Compare" {
|
||||
opts.Compare = &symbols
|
||||
}
|
||||
if name == "Alerts" {
|
||||
opts.Alerts = &alerts
|
||||
}
|
||||
if name == "Drawings" {
|
||||
opts.Drawings = &drawings
|
||||
}
|
||||
r = CandlestickChart(c, candles, &v, opts)
|
||||
}
|
||||
})
|
||||
}, 620, 350)
|
||||
for range 10 {
|
||||
if tt.Focused("Market") {
|
||||
break
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if r.Point < 0 || len(tt.Announcements()) == 0 {
|
||||
t.Fatalf("keyboard path point %d", r.Point)
|
||||
}
|
||||
tt.Key(0, ui.KeyHome)
|
||||
first := r.Point
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
if name != "Depth" && name != "Profile" && r.Point != first {
|
||||
t.Fatalf("left boundary %d", r.Point)
|
||||
}
|
||||
disabled = true
|
||||
before := v
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyEqual)
|
||||
tt.Scroll(200, 120, 0, 20)
|
||||
tt.ClickAt(200, 120)
|
||||
if v != before {
|
||||
t.Fatalf("disabled window changed: %+v", v)
|
||||
}
|
||||
disabled = false
|
||||
tt.Frame()
|
||||
height := r.Element.Bounds().H
|
||||
for _, st := range []data.DataStatus{data.DataLoading, data.DataFailed, data.DataReady} {
|
||||
status = st
|
||||
empty = st == data.DataReady
|
||||
tt.Frame()
|
||||
if r.Point != -1 || r.Element.Bounds().H != height {
|
||||
t.Fatalf("state %v: point %d height %v want %v", st, r.Point, r.Element.Bounds().H, height)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/input"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// ChartCell holds one chart's symbol, interval, and window.
|
||||
type ChartCell struct {
|
||||
Symbol string
|
||||
Interval Interval
|
||||
View MarketView
|
||||
}
|
||||
|
||||
// MultiChartState preserves six cells across layout changes.
|
||||
type MultiChartState struct {
|
||||
// Count is 1, 2, 4 or 6; zero is 1.
|
||||
Count int
|
||||
// Cells preserve their settings while hidden.
|
||||
Cells [6]ChartCell
|
||||
Active int
|
||||
}
|
||||
|
||||
// MultiChartLayoutOptions configure a MultiChartLayout.
|
||||
type MultiChartLayoutOptions struct {
|
||||
// Height of the grid; zero is 480.
|
||||
Height float32
|
||||
}
|
||||
|
||||
// MultiChartLayout builds 1, 2, 4, or 6 independent charts.
|
||||
func MultiChartLayout(c *ui.Context, state *MultiChartState, opts MultiChartLayoutOptions, cell func(i int, cell *ChartCell)) core.ChoiceResult {
|
||||
if state == nil || cell == nil {
|
||||
panic("mujica: MultiChartLayout needs a state and a cell builder")
|
||||
}
|
||||
root := ui.Column(c).Gap(core.Density(c).Unit() * 2).FillWidth()
|
||||
if root.IsDisabled() {
|
||||
copy := *state
|
||||
state = ©
|
||||
}
|
||||
if opts.Height < 0 {
|
||||
panic("mujica: MultiChartLayout Height must be non-negative")
|
||||
}
|
||||
if state.Count == 0 {
|
||||
state.Count = 1
|
||||
}
|
||||
cols := map[int]int{1: 1, 2: 2, 4: 2, 6: 3}[state.Count]
|
||||
if cols == 0 {
|
||||
panic(fmt.Sprintf("mujica: MultiChartLayout Count %d must be 1, 2, 4 or 6", state.Count))
|
||||
}
|
||||
if state.Active < 0 || state.Active >= state.Count {
|
||||
panic(fmt.Sprintf("mujica: MultiChartLayout Active %d outside %d cells", state.Active, state.Count))
|
||||
}
|
||||
k := core.Tokens(c)
|
||||
u := core.Density(c).Unit()
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 480
|
||||
}
|
||||
changed := false
|
||||
root.Children(func() {
|
||||
sel := []int{state.Count}
|
||||
var items []input.ToggleItem[int]
|
||||
for _, n := range []int{1, 2, 4, 6} {
|
||||
items = append(items, input.ToggleItem[int]{Value: n, Label: fmt.Sprint(n), Name: fmt.Sprintf(core.Msg(c, "multi_chart_layout.count"), n)})
|
||||
}
|
||||
ui.Row(c).Children(func() {
|
||||
if input.ToggleGroup(c, &sel, items, input.ToggleGroupOptions{Label: core.Msg(c, "multi_chart_layout.label")}).Changed() {
|
||||
state.Count, changed = sel[0], true
|
||||
state.Active = min(state.Active, state.Count-1)
|
||||
slog.Debug("mujica: multi_chart_layout count", "count", state.Count)
|
||||
}
|
||||
})
|
||||
cols = map[int]int{1: 1, 2: 2, 4: 2, 6: 3}[state.Count]
|
||||
rows := (state.Count + cols - 1) / cols
|
||||
grid := ui.Box(c).Grid().Columns(cols).Gap(u * 2).Height(h).FillWidth()
|
||||
grid.Children(func() {
|
||||
for i := range state.Count {
|
||||
box := ui.Column(c).Key(i).MinWidth(0).MinHeight(0).Height((h - u*2*float32(rows-1)) / float32(rows)).Padding(u * 2).
|
||||
Background(k.Surface).Radius(theme.CardRadius).Clip().Role(ui.RoleGroup).
|
||||
Label(fmt.Sprintf(core.Msg(c, "multi_chart_layout.cell"), i+1, state.Cells[i].Symbol))
|
||||
if i == state.Active && state.Count > 1 {
|
||||
box.Border(2, k.AccentText)
|
||||
box.DrawOver(func(p *ui.Painter, r ui.Rect) { internal.Frame(p, r, 3, 5, k.Ornament) })
|
||||
} else {
|
||||
box.Border(theme.BorderWidth, k.Border)
|
||||
}
|
||||
if !box.IsDisabled() && (box.FocusWithin() || box.Clicked()) {
|
||||
if state.Active != i {
|
||||
state.Active, changed = i, true
|
||||
}
|
||||
}
|
||||
box.Children(func() { cell(i, &state.Cells[i]) })
|
||||
}
|
||||
})
|
||||
})
|
||||
return core.NewChoiceResult(root, changed)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestMultiChartLayoutKeepsCellsAndDisabled(t *testing.T) {
|
||||
state := MultiChartState{Count: 6, Active: 5}
|
||||
for i := range state.Cells {
|
||||
state.Cells[i] = ChartCell{Symbol: "BTC", Interval: "1h", View: MarketView{Count: 20 + i}}
|
||||
}
|
||||
disabled := false
|
||||
built := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
built = 0
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
MultiChartLayout(c, &state, MultiChartLayoutOptions{Height: 200}, func(_ int, cell *ChartCell) { built++; ui.Text(c, cell.Symbol) })
|
||||
})
|
||||
}, 700, 300)
|
||||
if built != 6 {
|
||||
t.Fatalf("built %d", built)
|
||||
}
|
||||
if err := tt.Click("6 charts"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if state.Count != 4 || state.Active != 3 || built != 4 {
|
||||
t.Fatalf("layout %+v built %d", state, built)
|
||||
}
|
||||
if state.Cells[5].View.Count != 25 {
|
||||
t.Fatal("hidden state lost")
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if state.Count != 6 || state.Cells[5].View.Count != 25 {
|
||||
t.Fatal("restored state lost")
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if state.Count != 6 {
|
||||
t.Fatal("disabled layout changed")
|
||||
}
|
||||
}
|
||||
func TestMultiChartLayoutSnapshot(t *testing.T) {
|
||||
state := MultiChartState{Count: 4}
|
||||
snapshot(t, "multi_chart_layout", 640, 360, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
MultiChartLayout(c, &state, MultiChartLayoutOptions{Height: 280}, func(i int, cell *ChartCell) { ui.Text(c, "BTC · 1h") })
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/theme"
|
||||
)
|
||||
|
||||
// GraphNode is a node placed at X, Y unless forced.
|
||||
type GraphNode struct {
|
||||
ID, Label string
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
// GraphEdge joins two nodes by ID.
|
||||
type GraphEdge struct{ From, To string }
|
||||
|
||||
// NetworkGraphOptions configure a NetworkGraph.
|
||||
type NetworkGraphOptions struct {
|
||||
Frame ChartFrame
|
||||
// Force lays nodes out by a simple force simulation.
|
||||
Force bool
|
||||
}
|
||||
|
||||
// NetworkGraphState holds node positions, zoom and pan.
|
||||
type NetworkGraphState struct {
|
||||
pos map[string][2]float64
|
||||
lo, hi [2]float64
|
||||
layout string
|
||||
zoom float32
|
||||
panX float32
|
||||
panY float32
|
||||
drag string
|
||||
dragging bool
|
||||
}
|
||||
|
||||
// Position returns where node id is, in layout units.
|
||||
func (s *NetworkGraphState) Position(id string) (x, y float64, ok bool) {
|
||||
p, ok := s.pos[id]
|
||||
return p[0], p[1], ok
|
||||
}
|
||||
|
||||
// Zoom returns the view's scale, 1 when fitted.
|
||||
func (s *NetworkGraphState) Zoom() float32 { return max(s.zoom, 0) }
|
||||
|
||||
// ResetView fits the graph again.
|
||||
func (s *NetworkGraphState) ResetView() { s.zoom, s.panX, s.panY = 1, 0, 0 }
|
||||
|
||||
// networkForceLimit bounds the nodes the O(n²) force layout takes.
|
||||
const networkForceLimit = 500
|
||||
|
||||
// NetworkGraph draws nodes and edges; drag, pan and zoom.
|
||||
func NetworkGraph(c *ui.Context, s *NetworkGraphState, nodes []GraphNode, edges []GraphEdge, opts NetworkGraphOptions) ChartResult {
|
||||
at := map[string]int{}
|
||||
for i, n := range nodes {
|
||||
if _, dup := at[n.ID]; dup || n.ID == "" || n.Label == "" {
|
||||
panic("mujica: NetworkGraph nodes need unique IDs and labels")
|
||||
}
|
||||
at[n.ID] = i
|
||||
}
|
||||
links := make([][2]int, len(edges))
|
||||
for i, e := range edges {
|
||||
f, okf := at[e.From]
|
||||
t, okt := at[e.To]
|
||||
if !okf || !okt {
|
||||
panic("mujica: NetworkGraph edge ends must be node IDs")
|
||||
}
|
||||
links[i] = [2]int{f, t}
|
||||
}
|
||||
if opts.Force && len(nodes) > networkForceLimit {
|
||||
panic("mujica: NetworkGraph Force takes at most 500 nodes")
|
||||
}
|
||||
if s.zoom == 0 {
|
||||
s.zoom = 1
|
||||
}
|
||||
if key := ChartID(nodes) + ChartID(edges) + fmt.Sprint(opts.Force); s.layout != key {
|
||||
s.layout, s.pos = key, networkLayout(nodes, links, opts.Force)
|
||||
// The view fits the layout; dragging does not refit.
|
||||
s.lo, s.hi = [2]float64{math.Inf(1), math.Inf(1)}, [2]float64{math.Inf(-1), math.Inf(-1)}
|
||||
for _, p := range s.pos {
|
||||
s.lo, s.hi = [2]float64{min(s.lo[0], p[0]), min(s.lo[1], p[1])}, [2]float64{max(s.hi[0], p[0]), max(s.hi[1], p[1])}
|
||||
}
|
||||
slog.Debug("mujica: network_graph layout", "nodes", len(nodes))
|
||||
}
|
||||
ch := NewChart(c, opts.Frame)
|
||||
k := core.Tokens(c)
|
||||
pal := internal.Palette(k)
|
||||
size := core.FontSize(c, theme.CaptionSize)
|
||||
lo, hi := s.lo, s.hi
|
||||
// screen maps a layout point into r, zoomed and panned.
|
||||
screen := func(r ui.Rect, p [2]float64) (float32, float32) {
|
||||
span := max(hi[0]-lo[0], hi[1]-lo[1], 1e-9)
|
||||
fit := float64(min(r.W, r.H)-48) / span
|
||||
cx, cy := (lo[0]+hi[0])/2, (lo[1]+hi[1])/2
|
||||
x := float32((p[0]-cx)*fit)*s.zoom + r.X + r.W/2 + s.panX
|
||||
y := float32((p[1]-cy)*fit)*s.zoom + r.Y + r.H/2 + s.panY
|
||||
return x, y
|
||||
}
|
||||
unit := func(r ui.Rect) float64 {
|
||||
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{Marks: len(nodes)}
|
||||
spec.Readout = func(i int) string {
|
||||
var near []string
|
||||
for _, l := range links {
|
||||
switch i {
|
||||
case l[0]:
|
||||
near = append(near, nodes[l[1]].Label)
|
||||
case l[1]:
|
||||
near = append(near, nodes[l[0]].Label)
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf(core.Msg(c, "network_graph.readout"), nodes[i].Label, len(near), strings.Join(near, ", "))
|
||||
}
|
||||
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) {
|
||||
p.Clip(r, 0, func() {
|
||||
for _, l := range links {
|
||||
x0, y0 := pt(r, l[0])
|
||||
x1, y1 := pt(r, l[1])
|
||||
var line ui.Path
|
||||
line.MoveTo(internal.Snap(p, x0), internal.Snap(p, y0)).LineTo(internal.Snap(p, x1), internal.Snap(p, y1))
|
||||
w, col := float32(1.25), k.ControlBorder
|
||||
if active == l[0] || active == l[1] {
|
||||
w, col = 2.5, k.Text
|
||||
}
|
||||
p.StrokePath(&line, w, col)
|
||||
}
|
||||
for i, n := range nodes {
|
||||
x, y := pt(r, i)
|
||||
internal.Marker(p, 0, x, y, 14, k.Surface)
|
||||
internal.Marker(p, 0, x, y, 11, pal[i%len(pal)])
|
||||
if i == active {
|
||||
var ring ui.Path
|
||||
ring.Circle(internal.Snap(p, x), internal.Snap(p, y), 10)
|
||||
p.StrokePath(&ring, 2, k.Text)
|
||||
}
|
||||
ChartText(p, n.Label, x, y+9, size, k.Text, 0.5, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
res, plot := ch.Build(spec)
|
||||
if plot == nil || plot.IsDisabled() {
|
||||
return res
|
||||
}
|
||||
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 {
|
||||
return nodes[i].ID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
networkInput(plot, s, ch.st.focus, nodes, unit, hit)
|
||||
return res
|
||||
}
|
||||
|
||||
// networkInput drags, pans, zooms and moves the focused node.
|
||||
func networkInput(plot *ui.Element, s *NetworkGraphState, focus int, nodes []GraphNode, unit func(ui.Rect) float64, hit func(x, y float32) string) {
|
||||
b := plot.Bounds()
|
||||
r := ui.Rect{W: b.W, H: b.H}
|
||||
move := func(id string, dx, dy float32) {
|
||||
p := s.pos[id]
|
||||
s.pos[id] = [2]float64{p[0] + float64(dx)*unit(r), p[1] + float64(dy)*unit(r)}
|
||||
}
|
||||
zoom := func(f float32) { s.zoom = max(0.25, min(8, s.zoom*f)) }
|
||||
if dx, dy, held := plot.Dragged(); held {
|
||||
if !s.dragging {
|
||||
x, y, _ := plot.PointerPosition()
|
||||
s.dragging, s.drag = true, hit(x, y)
|
||||
}
|
||||
if s.drag != "" {
|
||||
move(s.drag, dx, dy)
|
||||
} else {
|
||||
s.panX, s.panY = s.panX+dx, s.panY+dy
|
||||
}
|
||||
} else if s.dragging {
|
||||
s.dragging = false
|
||||
slog.Debug("mujica: network_graph drag", "node", s.drag)
|
||||
}
|
||||
plot.HandleInput(func(ev ui.InputEvent) bool {
|
||||
if ev.Kind != ui.InputScroll || ev.DY == 0 || plot.IsDisabled() {
|
||||
return false
|
||||
}
|
||||
if ev.DY < 0 {
|
||||
zoom(1.2)
|
||||
} else {
|
||||
zoom(1 / 1.2)
|
||||
}
|
||||
return true
|
||||
})
|
||||
switch {
|
||||
case plot.Shortcut(0, ui.KeyEqual), plot.Shortcut(ui.Shift, ui.KeyEqual):
|
||||
zoom(1.2)
|
||||
case plot.Shortcut(0, ui.KeyMinus):
|
||||
zoom(1 / 1.2)
|
||||
case plot.Shortcut(0, ui.Key0):
|
||||
s.ResetView()
|
||||
}
|
||||
if focus < 0 || focus >= len(nodes) {
|
||||
return
|
||||
}
|
||||
for key, d := range map[ui.Key][2]float32{ui.KeyLeft: {-12, 0}, ui.KeyRight: {12, 0}, ui.KeyUp: {0, -12}, ui.KeyDown: {0, 12}} {
|
||||
if plot.Shortcut(ui.Shift, key) {
|
||||
move(nodes[focus].ID, d[0], d[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// networkLayout uses X, Y, or fixed force steps.
|
||||
func networkLayout(nodes []GraphNode, links [][2]int, force bool) map[string][2]float64 {
|
||||
pos := make([][2]float64, len(nodes))
|
||||
for i, n := range nodes {
|
||||
pos[i] = [2]float64{n.X, n.Y}
|
||||
if force {
|
||||
a := 2 * math.Pi * float64(i) / float64(len(nodes))
|
||||
pos[i] = [2]float64{math.Cos(a), math.Sin(a)}
|
||||
}
|
||||
}
|
||||
if force && len(nodes) > 1 {
|
||||
ideal := math.Sqrt(4.0 / float64(len(nodes)))
|
||||
for step := range 300 {
|
||||
heat := 0.1 * (1 - float64(step)/300)
|
||||
move := make([][2]float64, len(nodes))
|
||||
for i := range pos {
|
||||
for j := range pos {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
dx, dy := pos[i][0]-pos[j][0], pos[i][1]-pos[j][1]
|
||||
d := max(math.Hypot(dx, dy), 1e-6)
|
||||
f := ideal * ideal / d
|
||||
move[i][0], move[i][1] = move[i][0]+dx/d*f, move[i][1]+dy/d*f
|
||||
}
|
||||
}
|
||||
for _, l := range links {
|
||||
dx, dy := pos[l[0]][0]-pos[l[1]][0], pos[l[0]][1]-pos[l[1]][1]
|
||||
d := max(math.Hypot(dx, dy), 1e-6)
|
||||
f := d * d / ideal
|
||||
move[l[0]][0], move[l[0]][1] = move[l[0]][0]-dx/d*f, move[l[0]][1]-dy/d*f
|
||||
move[l[1]][0], move[l[1]][1] = move[l[1]][0]+dx/d*f, move[l[1]][1]+dy/d*f
|
||||
}
|
||||
for i, m := range move {
|
||||
d := max(math.Hypot(m[0], m[1]), 1e-9)
|
||||
pos[i][0] += m[0] / d * min(d, heat)
|
||||
pos[i][1] += m[1] / d * min(d, heat)
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make(map[string][2]float64, len(nodes))
|
||||
for i, n := range nodes {
|
||||
out[n.ID] = pos[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
var graphNodes = []GraphNode{{"a", "Abbot", 0, 0}, {"b", "Prior", 10, 0}, {"c", "Cellarer", 10, 10}, {"d", "Novice", 0, 10}}
|
||||
var graphEdges = []GraphEdge{{"a", "b"}, {"a", "c"}, {"c", "d"}}
|
||||
|
||||
func TestNetworkGraphReadoutAndKeys(t *testing.T) {
|
||||
var s NetworkGraphState
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return NetworkGraph(c, &s, graphNodes, graphEdges, NetworkGraphOptions{Frame: ChartFrame{Label: "House"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
tabTo(t, tt, "House")
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if got := chartReadout(tt); got != "Abbot · 2 links: Prior, Cellarer" {
|
||||
t.Fatalf("readout %q", got)
|
||||
}
|
||||
x0, y0, _ := s.Position("a")
|
||||
tt.Key(ui.Shift, ui.KeyRight)
|
||||
if x, y, _ := s.Position("a"); x <= x0 || y != y0 {
|
||||
t.Fatalf("Shift+Right left the node at %v,%v", x, y)
|
||||
}
|
||||
tt.Key(0, ui.KeyEqual)
|
||||
if s.Zoom() < 1.19 {
|
||||
t.Fatalf("zoom %v", s.Zoom())
|
||||
}
|
||||
tt.Key(0, ui.Key0)
|
||||
if s.Zoom() != 1 {
|
||||
t.Fatalf("reset zoom %v", s.Zoom())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkGraphDragNodeAndPan(t *testing.T) {
|
||||
var s NetworkGraphState
|
||||
h := &chartHarness{build: func(c *ui.Context) ChartResult {
|
||||
return NetworkGraph(c, &s, graphNodes, graphEdges, NetworkGraphOptions{Frame: ChartFrame{Label: "House"}})
|
||||
}}
|
||||
tt := ui.NewTester(h.view, 520, 420)
|
||||
b := h.res.Element.Bounds()
|
||||
// The 10×10 layout fits 240-48 DIPs, centered in the plot.
|
||||
cx, cy := b.X+b.W/2, b.Y+120
|
||||
ax, ay := cx-96, cy-96
|
||||
tt.Press(ax, ay)
|
||||
tt.Move(ax+20, ay)
|
||||
tt.Move(ax+48, ay)
|
||||
tt.Release(ax+48, ay)
|
||||
if x, y, _ := s.Position("a"); math.Abs(x-2.5) > 0.2 || math.Abs(y) > 0.01 {
|
||||
t.Fatalf("dragged node to %v,%v", x, y)
|
||||
}
|
||||
tt.Press(cx, cy) // the empty middle pans
|
||||
tt.Move(cx+30, cy+10)
|
||||
tt.Release(cx+30, cy+10)
|
||||
if x, _, _ := s.Position("b"); x != 10 {
|
||||
t.Fatalf("panning moved a node: %v", x)
|
||||
}
|
||||
if got := s.panX; got != 30 {
|
||||
t.Fatalf("pan %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkGraphForceLayout(t *testing.T) {
|
||||
pos := networkLayout(graphNodes, [][2]int{{0, 1}, {0, 2}, {2, 3}}, true)
|
||||
again := networkLayout(graphNodes, [][2]int{{0, 1}, {0, 2}, {2, 3}}, true)
|
||||
for id, p := range pos {
|
||||
if again[id] != p || math.IsNaN(p[0]) {
|
||||
t.Fatalf("layout not deterministic for %s", id)
|
||||
}
|
||||
}
|
||||
d := func(a, b string) float64 { return math.Hypot(pos[a][0]-pos[b][0], pos[a][1]-pos[b][1]) }
|
||||
if d("c", "d") >= d("b", "d") {
|
||||
t.Fatalf("linked nodes not closer: %v vs %v", d("c", "d"), d("b", "d"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkGraphBad(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("no panic for an unknown edge end")
|
||||
}
|
||||
}()
|
||||
var s NetworkGraphState
|
||||
ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
NetworkGraph(c, &s, graphNodes, []GraphEdge{{"a", "z"}}, NetworkGraphOptions{Frame: ChartFrame{Label: "x"}})
|
||||
}, 300, 300)
|
||||
}
|
||||
|
||||
func TestNetworkGraphSnapshot(t *testing.T) {
|
||||
snapshot(t, "network_graph", 520, 320, func(c *ui.Context) {
|
||||
var s NetworkGraphState
|
||||
ui.Column(c).Padding(16).Children(func() {
|
||||
NetworkGraph(c, &s, graphNodes, append(graphEdges, GraphEdge{"b", "d"}, GraphEdge{"b", "c"}), NetworkGraphOptions{Frame: ChartFrame{Label: "House"}, Force: true})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
func TestOHLCChartSharesDataAndDiffersInLook(t *testing.T) {
|
||||
candles := sampleCandles(60)
|
||||
view := MarketView{Count: 40}
|
||||
ohlc := true
|
||||
var res ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
core.Use(c, core.Settings{})
|
||||
if ohlc {
|
||||
res = OHLCChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Bars"}})
|
||||
} else {
|
||||
res = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Bars"}})
|
||||
}
|
||||
}, 600, 320)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
if res.Point != 59 || mktState0(res).drawn != 40 {
|
||||
t.Fatalf("point %d drawn %d", res.Point, mktState0(res).drawn)
|
||||
}
|
||||
a := encode(t, tt.Image())
|
||||
ohlc = false
|
||||
tt.Frame()
|
||||
if bytes.Equal(a, encode(t, tt.Image())) {
|
||||
t.Fatal("OHLC bars look the same as candles")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOHLCChartSnapshot(t *testing.T) {
|
||||
candles := sampleCandles(60)
|
||||
view := MarketView{Count: 50}
|
||||
snapshot(t, "ohlc_chart", 640, 300, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
OHLCChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Bars", Height: 240}})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/internal"
|
||||
"github.com/ZacharyZhang-NY/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 := core.Tokens(c)
|
||||
size := core.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, core.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 := core.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(core.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 core.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 core.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: core.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,134 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"github.com/ZacharyZhang-NY/MujicaUI/core"
|
||||
)
|
||||
|
||||
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) {
|
||||
core.Use(c, core.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))
|
||||
}
|
||||
}
|
||||
Loaded 100 of 1822 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user