1import { useSearchParams } from "@solidjs/router";
2import { parseResponse } from "hono/client";
3import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js";
4import type { ServiceSummary } from "../types/model.ts";
5import type { MapNode } from "../types/storage.index.ts";
6import { api, reason } from "../api.ts";
7import { Crumbs, squarify } from "../components/Explorer.tsx";
8import { lastGood } from "../components/Loaded.tsx";
9import { OpenApp } from "../components/OpenApp.tsx";
10import { bytes, percent, plural } from "../format.ts";
11
12/** Extensions by type, in categorical color order: video, audio, image, archive, document, code, data, font. */
13const KINDS = [
14 /^(mkv|mp4|m4v|mov|webm|avi|mts|wmv)$/,
15 /^(flac|wav|aiff?|mp3|m4a|ogg|opus|als|alac)$/,
16 /^(jpe?g|png|gif|webp|heic|tiff?|dng|psd|raw|cr2|arw|svg|avif)$/,
17 /^(zip|tar|gz|tgz|xz|zst|7z|rar|dmg|iso|img|pack|part)$/,
18 /^(pdf|docx?|pages|key|txt|md|rtf|epub|srt|ass|xlsx?|numbers)$/,
19 /^(js|mjs|cjs|ts|tsx|jsx|py|rs|go|c|h|cpp|css|html?|json|ya?ml|toml|sh|lua|map)$/,
20 /^(db|sqlite3?|wal|shm|sql|parquet|csv|log|bin)$/,
21 /^(otf|ttf|woff2?|ttc)$/,
22];
23
24const extension = (name: string) => (name.lastIndexOf(".") > 0 ? name.slice(name.lastIndexOf(".") + 1).toLowerCase() : "");
25const colorOf = (ext: string) => {
26 const kind = KINDS.findIndex((pattern) => pattern.test(ext));
27 return kind === -1 ? "--other" : `--series-${kind + 1}`;
28};
29
30/** WinDirStat's cushion constants: ridge height, its falloff per level, ambient light, and a light at (-1, -1, 10). */
31const HEIGHT = 0.38;
32const FALLOFF = 0.91;
33const AMBIENT = 0.13;
34const LIGHT_XY = -1 / Math.hypot(1, 1, 10);
35const LIGHT_Z = 10 / Math.hypot(1, 1, 10);
36/** Flat ground shows a type's color a little brighter, as WinDirStat does, so the shaded rims stay readable. */
37const FLAT = (AMBIENT + (1 - AMBIENT) * LIGHT_Z) / 1.45;
38/** How much of the rims' darkening light mode keeps, so they don't read as black outlines on a pale page. */
39const LIGHT_RIMS = 0.5;
40
41/** A laid-out entry; `ext` is null for folders drawn whole and for the space a folder's small files take together. */
42interface Box {
43 name: string;
44 size: number;
45 files: number;
46 dir: boolean;
47 ext: string | null;
48 x: number;
49 y: number;
50 w: number;
51 h: number;
52 /** The cushion's surface coefficients: x², y², x and y. */
53 surface: [number, number, number, number];
54 children: Box[];
55}
56
57function layout(node: MapNode, x: number, y: number, w: number, h: number, parent: Box["surface"], height: number): Box {
58 const [name, size, files, children] = node;
59 const surface: Box["surface"] = [
60 parent[0] - (4 * height) / w, parent[1] - (4 * height) / h,
61 parent[2] + ((4 * height) / w) * (2 * x + w), parent[3] + ((4 * height) / h) * (2 * y + h),
62 ];
63 const box: Box = { name, size, files: files ?? 1, dir: !!children, ext: children ? null : extension(name), x, y, w, h, surface, children: [] };
64 if (!children?.length) return box;
65 const shown = children.reduce((sum, child) => sum + child[1], 0);
66 const shownFiles = children.reduce((sum, child) => sum + (child[2] ?? 1), 0);
67 const items = [...children, ...(size > shown ? [["", size - shown, files! - shownFiles, []] satisfies MapNode] : [])]
68 .filter((item) => item[1] > 0).map((item) => ({ item, size: item[1] })).sort((a, b) => b.size - a.size);
69 box.children = squarify(items, w, h)
70 .map((cell) => layout(cell.item.item, x + cell.x, y + cell.y, cell.w, cell.h, surface, height * FALLOFF));
71 return box;
72}
73
74const leaves = (box: Box): Box[] => (box.children.length ? box.children.flatMap(leaves) : [box]);
75
76/** The chain of boxes from `box` down to the leaf under the point. */
77function hit(box: Box, px: number, py: number): Box[] {
78 const inside = box.children.find((child) => px >= child.x && px < child.x + child.w && py >= child.y && py < child.y + child.h);
79 return inside ? [box, ...hit(inside, px, py)] : [box];
80}
81
82function rgb(canvas: HTMLCanvasElement, variable: string) {
83 const probe = document.createElement("canvas").getContext("2d")!;
84 probe.fillStyle = getComputedStyle(canvas).getPropertyValue(variable).trim();
85 const hex = probe.fillStyle;
86 return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
87}
88
89/** Every file in the pool as a cushion-shaded treemap, WinDirStat style, colored by type. */
90export function StorageMap(props: { copyparty: { href: string; app?: Pick<ServiceSummary, "id" | "name" | "icon"> } | null | undefined }) {
91 const [params, setParams] = useSearchParams<{ path?: string }>();
92 const current = () => params.path ?? "";
93 const go = (path: string) => setParams({ path: path || undefined });
94 let frame!: HTMLDivElement;
95 let canvas!: HTMLCanvasElement;
96 const [size, setSize] = createSignal({ width: 0, height: 0 });
97 const [scheme, setScheme] = createSignal(0);
98 onMount(() => {
99 const resizes = new ResizeObserver(([entry]) => setSize({ width: entry!.contentRect.width, height: entry!.contentRect.height }));
100 resizes.observe(frame);
101 const dark = matchMedia("(prefers-color-scheme: dark)");
102 const repaint = () => setScheme(scheme() + 1);
103 dark.addEventListener("change", repaint);
104 onCleanup(() => {
105 resizes.disconnect();
106 dark.removeEventListener("change", repaint);
107 });
108 });
109 // Buckets keep a window resize from refetching on every pixel.
110 const query = () => size().width ? {
111 path: current(),
112 width: String(Math.ceil(size().width / 128) * 128),
113 height: String(Math.ceil(size().height / 128) * 128),
114 } : null;
115 const [map, { refetch }] = createResource(query, (query) =>
116 parseResponse(api.storage.files.map.$get({ query })).then((map) => ({ ...map, path: query.path })));
117 const loaded = lastGood(map);
118 /** A resize redraws the map it has, but another folder's would zoom to the wrong paths. */
119 const data = () => (loaded()?.path === current() ? loaded() : undefined);
120 createEffect(() => {
121 if (!data()?.scanning || data()?.tree) return;
122 const timer = setTimeout(refetch, 2000);
123 onCleanup(() => clearTimeout(timer));
124 });
125
126 const root = createMemo(() => {
127 const tree = data()?.tree;
128 const { width, height } = size();
129 return tree && width && tree[1] > 0 ? layout(tree, 0, 0, width, height, [0, 0, 0, 0], HEIGHT) : null;
130 });
131 const legend = createMemo(() => {
132 const sizes = new Map<string, { size: number; files: number }>();
133 let small = 0;
134 for (const leaf of root() ? leaves(root()!) : []) {
135 if (leaf.ext === null) small += leaf.size;
136 else {
137 const total = sizes.get(leaf.ext) ?? { size: 0, files: 0 };
138 sizes.set(leaf.ext, { size: total.size + leaf.size, files: total.files + 1 });
139 }
140 }
141 const ranked = [...sizes].sort((a, b) => b[1].size - a[1].size);
142 return { top: ranked.slice(0, 14), rest: ranked.slice(14).reduce((sum, [, total]) => sum + total.size, 0), small };
143 });
144 const [focus, setFocus] = createSignal<string | null | undefined>(undefined);
145 const [pointer, setPointer] = createSignal<Box[] | null>(null);
146 const [picked, setPicked] = createSignal<number | null>(null);
147 /** The chain under the pointer, or the child of the view picked with the arrow keys. */
148 const chain = () => {
149 const child = picked() !== null && root()?.children[picked()!];
150 return pointer() ?? (child ? [root()!, child] : null);
151 };
152 /** The folder a click opens; the unnamed box is a folder's small files together. */
153 const target = () => {
154 const box = chain()?.[1];
155 return box?.dir && box.name ? box : null;
156 };
157
158 createEffect(() => {
159 const box = root();
160 const highlight = focus();
161 scheme();
162 const ratio = devicePixelRatio;
163 canvas.width = Math.round(size().width * ratio);
164 canvas.height = Math.round(size().height * ratio);
165 if (!box) return;
166 const context = canvas.getContext("2d")!;
167 const image = context.createImageData(canvas.width, canvas.height);
168 const colors = new Map<string, number[]>();
169 const page = rgb(canvas, "--page");
170 const rims = matchMedia("(prefers-color-scheme: dark)").matches ? 1 : LIGHT_RIMS;
171 for (const leaf of leaves(box)) {
172 const variable = leaf.ext === null ? "--other" : colorOf(leaf.ext);
173 let color = colors.get(variable) ?? rgb(canvas, variable);
174 colors.set(variable, color);
175 // Space under the cutoff reads as a quieter "other".
176 if (leaf.ext === null) color = color.map((c, i) => (c + page[i]!) / 2);
177 if (highlight !== undefined && leaf.ext !== highlight) color = color.map((c, i) => c * 0.25 + page[i]! * 0.75);
178 const [red = 0, green = 0, blue = 0] = color;
179 const [s0, s1, s2, s3] = leaf.surface;
180 const [x0, x1, y0, y1] = [leaf.x, leaf.x + leaf.w, leaf.y, leaf.y + leaf.h].map((v) => Math.round(v * ratio));
181 for (let py = y0!; py < y1!; py++) {
182 const y = (py + 0.5) / ratio;
183 const ny = -(2 * s1 * y + s3);
184 for (let px = x0!; px < x1!; px++) {
185 const x = (px + 0.5) / ratio;
186 const nx = -(2 * s0 * x + s2);
187 const light = AMBIENT + (1 - AMBIENT) * Math.max(0, ((nx + ny) * LIGHT_XY + LIGHT_Z) / Math.sqrt(nx * nx + ny * ny + 1));
188 const shade = light < FLAT ? 1 - (1 - light / FLAT) * rims : light / FLAT;
189 const at = (py * canvas.width + px) * 4;
190 image.data[at] = red * shade;
191 image.data[at + 1] = green * shade;
192 image.data[at + 2] = blue * shade;
193 image.data[at + 3] = 255;
194 }
195 }
196 }
197 context.putImageData(image, 0, 0);
198 });
199
200 const outline = (box: Box | null | undefined) =>
201 box ? { left: `${box.x}px`, top: `${box.y}px`, width: `${box.w}px`, height: `${box.h}px` } : { display: "none" };
202 const zoom = () => {
203 const box = target();
204 if (!box) return;
205 setPointer(null);
206 setPicked(null);
207 go(current() ? `${current()}/${box.name}` : box.name);
208 };
209 const up = () => current() && go(current().split("/").slice(0, -1).join("/"));
210
211 return (
212 <div class="map fill">
213 <div class="files-head">
214 <Crumbs root={data()?.root ?? ""} path={current()} />
215 <span class="spacer" />
216 <span class="muted nowrap map-status">
217 <Show when={chain()} fallback={root() && `${bytes(root()!.size)} in ${plural(root()!.files, "file")}`}>
218 {(boxes) => {
219 const leaf = () => boxes().at(-1)!;
220 return (
221 <>
222 <span class="path">
223 {[current(), ...boxes().slice(1).map((box) => box.name || "smaller files")].filter(Boolean).join("/")}
224 </span>
225 <b>{bytes(leaf().size)}</b>
226 <Show when={leaf().ext === null}>{plural(leaf().files, "file")}</Show>
227 </>
228 );
229 }}
230 </Show>
231 </span>
232 <Show when={props.copyparty}>
233 {(link) => <OpenApp app={link().app ?? { id: "copyparty", name: "Copyparty", icon: null }} href={link().href} />}
234 </Show>
235 </div>
236 <div class="map-body">
237 <div ref={frame} class="map-frame" tabindex="0" role="img" classList={{ zoomable: !!target() }}
238 aria-label={`Treemap of ${data()?.root ?? ""}${current() ? `/${current()}` : ""}. Arrow keys pick a folder, Enter opens it, Backspace goes up.`}
239 onPointerMove={(event) => {
240 const box = root();
241 const bounds = frame.getBoundingClientRect();
242 setPointer(box ? hit(box, event.clientX - bounds.left, event.clientY - bounds.top) : null);
243 }}
244 onPointerLeave={() => setPointer(null)}
245 onBlur={() => setPicked(null)}
246 onClick={zoom}
247 onKeyDown={(event) => {
248 const children = root()?.children.length || 1;
249 const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
250 if (step) setPicked(((picked() ?? (step > 0 ? -1 : 0)) + step + children) % children);
251 else if (event.key === "Enter") zoom();
252 else if (event.key === "Backspace") up();
253 else return;
254 event.preventDefault();
255 }}>
256 <canvas ref={canvas} />
257 <div class="outline target" style={outline(target())} />
258 <div class="outline leaf" style={outline(chain()?.at(-1))} />
259 <Show when={!data()?.tree && data()}>
260 <div class="empty">
261 {data()!.scanning ? "Indexing the pool. The map fills in when the first pass finishes." : "Nothing is indexed at this path."}
262 </div>
263 </Show>
264 <Show when={!data() && map.loading}><div class="skeleton" /></Show>
265 <Show when={!data() && map.error}><div class="empty">{reason(map.error)}</div></Show>
266 </div>
267 <ul class="map-legend" aria-label="Types">
268 <For each={legend().top}>
269 {([ext, total]) => (
270 <li tabindex="0" onPointerEnter={() => setFocus(ext)} onPointerLeave={() => setFocus(undefined)}
271 onFocus={() => setFocus(ext)} onBlur={() => setFocus(undefined)}
272 data-tip={`${plural(total.files, "file")}, ${percent((total.size / root()!.size) * 100)}`}>
273 <span class="swatch" style={{ background: `var(${colorOf(ext)})` }} />
274 <span class="mono">{ext ? `.${ext}` : "no extension"}</span>
275 <span class="v">{bytes(total.size)}</span>
276 </li>
277 )}
278 </For>
279 <Show when={legend().rest}>
280 <li class="muted">
281 <span class="swatch" />
282 <span>other types</span>
283 <span class="v">{bytes(legend().rest)}</span>
284 </li>
285 </Show>
286 <Show when={legend().small}>
287 <li tabindex="0" onPointerEnter={() => setFocus(null)} onPointerLeave={() => setFocus(undefined)}
288 onFocus={() => setFocus(null)} onBlur={() => setFocus(undefined)}
289 data-tip="Too small to draw at this zoom; open a folder to see them">
290 <span class="swatch small" />
291 <span>under {bytes(data()!.min)}</span>
292 <span class="v">{bytes(legend().small)}</span>
293 </li>
294 </Show>
295 </ul>
296 </div>
297 </div>
298 );
299}