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]>
This commit is contained in:
@@ -56,27 +56,39 @@
|
||||
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>, or use “Open file…”.</p>
|
||||
<p class="muted">The file is read in this page by clawhdf5 compiled to WebAssembly; it is not uploaded anywhere.</p>
|
||||
<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, version } from "./pkg/clawhdf5_wasm.js";
|
||||
import { joinPath, formatValue, viewWindow, toRows, perElement } from "./viewer-lib.js";
|
||||
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) => {
|
||||
@@ -85,30 +97,58 @@ const el = (tag, props = {}, ...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()}`;
|
||||
|
||||
async function load(blob) {
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
// 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;
|
||||
$("filename").textContent = blob.name;
|
||||
selectedNode = null;
|
||||
showSeq++;
|
||||
$("filename").textContent = name;
|
||||
$("tree").replaceChildren();
|
||||
$("netstats").textContent = "";
|
||||
$("detail").replaceChildren(el("p", { className: "muted", textContent: `Opening ${name}…` }));
|
||||
try {
|
||||
file = open(bytes);
|
||||
file = await opener();
|
||||
} catch (e) {
|
||||
$("detail").replaceChildren(el("p", { className: "error", textContent: `Cannot open ${blob.name}: ${e.message}` }));
|
||||
$("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);
|
||||
root.querySelector(".node").click();
|
||||
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" ? "▸" : "·" });
|
||||
@@ -116,34 +156,39 @@ function treeNode(path, name, kind) {
|
||||
row.dataset.path = path;
|
||||
li.append(row);
|
||||
let children = null;
|
||||
row.addEventListener("click", () => {
|
||||
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 file.list(path)) children.append(treeNode(joinPath(path, c.name), c.name, c.kind));
|
||||
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 }));
|
||||
}
|
||||
li.append(children);
|
||||
updateStats();
|
||||
}
|
||||
icon.textContent = children.hidden ? "▸" : "▾";
|
||||
}
|
||||
show(path, kind);
|
||||
});
|
||||
await shown;
|
||||
};
|
||||
row.addEventListener("click", () => row.activate());
|
||||
return li;
|
||||
}
|
||||
|
||||
function attrsTable(path) {
|
||||
const activate = (row) => row.activate();
|
||||
|
||||
async function attrsTable(path) {
|
||||
let attrs, errors;
|
||||
try {
|
||||
attrs = file.attrs(path);
|
||||
errors = file.attrErrors(path);
|
||||
attrs = await file.attrs(path);
|
||||
errors = await file.attrErrors(path);
|
||||
} catch (e) {
|
||||
return el("p", { className: "error", textContent: e.message });
|
||||
}
|
||||
@@ -157,14 +202,16 @@ function attrsTable(path) {
|
||||
return el("div", { className: "scroll" }, t);
|
||||
}
|
||||
|
||||
function show(path, kind) {
|
||||
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 = file.info(path);
|
||||
info = await file.info(path);
|
||||
} catch (e) {
|
||||
$("detail").replaceChildren(...out, el("p", { className: "error", textContent: e.message }));
|
||||
if (current()) $("detail").replaceChildren(...out, el("p", { className: "error", textContent: e.message }));
|
||||
return;
|
||||
}
|
||||
const max = info.maxshape === null ? "—" : `(${info.maxshape.map((d) => d ?? "∞").join(", ")})`;
|
||||
@@ -172,15 +219,16 @@ function show(path, kind) {
|
||||
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" }), attrsTable(path));
|
||||
out.push(el("h3", { textContent: "Values" }), valuesView(path, info));
|
||||
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" }), attrsTable(path));
|
||||
out.push(el("h3", { textContent: "Attributes" }), await attrsTable(path));
|
||||
}
|
||||
$("detail").replaceChildren(...out);
|
||||
updateStats();
|
||||
if (current()) $("detail").replaceChildren(...out);
|
||||
}
|
||||
|
||||
function valuesView(path, info) {
|
||||
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) };
|
||||
@@ -202,15 +250,19 @@ function valuesView(path, info) {
|
||||
if (shape.length >= 2) controls.append(num("column", "col"));
|
||||
state.fixed.forEach((_, i) => controls.append(num(`dim ${i}`, "fixed", i)));
|
||||
|
||||
function render() {
|
||||
let renderSeq = 0;
|
||||
async function render() {
|
||||
const seq = ++renderSeq;
|
||||
const w = viewWindow(shape, state);
|
||||
let res;
|
||||
try {
|
||||
res = w === null ? file.read(path) : file.readHyperslab(path, w.start, w.count);
|
||||
res = w === null ? await file.read(path) : await file.readHyperslab(path, w.start, w.count);
|
||||
} catch (e) {
|
||||
body.replaceChildren(el("p", { className: "error", textContent: e.message }));
|
||||
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;
|
||||
@@ -229,41 +281,39 @@ function valuesView(path, info) {
|
||||
(shape.length >= 2 ? `, columns ${w.col}–${w.col + w.cols - 1}` : "") + ` of ${total} values` });
|
||||
body.replaceChildren(t, note);
|
||||
}
|
||||
render();
|
||||
await render();
|
||||
return box;
|
||||
}
|
||||
|
||||
// Expand the tree down to `path` and select it.
|
||||
function reveal(path) {
|
||||
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) row.click();
|
||||
if (!kids || kids.hidden) await activate(row);
|
||||
cur = joinPath(cur, part);
|
||||
}
|
||||
const target = rowFor(cur);
|
||||
if (target && target !== selectedNode) target.click();
|
||||
if (target && target !== selectedNode) await activate(target);
|
||||
}
|
||||
|
||||
// ?file=<url>&path=<object> opens a file from a URL (same origin, or one
|
||||
// that allows CORS) and selects an object in it.
|
||||
// that allows CORS) by range requests and selects an object in it.
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get("file")) {
|
||||
const url = params.get("file");
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const blob = await resp.blob();
|
||||
await load(new File([blob], url.split("/").pop()));
|
||||
if (file && params.get("path")) reveal(params.get("path"));
|
||||
} catch (e) {
|
||||
$("detail").replaceChildren(el("p", { className: "error", textContent: `Cannot fetch ${url}: ${e.message}` }));
|
||||
}
|
||||
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"));
|
||||
|
||||
Reference in New Issue
Block a user