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