303 lines
10 KiB
Rust
303 lines
10 KiB
Rust
//! Pure geometry: squarified layout on a cell grid, level-of-detail
|
|
//! selection and spatial neighbour search. No knowledge of filesystems or
|
|
//! terminals.
|
|
|
|
mod nav;
|
|
|
|
pub use nav::{neighbour, Direction};
|
|
|
|
/// An integer cell rectangle.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub struct Rect {
|
|
pub x: i32,
|
|
pub y: i32,
|
|
pub w: i32,
|
|
pub h: i32,
|
|
}
|
|
|
|
impl Rect {
|
|
/// Whether the rectangle has no cells.
|
|
pub fn empty(&self) -> bool {
|
|
self.w <= 0 || self.h <= 0
|
|
}
|
|
|
|
/// w*h.
|
|
pub fn area(&self) -> i32 {
|
|
self.w.max(0) * self.h.max(0)
|
|
}
|
|
|
|
/// Whether the cell (x, y) is inside.
|
|
pub fn contains(&self, x: i32, y: i32) -> bool {
|
|
x >= self.x && x < self.x + self.w && y >= self.y && y < self.y + self.h
|
|
}
|
|
|
|
/// Shrinks by n cells on each side.
|
|
pub fn inset(&self, n: i32) -> Rect {
|
|
Rect { x: self.x + n, y: self.y + n, w: (self.w - 2 * n).max(0), h: (self.h - 2 * n).max(0) }
|
|
}
|
|
}
|
|
|
|
/// Something to lay out. Size must be > 0 to receive space.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct Item {
|
|
pub id: i64,
|
|
pub size: f64,
|
|
}
|
|
|
|
/// A laid-out item.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub struct Block {
|
|
pub id: i64,
|
|
pub rect: Rect,
|
|
}
|
|
|
|
/// Lays items out inside bounds using the squarified treemap algorithm
|
|
/// (Bruls, Huizing, van Wijk). Items with non-positive or non-finite 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.
|
|
pub fn squarify(items: &[Item], bounds: Rect) -> Vec<Block> {
|
|
if bounds.empty() {
|
|
return Vec::new();
|
|
}
|
|
let mut its: Vec<Item> = items.iter().copied().filter(|it| it.size > 0.0 && it.size.is_finite()).collect();
|
|
let total: f64 = its.iter().map(|it| it.size).sum();
|
|
if its.is_empty() || total <= 0.0 {
|
|
return Vec::new();
|
|
}
|
|
its.sort_by(|a, b| b.size.total_cmp(&a.size).then(a.id.cmp(&b.id)));
|
|
let scale = bounds.w as f64 * bounds.h as f64 / total;
|
|
let areas: Vec<f64> = its.iter().map(|it| it.size * scale).collect();
|
|
let frs = layout(&areas, bounds.x as f64, bounds.y as f64, bounds.w as f64, bounds.h as f64);
|
|
let mut out = Vec::with_capacity(its.len());
|
|
for (it, f) in its.iter().zip(frs) {
|
|
let (x0, y0) = (f[0].round() as i32, f[1].round() as i32);
|
|
let x1 = ((f[0] + f[2]).round() as i32).min(bounds.x + bounds.w);
|
|
let y1 = ((f[1] + f[3]).round() as i32).min(bounds.y + bounds.h);
|
|
if x1 > x0 && y1 > y0 {
|
|
out.push(Block { id: it.id, rect: Rect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 } });
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The worst aspect ratio of a row with the given sum, largest and smallest
|
|
/// areas laid along a side of length side.
|
|
fn worst(sum: f64, largest: f64, smallest: f64, side: f64) -> f64 {
|
|
let (s2, w2) = (sum * sum, side * side);
|
|
(w2 * largest / s2).max(s2 / (w2 * smallest))
|
|
}
|
|
|
|
/// Float rectangles [x, y, w, h] for areas sorted descending.
|
|
fn layout(areas: &[f64], mut x: f64, mut y: f64, mut w: f64, mut h: f64) -> Vec<[f64; 4]> {
|
|
let n = areas.len();
|
|
let mut out = vec![[0.0; 4]; n];
|
|
let mut i = 0;
|
|
while i < n {
|
|
let short = w.min(h);
|
|
let mut j = i + 1;
|
|
let mut sum = areas[i];
|
|
let mut cur = worst(sum, areas[i], areas[i], short);
|
|
while j < n {
|
|
let s2 = sum + areas[j];
|
|
let nw = worst(s2, areas[i], areas[j], short);
|
|
if nw > cur {
|
|
break;
|
|
}
|
|
cur = nw;
|
|
sum = s2;
|
|
j += 1;
|
|
}
|
|
let last = j == n;
|
|
if w >= h {
|
|
let mut thick = sum / h;
|
|
if last || thick > w {
|
|
thick = w;
|
|
}
|
|
let mut yy = y;
|
|
for k in i..j {
|
|
let hh = if k == j - 1 { y + h - yy } else { areas[k] / sum * h };
|
|
out[k] = [x, yy, thick, hh];
|
|
yy += hh;
|
|
}
|
|
x += thick;
|
|
w -= thick;
|
|
} else {
|
|
let mut thick = sum / w;
|
|
if last || thick > h {
|
|
thick = h;
|
|
}
|
|
let mut xx = x;
|
|
for k in i..j {
|
|
let ww = if k == j - 1 { x + w - xx } else { areas[k] / sum * w };
|
|
out[k] = [xx, y, ww, thick];
|
|
xx += ww;
|
|
}
|
|
y += thick;
|
|
h -= thick;
|
|
}
|
|
i = j;
|
|
}
|
|
out
|
|
}
|
|
|
|
/// 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 min_area cells and fewer than max_items have been
|
|
/// kept. The remainder should be merged into one group block.
|
|
pub fn visible(sizes: &[f64], total: f64, area: i32, min_area: i32, max_items: usize) -> usize {
|
|
if total <= 0.0 || area <= 0 {
|
|
return 0;
|
|
}
|
|
let cells = |s: f64| s / total * area as f64;
|
|
let mut k = 0;
|
|
while k < sizes.len() && k < max_items {
|
|
if cells(sizes[k]) < min_area as f64 {
|
|
break;
|
|
}
|
|
k += 1;
|
|
}
|
|
// Grouping a single leftover item gains nothing; show it directly if it
|
|
// gets at least one cell.
|
|
if k + 1 == sizes.len() && k < max_items && cells(sizes[k]) >= 1.0 {
|
|
k += 1;
|
|
}
|
|
k
|
|
}
|
|
|
|
/// The block containing the cell (x, y).
|
|
pub fn at(blocks: &[Block], x: i32, y: i32) -> Option<i64> {
|
|
blocks.iter().find(|b| b.rect.contains(x, y)).map(|b| b.id)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod tests {
|
|
use super::*;
|
|
|
|
pub fn r(x: i32, y: i32, w: i32, h: i32) -> Rect {
|
|
Rect { x, y, w, h }
|
|
}
|
|
|
|
pub fn it(id: i64, size: f64) -> Item {
|
|
Item { id, size }
|
|
}
|
|
|
|
/// Small deterministic PRNG for property checks (splitmix64).
|
|
pub struct Rng(u64);
|
|
impl Rng {
|
|
pub fn new(seed: u64) -> Rng {
|
|
Rng(seed)
|
|
}
|
|
pub fn intn(&mut self, n: u64) -> u64 {
|
|
self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15);
|
|
let mut z = self.0;
|
|
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
|
|
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
|
|
(z ^ (z >> 31)) % n
|
|
}
|
|
}
|
|
|
|
fn check_partition(blocks: &[Block], b: Rect, exact: bool) {
|
|
let mut grid = vec![false; (b.w * b.h) as usize];
|
|
for bl in blocks {
|
|
let br = bl.rect;
|
|
assert!(br.w > 0 && br.h > 0, "non-positive block {bl:?}");
|
|
assert!(br.x >= b.x && br.y >= b.y && br.x + br.w <= b.x + b.w && br.y + br.h <= b.y + b.h, "block {bl:?} outside bounds {b:?}");
|
|
for y in br.y..br.y + br.h {
|
|
for x in br.x..br.x + br.w {
|
|
let i = ((y - b.y) * b.w + (x - b.x)) as usize;
|
|
assert!(!grid[i], "overlap at {x},{y}");
|
|
grid[i] = true;
|
|
}
|
|
}
|
|
}
|
|
if exact {
|
|
if let Some(i) = grid.iter().position(|v| !v) {
|
|
panic!("gap at {},{}", b.x + i as i32 % b.w, b.y + i as i32 / b.w);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn squarify_basic() {
|
|
let b = r(2, 3, 60, 20);
|
|
let items = [it(1, 6.0), it(2, 6.0), it(3, 4.0), it(4, 3.0), it(5, 2.0), it(6, 2.0), it(7, 1.0)];
|
|
let blocks = squarify(&items, b);
|
|
assert_eq!(blocks.len(), items.len());
|
|
check_partition(&blocks, b, true);
|
|
// Areas roughly proportional.
|
|
for bl in &blocks {
|
|
let want = b.area() as f64 * items[(bl.id - 1) as usize].size / 24.0;
|
|
let got = bl.rect.area() as f64;
|
|
assert!(got >= want * 0.6 - 4.0 && got <= want * 1.4 + 4.0, "block {} area {got} want ~{want}", bl.id);
|
|
}
|
|
// Deterministic regardless of input order.
|
|
let mut rev = items.to_vec();
|
|
rev.reverse();
|
|
assert_eq!(blocks, squarify(&rev, b), "layout depends on input order");
|
|
}
|
|
|
|
#[test]
|
|
fn squarify_edge_cases() {
|
|
assert!(squarify(&[], r(0, 0, 10, 10)).is_empty());
|
|
assert!(squarify(&[it(1, 0.0), it(2, -5.0)], r(0, 0, 10, 10)).is_empty(), "zero sizes should produce nothing");
|
|
assert!(squarify(&[it(1, f64::NAN), it(2, f64::INFINITY)], r(0, 0, 10, 10)).is_empty());
|
|
for b in [r(0, 0, 0, 0), r(0, 0, 1, 1), r(0, 0, 1, 50), r(0, 0, 50, 1), r(0, 0, -3, 4)] {
|
|
let bl = squarify(&[it(1, 5.0), it(2, 3.0), it(3, 1.0)], b);
|
|
if !b.empty() {
|
|
check_partition(&bl, b, true);
|
|
}
|
|
}
|
|
// One enormous item and many tiny ones.
|
|
let mut items = vec![it(0, 1e15)];
|
|
items.extend((1..500).map(|i| it(i, 1.0)));
|
|
check_partition(&squarify(&items, r(0, 0, 80, 24)), r(0, 0, 80, 24), true);
|
|
}
|
|
|
|
#[test]
|
|
fn resize_recomputes() {
|
|
let items = [it(1, 50.0), it(2, 30.0), it(3, 20.0)];
|
|
for b in [r(0, 0, 80, 24), r(0, 0, 120, 40), r(0, 0, 300, 80), r(0, 0, 70, 20), r(0, 0, 180, 50)] {
|
|
let bl = squarify(&items, b);
|
|
check_partition(&bl, b, true);
|
|
assert_eq!(bl.len(), 3, "{b:?}");
|
|
}
|
|
}
|
|
|
|
/// The Go fuzz target's property, run over fixed seeds.
|
|
#[test]
|
|
fn squarify_random_partitions() {
|
|
let mut rng = Rng::new(1);
|
|
for _ in 0..300 {
|
|
let (w, h, n) = (rng.intn(400) as i32, rng.intn(150) as i32, rng.intn(3000) as usize);
|
|
let items: Vec<Item> = (0..n)
|
|
.map(|i| {
|
|
let bits = rng.intn(40) + 1;
|
|
it(i as i64, rng.intn(1 << bits) as f64)
|
|
})
|
|
.collect();
|
|
let b = r(rng.intn(10) as i32, rng.intn(10) as i32, w, h);
|
|
let bl = squarify(&items, b);
|
|
if !b.empty() && !bl.is_empty() {
|
|
check_partition(&bl, b, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn visible_counts() {
|
|
let sizes = [100.0, 50.0, 10.0, 1.0, 1.0, 1.0];
|
|
assert_eq!(visible(&sizes, 163.0, 1630, 20, 100), 3);
|
|
assert_eq!(visible(&sizes, 163.0, 1630, 20, 2), 2, "max_items");
|
|
assert_eq!(visible(&[10.0, 1.0], 11.0, 1100, 200, 10), 2, "single leftover");
|
|
}
|
|
|
|
#[test]
|
|
fn at_finds_block() {
|
|
let bl = [Block { id: 1, rect: r(0, 0, 5, 5) }, Block { id: 2, rect: r(5, 0, 5, 5) }];
|
|
assert_eq!(at(&bl, 6, 2), Some(2));
|
|
assert_eq!(at(&bl, 10, 2), None);
|
|
}
|
|
}
|