Skip to main content

dumap_layout/
squarify.rs

1use crate::rect::LayoutRect;
2use dumap_core::tree::{FileTree, NodeId, NodeKind};
3
4/// Configuration for the layout algorithm.
5#[derive(Debug, Clone)]
6pub struct LayoutConfig {
7    /// Maximum depth to lay out (relative to visible root).
8    pub max_depth: u16,
9    /// Minimum rectangle dimension in pixels. Rectangles smaller than this
10    /// are not subdivided further.
11    pub min_rect_size: f64,
12    /// Padding between directory boundaries (pixels per side).
13    pub padding: f64,
14    /// Height reserved at the top of each directory for the name header (pixels).
15    /// Set to 0.0 to disable directory headers.
16    pub header_height: f64,
17}
18
19impl Default for LayoutConfig {
20    fn default() -> Self {
21        Self {
22            max_depth: 3,
23            min_rect_size: 2.0,
24            padding: 3.0,
25            header_height: 20.0,
26        }
27    }
28}
29
30/// A laid-out treemap entry: pairs a file tree NodeId with its screen rectangle.
31#[derive(Debug, Clone)]
32pub struct LayoutEntry {
33    pub node_id: NodeId,
34    pub rect: LayoutRect,
35    /// Depth relative to the visible root (0 = visible root).
36    pub depth: u16,
37}
38
39/// The complete layout result.
40pub struct TreemapLayout {
41    /// Laid-out entries in parent-before-child order.
42    entries: Vec<LayoutEntry>,
43}
44
45impl TreemapLayout {
46    /// Get all entries.
47    pub fn entries(&self) -> &[LayoutEntry] {
48        &self.entries
49    }
50
51    /// Number of laid-out entries.
52    pub fn len(&self) -> usize {
53        self.entries.len()
54    }
55
56    /// Whether there are no entries.
57    pub fn is_empty(&self) -> bool {
58        self.entries.is_empty()
59    }
60
61    /// Find the deepest entry whose rectangle contains the given point.
62    /// Returns None if no entry contains the point.
63    pub fn hit_test(&self, x: f64, y: f64) -> Option<&LayoutEntry> {
64        // Iterate in reverse (children before parents) to find the deepest match
65        self.entries.iter().rev().find(|entry| {
66            let r = &entry.rect;
67            x >= r.x && x < r.x + r.w && y >= r.y && y < r.y + r.h
68        })
69    }
70}
71
72/// Lay out a subtree of the FileTree starting at `root_id` into `bounds`.
73pub fn squarify_layout(
74    tree: &FileTree,
75    root_id: NodeId,
76    bounds: LayoutRect,
77    config: &LayoutConfig,
78) -> TreemapLayout {
79    let estimated_capacity = tree.len().min(10_000);
80    let mut entries = Vec::with_capacity(estimated_capacity);
81    layout_node(tree, root_id, bounds, 0, config, &mut entries);
82    TreemapLayout { entries }
83}
84
85fn layout_node(
86    tree: &FileTree,
87    node_id: NodeId,
88    rect: LayoutRect,
89    depth: u16,
90    config: &LayoutConfig,
91    out: &mut Vec<LayoutEntry>,
92) {
93    let node = tree.node(node_id);
94
95    // Record this node's layout
96    out.push(LayoutEntry {
97        node_id,
98        rect,
99        depth,
100    });
101
102    // Stop conditions
103    let children = match &node.kind {
104        NodeKind::File { .. } => return,
105        NodeKind::Directory { children } => children,
106    };
107    if depth >= config.max_depth || !rect.is_visible(config.min_rect_size) || children.is_empty() {
108        return;
109    }
110
111    // Apply padding to create visual border for directories
112    let padded = rect.inset(config.padding);
113    if padded.w <= 0.0 || padded.h <= 0.0 {
114        return;
115    }
116
117    // Reserve space at the top for the directory header label
118    let inner =
119        if config.header_height > 0.0 && padded.h > config.header_height + config.min_rect_size {
120            LayoutRect::new(
121                padded.x,
122                padded.y + config.header_height,
123                padded.w,
124                padded.h - config.header_height,
125            )
126        } else {
127            padded
128        };
129    if inner.w <= 0.0 || inner.h <= 0.0 {
130        return;
131    }
132
133    // Children are pre-sorted by total_size descending in FileTree.
134    // Filter out zero-size children.
135    let sized_children: Vec<(NodeId, f64)> = children
136        .iter()
137        .map(|&cid| (cid, tree.node(cid).total_size as f64))
138        .filter(|(_, size)| *size > 0.0)
139        .collect();
140
141    if sized_children.is_empty() {
142        return;
143    }
144
145    // Run squarified algorithm
146    let child_rects = squarify_children(&sized_children, inner);
147
148    // Recurse into each child
149    for (child_id, child_rect) in child_rects {
150        layout_node(tree, child_id, child_rect, depth + 1, config, out);
151    }
152}
153
154/// Core squarified algorithm: given items sorted by size descending and a
155/// bounding rectangle, compute the rectangle for each item.
156///
157/// Algorithm (Bruls, Huizing, van Wijk 2000):
158/// 1. Start with the full rectangle as "remaining" area.
159/// 2. Begin a new row (strip along the shorter side).
160/// 3. Add items one at a time.
161/// 4. After each addition, compute the worst aspect ratio in the row.
162/// 5. If adding the next item would make the worst aspect ratio worse,
163///    finalize the current row and start a new one.
164/// 6. Repeat until all items are placed.
165fn squarify_children(children: &[(NodeId, f64)], bounds: LayoutRect) -> Vec<(NodeId, LayoutRect)> {
166    let total_size: f64 = children.iter().map(|(_, s)| *s).sum();
167    if total_size <= 0.0 {
168        return Vec::new();
169    }
170
171    let mut result = Vec::with_capacity(children.len());
172    let mut remaining = bounds;
173    let mut i = 0;
174
175    while i < children.len() {
176        if remaining.w <= 0.0 || remaining.h <= 0.0 {
177            break;
178        }
179
180        let remaining_size: f64 = children[i..].iter().map(|(_, s)| *s).sum();
181        let shorter = remaining.shorter_side();
182
183        // Build current row
184        let mut row: Vec<(NodeId, f64)> = Vec::new();
185        let mut row_size = 0.0;
186
187        // Add first item (always)
188        row.push(children[i]);
189        row_size += children[i].1;
190        i += 1;
191
192        // Try adding more items
193        while i < children.len() {
194            let worst_before =
195                worst_aspect_ratio(&row, row_size, shorter, remaining_size, remaining.area());
196
197            row.push(children[i]);
198            let candidate_size = row_size + children[i].1;
199            let worst_after = worst_aspect_ratio(
200                &row,
201                candidate_size,
202                shorter,
203                remaining_size,
204                remaining.area(),
205            );
206
207            if worst_after > worst_before {
208                // Adding this item made things worse; remove and finalize row
209                row.pop();
210                break;
211            }
212            row_size = candidate_size;
213            i += 1;
214        }
215
216        // Lay out the finalized row as a strip
217        let row_fraction = row_size / remaining_size;
218        let (strip, rest) = remaining.split_strip(row_fraction);
219        layout_row(&row, row_size, strip, &mut result);
220        remaining = rest;
221    }
222
223    result
224}
225
226/// Compute worst (maximum) aspect ratio among items in a row.
227fn worst_aspect_ratio(
228    row: &[(NodeId, f64)],
229    row_size: f64,
230    shorter_side: f64,
231    total_remaining: f64,
232    remaining_area: f64,
233) -> f64 {
234    if row_size <= 0.0 || shorter_side <= 0.0 || total_remaining <= 0.0 {
235        return f64::MAX;
236    }
237
238    let strip_area = (row_size / total_remaining) * remaining_area;
239    let strip_thickness = strip_area / shorter_side;
240
241    if strip_thickness <= 0.0 {
242        return f64::MAX;
243    }
244
245    let mut worst = 1.0_f64;
246    for &(_, item_size) in row {
247        let item_length = (item_size / row_size) * shorter_side;
248        if item_length <= 0.0 {
249            continue;
250        }
251        let ratio = if item_length > strip_thickness {
252            item_length / strip_thickness
253        } else {
254            strip_thickness / item_length
255        };
256        worst = worst.max(ratio);
257    }
258    worst
259}
260
261/// Lay out items within a finalized row strip.
262fn layout_row(
263    row: &[(NodeId, f64)],
264    row_size: f64,
265    strip: LayoutRect,
266    out: &mut Vec<(NodeId, LayoutRect)>,
267) {
268    if row_size <= 0.0 {
269        return;
270    }
271
272    let mut offset = 0.0;
273    for &(node_id, item_size) in row {
274        let fraction = item_size / row_size;
275        let item_rect = if strip.is_wide() {
276            LayoutRect {
277                x: strip.x + offset * strip.w,
278                y: strip.y,
279                w: fraction * strip.w,
280                h: strip.h,
281            }
282        } else {
283            LayoutRect {
284                x: strip.x,
285                y: strip.y + offset * strip.h,
286                w: strip.w,
287                h: fraction * strip.h,
288            }
289        };
290        out.push((node_id, item_rect));
291        offset += fraction;
292    }
293}
294
295#[cfg(test)]
296#[path = "squarify_tests.rs"]
297mod squarify_tests;