Skip to content

Treemap legibility: floors, plates, seams, labels and depth - #4

Open
Li3age wants to merge 13 commits into
gitRasheed:mainfrom
Li3age:feat/treemap-legible
Open

Li3age wants to merge 13 commits into
gitRasheed:mainfrom
Li3age:feat/treemap-legible

Conversation

@Li3age

@Li3age Li3age commented Sep 20, 2026

Copy link
Copy Markdown

Closes #2.

Thirteen commits that make the treemap readable: blocks that tile instead of
leaving holes, folders drawn as blocks rather than as a texture, labels that
cannot move the geometry, and a Depth setting that offers the adaptive layout
and the fixed levels as real alternatives.

The floor raises small blocks instead of culling them

min_side_px was read as a cut-off line: a child under it was skipped, but
its share of the frame stayed reserved, so a directory that was mostly small
files came out as a patch of flat nothing. It is a floor now — a child below
the line is raised to one minimum block and drawn anyway, so the directory
tiles edge to edge. What still does not fit at that size is dropped
smallest-first, and the survivors re-normalize into the space, so dropping
leaves no hole either. SpaceSniffer does the same, and it is the right way
round: nobody opens this to look at 4 KB files.

A row can still come out too thin to read — one file holding 99.985% of a
block squeezes the rest into a 0.09px column, which hit-testing then wins for
the whole height of it, because it walks deepest-first. Laying out into a
scratch row list before emitting anything lets the fit check the result and
shed from the first row that gives out.

Depth is decided by pixels, not by a count

A directory subdivides only while three things hold: its interior can hold a
legible child, its biggest child would land well clear of the floor (twice
it — DETAIL_FACTOR), and it has room to give each child a block worth
looking at (COMFORT_PX, 32px squared — a capacity derived from the folder's
own area, not a fixed count, because two hundred children in a narrow folder
and two hundred in a wide one are the same number and not the same picture).

Without the middle gate, folders nest into folders of specks: every level
spends padding to cut the next one finer, until the map is a field of 6px
blocks nobody can read or point at. Measured on /usr/share (337k nodes):
3686 rects, down from 6969 with the gate off.

Seams are one width

Every visible seam was supposed to be the renderer's 1px, but the layout
inset each directory's children by another 1px, so the two stacked: two
siblings showed a 1px seam and two blocks from different parents showed 3px.
Measured along one row of the real window: runs of 1, 2 and 3 pixels.
padding_px is gone, so a child's frame is its parent's frame whole and
every seam is the same 1px at any depth.

Folders are drawn like files

