Files
mapsize/internal/treemap/treemap.go
T
Andy Dixon e8c831cb47 Add squarified treemap layout and spatial navigation
Edge-snapped integer layout that partitions its bounds exactly,
level-of-detail selection, and a deterministic geometric neighbour
search for arrow-key navigation. Fuzz-tested.
2026-10-02 08:09:52 +01:00

190 lines
4.8 KiB
Go

// Package treemap is pure geometry: squarified layout on a cell grid,
// level-of-detail selection and spatial neighbour search. It has no
// knowledge of filesystems or terminals.
package treemap
import (
"math"
"sort"
)
// Rect is an integer cell rectangle.
type Rect struct{ X, Y, W, H int }
// Empty reports whether r has no cells.
func (r Rect) Empty() bool { return r.W <= 0 || r.H <= 0 }
// Area returns W*H.
func (r Rect) Area() int { return max(r.W, 0) * max(r.H, 0) }
// Contains reports whether the cell (x, y) is inside r.
func (r Rect) Contains(x, y int) bool { return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H }
// Inset shrinks r by n cells on each side.
func (r Rect) Inset(n int) Rect {
r = Rect{r.X + n, r.Y + n, r.W - 2*n, r.H - 2*n}
if r.W < 0 {
r.W = 0
}
if r.H < 0 {
r.H = 0
}
return r
}
// Item is something to lay out. Size must be > 0 to receive space.
type Item struct {
ID int64
Size float64
}
// Block is a laid-out item.
type Block struct {
ID int64
Rect
}
// Squarify lays items out inside bounds using the squarified treemap
// algorithm (Bruls, Huizing, van Wijk). Items with non-positive size are
// ignored. The output partitions bounds exactly: rectangle edges are snapped
// to the grid by rounding shared float edges, so there are no gaps and no
// overlaps. Items that round to zero width or height are omitted. The result
// is deterministic for a given input set regardless of input order.
func Squarify(items []Item, bounds Rect) []Block {
if bounds.Empty() {
return nil
}
its := make([]Item, 0, len(items))
total := 0.0
for _, it := range items {
if it.Size > 0 && !math.IsInf(it.Size, 0) && !math.IsNaN(it.Size) {
its = append(its, it)
total += it.Size
}
}
if len(its) == 0 || total <= 0 {
return nil
}
sort.SliceStable(its, func(i, j int) bool {
if its[i].Size != its[j].Size {
return its[i].Size > its[j].Size
}
return its[i].ID < its[j].ID
})
scale := float64(bounds.W) * float64(bounds.H) / total
areas := make([]float64, len(its))
for i, it := range its {
areas[i] = it.Size * scale
}
frs := layout(areas, float64(bounds.X), float64(bounds.Y), float64(bounds.W), float64(bounds.H))
out := make([]Block, 0, len(its))
for i, f := range frs {
x0, y0 := int(math.Round(f[0])), int(math.Round(f[1]))
x1, y1 := int(math.Round(f[0]+f[2])), int(math.Round(f[1]+f[3]))
x1 = min(x1, bounds.X+bounds.W)
y1 = min(y1, bounds.Y+bounds.H)
if x1 > x0 && y1 > y0 {
out = append(out, Block{ID: its[i].ID, Rect: Rect{x0, y0, x1 - x0, y1 - y0}})
}
}
return out
}
// worst returns the worst aspect ratio of a row with the given sum, largest
// and smallest areas laid along a side of length side.
func worst(sum, largest, smallest, side float64) float64 {
s2, w2 := sum*sum, side*side
return math.Max(w2*largest/s2, s2/(w2*smallest))
}
// layout returns float rectangles [x, y, w, h] for areas sorted descending.
func layout(areas []float64, x, y, w, h float64) [][4]float64 {
out := make([][4]float64, len(areas))
n := len(areas)
for i := 0; i < n; {
short := math.Min(w, h)
j := i + 1
sum := areas[i]
cur := worst(sum, areas[i], areas[i], short)
for j < n {
s2 := sum + areas[j]
if nw := worst(s2, areas[i], areas[j], short); nw > cur {
break
} else {
cur = nw
}
sum = s2
j++
}
last := j == n
if w >= h {
thick := sum / h
if last || thick > w {
thick = w
}
yy := y
for k := i; k < j; k++ {
hh := areas[k] / sum * h
if k == j-1 {
hh = y + h - yy
}
out[k] = [4]float64{x, yy, thick, hh}
yy += hh
}
x += thick
w -= thick
} else {
thick := sum / w
if last || thick > h {
thick = h
}
xx := x
for k := i; k < j; k++ {
ww := areas[k] / sum * w
if k == j-1 {
ww = x + w - xx
}
out[k] = [4]float64{xx, y, ww, thick}
xx += ww
}
y += thick
h -= thick
}
i = j
}
return out
}
// Visible decides how many leading items of a size-sorted (descending)
// list deserve their own block when area cells represent total: items are
// kept while each would get at least minArea cells and fewer than maxItems
// have been kept. The remainder should be merged into one group block.
func Visible(sizes []float64, total float64, area, minArea, maxItems int) int {
if total <= 0 || area <= 0 {
return 0
}
k := 0
for k < len(sizes) && k < maxItems {
if sizes[k]/total*float64(area) < float64(minArea) {
break
}
k++
}
// Grouping a single leftover item gains nothing; show it directly if it
// gets at least one cell.
if k == len(sizes)-1 && k < maxItems && sizes[k]/total*float64(area) >= 1 {
k++
}
return k
}
// At returns the block containing the cell (x, y).
func At(blocks []Block, x, y int) (int64, bool) {
for _, b := range blocks {
if b.Contains(x, y) {
return b.ID, true
}
}
return 0, false
}