How I built a native macOS app that renders your file system as a hand-illustrated territory map — and the engineering decisions that made it feel like a real place instead of a chart.
The premise
Every file browser looks the same: a list, a tree, maybe a bar chart if it's a disk-usage tool. They're all correct and all forgettable. You look at a treemap of your Downloads folder and your eyes glaze over — it's information, not a place.
Cartographer started from a different question: what if your disk were a territory you could explore? A 4 GB video folder becomes a continent. A 12 KB config file becomes a pebble on the coast. File types become terrain — code is one biome, media another, archives another. You don't read your storage, you travel it: zoom in, drill down, watch new territories emerge from the fog.
This post walks through how that idea became a working, native macOS app — the architecture, the rendering tricks, and the two or three problems that were genuinely hard.
The shape of the app
Cartographer is a Glaze app, which means it has two halves that never touch each other directly:
- A Node.js backend with real access to macOS — the filesystem, native dialogs, Finder integration, window management.
- A React + Vite frontend that renders inside a
WKWebViewand knows nothing about the disk. It asks the backend for data over a JSON-RPC bridge and draws pictures.
That separation isn't ceremony — it's the whole security model. The renderer physically cannot read a file; it can only call a named IPC channel that the backend chose to expose.
┌──────────────────────────────────────────────┐
│ Renderer (React, in WebView) │
│ home-view → landing / scanning / map │
│ lib/ treemap · regions · terrain · snapshot │
└──────────────────────┬─────────────────────────┘
window.glazeAPI.glaze.ipc
(JSON-RPC 2.0 bridge)
┌──────────────────────▼─────────────────────────┐
│ Backend (Node.js main process) │
│ scanner · file-kind · file-ops · scan-cache │
│ (calls native macOS/Swift APIs) │
└────────────────────────────────────────────────┘
The app runs as a three-state machine driven by a single mode value in home-view.tsx: landing → scanning → map. No router juggling, no nested routes — one orchestrator component owns the drill path, the zoom/pan transform, the search query, the sort order, and the selection. Everything else is a controlled child.
Problem 1: scanning without freezing
The backend's job sounds simple — walk a directory tree and add up sizes. The catch is that a directory's size is meaningless until you've visited every descendant. You can't show a folder as a continent until you've counted every pebble inside it. For Downloads that can mean 50,000 files.
Three things make this bearable:
It runs off the main thread and streams. The scan is a fully async recursive walk that emits ScanProgress chunks as it goes, so the UI can animate a live "emergence" counter instead of showing a spinner and a prayer.
// renderer/lib/fs-api.ts
export async function scanDirectory(
path: string,
onChunk: (progress: ScanProgress) => void,
maxDepth?: number,
): Promise<ScanResult> {
return window.glazeAPI.glaze.ipc.stream<ScanProgress, ScanResult>(
"fs:scan",
{ path, maxDepth },
onChunk,
);
}
It caps depth but never lies about size. The scanner materializes children down to six levels; anything deeper is marked truncated and gets a "Dive Deeper" affordance instead of being rendered. Crucially, the aggregate size still includes those deep descendants — we just don't build DOM for a folder 12 levels down that's two pixels wide. Truncation is a rendering budget, not a data lie.
It's cancellable and cached. Every scan honors an AbortSignal (change roots mid-scan and the old walk stops), and results are cached as JSON under userData/scan-cache/, keyed by a SHA-1 of the path. On relaunch, the last map renders instantly from cache while a fresh scan runs quietly in the background and swaps in when done.
The shared data model lives in one file, main/services/fs-types.ts, and is imported by both halves — the renderer imports the backend's types directly, so the IPC contract can't drift:
export interface FsNode {
name: string;
path: string;
kind: FileKind; // folder | code | media | document | archive | audio | system | other
isDirectory: boolean;
size: number; // aggregate recursive size in bytes
mtimeMs: number;
fileCount: number;
folderCount: number;
truncated?: boolean; // depth cap reached — offers "Dive Deeper"
children?: FsNode[];
isCluster?: boolean; // synthetic "+N more" node — client-side only
clusteredCount?: number;
}
Problem 2: making a treemap look like a coastline
The layout underneath the map is a squarified treemap — the algorithm that tiles a rectangle with sub-rectangles proportional to a value, keeping each one as close to square as possible. That gives us "big folder = big landmass" for free. But a raw squarified treemap looks exactly like every other treemap: a grid of hard-edged rectangles. Turning that into something that reads as terrain was most of the work.
A few of the tricks, all pure SVG:
-
Wobbly coastlines. Every region border runs through an SVG
feTurbulence+feDisplacementMapfilter (baseFrequencyaround 0.018–0.026, displacement scale 3.2). The straight rectangle edges become organic, hand-drawn coastlines. One filter, applied everywhere, and the whole map stops looking like a spreadsheet. - Ocean channels. Sibling regions are inset from each other with a margin that widens at shallower depths, so there's visible "water" between territories instead of shared hairlines.
-
Terrain palettes + radial shading. Each
FileKindmaps to a palette, and each region is filled with a per-kind<radialGradient>so landmasses have a lit center falling off to darker coasts — elevation, essentially. - Elevation-tinted borders. Border color shifts with nesting depth, so drilling in feels like descending into a valley.
-
Fog of war. Drilling into a folder keys a re-mount on the region group (
emergenceKey= the current node's path), fading the inner regions up from an opacity floor under a radial mist overlay. New territory literally emerges from the fog. It's keyed on path, not on every render, so re-sorting or searching doesn't retrigger the animation.
The map deliberately bypasses the design system. Glaze ships a full component library, and we use it for the draggable toolbar — but the terrain itself is raw SVG. Semantic UI colors are built to read on flat app chrome; they don't survive on a parchment-and-ocean palette. So the map, the parchment legend, and the compass rose all use their own inline color tones. Knowing when to step outside the framework was as important as using it.
Problem 3: the wall of identical squares
Here's the bug that a screenshot revealed and that took the most thought to fix well.
A treemap is honest about size — which means a folder holding 300 near-identical 40 KB files renders as 300 near-identical little squares. It's technically correct and completely useless. Worse, a per-item minimum-area floor (there so tiny files don't vanish) was forcing those squares to a uniform size, so even the slight real differences between them disappeared. The dense corners of the map turned into gray static, with lock/error/"dive deeper" icons stacking into unreadable glyph soup.
The fix came in three layers:
- Lowered the minimum-area floor (0.006 → 0.0015) so real size differences survive in dense folders instead of being flattened.
- Size-gated the status icons — the lock/error/dive-deeper glyph only paints when a cell is actually big enough to hold it (or is hovered), killing the overlapping-icon soup.
- Sibling clustering — the real fix. Once a folder has 24+ children, we fold every sibling below ~1.2% of the parent's size into a single synthetic "+N more" cell.
The clustering has two decisions in it that matter more than they look:
Cluster membership is based on real size, not the active sort. If we folded "the smallest by whatever the user is currently sorting by," then toggling Size → Date → Name would reshuffle which items get hidden — deeply disorienting. Instead, membership is always decided by real size share (always keeping the six biggest), so re-sorting only rearranges what's visible, never changes what's folded.
// renderer/lib/regions.ts
function partitionForClustering(children: FsNode[]): { visible: FsNode[]; clustered: FsNode[] } {
const bySize = [...children].sort((a, b) => b.size - a.size);
const totalSize = bySize.reduce((sum, n) => sum + n.size, 0);
// Fold any sibling below ~1.2% of parent size into `clustered`,
// but only once the folder has 24+ children and it folds away 8+ items.
// The 6 largest are always kept, no matter their share.
}
Clustering disables itself during search. A folded-away file can't be highlighted if it doesn't exist as a cell. So the moment the search box has any text, home-view passes clusterSmall: false and every individual item comes back — nothing is ever hidden from search. Decluttering is a view optimization, never a data one.
The synthetic cluster node gets a real-shaped FsNode (summed size and counts, max mtime across the folded items) with isCluster: true, a dashed border and a Layers icon so it reads as "an aggregate, not a real thing," and no right-click menu — you can't rename or trash a synthetic path. Its path is "${parent.path} cluster"; a space can't start a path segment the scanner would ever produce, so it's a guaranteed-unique React key that can never collide with a real sibling and never travels over IPC.
The native touches
What makes it an app and not a web page is the stuff that only the backend can do:
-
Real file operations — right-click to Open in Finder, Copy Path, Get Info (via
osascript), Rename, or Move to Trash. All routed throughfile-ops.ts, with anEXDEVcopy-then-remove fallback for moves across volumes. -
Finder drag-in — drag a file from Finder straight onto a territory and it moves there, resolved through
webUtils.getPathForFile. -
Snapshot export — the camera button (or ⌘S) renders the current territory as a vintage-map poster PNG: title, subtitle, legend row, compass. It's not a screenshot of the DOM — it's a standalone SVG string built from scratch, then rasterized through an
Image→<canvas>at 2× so the export can be higher resolution than your screen. A native save dialog writes it out. - Window as context — the window title bar always shows the current root folder, the app is dark-first (deep-navy "ocean" background), and it remembers its size and position between launches.
And notably: none of this needed a custom preload surface. Everything routes through the already-exposed glaze.ipc.invoke/stream, so there's no bespoke contextBridge wiring to audit — a smaller attack surface and less to maintain.
What we'd build next
Cartographer is deliberately a foundation. A few directions it's set up to grow:
- Multi-root comparison — two territories side by side to see what's eating a drive.
- Time-lapse mode — diff two scans of the same root and watch the map grow or shrink.
- Duplicate detection & cleanup suggestions — hash-based, surfaced as "reclaimable land."
- Custom terrain themes — swap the palette (desert, arctic, nautical).
- Interactive HTML export — share a map as a self-contained page.
If any of that sounds fun, the whole thing is forkable — the README has the getting-started steps and the full IPC contract.
The takeaway
The interesting engineering here wasn't the treemap algorithm (that's textbook) — it was everything around making data feel like a place: streaming a scan so it never freezes, capping depth without lying about size, and the clustering rules that keep a dense folder legible without ever hiding anything from search. The visual polish (coastlines, fog, parchment) is what people notice first, but the app only works because of the boring correctness underneath.
A folder is just a number. A territory is somewhere you want to go back to.
Code & more: https://www.dailybuild.xyz/project/203-cartographer
Top comments (0)