Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to HDTreeMap are documented here.
## Unreleased

### Added
- A third shading style, **Bevel**: each file is a raised tile with an even fill, a light
rim along its top and left edges and a dark rim along the bottom and right, lit from the
upper left like the cushions but with hard edges. The toolbar's Cushions checkbox is now
a Cushion / Flat / Bevel switch, and View ▸ Cushion Shading is a View ▸ Shading submenu.
Settings store the style as `shading` (keeping `cushions` so 0.1.1 still reads them).
- A fourth style, **City**: the map as a cityscape seen from above and a little to the
lower right. Every file is a building whose footprint is its rectangle less a street on
the right and bottom (wider along folder boundaries, so folders read as city blocks),
and whose height grows with the cube root of its size, so the space hogs stand up as
towers. Drawn in an oblique projection with shaded walls; hover, selection and hit
testing still use the footprint.
- `run-windows.cmd`: the Windows counterpart of `run-mac.command`, a double-click
launcher that builds a release binary and starts the app, passing its arguments through
(`[PATH | --demo ID]`).
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ hdtreemap-core/ rlib: everything testable, no platform code
src/settings.rs remembered settings + recent scans as JSON; SettingsStore trait
src/demo_fs/ DemoScanner + per-drive recipes for the simulated drives (all targets)
src/layout/ squarified cushion layout (Y-down physical pixels), aggregates, hit grid
src/render/ palette (by type or top folder) + cushion rasteriser (rayon row bands
natively), HDTREEMAP_STATS (stats_line; a shell can redirect it)
src/render/ palette (by type or top folder) + rasteriser (cushion, flat, bevel;
rayon row bands natively), city.rs (the cityscape: buildings, streets,
oblique projection), HDTREEMAP_STATS (stats_line; a shell can redirect it)
src/state.rs AppState (screen, running scan, finished result, with_result over a
live or finished ScanView); state/pump.rs polls scans and folder
rescans once per frame (an agg-gui ui_thread drain hook); live.rs:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ server). It never reads anything on your computer.
- **Correct totals.** Stays on one filesystem, doesn't follow symlinks, and counts hard
links (and, on macOS, APFS clones) once. Unreadable folders are counted, not fatal.
"Size on disk" or apparent size, switchable without rescanning.
- **Squarified cushion treemap**, coloured by file type or by top-level folder; folders
- **Squarified cushion treemap** (or flat blocks, bevelled tiles, or a cityscape whose
buildings rise with file size), coloured by file type or by top-level folder; folders
too small to open and leftover small files show as grey blocks, so all space is shown.
- **Three linked views.** A folder tree beside the map, sorted by size with each folder's
share, and a legend of file types with sizes, shares and file counts. Select or hover in
Expand Down
14 changes: 11 additions & 3 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Algorithms:
- **Move to Trash…** (confirmed first; uses the `trash` crate, never a permanent delete). The node's size is then subtracted from the tree and the map re-laid out.
- Menu bar (native on macOS through agg-gui-shell):
- File: Open Folder, Rescan, Quit
- View: Size Mode, Cushion Shading on/off, Colour By (Extension / Top Folder), Zoom Out
- View: Size Mode, Shading (Cushion / Flat / Bevel / City), Colour By (Extension / Top Folder), Zoom Out
- Help: About

