6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3 importFigureHdf5,
4 loadFigureFromHash,
5 FigureView,
6 type FigureState,
7} from "numbl/graphics";
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 8import { buildTree } from "./tree";
9import { ObjectTree } from "./ObjectTree";
10import { InfoPanel } from "./InfoPanel";
11import { useMediaQuery } from "./useMediaQuery";
13 applyViewState,
14 emptyViewState,
15 figureHasContent,
16 isolate,
17 showAll,
18 toggleHidden,
19 type ViewState,
20} from "./viewState";
22const clamp = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x));
24/** A draggable vertical divider; reports incremental drag deltas in px. */
25function Divider({ onResize }: { onResize: (dx: number) => void }) {
26 const onPointerDown = (e: React.PointerEvent) => {
27 e.preventDefault();
28 let lastX = e.clientX;
29 const move = (ev: PointerEvent) => {
30 onResize(ev.clientX - lastX);
31 lastX = ev.clientX;
32 };
33 const up = () => {
34 window.removeEventListener("pointermove", move);
35 window.removeEventListener("pointerup", up);
36 document.body.style.cursor = "";
37 document.body.style.userSelect = "";
38 };
39 window.addEventListener("pointermove", move);
40 window.addEventListener("pointerup", up);
41 document.body.style.cursor = "col-resize";
42 document.body.style.userSelect = "none";
43 };
44 return <div className="divider" onPointerDown={onPointerDown} role="separator" />;
45}
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 46
47export function App() {
48 const [figure, setFigure] = useState<FigureState | null>(null);
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 49 const [fileName, setFileName] = useState("figure");
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 50 const [error, setError] = useState<string | null>(null);
51 const [loading, setLoading] = useState(false);
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 52 const [selectedId, setSelectedId] = useState<string | null>(null);
d24678aDetails data view + per-element visibility controlsJeremy Magland 53 const [viewState, setViewState] = useState<ViewState>(emptyViewState);
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 54 const [dragOver, setDragOver] = useState(false);
56 const isMobile = useMediaQuery("(max-width: 820px)");
57 const [leftOpen, setLeftOpen] = useState(true);
58 const [rightOpen, setRightOpen] = useState(true);
59 const [leftW, setLeftW] = useState(260);
60 const [rightW, setRightW] = useState(320);
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 61 const inputRef = useRef<HTMLInputElement>(null);
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 63 // Panels are inline columns on desktop, slide-in drawers on mobile.
64 useEffect(() => {
65 setLeftOpen(!isMobile);
66 setRightOpen(!isMobile);
67 }, [isMobile]);
ffb6f96Load a figure handed over from numbl via the URL hashJeremy Magland 69 // Receive a figure handed over from numbl via the URL hash (postMessage can't
70 // reach a COOP-isolated opener's cross-origin popup). Two forms: the figure
71 // gzip-encoded directly (`fig=`), or a reference to an encrypted upload
72 // (`u=&k=&iv=`) which we fetch + decrypt. Runs once (the upload host is
73 // one-shot); the hash is stripped from the address bar immediately.
74 const hashLoaded = useRef(false);
75 useEffect(() => {
76 if (hashLoaded.current) return;
77 hashLoaded.current = true;
78 const hash = window.location.hash;
79 if (!hash.startsWith("#fig=") && !hash.startsWith("#u=")) return;
80 try {
81 history.replaceState(
82 null,
83 "",
84 window.location.pathname + window.location.search
85 );
86 } catch {
87 /* ignore */
88 }
89 setLoading(true);
90 setError(null);
91 loadFigureFromHash(hash)
92 .then(fig => {
93 if (!fig) return;
94 setFigure(fig);
95 setFileName("from numbl");
96 setSelectedId("figure");
97 setViewState(emptyViewState());
98 })
99 .catch(e => setError(e instanceof Error ? e.message : String(e)))
100 .finally(() => setLoading(false));
101 }, []);
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 103 const tree = useMemo(
104 () => (figure ? buildTree(figure, fileName) : null),
105 [figure, fileName]
106 );
107 const selectedNode =
108 tree && selectedId ? (tree.byId.get(selectedId) ?? null) : null;
d24678aDetails data view + per-element visibility controlsJeremy Magland 110 // The figure actually rendered is derived from the parsed figure + view state.
111 const viewFigure = useMemo(
112 () => (figure ? applyViewState(figure, viewState) : null),
113 [figure, viewState]
114 );
115 const nothingVisible = !!viewFigure && !figureHasContent(viewFigure);
117 const onToggleHidden = (id: string) =>
118 setViewState(vs => toggleHidden(vs, id));
119 const onIsolate = (id: string) =>
120 setViewState(vs => (tree ? isolate(vs, tree, id) : vs));
121 const onShowAll = () => setViewState(showAll);
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 123 const loadFile = useCallback(async (file: File) => {
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 124 setLoading(true);
125 setError(null);
126 try {
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 127 const bytes = new Uint8Array(await file.arrayBuffer());
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 128 const fig = await importFigureHdf5(bytes);
129 setFigure(fig);
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 130 setFileName(file.name);
131 setSelectedId("figure");
d24678aDetails data view + per-element visibility controlsJeremy Magland 132 setViewState(emptyViewState());
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 133 } catch (e) {
134 setError(e instanceof Error ? e.message : String(e));
135 setFigure(null);
136 } finally {
137 setLoading(false);
138 }
139 }, []);
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 141 const pickFile = () => inputRef.current?.click();
142 const onSelect = (id: string) => {
143 setSelectedId(id);
144 if (isMobile) setLeftOpen(false);
145 };
147 const fileInput = (
148 <input
149 ref={inputRef}
150 type="file"
151 accept=".h5,.hdf5"
152 style={{ display: "none" }}
153 onChange={e => {
154 const f = e.target.files?.[0];
155 if (f) loadFile(f);
156 e.target.value = "";
157 }}
158 />
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 159 );
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 161 const dropHandlers = {
162 onDragOver: (e: React.DragEvent) => {
163 e.preventDefault();
164 setDragOver(true);
165 },
166 onDragLeave: () => setDragOver(false),
167 onDrop: (e: React.DragEvent) => {
168 e.preventDefault();
169 setDragOver(false);
170 const f = e.dataTransfer.files[0];
171 if (f) loadFile(f);
172 },
173 };
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 174
175 return (
176 <div className="app">
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 178
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 179 <header className="titlebar">
180 {figure && (
181 <button
182 className="icon-btn"
183 title="Toggle object tree"
184 onClick={() => setLeftOpen(o => !o)}
185 >
186 ☰
187 </button>
188 )}
189 <span className="app-title">numbl figure viewer</span>
190 {figure && <span className="file-name">{fileName}</span>}
191 <span className="spacer" />
192 <button className="text-btn" onClick={pickFile} disabled={loading}>
193 {loading ? "Loading…" : "Open .h5…"}
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 194 </button>
196 <button
197 className="icon-btn"
198 title="Toggle details panel"
199 onClick={() => setRightOpen(o => !o)}
200 >
201 ⓘ
202 </button>
203 )}
204 </header>
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 205
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 206 {error && <div className="error-bar">Failed to open file: {error}</div>}
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 207
209 <div
210 className={"empty-state" + (dragOver ? " over" : "")}
211 onClick={pickFile}
212 {...dropHandlers}
213 >
214 <div className="empty-inner">
215 <div className="empty-icon">▦</div>
217 <h2>Loading figure…</h2>
218 ) : (
219 <>
220 <h2>Open a numbl figure</h2>
221 <p>
222 Drag a <code>.h5</code> figure file here, or click to choose
223 one.
224 </p>
225 <p className="muted small">
226 Exported from numbl via “Download data (.h5)”.
227 </p>
228 </>
229 )}
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 230 </div>
231 </div>
233 <div className="body">
234 {/* Left: object tree */}
235 {leftOpen && (
236 <aside
237 className={"panel left" + (isMobile ? " drawer" : "")}
238 style={isMobile ? undefined : { width: leftW }}
239 >
240 <div className="panel-head">
241 <span>Objects</span>
d24678aDetails data view + per-element visibility controlsJeremy Magland 242 <span className="head-actions">
243 {viewState.hidden.size > 0 && (
244 <button className="link-btn" onClick={onShowAll}>
245 Show all
246 </button>
247 )}
248 {isMobile && (
249 <button
250 className="icon-btn"
251 onClick={() => setLeftOpen(false)}
252 >
253 ✕
254 </button>
255 )}
256 </span>
258 <div className="panel-body">
259 {tree && (
260 <ObjectTree
261 root={tree.root}
262 selectedId={selectedId}
263 onSelect={onSelect}
265 onToggleHidden={onToggleHidden}
266 onIsolate={onIsolate}
268 )}
269 </div>
270 </aside>
271 )}
272 {leftOpen && !isMobile && (
273 <Divider onResize={dx => setLeftW(w => clamp(w + dx, 180, 520))} />
274 )}
d24678aDetails data view + per-element visibility controlsJeremy Magland 276 {/* Center: figure (rendered from the view-state-derived figure) */}
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 277 <main className="center" {...dropHandlers}>
278 <div className="figure-host">
d24678aDetails data view + per-element visibility controlsJeremy Magland 279 {viewFigure && !nothingVisible && (
280 <FigureView figure={viewFigure} />
281 )}
284 <div className="center-overlay">
285 <div>
286 <p>Nothing visible</p>
287 <button className="text-btn" onClick={onShowAll}>
288 Show all
289 </button>
290 </div>
291 </div>
292 )}
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 293 {dragOver && <div className="drop-hint">Drop to open</div>}
294 </main>
296 {/* Right: details */}
297 {rightOpen && !isMobile && (
298 <Divider onResize={dx => setRightW(w => clamp(w - dx, 220, 620))} />
299 )}
300 {rightOpen && (
301 <aside
302 className={"panel right" + (isMobile ? " drawer" : "")}
303 style={isMobile ? undefined : { width: rightW }}
304 >
305 <div className="panel-head">
306 <span>Details</span>
307 {isMobile && (
308 <button
309 className="icon-btn"
310 onClick={() => setRightOpen(false)}
311 >
312 ✕
313 </button>
314 )}
315 </div>
316 <div className="panel-body">
317 <InfoPanel node={selectedNode} />
318 </div>
319 </aside>
320 )}
322 {/* Mobile drawer backdrop */}
323 {isMobile && (leftOpen || rightOpen) && (
324 <div
325 className="backdrop"
326 onClick={() => {
327 setLeftOpen(false);
328 setRightOpen(false);
329 }}
330 />
331 )}
332 </div>
a9daa5bnumbl figure viewer: upload a numbl .h5 figure and render it via numbl/graphicsJeremy Magland 333 )}
334 </div>
335 );
336}