/ concept-collection / numbl-figure-viewer
Sign in
concept-collection / numbl-figure-viewer
numbl-figure-viewer / src / App.tsx
290 lines · 9.0 KBBlameHistoryRaw
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { importFigureHdf5, FigureView, type FigureState } from "numbl/graphics";
3import { buildTree } from "./tree";
4import { ObjectTree } from "./ObjectTree";
5import { InfoPanel } from "./InfoPanel";
6import { useMediaQuery } from "./useMediaQuery";
7import {
8 applyViewState,
9 emptyViewState,
10 figureHasContent,
11 isolate,
12 showAll,
13 toggleHidden,
14 type ViewState,
15} from "./viewState";
17const clamp = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x));
19/** A draggable vertical divider; reports incremental drag deltas in px. */
20function Divider({ onResize }: { onResize: (dx: number) => void }) {
21 const onPointerDown = (e: React.PointerEvent) => {
22 e.preventDefault();
23 let lastX = e.clientX;
24 const move = (ev: PointerEvent) => {
25 onResize(ev.clientX - lastX);
26 lastX = ev.clientX;
27 };
28 const up = () => {
29 window.removeEventListener("pointermove", move);
30 window.removeEventListener("pointerup", up);
31 document.body.style.cursor = "";
32 document.body.style.userSelect = "";
33 };
34 window.addEventListener("pointermove", move);
35 window.addEventListener("pointerup", up);
36 document.body.style.cursor = "col-resize";
37 document.body.style.userSelect = "none";
38 };
39 return <div className="divider" onPointerDown={onPointerDown} role="separator" />;
42export function App() {
43 const [figure, setFigure] = useState<FigureState | null>(null);
44 const [fileName, setFileName] = useState("figure");
45 const [error, setError] = useState<string | null>(null);
46 const [loading, setLoading] = useState(false);
47 const [selectedId, setSelectedId] = useState<string | null>(null);
48 const [viewState, setViewState] = useState<ViewState>(emptyViewState);
49 const [dragOver, setDragOver] = useState(false);
51 const isMobile = useMediaQuery("(max-width: 820px)");
52 const [leftOpen, setLeftOpen] = useState(true);
53 const [rightOpen, setRightOpen] = useState(true);
54 const [leftW, setLeftW] = useState(260);
55 const [rightW, setRightW] = useState(320);
56 const inputRef = useRef<HTMLInputElement>(null);
58 // Panels are inline columns on desktop, slide-in drawers on mobile.
59 useEffect(() => {
60 setLeftOpen(!isMobile);
61 setRightOpen(!isMobile);
62 }, [isMobile]);
64 const tree = useMemo(
65 () => (figure ? buildTree(figure, fileName) : null),
66 [figure, fileName]
67 );
68 const selectedNode =
69 tree && selectedId ? (tree.byId.get(selectedId) ?? null) : null;
71 // The figure actually rendered is derived from the parsed figure + view state.
72 const viewFigure = useMemo(
73 () => (figure ? applyViewState(figure, viewState) : null),
74 [figure, viewState]
75 );
76 const nothingVisible = !!viewFigure && !figureHasContent(viewFigure);
78 const onToggleHidden = (id: string) =>
79 setViewState(vs => toggleHidden(vs, id));
80 const onIsolate = (id: string) =>
81 setViewState(vs => (tree ? isolate(vs, tree, id) : vs));
82 const onShowAll = () => setViewState(showAll);
84 const loadFile = useCallback(async (file: File) => {
85 setLoading(true);
86 setError(null);
87 try {
88 const bytes = new Uint8Array(await file.arrayBuffer());
89 const fig = await importFigureHdf5(bytes);
90 setFigure(fig);
91 setFileName(file.name);
92 setSelectedId("figure");
93 setViewState(emptyViewState());
94 } catch (e) {
95 setError(e instanceof Error ? e.message : String(e));
96 setFigure(null);
97 } finally {
98 setLoading(false);
99 }
100 }, []);
102 const pickFile = () => inputRef.current?.click();
103 const onSelect = (id: string) => {
104 setSelectedId(id);
105 if (isMobile) setLeftOpen(false);
106 };
108 const fileInput = (
109 <input
110 ref={inputRef}
111 type="file"
112 accept=".h5,.hdf5"
113 style={{ display: "none" }}
114 onChange={e => {
115 const f = e.target.files?.[0];
116 if (f) loadFile(f);
117 e.target.value = "";
118 }}
119 />
120 );
122 const dropHandlers = {
123 onDragOver: (e: React.DragEvent) => {
124 e.preventDefault();
125 setDragOver(true);
126 },
127 onDragLeave: () => setDragOver(false),
128 onDrop: (e: React.DragEvent) => {
129 e.preventDefault();
130 setDragOver(false);
131 const f = e.dataTransfer.files[0];
132 if (f) loadFile(f);
133 },
134 };
136 return (
137 <div className="app">
138 {fileInput}
140 <header className="titlebar">
141 {figure && (
142 <button
143 className="icon-btn"
144 title="Toggle object tree"
145 onClick={() => setLeftOpen(o => !o)}
146 >
147
148 </button>
149 )}
150 <span className="app-title">numbl figure viewer</span>
151 {figure && <span className="file-name">{fileName}</span>}
152 <span className="spacer" />
153 <button className="text-btn" onClick={pickFile} disabled={loading}>
154 {loading ? "Loading…" : "Open .h5…"}
155 </button>
156 {figure && (
157 <button
158 className="icon-btn"
159 title="Toggle details panel"
160 onClick={() => setRightOpen(o => !o)}
161 >
162
163 </button>
164 )}
165 </header>
167 {error && <div className="error-bar">Failed to open file: {error}</div>}
169 {!figure ? (
170 <div
171 className={"empty-state" + (dragOver ? " over" : "")}
172 onClick={pickFile}
173 {...dropHandlers}
174 >
175 <div className="empty-inner">
176 <div className="empty-icon"></div>
177 <h2>Open a numbl figure</h2>
178 <p>
179 Drag a <code>.h5</code> figure file here, or click to choose one.
180 </p>
181 <p className="muted small">
182 Exported from numbl via “Download data (.h5)”.
183 </p>
184 </div>
185 </div>
186 ) : (
187 <div className="body">
188 {/* Left: object tree */}
189 {leftOpen && (
190 <aside
191 className={"panel left" + (isMobile ? " drawer" : "")}
192 style={isMobile ? undefined : { width: leftW }}
193 >
194 <div className="panel-head">
195 <span>Objects</span>
196 <span className="head-actions">
197 {viewState.hidden.size > 0 && (
198 <button className="link-btn" onClick={onShowAll}>
199 Show all
200 </button>
201 )}
202 {isMobile && (
203 <button
204 className="icon-btn"
205 onClick={() => setLeftOpen(false)}
206 >
207
208 </button>
209 )}
210 </span>
211 </div>
212 <div className="panel-body">
213 {tree && (
214 <ObjectTree
215 root={tree.root}
216 selectedId={selectedId}
217 onSelect={onSelect}
218 viewState={viewState}
219 onToggleHidden={onToggleHidden}
220 onIsolate={onIsolate}
221 />
222 )}
223 </div>
224 </aside>
225 )}
226 {leftOpen && !isMobile && (
227 <Divider onResize={dx => setLeftW(w => clamp(w + dx, 180, 520))} />
228 )}
230 {/* Center: figure (rendered from the view-state-derived figure) */}
231 <main className="center" {...dropHandlers}>
232 <div className="figure-host">
233 {viewFigure && !nothingVisible && (
234 <FigureView figure={viewFigure} />
235 )}
236 </div>
237 {nothingVisible && (
238 <div className="center-overlay">
239 <div>
240 <p>Nothing visible</p>
241 <button className="text-btn" onClick={onShowAll}>
242 Show all
243 </button>
244 </div>
245 </div>
246 )}
247 {dragOver && <div className="drop-hint">Drop to open</div>}
248 </main>
250 {/* Right: details */}
251 {rightOpen && !isMobile && (
252 <Divider onResize={dx => setRightW(w => clamp(w - dx, 220, 620))} />
253 )}
254 {rightOpen && (
255 <aside
256 className={"panel right" + (isMobile ? " drawer" : "")}
257 style={isMobile ? undefined : { width: rightW }}
258 >
259 <div className="panel-head">
260 <span>Details</span>
261 {isMobile && (
262 <button
263 className="icon-btn"
264 onClick={() => setRightOpen(false)}
265 >
266
267 </button>
268 )}
269 </div>
270 <div className="panel-body">
271 <InfoPanel node={selectedNode} />
272 </div>
273 </aside>
274 )}
276 {/* Mobile drawer backdrop */}
277 {isMobile && (leftOpen || rightOpen) && (
278 <div
279 className="backdrop"
280 onClick={() => {
281 setLeftOpen(false);
282 setRightOpen(false);
283 }}
284 />
285 )}
286 </div>
287 )}
288 </div>
289 );
moveopenescclose