**Web demo (wasm, GitHub Pages):** the full app running in the browser against a **simulated drive**: a deterministic, realistic synthetic file tree (OS folders, apps, photos, videos, `node_modules`, caches…) generated by `hdtreemap-core::demo_fs`. The "scan" streams that tree at a believable pace, so the progress screen and the live map during the scan are shown too. Every menu, view and action works, with platform actions simulated: Move to Trash removes the node from the model, and Reveal/Open show a toast saying it's a demo. A banner makes clear it is a demo. The start screen offers several descriptive simulated drives, each with its own tree profile:
Expand Down Expand Up @@ -88,7 +88,7 @@ HDTreeMap/
src/scan/ walker (jwalk), progress, cancel, inode dedup, fs boundaries
src/model/ arena tree, ext table, size modes, subtract-on-delete
src/layout/ squarify + cushion coefficients (pure, no drawing)
src/render/ cushion rasteriser → RGBA buffer, palette
src/render/ cushion rasteriser → RGBA buffer, palette; city.rs: the cityscape painter
src/ui/ widgets: TreemapView, FolderTree, ExtLegend, Breadcrumb, StatusBar, StartScreen
src/platform.rs trait PlatformServices { pick_folder, reveal, open, trash }
hdtreemap-native/ tiny bin: ShellConfig + agg_gui_shell::run, implements PlatformServices
Expand Down Expand Up @@ -190,7 +190,15 @@ Scanning is behind a `ScanSource` abstraction: `FsScanner` (native only, `cfg(no
- Performance target: under 10 ms per frame when nothing has changed (agg-gui rule), and under about 150 ms to re-rasterise a 4K map with a million files. Both are measured with a stats env var, following the `CRITTERS_STATS` pattern.
- *As built (M2):* `I = Ia + max(0, Is·n·L)` with `Ia = 0.15`, `Is = 1 − Ia`, `L` ∝ (−1, −1, 10),
times a 1.2 brightness gain, clamped. Flat mode darkens each rectangle's right and bottom
pixel line. Rows go in 16-row bands (rayon natively, serial on wasm). `HDTREEMAP_STATS=1`
pixel line. Bevel mode fills like flat and adds a two-pixel rim (one pixel on small
rectangles, none under 3 px): the top and left moved 45% towards white, the bottom and
right at half brightness. Cityscape mode (`render/city.rs`) makes each rectangle a
building: a street of 1 px on its right and bottom, +1.5 px per enclosing folder that
ends on that edge (at most 6 px, or 40% of the side); height = tallest × ∛(size / biggest
in view), the tallest being a tenth of the raster's shorter side within 8–56 px, and flat
under 2 px; drawn in an oblique projection (roof moved by (−½, −1) per pixel of height,
right wall at 0.7, bottom wall at 0.5) with a per-band depth buffer, so the bands stay
independent. Rows go in 16-row bands (rayon natively, serial on wasm). `HDTREEMAP_STATS=1`
prints each layout and raster pass and, once a second, the CPU layout+paint time per
frame; `examples/bench_treemap` times them headless. Measured on a loaded 10-core Apple
Silicon machine, archive-server demo (1.56 M files) at 3840×2160: layout 45–90 ms, raster
Expand Down
2 changes: 1 addition & 1 deletion hdtreemap-core/src/layout/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ impl PxRect {

/// Pixels whose centre `i + 0.5` lies in `[from, to)`. Adjacent spans never overlap and leave
/// no gap, which is what makes the painted rectangles tile the raster exactly.
fn pixel_span(from: f64, to: f64, limit: u32) -> (u32, u32) {
pub(crate) fn pixel_span(from: f64, to: f64, limit: u32) -> (u32, u32) {
let first = (from - 0.5).ceil().clamp(0.0, f64::from(limit));
let end = (to - 0.5).ceil().clamp(0.0, f64::from(limit));
(first as u32, (end as u32).max(first as u32))
Expand Down
194 changes: 194 additions & 0 deletions hdtreemap-core/src/render/city.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//! The cityscape painter ([`Shading::Cityscape`](super::Shading::Cityscape)): the treemap
//! as a city seen from above and a little to the lower right. Every painted rectangle is a
//! building. Its footprint is the rectangle less a street along its right and bottom edges,
//! wider where a folder boundary runs (one step per enclosing folder that ends on that
//! edge), so folders read as city blocks between avenues. Its height grows with the cube
//! root of its size, up to [`max_height`] for the biggest item in view, so the space hogs
//! stand up as towers and the small fry stay low. Buildings are drawn in an oblique
//! projection: the roof is the footprint moved by [`RISE`] per pixel of height (up and a
//! little to the left), and the right and bottom walls fill in below it, shaded darker as
//! they face away from the light. A per-band depth buffer settles which building shows at
//! a pixel, so the bands stay independent and the paint order does not matter.
//!
//! Only the pixels are 3D: hit testing, hover and the outlines still use the footprint.

use super::palette::Rgb;
use super::raster::{to_byte, Band, FLAT_INTENSITY};
use crate::layout::{pixel_span, TreemapLayout, NO_PARENT};
use crate::model::Tree;

/// Where a roof moves per pixel of height: half a pixel left and a full pixel up.
pub const RISE: [f32; 2] = [-0.5, -1.0];
/// The tallest building is this share of the raster's shorter side, within the limits.
const HEIGHT_SHARE: f32 = 0.1;
/// The tallest building is never shorter than this (a small window still shows relief)…
pub const MIN_MAX_HEIGHT: f32 = 8.0;
/// …nor taller than this (a 4K map does not become a forest of spires).
pub const MAX_MAX_HEIGHT: f32 = 56.0;
/// Footprints narrower than this on either side stay flat: ground tiles, not needles.
const FLAT_BELOW: f32 = 2.0;
/// A plain street between neighbours, in pixels.
pub const STREET: f32 = 1.0;
/// Extra street width per folder boundary that runs along an edge.
pub const STREET_PER_FOLDER: f32 = 1.5;
/// A street never gets wider than this…
const STREET_MAX: f32 = 6.0;
/// …nor takes more than this share of a rectangle's side.
const STREET_SHARE: f32 = 0.4;
/// Wall intensities relative to the roof: the right wall is lit a little by the ambient
/// light, the bottom wall is in shadow.
pub const RIGHT_WALL: f32 = 0.7;
pub const BOTTOM_WALL: f32 = 0.5;

/// One building: its footprint in raster pixels (the rectangle less its streets) and its
/// height in pixels of rise.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Building {
pub x0: f32,
pub y0: f32,
pub x1: f32,
pub y1: f32,
pub height: f32,
}

impl Building {
/// The pixel rows the building's projection covers, clamped to `0..limit`.
pub fn pixel_rows(&self, limit: u32) -> (u32, u32) {
let top = self.y0 + self.height * RISE[1];
pixel_span(f64::from(top), f64::from(self.y1), limit)
}

/// The pixel columns the building's projection covers, clamped to `0..limit`.
pub fn pixel_cols(&self, limit: u32) -> (u32, u32) {
let left = self.x0 + self.height * RISE[0];
pixel_span(f64::from(left), f64::from(self.x1), limit)
}
}

/// The height of the biggest item in a raster of this size.
pub fn max_height(width: u32, height: u32) -> f32 {
(width.min(height) as f32 * HEIGHT_SHARE).clamp(MIN_MAX_HEIGHT, MAX_MAX_HEIGHT)
}

/// A building for each of `leaves` (indices of painted rectangles in `layout.rects()`), in
/// the same order.
pub fn plan(
layout: &TreemapLayout,
tree: &Tree,
leaves: &[usize],
width: u32,
height: u32,
) -> Vec<Building> {
let rects = layout.rects();
let size_of = |i: usize| match layout.aggregate(i) {
Some(merged) => merged.bytes,
None => rects.get(i).map_or(0, |r| tree.size(r.node)),
};
let biggest = leaves.iter().map(|&i| size_of(i)).max().unwrap_or(0).max(1) as f64;
let tallest = max_height(width, height);
leaves
.iter()
.filter_map(|&i| rects.get(i).map(|r| (i, r)))
.map(|(i, r)| {
// Every enclosing folder that ends on an edge widens that edge's street; the
// zoom root's edges are the map's and get none.
let (mut right, mut bottom) = (STREET, STREET);
for a in layout.ancestors(i) {
let Some(folder) = rects.get(a) else { break };
if folder.parent == NO_PARENT {
break;
}
if (folder.rect.right() - r.rect.right()).abs() < 1e-6 {
right += STREET_PER_FOLDER;
}
if (folder.rect.bottom() - r.rect.bottom()).abs() < 1e-6 {
bottom += STREET_PER_FOLDER;
}
}
let (w, h) = (r.rect.w as f32, r.rect.h as f32);
let right = right.min(STREET_MAX).min(w * STREET_SHARE);
let bottom = bottom.min(STREET_MAX).min(h * STREET_SHARE);
let (x0, y0) = (r.rect.x as f32, r.rect.y as f32);
let (x1, y1) = (x0 + w - right, y0 + h - bottom);
let height = if (x1 - x0).min(y1 - y0) < FLAT_BELOW {
0.0
} else {
tallest * (size_of(i) as f64 / biggest).cbrt() as f32
};
Building {
x0,
y0,
x1,
y1,
height,
}
})
.collect()
}

/// The depth of a point `height` above ground point `(x, y)`: larger is nearer the viewer.
/// Points that land on the same pixel lie on one line of sight, along which this grows.
fn depth_key(x: f32, y: f32, height: f32) -> f32 {
-RISE[0] * x - RISE[1] * y + height
}

/// Paint `building` in `color` into the band's rows, keeping whatever is nearer in `depth`
/// (one entry per band pixel, `NEG_INFINITY` where nothing has been painted).
pub(super) fn paint(mut band: Band<'_>, building: &Building, color: Rgb, depth: &mut [f32]) {
let (cx0, cx1) = building.pixel_cols(band.width);
let (ry0, ry1) = building.pixel_rows(band.height);
let y0 = ry0.max(band.first_row);
let y1 = ry1.min(band.first_row + band.rows);
if cx0 >= cx1 || y0 >= y1 {
return;
}
let roof = color.map(|c| to_byte(c * FLAT_INTENSITY));
let right_wall = color.map(|c| to_byte(c * FLAT_INTENSITY * RIGHT_WALL));
let bottom_wall = color.map(|c| to_byte(c * FLAT_INTENSITY * BOTTOM_WALL));
let (ox, oy) = (building.height * RISE[0], building.height * RISE[1]);
let b = building;
for y in y0..y1 {
let py = y as f32 + 0.5;
let from = ((y - band.first_row) * band.width + cx0) as usize;
let to = ((y - band.first_row) * band.width + cx1) as usize;
let row = band.row(y, cx0, cx1);
let depths = &mut depth[from..to];
for (i, (px, d)) in row
.as_chunks_mut::<4>()
.0
.iter_mut()
.zip(depths)
.enumerate()
{
let sx = (cx0 + i as u32) as f32 + 0.5;
let on_roof = sx >= b.x0 + ox && sx < b.x1 + ox && py >= b.y0 + oy && py < b.y1 + oy;
let (gx, gy, z, rgb) = if on_roof {
(sx - ox, py - oy, b.height, roof)
} else if b.height <= 0.0 {
continue;
} else {
// The right wall sweeps the footprint's right edge up along the rise; a
// pixel on it is `s` of the way up. The bottom wall likewise.
let s = (sx - b.x1) / ox;
let gy = py - s * oy;
if (0.0..1.0).contains(&s) && gy >= b.y0 && gy < b.y1 {
(b.x1, gy, s * b.height, right_wall)
} else {
let s = (py - b.y1) / oy;
let gx = sx - s * ox;
if (0.0..1.0).contains(&s) && gx >= b.x0 && gx < b.x1 {
(gx, b.y1, s * b.height, bottom_wall)
} else {
continue;
}
}
};
let key = depth_key(gx, gy, z);
if key > *d {
*d = key;
px[..3].copy_from_slice(&rgb);
px[3] = 255;
}
}
}
}
10 changes: 7 additions & 3 deletions hdtreemap-core/src/render/mod.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
//! Treemap rendering (`docs/PLAN.md` §3.4): colours and the per-pixel cushion rasteriser.
//!
//! - [`palette`] — a hue per top extension (or per top-level folder), greys for the rest.
//! - [`raster`] — paints a `layout::TreemapLayout` into an RGBA buffer, cushioned or flat,
//! in parallel row bands natively.
//! - [`raster`] — paints a `layout::TreemapLayout` into an RGBA buffer, cushioned, flat,
//! bevelled or as a cityscape, in parallel row bands natively.
//! - [`city`] — the cityscape: buildings with streets between, in an oblique projection.
//!
//! The `HDTREEMAP_STATS` environment variable ([`stats_enabled`]) makes the treemap view
//! print how long each layout and raster pass took ([`stats_line`]; the web demo turns it
//! on with `?stats` and logs to the console).

pub mod city;
pub mod palette;
pub mod raster;

pub use palette::{ColorBy, Palette, Rgb, FOLDER_BLOCK, HUES, OTHER_FILES};
pub use raster::{cushion_intensity, rasterise, Shading, AMBIENT, BACKGROUND, BRIGHTNESS, LIGHT};
pub use raster::{
cushion_intensity, rasterise, Shading, AMBIENT, BACKGROUND, BEVEL_WIDTH, BRIGHTNESS, LIGHT,
};

/// Where stats lines go when stats are on: stderr natively, or a sink a shell installed
/// ([`enable_stats`]; the web shell logs to the browser console).
Expand Down
Loading