Files
clawhdf5/examples/wasm-viewer/index.html
T
osobhandClaude Opus 5.5 1abd93e0f8 feat(wasm): examples/wasm-viewer, an HDF5/NetCDF-4 viewer page
Drop a file (or pass ?file=<url>&path=<object>), browse the tree lazily,
see a dataset's type, shape, max shape and attributes, and page through
its values as 50x12 hyperslab windows (leading dims of 3-D+ data held
at chosen indices). build.sh produces pkg/ (not committed) with
wasm-bindgen --target web and checks the CLI matches the crate version.

test/run.sh builds it and runs test.mjs under Node against the h5py/
netCDF4 fixture (250 checks: every dataset whole and as a strided
hyperslab, listings, attributes, error paths, the page's DOM-free
helpers), then browser.sh renders the page in headless Chromium for
eight objects and checks the DOM. The fixture gains LZ4 (read) and Zstd
(refused: links C) datasets and a compound attribute (value null plus
its type). ci-test.sh runs it when node and wasm-bindgen exist; the CI
container has neither, so CI relies on the native h5py_interop test.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 00:05:34 -05:00

279 lines
12 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); }
</style>
</head>
<body>
<header>
<h1>HDF5 Viewer</h1>
<span class="file" id="filename">no file</span>
<label class="button">Open file…<input type="file" id="picker" accept=".h5,.hdf5,.he5,.nc,.nc4,.cdf" hidden></label>
</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 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";
const $ = (id) => document.getElementById(id);
const el = (tag, props = {}, ...kids) => {
const e = Object.assign(document.createElement(tag), props);
e.append(...kids);
return e;
};
let file = null;
let selectedNode = null;
await init();
$("version").textContent = `clawhdf5 ${version()}`;
async function load(blob) {
const bytes = new Uint8Array(await blob.arrayBuffer());
if (file) file.free();
file = null;
$("filename").textContent = blob.name;
$("tree").replaceChildren();
try {
file = open(bytes);
} catch (e) {
$("detail").replaceChildren(el("p", { className: "error", textContent: `Cannot open ${blob.name}: ${e.message}` }));
return;
}
const root = el("ul", { className: "tree" });
root.append(treeNode("/", "/", "group"));
$("tree").append(root);
root.querySelector(".node").click();
}
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.addEventListener("click", () => {
if (selectedNode) selectedNode.classList.remove("selected");
row.classList.add("selected");
selectedNode = row;
if (kind === "group") {
if (children) {
children.hidden = !children.hidden;
} else {
children = el("ul", { className: "tree" });
try {
for (const c of 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);
}
icon.textContent = children.hidden ? "▸" : "▾";
}
show(path, kind);
});
return li;
}
function attrsTable(path) {
let attrs, errors;
try {
attrs = file.attrs(path);
errors = 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);
}
function show(path, kind) {
const out = [el("h2", { textContent: path })];
if (kind === "dataset") {
let info;
try {
info = file.info(path);
} catch (e) {
$("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" }), attrsTable(path));
out.push(el("h3", { textContent: "Values" }), valuesView(path, info));
} else {
out.push(el("h3", { textContent: "Attributes" }), attrsTable(path));
}
$("detail").replaceChildren(...out);
}
function valuesView(path, info) {
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)));
function render() {
const w = viewWindow(shape, state);
let res;
try {
res = w === null ? file.read(path) : file.readHyperslab(path, w.start, w.count);
} catch (e) {
body.replaceChildren(el("p", { className: "error", textContent: e.message }));
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);
}
render();
return box;
}
// Expand the tree down to `path` and select it.
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();
cur = joinPath(cur, part);
}
const target = rowFor(cur);
if (target && target !== selectedNode) target.click();
}
// ?file=<url>&path=<object> opens a file from a URL (same origin, or one
// that allows CORS) 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}` }));
}
}
$("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>