From 281febecf5232bd92667a28482d9c1f079a2b145 Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:14:55 +0800 Subject: [PATCH 1/3] Pin that a depth cap only hides deeper rects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI is about to let the user change how deep the treemap lays out, and re-layouts on every change. That is only smooth because of a property the layout already had without anyone writing it down: a rect's frame comes from its ancestors' sibling sizes and the area cull, never from max_depth, and the cap is tested after the rect is pushed. So the capped layout is exactly the prefix of a deeper one — changing depth dissolves deeper tiles back into their parent plate and moves nothing. Worth a test rather than a comment: the whole feature is unpleasant to use if it ever stops holding, and the assertion compares whole TreemapRects, so any field that starts depending on depth fails here first. The second test pins the other half of the semantics, that depth counts from the laid-out root. An absolute depth would leave a drilled-into folder with nothing left to show at a cap of 1. --- crates/core/src/treemap.rs | 56 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/core/src/treemap.rs b/crates/core/src/treemap.rs index b27707f..da63ec2 100644 --- a/crates/core/src/treemap.rs +++ b/crates/core/src/treemap.rs @@ -553,6 +553,62 @@ mod tests { assert!(rects.iter().all(|r| r.id != 2), "depth-2 file not emitted"); } + /// A cap hides deeper rects without moving the ones that survive: the + /// capped layout is exactly the prefix of a deeper one. The UI can change + /// depth without reflowing a single rectangle. + #[test] + fn a_depth_cap_only_hides_deeper_rects() { + let tree = nested_tree(); + let vp = Viewport { w: 400.0, h: 300.0 }; + + let capped = layout(&tree, 0, vp, &capped_at(2)); + let deep: Vec = layout(&tree, 0, vp, &capped_at(8)) + .into_iter() + .filter(|r| r.depth <= 2) + .collect(); + + assert_eq!(capped, deep); + assert!(capped.iter().any(|r| r.id == 3), "depth-2 dir is emitted"); + assert!(capped.iter().all(|r| r.id != 5), "depth-3 dir is not"); + } + + /// Depth counts from the laid-out root, not the scan root — otherwise + /// drilling into a folder would leave its own children permanently out of + /// reach. + #[test] + fn depth_counts_from_the_laid_out_root() { + let tree = nested_tree(); + let vp = Viewport { w: 400.0, h: 300.0 }; + + let one = layout(&tree, 1, vp, &capped_at(1)); + assert!(one.iter().any(|r| r.id == 3), "a child of the root shows"); + assert!(one.iter().all(|r| r.id != 5), "a grandchild does not"); + } + + /// root { d1 { d2 { d3 { f_c 50 } + f_b 100 } + f_a 200 } + f_big 400 }, + /// so d1..d3 sit at depths 1..3 with f_c at 4. + fn nested_tree() -> Tree { + let mut b = EntryBatch::default(); + b.push("root", entry(0, 0, DIR, 0)); + b.push("d1", entry(1, 0, DIR, 0)); + b.push("f_big", entry(2, 0, FILE, 400)); + b.push("d2", entry(3, 1, DIR, 0)); + b.push("f_a", entry(4, 1, FILE, 200)); + b.push("d3", entry(5, 3, DIR, 0)); + b.push("f_b", entry(6, 3, FILE, 100)); + b.push("f_c", entry(7, 5, FILE, 50)); + let mut builder = TreeBuilder::new(); + builder.add_batch(&b); + builder.finish() + } + + fn capped_at(max_depth: u8) -> TreemapOptions { + TreemapOptions { + max_depth, + ..no_padding() + } + } + #[test] fn drill_down_layouts_from_a_subdirectory() { let mut b = EntryBatch::default(); From 61e3faf2efe1f3875dee37e3faaf08f87efc69ba Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:15:01 +0800 Subject: [PATCH 2/3] Cap the treemap layout at the depth the UI asks for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_treemap takes an optional maxDepth, laid out relative to root_id. Null is "as deep as the layout goes", which is the 24 that used to be hardcoded, so a caller that does not care keeps the existing picture byte for byte. The treemap has always drawn at most 24 levels; this only lets the UI ask for fewer. The clamp's floor of 1 is a contract with the UI rather than defensive programming. At a cap of 0 no rect has depth 1, and double-click, wheel and the context menu's zoom-in all hit-test `depth == 1` rects — every way into the tree but the breadcrumbs would go dead. The ceiling is what bounds the recursion and the IPC payload against a number the UI would never send itself, since the argument crosses IPC as a u32 and the layout option is a u8. Kept as a free function so it is testable without Tauri's State, the way delete_block_reason and filters_by_extension_only already are. The argument list is over clippy's limit now; the allow says why, matching lay_row's. --- src-tauri/src/scan.rs | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/scan.rs b/src-tauri/src/scan.rs index 1f4f8a2..16de234 100644 --- a/src-tauri/src/scan.rs +++ b/src-tauri/src/scan.rs @@ -338,6 +338,29 @@ pub fn get_path(state: State<'_, AppState>, generation: u64, id: NodeId) -> Resu Ok(tree.path(id)) } +/// The deepest the treemap lays out. Each level insets its children by +/// 2×`padding_px`, so a real viewport runs out of pixels well before this. +/// Mirrored as `LAYOUT_ALL_DEPTH` in `ui/src/lib/prefs.ts`. +const MAX_TREEMAP_DEPTH: u8 = 24; + +/// Resolves the UI's `maxDepth` preference into a layout cap, relative to the +/// laid-out root. `None` is "as deep as the layout goes" — the pre-existing +/// behaviour. +/// +/// The floor of 1 is a contract with the UI, not a nicety: at a cap of 0 no +/// rect has depth 1, and double-click, wheel and the context menu's zoom-in all +/// hit-test `depth == 1` rects, so every way in but the breadcrumbs would go +/// dead. The ceiling keeps the recursion — and the IPC payload — bounded on +/// hostile input. +fn treemap_max_depth(requested: Option) -> u8 { + requested.map_or(MAX_TREEMAP_DEPTH, |depth| { + depth.clamp(1, MAX_TREEMAP_DEPTH as u32) as u8 + }) +} + +// The argument list mirrors the UI's query; grouping it into a struct would +// only move the same list one level down. +#[allow(clippy::too_many_arguments)] #[tauri::command(async)] pub fn get_treemap( state: State<'_, AppState>, @@ -347,6 +370,7 @@ pub fn get_treemap( height: f32, hide_system: bool, filter: Option, + max_depth: Option, ) -> Result, String> { let session = session_for(&state, generation)?; let builder = session.builder.read().unwrap(); @@ -360,7 +384,7 @@ pub fn get_treemap( let opts = TreemapOptions { min_area_px: 3.0, padding_px: 1.0, - max_depth: 24, + max_depth: treemap_max_depth(max_depth), hide_system, }; let viewport = Viewport { @@ -964,6 +988,22 @@ mod tests { assert!(!filters_by_extension_only(None)); } + #[test] + fn a_requested_depth_is_clamped_into_the_layout_contract() { + assert_eq!(treemap_max_depth(None), MAX_TREEMAP_DEPTH); + assert_eq!(treemap_max_depth(Some(1)), 1); + assert_eq!(treemap_max_depth(Some(3)), 3); + assert_eq!( + treemap_max_depth(Some(MAX_TREEMAP_DEPTH as u32)), + MAX_TREEMAP_DEPTH + ); + + // 0 would leave the UI's depth-1 hit-tests with nothing to hit; the + // ceiling bounds recursion on input the UI would never send itself. + assert_eq!(treemap_max_depth(Some(0)), 1); + assert_eq!(treemap_max_depth(Some(u32::MAX)), MAX_TREEMAP_DEPTH); + } + #[test] fn deletes_are_blocked_while_scanning() { assert!(delete_block_reason(ScanState::Scanning, "C:\\Users\\me\\big.iso").is_some()); From 875cd1101f114b4f403b85da825f351a90b54116 Mon Sep 17 00:00:00 2001 From: Li3age <62469261+Li3age@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:15:08 +0800 Subject: [PATCH 3/3] Treemap expand depth is a remembered setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The treemap opened every scan fully expanded, which is a lot of pixels to read in a big folder and there was no way to ask for less. Settings now carries a Treemap depth of All, 1, 2 or 3 levels, defaulting to All so nothing changes for anyone who does not touch it. Depth is measured from the folder in view rather than from the scan root. That is what makes the setting work with the drill the treemap already had: a click fills the view with a folder and it expands one level down from there, so the setting decides how much of the *next* layer is on screen, not how deep the original scan was opened. Measured absolutely, drilling into a folder would find its own children permanently out of reach. The plate a cap stops at is painted differently, and that is the whole reason this is not a one-line layout change. Every directory plate carries a grain texture meaning "real bytes here, too small to draw"; a plate whose contents are hidden by the depth preference means something else, so it is left bare over the grain its expanded ancestors laid down and marked with a "+" in place of the texture. It has to be painted after the grain pass, not before: the grain lands on whole ancestor rects, so a bare plate drawn first would be textured right back. Both passes key off the same `depth === layoutCap(maxDepth)` test, and layoutCap mirrors the back end's clamp in one place so the plate the UI calls collapsed is the plate the layout actually stopped at. The mark is 3:1 or better against the plate in both themes — it is the only thing distinguishing a collapsed plate from dead space, so it counts as meaningful graphics, not decoration. Persisted in localStorage next to the theme. Node ids are per scan, so "remember which folders were expanded" cannot survive a rescan by id; the depth is the part of that request that can be remembered, and it is the part that caused the initial visual overload. The treemap already had the rest of the SpaceSniffer feel this was asked for — clicking a block zooms into it, the crumbs go back — so nothing about the drill or its hit-testing changed. --- ui/src/App.tsx | 13 ++++++ ui/src/components/SettingsMenu.tsx | 35 ++++++++++++++- ui/src/components/Toolbar.tsx | 7 +++ ui/src/components/Treemap.tsx | 68 +++++++++++++++++++++++++++--- ui/src/index.css | 2 + ui/src/lib/api.ts | 2 + ui/src/lib/palette.ts | 1 + ui/src/lib/prefs.ts | 46 ++++++++++++++++++++ 8 files changed, 165 insertions(+), 9 deletions(-) create mode 100644 ui/src/lib/prefs.ts diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2039b87..226bdc0 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -18,6 +18,11 @@ import { } from "./lib/api"; import { copyText } from "./lib/clipboard"; import { onUiError, reportUiError, reportUnlessStale } from "./lib/errors"; +import { + type DepthPref, + loadTreemapDepth, + saveTreemapDepth, +} from "./lib/prefs"; const TREE_PANE_MIN = 320; const TREEMAP_PANE_MIN = 280; @@ -56,6 +61,7 @@ export default function App() { const [revealId, setRevealId] = useState(null); const [treeWidth, setTreeWidth] = useState(560); const [typePanelOpen, setTypePanelOpen] = useState(true); + const [maxDepth, setMaxDepth] = useState(loadTreemapDepth); const [uiError, setUiError] = useState(null); const [menu, setMenu] = useState<{ x: number; @@ -80,6 +86,10 @@ export default function App() { .catch(() => setElevation(null)); // unknown — don't nag }, []); + useEffect(() => { + saveTreemapDepth(maxDepth); + }, [maxDepth]); + useEffect(() => { let timer = 0; const off = onUiError((msg) => { @@ -359,6 +369,7 @@ export default function App() { hideSystem={scan.hideSystem} filter={scan.filter} typePanelOpen={typePanelOpen} + maxDepth={maxDepth} themePref={theme.pref} accent={theme.accent} onScan={handleScan} @@ -367,6 +378,7 @@ export default function App() { onToggleTypePanel={() => setTypePanelOpen((v) => !v)} onSearchSelect={handleSearchSelect} onApplyFilter={scan.setFilter} + onMaxDepth={setMaxDepth} onThemePref={theme.setPref} onAccent={theme.setAccent} /> @@ -422,6 +434,7 @@ export default function App() { themeRev={theme.themeRev} hideSystem={scan.hideSystem} filter={scan.filter} + maxDepth={maxDepth} selected={selected} hoveredId={hoveredId} onSelect={handleTreemapSelect} diff --git a/ui/src/components/SettingsMenu.tsx b/ui/src/components/SettingsMenu.tsx index 211aa0f..349e03b 100644 --- a/ui/src/components/SettingsMenu.tsx +++ b/ui/src/components/SettingsMenu.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { DEPTH_OPTIONS, type DepthPref } from "../lib/prefs"; import { ACCENTS, type AccentName, @@ -15,18 +16,22 @@ const THEME_OPTIONS: { value: ThemePref; label: string }[] = [ interface SettingsMenuProps { hideSystem: boolean; + maxDepth: DepthPref; themePref: ThemePref; accent: AccentName; onToggleHideSystem: () => void; + onMaxDepth: (depth: DepthPref) => void; onThemePref: (pref: ThemePref) => void; onAccent: (accent: AccentName) => void; } export function SettingsMenu({ hideSystem, + maxDepth, themePref, accent, onToggleHideSystem, + onMaxDepth, onThemePref, onAccent, }: SettingsMenuProps) { @@ -53,8 +58,8 @@ export function SettingsMenu({
+ ))} +
Theme
diff --git a/ui/src/components/Toolbar.tsx b/ui/src/components/Toolbar.tsx index 0cb9404..0f23b38 100644 --- a/ui/src/components/Toolbar.tsx +++ b/ui/src/components/Toolbar.tsx @@ -1,4 +1,5 @@ import type { SearchHit } from "../lib/api"; +import type { DepthPref } from "../lib/prefs"; import type { AccentName, ThemePref } from "../lib/theme"; import { ExportMenu } from "./ExportMenu"; import { ScanMenu } from "./ScanMenu"; @@ -15,6 +16,7 @@ interface ToolbarProps { hideSystem: boolean; filter: string | null; typePanelOpen: boolean; + maxDepth: DepthPref; themePref: ThemePref; accent: AccentName; onScan: (path: string) => void; @@ -23,6 +25,7 @@ interface ToolbarProps { onToggleTypePanel: () => void; onSearchSelect: (hit: SearchHit) => void; onApplyFilter: (query: string | null) => void; + onMaxDepth: (depth: DepthPref) => void; onThemePref: (pref: ThemePref) => void; onAccent: (accent: AccentName) => void; } @@ -35,6 +38,7 @@ export function Toolbar({ hideSystem, filter, typePanelOpen, + maxDepth, themePref, accent, onScan, @@ -43,6 +47,7 @@ export function Toolbar({ onToggleTypePanel, onSearchSelect, onApplyFilter, + onMaxDepth, onThemePref, onAccent, }: ToolbarProps) { @@ -97,9 +102,11 @@ export function Toolbar({ /> diff --git a/ui/src/components/Treemap.tsx b/ui/src/components/Treemap.tsx index 7af81ef..c863ce6 100644 --- a/ui/src/components/Treemap.tsx +++ b/ui/src/components/Treemap.tsx @@ -26,16 +26,18 @@ import { import { isStale, reportUnlessStale } from "../lib/errors"; import { formatBytes, formatPercent } from "../lib/format"; import { PALETTE, canvasColors } from "../lib/palette"; +import { LAYOUT_ALL_DEPTH, layoutCap } from "../lib/prefs"; const SCAN_REFRESH_MS = 400; const ZOOM_MS = 220; const TOOLTIP_DELAY_MS = 120; -// Grain tile for directory plates. By the layout's contract, culled children -// still consume their share of space, so every bare plate pixel is real bytes -// too small to draw — the grain makes that read as "many small files" instead -// of dead space. Drawn tiles paint over it, so it shows only where content -// was culled. +// Directory plates read two ways. Grain means the plate's children are real +// bytes too small to draw: the layout culls them but still gives them their +// share of space, and drawn tiles paint over it, so it survives only where +// content was culled. A plate at the depth cap gets no grain — its contents +// are hidden by the depth preference rather than by their size — and is +// marked as somewhere to drill into instead. const GRAIN_PITCH = 4; let grainTile: { key: string; canvas: HTMLCanvasElement } | null = null; @@ -106,6 +108,8 @@ export interface TreemapProps { hideSystem: boolean; /** Active view filter (search grammar) or null. */ filter: string | null; + /** Levels to lay out below the focused folder; null lays out all of them. */ + maxDepth: number | null; selected: number | null; hoveredId: number | null; onSelect: (rect: TreemapRect) => void; @@ -128,6 +132,7 @@ export function Treemap({ themeRev, hideSystem, filter, + maxDepth, selected, hoveredId, onSelect, @@ -141,6 +146,8 @@ export function Treemap({ const tooltipRef = useRef(null); const rectsRef = useRef([]); + // The cap `rectsRef` was laid out with; see the collapsed-plate pass in bake. + const rectsCapRef = useRef(LAYOUT_ALL_DEPTH); const byIdRef = useRef>(new Map()); const offscreenRef = useRef(null); const rootIdRef = useRef(0); @@ -166,6 +173,8 @@ export function Treemap({ hideSystemRef.current = hideSystem; const filterRef = useRef(filter); filterRef.current = filter; + const maxDepthRef = useRef(maxDepth); + maxDepthRef.current = maxDepth; const [crumbs, setCrumbs] = useState([]); const [tooltip, setTooltip] = useState(null); @@ -244,13 +253,32 @@ export function Treemap({ ctx.fillStyle = theme.background; ctx.fillRect(0, 0, off.width, off.height); + // Grain on every plate that was expanded. By the layout's contract culled + // children still eat their share of the plate, so bare grain reads as + // "many small files" rather than as dead space. The cap comes from the + // rects' own layout, not the live preference — a re-bake can land while a + // depth change is still in flight. + const cap = rectsCapRef.current; ctx.fillStyle = ctx.createPattern( getGrainTile(theme.plate, theme.plateGrain), "repeat", )!; ctx.beginPath(); for (const r of rects) { - if (!r.isDir) continue; + if (!r.isDir || r.depth === cap) continue; + const s = snap(r, dpr, 0); + if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); + } + ctx.fill(); + + // A plate *at* the cap means something else: its contents are hidden by + // the depth preference, not by their size. Paint it bare — which has to + // happen after the grain, or an ancestor's pass would texture it right + // back — and mark it further down as somewhere to drill into. + ctx.fillStyle = theme.plate; + ctx.beginPath(); + for (const r of rects) { + if (!r.isDir || r.depth !== cap) continue; const s = snap(r, dpr, 0); if (s.w > 0 && s.h > 0) ctx.rect(s.x, s.y, s.w, s.h); } @@ -279,6 +307,27 @@ export function Treemap({ if (s.w > 3 && s.h > 3) ctx.drawImage(sprite, s.x, s.y, s.w, s.h); } + // Bare plates would read as empty; a "+" says there is more inside. Two + // strokes rather than a glyph: text metrics blur at fractional dpr. + ctx.strokeStyle = theme.plateMark; + ctx.lineWidth = Math.max(1, Math.round(2 * dpr)); + ctx.beginPath(); + for (const r of rects) { + if (!r.isDir || r.depth !== cap) continue; + const s = snap(r, dpr, 0); + // Scales with the plate, so a small one gets a small mark rather than + // none: the plate is bare either way, and a bare plate with no mark at + // all would read as empty. + const arm = Math.min(8 * dpr, Math.min(s.w, s.h) / 3); + const cx = s.x + s.w / 2; + const cy = s.y + s.h / 2; + ctx.moveTo(cx - arm, cy); + ctx.lineTo(cx + arm, cy); + ctx.moveTo(cx, cy - arm); + ctx.lineTo(cx, cy + arm); + } + ctx.stroke(); + if (zoomRafRef.current === 0) blit(); }, [blit]); @@ -327,9 +376,11 @@ export function Treemap({ h, hideSystemRef.current, filterRef.current, + maxDepthRef.current, ); if (seq !== fetchSeqRef.current || forRoot !== rootIdRef.current) return; rectsRef.current = rects; + rectsCapRef.current = layoutCap(maxDepthRef.current); byIdRef.current = new Map(rects.map((r) => [r.id, r])); hitFrozenRef.current = false; setHasRects(rects.length > 0); @@ -421,9 +472,12 @@ export function Treemap({ bake(); // repaint the baked layout with the new theme's canvas colors }, [themeRev, bake]); + // Depth only drops rects the cap hides; the ones that stay keep their exact + // geometry (see the treemap layout's cap test), so there is nothing to freeze + // hit-testing against while the new layout is in flight. useEffect(() => { void fetchLayout(); - }, [hideSystem, filter, fetchLayout]); + }, [hideSystem, filter, maxDepth, fetchLayout]); const prevStateRef = useRef(undefined); useEffect(() => { diff --git a/ui/src/index.css b/ui/src/index.css index 0a77bfb..9ed3c1a 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -11,6 +11,7 @@ --color-edge-strong: #3f3f46; --color-plate: #30323a; /* treemap directory plate */ --color-plate-grain: #454a57; /* dots on bare plate: culled-content texture */ + --color-plate-mark: #8b93a7; /* "+" on a plate the depth cap collapsed */ --color-ink: #f4f4f5; --color-ink-2: #d4d4d8; @@ -46,6 +47,7 @@ --color-edge-strong: #d4d4d8; --color-plate: #dcdce0; --color-plate-grain: #bfc0c8; + --color-plate-mark: #6b7280; /* ≥3:1 on the plate: the mark carries meaning */ --color-ink: #18181b; --color-ink-2: #3f3f46; diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 3a7e806..ae7937b 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -159,6 +159,7 @@ export const api = { height: number, hideSystem: boolean, filter: string | null, + maxDepth: number | null, ) => invoke("get_treemap", { generation, @@ -167,6 +168,7 @@ export const api = { height, hideSystem, filter, + maxDepth, }), getTypeStats: ( generation: number, diff --git a/ui/src/lib/palette.ts b/ui/src/lib/palette.ts index 350de0d..4f0dd96 100644 --- a/ui/src/lib/palette.ts +++ b/ui/src/lib/palette.ts @@ -22,6 +22,7 @@ export function canvasColors() { background: v("--color-app"), plate: v("--color-plate"), plateGrain: v("--color-plate-grain"), + plateMark: v("--color-plate-mark"), selection: v("--color-ink"), hoverRing: v("--color-accent-ink"), }; diff --git a/ui/src/lib/prefs.ts b/ui/src/lib/prefs.ts new file mode 100644 index 0000000..963cd88 --- /dev/null +++ b/ui/src/lib/prefs.ts @@ -0,0 +1,46 @@ +// View preferences that outlive a scan. Values the back end also has to agree +// on are mirrored here, same as palette.ts mirrors mathom-core's categories. + +/** Mirrors `MAX_TREEMAP_DEPTH` in src-tauri/src/scan.rs. */ +export const LAYOUT_ALL_DEPTH = 24; + +/** Levels the treemap expands below the focused folder; `null` is "all". */ +export type DepthPref = number | null; + +const DEPTH_KEY = "mathom:treemapDepth"; + +/** Levels the treemap can be pinned to; the settings menu offers exactly these. */ +const DEPTHS = [1, 2, 3]; + +export const DEPTH_OPTIONS: { value: DepthPref; label: string }[] = [ + { value: null, label: "All" }, + ...DEPTHS.map((depth) => ({ value: depth, label: String(depth) })), +]; + +/** + * Total, and its domain is exactly the options above: a stored level that no + * button can show would leave the control with nothing selected, so anything + * else reads as "all". + */ +export function parseDepth(raw: string | null): DepthPref { + const n = Number(raw); + return DEPTHS.includes(n) ? n : null; +} + +/** + * The cap the back end lays out for `depth`, mirroring its own clamp. The + * treemap needs it to tell a plate the cap collapsed from one whose children + * were merely culled by size, and that reading has to match the layout. + */ +export function layoutCap(depth: DepthPref): number { + if (depth === null) return LAYOUT_ALL_DEPTH; + return Math.min(Math.max(depth, 1), LAYOUT_ALL_DEPTH); +} + +export function loadTreemapDepth(): DepthPref { + return parseDepth(localStorage.getItem(DEPTH_KEY)); +} + +export function saveTreemapDepth(depth: DepthPref) { + localStorage.setItem(DEPTH_KEY, depth === null ? "all" : String(depth)); +}