diff --git a/chart/areachart.go b/chart/areachart.go new file mode 100644 index 0000000..a743810 --- /dev/null +++ b/chart/areachart.go @@ -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) + }) +} diff --git a/chart/axis.go b/chart/axis.go new file mode 100644 index 0000000..48efbba --- /dev/null +++ b/chart/axis.go @@ -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) + } + }) +} diff --git a/chart/barchart.go b/chart/barchart.go new file mode 100644 index 0000000..a488ad1 --- /dev/null +++ b/chart/barchart.go @@ -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 +} diff --git a/chart/bars_test.go b/chart/bars_test.go new file mode 100644 index 0000000..1ecf469 --- /dev/null +++ b/chart/bars_test.go @@ -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) +} diff --git a/chart/boxplot.go b/chart/boxplot.go new file mode 100644 index 0000000..110e9cf --- /dev/null +++ b/chart/boxplot.go @@ -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, "; ") +} diff --git a/chart/bubblechart.go b/chart/bubblechart.go new file mode 100644 index 0000000..7af90ad --- /dev/null +++ b/chart/bubblechart.go @@ -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) + } + }) +} diff --git a/chart/bulletchart.go b/chart/bulletchart.go new file mode 100644 index 0000000..616f652 --- /dev/null +++ b/chart/bulletchart.go @@ -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) + }) +} diff --git a/chart/calendarheatmap.go b/chart/calendarheatmap.go new file mode 100644 index 0000000..dd392e2 --- /dev/null +++ b/chart/calendarheatmap.go @@ -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) + } + }) +} diff --git a/chart/candlestick.go b/chart/candlestick.go new file mode 100644 index 0000000..e0faea4 --- /dev/null +++ b/chart/candlestick.go @@ -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, "; ") +} diff --git a/chart/chart.go b/chart/chart.go new file mode 100644 index 0000000..73e02e8 --- /dev/null +++ b/chart/chart.go @@ -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, ", ") +} diff --git a/chart/chart_test.go b/chart/chart_test.go new file mode 100644 index 0000000..c06bea9 --- /dev/null +++ b/chart/chart_test.go @@ -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 +} diff --git a/chart/crosshair.go b/chart/crosshair.go new file mode 100644 index 0000000..f3d2a11 --- /dev/null +++ b/chart/crosshair.go @@ -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 + } + }) +} diff --git a/chart/describe_test.go b/chart/describe_test.go new file mode 100644 index 0000000..64411f4 --- /dev/null +++ b/chart/describe_test.go @@ -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) + } +} diff --git a/chart/donutchart.go b/chart/donutchart.go new file mode 100644 index 0000000..a077884 --- /dev/null +++ b/chart/donutchart.go @@ -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) + }) +} diff --git a/chart/field.go b/chart/field.go new file mode 100644 index 0000000..8adb915 --- /dev/null +++ b/chart/field.go @@ -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, " ") +} diff --git a/chart/funnel.go b/chart/funnel.go new file mode 100644 index 0000000..211795a --- /dev/null +++ b/chart/funnel.go @@ -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) + } + } + }) +} diff --git a/chart/gauge.go b/chart/gauge.go new file mode 100644 index 0000000..b46f78c --- /dev/null +++ b/chart/gauge.go @@ -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) + }) +} diff --git a/chart/heatmap.go b/chart/heatmap.go new file mode 100644 index 0000000..b79fe4c --- /dev/null +++ b/chart/heatmap.go @@ -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 +} diff --git a/chart/histogram.go b/chart/histogram.go new file mode 100644 index 0000000..3e164c5 --- /dev/null +++ b/chart/histogram.go @@ -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) + } + }) +} diff --git a/chart/horizontalbar.go b/chart/horizontalbar.go new file mode 100644 index 0000000..1626630 --- /dev/null +++ b/chart/horizontalbar.go @@ -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) + } + }) +} diff --git a/chart/labels_test.go b/chart/labels_test.go new file mode 100644 index 0000000..4110554 --- /dev/null +++ b/chart/labels_test.go @@ -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) + } +} diff --git a/chart/legend.go b/chart/legend.go new file mode 100644 index 0000000..fb7f253 --- /dev/null +++ b/chart/legend.go @@ -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 +} diff --git a/chart/linechart.go b/chart/linechart.go new file mode 100644 index 0000000..93a34de --- /dev/null +++ b/chart/linechart.go @@ -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 +} diff --git a/chart/lines_test.go b/chart/lines_test.go new file mode 100644 index 0000000..7aadf6c --- /dev/null +++ b/chart/lines_test.go @@ -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 +} diff --git a/chart/piechart.go b/chart/piechart.go new file mode 100644 index 0000000..fdce1ad --- /dev/null +++ b/chart/piechart.go @@ -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) + }) +} diff --git a/chart/polararea.go b/chart/polararea.go new file mode 100644 index 0000000..a06ecae --- /dev/null +++ b/chart/polararea.go @@ -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)) + } + } + }) +} diff --git a/chart/progressring.go b/chart/progressring.go new file mode 100644 index 0000000..fb14f00 --- /dev/null +++ b/chart/progressring.go @@ -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) + }) +} diff --git a/chart/pyramid.go b/chart/pyramid.go new file mode 100644 index 0000000..8df416b --- /dev/null +++ b/chart/pyramid.go @@ -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) + } + }) +} diff --git a/chart/radarchart.go b/chart/radarchart.go new file mode 100644 index 0000000..54bcddd --- /dev/null +++ b/chart/radarchart.go @@ -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)))) +} diff --git a/chart/round.go b/chart/round.go new file mode 100644 index 0000000..1a07871 --- /dev/null +++ b/chart/round.go @@ -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) +} diff --git a/chart/scatterplot.go b/chart/scatterplot.go new file mode 100644 index 0000000..66ffed9 --- /dev/null +++ b/chart/scatterplot.go @@ -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) + } + }) +} diff --git a/chart/shapes_test.go b/chart/shapes_test.go new file mode 100644 index 0000000..7818642 --- /dev/null +++ b/chart/shapes_test.go @@ -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"}}) }) + }) +} diff --git a/chart/sparkline.go b/chart/sparkline.go new file mode 100644 index 0000000..2a2f1ec --- /dev/null +++ b/chart/sparkline.go @@ -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) + }) +} diff --git a/chart/spectrumbars.go b/chart/spectrumbars.go new file mode 100644 index 0000000..1490ade --- /dev/null +++ b/chart/spectrumbars.go @@ -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) + } + } + }) +} diff --git a/chart/stackedbar.go b/chart/stackedbar.go new file mode 100644 index 0000000..159fddc --- /dev/null +++ b/chart/stackedbar.go @@ -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] + } + } + }) +} diff --git a/chart/stepchart.go b/chart/stepchart.go new file mode 100644 index 0000000..458bdaa --- /dev/null +++ b/chart/stepchart.go @@ -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) + } + } + }) +} diff --git a/chart/treemap.go b/chart/treemap.go new file mode 100644 index 0000000..b136311 --- /dev/null +++ b/chart/treemap.go @@ -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)) +} diff --git a/chart/waterfall.go b/chart/waterfall.go new file mode 100644 index 0000000..6c1fe30 --- /dev/null +++ b/chart/waterfall.go @@ -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) + } + } + }) +} diff --git a/chart/winloss.go b/chart/winloss.go new file mode 100644 index 0000000..724b418 --- /dev/null +++ b/chart/winloss.go @@ -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)) +} diff --git a/gallery/chart_demo_test.go b/gallery/chart_demo_test.go new file mode 100644 index 0000000..6f6ab78 --- /dev/null +++ b/gallery/chart_demo_test.go @@ -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) + } + } + } +} diff --git a/gallery/demos_chart.go b/gallery/demos_chart.go new file mode 100644 index 0000000..dffc363 --- /dev/null +++ b/gallery/demos_chart.go @@ -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)) + } + }) +} diff --git a/gallery/demos_plots.go b/gallery/demos_plots.go new file mode 100644 index 0000000..38ebe46 --- /dev/null +++ b/gallery/demos_plots.go @@ -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]) + } + }) +} diff --git a/gallery/registry.go b/gallery/registry.go index e6843d0..b47897a 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -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 }