Files
8bitMyGoUI/chart/treemap.go
T
ZacharyZhang-NY 596c62e026 Add chart components 216-245; step-aware ticks, font-aware labels
Bars, lines, areas, pies, donuts, radar, histogram, scatter, bubbles,
heatmaps, gauge, bullet, funnel, waterfall, treemap, candles, box
plots, steps, rings, legend, axis, crosshair, polar area, pyramid,
spectrum bars and win-loss strips, each drawn dot by dot. Ticks follow
1-2-5 steps and print as precisely as their step needs; a label row
uses micro letters or the pixel font as a whole. Descriptions carry the
data in numbers. Bad data panics.
2026-10-12 00:58:44 -04:00

117 lines
3.0 KiB
Go

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))
}