diff --git a/internal/treemap/nav.go b/internal/treemap/nav.go new file mode 100644 index 0000000..e947b84 --- /dev/null +++ b/internal/treemap/nav.go @@ -0,0 +1,106 @@ +package treemap + +// Direction is an arrow-key direction. +type Direction int + +const ( + Left Direction = iota + Right + Up + Down +) + +// Neighbour finds the block a user expects to reach by pressing an arrow +// key from the block with ID sel: +// +// 1. Only blocks entirely beyond the selected block's edge in the +// direction of travel are candidates. +// 2. Blocks whose span overlaps the selected block's orthogonal span are +// strongly preferred over all others. +// 3. Then the smallest gap along the direction of travel wins. +// 4. Then the largest overlap with the selected block's span. +// 5. Then the block whose orthogonal span is closest to the selected +// block's centre line (0 if it contains it). +// 6. Ties break by position (top, then left) and finally ID. +// +// It returns false if there is no block in that direction. +func Neighbour(blocks []Block, sel int64, d Direction) (int64, bool) { + var s Block + found := false + for _, b := range blocks { + if b.ID == sel { + s, found = b, true + break + } + } + if !found { + return 0, false + } + type score struct { + tier, gap, ovl, off, y, x int + id int64 + } + less := func(a, b score) bool { + switch { + case a.tier != b.tier: + return a.tier < b.tier + case a.gap != b.gap: + return a.gap < b.gap + case a.ovl != b.ovl: + return a.ovl > b.ovl + case a.off != b.off: + return a.off < b.off + case a.y != b.y: + return a.y < b.y + case a.x != b.x: + return a.x < b.x + } + return a.id < b.id + } + var best score + have := false + for _, c := range blocks { + if c.ID == sel { + continue + } + var gap, lo, hi, clo, chi int + var ok bool + switch d { + case Right: + ok, gap = c.X >= s.X+s.W, c.X-(s.X+s.W) + lo, hi, clo, chi = s.Y, s.Y+s.H, c.Y, c.Y+c.H + case Left: + ok, gap = c.X+c.W <= s.X, s.X-(c.X+c.W) + lo, hi, clo, chi = s.Y, s.Y+s.H, c.Y, c.Y+c.H + case Down: + ok, gap = c.Y >= s.Y+s.H, c.Y-(s.Y+s.H) + lo, hi, clo, chi = s.X, s.X+s.W, c.X, c.X+c.W + case Up: + ok, gap = c.Y+c.H <= s.Y, s.Y-(c.Y+c.H) + lo, hi, clo, chi = s.X, s.X+s.W, c.X, c.X+c.W + } + if !ok { + continue + } + tier, ovl := 1, min(hi, chi)-max(lo, clo) + if ovl > 0 { + tier = 0 + } else { + ovl = 0 + } + // Distance (doubled, to stay integral) from the selected centre + // line to the candidate's span; 0 if the span contains it. + centre := lo + hi + off := 0 + if centre < 2*clo { + off = 2*clo - centre + } else if centre > 2*chi { + off = centre - 2*chi + } + sc := score{tier, gap, ovl, off, c.Y, c.X, c.ID} + if !have || less(sc, best) { + best, have = sc, true + } + } + return best.id, have +} diff --git a/internal/treemap/treemap.go b/internal/treemap/treemap.go new file mode 100644 index 0000000..1dd9aab --- /dev/null +++ b/internal/treemap/treemap.go @@ -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 +} diff --git a/internal/treemap/treemap_test.go b/internal/treemap/treemap_test.go new file mode 100644 index 0000000..9638f9c --- /dev/null +++ b/internal/treemap/treemap_test.go @@ -0,0 +1,247 @@ +package treemap + +import ( + "math/rand" + "testing" +) + +func checkPartition(t *testing.T, blocks []Block, b Rect, exact bool) { + t.Helper() + grid := make([]int, b.W*b.H) + for _, bl := range blocks { + if bl.W <= 0 || bl.H <= 0 { + t.Fatalf("non-positive block %+v", bl) + } + if bl.X < b.X || bl.Y < b.Y || bl.X+bl.W > b.X+b.W || bl.Y+bl.H > b.Y+b.H { + t.Fatalf("block %+v outside bounds %+v", bl, b) + } + for y := bl.Y; y < bl.Y+bl.H; y++ { + for x := bl.X; x < bl.X+bl.W; x++ { + i := (y-b.Y)*b.W + (x - b.X) + if grid[i] != 0 { + t.Fatalf("overlap at %d,%d", x, y) + } + grid[i] = 1 + } + } + } + if exact { + for i, v := range grid { + if v == 0 { + t.Fatalf("gap at %d,%d", b.X+i%b.W, b.Y+i/b.W) + } + } + } +} + +func TestSquarifyBasic(t *testing.T) { + b := Rect{2, 3, 60, 20} + items := []Item{{1, 6}, {2, 6}, {3, 4}, {4, 3}, {5, 2}, {6, 2}, {7, 1}} + blocks := Squarify(items, b) + if len(blocks) != len(items) { + t.Fatalf("got %d blocks", len(blocks)) + } + checkPartition(t, blocks, b, true) + // Areas roughly proportional. + for _, bl := range blocks { + want := float64(b.Area()) * items[bl.ID-1].Size / 24 + if got := float64(bl.Area()); got < want*0.6-4 || got > want*1.4+4 { + t.Errorf("block %d area %v want ~%v", bl.ID, got, want) + } + } + // Deterministic regardless of input order. + rev := append([]Item(nil), items...) + for i, j := 0, len(rev)-1; i < j; i, j = i+1, j-1 { + rev[i], rev[j] = rev[j], rev[i] + } + b2 := Squarify(rev, b) + for i := range blocks { + if blocks[i] != b2[i] { + t.Fatal("layout depends on input order") + } + } +} + +func TestSquarifyEdgeCases(t *testing.T) { + if Squarify(nil, Rect{0, 0, 10, 10}) != nil { + t.Fatal("nil items") + } + if Squarify([]Item{{1, 0}, {2, -5}}, Rect{0, 0, 10, 10}) != nil { + t.Fatal("zero sizes should produce nothing") + } + for _, b := range []Rect{{0, 0, 0, 0}, {0, 0, 1, 1}, {0, 0, 1, 50}, {0, 0, 50, 1}, {0, 0, -3, 4}} { + bl := Squarify([]Item{{1, 5}, {2, 3}, {3, 1}}, b) + if !b.Empty() { + checkPartition(t, bl, b, true) + } + } + // One enormous item and many tiny ones. + items := []Item{{0, 1e15}} + for i := 1; i < 500; i++ { + items = append(items, Item{int64(i), 1}) + } + bl := Squarify(items, Rect{0, 0, 80, 24}) + checkPartition(t, bl, Rect{0, 0, 80, 24}, true) +} + +func TestResizeRecomputes(t *testing.T) { + items := []Item{{1, 50}, {2, 30}, {3, 20}} + for _, b := range []Rect{{0, 0, 80, 24}, {0, 0, 120, 40}, {0, 0, 300, 80}, {0, 0, 70, 20}, {0, 0, 180, 50}} { + bl := Squarify(items, b) + checkPartition(t, bl, b, true) + if len(bl) != 3 { + t.Fatalf("%v: %d blocks", b, len(bl)) + } + } +} + +func FuzzSquarify(f *testing.F) { + f.Add(int64(1), 80, 24, 10) + f.Add(int64(2), 3, 2, 100) + f.Add(int64(3), 300, 90, 1000) + f.Fuzz(func(t *testing.T, seed int64, w, h, n int) { + w, h, n = w%400, h%150, n%3000 + if w < 0 || h < 0 || n < 0 { + return + } + r := rand.New(rand.NewSource(seed)) + items := make([]Item, n) + for i := range items { + items[i] = Item{int64(i), float64(r.Int63n(1 << uint(r.Intn(40)+1)))} + } + b := Rect{r.Intn(10), r.Intn(10), w, h} + bl := Squarify(items, b) + if !b.Empty() && len(bl) > 0 { + checkPartition(t, bl, b, true) + } + }) +} + +func TestVisible(t *testing.T) { + sizes := []float64{100, 50, 10, 1, 1, 1} + if k := Visible(sizes, 163, 1630, 20, 100); k != 3 { + t.Fatalf("k=%d", k) + } + if k := Visible(sizes, 163, 1630, 20, 2); k != 2 { + t.Fatalf("maxItems k=%d", k) + } + if k := Visible([]float64{10, 1}, 11, 1100, 200, 10); k != 2 { + t.Fatalf("single leftover k=%d", k) + } +} + +// Layout: +// +// ┌───────┬───────┐ +// │ A │ B │ +// ├───┬───┴───────┤ +// │ C │ D │ +// └───┴───────────┘ +func TestNeighbour(t *testing.T) { + A := Block{1, Rect{0, 0, 8, 4}} + B := Block{2, Rect{8, 0, 8, 4}} + C := Block{3, Rect{0, 4, 4, 4}} + D := Block{4, Rect{4, 4, 12, 4}} + bs := []Block{D, C, B, A} // order must not matter + cases := []struct { + from int64 + d Direction + want int64 + ok bool + }{ + {1, Right, 2, true}, {2, Left, 1, true}, {1, Down, 3, true}, {2, Down, 4, true}, + {3, Right, 4, true}, {4, Left, 3, true}, {3, Up, 1, true}, {4, Up, 2, true}, + {1, Left, 0, false}, {1, Up, 0, false}, {4, Right, 0, false}, {4, Down, 0, false}, + } + for _, c := range cases { + got, ok := Neighbour(bs, c.from, c.d) + if ok != c.ok || got != c.want { + t.Errorf("from %d dir %d: got %d,%v want %d,%v", c.from, c.d, got, ok, c.want, c.ok) + } + } +} + +func TestNeighbourPrefersAdjacentOverArrayOrder(t *testing.T) { + // S is on the left; R is directly right; F is far right and lower but + // appears first in the slice. + S := Block{1, Rect{0, 0, 10, 5}} + R := Block{2, Rect{10, 0, 10, 5}} + F := Block{3, Rect{10, 20, 30, 5}} + if got, _ := Neighbour([]Block{F, S, R}, 1, Right); got != 2 { + t.Fatalf("got %d", got) + } + // Tall block on the left with three stacked blocks to the right: pick + // the one at the centre line. + T := Block{10, Rect{0, 0, 10, 9}} + r1 := Block{11, Rect{10, 0, 10, 3}} + r2 := Block{12, Rect{10, 3, 10, 3}} + r3 := Block{13, Rect{10, 6, 10, 3}} + if got, _ := Neighbour([]Block{r3, r1, r2, T}, 10, Right); got != 12 { + t.Fatalf("centre preference: got %d", got) + } + if got, _ := Neighbour([]Block{r3, r1, r2, T}, 13, Left); got != 10 { + t.Fatalf("back left: got %d", got) + } +} + +func TestNeighbourRandomLayoutsAlwaysAdjacent(t *testing.T) { + r := rand.New(rand.NewSource(7)) + for iter := 0; iter < 200; iter++ { + items := make([]Item, 2+r.Intn(30)) + for i := range items { + items[i] = Item{int64(i), float64(1 + r.Intn(1000))} + } + bl := Squarify(items, Rect{0, 0, 120, 40}) + for _, s := range bl { + for _, d := range []Direction{Left, Right, Up, Down} { + id, ok := Neighbour(bl, s.ID, d) + if !ok { + continue + } + var c Block + for _, b := range bl { + if b.ID == id { + c = b + } + } + // In a partition, if any block touches the edge in direction d + // with overlapping span, the chosen one must touch too. + var touch bool + switch d { + case Right: + touch = c.X == s.X+s.W + case Left: + touch = c.X+c.W == s.X + case Down: + touch = c.Y == s.Y+s.H + case Up: + touch = c.Y+c.H == s.Y + } + if !touch { + t.Fatalf("iter %d: from %+v dir %d chose non-adjacent %+v", iter, s, d, c) + } + } + } + } +} + +func BenchmarkSquarify(b *testing.B) { + items := make([]Item, 300) + for i := range items { + items[i] = Item{int64(i), float64(1 + i*i)} + } + for b.Loop() { + Squarify(items, Rect{0, 0, 200, 60}) + } +} + +func BenchmarkNeighbour(b *testing.B) { + items := make([]Item, 300) + for i := range items { + items[i] = Item{int64(i), float64(1 + i*i)} + } + bl := Squarify(items, Rect{0, 0, 200, 60}) + for b.Loop() { + Neighbour(bl, 150, Right) + } +}