Charts I: components 101-120
This commit is contained in:
116 files changed
+7825
-3
No files matched your search
+171
@@ -0,0 +1,171 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// ChartNode is a hierarchy node; branches sum their leaves.
|
||||
type ChartNode struct {
|
||||
Name string
|
||||
Value float64
|
||||
Children []ChartNode
|
||||
}
|
||||
|
||||
// total is the node's value, or its children's sum.
|
||||
func (n ChartNode) total() float64 {
|
||||
if len(n.Children) == 0 {
|
||||
return n.Value
|
||||
}
|
||||
s := 0.0
|
||||
for _, ch := range n.Children {
|
||||
s += ch.total()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// chartCheckTree panics on unnamed, duplicate-sibling or negative nodes.
|
||||
func chartCheckTree(n ChartNode) {
|
||||
if n.Name == "" || n.Value < 0 || math.IsNaN(n.Value) || math.IsInf(n.Value, 0) {
|
||||
panic("mujica: chart nodes need a name and a finite value ≥ 0")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, ch := range n.Children {
|
||||
if seen[ch.Name] {
|
||||
panic("mujica: chart node has two children named " + ch.Name)
|
||||
}
|
||||
seen[ch.Name] = true
|
||||
chartCheckTree(ch)
|
||||
}
|
||||
}
|
||||
|
||||
// chartDescend returns the node at path, panicking if unknown.
|
||||
func chartDescend(root ChartNode, path []string) ChartNode {
|
||||
n := root
|
||||
for _, name := range path {
|
||||
i := slices.IndexFunc(n.Children, func(ch ChartNode) bool { return ch.Name == name })
|
||||
if i < 0 {
|
||||
panic("mujica: chart path has no node " + name)
|
||||
}
|
||||
n = n.Children[i]
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// chartPath shows Back and the breadcrumb; reports a move up.
|
||||
func chartPath(c *ui.Context, root string, path *[]string, disabled bool) bool {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
moved := false
|
||||
ui.Row(c).Gap(u).AlignItems(ui.Center).Wrap().Children(func() {
|
||||
back := iconAction(c, icons.Must("chevron-left"), msg(c, "chart.back")).Disabled(disabled || len(*path) == 0)
|
||||
if back.Clicked() && !back.IsDisabled() {
|
||||
*path, moved = (*path)[:len(*path)-1], true
|
||||
}
|
||||
names := append([]string{root}, *path...)
|
||||
for i, name := range names {
|
||||
if i > 0 {
|
||||
ui.Text(c, "›").TextColor(k.TextMuted)
|
||||
}
|
||||
if i == len(names)-1 {
|
||||
ui.Text(c, name).FontWeight(600).SingleLine()
|
||||
continue
|
||||
}
|
||||
crumb := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, u).Radius(theme.ControlRadius).Disabled(disabled).TextColor(k.AccentText)
|
||||
internal.Ring(crumb, k.Focus, theme.ControlRadius)
|
||||
crumb.Children(func() { ui.Text(c, name).Underline().SingleLine() })
|
||||
if crumb.Clicked() && !crumb.IsDisabled() {
|
||||
*path, moved = slices.Clone((*path)[:i]), true
|
||||
}
|
||||
}
|
||||
})
|
||||
if moved {
|
||||
slog.Debug("mujica: chart path", "path", strings.Join(*path, "/"))
|
||||
}
|
||||
return moved
|
||||
}
|
||||
|
||||
// chartFit shortens s with an ellipsis to fit w.
|
||||
func chartFit(p *ui.Painter, s string, w, size float32) string {
|
||||
r := []rune(s)
|
||||
for len(r) > 0 {
|
||||
t := string(r)
|
||||
if len(r) < len([]rune(s)) {
|
||||
t += "…"
|
||||
}
|
||||
if tw, _ := p.MeasureText(0, ui.Span{Text: t, Size: size}); tw <= w {
|
||||
return t
|
||||
}
|
||||
r = r[:len(r)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// squarify tiles descending values into near-square rectangles.
|
||||
func squarify(values []float64, r ui.Rect) []ui.Rect {
|
||||
out := make([]ui.Rect, len(values))
|
||||
total := 0.0
|
||||
for _, v := range values {
|
||||
total += v
|
||||
}
|
||||
if total <= 0 {
|
||||
return out
|
||||
}
|
||||
scale := float64(r.W*r.H) / total
|
||||
for i := 0; i < len(values); {
|
||||
side := float64(min(r.W, r.H))
|
||||
j, best := i+1, math.Inf(1)
|
||||
for ; j <= len(values); j++ {
|
||||
worst := squarifyWorst(values[i:j], side, scale)
|
||||
if worst > best {
|
||||
break
|
||||
}
|
||||
best = worst
|
||||
}
|
||||
j--
|
||||
row := 0.0
|
||||
for _, v := range values[i:j] {
|
||||
row += v * scale
|
||||
}
|
||||
thick := float32(row / side)
|
||||
at := float32(0)
|
||||
for k := i; k < j; k++ {
|
||||
l := float32(values[k]*scale) / thick
|
||||
if r.W >= r.H {
|
||||
out[k] = ui.Rect{X: r.X, Y: r.Y + at, W: thick, H: l}
|
||||
} else {
|
||||
out[k] = ui.Rect{X: r.X + at, Y: r.Y, W: l, H: thick}
|
||||
}
|
||||
at += l
|
||||
}
|
||||
if r.W >= r.H {
|
||||
r.X, r.W = r.X+thick, r.W-thick
|
||||
} else {
|
||||
r.Y, r.H = r.Y+thick, r.H-thick
|
||||
}
|
||||
i = j
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// squarifyWorst is a row's worst aspect ratio along side.
|
||||
func squarifyWorst(row []float64, side, scale float64) float64 {
|
||||
sum, lo, hi := 0.0, math.Inf(1), 0.0
|
||||
for _, v := range row {
|
||||
a := v * scale
|
||||
sum, lo, hi = sum+a, min(lo, a), max(hi, a)
|
||||
}
|
||||
if lo == 0 {
|
||||
return math.Inf(1)
|
||||
}
|
||||
s2 := side * side
|
||||
return max(s2*hi/(sum*sum), sum*sum/(s2*lo))
|
||||
}
|
||||
Reference in new issue
Block a user