Add chart components 216-245; step-aware ticks, font-aware labels
Bars, lines, areas, pies, donuts, radar, histogram, scatter, bubbles, heatmaps, gauge, bullet, funnel, waterfall, treemap, candles, box plots, steps, rings, legend, axis, crosshair, polar area, pyramid, spectrum bars and win-loss strips, each drawn dot by dot. Ticks follow 1-2-5 steps and print as precisely as their step needs; a label row uses micro letters or the pixel font as a whole. Descriptions carry the data in numbers. Bad data panics.
This commit is contained in:
43 files changed
+3336
-1
No files matched your search
@@ -0,0 +1,41 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type AreaChartOptions struct {
|
||||
// Width and Height are dots; zeros mean 160 by 80.
|
||||
Width, Height int
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// AreaChart draws a line with the area below it dithered.
|
||||
func AreaChart(c *ui.Context, text string, labels []string, values []float64, o AreaChartOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
w, h := size("AreaChart", o.Width, o.Height, 160, 80)
|
||||
if len(values) != len(labels) || len(values) < 2 {
|
||||
panic(fmt.Sprintf("chart: AreaChart has %d labels for %d values", len(labels), len(values)))
|
||||
}
|
||||
s := []Series{{Name: text, Values: values, Slot: pick(o.Slot, 0)}}
|
||||
lo, hi := span(values)
|
||||
p := fit(min(0, lo), max(0, hi), w, h, labels)
|
||||
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(read(labels, values)).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(pt, r)
|
||||
p.grid(g, labels)
|
||||
// The area spans from the line to zero, either way.
|
||||
n, base := len(values), p.at(0)
|
||||
for i := 1; i < n; i++ {
|
||||
x0, x1 := p.slot(i-1, n), p.slot(i, n)
|
||||
for x := x0; x < x1; x++ {
|
||||
y := p.at(values[i-1] + (values[i]-values[i-1])*float64(x-x0)/float64(x1-x0))
|
||||
g.Veil(x, min(y, base), 1, abs(y-base), s[0].Slot, 6)
|
||||
}
|
||||
}
|
||||
lines(g, p, n, s)
|
||||
})
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// plot is a chart's value range and box, in dots.
|
||||
type plot struct {
|
||||
x, y, w, h int
|
||||
from, step float64
|
||||
n int
|
||||
lowest, upper float64
|
||||
// pad keeps marks this many dots inside the box.
|
||||
pad int
|
||||
}
|
||||
|
||||
// labelH is the room x labels need under a plot.
|
||||
func labelH(labels []string) int { return inkH(small(labels...)) + 3 }
|
||||
|
||||
// fit lays lo–hi in a w×h canvas, leaving label room.
|
||||
func fit(lo, hi float64, w, h int, labels []string) plot {
|
||||
from, step, n := ticks(lo, hi, 5)
|
||||
gutter := 0
|
||||
for i := range n {
|
||||
gutter = max(gutter, tinyW(tick(from+float64(i)*step, step)))
|
||||
}
|
||||
p := plot{x: gutter + 4, y: 3, from: from, step: step, n: n}
|
||||
p.w, p.h = w-p.x-1, h-p.y-labelH(labels)-2
|
||||
if p.w < 4 || p.h < 4 {
|
||||
panic(fmt.Sprintf("chart: a %d×%d chart leaves no room for its plot", w, h))
|
||||
}
|
||||
p.lowest, p.upper = from, from+float64(n-1)*step
|
||||
return p
|
||||
}
|
||||
|
||||
// at is the y of v.
|
||||
func (p plot) at(v float64) int {
|
||||
run := float64(p.h - 1 - 2*p.pad)
|
||||
return p.y + p.h - 1 - p.pad - int(math.Round((v-p.lowest)/(p.upper-p.lowest)*run))
|
||||
}
|
||||
|
||||
// slot is the middle x of category i of n.
|
||||
func (p plot) slot(i, n int) int { return p.x + (2*i+1)*p.w/(2*n) }
|
||||
|
||||
// grid draws value ticks, the frame and x labels.
|
||||
func (p plot) grid(g pixel.Canvas, labels []string) {
|
||||
for i := range p.n {
|
||||
v := p.from + float64(i)*p.step
|
||||
y := p.at(v)
|
||||
g.Checker(p.x, y, p.w, 1, pixel.Shade)
|
||||
s := tick(v, p.step)
|
||||
tiny(g, p.x-3-tinyW(s), y-2, s, pixel.Faint)
|
||||
}
|
||||
g.VLine(p.x-1, p.y, p.h, pixel.Edge)
|
||||
g.HLine(p.x-1, p.y+p.h, p.w+1, pixel.Edge)
|
||||
sm := small(labels...)
|
||||
for i, l := range labels {
|
||||
x := p.slot(i, len(labels))
|
||||
g.Dot(x, p.y+p.h+1, pixel.Edge)
|
||||
mark(g, max(0, min(g.W-markW(l, sm), x-markW(l, sm)/2)), p.y+p.h+3, l, sm, pixel.Faint)
|
||||
}
|
||||
}
|
||||
|
||||
type AxisOptions struct {
|
||||
// Min and Max bound ticks, zeros 0–100; Labels replace them.
|
||||
Min, Max float64
|
||||
Labels []string
|
||||
// Length is in dots; zero means 120.
|
||||
Length int
|
||||
Vertical bool
|
||||
}
|
||||
|
||||
// Axis draws ticks and micro labels, for any chart.
|
||||
func Axis(c *ui.Context, o AxisOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
n := pixel.Or("Axis Length", o.Length, 120)
|
||||
marks := o.Labels
|
||||
if marks == nil {
|
||||
lo, hi := o.Min, o.Max
|
||||
if lo == 0 && hi == 0 {
|
||||
hi = 100
|
||||
}
|
||||
if span([]float64{lo, hi}); !(lo < hi) {
|
||||
panic(fmt.Sprintf("chart: Axis needs Min below Max, or Labels; got %v–%v", lo, hi))
|
||||
}
|
||||
from, step, count := ticks(lo, hi, 5)
|
||||
for i := range count {
|
||||
marks = append(marks, tick(from+float64(i)*step, step))
|
||||
}
|
||||
}
|
||||
if len(marks) < 2 {
|
||||
panic("chart: Axis needs two marks or more")
|
||||
}
|
||||
sm, thick := small(marks...), 0
|
||||
for _, m := range marks {
|
||||
thick = max(thick, markW(m, sm))
|
||||
}
|
||||
w, h := n, labelH(marks)+3
|
||||
if o.Vertical {
|
||||
w, h = thick+5, n
|
||||
}
|
||||
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(marks[0] + "–" + marks[len(marks)-1]).Description(strings.Join(marks, ", ")).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for i, m := range marks {
|
||||
at := 1 + i*(n-3)/(len(marks)-1)
|
||||
if o.Vertical {
|
||||
y := n - 1 - at
|
||||
g.HLine(w-3, y, 2, pixel.Edge)
|
||||
mark(g, w-4-markW(m, sm), max(0, min(n-inkH(sm), y-inkH(sm)/2)), m, sm, pixel.Faint)
|
||||
continue
|
||||
}
|
||||
g.VLine(at, 1, 2, pixel.Edge)
|
||||
mark(g, max(0, min(n-markW(m, sm), at-markW(m, sm)/2)), 4, m, sm, pixel.Faint)
|
||||
}
|
||||
if o.Vertical {
|
||||
g.VLine(w-1, 0, n, pixel.Edge)
|
||||
} else {
|
||||
g.HLine(0, 0, n, pixel.Edge)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type BarChartOptions struct {
|
||||
// Width and Height are dots; zeros mean 160 by 80.
|
||||
Width, Height int
|
||||
// Slot fills the bars; zero means Accent.
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// BarChart stands bars on micro-font axes; hover reads a value.
|
||||
func BarChart(c *ui.Context, text string, labels []string, values []float64, o BarChartOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if len(labels) != len(values) || len(values) == 0 {
|
||||
panic(fmt.Sprintf("chart: BarChart has %d labels for %d values", len(labels), len(values)))
|
||||
}
|
||||
w, h := size("BarChart", o.Width, o.Height, 160, 80)
|
||||
lo, hi := span(values)
|
||||
p := fit(min(0, lo), max(0, hi), w, h, labels)
|
||||
e := ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(read(labels, values))
|
||||
hover := -1
|
||||
if x, y, over := e.PointerPosition(); over {
|
||||
if dx := int(x/k.Dot()) - p.x; dx >= 0 && dx < p.w && int(y/k.Dot()) < p.y+p.h {
|
||||
hover = dx * len(values) / p.w
|
||||
}
|
||||
}
|
||||
ink := pick(o.Slot, 0)
|
||||
return e.Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(pt, r)
|
||||
p.grid(g, labels)
|
||||
share := p.w / len(values)
|
||||
bw, zero := max(1, share*2/3), p.at(0)
|
||||
for i, v := range values {
|
||||
x, top := p.slot(i, len(values))-bw/2, p.at(v)
|
||||
g.Rect(x, min(top, zero), bw, max(1, abs(zero-top)), ink)
|
||||
}
|
||||
if hover >= 0 {
|
||||
readout(g, p.slot(hover, len(values)), p.at(values[hover]), num(values[hover]), w)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// readout boxes s above x, y, kept inside w dots.
|
||||
func readout(g pixel.Canvas, x, y int, s string, w int) {
|
||||
bw := tinyW(s) + 4
|
||||
bx, by := max(0, min(w-bw, x-bw/2)), max(0, y-11)
|
||||
g.Frame(bx, by, bw, 9, pixel.Flat, pixel.Lit)
|
||||
tiny(g, bx+2, by+2, s, pixel.Ink)
|
||||
}
|
||||
|
||||
// read lists values for assistive technology: "A 3, B 5".
|
||||
func read(labels []string, values []float64) string {
|
||||
parts := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
parts[i] = labels[i] + " " + num(v)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package chart_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chart"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
var days = []string{"MON", "TUE", "WED", "THU"}
|
||||
|
||||
func TestBarChartHeightsAndHover(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.BarChart(c, "Gold", days, []float64{10, 40, 20, 0}, chart.BarChartOptions{})
|
||||
})
|
||||
img := tt.Image()
|
||||
// Bars stand mid-category, past the 13-dot label gutter.
|
||||
var heights []int
|
||||
for i := range 4 {
|
||||
x := 13 + (2*i+1)*146/8
|
||||
_, n := column(t, img, x, 80, pixel.Accent)
|
||||
heights = append(heights, n)
|
||||
}
|
||||
// Topped at tick 40, that bar spans the 66-dot plot.
|
||||
if heights[1] != 66 || heights[2] != 33 || heights[0] < 16 || heights[0] > 17 || heights[3] != 1 {
|
||||
t.Fatalf("bar heights %v for 10, 40, 20, 0", heights)
|
||||
}
|
||||
keeps(t, img)
|
||||
tt.Move(2*(13+3*146/8), 2*40)
|
||||
if n := count(t, tt.Image(), 0, 0, 160, 80, pixel.Lit); n == 0 {
|
||||
t.Fatal("hovering a bar showed no readout")
|
||||
}
|
||||
// The value gutter left of the plot reads nothing.
|
||||
tt.Move(2*8, 2*40)
|
||||
if n := count(t, tt.Image(), 0, 0, 160, 80, pixel.Lit); n != 0 {
|
||||
t.Fatal("hovering the gutter showed a readout")
|
||||
}
|
||||
panics(t, "a short label list", func() {
|
||||
view(100, 100, newClock(), func(c *ui.Context) { chart.BarChart(c, "x", days[:1], []float64{1, 2}, chart.BarChartOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestStackedBarStacksSeriesInOrder(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.StackedBar(c, "Loot", days[:2], []chart.Series{
|
||||
{Name: "Gold", Values: []float64{10, 5}, Slot: pixel.Warn},
|
||||
{Name: "Gems", Values: []float64{10, 15}, Slot: pixel.Info},
|
||||
}, chart.StackedBarOptions{})
|
||||
})
|
||||
img := tt.Image()
|
||||
x := 9 + 150/4
|
||||
warnTop, warn := column(t, img, x, 80, pixel.Warn)
|
||||
infoTop, info := column(t, img, x, 80, pixel.Info)
|
||||
if warn == 0 || info == 0 || infoTop >= warnTop || warn < info-3 || warn > info+3 {
|
||||
t.Fatalf("first stack: gold %d from %d, gems %d from %d", warn, warnTop, info, infoTop)
|
||||
}
|
||||
keeps(t, img)
|
||||
panics(t, "a negative stack", func() {
|
||||
view(100, 100, newClock(), func(c *ui.Context) {
|
||||
chart.StackedBar(c, "x", days[:1], []chart.Series{{Name: "a", Values: []float64{-1}}}, chart.StackedBarOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestHorizontalBarLongestReachesTheEdge(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.HorizontalBar(c, "Kills", []string{"Aria", "Brom"}, []float64{50, 100}, chart.HorizontalBarOptions{})
|
||||
})
|
||||
img := tt.Image()
|
||||
a, b := count(t, img, 0, 0, 160, 16, pixel.Accent), count(t, img, 0, 16, 160, 16, pixel.Accent)
|
||||
if a == 0 || b < 2*a-10 || b > 2*a+10 {
|
||||
t.Fatalf("bar dots %d and %d for 50 and 100", a, b)
|
||||
}
|
||||
keeps(t, img)
|
||||
}
|
||||
|
||||
func TestHistogramBinsValues(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.Histogram(c, "Damage", []float64{1, 1, 1, 2, 2, 9}, chart.HistogramOptions{Bins: 2})
|
||||
})
|
||||
img := tt.Image()
|
||||
_, low := column(t, img, 40, 80, pixel.Accent)
|
||||
_, high := column(t, img, 120, 80, pixel.Accent)
|
||||
if low == 0 || high == 0 || low < 4*high-6 {
|
||||
t.Fatalf("bins of 5 and 1 stand %d and %d dots", low, high)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaterfallInksStepsByDirection(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.Waterfall(c, "Gold", []string{"QUEST", "SHOP"}, []float64{30, -10}, chart.WaterfallOptions{})
|
||||
})
|
||||
img := tt.Image()
|
||||
if count(t, img, 0, 0, 80, 80, pixel.Good) == 0 || count(t, img, 80, 0, 80, 80, pixel.Bad) == 0 {
|
||||
t.Fatal("the up step is not Good, or the down step not Bad")
|
||||
}
|
||||
keeps(t, img)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type BoxPlotOptions struct {
|
||||
// Width and Height are dots; zeros mean 160 by 80.
|
||||
Width, Height int
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// quartile is the q-th quartile of sorted values, interpolated.
|
||||
func quartile(sorted []float64, q float64) float64 {
|
||||
at := q * float64(len(sorted)-1)
|
||||
i := int(at)
|
||||
if i+1 >= len(sorted) {
|
||||
return sorted[i]
|
||||
}
|
||||
return sorted[i] + (sorted[i+1]-sorted[i])*(at-float64(i))
|
||||
}
|
||||
|
||||
// BoxPlot boxes each group's quartiles; whiskers reach its ends.
|
||||
func BoxPlot(c *ui.Context, text string, groups []Series, o BoxPlotOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if len(groups) == 0 {
|
||||
panic("chart: BoxPlot has no groups")
|
||||
}
|
||||
w, h := size("BoxPlot", o.Width, o.Height, 160, 80)
|
||||
labels, rows := make([]string, len(groups)), make([][]float64, len(groups))
|
||||
for i, s := range groups {
|
||||
if len(s.Values) == 0 {
|
||||
panic(fmt.Sprintf("chart: BoxPlot group %q is empty", s.Name))
|
||||
}
|
||||
labels[i], rows[i] = s.Name, slices.Sorted(slices.Values(s.Values))
|
||||
}
|
||||
lo, hi := span(rows...)
|
||||
p := fit(lo, hi, w, h, labels)
|
||||
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(fives(labels, rows)).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(pt, r)
|
||||
p.grid(g, labels)
|
||||
bw := max(3, p.w/len(groups)/2)
|
||||
for i, row := range rows {
|
||||
x := p.slot(i, len(groups))
|
||||
q1, mid, q3 := p.at(quartile(row, 0.25)), p.at(quartile(row, 0.5)), p.at(quartile(row, 0.75))
|
||||
top, bottom := p.at(row[len(row)-1]), p.at(row[0])
|
||||
g.VLine(x, top, q3-top, pixel.Edge)
|
||||
g.VLine(x, q1, bottom-q1+1, pixel.Edge)
|
||||
g.HLine(x-bw/4, top, bw/2+1, pixel.Edge)
|
||||
g.HLine(x-bw/4, bottom, bw/2+1, pixel.Edge)
|
||||
g.Frame(x-bw/2, q3, bw, max(3, q1-q3+1), pixel.Flat, pick(groups[i].Slot, i))
|
||||
g.HLine(x-bw/2, mid, bw, pixel.Ink)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// fives lists each sorted row's five-number summary.
|
||||
func fives(labels []string, rows [][]float64) string {
|
||||
parts := make([]string, len(rows))
|
||||
for i, row := range rows {
|
||||
parts[i] = labels[i] + " " + nums([]float64{row[0], quartile(row, 0.25), quartile(row, 0.5), quartile(row, 0.75), row[len(row)-1]})
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type BubbleChartOptions struct {
|
||||
// Width and Height are dots; zeros mean 160 by 80.
|
||||
Width, Height int
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// BubbleChart draws points as discs, area by Size.
|
||||
func BubbleChart(c *ui.Context, text string, points []Point, o BubbleChartOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
w, h := size("BubbleChart", o.Width, o.Height, 160, 80)
|
||||
f := fitPoints(points, w, h, 8)
|
||||
most := 0.0
|
||||
for _, p := range points {
|
||||
if !(p.Size >= 0 && p.Size <= math.MaxFloat64) {
|
||||
panic(fmt.Sprintf("chart: bubble size %v is not a size", p.Size))
|
||||
}
|
||||
most = max(most, p.Size)
|
||||
}
|
||||
ink := pick(o.Slot, 0)
|
||||
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(where(points, true)).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(pt, r)
|
||||
f.grid(g)
|
||||
for _, p := range points {
|
||||
rad := 1 + int(math.Sqrt(part(p.Size, most))*6)
|
||||
g.Disc(f.ax(p.X), f.at(p.Y), rad, ink)
|
||||
g.Circle(f.ax(p.X), f.at(p.Y), rad, pixel.Edge)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type BulletChartOptions struct {
|
||||
// Width is in dots; zero means 160.
|
||||
Width int
|
||||
}
|
||||
|
||||
// BulletChart lays a bar on rising ranges and a target.
|
||||
func BulletChart(c *ui.Context, text string, value, target float64, ranges []float64, o BulletChartOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if len(ranges) == 0 {
|
||||
panic("chart: BulletChart needs ranges")
|
||||
}
|
||||
span(ranges, []float64{value, target})
|
||||
for i, r := range ranges {
|
||||
if r <= 0 || i > 0 && r <= ranges[i-1] {
|
||||
panic(fmt.Sprintf("chart: BulletChart ranges %v must rise above zero", ranges))
|
||||
}
|
||||
}
|
||||
end := ranges[len(ranges)-1]
|
||||
if value < 0 || value > end || target < 0 || target > end {
|
||||
panic(fmt.Sprintf("chart: BulletChart value %v or target %v outside 0–%v", value, target, end))
|
||||
}
|
||||
w := pixel.Or("BulletChart Width", o.Width, 160)
|
||||
x := func(v float64) int { return int(v / end * float64(w-1)) }
|
||||
return ui.Box(c).Size(k.D(w), k.D(14)).Role(ui.RoleMeter).Label(text).Description(num(value) + " / " + num(target)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
from := 0
|
||||
for i, band := range ranges {
|
||||
to := x(band) + 1
|
||||
g.Dither(from, 0, to-from, 14, pixel.Well, pixel.Shade, 12-12*i/len(ranges))
|
||||
from = to
|
||||
}
|
||||
g.Rect(0, 5, max(1, x(value)), 4, pixel.Ink)
|
||||
g.Rect(min(x(target), w-2), 2, 2, 10, pixel.Accent)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type CalendarHeatmapOptions struct {
|
||||
// Slot is the busiest days' color; zero means Good.
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// day is a 4×4 square plus its gap, in dots.
|
||||
const day = 5
|
||||
|
||||
// CalendarHeatmap rates a year's UTC days 0–4, week by week.
|
||||
func CalendarHeatmap(c *ui.Context, text string, year int, level func(d time.Time) int, o CalendarHeatmapOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
ink := o.Slot
|
||||
if ink == 0 {
|
||||
ink = pixel.Good
|
||||
}
|
||||
first := time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
skip := int(first.Weekday())
|
||||
levels := make([]int, first.AddDate(1, 0, 0).Sub(first)/(24*time.Hour))
|
||||
sums := make([]float64, 12)
|
||||
for i := range levels {
|
||||
d := first.AddDate(0, 0, i)
|
||||
l := level(d)
|
||||
if l < 0 || l > 4 {
|
||||
panic(fmt.Sprintf("chart: CalendarHeatmap level %d outside 0–4", l))
|
||||
}
|
||||
levels[i], sums[d.Month()-1] = l, sums[d.Month()-1]+float64(l)
|
||||
}
|
||||
months := strings.Fields(k.Msg("months"))
|
||||
sm := small(months...)
|
||||
head := labelH(months)
|
||||
weeks := (skip + len(levels) + 6) / 7
|
||||
// Assistive technology hears each month's summed levels.
|
||||
return ui.Box(c).Size(k.D(weeks*day), k.D(head+7*day)).Role(ui.RoleImage).Label(text + " " + strconv.Itoa(year)).Description(read(months, sums)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
// Month names top their first week, room permitting.
|
||||
next := 0
|
||||
for m, name := range months {
|
||||
x := (skip + time.Date(year, time.Month(m+1), 1, 0, 0, 0, 0, time.UTC).YearDay() - 1) / 7 * day
|
||||
if x >= next {
|
||||
mark(g, x, 1, name, sm, pixel.Faint)
|
||||
next = x + markW(name, sm) + 3
|
||||
}
|
||||
}
|
||||
for i, l := range levels {
|
||||
n := skip + i
|
||||
g.Dither(n/7*day, head+n%7*day, day-1, day-1, pixel.Shade, ink, l*4)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// Candle is a period's open, high, low and close.
|
||||
type Candle struct {
|
||||
Label string
|
||||
Open, High, Low, Close float64
|
||||
}
|
||||
|
||||
type CandlestickOptions struct {
|
||||
// Width and Height are dots; zeros mean 160 by 80.
|
||||
Width, Height int
|
||||
}
|
||||
|
||||
// Candlestick draws wicks and bodies: up Good, down Bad.
|
||||
func Candlestick(c *ui.Context, text string, candles []Candle, o CandlestickOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if len(candles) == 0 {
|
||||
panic("chart: Candlestick has no candles")
|
||||
}
|
||||
w, h := size("Candlestick", o.Width, o.Height, 160, 80)
|
||||
labels, lows, highs := make([]string, len(candles)), make([]float64, len(candles)), make([]float64, len(candles))
|
||||
for i, cd := range candles {
|
||||
span([]float64{cd.Open, cd.High, cd.Low, cd.Close})
|
||||
if cd.Low > min(cd.Open, cd.Close) || cd.High < max(cd.Open, cd.Close) {
|
||||
panic(fmt.Sprintf("chart: candle %q has open %v and close %v outside %v–%v", cd.Label, cd.Open, cd.Close, cd.Low, cd.High))
|
||||
}
|
||||
labels[i], lows[i], highs[i] = cd.Label, cd.Low, cd.High
|
||||
}
|
||||
lo, _ := span(lows)
|
||||
_, hi := span(highs)
|
||||
p := fit(lo, hi, w, h, labels)
|
||||
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(ohlc(candles)).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(pt, r)
|
||||
p.grid(g, labels)
|
||||
bw := max(1, p.w/len(candles)/2)
|
||||
for i, cd := range candles {
|
||||
x, ink := p.slot(i, len(candles)), pixel.Good
|
||||
if cd.Close < cd.Open {
|
||||
ink = pixel.Bad
|
||||
}
|
||||
g.VLine(x, p.at(cd.High), p.at(cd.Low)-p.at(cd.High)+1, pixel.Edge)
|
||||
top, bottom := p.at(max(cd.Open, cd.Close)), p.at(min(cd.Open, cd.Close))
|
||||
g.Rect(x-bw/2, top, max(1, bw), max(1, bottom-top), ink)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ohlc lists each candle's open, high, low and close.
|
||||
func ohlc(candles []Candle) string {
|
||||
parts := make([]string, len(candles))
|
||||
for i, cd := range candles {
|
||||
parts[i] = cd.Label + " " + nums([]float64{cd.Open, cd.High, cd.Low, cd.Close})
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
// Package chart plots numbers in dots: bars, lines, pies.
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// Series is a named row of values.
|
||||
type Series struct {
|
||||
Name string
|
||||
Values []float64
|
||||
// Slot colors it; zero takes the next series slot.
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// Slice is a named share of a whole.
|
||||
type Slice struct {
|
||||
Name string
|
||||
Value float64
|
||||
// Slot colors it; zero takes the next series slot.
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// slots color series and slices in turn.
|
||||
var slots = []pixel.Slot{pixel.Accent, pixel.Info, pixel.Good, pixel.Warn, pixel.Magic, pixel.Bad}
|
||||
|
||||
// pick is s, or the i-th series slot for zero.
|
||||
func pick(s pixel.Slot, i int) pixel.Slot {
|
||||
if s != 0 {
|
||||
return s
|
||||
}
|
||||
return slots[i%len(slots)]
|
||||
}
|
||||
|
||||
// size is w×h in dots, or the defaults for zeros.
|
||||
func size(name string, w, h, dw, dh int) (int, int) {
|
||||
return pixel.Or(name+" Width", w, dw), pixel.Or(name+" Height", h, dh)
|
||||
}
|
||||
|
||||
// num prints a value exactly, float noise rounded off.
|
||||
func num(v float64) string {
|
||||
r, err := strconv.ParseFloat(strconv.FormatFloat(v, 'g', 12, 64), 64)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("chart: %v does not print: %v", v, err))
|
||||
}
|
||||
return strconv.FormatFloat(r, 'f', -1, 64)
|
||||
}
|
||||
|
||||
// tick prints a tick as precisely as its step needs.
|
||||
func tick(v, step float64) string {
|
||||
if v == 0 {
|
||||
// Minus zero prints as zero.
|
||||
v = 0
|
||||
}
|
||||
unit, suffix := 1.0, ""
|
||||
switch a := math.Abs(v); {
|
||||
case a >= 1e6 && step >= 1e5:
|
||||
unit, suffix = 1e6, "M"
|
||||
case a >= 1e4 && step >= 1e2:
|
||||
unit, suffix = 1e3, "K"
|
||||
}
|
||||
d := max(0, -int(math.Floor(math.Log10(step/unit)+1e-9)))
|
||||
return strconv.FormatFloat(v/unit, 'f', d, 64) + suffix
|
||||
}
|
||||
|
||||
// part is v's share of whole, zero of nothing.
|
||||
func part(v, whole float64) float64 {
|
||||
if whole == 0 {
|
||||
return 0
|
||||
}
|
||||
return v / whole
|
||||
}
|
||||
|
||||
// small is whether every label fits the micro font.
|
||||
func small(labels ...string) bool {
|
||||
for _, l := range labels {
|
||||
if !pixel.MicroOK(l) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// inkH is a label's ink height (font rows 3–14).
|
||||
func inkH(small bool) int {
|
||||
if small {
|
||||
return pixel.MicroHeight
|
||||
}
|
||||
return 12
|
||||
}
|
||||
|
||||
// mark writes s with its ink's top at y.
|
||||
func mark(g pixel.Canvas, x, y int, s string, small bool, ink pixel.Slot) {
|
||||
if small {
|
||||
g.Micro(x, y, s, ink, 1)
|
||||
return
|
||||
}
|
||||
g.Text(x, y-3, s, ink, 1)
|
||||
}
|
||||
|
||||
// markW is s's width as mark writes it.
|
||||
func markW(s string, small bool) int {
|
||||
if small {
|
||||
return pixel.MicroWidth(s)
|
||||
}
|
||||
return pixel.Measure(s)
|
||||
}
|
||||
|
||||
// tiny centers s on a micro line's middle, y+2.
|
||||
func tiny(g pixel.Canvas, x, y int, s string, ink pixel.Slot) {
|
||||
sm := small(s)
|
||||
mark(g, x, y+2-inkH(sm)/2, s, sm, ink)
|
||||
}
|
||||
|
||||
// tinyW is s's width as tiny writes it.
|
||||
func tinyW(s string) int { return markW(s, small(s)) }
|
||||
|
||||
// ticks steps nicely from below lo to above hi.
|
||||
func ticks(lo, hi float64, want int) (from, step float64, n int) {
|
||||
// Flat data widens a tenth each way; zeros from zero.
|
||||
if hi <= lo {
|
||||
if lo == 0 {
|
||||
hi = 1
|
||||
} else {
|
||||
m := math.Abs(lo) / 10
|
||||
lo, hi = lo-m, hi+m
|
||||
}
|
||||
}
|
||||
step = nice((hi - lo) / float64(want))
|
||||
from = math.Floor(lo/step) * step
|
||||
n = int(math.Ceil((hi-from)/step-1e-9)) + 1
|
||||
return from, step, n
|
||||
}
|
||||
|
||||
// nice rounds raw up to a 1-2-5 step.
|
||||
func nice(raw float64) float64 {
|
||||
mag := math.Pow(10, math.Floor(math.Log10(raw)))
|
||||
for _, m := range []float64{1, 2, 5} {
|
||||
if m*mag >= raw {
|
||||
return m * mag
|
||||
}
|
||||
}
|
||||
return 10 * mag
|
||||
}
|
||||
|
||||
// span is the least and most of every value.
|
||||
func span(rows ...[]float64) (lo, hi float64) {
|
||||
lo, hi = math.Inf(1), math.Inf(-1)
|
||||
for _, r := range rows {
|
||||
for _, v := range r {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
panic(fmt.Sprintf("chart: value %v cannot be plotted", v))
|
||||
}
|
||||
lo, hi = min(lo, v), max(hi, v)
|
||||
}
|
||||
}
|
||||
if lo > hi {
|
||||
return 0, 1
|
||||
}
|
||||
return lo, hi
|
||||
}
|
||||
|
||||
// nums lists values: "3, 9, 4".
|
||||
func nums(values []float64) string {
|
||||
parts := make([]string, len(values))
|
||||
for i, v := range values {
|
||||
parts[i] = num(v)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package chart_test
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type clock struct{ t time.Time }
|
||||
|
||||
func (c *clock) now() time.Time { return c.t }
|
||||
|
||||
func newClock() *clock { return &clock{time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC)} }
|
||||
|
||||
// view builds charts on a fake clock.
|
||||
func view(w, h int, clk *clock, build func(c *ui.Context)) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: clk.now})
|
||||
build(c)
|
||||
}, w, h)
|
||||
}
|
||||
|
||||
func find(t *testing.T, tt *ui.Tester, label string) ui.Rect {
|
||||
t.Helper()
|
||||
r, ok := tt.Find(label)
|
||||
if !ok {
|
||||
t.Fatalf("no %q in %q", label, tt.Texts())
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// at is the slot of dot x, y.
|
||||
func at(t *testing.T, img *image.RGBA, x, y int) pixel.Slot {
|
||||
t.Helper()
|
||||
c := img.RGBAAt(2*x, 2*y)
|
||||
for s, pc := range pixel.SpringDay.Colors {
|
||||
if pc.R == c.R && pc.G == c.G && pc.B == c.B {
|
||||
return pixel.Slot(s)
|
||||
}
|
||||
}
|
||||
t.Fatalf("dot %d,%d is %v, off the palette", x, y, c)
|
||||
return 0
|
||||
}
|
||||
|
||||
// count tallies dots of s in a dot box.
|
||||
func count(t *testing.T, img *image.RGBA, x, y, w, h int, s pixel.Slot) int {
|
||||
t.Helper()
|
||||
n := 0
|
||||
for j := y; j < y+h; j++ {
|
||||
for i := x; i < x+w; i++ {
|
||||
if at(t, img, i, j) == s {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// keeps fails on off-palette colors or split dots.
|
||||
func keeps(t *testing.T, img *image.RGBA) {
|
||||
t.Helper()
|
||||
b := img.Bounds()
|
||||
for y := 0; y+1 < b.Dy(); y += 2 {
|
||||
for x := 0; x+1 < b.Dx(); x += 2 {
|
||||
at(t, img, x/2, y/2)
|
||||
c := img.RGBAAt(x, y)
|
||||
if img.RGBAAt(x+1, y) != c || img.RGBAAt(x, y+1) != c || img.RGBAAt(x+1, y+1) != c {
|
||||
t.Fatalf("dot at %d,%d is split", x/2, y/2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// panics fails unless fn panics.
|
||||
func panics(t *testing.T, what string, fn func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatalf("%s did not panic", what)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
// column is the top row and count of s.
|
||||
func column(t *testing.T, img *image.RGBA, x, h int, s pixel.Slot) (top, n int) {
|
||||
t.Helper()
|
||||
top = -1
|
||||
for y := range h {
|
||||
if at(t, img, x, y) == s {
|
||||
if top < 0 {
|
||||
top = y
|
||||
}
|
||||
n++
|
||||
}
|
||||
}
|
||||
return top, n
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type CrosshairOptions struct {
|
||||
// Width and Height are dots; zeros mean 160 by 80.
|
||||
Width, Height int
|
||||
}
|
||||
|
||||
// Crosshair is a line chart; hover crosses the nearest point.
|
||||
func Crosshair(c *ui.Context, text string, labels []string, series []Series, o CrosshairOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
w, h := size("Crosshair", o.Width, o.Height, 160, 80)
|
||||
p := fitSeries(labels, series, w, h)
|
||||
e := ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(readSeries(labels, series))
|
||||
at, py := -1, 0
|
||||
if x, y, over := e.PointerPosition(); over && len(labels) > 0 {
|
||||
at = max(0, min(len(labels)-1, (int(x/k.Dot())-p.x)*len(labels)/max(1, p.w)))
|
||||
py = max(p.y, min(p.y+p.h-1, int(y/k.Dot())))
|
||||
}
|
||||
return e.Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(pt, r)
|
||||
p.grid(g, labels)
|
||||
lines(g, p, len(labels), series)
|
||||
if at < 0 {
|
||||
return
|
||||
}
|
||||
x := p.slot(at, len(labels))
|
||||
g.Checker(x, p.y, 1, p.h, pixel.Ink)
|
||||
g.Checker(p.x, py, p.w, 1, pixel.Ink)
|
||||
// The readout lists the label, then each series' value.
|
||||
rows := []string{labels[at]}
|
||||
for _, s := range series {
|
||||
rows = append(rows, num(s.Values[at]))
|
||||
}
|
||||
bw, bh := 4, 3
|
||||
for _, row := range rows {
|
||||
bw, bh = max(bw, tinyW(row)+4), bh+inkH(small(row))+2
|
||||
}
|
||||
bx := min(w-bw, x+3)
|
||||
g.Frame(bx, p.y, bw, bh, pixel.Flat, pixel.Lit)
|
||||
y := p.y + 2
|
||||
for i, row := range rows {
|
||||
ink := pixel.Ink
|
||||
if i > 0 {
|
||||
ink = pick(series[i-1].Slot, i-1)
|
||||
}
|
||||
sm := small(row)
|
||||
mark(g, bx+2, y, row, sm, ink)
|
||||
y += inkH(sm) + 2
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNumbersStayExact(t *testing.T) {
|
||||
for v, want := range map[float64]string{
|
||||
0: "0", 12: "12", 3.5: "3.5", -0.5: "-0.5", 0.01: "0.01", 1e-7: "0.0000001", 2e-7: "0.0000002",
|
||||
1000001: "1000001", 12345: "12345", 0.1 + 0.2: "0.3",
|
||||
} {
|
||||
if got := num(v); got != want {
|
||||
t.Errorf("num(%v) = %q, want %q", v, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTicksKeepTheirStep(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
v, step float64
|
||||
want string
|
||||
}{
|
||||
{15000, 5000, "15K"}, {12300, 100, "12.3K"}, {1.5e6, 1e5, "1.5M"}, {1000000.2, 0.2, "1000000.2"},
|
||||
{0.6000000000000001, 0.2, "0.6"}, {2e-7, 1e-7, "0.0000002"}, {math.Copysign(0, -1), 1, "0"}, {40, 10, "40"},
|
||||
} {
|
||||
if got := tick(tc.v, tc.step); got != tc.want {
|
||||
t.Errorf("tick(%v, %v) = %q, want %q", tc.v, tc.step, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescriptionsCarryTheData(t *testing.T) {
|
||||
for _, tc := range []struct{ got, want string }{
|
||||
{read([]string{"A", "B"}, []float64{0.01, 0.02}), "A 0.01, B 0.02"},
|
||||
{readSeries([]string{"X"}, []Series{{Name: "HP", Values: []float64{3}}, {Name: "MP", Values: []float64{1}}}), "HP: X 3; MP: X 1"},
|
||||
{readSlices([]Slice{{Name: "Gold", Value: 52}}), "Gold 52"},
|
||||
{where([]Point{{X: 1, Y: 3, Size: 9}}, true), "(1, 3) 9"},
|
||||
{record([]int{1, 1, -1, 0}), "2-1-1: 1, 1, -1, 0"},
|
||||
{ohlc([]Candle{{Label: "MON", Open: 10, High: 14, Low: 9, Close: 13}}), "MON 10, 14, 9, 13"},
|
||||
{fives([]string{"D6"}, [][]float64{{1, 2, 3, 4, 5}}), "D6 1, 2, 3, 4, 5"},
|
||||
} {
|
||||
if tc.got != tc.want {
|
||||
t.Errorf("got %q, want %q", tc.got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlatRangesGetDistinctTicks(t *testing.T) {
|
||||
for _, r := range [][2]float64{{1e6, 1e6}, {40, 40}, {1e-5, 1e-5}, {-3, -3}, {1e6, 1e6 + 1}} {
|
||||
from, step, n := ticks(r[0], r[1], 5)
|
||||
top := from + float64(n-1)*step
|
||||
if !(from < r[1] && r[0] < top) || tick(from, step) == tick(from+step, step) {
|
||||
t.Errorf("%v ticks %v to %v by %v", r, from, top, step)
|
||||
}
|
||||
}
|
||||
// All zeros keep zero as the floor, for bars.
|
||||
if from, _, _ := ticks(0, 0, 5); from != 0 {
|
||||
t.Errorf("flat zero ticks from %v", from)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type DonutChartOptions struct {
|
||||
// Size is the ring's width in dots; zero means 64.
|
||||
Size int
|
||||
}
|
||||
|
||||
// DonutChart is a pie with a hole holding the total.
|
||||
func DonutChart(c *ui.Context, text string, slices []Slice, o DonutChartOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
n := pixel.Or("DonutChart Size", o.Size, 64)
|
||||
total, cuts := shares(slices)
|
||||
sum := num(total)
|
||||
return ui.Box(c).Size(k.D(n), k.D(n)).Role(ui.RoleImage).Label(text).Description(readSlices(slices)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
c, rad := n/2, n/2-1
|
||||
for i, s := range slices {
|
||||
sector(g, c, c, rad/2, rad, cuts[i], cuts[i+1], pick(s.Slot, i))
|
||||
}
|
||||
g.Circle(c, c, rad, pixel.Edge)
|
||||
g.Circle(c, c, rad/2, pixel.Edge)
|
||||
tiny(g, c-tinyW(sum)/2+1, c-2, sum, pixel.Ink)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// Point sits on two value axes; Size sizes bubbles.
|
||||
type Point struct {
|
||||
X, Y, Size float64
|
||||
}
|
||||
|
||||
// field is a plot with a value axis across too.
|
||||
type field struct {
|
||||
plot
|
||||
xfrom, xstep float64
|
||||
xn int
|
||||
}
|
||||
|
||||
// fitPoints fits both axes around points, marks pad dots in.
|
||||
func fitPoints(points []Point, w, h, pad int) field {
|
||||
if len(points) == 0 {
|
||||
panic("chart: no points to plot")
|
||||
}
|
||||
xs, ys := make([]float64, len(points)), make([]float64, len(points))
|
||||
for i, pt := range points {
|
||||
xs[i], ys[i] = pt.X, pt.Y
|
||||
}
|
||||
xlo, xhi := span(xs)
|
||||
ylo, yhi := span(ys)
|
||||
f := field{plot: fit(ylo, yhi, w, h, []string{"0"})}
|
||||
f.pad = pad
|
||||
if f.w-1-2*pad < 2 || f.h-1-2*pad < 2 {
|
||||
panic(fmt.Sprintf("chart: a %d×%d chart leaves no room inside %d-dot margins", w, h, pad))
|
||||
}
|
||||
f.xfrom, f.xstep, f.xn = ticks(xlo, xhi, 5)
|
||||
return f
|
||||
}
|
||||
|
||||
// ax is the x of v.
|
||||
func (f field) ax(v float64) int {
|
||||
top := f.xfrom + float64(f.xn-1)*f.xstep
|
||||
return f.x + f.pad + int((v-f.xfrom)/(top-f.xfrom)*float64(f.w-1-2*f.pad)+0.5)
|
||||
}
|
||||
|
||||
// grid draws both axes with their ticks.
|
||||
func (f field) grid(g pixel.Canvas) {
|
||||
f.plot.grid(g, nil)
|
||||
for i := range f.xn {
|
||||
v := f.xfrom + float64(i)*f.xstep
|
||||
x, s := f.ax(v), tick(v, f.xstep)
|
||||
g.Dot(x, f.y+f.h+1, pixel.Edge)
|
||||
tiny(g, max(0, min(g.W-tinyW(s), x-tinyW(s)/2)), f.y+f.h+3, s, pixel.Faint)
|
||||
}
|
||||
}
|
||||
|
||||
// where lists points as "(x, y)", sizes after for bubbles.
|
||||
func where(points []Point, sized bool) string {
|
||||
parts := make([]string, len(points))
|
||||
for i, p := range points {
|
||||
parts[i] = "(" + num(p.X) + ", " + num(p.Y) + ")"
|
||||
if sized {
|
||||
parts[i] += " " + num(p.Size)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type FunnelOptions struct {
|
||||
// Width is in dots; zero means 160.
|
||||
Width int
|
||||
}
|
||||
|
||||
// Funnel narrows stage by stage, each bar with its count.
|
||||
func Funnel(c *ui.Context, text string, stages []Slice, o FunnelOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if len(stages) == 0 {
|
||||
panic("chart: Funnel has no stages")
|
||||
}
|
||||
w := pixel.Or("Funnel Width", o.Width, 160)
|
||||
top := stages[0].Value
|
||||
for i, s := range stages {
|
||||
span([]float64{s.Value})
|
||||
if s.Value < 0 || i > 0 && s.Value > stages[i-1].Value {
|
||||
panic(fmt.Sprintf("chart: Funnel stage %q has %v; stages must narrow from %v", s.Name, s.Value, top))
|
||||
}
|
||||
}
|
||||
gutter := 0
|
||||
for _, s := range stages {
|
||||
gutter = max(gutter, pixel.Measure(s.Name)+6)
|
||||
}
|
||||
return ui.Box(c).Size(k.D(w), k.D(len(stages)*pixel.LineHeight)).Role(ui.RoleImage).Label(text).Description(readSlices(stages)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
room := w - gutter
|
||||
for i, s := range stages {
|
||||
bw := max(2, int(part(s.Value, top)*float64(room)))
|
||||
x, y := gutter+(room-bw)/2, i*pixel.LineHeight
|
||||
g.Text(0, y, s.Name, pixel.Faint, 1)
|
||||
g.Rect(x, y+2, bw, pixel.LineHeight-4, pick(s.Slot, i))
|
||||
// The count sits on its bar, or beside thin ones.
|
||||
n := num(s.Value)
|
||||
if tinyW(n)+4 <= bw {
|
||||
tiny(g, x+(bw-tinyW(n))/2, y+6, n, pixel.AccentInk)
|
||||
} else {
|
||||
tiny(g, x+bw+3, y+6, n, pixel.Ink)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type GaugeOptions struct {
|
||||
// Min and Max bound the dial; zeros mean 0–100.
|
||||
Min, Max float64
|
||||
// Size is the dial's width in dots; zero means 64.
|
||||
Size int
|
||||
}
|
||||
|
||||
// Gauge needles a half dial and prints the value.
|
||||
func Gauge(c *ui.Context, text string, value float64, o GaugeOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
span([]float64{o.Min, o.Max, value})
|
||||
lo, hi := o.Min, o.Max
|
||||
if lo == 0 && hi == 0 {
|
||||
hi = 100
|
||||
}
|
||||
if !(lo < hi && value >= lo && value <= hi) {
|
||||
panic(fmt.Sprintf("chart: Gauge value %v outside %v–%v", value, lo, hi))
|
||||
}
|
||||
n := pixel.Or("Gauge Size", o.Size, 64)
|
||||
shown := num(value)
|
||||
t := (value - lo) / (hi - lo)
|
||||
return ui.Box(c).Size(k.D(n), k.D(n/2+10)).Role(ui.RoleMeter).Label(text).Description(shown).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
c, rad := n/2, n/2-1
|
||||
// The dial runs from nine o'clock, over twelve, to three.
|
||||
sector(g, c, c, rad-5, rad, 0.75, 1, pixel.Good)
|
||||
sector(g, c, c, rad-5, rad, 0, 0.125, pixel.Warn)
|
||||
sector(g, c, c, rad-5, rad, 0.125, 0.25, pixel.Bad)
|
||||
a := math.Pi * (1 - t)
|
||||
g.Line(c, c, c+int(math.Round(float64(rad-3)*math.Cos(a))), c-int(math.Round(float64(rad-3)*math.Sin(a))), pixel.Ink)
|
||||
g.Disc(c, c, 2, pixel.Edge)
|
||||
tiny(g, c-tinyW(shown)/2+1, c+5, shown, pixel.Ink)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type HeatmapOptions struct {
|
||||
// Cell is a cell's least side, zero for 10.
|
||||
Cell int
|
||||
// Low and High end the dither; zeros mean Well, Bad.
|
||||
Low, High pixel.Slot
|
||||
}
|
||||
|
||||
// Heatmap shades cells by dither level between two slots.
|
||||
func Heatmap(c *ui.Context, text string, rows, cols []string, values [][]float64, o HeatmapOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if len(values) != len(rows) {
|
||||
panic(fmt.Sprintf("chart: Heatmap has %d rows of values for %d rows", len(values), len(rows)))
|
||||
}
|
||||
for i, r := range values {
|
||||
if len(r) != len(cols) {
|
||||
panic(fmt.Sprintf("chart: Heatmap row %q has %d values for %d columns", rows[i], len(r), len(cols)))
|
||||
}
|
||||
}
|
||||
cell := pixel.Or("Heatmap Cell", o.Cell, 10)
|
||||
low, high := o.Low, o.High
|
||||
if low == 0 && high == 0 {
|
||||
low, high = pixel.Well, pixel.Bad
|
||||
}
|
||||
lo, hi := span(values...)
|
||||
if hi == lo {
|
||||
hi = lo + 1
|
||||
}
|
||||
rs, cs := small(rows...), small(cols...)
|
||||
gutter := 0
|
||||
for _, r := range rows {
|
||||
gutter = max(gutter, markW(r, rs)+3)
|
||||
}
|
||||
// Cells grow to fit their labels.
|
||||
cw, ch := cell, max(cell, inkH(rs)+2)
|
||||
for _, col := range cols {
|
||||
cw = max(cw, markW(col, cs)+3)
|
||||
}
|
||||
head := labelH(cols)
|
||||
w, h := gutter+len(cols)*cw, head+len(rows)*ch
|
||||
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(readSeries(cols, grid(rows, values))).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for j, col := range cols {
|
||||
mark(g, gutter+j*cw+(cw-markW(col, cs))/2, 1, col, cs, pixel.Faint)
|
||||
}
|
||||
for i, row := range values {
|
||||
mark(g, 0, head+i*ch+(ch-1-inkH(rs))/2, rows[i], rs, pixel.Faint)
|
||||
for j, v := range row {
|
||||
level := int((v - lo) / (hi - lo) * 16)
|
||||
g.Dither(gutter+j*cw, head+i*ch, cw-1, ch-1, low, high, level)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// grid names each row of values as a series.
|
||||
func grid(rows []string, values [][]float64) []Series {
|
||||
out := make([]Series, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = Series{Name: r, Values: values[i]}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type HistogramOptions struct {
|
||||
// Bins is roughly how many, zero for 8; edges round.
|
||||
Bins int
|
||||
// Width and Height are dots; zeros mean 160 by 80.
|
||||
Width, Height int
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// Histogram counts values into bins, side by side.
|
||||
func Histogram(c *ui.Context, text string, values []float64, o HistogramOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
n := pixel.Or("Histogram Bins", o.Bins, 8)
|
||||
w, h := size("Histogram", o.Width, o.Height, 160, 80)
|
||||
if len(values) == 0 {
|
||||
panic("chart: Histogram has no values")
|
||||
}
|
||||
lo, hi := span(values)
|
||||
// A lone value gets a bin sized to itself.
|
||||
run := hi - lo
|
||||
if run == 0 {
|
||||
run = max(math.Abs(lo), 1)
|
||||
}
|
||||
step := nice(run / float64(n))
|
||||
from := math.Floor(lo/step) * step
|
||||
bins := int((hi-from)/step) + 1
|
||||
counts, labels := make([]float64, bins), make([]string, bins)
|
||||
for _, v := range values {
|
||||
counts[int((v-from)/step)]++
|
||||
}
|
||||
for i := range bins {
|
||||
labels[i] = tick(from+float64(i)*step, step)
|
||||
}
|
||||
_, top := span(counts)
|
||||
p := fit(0, top, w, h, labels)
|
||||
ink := pick(o.Slot, 0)
|
||||
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(read(labels, counts)).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(pt, r)
|
||||
p.grid(g, labels)
|
||||
for i, v := range counts {
|
||||
x0, x1 := p.x+i*p.w/bins, p.x+(i+1)*p.w/bins
|
||||
g.Rect(x0, p.at(v), x1-x0-1, p.at(0)-p.at(v), ink)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type HorizontalBarOptions struct {
|
||||
// Width is in dots, zero means 160; rows are lines.
|
||||
Width int
|
||||
// Slot fills the bars; zero means Accent.
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// HorizontalBar lays labeled bars across, longest to the edge.
|
||||
func HorizontalBar(c *ui.Context, text string, labels []string, values []float64, o HorizontalBarOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if len(labels) != len(values) || len(values) == 0 {
|
||||
panic(fmt.Sprintf("chart: HorizontalBar has %d labels for %d values", len(labels), len(values)))
|
||||
}
|
||||
w := pixel.Or("HorizontalBar Width", o.Width, 160)
|
||||
lo, hi := span(values)
|
||||
if lo < 0 {
|
||||
panic(fmt.Sprintf("chart: HorizontalBar value %v is negative", lo))
|
||||
}
|
||||
gutter, room := 0, 0
|
||||
for i, l := range labels {
|
||||
gutter, room = max(gutter, pixel.Measure(l)), max(room, tinyW(num(values[i])))
|
||||
}
|
||||
ink := pick(o.Slot, 0)
|
||||
return ui.Box(c).Size(k.D(w), k.D(len(values)*pixel.LineHeight)).Role(ui.RoleImage).Label(text).Description(read(labels, values)).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(pt, r)
|
||||
span := w - gutter - room - 8
|
||||
for i, v := range values {
|
||||
y, bw := i*pixel.LineHeight, int(part(v, hi)*float64(span))
|
||||
g.Text(gutter-pixel.Measure(labels[i]), y, labels[i], pixel.Faint, 1)
|
||||
g.Rect(gutter+4, y+4, max(1, bw), pixel.LineHeight-8, ink)
|
||||
tiny(g, gutter+6+bw, y+6, num(v), pixel.Ink)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package chart_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chart"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
var zhDays = []string{"周一", "周二", "周三"}
|
||||
|
||||
func TestFontLabelsClearTheAxis(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.BarChart(c, "金币", zhDays, []float64{3, 9, 4}, chart.BarChartOptions{})
|
||||
})
|
||||
img := tt.Image()
|
||||
// With 15-row font labels the axis is row 63.
|
||||
if n := count(t, img, 10, 63, 149, 1, pixel.Edge); n != 149 {
|
||||
t.Fatalf("the axis row holds %d Edge dots of 149", n)
|
||||
}
|
||||
if count(t, img, 0, 64, 160, 16, pixel.Faint) == 0 || count(t, img, 11, 3, 148, 60, pixel.Faint) != 0 {
|
||||
t.Fatal("labels are not all below the axis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeatmapAndCrosshairGrowForFontLabels(t *testing.T) {
|
||||
tt := view(400, 300, newClock(), func(c *ui.Context) {
|
||||
chart.Heatmap(c, "热", []string{"周一", "周二"}, []string{"早", "晚上"}, [][]float64{{1, 2}, {3, 4}}, chart.HeatmapOptions{})
|
||||
chart.Crosshair(c, "血", zhDays, []chart.Series{{Name: "A", Values: []float64{1, 2, 3}}, {Name: "B", Values: []float64{3, 2, 1}}}, chart.CrosshairOptions{})
|
||||
})
|
||||
// Rows are 14 dots under a 15-dot label head.
|
||||
if r := find(t, tt, "热"); r.H != 2*(15+2*14) {
|
||||
t.Fatalf("heatmap is %v px tall", r.H)
|
||||
}
|
||||
r := find(t, tt, "血")
|
||||
tt.Move(r.X+2*80, r.Y+2*30)
|
||||
// The readout holds a 14-row label and two 7-row values.
|
||||
x, last := int(r.X)/2+95, -1
|
||||
top, _ := column(t, tt.Image(), x, 150, pixel.Lit)
|
||||
for y := range 150 {
|
||||
if at(t, tt.Image(), x, y) == pixel.Lit {
|
||||
last = y
|
||||
}
|
||||
}
|
||||
if top < 0 || last-top+1 < 3+14+2*7-2 {
|
||||
t.Fatalf("readout spans rows %d–%d", top, last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreemapSkipsEmptySlices(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.Treemap(c, "Bag", []chart.Slice{{Name: "a", Value: 1, Slot: pixel.Info}, {Name: "none", Slot: pixel.Magic},
|
||||
{Name: "b", Value: 3, Slot: pixel.Warn}, {Name: "zero", Slot: pixel.Magic}}, chart.TreemapOptions{})
|
||||
})
|
||||
img := tt.Image()
|
||||
a, none, b := count(t, img, 0, 0, 160, 80, pixel.Info), count(t, img, 0, 0, 160, 80, pixel.Magic), count(t, img, 0, 0, 160, 80, pixel.Warn)
|
||||
if none != 0 || a == 0 || b < 3*a-200 || b > 3*a+200 {
|
||||
t.Fatalf("dots: a %d, empty %d, b %d", a, none, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChartsRejectBadData(t *testing.T) {
|
||||
nan := math.NaN()
|
||||
for name, build := range map[string]func(c *ui.Context){
|
||||
"a widening funnel": func(c *ui.Context) {
|
||||
chart.Funnel(c, "x", []chart.Slice{{Name: "A", Value: 9}, {Name: "B", Value: 3}, {Name: "C", Value: 5}}, chart.FunnelOptions{})
|
||||
},
|
||||
"a NaN funnel stage": func(c *ui.Context) {
|
||||
chart.Funnel(c, "x", []chart.Slice{{Name: "A", Value: nan}}, chart.FunnelOptions{})
|
||||
},
|
||||
"no stacked labels": func(c *ui.Context) { chart.StackedBar(c, "x", nil, nil, chart.StackedBarOptions{}) },
|
||||
"no box groups": func(c *ui.Context) { chart.BoxPlot(c, "x", nil, chart.BoxPlotOptions{}) },
|
||||
"an axis of nothing": func(c *ui.Context) { chart.Axis(c, chart.AxisOptions{Min: 5, Max: 5}) },
|
||||
"a NaN bullet": func(c *ui.Context) { chart.BulletChart(c, "x", nan, 1, []float64{2}, chart.BulletChartOptions{}) },
|
||||
"a NaN bubble": func(c *ui.Context) {
|
||||
chart.BubbleChart(c, "x", []chart.Point{{X: 1, Y: 1, Size: nan}}, chart.BubbleChartOptions{})
|
||||
},
|
||||
"a NaN candle": func(c *ui.Context) {
|
||||
chart.Candlestick(c, "x", []chart.Candle{{Label: "A", Open: nan, High: 2, Low: 0, Close: 1}}, chart.CandlestickOptions{})
|
||||
},
|
||||
"a negative radar": func(c *ui.Context) {
|
||||
chart.RadarChart(c, "x", []string{"A", "B", "C"}, []float64{1, -2, 3}, chart.RadarChartOptions{})
|
||||
},
|
||||
"a NaN radar Max": func(c *ui.Context) {
|
||||
chart.RadarChart(c, "x", []string{"A", "B", "C"}, []float64{1, 2, 3}, chart.RadarChartOptions{Max: nan})
|
||||
},
|
||||
"a negative radar Max": func(c *ui.Context) {
|
||||
chart.RadarChart(c, "x", []string{"A", "B", "C"}, []float64{1, 2, 3}, chart.RadarChartOptions{Max: -1})
|
||||
},
|
||||
"bars with no room": func(c *ui.Context) {
|
||||
chart.BarChart(c, "x", []string{"A"}, []float64{1}, chart.BarChartOptions{Width: 10})
|
||||
},
|
||||
"bubbles with no room": func(c *ui.Context) {
|
||||
chart.BubbleChart(c, "x", []chart.Point{{Size: 1}}, chart.BubbleChartOptions{Height: 20})
|
||||
},
|
||||
"an endless gauge": func(c *ui.Context) {
|
||||
chart.Gauge(c, "x", math.Inf(1), chart.GaugeOptions{Max: math.Inf(1)})
|
||||
},
|
||||
} {
|
||||
panics(t, name, func() { view(400, 200, newClock(), build) })
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelsStayInsideTheChart(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: newClock().now, Locale: pixel.Zh})
|
||||
ui.Column(c).Children(func() {
|
||||
chart.ScatterPlot(c, "Hits", []chart.Point{{X: 100, Y: 1}, {X: 900, Y: 9}}, chart.ScatterPlotOptions{})
|
||||
chart.CalendarHeatmap(c, "Days", 2026, func(time.Time) int { return 0 }, chart.CalendarHeatmapOptions{})
|
||||
chart.BarChart(c, "Days", []string{"A", "WEDNESDAY"}, []float64{1, 2}, chart.BarChartOptions{Width: 60})
|
||||
})
|
||||
}, 800, 600)
|
||||
img := tt.Image()
|
||||
if n := count(t, img, 160, 0, 240, 80, pixel.Faint); n != 0 {
|
||||
t.Fatalf("%d scatter label dots past the right edge", n)
|
||||
}
|
||||
cal := find(t, tt, "Days 2026")
|
||||
// The year spans 53 weeks of 5 dots.
|
||||
if n := count(t, img, 265, int(cal.Y)/2, 135, 20, pixel.Faint); n != 0 {
|
||||
t.Fatalf("%d month label dots past the right edge", n)
|
||||
}
|
||||
bars := find(t, tt, "Days")
|
||||
if n := count(t, img, 60, int(bars.Y)/2, 340, 80, pixel.Faint); n != 0 {
|
||||
t.Fatalf("%d bar label dots past the right edge", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulletTargetAtTheEndStaysInside(t *testing.T) {
|
||||
tt := view(400, 100, newClock(), func(c *ui.Context) {
|
||||
ui.Row(c).Children(func() {
|
||||
chart.BulletChart(c, "Goal", 50, 100, []float64{100}, chart.BulletChartOptions{Width: 100})
|
||||
})
|
||||
})
|
||||
if n := count(t, tt.Image(), 100, 0, 100, 14, pixel.Accent); n != 0 {
|
||||
t.Fatalf("%d target dots past the right edge", n)
|
||||
}
|
||||
if n := count(t, tt.Image(), 98, 2, 2, 10, pixel.Accent); n != 20 {
|
||||
t.Fatalf("the end target holds %d dots of 20", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTinyRangesKeepTheirShape(t *testing.T) {
|
||||
for _, scale := range []float64{1, 1e-5} {
|
||||
tt := view(400, 400, newClock(), func(c *ui.Context) {
|
||||
chart.ScatterPlot(c, "Hits", []chart.Point{{X: 1 * scale, Y: 1 * scale}, {X: 9 * scale, Y: 9 * scale}}, chart.ScatterPlotOptions{Slot: pixel.Magic})
|
||||
chart.Histogram(c, "Rolls", []float64{1 * scale, 1 * scale, 1 * scale, 2 * scale, 2 * scale, 9 * scale}, chart.HistogramOptions{Bins: 2, Slot: pixel.Info})
|
||||
})
|
||||
img := tt.Image()
|
||||
if n := count(t, img, 0, 0, 160, 80, pixel.Magic); n != 8 {
|
||||
t.Fatalf("scale %v: two points drew %d dots", scale, n)
|
||||
}
|
||||
_, low := column(t, img, 40, 160, pixel.Info)
|
||||
_, high := column(t, img, 120, 160, pixel.Info)
|
||||
if high == 0 || low < 4*high-6 {
|
||||
t.Fatalf("scale %v: bins of 5 and 1 stand %d and %d dots", scale, low, high)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestZeroRadarKeepsItsWeb(t *testing.T) {
|
||||
tt := view(200, 200, newClock(), func(c *ui.Context) {
|
||||
chart.RadarChart(c, "Newbie", []string{"STR", "DEX", "INT", "VIT", "AGI", "LUK"}, make([]float64, 6), chart.RadarChartOptions{})
|
||||
})
|
||||
if n := count(t, tt.Image(), 0, 0, 80, 80, pixel.Shade); n < 200 {
|
||||
t.Fatalf("the web holds %d dots", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBubblesStayInsideAShortChart(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
ui.Column(c).Padding(pixel.K(c).D(20)).Children(func() {
|
||||
chart.BubbleChart(c, "Towns", []chart.Point{{Size: 1}, {X: 10, Y: 10, Size: 1}}, chart.BubbleChartOptions{Height: 40})
|
||||
})
|
||||
})
|
||||
img := tt.Image()
|
||||
if n := 200*20 - count(t, img, 0, 0, 200, 20, pixel.Back); n != 0 {
|
||||
t.Fatalf("%d bubble dots above the chart", n)
|
||||
}
|
||||
if n := 20*40 - count(t, img, 180, 20, 20, 40, pixel.Back); n != 0 {
|
||||
t.Fatalf("%d bubble dots right of the chart", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTinyMagnitudesKeepTheirProportions(t *testing.T) {
|
||||
bubbles := map[float64]int{}
|
||||
for _, scale := range []float64{1, 1e-12} {
|
||||
tt := view(800, 400, newClock(), func(c *ui.Context) {
|
||||
ui.Row(c).Children(func() {
|
||||
chart.BubbleChart(c, "Towns", []chart.Point{{X: 0, Y: 0, Size: 1 * scale}, {X: 9, Y: 9, Size: 4 * scale}}, chart.BubbleChartOptions{Slot: pixel.Info})
|
||||
chart.HorizontalBar(c, "Kills", []string{"A", "B"}, []float64{1 * scale, 2 * scale}, chart.HorizontalBarOptions{Slot: pixel.Warn})
|
||||
})
|
||||
chart.Pyramid(c, "Ages", []string{"A"}, chart.Series{Name: "L", Values: []float64{1 * scale}, Slot: pixel.Magic},
|
||||
chart.Series{Name: "R", Values: []float64{2 * scale}, Slot: pixel.Good}, chart.PyramidOptions{})
|
||||
chart.Funnel(c, "Shop", []chart.Slice{{Name: "IN", Value: 4 * scale, Slot: pixel.Bad}, {Name: "OUT", Value: 1 * scale, Slot: pixel.Bad}}, chart.FunnelOptions{})
|
||||
})
|
||||
img, kills := tt.Image(), find(t, tt, "Kills")
|
||||
kx, ky := int(kills.X)/2, int(kills.Y)/2
|
||||
bubbles[scale] = count(t, img, 0, 0, 160, 80, pixel.Info)
|
||||
// One dot row through each bar gives its length.
|
||||
for _, bars := range [][2]int{
|
||||
{count(t, img, kx, ky+4, 160, 1, pixel.Warn), count(t, img, kx, ky+20, 160, 1, pixel.Warn)},
|
||||
{count(t, img, 0, 100, 200, 1, pixel.Magic), count(t, img, 0, 100, 200, 1, pixel.Good)},
|
||||
{count(t, img, 0, 131, 160, 1, pixel.Bad) * 2, count(t, img, 0, 115, 160, 1, pixel.Bad)},
|
||||
} {
|
||||
if bars[0] < 10 || bars[1]-2*bars[0] > 4 || 2*bars[0]-bars[1] > 4 {
|
||||
t.Errorf("scale %v: bars %v are not 1:2", scale, bars)
|
||||
}
|
||||
}
|
||||
}
|
||||
if bubbles[1] != bubbles[1e-12] || bubbles[1] == 0 {
|
||||
t.Fatalf("bubble dots %v", bubbles)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type LegendOptions struct {
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
type LegendResult struct {
|
||||
Element *ui.Element
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// Legend names series by swatch; clicks hide or show one.
|
||||
func Legend(c *ui.Context, text string, series []Series, hidden []bool, o LegendOptions) LegendResult {
|
||||
k := pixel.K(c)
|
||||
if len(hidden) != len(series) {
|
||||
panic(fmt.Sprintf("chart: Legend has %d series and %d hidden flags", len(series), len(hidden)))
|
||||
}
|
||||
row := ui.Row(c).Gap(k.D(8)).FocusGroup(ui.Horizontal).Role(ui.RoleGroup).Label(text)
|
||||
res := LegendResult{Element: row}
|
||||
row.Children(func() {
|
||||
for i, s := range series {
|
||||
shown := !hidden[i]
|
||||
shell, base, st := pixel.Shell(c, func() *ui.Element { return ui.ToggleBase(c, &shown).Label(s.Name).Disabled(o.Disabled) })
|
||||
if base.Changed() {
|
||||
hidden[i], res.Changed = !shown, true
|
||||
slog.Debug("8bit: legend", "label", text, "series", s.Name, "shown", shown)
|
||||
}
|
||||
ink := pick(s.Slot, i)
|
||||
shell.Size(k.D(10+pixel.Measure(s.Name)), k.D(pixel.LineHeight)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
name := pixel.Ink
|
||||
if shown {
|
||||
g.Rect(0, 5, 6, 6, ink)
|
||||
} else {
|
||||
g.Rect(0, 5, 6, 6, pixel.Edge)
|
||||
g.Rect(1, 6, 4, 4, pixel.Well)
|
||||
name = pixel.Faint
|
||||
}
|
||||
g.Text(10, 0, s.Name, name, 1)
|
||||
g.Finish(st)
|
||||
})
|
||||
}
|
||||
})
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type LineChartOptions struct {
|
||||
// Width and Height are dots; zeros mean 160 by 80.
|
||||
Width, Height int
|
||||
}
|
||||
|
||||
// LineChart draws series as Bresenham lines with square markers.
|
||||
func LineChart(c *ui.Context, text string, labels []string, series []Series, o LineChartOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
w, h := size("LineChart", o.Width, o.Height, 160, 80)
|
||||
p := fitSeries(labels, series, w, h)
|
||||
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(readSeries(labels, series)).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(pt, r)
|
||||
p.grid(g, labels)
|
||||
lines(g, p, len(labels), series)
|
||||
})
|
||||
}
|
||||
|
||||
// fitSeries checks series against labels and fits their range.
|
||||
func fitSeries(labels []string, series []Series, w, h int) plot {
|
||||
rows := make([][]float64, len(series))
|
||||
for i, s := range series {
|
||||
if len(s.Values) != len(labels) {
|
||||
panic(fmt.Sprintf("chart: series %q has %d values for %d labels", s.Name, len(s.Values), len(labels)))
|
||||
}
|
||||
rows[i] = s.Values
|
||||
}
|
||||
lo, hi := span(rows...)
|
||||
return fit(lo, hi, w, h, labels)
|
||||
}
|
||||
|
||||
// lines joins each series' points and marks them with squares.
|
||||
func lines(g pixel.Canvas, p plot, n int, series []Series) {
|
||||
for j, s := range series {
|
||||
ink := pick(s.Slot, j)
|
||||
for i := 1; i < n; i++ {
|
||||
g.Line(p.slot(i-1, n), p.at(s.Values[i-1]), p.slot(i, n), p.at(s.Values[i]), ink)
|
||||
}
|
||||
for i, v := range s.Values {
|
||||
g.Rect(p.slot(i, n)-1, p.at(v)-1, 3, 3, ink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readSeries lists each series for assistive technology.
|
||||
func readSeries(labels []string, series []Series) string {
|
||||
out := ""
|
||||
for i, s := range series {
|
||||
if i > 0 {
|
||||
out += "; "
|
||||
}
|
||||
out += s.Name + ": " + read(labels, s.Values)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package chart_test
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chart"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
var hp = []chart.Series{{Name: "HP", Values: []float64{10, 30, 20, 40}, Slot: pixel.Good}, {Name: "MP", Values: []float64{5, 5, 15, 10}, Slot: pixel.Info}}
|
||||
|
||||
func TestLineChartJoinsAndMarksPoints(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.LineChart(c, "Stats", days, hp, chart.LineChartOptions{})
|
||||
})
|
||||
img := tt.Image()
|
||||
good, info := count(t, img, 0, 0, 160, 80, pixel.Good), count(t, img, 0, 0, 160, 80, pixel.Info)
|
||||
if good < 100 || info < 100 {
|
||||
t.Fatalf("line dots: HP %d, MP %d", good, info)
|
||||
}
|
||||
// One-dot lines leave whole 3×3 blocks only at the markers.
|
||||
if hb, mb := blocks(t, img, pixel.Good), blocks(t, img, pixel.Info); hb != 4 || mb != 4 {
|
||||
t.Fatalf("markers: HP %d, MP %d", hb, mb)
|
||||
}
|
||||
keeps(t, img)
|
||||
panics(t, "a series too short", func() {
|
||||
view(100, 100, newClock(), func(c *ui.Context) {
|
||||
chart.LineChart(c, "x", days, []chart.Series{{Name: "a", Values: []float64{1}}}, chart.LineChartOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestAreaChartDithersBelowTheLine(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.AreaChart(c, "Gold", days, []float64{20, 20, 20, 20}, chart.AreaChartOptions{Slot: pixel.Warn})
|
||||
})
|
||||
img := tt.Image()
|
||||
// Under a flat line, the area mixes Warn and ground.
|
||||
lit, ground := count(t, img, 60, 50, 40, 10, pixel.Warn), count(t, img, 60, 50, 40, 10, pixel.Back)
|
||||
if lit == 0 || ground == 0 {
|
||||
t.Fatalf("area under the line: %d Warn, %d ground", lit, ground)
|
||||
}
|
||||
keeps(t, img)
|
||||
}
|
||||
|
||||
func TestAreaChartFillsBelowZeroToo(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.AreaChart(c, "Debt", days, []float64{-20, -20, -20, -20}, chart.AreaChartOptions{Slot: pixel.Bad})
|
||||
})
|
||||
img := tt.Image()
|
||||
// Zero tops the plot; the area hangs below it.
|
||||
lit, ground := count(t, img, 60, 10, 40, 10, pixel.Bad), count(t, img, 60, 10, 40, 10, pixel.Back)
|
||||
if lit == 0 || ground == 0 {
|
||||
t.Fatalf("area above the line: %d Bad, %d ground", lit, ground)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStepChartHoldsThenSteps(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.StepChart(c, "Level", days, []float64{1, 1, 3, 3}, chart.StepChartOptions{Slot: pixel.Magic})
|
||||
})
|
||||
img := tt.Image()
|
||||
// A flat run of a whole share holds one row.
|
||||
rows := map[int]bool{}
|
||||
for x := 20; x < 40; x++ {
|
||||
for y := range 80 {
|
||||
if at(t, img, x, y) == pixel.Magic {
|
||||
rows[y] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("the first hold spans rows %v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSparklineMarksItsEnd(t *testing.T) {
|
||||
tt := view(200, 60, newClock(), func(c *ui.Context) {
|
||||
chart.Sparkline(c, "Ping", []float64{3, 9, 4, 12}, chart.SparklineOptions{Slot: pixel.Info})
|
||||
})
|
||||
img := tt.Image()
|
||||
if count(t, img, 0, 0, 48, 16, pixel.Info) < 30 || count(t, img, 44, 0, 4, 16, pixel.Accent) == 0 {
|
||||
t.Fatal("no line, or no mark at its end")
|
||||
}
|
||||
panics(t, "one value", func() {
|
||||
view(100, 60, newClock(), func(c *ui.Context) { chart.Sparkline(c, "x", []float64{1}, chart.SparklineOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrosshairCrossesOnHover(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.Crosshair(c, "Stats", days, hp, chart.CrosshairOptions{})
|
||||
})
|
||||
if n := count(t, tt.Image(), 0, 0, 160, 80, pixel.Lit); n != 0 {
|
||||
t.Fatal("a readout shows before hover")
|
||||
}
|
||||
tt.Move(2*90, 2*30)
|
||||
img := tt.Image()
|
||||
if count(t, img, 0, 0, 160, 80, pixel.Lit) == 0 || count(t, img, 0, 30, 160, 1, pixel.Ink) < 20 {
|
||||
t.Fatal("hover drew no crosshair or readout")
|
||||
}
|
||||
keeps(t, img)
|
||||
}
|
||||
|
||||
func TestSpectrumPeaksFallAndRest(t *testing.T) {
|
||||
clk := newClock()
|
||||
levels := []float64{1, 0.5}
|
||||
tt := view(200, 100, clk, func(c *ui.Context) {
|
||||
chart.SpectrumBars(c, "EQ", levels, chart.SpectrumBarsOptions{})
|
||||
})
|
||||
img := tt.Image()
|
||||
if lit := count(t, img, 0, 0, 5, 30, pixel.Shade); lit != 0 {
|
||||
t.Fatalf("a full bar shows %d dark dots", lit)
|
||||
}
|
||||
// Five idle seconds do not count toward the fall.
|
||||
clk.t = clk.t.Add(5 * time.Second)
|
||||
levels[0] = 0
|
||||
tt.Frame()
|
||||
if top, _ := column(t, tt.Image(), 2, 30, pixel.Ink); top != 0 {
|
||||
t.Fatalf("at the drop the peak sits at row %d", top)
|
||||
}
|
||||
clk.t = clk.t.Add(300 * time.Millisecond)
|
||||
tt.Frame()
|
||||
// After 0.3 s the peak has fallen three blocks.
|
||||
top, _ := column(t, tt.Image(), 2, 30, pixel.Ink)
|
||||
if top != 9 {
|
||||
t.Fatalf("the peak sits at row %d", top)
|
||||
}
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
tt.Frame()
|
||||
if top, _ := column(t, tt.Image(), 2, 30, pixel.Ink); top != -1 {
|
||||
t.Fatalf("reduced, the peak still floats at %d", top)
|
||||
}
|
||||
}
|
||||
|
||||
// blocks counts 3×3 squares of s on a 160×80 chart.
|
||||
func blocks(t *testing.T, img *image.RGBA, s pixel.Slot) int {
|
||||
t.Helper()
|
||||
n := 0
|
||||
for y := range 78 {
|
||||
for x := range 158 {
|
||||
if count(t, img, x, y, 3, 3, s) == 9 {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type PieChartOptions struct {
|
||||
// Size is the pie's width in dots; zero means 64.
|
||||
Size int
|
||||
}
|
||||
|
||||
// PieChart rasterizes one sector per slice, dot by dot.
|
||||
func PieChart(c *ui.Context, text string, slices []Slice, o PieChartOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
n := pixel.Or("PieChart Size", o.Size, 64)
|
||||
_, cuts := shares(slices)
|
||||
return ui.Box(c).Size(k.D(n), k.D(n)).Role(ui.RoleImage).Label(text).Description(readSlices(slices)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
c, rad := n/2, n/2-1
|
||||
for i, s := range slices {
|
||||
sector(g, c, c, 0, rad, cuts[i], cuts[i+1], pick(s.Slot, i))
|
||||
}
|
||||
g.Circle(c, c, rad, pixel.Edge)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type PolarAreaOptions struct {
|
||||
// Size is the chart's width in dots; zero means 64.
|
||||
Size int
|
||||
}
|
||||
|
||||
// PolarArea gives slices equal turns, reaching out by value.
|
||||
func PolarArea(c *ui.Context, text string, slices []Slice, o PolarAreaOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
n := pixel.Or("PolarArea Size", o.Size, 64)
|
||||
shares(slices)
|
||||
most := 0.0
|
||||
for _, s := range slices {
|
||||
most = max(most, s.Value)
|
||||
}
|
||||
return ui.Box(c).Size(k.D(n), k.D(n)).Role(ui.RoleImage).Label(text).Description(readSlices(slices)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
c, rad := n/2, n/2-1
|
||||
g.Circle(c, c, rad, pixel.Shade)
|
||||
g.Circle(c, c, rad/2, pixel.Shade)
|
||||
step := 1 / float64(len(slices))
|
||||
for i, s := range slices {
|
||||
if s.Value > 0 {
|
||||
reach := max(1, int(s.Value/most*float64(rad)))
|
||||
sector(g, c, c, 0, reach, float64(i)*step, float64(i+1)*step, pick(s.Slot, i))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type ProgressRingOptions struct {
|
||||
// Size is the ring's width in dots; zero means 40.
|
||||
Size int
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// ProgressRing fills a ring clockwise from twelve, in pixel arcs.
|
||||
func ProgressRing(c *ui.Context, text string, value float64, o ProgressRingOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if !(value >= 0 && value <= 1) {
|
||||
panic(fmt.Sprintf("chart: ProgressRing value %v outside 0–1", value))
|
||||
}
|
||||
n := pixel.Or("ProgressRing Size", o.Size, 40)
|
||||
pct := strconv.Itoa(int(value*100)) + "%"
|
||||
ink := pick(o.Slot, 0)
|
||||
return ui.Box(c).Size(k.D(n), k.D(n)).Role(ui.RoleProgress).Label(text).Description(pct).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
c, rad := n/2, n/2-1
|
||||
sector(g, c, c, rad-4, rad, 0, 1, pixel.Well)
|
||||
sector(g, c, c, rad-4, rad, 0, value, ink)
|
||||
tiny(g, c-tinyW(pct)/2+1, c-2, pct, pixel.Ink)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type PyramidOptions struct {
|
||||
// Width is in dots, zero means 200; rows are lines.
|
||||
Width int
|
||||
}
|
||||
|
||||
// Pyramid sets two series back to back by group.
|
||||
func Pyramid(c *ui.Context, text string, groups []string, left, right Series, o PyramidOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if len(left.Values) != len(groups) || len(right.Values) != len(groups) {
|
||||
panic(fmt.Sprintf("chart: Pyramid has %d groups for %d and %d values", len(groups), len(left.Values), len(right.Values)))
|
||||
}
|
||||
w := pixel.Or("Pyramid Width", o.Width, 200)
|
||||
lo, hi := span(left.Values, right.Values)
|
||||
if lo < 0 {
|
||||
panic(fmt.Sprintf("chart: Pyramid value %v is negative", lo))
|
||||
}
|
||||
mid := 0
|
||||
for _, g := range groups {
|
||||
mid = max(mid, pixel.Measure(g)+12)
|
||||
}
|
||||
lslot, rslot := pick(left.Slot, 1), pick(right.Slot, 0)
|
||||
head := pixel.LineHeight
|
||||
return ui.Box(c).Size(k.D(w), k.D(head+len(groups)*pixel.LineHeight)).Role(ui.RoleImage).Label(text).
|
||||
Description(left.Name + ": " + read(groups, left.Values) + "; " + right.Name + ": " + read(groups, right.Values)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
reach := (w - mid) / 2
|
||||
cx := reach + mid/2
|
||||
g.Text(0, 0, left.Name, lslot, 1)
|
||||
g.Text(w-pixel.Measure(right.Name), 0, right.Name, rslot, 1)
|
||||
for i, name := range groups {
|
||||
y := head + i*pixel.LineHeight
|
||||
g.Text(cx-pixel.Measure(name)/2, y, name, pixel.Faint, 1)
|
||||
lw, rw := int(part(left.Values[i], hi)*float64(reach)), int(part(right.Values[i], hi)*float64(reach))
|
||||
g.Rect(reach-lw, y+4, lw, pixel.LineHeight-8, lslot)
|
||||
g.Rect(reach+mid, y+4, rw, pixel.LineHeight-8, rslot)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type RadarChartOptions struct {
|
||||
// Max is the web's rim; zero takes the largest value.
|
||||
Max float64
|
||||
// Size is the web's width in dots; zero means 80.
|
||||
Size int
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// RadarChart draws a stat polygon on a web of axes.
|
||||
func RadarChart(c *ui.Context, text string, axes []string, values []float64, o RadarChartOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if len(axes) != len(values) || len(axes) < 3 {
|
||||
panic(fmt.Sprintf("chart: RadarChart has %d axes for %d values", len(axes), len(values)))
|
||||
}
|
||||
span([]float64{o.Max})
|
||||
lo, top := span(values)
|
||||
if lo < 0 || o.Max < 0 || o.Max > 0 && top > o.Max {
|
||||
panic(fmt.Sprintf("chart: RadarChart values %v–%v outside 0–%v", lo, top, o.Max))
|
||||
}
|
||||
if o.Max > 0 {
|
||||
top = o.Max
|
||||
}
|
||||
// All zeros: any rim works; the polygon sits mid-web.
|
||||
if top == 0 {
|
||||
top = 1
|
||||
}
|
||||
n := pixel.Or("RadarChart Size", o.Size, 80)
|
||||
sm, ink := small(axes...), pick(o.Slot, 0)
|
||||
rad := web(axes, sm, n)
|
||||
// point is where value v sits on axis i.
|
||||
point := func(i int, v float64) (int, int) {
|
||||
sin, cos := math.Sincos(2 * math.Pi * float64(i) / float64(len(axes)))
|
||||
reach := v / top * float64(rad)
|
||||
return n/2 + int(math.Round(reach*sin)), n/2 - int(math.Round(reach*cos))
|
||||
}
|
||||
return ui.Box(c).Size(k.D(n), k.D(n)).Role(ui.RoleImage).Label(text).Description(read(axes, values)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for _, ring := range []float64{1.0 / 3, 2.0 / 3, 1} {
|
||||
for i := range axes {
|
||||
x0, y0 := point(i, ring*top)
|
||||
x1, y1 := point((i+1)%len(axes), ring*top)
|
||||
g.Line(x0, y0, x1, y1, pixel.Shade)
|
||||
}
|
||||
}
|
||||
for i, a := range axes {
|
||||
x, y := point(i, top)
|
||||
g.Line(n/2, n/2, x, y, pixel.Shade)
|
||||
lx, ly := tag(i, len(axes), rad, markW(a, sm), inkH(sm), n)
|
||||
mark(g, lx, ly, a, sm, pixel.Faint)
|
||||
}
|
||||
for i, v := range values {
|
||||
x0, y0 := point(i, v)
|
||||
x1, y1 := point((i+1)%len(values), values[(i+1)%len(values)])
|
||||
g.Line(x0, y0, x1, y1, ink)
|
||||
}
|
||||
for i, v := range values {
|
||||
x, y := point(i, v)
|
||||
g.Rect(x-1, y-1, 3, 3, ink)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// gap is the room between the rim and a label.
|
||||
const gap = 3
|
||||
|
||||
// lean is -1, 0 or 1: a label's side.
|
||||
func lean(v float64) float64 {
|
||||
switch {
|
||||
case v > 0.3:
|
||||
return 1
|
||||
case v < -0.3:
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// web is the largest rim keeping every label inside.
|
||||
func web(axes []string, sm bool, n int) int {
|
||||
rad := float64(n/2 - 1)
|
||||
for i, a := range axes {
|
||||
sin, cos := math.Sincos(2 * math.Pi * float64(i) / float64(len(axes)))
|
||||
w, h := float64(markW(a, sm)), float64(inkH(sm))
|
||||
// A leaning label reaches its whole size out; others half.
|
||||
ex, ey := w/2*(1+math.Abs(lean(sin))), h/2*(1+math.Abs(lean(cos)))
|
||||
if ex > float64(n)/2 || ey > float64(n)/2 {
|
||||
rad = 0
|
||||
}
|
||||
if s := math.Abs(sin); s > 1e-9 {
|
||||
rad = min(rad, (float64(n/2)-ex)/s-gap)
|
||||
}
|
||||
if c := math.Abs(cos); c > 1e-9 {
|
||||
rad = min(rad, (float64(n/2)-ey)/c-gap)
|
||||
}
|
||||
}
|
||||
if rad < 4 {
|
||||
panic(fmt.Sprintf("chart: RadarChart Size %d leaves no room inside its labels", n))
|
||||
}
|
||||
return int(rad)
|
||||
}
|
||||
|
||||
// tag is the top left of axis i's label.
|
||||
func tag(i, count, rad, w, h, n int) (int, int) {
|
||||
sin, cos := math.Sincos(2 * math.Pi * float64(i) / float64(count))
|
||||
ax, ay := float64(n)/2+float64(rad+gap)*sin, float64(n)/2-float64(rad+gap)*cos
|
||||
x := ax - float64(w)/2*(1-lean(sin))
|
||||
y := ay - float64(h)/2*(1+lean(cos))
|
||||
return max(0, min(n-w, int(math.Round(x)))), max(0, min(n-h, int(math.Round(y))))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
// turn is a dot's angle, 0–1 clockwise from twelve.
|
||||
func turn(dx, dy int) float64 {
|
||||
a := math.Atan2(float64(dx)+0.5, -float64(dy)-0.5) / (2 * math.Pi)
|
||||
if a < 0 {
|
||||
a++
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// sector fills dots between radii r0–r1 and turns a0–a1.
|
||||
func sector(g pixel.Canvas, cx, cy, r0, r1 int, a0, a1 float64, s pixel.Slot) {
|
||||
for dy := -r1; dy <= r1; dy++ {
|
||||
for dx := -r1; dx <= r1; dx++ {
|
||||
d := dx*dx + dy*dy
|
||||
if d > r1*r1+r1 || d < r0*r0 {
|
||||
continue
|
||||
}
|
||||
if a := turn(dx, dy); a >= a0 && a < a1 {
|
||||
g.Dot(cx+dx, cy+dy, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shares are slices' fractions of their total, checked.
|
||||
func shares(slices []Slice) (total float64, cuts []float64) {
|
||||
for _, s := range slices {
|
||||
if s.Value < 0 || math.IsNaN(s.Value) || math.IsInf(s.Value, 0) {
|
||||
panic(fmt.Sprintf("chart: slice %q has value %v", s.Name, s.Value))
|
||||
}
|
||||
total += s.Value
|
||||
}
|
||||
if total == 0 {
|
||||
panic("chart: slices sum to zero")
|
||||
}
|
||||
at := 0.0
|
||||
cuts = []float64{0}
|
||||
for _, s := range slices {
|
||||
at += s.Value / total
|
||||
cuts = append(cuts, at)
|
||||
}
|
||||
cuts[len(cuts)-1] = 1
|
||||
return total, cuts
|
||||
}
|
||||
|
||||
// readSlices lists slices for assistive technology.
|
||||
func readSlices(slices []Slice) string {
|
||||
labels, values := make([]string, len(slices)), make([]float64, len(slices))
|
||||
for i, s := range slices {
|
||||
labels[i], values[i] = s.Name, s.Value
|
||||
}
|
||||
return read(labels, values)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type ScatterPlotOptions struct {
|
||||
// Width and Height are dots; zeros mean 160 by 80.
|
||||
Width, Height int
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// ScatterPlot puts each point as a 2×2 square.
|
||||
func ScatterPlot(c *ui.Context, text string, points []Point, o ScatterPlotOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
w, h := size("ScatterPlot", o.Width, o.Height, 160, 80)
|
||||
f := fitPoints(points, w, h, 2)
|
||||
ink := pick(o.Slot, 0)
|
||||
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(where(points, false)).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(pt, r)
|
||||
f.grid(g)
|
||||
for _, p := range points {
|
||||
g.Rect(f.ax(p.X), f.at(p.Y)-1, 2, 2, ink)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package chart_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chart"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
var loot = []chart.Slice{{Name: "Gold", Value: 3, Slot: pixel.Warn}, {Name: "Gems", Value: 1, Slot: pixel.Info}}
|
||||
|
||||
func TestPieAndDonutShareBySlice(t *testing.T) {
|
||||
tt := view(300, 200, newClock(), func(c *ui.Context) {
|
||||
ui.Row(c).Children(func() {
|
||||
chart.PieChart(c, "Pie", loot, chart.PieChartOptions{})
|
||||
chart.DonutChart(c, "Donut", loot, chart.DonutChartOptions{})
|
||||
})
|
||||
})
|
||||
img := tt.Image()
|
||||
gold, gems := count(t, img, 0, 0, 64, 64, pixel.Warn), count(t, img, 0, 0, 64, 64, pixel.Info)
|
||||
if share := float64(gold) / float64(gold+gems); share < 0.72 || share > 0.78 {
|
||||
t.Fatalf("pie dots %d and %d for 3:1", gold, gems)
|
||||
}
|
||||
// The donut's middle is hollow.
|
||||
if at(t, img, 64+32, 26) == pixel.Warn || count(t, img, 64, 0, 64, 64, pixel.Warn) == 0 {
|
||||
t.Fatal("the donut has no hole, or no ring")
|
||||
}
|
||||
keeps(t, img)
|
||||
panics(t, "slices of nothing", func() {
|
||||
view(100, 100, newClock(), func(c *ui.Context) { chart.PieChart(c, "x", []chart.Slice{{Name: "a"}}, chart.PieChartOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestPolarAreaReachesByValue(t *testing.T) {
|
||||
tt := view(200, 200, newClock(), func(c *ui.Context) {
|
||||
chart.PolarArea(c, "Stats", []chart.Slice{{Name: "A", Value: 4, Slot: pixel.Warn}, {Name: "B", Value: 2, Slot: pixel.Info}}, chart.PolarAreaOptions{})
|
||||
})
|
||||
img := tt.Image()
|
||||
a, b := count(t, img, 0, 0, 64, 64, pixel.Warn), count(t, img, 0, 0, 64, 64, pixel.Info)
|
||||
// Twice the reach is four times the area.
|
||||
if a < 3*b || a > 5*b {
|
||||
t.Fatalf("polar dots %d and %d for reaches 2:1", a, b)
|
||||
}
|
||||
tt = view(200, 200, newClock(), func(c *ui.Context) {
|
||||
chart.PolarArea(c, "Stats", []chart.Slice{{Name: "A", Value: 4, Slot: pixel.Warn}, {Name: "B", Slot: pixel.Info}}, chart.PolarAreaOptions{})
|
||||
})
|
||||
if n := count(t, tt.Image(), 0, 0, 64, 64, pixel.Info); n != 0 {
|
||||
t.Fatalf("a zero slice drew %d dots", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressRingFillsClockwise(t *testing.T) {
|
||||
tt := view(200, 100, newClock(), func(c *ui.Context) {
|
||||
chart.ProgressRing(c, "Load", 0.25, chart.ProgressRingOptions{Slot: pixel.Good})
|
||||
})
|
||||
img := tt.Image()
|
||||
if right, left := count(t, img, 20, 0, 20, 20, pixel.Good), count(t, img, 0, 0, 20, 40, pixel.Good); right == 0 || left != 0 {
|
||||
t.Fatalf("a quarter lit %d dots top right, %d on the left", right, left)
|
||||
}
|
||||
panics(t, "a value of 2", func() {
|
||||
view(100, 100, newClock(), func(c *ui.Context) { chart.ProgressRing(c, "x", 2, chart.ProgressRingOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestGaugeNeedlePoints(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
v float64
|
||||
x, y int
|
||||
}{{0, 6, 32}, {100, 58, 32}, {50, 32, 6}} {
|
||||
tt := view(200, 100, newClock(), func(c *ui.Context) { chart.Gauge(c, "Speed", tc.v, chart.GaugeOptions{}) })
|
||||
if s := at(t, tt.Image(), tc.x, tc.y); s != pixel.Ink {
|
||||
t.Fatalf("value %v: no needle at %d,%d (%v)", tc.v, tc.x, tc.y, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRadarChartReachesTheRim(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
axes []string
|
||||
rim int
|
||||
}{
|
||||
// 11-dot labels on the 60° axes leave a 30 rim.
|
||||
{[]string{"STR", "DEX", "INT", "VIT", "AGI", "LUK"}, 30},
|
||||
// Pixel-font labels, 24 wide, shrink it to 15.
|
||||
{[]string{"力量", "敏捷", "智力", "体力", "速度", "运气"}, 15},
|
||||
} {
|
||||
tt := view(200, 200, newClock(), func(c *ui.Context) {
|
||||
chart.RadarChart(c, "Hero", tc.axes, []float64{10, 5, 5, 5, 5, 5}, chart.RadarChartOptions{Slot: pixel.Magic})
|
||||
})
|
||||
if top, _ := column(t, tt.Image(), 40, 80, pixel.Magic); top != 40-tc.rim-1 {
|
||||
t.Fatalf("%s: the largest value's marker tops row %d", tc.axes[0], top)
|
||||
}
|
||||
keeps(t, tt.Image())
|
||||
}
|
||||
panics(t, "a value above Max", func() {
|
||||
view(200, 200, newClock(), func(c *ui.Context) {
|
||||
chart.RadarChart(c, "x", []string{"A", "B", "C"}, []float64{1, 2, 12}, chart.RadarChartOptions{Max: 10})
|
||||
})
|
||||
})
|
||||
panics(t, "labels too wide for the size", func() {
|
||||
view(200, 200, newClock(), func(c *ui.Context) {
|
||||
chart.RadarChart(c, "x", []string{"STRENGTH", "B", "C"}, []float64{1, 2, 3}, chart.RadarChartOptions{Size: 30})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestHeatmapsShadeByLevel(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.Heatmap(c, "Heat", []string{"A"}, []string{"X", "Y"}, [][]float64{{0, 10}}, chart.HeatmapOptions{})
|
||||
chart.CalendarHeatmap(c, "Raids", 2026, func(d time.Time) int {
|
||||
if d.Month() == time.January {
|
||||
return 4
|
||||
}
|
||||
return 0
|
||||
}, chart.CalendarHeatmapOptions{})
|
||||
})
|
||||
img := tt.Image()
|
||||
r := find(t, tt, "Heat")
|
||||
ox, oy := int(r.X)/2, int(r.Y)/2
|
||||
// Labels take six dots left, eight on top.
|
||||
low, high := count(t, img, ox+6, oy+8, 9, 9, pixel.Bad), count(t, img, ox+16, oy+8, 9, 9, pixel.Bad)
|
||||
if low != 0 || high != 81 {
|
||||
t.Fatalf("cells of 0 and 10 hold %d and %d Bad dots", low, high)
|
||||
}
|
||||
cal := find(t, tt, "Raids 2026")
|
||||
cx, cy := int(cal.X)/2, int(cal.Y)/2
|
||||
if jan, jun := count(t, img, cx, cy, 16, 28, pixel.Good), count(t, img, cx+100, cy, 16, 28, pixel.Good); jan == 0 || jun != 0 {
|
||||
t.Fatalf("January lit %d dots, June %d", jan, jun)
|
||||
}
|
||||
panics(t, "a level of 5", func() {
|
||||
view(400, 100, newClock(), func(c *ui.Context) {
|
||||
chart.CalendarHeatmap(c, "x", 2026, func(time.Time) int { return 5 }, chart.CalendarHeatmapOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestTreemapAreasFollowValues(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.Treemap(c, "Bag", []chart.Slice{{Name: "a", Value: 1, Slot: pixel.Info}, {Name: "b", Value: 3, Slot: pixel.Warn}}, chart.TreemapOptions{})
|
||||
})
|
||||
img := tt.Image()
|
||||
a, b := count(t, img, 0, 0, 160, 80, pixel.Info), count(t, img, 0, 0, 160, 80, pixel.Warn)
|
||||
if b < 3*a-200 || b > 3*a+200 {
|
||||
t.Fatalf("treemap dots %d and %d for 1:3", a, b)
|
||||
}
|
||||
keeps(t, img)
|
||||
}
|
||||
|
||||
func TestBulletFunnelCandlesBoxes(t *testing.T) {
|
||||
tt := view(400, 400, newClock(), func(c *ui.Context) {
|
||||
chart.BulletChart(c, "Goal", 60, 80, []float64{50, 100}, chart.BulletChartOptions{Width: 100})
|
||||
chart.Funnel(c, "Sales", []chart.Slice{{Name: "SEEN", Value: 100, Slot: pixel.Info}, {Name: "BOUGHT", Value: 25, Slot: pixel.Info}}, chart.FunnelOptions{Width: 100})
|
||||
chart.Candlestick(c, "Gold", []chart.Candle{{Label: "A", Open: 10, High: 20, Low: 5, Close: 15}, {Label: "B", Open: 15, High: 16, Low: 2, Close: 4}}, chart.CandlestickOptions{})
|
||||
})
|
||||
img := tt.Image()
|
||||
if bar := count(t, img, 0, 7, 100, 1, pixel.Ink); bar < 58 || bar > 62 {
|
||||
t.Fatalf("a 60 of 100 bar is %d dots", bar)
|
||||
}
|
||||
if mark := at(t, img, 79, 7); mark != pixel.Accent {
|
||||
t.Fatalf("no target mark at 80: %v", mark)
|
||||
}
|
||||
f := find(t, tt, "Sales")
|
||||
fy := int(f.Y) / 2
|
||||
if top, bottom := count(t, img, 0, fy+2, 100, 1, pixel.Info), count(t, img, 0, fy+18, 100, 1, pixel.Info); top < 3*bottom {
|
||||
t.Fatalf("funnel rows %d and %d dots", top, bottom)
|
||||
}
|
||||
cs := find(t, tt, "Gold")
|
||||
cx, cy := int(cs.X)/2, int(cs.Y)/2
|
||||
if count(t, img, cx, cy, 80, 80, pixel.Good) == 0 || count(t, img, cx+80, cy, 80, 80, pixel.Bad) == 0 {
|
||||
t.Fatal("candles are not inked by direction")
|
||||
}
|
||||
keeps(t, img)
|
||||
panics(t, "a candle closing above its high", func() {
|
||||
view(200, 100, newClock(), func(c *ui.Context) {
|
||||
chart.Candlestick(c, "x", []chart.Candle{{Open: 1, High: 2, Low: 0, Close: 3}}, chart.CandlestickOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestBoxPlotMedianAndWhiskers(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.BoxPlot(c, "Rolls", []chart.Series{{Name: "D6", Values: []float64{1, 2, 3, 4, 5, 6, 7}}}, chart.BoxPlotOptions{})
|
||||
})
|
||||
img := tt.Image()
|
||||
if count(t, img, 0, 0, 160, 80, pixel.Accent) == 0 || count(t, img, 0, 0, 160, 80, pixel.Edge) < 40 {
|
||||
t.Fatal("no box or no whiskers")
|
||||
}
|
||||
keeps(t, img)
|
||||
}
|
||||
|
||||
func TestLegendTogglesSeries(t *testing.T) {
|
||||
hidden, changes := []bool{false, false}, 0
|
||||
disabled, inert := false, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Inert: inert})
|
||||
if chart.Legend(c, "Series", hp, hidden, chart.LegendOptions{Disabled: disabled}).Changed {
|
||||
changes++
|
||||
}
|
||||
}, 300, 60)
|
||||
if err := tt.Click("MP"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if !hidden[0] || !hidden[1] || changes != 2 {
|
||||
t.Fatalf("click MP, Left, Space left %v", hidden)
|
||||
}
|
||||
keeps(t, tt.Image())
|
||||
for name, set := range map[string]func(bool){"disabled": func(b bool) { disabled = b }, "inert": func(b bool) { inert = b }} {
|
||||
set(true)
|
||||
tt.Frame()
|
||||
tt.Click("HP")
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if !hidden[0] || changes != 2 {
|
||||
t.Fatalf("a %s legend toggled", name)
|
||||
}
|
||||
set(false)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScatterAndBubblesPlacePoints(t *testing.T) {
|
||||
tt := view(400, 400, newClock(), func(c *ui.Context) {
|
||||
chart.ScatterPlot(c, "Hits", []chart.Point{{X: 1, Y: 1}, {X: 5, Y: 5}, {X: 9, Y: 9}}, chart.ScatterPlotOptions{Slot: pixel.Magic})
|
||||
chart.BubbleChart(c, "Towns", []chart.Point{{X: 0, Y: 5, Size: 1}, {X: 10, Y: 5, Size: 4}}, chart.BubbleChartOptions{Slot: pixel.Info})
|
||||
})
|
||||
img := tt.Image()
|
||||
if n := count(t, img, 0, 0, 160, 80, pixel.Magic); n != 12 {
|
||||
t.Fatalf("three points drew %d dots", n)
|
||||
}
|
||||
// Each point sits up and right of the one before.
|
||||
var xs, ys []int
|
||||
for y := range 80 {
|
||||
for x := range 160 {
|
||||
if at(t, img, x, y) == pixel.Magic && (len(xs) == 0 || abs(xs[len(xs)-1]-x) > 2) {
|
||||
xs, ys = append(xs, x), append(ys, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(xs) != 3 || !(xs[0] > xs[1] && xs[1] > xs[2] && ys[0] < ys[1] && ys[1] < ys[2]) {
|
||||
t.Fatalf("points at x %v, y %v", xs, ys)
|
||||
}
|
||||
small, big := count(t, img, 0, 80, 80, 80, pixel.Info), count(t, img, 80, 80, 80, 80, pixel.Info)
|
||||
// Area follows size: 2–4× the dots for 4× the size.
|
||||
if small == 0 || big < 2*small || big > 4*small {
|
||||
t.Fatalf("bubbles of size 1 and 4 hold %d and %d dots", small, big)
|
||||
}
|
||||
keeps(t, img)
|
||||
panics(t, "a negative bubble", func() {
|
||||
view(200, 100, newClock(), func(c *ui.Context) {
|
||||
chart.BubbleChart(c, "x", []chart.Point{{Size: -1}}, chart.BubbleChartOptions{})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestPyramidAndWinLoss(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.Pyramid(c, "Party", []string{"KIDS"}, chart.Series{Name: "BOYS", Values: []float64{10}, Slot: pixel.Info},
|
||||
chart.Series{Name: "GIRLS", Values: []float64{5}, Slot: pixel.Warn}, chart.PyramidOptions{Width: 100})
|
||||
chart.WinLoss(c, "Season", []int{1, -1, 0}, chart.WinLossOptions{})
|
||||
})
|
||||
img := tt.Image()
|
||||
left, right := count(t, img, 0, 20, 50, 4, pixel.Info), count(t, img, 50, 20, 50, 4, pixel.Warn)
|
||||
if left == 0 || right == 0 || left < 2*right-8 {
|
||||
t.Fatalf("pyramid bars %d and %d for 10 and 5", left, right)
|
||||
}
|
||||
w := find(t, tt, "Season")
|
||||
wx, wy := int(w.X)/2, int(w.Y)/2
|
||||
if at(t, img, wx+1, wy+4) != pixel.Good || at(t, img, wx+6, wy+11) != pixel.Bad || at(t, img, wx+11, wy+8) != pixel.Faint {
|
||||
t.Fatal("win, loss and draw blocks are misplaced")
|
||||
}
|
||||
panics(t, "a result of 2", func() {
|
||||
view(100, 50, newClock(), func(c *ui.Context) { chart.WinLoss(c, "x", []int{2}, chart.WinLossOptions{}) })
|
||||
})
|
||||
}
|
||||
|
||||
func TestAxisMarksTicksOrLabels(t *testing.T) {
|
||||
tt := view(400, 200, newClock(), func(c *ui.Context) {
|
||||
chart.Axis(c, chart.AxisOptions{})
|
||||
chart.Axis(c, chart.AxisOptions{Labels: []string{"LOW", "HIGH"}, Vertical: true, Length: 40})
|
||||
chart.Axis(c, chart.AxisOptions{Max: 250})
|
||||
})
|
||||
if !tt.HasText("0–100") || !tt.HasText("LOW–HIGH") || !tt.HasText("0–250") {
|
||||
t.Fatalf("texts %q", tt.Texts())
|
||||
}
|
||||
keeps(t, tt.Image())
|
||||
panics(t, "one mark", func() {
|
||||
view(100, 50, newClock(), func(c *ui.Context) { chart.Axis(c, chart.AxisOptions{Labels: []string{"A"}}) })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type SparklineOptions struct {
|
||||
// Width is in dots; zero means 48.
|
||||
Width int
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// Sparkline is a line one text line high, end marked.
|
||||
func Sparkline(c *ui.Context, text string, values []float64, o SparklineOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
w := pixel.Or("Sparkline Width", o.Width, 48)
|
||||
if len(values) < 2 {
|
||||
panic("chart: Sparkline needs two values or more")
|
||||
}
|
||||
lo, hi := span(values)
|
||||
if hi == lo {
|
||||
hi = lo + 1
|
||||
}
|
||||
ink := pick(o.Slot, 0)
|
||||
at := func(i int) (int, int) {
|
||||
return i * (w - 2) / (len(values) - 1), pixel.LineHeight - 3 - int((values[i]-lo)/(hi-lo)*float64(pixel.LineHeight-6))
|
||||
}
|
||||
return ui.Box(c).Size(k.D(w), k.D(pixel.LineHeight)).Role(ui.RoleImage).Label(text).Description(nums(values)).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(pt, r)
|
||||
for i := 1; i < len(values); i++ {
|
||||
x0, y0 := at(i - 1)
|
||||
x1, y1 := at(i)
|
||||
g.Line(x0, y0, x1, y1, ink)
|
||||
}
|
||||
x, y := at(len(values) - 1)
|
||||
g.Rect(x, y-1, 2, 2, pixel.Accent)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type SpectrumBarsOptions struct {
|
||||
// Segments is each bar's height in blocks; zero means 10.
|
||||
Segments int
|
||||
}
|
||||
|
||||
type peaksKey struct{}
|
||||
|
||||
// peaks are the falling dots, when built last, whether falling.
|
||||
type peaks struct {
|
||||
at []float64
|
||||
last time.Time
|
||||
moving bool
|
||||
}
|
||||
|
||||
// SpectrumBars lights segmented bars; peak dots fall behind them.
|
||||
func SpectrumBars(c *ui.Context, text string, levels []float64, o SpectrumBarsOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
n := pixel.Or("SpectrumBars Segments", o.Segments, 10)
|
||||
for _, l := range levels {
|
||||
if !(l >= 0 && l <= 1) {
|
||||
panic(fmt.Sprintf("chart: SpectrumBars level %v outside 0–1", l))
|
||||
}
|
||||
}
|
||||
e := ui.Box(c).Size(k.D(len(levels)*6), k.D(n*3)).Role(ui.RoleImage).Label(text).Description(nums(levels))
|
||||
pk := ui.Local(e, peaksKey{}, func() peaks { return peaks{} })
|
||||
if len(pk.at) != len(levels) {
|
||||
pk.at = append([]float64(nil), levels...)
|
||||
}
|
||||
now := k.Now()
|
||||
// A peak falls a bar a second; reduced, it rests.
|
||||
fall := now.Sub(pk.last).Seconds()
|
||||
switch {
|
||||
case pk.last.IsZero() || k.Reduce():
|
||||
fall = 1
|
||||
case !pk.moving:
|
||||
// Resting peaks start falling now, not at the last build.
|
||||
fall = 0
|
||||
}
|
||||
pk.last, pk.moving = now, false
|
||||
for i, l := range levels {
|
||||
pk.at[i] = max(l, pk.at[i]-fall)
|
||||
pk.moving = pk.moving || pk.at[i] > l
|
||||
}
|
||||
if pk.moving {
|
||||
k.Tick(c, 20)
|
||||
}
|
||||
shown := append([]float64(nil), pk.at...)
|
||||
return e.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for i, l := range levels {
|
||||
lit := int(l*float64(n) + 0.5)
|
||||
for s := range n {
|
||||
y := (n - 1 - s) * 3
|
||||
ink := pixel.Good
|
||||
switch {
|
||||
case s >= n*8/10:
|
||||
ink = pixel.Bad
|
||||
case s >= n*6/10:
|
||||
ink = pixel.Warn
|
||||
}
|
||||
if s < lit {
|
||||
g.Rect(i*6, y, 5, 2, ink)
|
||||
} else {
|
||||
g.Checker(i*6, y, 5, 2, pixel.Shade)
|
||||
}
|
||||
}
|
||||
if top := int(shown[i]*float64(n) + 0.5); top > 0 {
|
||||
g.Rect(i*6, (n-top)*3, 5, 1, pixel.Ink)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type StackedBarOptions struct {
|
||||
// Width and Height are dots; zeros mean 160 by 80.
|
||||
Width, Height int
|
||||
}
|
||||
|
||||
// StackedBar stacks each category's series, one slot per series.
|
||||
func StackedBar(c *ui.Context, text string, labels []string, series []Series, o StackedBarOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if len(labels) == 0 {
|
||||
panic("chart: StackedBar has no labels")
|
||||
}
|
||||
w, h := size("StackedBar", o.Width, o.Height, 160, 80)
|
||||
totals := make([]float64, len(labels))
|
||||
for _, s := range series {
|
||||
if len(s.Values) != len(labels) {
|
||||
panic(fmt.Sprintf("chart: series %q has %d values for %d labels", s.Name, len(s.Values), len(labels)))
|
||||
}
|
||||
for i, v := range s.Values {
|
||||
if v < 0 {
|
||||
panic(fmt.Sprintf("chart: series %q stacks a negative %v", s.Name, v))
|
||||
}
|
||||
totals[i] += v
|
||||
}
|
||||
}
|
||||
_, hi := span(totals)
|
||||
p := fit(0, hi, w, h, labels)
|
||||
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(readSeries(labels, series)).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(pt, r)
|
||||
p.grid(g, labels)
|
||||
bw := max(1, p.w/len(labels)*2/3)
|
||||
for i := range labels {
|
||||
x, base := p.slot(i, len(labels))-bw/2, 0.0
|
||||
for j, s := range series {
|
||||
lo, top := p.at(base), p.at(base+s.Values[i])
|
||||
g.Rect(x, top, bw, lo-top, pick(s.Slot, j))
|
||||
base += s.Values[i]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type StepChartOptions struct {
|
||||
// Width and Height are dots; zeros mean 160 by 80.
|
||||
Width, Height int
|
||||
Slot pixel.Slot
|
||||
}
|
||||
|
||||
// StepChart holds each value flat, then steps to the next.
|
||||
func StepChart(c *ui.Context, text string, labels []string, values []float64, o StepChartOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
w, h := size("StepChart", o.Width, o.Height, 160, 80)
|
||||
p := fitSeries(labels, []Series{{Name: text, Values: values}}, w, h)
|
||||
ink := pick(o.Slot, 0)
|
||||
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(read(labels, values)).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(pt, r)
|
||||
p.grid(g, labels)
|
||||
n := len(values)
|
||||
for i, v := range values {
|
||||
x0, x1, y := p.x+i*p.w/n, p.x+(i+1)*p.w/n, p.at(v)
|
||||
g.HLine(x0, y, x1-x0, ink)
|
||||
if i+1 < n {
|
||||
y1 := p.at(values[i+1])
|
||||
g.VLine(x1, min(y, y1), abs(y-y1)+1, ink)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type TreemapOptions struct {
|
||||
// Width and Height are dots; zeros mean 160 by 80.
|
||||
Width, Height int
|
||||
}
|
||||
|
||||
// box is a laid-out rectangle in dots.
|
||||
type box struct{ x, y, w, h int }
|
||||
|
||||
// Treemap tiles slices by value in squarified rectangles.
|
||||
func Treemap(c *ui.Context, text string, slices []Slice, o TreemapOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
w, h := size("Treemap", o.Width, o.Height, 160, 80)
|
||||
total, _ := shares(slices)
|
||||
// Largest first keeps rows square; empty slices take no room.
|
||||
var order []int
|
||||
for i, s := range slices {
|
||||
if s.Value > 0 {
|
||||
order = append(order, i)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(order, func(a, b int) bool { return slices[order[a]].Value > slices[order[b]].Value })
|
||||
areas := make([]float64, len(order))
|
||||
for i, j := range order {
|
||||
areas[i] = slices[j].Value / total * float64(w*h)
|
||||
}
|
||||
placed := squarify(areas, box{0, 0, w, h})
|
||||
boxes := make([]box, len(slices))
|
||||
for i, j := range order {
|
||||
boxes[j] = placed[i]
|
||||
}
|
||||
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(readSlices(slices)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
for i, b := range boxes {
|
||||
if b.w < 2 || b.h < 2 {
|
||||
continue
|
||||
}
|
||||
g.Rect(b.x, b.y, b.w-1, b.h-1, pick(slices[i].Slot, i))
|
||||
name := slices[i].Name
|
||||
if sm := small(name); markW(name, sm)+4 <= b.w && inkH(sm)+4 <= b.h {
|
||||
mark(g, b.x+2, b.y+2, name, sm, pixel.AccentInk)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// squarify lays areas into b in rows kept near square.
|
||||
func squarify(areas []float64, b box) []box {
|
||||
out := make([]box, 0, len(areas))
|
||||
for len(areas) > 0 {
|
||||
short := float64(min(b.w, b.h))
|
||||
n, worst := 1, aspect(areas[:1], short)
|
||||
for n < len(areas) {
|
||||
if next := aspect(areas[:n+1], short); next <= worst {
|
||||
n, worst = n+1, next
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
sum := 0.0
|
||||
for _, a := range areas[:n] {
|
||||
sum += a
|
||||
}
|
||||
// A row spans the short side; depth gives its area.
|
||||
if b.w >= b.h {
|
||||
depth := min(b.w, int(sum/float64(b.h)+0.5))
|
||||
if n == len(areas) {
|
||||
depth = b.w
|
||||
}
|
||||
y := b.y
|
||||
for i, a := range areas[:n] {
|
||||
hh := int(a/sum*float64(b.h) + 0.5)
|
||||
if i == n-1 {
|
||||
hh = b.y + b.h - y
|
||||
}
|
||||
out, y = append(out, box{b.x, y, depth, hh}), y+hh
|
||||
}
|
||||
b = box{b.x + depth, b.y, b.w - depth, b.h}
|
||||
} else {
|
||||
depth := min(b.h, int(sum/float64(b.w)+0.5))
|
||||
if n == len(areas) {
|
||||
depth = b.h
|
||||
}
|
||||
x := b.x
|
||||
for i, a := range areas[:n] {
|
||||
ww := int(a/sum*float64(b.w) + 0.5)
|
||||
if i == n-1 {
|
||||
ww = b.x + b.w - x
|
||||
}
|
||||
out, x = append(out, box{x, b.y, ww, depth}), x+ww
|
||||
}
|
||||
b = box{b.x, b.y + depth, b.w, b.h - depth}
|
||||
}
|
||||
areas = areas[n:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// aspect is a row's worst ratio laid along side.
|
||||
func aspect(row []float64, side float64) float64 {
|
||||
sum, lo, hi := 0.0, row[0], row[0]
|
||||
for _, a := range row {
|
||||
sum, lo, hi = sum+a, min(lo, a), max(hi, a)
|
||||
}
|
||||
s2 := side * side
|
||||
return max(s2*hi/(sum*sum), sum*sum/(s2*lo))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type WaterfallOptions struct {
|
||||
// Width and Height are dots; zeros mean 160 by 80.
|
||||
Width, Height int
|
||||
}
|
||||
|
||||
// Waterfall walks a running total: Good up, Bad down.
|
||||
func Waterfall(c *ui.Context, text string, labels []string, deltas []float64, o WaterfallOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
if len(labels) != len(deltas) || len(deltas) == 0 {
|
||||
panic(fmt.Sprintf("chart: Waterfall has %d labels for %d steps", len(labels), len(deltas)))
|
||||
}
|
||||
w, h := size("Waterfall", o.Width, o.Height, 160, 80)
|
||||
totals := make([]float64, len(deltas)+1)
|
||||
for i, d := range deltas {
|
||||
totals[i+1] = totals[i] + d
|
||||
}
|
||||
lo, hi := span(totals)
|
||||
p := fit(lo, hi, w, h, labels)
|
||||
return ui.Box(c).Size(k.D(w), k.D(h)).Role(ui.RoleImage).Label(text).Description(read(labels, deltas)).Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(pt, r)
|
||||
p.grid(g, labels)
|
||||
bw := max(1, p.w/len(deltas)*2/3)
|
||||
for i, d := range deltas {
|
||||
a, b := p.at(totals[i]), p.at(totals[i+1])
|
||||
ink := pixel.Good
|
||||
if d < 0 {
|
||||
ink = pixel.Bad
|
||||
}
|
||||
x := p.slot(i, len(deltas)) - bw/2
|
||||
g.Rect(x, min(a, b), bw, max(1, abs(a-b)), ink)
|
||||
if i+1 < len(deltas) {
|
||||
g.Checker(x+bw, b, p.slot(i+1, len(deltas))-bw/2-x-bw, 1, pixel.Edge)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package chart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
type WinLossOptions struct{}
|
||||
|
||||
// WinLoss rows blocks: wins up, losses down, draws dashed.
|
||||
func WinLoss(c *ui.Context, text string, results []int, o WinLossOptions) *ui.Element {
|
||||
k := pixel.K(c)
|
||||
return ui.Box(c).Size(k.D(len(results)*5), k.D(pixel.LineHeight)).Role(ui.RoleImage).Label(text).
|
||||
Description(record(results)).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
g := k.Canvas(p, r)
|
||||
mid := pixel.LineHeight / 2
|
||||
for i, res := range results {
|
||||
x := i * 5
|
||||
switch res {
|
||||
case 1:
|
||||
g.Rect(x, mid-6, 4, 5, pixel.Good)
|
||||
case -1:
|
||||
g.Rect(x, mid+1, 4, 5, pixel.Bad)
|
||||
default:
|
||||
g.Rect(x, mid-1, 4, 2, pixel.Faint)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// record is "wins-losses-draws: " then each result, checked.
|
||||
func record(results []int) string {
|
||||
var n [3]int
|
||||
all := make([]float64, len(results))
|
||||
for i, r := range results {
|
||||
if r < -1 || r > 1 {
|
||||
panic(fmt.Sprintf("chart: WinLoss result %d is not -1, 0 or 1", r))
|
||||
}
|
||||
n[1-r]++
|
||||
all[i] = float64(r)
|
||||
}
|
||||
return fmt.Sprintf("%d-%d-%d: %s", n[0], n[2], n[1], nums(all))
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
func TestCalendarDemoReadsLocalDays(t *testing.T) {
|
||||
ny, err := time.LoadLocation("America/New_York")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 21:00 in New York is already the 14th in UTC.
|
||||
now := time.Date(2026, 10, 13, 21, 0, 0, 0, ny)
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return now }})
|
||||
demoCalendarHeatmap(c, func(string) {})
|
||||
}, 800, 200)
|
||||
r, ok := tt.Find("Play days 2026")
|
||||
if !ok {
|
||||
t.Fatalf("no heatmap in %q", tt.Texts())
|
||||
}
|
||||
// Oct 14 sits at week 41, Wednesday, after four blanks.
|
||||
x, y := int(r.X)/2+41*5, int(r.Y)/2+8+3*5
|
||||
img, shade := tt.Image(), pixel.SpringDay.Colors[pixel.Shade]
|
||||
for j := y; j < y+4; j++ {
|
||||
for i := x; i < x+4; i++ {
|
||||
if c := img.RGBAAt(2*i, 2*j); c.R != shade.R || c.G != shade.G || c.B != shade.B {
|
||||
t.Fatalf("tomorrow's square holds %v at %d,%d", c, i, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chart"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
var chartEntries = []Entry{
|
||||
{216, "BarChart", "柱状图", "chart", "Gold earned per day. Point at a bar to read it.", demoBarChart},
|
||||
{217, "StackedBar", "堆叠柱图", "chart", "Gold under gems, stacked day by day.", demoStackedBar},
|
||||
{218, "HorizontalBar", "条形图", "chart", "Slimes slain, by hero. Brom is not sharing.", demoHorizontalBar},
|
||||
{219, "LineChart", "折线图", "chart", "HP and MP down a dungeon, joined dot by dot.", demoLineChart},
|
||||
{220, "AreaChart", "面积图", "chart", "Gold over a week; the ground under the line dithers.", demoAreaChart},
|
||||
{221, "Sparkline", "迷你图", "chart", "A chart one text line tall. It fits in a sentence.", demoSparkline},
|
||||
{222, "PieChart", "饼图", "chart", "Where the loot went, sector by sector, dot by dot.", demoPieChart},
|
||||
{223, "DonutChart", "环形图", "chart", "A pie with a hole; the total waits inside.", demoDonutChart},
|
||||
{224, "RadarChart", "雷达图", "chart", "Six stats on a hexagon web. LEVEL UP and watch it grow.", demoRadarChart},
|
||||
{225, "Histogram", "直方图", "chart", "Damage rolls in bins. ROLL throws ten more.", demoHistogram},
|
||||
{226, "ScatterPlot", "散点图", "chart", "Every hit by distance and damage, one 2×2 dot each.", demoScatterPlot},
|
||||
{227, "BubbleChart", "气泡图", "chart", "Towns by trade and danger, sized by how many live there.", demoBubbleChart},
|
||||
{228, "Heatmap", "热力图", "chart", "Raids by day and hour. Hot cells dither red.", demoHeatmap},
|
||||
{229, "CalendarHeatmap", "日历热力图", "chart", "A year of play, a square a day. A commit graph for slimes.", demoCalendarHeatmap},
|
||||
{230, "Gauge", "仪表", "chart", "Push the throttle; the needle follows.", demoGauge},
|
||||
}
|
||||
|
||||
type chartDemo struct {
|
||||
stats []float64
|
||||
rolls []float64
|
||||
dice *rand.Rand
|
||||
throttle float64
|
||||
hidden []bool
|
||||
since time.Time
|
||||
play bool
|
||||
matches []int
|
||||
}
|
||||
|
||||
func chartState(c *ui.Context) *chartDemo {
|
||||
return ui.Local(c.Root(), demoKey("chart"), func() chartDemo {
|
||||
s := chartDemo{stats: []float64{4, 3, 2, 4, 3, 2}, dice: rand.New(rand.NewPCG(1, 2)), throttle: 40,
|
||||
hidden: []bool{false, false, false}, play: true, matches: []int{1, 1, -1, 1, 0, -1, 1, 1}}
|
||||
for range 30 {
|
||||
s.rolls = append(s.rolls, roll(s.dice))
|
||||
}
|
||||
return s
|
||||
})
|
||||
}
|
||||
|
||||
// roll throws two dice.
|
||||
func roll(r *rand.Rand) float64 { return float64(r.IntN(6) + r.IntN(6) + 2) }
|
||||
|
||||
var week = []string{"MON", "TUE", "WED", "THU", "FRI"}
|
||||
|
||||
// heroes are three HP series with fixed colors.
|
||||
var heroes = []chart.Series{
|
||||
{Name: "Aria", Values: []float64{30, 26, 28, 18, 24}, Slot: pixel.Good},
|
||||
{Name: "Brom", Values: []float64{40, 34, 22, 26, 12}, Slot: pixel.Warn},
|
||||
{Name: "Cid", Values: []float64{18, 20, 16, 22, 20}, Slot: pixel.Info},
|
||||
}
|
||||
|
||||
func demoBarChart(c *ui.Context, log func(string)) {
|
||||
chart.BarChart(c, "Gold per day", week, []float64{120, 340, 90, 260, 410}, chart.BarChartOptions{Width: 200, Height: 90})
|
||||
}
|
||||
|
||||
func demoStackedBar(c *ui.Context, log func(string)) {
|
||||
chart.StackedBar(c, "Loot per day", week, []chart.Series{
|
||||
{Name: "Gold", Values: []float64{12, 30, 8, 22, 18}, Slot: pixel.Warn},
|
||||
{Name: "Gems", Values: []float64{4, 6, 12, 2, 9}, Slot: pixel.Magic},
|
||||
}, chart.StackedBarOptions{Width: 200, Height: 90})
|
||||
}
|
||||
|
||||
func demoHorizontalBar(c *ui.Context, log func(string)) {
|
||||
chart.HorizontalBar(c, "Slimes slain", []string{"Aria", "Brom", "Cid", "Dax"}, []float64{48, 112, 37, 64}, chart.HorizontalBarOptions{Width: 200})
|
||||
}
|
||||
|
||||
func demoLineChart(c *ui.Context, log func(string)) {
|
||||
floors := []string{"B1", "B2", "B3", "B4", "B5", "B6"}
|
||||
chart.LineChart(c, "Dungeon run", floors, []chart.Series{
|
||||
{Name: "HP", Values: []float64{50, 44, 47, 30, 36, 12}, Slot: pixel.Good},
|
||||
{Name: "MP", Values: []float64{20, 16, 8, 12, 4, 6}, Slot: pixel.Info},
|
||||
}, chart.LineChartOptions{Width: 200, Height: 90})
|
||||
}
|
||||
|
||||
func demoAreaChart(c *ui.Context, log func(string)) {
|
||||
chart.AreaChart(c, "Gold", week, []float64{120, 180, 150, 260, 310}, chart.AreaChartOptions{Width: 200, Height: 90, Slot: pixel.Warn})
|
||||
}
|
||||
|
||||
func demoSparkline(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Column(c).Gap(k.D(2)).Children(func() {
|
||||
for _, r := range []struct {
|
||||
name, now string
|
||||
values []float64
|
||||
slot pixel.Slot
|
||||
}{
|
||||
{"Ping", "42 ms", []float64{40, 44, 39, 61, 45, 42, 38, 42}, pixel.Info},
|
||||
{"FPS", "60", []float64{60, 60, 58, 41, 59, 60, 60, 60}, pixel.Good},
|
||||
{"Gold", "1.2K", []float64{300, 420, 380, 650, 700, 980, 1100, 1200}, pixel.Warn},
|
||||
} {
|
||||
ui.Row(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() {
|
||||
ui.Box(c).Width(k.D(36)).Children(func() { pixel.Text(c, r.name, pixel.TextOptions{Slot: pixel.Faint}) })
|
||||
chart.Sparkline(c, r.name, r.values, chart.SparklineOptions{Slot: r.slot})
|
||||
pixel.Text(c, r.now, pixel.TextOptions{})
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoPieChart(c *ui.Context, log func(string)) {
|
||||
chart.PieChart(c, "Loot", []chart.Slice{{Name: "Gold", Value: 52, Slot: pixel.Warn}, {Name: "Gems", Value: 23, Slot: pixel.Magic},
|
||||
{Name: "Potions", Value: 15, Slot: pixel.Bad}, {Name: "Junk", Value: 10, Slot: pixel.Faint}}, chart.PieChartOptions{Size: 80})
|
||||
}
|
||||
|
||||
func demoDonutChart(c *ui.Context, log func(string)) {
|
||||
chart.DonutChart(c, "Party gold", []chart.Slice{{Name: "Aria", Value: 120}, {Name: "Brom", Value: 340}, {Name: "Cid", Value: 90}}, chart.DonutChartOptions{Size: 80})
|
||||
}
|
||||
|
||||
func demoRadarChart(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := chartState(c)
|
||||
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
|
||||
chart.RadarChart(c, "Hero", []string{"STR", "DEX", "INT", "VIT", "AGI", "LUK"}, s.stats, chart.RadarChartOptions{Max: 10, Size: 96, Slot: pixel.Accent})
|
||||
maxed := slices.Min(s.stats) >= 10
|
||||
if input.Button(c, "LEVEL UP", input.ButtonOptions{Disabled: maxed}).Clicked {
|
||||
for i := range s.stats {
|
||||
s.stats[i] = min(10, s.stats[i]+float64(1+i%2))
|
||||
}
|
||||
log(fmt.Sprintf("STR %g, DEX %g", s.stats[0], s.stats[1]))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoHistogram(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := chartState(c)
|
||||
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
|
||||
chart.Histogram(c, "2d6 rolls", s.rolls, chart.HistogramOptions{Bins: 11, Width: 200, Height: 90})
|
||||
if input.Button(c, "ROLL", input.ButtonOptions{}).Clicked {
|
||||
for range 10 {
|
||||
s.rolls = append(s.rolls, roll(s.dice))
|
||||
}
|
||||
log(fmt.Sprintf("%d rolls", len(s.rolls)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoScatterPlot(c *ui.Context, log func(string)) {
|
||||
hits := []chart.Point{{X: 1, Y: 12}, {X: 2, Y: 10}, {X: 2, Y: 14}, {X: 3, Y: 9}, {X: 4, Y: 8}, {X: 5, Y: 8}, {X: 5, Y: 3},
|
||||
{X: 6, Y: 6}, {X: 7, Y: 5}, {X: 8, Y: 4}, {X: 8, Y: 9}, {X: 9, Y: 2}}
|
||||
chart.ScatterPlot(c, "Hits", hits, chart.ScatterPlotOptions{Width: 200, Height: 90, Slot: pixel.Bad})
|
||||
}
|
||||
|
||||
func demoBubbleChart(c *ui.Context, log func(string)) {
|
||||
towns := []chart.Point{{X: 2, Y: 8, Size: 120}, {X: 5, Y: 3, Size: 900}, {X: 7, Y: 6, Size: 300}, {X: 9, Y: 9, Size: 40}}
|
||||
chart.BubbleChart(c, "Towns", towns, chart.BubbleChartOptions{Width: 200, Height: 90, Slot: pixel.Info})
|
||||
}
|
||||
|
||||
func demoHeatmap(c *ui.Context, log func(string)) {
|
||||
chart.Heatmap(c, "Raids", []string{"MON", "TUE", "WED", "THU", "FRI", "SAT", "SUN"}, []string{"AM", "NOON", "PM", "NIGHT"}, [][]float64{
|
||||
{1, 2, 3, 6}, {0, 2, 4, 7}, {1, 1, 3, 5}, {0, 3, 5, 8}, {2, 4, 7, 12}, {5, 8, 10, 14}, {6, 7, 9, 4},
|
||||
}, chart.HeatmapOptions{Cell: 12})
|
||||
}
|
||||
|
||||
func demoCalendarHeatmap(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
now := k.Now()
|
||||
// Tomorrow's square lights up at midnight.
|
||||
k.At(c, time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()))
|
||||
// Days come as UTC dates; today is the local date.
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
chart.CalendarHeatmap(c, "Play days", now.Year(), func(d time.Time) int {
|
||||
if today.Before(d) {
|
||||
return 0
|
||||
}
|
||||
// Weekends run hotter; the rest is a made-up habit.
|
||||
n := (d.YearDay()*d.YearDay()*7 + d.YearDay()*3) % 10
|
||||
if d.Weekday() == time.Saturday || d.Weekday() == time.Sunday {
|
||||
n += 4
|
||||
}
|
||||
return min(4, n/3)
|
||||
}, chart.CalendarHeatmapOptions{})
|
||||
}
|
||||
|
||||
func demoGauge(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := chartState(c)
|
||||
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
|
||||
chart.Gauge(c, "Speed", s.throttle, chart.GaugeOptions{Size: 80})
|
||||
if input.Slider(c, "Throttle", &s.throttle, input.SliderOptions{Max: 100}).Changed {
|
||||
log(fmt.Sprintf("speed %.0f", s.throttle))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/chart"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input"
|
||||
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
||||
)
|
||||
|
||||
var plotEntries = []Entry{
|
||||
{231, "BulletChart", "子弹图", "chart", "Progress against a goal, over bands of poor, fine and great.", demoBulletChart},
|
||||
{232, "Funnel", "漏斗图", "chart", "From SEEN to BOUGHT at the item shop. Most just window-shop.", demoFunnel},
|
||||
{233, "Waterfall", "瀑布图", "chart", "Your wallet through a day: green steps up, red steps down.", demoWaterfall},
|
||||
{234, "Treemap", "矩形树图", "chart", "The bag by weight, tiled into near squares.", demoTreemap},
|
||||
{235, "Candlestick", "K线图", "chart", "Potion prices. Green days rose, red days fell.", demoCandlestick},
|
||||
{236, "BoxPlot", "箱线图", "chart", "Rolls by die: the middle half in the box, the ends on whiskers.", demoBoxPlot},
|
||||
{237, "StepChart", "阶梯图", "chart", "Hero level by week. It only ever steps.", demoStepChart},
|
||||
{238, "ProgressRing", "进度环", "chart", "Clockwise from twelve, a pixel arc at a time.", demoProgressRing},
|
||||
{239, "Legend", "图例", "chart", "Click a hero to hide or show their line.", demoLegend},
|
||||
{240, "Axis", "坐标轴", "chart", "Ticks and micro labels, across or down.", demoAxis},
|
||||
{241, "Crosshair", "十字线", "chart", "Hover the chart; the crosshair reads every line.", demoCrosshair},
|
||||
{242, "PolarArea", "极坐标图", "chart", "Equal turns, each reaching out by its value.", demoPolarArea},
|
||||
{243, "Pyramid", "金字塔图", "chart", "Heroes and monsters back to back, by level.", demoPyramid},
|
||||
{244, "SpectrumBars", "频谱条", "chart", "Bars dance in segments; peak dots fall behind them.", demoSpectrumBars},
|
||||
{245, "WinLoss", "胜负图", "chart", "Wins up, losses down, draws a dash. PLAY for one more.", demoWinLoss},
|
||||
}
|
||||
|
||||
func demoBulletChart(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Column(c).Gap(k.D(4)).Children(func() {
|
||||
pixel.Text(c, "Quest XP: 62 of 80", pixel.TextOptions{Slot: pixel.Faint})
|
||||
chart.BulletChart(c, "Quest XP", 62, 80, []float64{40, 70, 100}, chart.BulletChartOptions{Width: 200})
|
||||
})
|
||||
}
|
||||
|
||||
func demoFunnel(c *ui.Context, log func(string)) {
|
||||
chart.Funnel(c, "Item shop", []chart.Slice{{Name: "SEEN", Value: 120}, {Name: "CLICKED", Value: 64}, {Name: "IN CART", Value: 30},
|
||||
{Name: "BOUGHT", Value: 12}}, chart.FunnelOptions{Width: 200})
|
||||
}
|
||||
|
||||
func demoWaterfall(c *ui.Context, log func(string)) {
|
||||
chart.Waterfall(c, "Wallet", []string{"QUEST", "INN", "SHOP", "LOOT", "TAX"}, []float64{40, -8, -25, 18, -5}, chart.WaterfallOptions{Width: 200, Height: 90})
|
||||
}
|
||||
|
||||
func demoTreemap(c *ui.Context, log func(string)) {
|
||||
chart.Treemap(c, "Bag weight", []chart.Slice{{Name: "ARMOR", Value: 10}, {Name: "SWORD", Value: 8}, {Name: "SHIELD", Value: 6},
|
||||
{Name: "HERBS", Value: 2}, {Name: "KEYS", Value: 1}, {Name: "MAP", Value: 1}}, chart.TreemapOptions{Width: 200, Height: 90})
|
||||
}
|
||||
|
||||
func demoCandlestick(c *ui.Context, log func(string)) {
|
||||
chart.Candlestick(c, "Potion price", []chart.Candle{
|
||||
{Label: "MON", Open: 10, High: 14, Low: 9, Close: 13}, {Label: "TUE", Open: 13, High: 15, Low: 8, Close: 9},
|
||||
{Label: "WED", Open: 9, High: 12, Low: 7, Close: 11}, {Label: "THU", Open: 11, High: 18, Low: 11, Close: 17},
|
||||
{Label: "FRI", Open: 17, High: 17, Low: 12, Close: 13},
|
||||
}, chart.CandlestickOptions{Width: 200, Height: 90})
|
||||
}
|
||||
|
||||
func demoBoxPlot(c *ui.Context, log func(string)) {
|
||||
chart.BoxPlot(c, "Rolls", []chart.Series{
|
||||
{Name: "D6", Values: []float64{1, 2, 3, 3, 4, 5, 6}},
|
||||
{Name: "D8", Values: []float64{1, 3, 4, 5, 5, 7, 8}},
|
||||
{Name: "D12", Values: []float64{2, 4, 6, 7, 9, 11, 12}},
|
||||
}, chart.BoxPlotOptions{Width: 200, Height: 90})
|
||||
}
|
||||
|
||||
func demoStepChart(c *ui.Context, log func(string)) {
|
||||
chart.StepChart(c, "Level", []string{"W1", "W2", "W3", "W4", "W5", "W6"}, []float64{1, 3, 3, 4, 7, 8}, chart.StepChartOptions{Width: 200, Height: 90, Slot: pixel.Magic})
|
||||
}
|
||||
|
||||
func demoProgressRing(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := chartState(c)
|
||||
// The pretend download takes four seconds.
|
||||
done := k.Since(c, s.since, 10, 40)
|
||||
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
|
||||
chart.ProgressRing(c, "Download", float64(done)/40, chart.ProgressRingOptions{Size: 48})
|
||||
if input.Button(c, "DOWNLOAD", input.ButtonOptions{Disabled: done < 40}).Clicked {
|
||||
s.since = k.Now()
|
||||
log("downloading more RAM")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoLegend(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := chartState(c)
|
||||
var shown []chart.Series
|
||||
for i, h := range heroes {
|
||||
if !s.hidden[i] {
|
||||
shown = append(shown, h)
|
||||
}
|
||||
}
|
||||
ui.Column(c).Gap(k.D(4)).Children(func() {
|
||||
if chart.Legend(c, "Heroes", heroes, s.hidden, chart.LegendOptions{}).Changed {
|
||||
log(fmt.Sprintf("hidden %v", s.hidden))
|
||||
}
|
||||
chart.LineChart(c, "Hero HP", week, shown, chart.LineChartOptions{Width: 200, Height: 90})
|
||||
})
|
||||
}
|
||||
|
||||
func demoAxis(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
ui.Row(c).Gap(k.D(12)).AlignItems(ui.Center).Children(func() {
|
||||
chart.Axis(c, chart.AxisOptions{Labels: []string{"LOW", "MID", "HIGH"}, Vertical: true, Length: 60})
|
||||
chart.Axis(c, chart.AxisOptions{Min: 0, Max: 250, Length: 160})
|
||||
})
|
||||
}
|
||||
|
||||
func demoCrosshair(c *ui.Context, log func(string)) {
|
||||
chart.Crosshair(c, "Hero HP", week, heroes, chart.CrosshairOptions{Width: 200, Height: 90})
|
||||
}
|
||||
|
||||
func demoPolarArea(c *ui.Context, log func(string)) {
|
||||
chart.PolarArea(c, "Rain", []chart.Slice{{Name: "SPRING", Value: 6, Slot: pixel.Good}, {Name: "SUMMER", Value: 3, Slot: pixel.Warn},
|
||||
{Name: "AUTUMN", Value: 8, Slot: pixel.Bad}, {Name: "WINTER", Value: 4, Slot: pixel.Info}}, chart.PolarAreaOptions{Size: 80})
|
||||
}
|
||||
|
||||
func demoPyramid(c *ui.Context, log func(string)) {
|
||||
chart.Pyramid(c, "By level", []string{"LV 1-9", "LV 10-19", "LV 20-29", "LV 30+"},
|
||||
chart.Series{Name: "HEROES", Values: []float64{40, 26, 12, 4}, Slot: pixel.Info},
|
||||
chart.Series{Name: "MONSTERS", Values: []float64{60, 30, 18, 9}, Slot: pixel.Bad}, chart.PyramidOptions{Width: 220})
|
||||
}
|
||||
|
||||
func demoSpectrumBars(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := chartState(c)
|
||||
n := 0
|
||||
if s.play && !k.Reduce() {
|
||||
n = k.Tick(c, 8)
|
||||
}
|
||||
levels := make([]float64, 12)
|
||||
for i := range levels {
|
||||
// A made-up song: a slow swell times a quick wobble.
|
||||
t, f := float64(n), float64(i)
|
||||
levels[i] = 0.5 + 0.45*math.Sin(t*0.7+f*1.3)*math.Sin(t*0.13+f*0.4)
|
||||
}
|
||||
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
|
||||
chart.SpectrumBars(c, "Music", levels, chart.SpectrumBarsOptions{Segments: 12})
|
||||
if input.Toggle(c, "PLAY", &s.play, input.ToggleOptions{Size: input.Small}).Changed {
|
||||
log(fmt.Sprintf("play %v", s.play))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func demoWinLoss(c *ui.Context, log func(string)) {
|
||||
k := pixel.K(c)
|
||||
s := chartState(c)
|
||||
ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() {
|
||||
chart.WinLoss(c, "Arena", s.matches, chart.WinLossOptions{})
|
||||
if input.Button(c, "PLAY", input.ButtonOptions{Size: input.Small}).Clicked {
|
||||
r := s.dice.IntN(3) - 1
|
||||
// The strip keeps the last twenty matches.
|
||||
s.matches = append(s.matches[max(0, len(s.matches)-19):], r)
|
||||
log([]string{"lost", "draw", "won"}[r+1])
|
||||
}
|
||||
})
|
||||
}
|
||||
+1
-1
@@ -56,7 +56,7 @@ var Categories = []Category{
|
||||
{"tabletop", "Tabletop", "桌游", icon.Crown},
|
||||
}
|
||||
|
||||
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries)
|
||||
var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries)
|
||||
|
||||
func Entries() []Entry { return entries }
|
||||
|
||||
|
||||
Reference in new issue
Block a user