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 && (

Nothing visible

)} {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}
}
); }