Files
MujicaUI/histogram.go
T
2026-10-05 14:54:57 -04:00

158 lines
4.2 KiB
Go

package mujica
import (
"fmt"
"math"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/internal"
"mujicaui/theme"
)
// HistogramOptions configure a Histogram.
type HistogramOptions struct {
Frame ChartFrame
// Bins is the bin count; 0 uses Sturges' rule.
Bins int
BinWidth float64
// Cumulative counts every value up to each bin's upper edge.
Cumulative bool
Format func(v float64) string
}
// histogramMaxBins bounds the bins a width may make.
const histogramMaxBins = 10_000
type histBins struct {
start, width float64
counts []int
}
// histogramBin counts finite values into equal bins.
func histogramBin(values []float64, bins int, width float64, cumulative bool) histBins {
lo, hi := math.Inf(1), math.Inf(-1)
n := 0
for _, v := range values {
if !math.IsNaN(v) && !math.IsInf(v, 0) {
lo, hi, n = min(lo, v), max(hi, v), n+1
}
}
if n == 0 {
return histBins{}
}
var b histBins
switch {
case width > 0:
b.start, b.width = math.Floor(lo/width)*width, width
bins = max(1, int(math.Ceil((hi-b.start)/width-1e-9)))
if hi == b.start+float64(bins)*width {
bins++
}
default:
if bins == 0 {
bins = int(math.Ceil(math.Log2(float64(n)))) + 1
}
b.start, b.width = lo, (hi-lo)/float64(bins)
if b.width == 0 {
b.width = 1
}
}
if bins > histogramMaxBins {
panic("mujica: Histogram would make more than 10000 bins")
}
b.counts = make([]int, bins)
for _, v := range values {
if math.IsNaN(v) || math.IsInf(v, 0) {
continue
}
b.counts[min(bins-1, int((v-b.start)/b.width))]++
}
if cumulative {
for i := 1; i < bins; i++ {
b.counts[i] += b.counts[i-1]
}
}
return b
}
// Histogram counts values into equal bins drawn as bars.
func Histogram(c *ui.Context, values []float64, opts HistogramOptions) ChartResult {
if opts.Bins < 0 || opts.BinWidth < 0 || math.IsNaN(opts.BinWidth) {
panic("mujica: Histogram Bins and BinWidth must be ≥ 0")
}
ch := newChart(c, opts.Frame)
k := Tokens(c)
col := internal.Palette(k)[0]
size := FontSize(c, theme.CaptionSize)
b := chartMemo(ch, "bins", [4]any{chartID(values), opts.Bins, opts.BinWidth, opts.Cumulative}, func() histBins {
return histogramBin(values, opts.Bins, opts.BinWidth, opts.Cumulative)
})
top := 0
for _, n := range b.counts {
top = max(top, n)
}
var ys internal.Linear
var hi, step float64
if top > 0 {
_, hi, step = internal.Nice(0, float64(top), 4)
step = max(step, 1)
}
// edge writes bin edge i at a given precision.
edge := func(i int, fine float64) string {
v := b.start + float64(i)*b.width
if opts.Format != nil {
return opts.Format(v)
}
s := internal.FormatTick(v, b.width/fine)
if strings.Contains(s, ".") {
s = strings.TrimRight(strings.TrimRight(s, "0"), ".")
}
return s
}
geom := func(r ui.Rect) (ui.Rect, internal.Band) {
plot := chartInset(r, 52, 26)
ys = internal.Linear{D0: 0, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}
return plot, internal.Band{N: len(b.counts), R0: plot.X, R1: plot.X + plot.W, Pad: 0.06}
}
spec := ChartSpec{Marks: len(b.counts)}
spec.Readout = func(i int) string {
if opts.Cumulative {
return fmt.Sprintf("≤ %s · %d", edge(i+1, 100), b.counts[i])
}
closer := ")"
if i == len(b.counts)-1 && opts.BinWidth == 0 {
closer = "]"
}
return fmt.Sprintf("[%s, %s%s · %d", edge(i, 100), edge(i+1, 100), closer, b.counts[i])
}
spec.Pick = func(r ui.Rect, x, y float32) int {
plot, band := geom(r)
if !plot.Contains(x, y) {
return -1
}
return min(len(b.counts)-1, int((x-plot.X)/band.Step()))
}
spec.Draw = func(p *ui.Painter, r ui.Rect, active int) {
plot, band := geom(r)
internal.AxisY(p, plot, ys, internal.Ticks(0, hi, step), func(v float64) string { return internal.FormatTick(v, step) }, size, k)
var xs []float32
var labels []string
for i := 0; i <= len(b.counts); i++ {
xs, labels = append(xs, plot.X+band.Step()*float32(i)), append(labels, edge(i, 1))
}
internal.AxisX(p, plot, xs, labels, size, k)
for i, n := range b.counts {
y := ys.Map(float64(n))
bar := ui.Rect{X: band.Start(i), Y: y, W: band.Width(), H: plot.Y + plot.H - y}
p.Fill(bar, col, 1)
if i == active {
p.Stroke(ui.Rect{X: bar.X - 2, Y: bar.Y - 2, W: bar.W + 4, H: bar.H + 4}, k.Text, 2, 1.5)
}
}
}
res, _ := ch.build(spec)
return res
}