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.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user