| 1 | import { useSearchParams } from "@solidjs/router"; |
| 2 | import { parseResponse } from "hono/client"; |
| 3 | import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"; |
| 4 | import type { ServiceSummary } from "../types/model.ts"; |
| 5 | import type { MapNode } from "../types/storage.index.ts"; |
| 6 | import { api, reason } from "../api.ts"; |
| 7 | import { Crumbs, squarify } from "../components/Explorer.tsx"; |
| 8 | import { lastGood } from "../components/Loaded.tsx"; |
| 9 | import { OpenApp } from "../components/OpenApp.tsx"; |
| 10 | import { bytes, percent, plural } from "../format.ts"; |
| 11 | |
| 12 | /** Extensions by type, in categorical color order: video, audio, image, archive, document, code, data, font. */ |
| 13 | const 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 | |
| 24 | const extension = (name: string) => (name.lastIndexOf(".") > 0 ? name.slice(name.lastIndexOf(".") + 1).toLowerCase() : ""); |
| 25 | const 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). */ |
| 31 | const HEIGHT = 0.38; |
| 32 | const FALLOFF = 0.91; |
| 33 | const AMBIENT = 0.13; |
| 34 | const LIGHT_XY = -1 / Math.hypot(1, 1, 10); |
| 35 | const 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. */ |
| 37 | const 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. */ |
| 39 | const 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. */ |
| 42 | interface 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 | |
| 57 | function 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 | |
| 74 | const 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. */ |
| 77 | function 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 | |
| 82 | function 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. */ |
| 90 | export 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 | } |