A folder the layout stopped short of subdividing read as nothing there at
all, which made "show one folder" show an absence. Plates are a flat fill at
FOLDER_PLATE (#55618f) — a colour picked as a surface this time, since
folders cover more of the map than anything else and a dark, low-saturation
blue over that much area reads as a muddy wash — with the same 1% gap and
the same sheen files have. The grain tile, its cache and the seam pass are
gone: a 1px gap against the backdrop does what the seam was invented to do.

A directory that did subdivide is no longer painted at all — it is only
the backdrop for what it holds, and painting it put a ring of folder colour
around every level of the map. What shows instead is the map's own surface,
the neutral grey that was there before.

Labels cannot move the geometry

Turning labels on used to reserve a strip at the top of every directory for
its name, so the geometry with labels was not the geometry without them.
label_px is gone from the layout entirely and get_treemap no longer takes
a labels flag — there is now no way for the geometry to know, so toggling
is a repaint rather than a refetch. Names are painted into blocks that have
room for them, and only into solid ones: a block the layout subdivided is
covered by its children, and a name centred in it would land on top of them.

A click acts at once, and only the map moving is animated

Opening a folder waited 300ms to find out whether the click was half of a
double click. That wait was the whole of the delay — the expand itself was
instant — and it made the double click worse than either gesture alone:
nothing on the first click, then a zoom, so the two halves read as a pause
and then an unrelated jump. Both go away by letting each click do its own
thing. A click on a plate opens it in place immediately; the second click of
a double click then zooms the folder that click opened — by identity, not by
hit test, because by then the layout has been replaced by what that folder
held and the cursor is over one of its children.

The opening is animated, since it is the one layout change that is
demonstrably the same blocks at different sizes. An earlier attempt grew the
children out of the plate and looked like what it was: three hundred blocks
leaving the same point at once, each carrying the sheen that makes a block
read as raised, stacked until the middle of the plate went white. It is a
crossfade now. Every other change — a scan tick, a resize, a filter — snaps,
because those are not the map moving, they are the map being redrawn.

Depth offers both layouts

The adaptive rules are what the map should do by default, but they had
displaced the fixed-depth layout entirely, and a fixed depth is a legitimate
thing to want from a treemap. Depth now offers Auto (the adaptive layout)
beside All, 1, 2, 3 (the original levels, where the cap is
the whole rule).

That is not just a shorter Auto. adaptive_depth: false stands the
legibility gates down — a cap takes every level it is allowed to and asks
nothing about whether the level is worth taking — which is what makes the
four levels a real alternative rather than four ways to say the same thing.
It comes with the picture that implies: All goes back to a mesh of small
blocks, deliberately. The caps nest, so the control is a dial and not four
unrelated pictures, and switching cannot make the blocks above the line jump.

Smaller things in here

  • layout_with_force takes one id and opens it plus every ancestor between
    it and the layout root. One value carries the whole accordion: asking for a
    directory outside the open one leaves the old one off the chain, so it
    closes. A forced layout is a superset of the unforced one — pinned as
    that rather than as the weaker prefix property — which is what lets the map
    expand in place without a jump.
  • The recursion bound is deliberately not lifted by a forced layout: it is
    the only brake on a tree whose depth is unbounded, and a click that lands
    deep should not be what removes it.
  • An open folder is dropped when the depth setting changes, rather than left
    set to something the map is not doing.
  • Shedding is proportional now rather than "drop everything the failing row
    was holding". A row is fit or unfit only as part of a whole layout — the
    same run of equal blocks tiles legibly at some counts and not others — so
    stopping at the first failure left a directory of equals as one plate.
  • Two fixtures that trip the new capacity limit were resized to stay under
    it: their subject is the legibility rules, and their 210 and 640 children
    were incidental to that.
  • Zero-size children emit nothing and nothing is NaN (pinned by a test).

Heads up, unrelated to this PR

cargo clippy --workspace fails on crates/scanner-ntfs/src/record.rs:276
with clippy::chunks_exact_to_as_chunks, which is new in clippy 1.98
(-D warnings makes it fatal). The repo doesn't pin a toolchain and CI uses
@stable, so I think main is red for this too. Happy to send that fix
separately if useful.

A treemap that draws every file it can subdivide is a mosaic: on a real
drive most of the pixels are specks too small to identify, and finding out
what any of them is costs a hover, one block at a time. That is the whole of
the "visual overload" complaint, and no depth setting fixes it — the setting
the issue asked for decides how much of the tree is drawn, not whether what
is drawn can be read.

Three changes, all in the layout:

Culling is by side, not area. `min_area_px: 3.0` accepted a 40×0.1 sliver
because 4 px² cleared the floor; `min_side_px` requires both dimensions, so
what survives is something a person can actually see. Culled children keep
eating their share of the parent, as before — that contract is what makes
the parent's area honest, and the test that pinned it still does.

A directory reserves a strip at the top of its interior for its label, and
its children tile what is left. The strip is taken from the *children's*
frame and never from the directory's own rect: that rect has to stay a
function of its own frame alone, or a depth cap that stops recursion early
would shift the rects that survive it, and the UI switches depth live. The
test that pins that prefix property runs with a strip on.

Depth became adaptive. A directory subdivides only while its interior can
still hold a legible child — padding, strip and padding again come out of
every level, so a branch runs out of room and stops on its own. Big folders
go deep because they have the pixels; small ones stay a plate. max_depth is
now only an upper bound on top of that, which leaves room for the explicit
depth preference to land on top of it without fighting it.

The rect carries the text too. Names come from `tree.name(id)` at the
DTO boundary rather than through `TreemapRect`, which stays `Copy` and
geometry-only for the layout's own tests; one string per visible rect is
also far cheaper than the round trip per node it would otherwise take to
label a block.
The canvas drew coloured rectangles and nothing else, so the map answered
"where did the space go" and only answered "what is this" through a tooltip,
one block at a time. Now every block that can hold a line of text carries its
name, and the sizes ride along: folded into a directory's label at the top of
its plate, centred under a file's name in the block itself. This is what
SpaceSniffer does, and it is why its map reads at a glance instead of needing
to be interrogated.

Nesting is drawn as a shade rather than a texture. The old grain said
"there is something in here too small to draw", which is accurate and also
impossible to tell apart from a block that is simply empty; a fill that
steps away from the theme's plate colour once per level instead says how deep
you are, on the plate itself, and reads the same light or dark — the step
flips direction with the theme. The grain tile and the `--color-plate-grain`
token it needed are gone.

Text picks its own ink: file blocks keep fixed category colours whatever the
theme, so choosing a theme token for the text under them would be choosing it
for the wrong surface. Blocks too short to hold their label's strip are left
unlabelled rather than drawn half outside themselves — with a six-pixel
legibility floor those are the plates the layout already stopped
subdividing, and a label hanging out of its own block reads as a label for
whatever is beneath it.
Reverts the look from the previous two commits. The shade ramp, the names
written into every block and the strip reserved for them borrowed more from
SpaceSniffer than this app wants: mathom's treemap is a map of coloured
blocks and that is the thing worth keeping. What survives is the part that
was never about looks — the legibility floor and the adaptive subdivision,
which decide how much gets drawn rather than how it is painted.

Naming the blocks is still worth having, so it moves behind Settings → View
as "Show names in treemap", off by default like the extra chrome it is. Off,
the layout reserves no strip at all (`label_px` is zero), so the geometry is
exactly what it was before labels existed. On, the label strip is the one
part of a plate that carries no grain — a name read through the texture is a
name you have to squint at — and the plates are otherwise the flat colour and
dots they have always been.
`min_side_px` was read as a cut-off line: a child under it was skipped,
but its share of the frame stayed reserved, so a directory that was
mostly small files came out as a patch of flat nothing. SpaceSniffer
does the opposite, and it is the right way round — the small stuff is
drawn at one uniform minimum size so the directory tiles edge to edge,
and only when even that does not fit does the smallest go. Nobody opens
this thing to look at 4KB files anyway.

So the layout now floors every child at `min_side²`, sheds the tail
until the floored weights fit the frame, and re-normalizes the
survivors into exactly what is left. Dropping is what pays for the
floor, and because the survivors renormalize, the space the dropped
ones held flows into the blocks that remain rather than opening a hole.

A row can still come out too thin to read — one file holding 99.985% of
a block squeezes the rest into a 0.09px column, which hit-testing wins
for the whole height of it, since it walks deepest-first. Laying out
into a scratch row list before emitting anything lets the fit check the
result and shed from the first row that gives out. `keep` only ever
shrinks and a lone block fills the frame, so it terminates.

The rendering side is untouched: still flat plates and grain, labels
still behind the setting. Measured on /usr/share (337k nodes): 1826
rects in 4ms, where strictly-proportional layout gives 86754.
Two things the last round got wrong, both visible.

**Folding folders nested into meshes.** A directory subdivided as long as
its interior could hold one minimum block, so a folder of packages became
a folder of packages of specks — every level spending padding to cut the
next one finer, until the map was a field of 6px blocks nobody could read
or point at. A level is now taken only when the *biggest* child would
land well clear of the floor (twice it, on a side). A folder whose best
child is still a speck says more as one plate. Measured on /usr/share
(337k nodes): 3686 rects, down from 6969 with the gate off.

**Labels moved the blocks.** Turning the mode on reserved a strip at the
top of every directory for its name, so the geometry with labels on was
not the geometry with them off. `label_px` is gone from the layout
entirely and `get_treemap` no longer takes a `labels` flag: there is now
no way for the geometry to know, so toggling is a repaint rather than a
refetch. Names are painted into blocks that have room for them, and only
into *solid* ones — a block the layout subdivided is covered by its
children, and a name centred in it would land on top of them.

Which leaves the seam. A solid folder plate had nothing to say where it
ended: the grain under it is one continuous pattern and the parent's
plate fills the gaps, so thirty equal folders drew as a single flat
field — "show one folder" showed nothing. Solid plates now get the 1px
seam the file blocks always had, painted in the background colour.
Plates that have children get nothing, or every subdivided directory
would ring itself in a hairline frame.

Shedding is proportional now rather than "drop everything the failing row
was holding". A row is fit or unfit only as part of a whole layout — the
same run of equal blocks tiles legibly at some counts and not others — so
stopping at the first failure left a directory of equals as one plate.
A slice at a time converges from any starting point and drops the
smallest first, which is the order this was always meant to drop in.
Two additions to what decides whether a directory becomes a plate.

A directory with more than 200 direct children is one now, by default.
At that width its blocks stop being things you can point at and become a
texture, and a plate you can click says more. Counted from the children
that would actually be drawn, not from `tree.children`, so a filter or
hide_system can bring a directory back under the limit — and counted
before the sort, so the directories this is meant to keep off the map
never pay to order their children.

`layout_with_force` takes one id and opens it, plus every ancestor
between it and the layout root. One value carries the whole accordion:
asking for a directory outside the open one leaves the old one off the
chain, so it closes; asking for one inside keeps it on, so it stays.

Where the override is consulted matters more than what it does. Both
gates return before a single child is placed, so it never reaches
`items`, `scale`, `fit` or a frame — opening a directory adds rects
under it and moves nothing. A forced layout is a *superset* of the
unforced one, which is what lets the map expand in place without a jump,
and it is pinned as that rather than as the weaker prefix property. The
recursion bound is deliberately not lifted: it is the only brake on a
tree whose depth is unbounded, and a click that lands 24 levels down
should not be what removes it.

The two fixtures that trip the new limit were resized to stay under it —
their subject is the legibility rules, and 210 and 640 children were
incidental to that.
Three things that only made sense together.

A folder plate was the app's own background grey, so a folder the layout
stopped short of subdividing read as nothing there at all — which made
"show one folder" show an absence. Plates are now `#3b4a6b`, the one dark
category colour and the only one that is not a file type, so a container
is never mistaken for its contents. It does not move with the theme,
which is why the seam between two plates is now a shade of the plate
rather than the app background: that painted a white hairline through
the map in the light theme. `--color-plate` and `--color-plate-grain`
are gone, both sides in one commit — a `ctx.fillStyle = ""` left behind
by a deleted variable is silently ignored rather than an error.

Single click on a plate opens it in place, through `layout_with_force`.
Everything else clicks as it did: a subdivided folder still zooms, a
file still only selects. The double click that used to do nothing is now
what zooms a plate, so the click has to wait out the double-click window
— and only that click. A click on a plate is deferred behind a 300ms
timer; a click on anything else acts on the spot.

And when opening in place would show nothing — the biggest thing inside
still under twice the minimum block — the folder is zoomed instead, with
the override kept so it arrives open rather than as one big plate. That
is the line the layout itself draws between "worth a level" and "not",
so it is the same judgement, applied to an ask that outranks it.
I read the ask as "give folders a colour" and stopped there, so a folder
kept the dot texture files never had. That made a plate read as a
surface full of specks rather than as a block — the opposite of the
point, which was for a folder to look like a file with a different
colour and nothing else.

So: one flat fill, the same 1% gap, the same sheen, same everything.
`FOLDER_PLATE` and the highlight sprite are the whole of it; the grain
tile, its cache and the seam pass are gone, since a 1px gap against the
backdrop does what the seam was invented to do.

A directory that *did* subdivide is no longer painted at all — it is
only the backdrop for what it holds, and painting it put a ring of
folder colour around every level of the map. What shows instead is the
map's own surface, which is the neutral grey that was there before and
that I should not have taken away: it is not the window background, it
is the map's, and it follows the theme in both directions.
Two things the map got wrong, both of them about a number that was not
a number.

**Blocks looked chewed.** Every visible seam was supposed to be the
renderer's 1px, but the layout inset each directory's children by
another 1px, so the two stacked: two siblings showed a 1px seam and two
blocks from different parents showed 3px. Measured along one row of the
real window: runs of 1, 2 and 3 pixels. `padding_px` is gone, so a
child's frame is its parent's frame whole and every seam is the same
1px at any depth. Re-measured: every run is 1.

**A fixed child limit cannot be right.** Two hundred children in one
folder and two hundred in another are the same number and not the same
picture — the first might be a wall of specks and the second a clean
grid. What matters is how much room the folder has, so the limit is now
a capacity derived from its own area: each child gets at least
`COMFORT_PX` squared to sit in, or the folder folds. At 32px that is
100 children in a 320×320 block and 39 in a 200×200 one; above four, so
the count alone never folds a directory holding a handful.

The tests that pinned the padding are replaced by one that pins the
absence of it — every frame in a chain is the frame the root was handed
— and by one where the same directory folds in a small window and opens
in a large one, which a fixed limit cannot do.
`#3b4a6b` was chosen as a colour without being looked at as a *surface*,
and folders cover more of the map than anything else — a dark, low
saturation blue over that much area reads as a muddy wash rather than as
a palette.

`#55618f` is the same family with the value and saturation lifted, which
does two things at once: the map gets a tone instead of a murk, and a
plate now sits clearly above the backdrop so a block reads as raised
rather than as a slightly different patch of background.
The adaptive rules are what the map should do by default, but they had
displaced the fixed-depth layout entirely, and a fixed depth is a
legitimate thing to want from a treemap. Depth now offers both: **Auto**
is the adaptive layout, and **All**, **1**, **2**, **3** are the original
levels, where the cap is the whole rule.

That is not just a shorter Auto. `adaptive_depth: false` stands the
legibility gates down — a cap takes every level it is allowed to and
asks nothing about whether the level is worth taking — which is what
makes the four levels a real alternative rather than four ways to say
the same thing. It comes with the picture that implies: All goes back to
a mesh of small blocks. That is the original behaviour, deliberately,
and it is no longer the default.

Two things follow from the gates standing down. Opening a plate by hand
is a request to override the legibility rules, so it only means
something under Auto; elsewhere a click on a folder goes back to
zooming, which is the same "show me inside this" answered the other way.
And an open folder is dropped when the depth changes, rather than left
set to something the map is not doing.

The caps nest — cap 1 is cap 2 above its line — so the control is a dial
and not four unrelated pictures, and switching cannot make the blocks
above the line jump.
Opening a folder waited 300ms to find out whether the click was half of
a double click. That wait was the whole of the "delay" — the expand
itself was instant — and it made the double click worse than either
gesture alone: nothing visible on the first click, then a zoom, so the
two halves read as a pause and then an unrelated jump.

Both go away by letting each click do its own thing. A click on a plate
opens it immediately; the second click of a double click then zooms the
folder that click opened — by identity, not by hit test, because by then
the layout has been replaced by what that folder held and the cursor is
over one of its children.

And the opening is animated, since it is the one layout change that is
demonstrably the same blocks at different sizes: the plate the user
clicked becomes what was inside it. Every rect that existed before keeps
its identity and slides to its new frame, and the ones that appear grow
out of the plate's old rect — which needs no parent lookup, because
everything revealed is inside that one rect by construction. Only this
change is animated; a scan tick, a resize or a filter snaps, because
those are not the map moving, they are the map being redrawn.
Growing the children out of the plate looked like what it was: three
hundred blocks leaving the same point at once, each carrying the sheen
that makes a block read as raised, stacked on top of each other until
the middle of the plate went white. A flashbang, and the wrong shape for
the change — opening a folder adds rects *inside* the one that was
clicked and moves nothing else, so there is nothing to travel between
and nothing should travel.

It is a crossfade now: the picture as it stands fades into the one that
replaces it. Same duration, same immediacy, no flare.
@Li3age

Li3age commented Sep 20, 2026

Copy link
Copy Markdown
Author

Should I tweak the color of the dir?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: configurable default expand depth (or remember expand state) to avoid visual overload

1 participant