Files
clawhdf5/examples/wasm-viewer/index.html
T
osobhandClaude Opus 5.5 b8c85f7627 wasm-viewer: open by URL, lazily, with a request counter
The page gets a URL box; ?file=<url> now opens with openUrl instead of
downloading the file, and the header shows the requests made and bytes
fetched so far ("5 requests, 5 MiB of 191 MiB fetched (2.6%)"). Every
file call is awaited, so local files (open) and remote ones share the
code; a selection that finishes after another was made is not shown.

browser.sh serves the page and fixtures with test/serve.py (no symlinks
into the repository any more) and also checks a server without range
support and, on the 200 MB file, that a small dataset and a window of
the big one render with a single-digit percentage fetched.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 06:44:51 -05:00

329 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HDF5 Viewer</title>
<style>
:root {
--bg: #fbfbfa; --panel: #ffffff; --ink: #1d1d1b; --muted: #6b6b66;
--line: #e2e1dc; --accent: #2f5d8a; --accent-soft: #e8eff6; --bad: #a3312a;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #161615; --panel: #1f1f1d; --ink: #ecebe6; --muted: #9a9993;
--line: #34332f; --accent: #8db8e0; --accent-soft: #23303c; --bad: #e0857c;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--ink);
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
header { display: flex; align-items: center; gap: 12px; padding: 12px 16px;
border-bottom: 1px solid var(--line); background: var(--panel); flex-wrap: wrap; }
header h1 { font-size: 15px; margin: 0; font-weight: 600; }
header .file { color: var(--muted); font-family: var(--mono); font-size: 13px; }
header label.button { margin-left: auto; }
.button { border: 1px solid var(--line); background: var(--panel); color: var(--ink);
padding: 5px 10px; border-radius: 6px; cursor: pointer; font: inherit; }
.button:hover { border-color: var(--accent); }
main { display: grid; grid-template-columns: minmax(200px, 300px) 1fr; min-height: calc(100vh - 50px); }
@media (max-width: 700px) { main { grid-template-columns: 1fr; } nav { border-right: 0; border-bottom: 1px solid var(--line); } }
nav { border-right: 1px solid var(--line); padding: 8px; overflow: auto; background: var(--panel); }
section { padding: 16px; overflow: auto; min-width: 0; }
ul.tree { list-style: none; margin: 0; padding-left: 14px; }
nav > ul.tree { padding-left: 0; }
.node { display: flex; gap: 6px; align-items: baseline; padding: 2px 6px; border-radius: 4px;
cursor: pointer; white-space: nowrap; font-family: var(--mono); font-size: 13px; }
.node:hover { background: var(--accent-soft); }
.node.selected { background: var(--accent-soft); color: var(--accent); }
.node .icon { width: 1em; color: var(--muted); flex: none; text-align: center; }
.drop { border: 2px dashed var(--line); border-radius: 10px; padding: 48px 16px; text-align: center;
color: var(--muted); max-width: 560px; margin: 48px auto; }
body.dragging .drop, body.dragging nav { border-color: var(--accent); }
h2 { font-size: 16px; margin: 0 0 8px; font-family: var(--mono); word-break: break-all; }
h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 20px 0 6px; }
dl.meta { display: grid; grid-template-columns: max-content 1fr; gap: 2px 16px; margin: 0; }
dl.meta dt { color: var(--muted); }
dl.meta dd { margin: 0; font-family: var(--mono); word-break: break-word; }
table { border-collapse: collapse; font-family: var(--mono); font-size: 12.5px; }
th, td { border: 1px solid var(--line); padding: 3px 8px; text-align: right; white-space: nowrap; }
th { background: var(--bg); color: var(--muted); font-weight: 500; }
table.attrs td { text-align: left; white-space: normal; word-break: break-word; }
.scroll { overflow: auto; max-width: 100%; }
.controls { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; margin: 6px 0 10px; }
.controls input { width: 6em; font: inherit; padding: 2px 4px; background: var(--panel); color: var(--ink);
border: 1px solid var(--line); border-radius: 4px; }
.error { color: var(--bad); font-family: var(--mono); white-space: pre-wrap; }
.muted { color: var(--muted); }
form.url { display: flex; gap: 6px; margin-left: auto; flex: 1 1 320px; max-width: 560px; }
form.url input { flex: 1; min-width: 0; font: 13px var(--mono); padding: 4px 8px; background: var(--panel);
color: var(--ink); border: 1px solid var(--line); border-radius: 6px; }
.netstats { flex-basis: 100%; color: var(--muted); font-size: 12.5px; font-family: var(--mono); }
.netstats:empty { display: none; }
</style>
</head>
<body>
<header>
<h1>HDF5 Viewer</h1>
<span class="file" id="filename">no file</span>
<form class="url" id="urlform">
<input type="url" id="url" placeholder="https://…/file.h5 (read by HTTP range requests)" aria-label="File URL">
<button class="button" type="submit">Open URL</button>
</form>
<label class="button">Open file…<input type="file" id="picker" accept=".h5,.hdf5,.he5,.nc,.nc4,.cdf" hidden></label>
<span class="netstats" id="netstats"></span>
</header>
<main>
<nav id="tree"></nav>
<section id="detail">
<div class="drop">
<p><strong>Drop an HDF5 or NetCDF-4 file here</strong>, use “Open file…”, or give a URL.</p>
<p class="muted">The file is read in this page by clawhdf5 compiled to WebAssembly; a local file is not uploaded anywhere.
A file given by URL is not downloaded: only the byte ranges each view needs are fetched (HTTP range requests),
and the requests and bytes it has cost are shown at the top.</p>
<p class="muted" id="version"></p>
</div>
</section>
</main>
<script type="module">
import init, { open, openUrl, version } from "./pkg/clawhdf5_wasm.js";
import { joinPath, formatValue, viewWindow, toRows, perElement, formatStats } from "./viewer-lib.js";
const $ = (id) => document.getElementById(id);
const el = (tag, props = {}, ...kids) => {
const e = Object.assign(document.createElement(tag), props);
e.append(...kids);
return e;
};
// The open file: an H5File (a local file, read in memory; synchronous
// methods) or a RemoteFile (openUrl: bytes fetched by range requests as
// needed; methods return promises). Every call below is awaited, so both
// work the same.
let file = null;
let selectedNode = null;
// Bumped by every selection, so a slow read for an object no longer shown
// does not overwrite the current one.
let showSeq = 0;
await init();
$("version").textContent = `clawhdf5 ${version()}`;
// Requests and bytes a remote file has cost so far.
function updateStats() {
$("netstats").textContent = file && file.stats ? formatStats(file.stats()) : "";
}
async function setFile(name, opener) {
if (file) file.free();
file = null;
selectedNode = null;
showSeq++;
$("filename").textContent = name;
$("tree").replaceChildren();
$("netstats").textContent = "";
$("detail").replaceChildren(el("p", { className: "muted", textContent: `Opening ${name}…` }));
try {
file = await opener();
} catch (e) {
$("detail").replaceChildren(el("p", { className: "error", textContent: `Cannot open ${name}: ${e.message}` }));
return;
}
updateStats();
const root = el("ul", { className: "tree" });
root.append(treeNode("/", "/", "group"));
$("tree").append(root);
await activate(root.querySelector(".node"));
}
async function load(blob) {
const bytes = new Uint8Array(await blob.arrayBuffer());
await setFile(blob.name, async () => open(bytes));
}
async function loadUrl(url) {
$("url").value = url;
await setFile(url, () => openUrl(new URL(url, location.href).href));
}
// A tree row: clicking it selects it (and expands a group); `activate`
// does the same and resolves once the listing and the detail are shown.
function treeNode(path, name, kind) {
const li = el("li");
const icon = el("span", { className: "icon", textContent: kind === "group" ? "▸" : "·" });
const row = el("div", { className: "node", title: path }, icon, el("span", { textContent: name }));
row.dataset.path = path;
li.append(row);
let children = null;
row.activate = async () => {
if (selectedNode) selectedNode.classList.remove("selected");
row.classList.add("selected");
selectedNode = row;
const shown = show(path, kind);
if (kind === "group") {
if (children) {
children.hidden = !children.hidden;
} else {
children = el("ul", { className: "tree" });
li.append(children);
try {
for (const c of await file.list(path)) children.append(treeNode(joinPath(path, c.name), c.name, c.kind));
} catch (e) {
children.append(el("li", { className: "error", textContent: e.message }));
}
updateStats();
}
icon.textContent = children.hidden ? "▸" : "▾";
}
await shown;
};
row.addEventListener("click", () => row.activate());
return li;
}
const activate = (row) => row.activate();
async function attrsTable(path) {
let attrs, errors;
try {
attrs = await file.attrs(path);
errors = await file.attrErrors(path);
} catch (e) {
return el("p", { className: "error", textContent: e.message });
}
if (!attrs.length && !errors.length) return el("p", { className: "muted", textContent: "none" });
const t = el("table", { className: "attrs" }, el("tr", {}, el("th", { textContent: "name" }), el("th", { textContent: "value" })));
for (const a of attrs) {
const v = a.value === null ? el("span", { className: "muted", textContent: `(${a.dtype})` }) : formatValue(a.value);
t.append(el("tr", {}, el("td", { textContent: a.name }), el("td", {}, v)));
}
for (const msg of errors) t.append(el("tr", {}, el("td", { className: "error", colSpan: 2, textContent: msg })));
return el("div", { className: "scroll" }, t);
}
async function show(path, kind) {
const seq = ++showSeq;
const current = () => seq === showSeq;
const out = [el("h2", { textContent: path })];
if (kind === "dataset") {
let info;
try {
info = await file.info(path);
} catch (e) {
if (current()) $("detail").replaceChildren(...out, el("p", { className: "error", textContent: e.message }));
return;
}
const max = info.maxshape === null ? "—" : `(${info.maxshape.map((d) => d ?? "∞").join(", ")})`;
out.push(el("dl", { className: "meta" },
el("dt", { textContent: "type" }), el("dd", { textContent: info.dtype }),
el("dt", { textContent: "shape" }), el("dd", { textContent: `(${info.shape.join(", ")})` }),
el("dt", { textContent: "max shape" }), el("dd", { textContent: max })));
out.push(el("h3", { textContent: "Attributes" }), await attrsTable(path));
out.push(el("h3", { textContent: "Values" }), await valuesView(path, info, current));
} else {
out.push(el("h3", { textContent: "Attributes" }), await attrsTable(path));
}
updateStats();
if (current()) $("detail").replaceChildren(...out);
}
async function valuesView(path, info, current) {
const shape = info.shape;
const per = perElement(info.elementShape);
const state = { row: 0, col: 0, rows: 50, cols: 12, fixed: shape.slice(0, Math.max(0, shape.length - 2)).map(() => 0) };
const box = el("div");
const controls = el("div", { className: "controls" });
const body = el("div", { className: "scroll" });
box.append(controls, body);
const num = (label, key, idx) => {
const input = el("input", { type: "number", min: 0, value: idx === undefined ? state[key] : state.fixed[idx] });
input.addEventListener("change", () => {
const v = Math.max(0, Math.floor(Number(input.value) || 0));
if (idx === undefined) state[key] = v; else state.fixed[idx] = v;
render();
});
return el("label", {}, `${label} `, input);
};
if (shape.length >= 1) controls.append(num("row", "row"));
if (shape.length >= 2) controls.append(num("column", "col"));
state.fixed.forEach((_, i) => controls.append(num(`dim ${i}`, "fixed", i)));
let renderSeq = 0;
async function render() {
const seq = ++renderSeq;
const w = viewWindow(shape, state);
let res;
try {
res = w === null ? await file.read(path) : await file.readHyperslab(path, w.start, w.count);
} catch (e) {
if (seq === renderSeq) body.replaceChildren(el("p", { className: "error", textContent: e.message }));
return;
}
updateStats();
if (seq !== renderSeq || !current()) return;
if (w === null) {
body.replaceChildren(el("pre", { textContent: toRows(res.data, 1, 1, per)[0][0] }));
return;
}
const t = el("table");
const head = el("tr", {}, el("th"));
for (let c = 0; c < w.cols; c++) head.append(el("th", { textContent: String(w.col + c) }));
t.append(head);
toRows(res.data, w.rows, w.cols, per).forEach((cells, r) => {
const tr = el("tr", {}, el("th", { textContent: String(w.row + r) }));
for (const c of cells) tr.append(el("td", { textContent: c }));
t.append(tr);
});
const total = shape.reduce((a, b) => a * b, 1);
const note = el("p", { className: "muted", textContent: `showing rows ${w.row}–${w.row + w.rows - 1}` +
(shape.length >= 2 ? `, columns ${w.col}–${w.col + w.cols - 1}` : "") + ` of ${total} values` });
body.replaceChildren(t, note);
}
await render();
return box;
}
// Expand the tree down to `path` and select it.
async function reveal(path) {
const rowFor = (p) => [...document.querySelectorAll(".node")].find((n) => n.dataset.path === p);
let cur = "/";
for (const part of path.split("/").filter(Boolean)) {
const row = rowFor(cur);
if (!row) return;
const kids = row.parentElement.querySelector(":scope > ul");
if (!kids || kids.hidden) await activate(row);
cur = joinPath(cur, part);
}
const target = rowFor(cur);
if (target && target !== selectedNode) await activate(target);
}
// ?file=<url>&path=<object> opens a file from a URL (same origin, or one
// that allows CORS) by range requests and selects an object in it.
const params = new URLSearchParams(location.search);
if (params.get("file")) {
await loadUrl(params.get("file"));
if (file && params.get("path")) await reveal(params.get("path"));
document.body.dataset.ready = "1";
}
$("urlform").addEventListener("submit", (e) => {
e.preventDefault();
const url = $("url").value.trim();
if (url) loadUrl(url);
});
$("picker").addEventListener("change", (e) => e.target.files[0] && load(e.target.files[0]));
document.addEventListener("dragover", (e) => { e.preventDefault(); document.body.classList.add("dragging"); });
document.addEventListener("dragleave", () => document.body.classList.remove("dragging"));
document.addEventListener("drop", (e) => {
e.preventDefault();
document.body.classList.remove("dragging");
const f = e.dataTransfer.files[0];
if (f) load(f);
});
</script>
</body>
</html>