import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
importFigureHdf5,
loadFigureFromHash,
buildFigureViewerLink,
FigureView,
type FigureState,
} from "numbl/graphics";
import { buildTree } from "./tree";
import { ObjectTree } from "./ObjectTree";
import { InfoPanel } from "./InfoPanel";
import { useMediaQuery } from "./useMediaQuery";
import {
applyViewState,
emptyViewState,
figureHasContent,
isolate,
showAll,
toggleHidden,
type ViewState,
} from "./viewState";
const clamp = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x));
/** A draggable vertical divider; reports incremental drag deltas in px. */
function Divider({ onResize }: { onResize: (dx: number) => void }) {
const onPointerDown = (e: React.PointerEvent) => {
e.preventDefault();
let lastX = e.clientX;
const move = (ev: PointerEvent) => {
onResize(ev.clientX - lastX);
lastX = ev.clientX;
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
};
return
;
}
export function App() {
const [figure, setFigure] = useState(null);
const [fileName, setFileName] = useState("figure");
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState(null);
const [viewState, setViewState] = useState(emptyViewState);
const [dragOver, setDragOver] = useState(false);
const [notice, setNotice] = useState(null);
const noticeTimer = useRef(undefined);
const isMobile = useMediaQuery("(max-width: 820px)");
const [leftOpen, setLeftOpen] = useState(true);
const [rightOpen, setRightOpen] = useState(true);
const [leftW, setLeftW] = useState(260);
const [rightW, setRightW] = useState(320);
const inputRef = useRef(null);
// Panels are inline columns on desktop, slide-in drawers on mobile.
useEffect(() => {
setLeftOpen(!isMobile);
setRightOpen(!isMobile);
}, [isMobile]);
// Receive a figure handed over from numbl via the URL hash (postMessage can't
// reach a COOP-isolated opener's cross-origin popup). Two forms: the figure
// gzip-encoded directly (`fig=`), or a reference to an encrypted upload
// (`u=&k=&iv=`) which we fetch + decrypt. Runs once (the upload host is
// one-shot); the hash is stripped from the address bar immediately.
const hashLoaded = useRef(false);
useEffect(() => {
if (hashLoaded.current) return;
hashLoaded.current = true;
const hash = window.location.hash;
if (!hash.startsWith("#fig=") && !hash.startsWith("#u=")) return;
try {
history.replaceState(
null,
"",
window.location.pathname + window.location.search
);
} catch {
/* ignore */
}
setLoading(true);
setError(null);
loadFigureFromHash(hash)
.then(fig => {
if (!fig) return;
setFigure(fig);
setFileName("from numbl");
setSelectedId("figure");
setViewState(emptyViewState());
})
.catch(e => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false));
}, []);
const tree = useMemo(
() => (figure ? buildTree(figure, fileName) : null),
[figure, fileName]
);
const selectedNode =
tree && selectedId ? (tree.byId.get(selectedId) ?? null) : null;
// The figure actually rendered is derived from the parsed figure + view state.
const viewFigure = useMemo(
() => (figure ? applyViewState(figure, viewState) : null),
[figure, viewState]
);
const nothingVisible = !!viewFigure && !figureHasContent(viewFigure);
const onToggleHidden = (id: string) =>
setViewState(vs => toggleHidden(vs, id));
const onIsolate = (id: string) =>
setViewState(vs => (tree ? isolate(vs, tree, id) : vs));
const onShowAll = () => setViewState(showAll);
const loadFile = useCallback(async (file: File) => {
setLoading(true);
setError(null);
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const fig = await importFigureHdf5(bytes);
setFigure(fig);
setFileName(file.name);
setSelectedId("figure");
setViewState(emptyViewState());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setFigure(null);
} finally {
setLoading(false);
}
}, []);
const pickFile = () => inputRef.current?.click();
const onSelect = (id: string) => {
setSelectedId(id);
if (isMobile) setLeftOpen(false);
};
const showNotice = useCallback((msg: string) => {
setNotice(msg);
if (noticeTimer.current) window.clearTimeout(noticeTimer.current);
noticeTimer.current = window.setTimeout(() => setNotice(null), 2800);
}, []);
// Share the current figure as a link: encode it into a URL pointing back at
// this viewer and copy it to the clipboard, unless it's too large for a URL.
const handleShare = useCallback(async () => {
if (!figure) return;
const base = window.location.origin + window.location.pathname;
const link = buildFigureViewerLink(figure, base);
if (!link.url) {
showNotice("This figure is too large to share via a link.");
return;
}
try {
await navigator.clipboard.writeText(link.url);
showNotice("Link copied to clipboard");
} catch {
showNotice("Couldn’t copy the link to the clipboard.");
}
}, [figure, showNotice]);
const fileInput = (
{
const f = e.target.files?.[0];
if (f) loadFile(f);
e.target.value = "";
}}
/>
);
const dropHandlers = {
onDragOver: (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
},
onDragLeave: () => setDragOver(false),
onDrop: (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files[0];
if (f) loadFile(f);
},
};
return (
{fileInput}
{figure && (
)}
numbl figure viewer
{figure && {fileName}}
{figure && (
)}
{figure && (
)}
{error &&
Failed to open file: {error}
}
{!figure ? (
▦
{loading ? (
Loading figure…
) : (
<>
Open a numbl figure
Drag a .h5 figure file here, or click to choose
one.
Exported from numbl via “Download data (.h5)”.
>
)}
) : (
{/* Left: object tree */}
{leftOpen && (
)}
{leftOpen && !isMobile && (
setLeftW(w => clamp(w + dx, 180, 520))} />
)}
{/* Center: figure (rendered from the view-state-derived figure) */}
{viewFigure && !nothingVisible && (
)}
{nothingVisible && (
)}
{dragOver && Drop to open
}
{/* Right: details */}
{rightOpen && !isMobile && (
setRightW(w => clamp(w - dx, 220, 620))} />
)}
{rightOpen && (
)}
{/* Mobile drawer backdrop */}
{isMobile && (leftOpen || rightOpen) && (
{
setLeftOpen(false);
setRightOpen(false);
}}
/>
)}
)}
{notice && {notice}
}
);
}