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";
8const clamp = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x));
10/** A draggable vertical divider; reports incremental drag deltas in px. */
11function Divider({ onResize }: { onResize: (dx: number) => void }) {
12 const onPointerDown = (e: React.PointerEvent) => {
13 e.preventDefault();
14 let lastX = e.clientX;
15 const move = (ev: PointerEvent) => {
16 onResize(ev.clientX - lastX);
17 lastX = ev.clientX;
18 };
19 const up = () => {
20 window.removeEventListener("pointermove", move);
21 window.removeEventListener("pointerup", up);
22 document.body.style.cursor = "";
23 document.body.style.userSelect = "";
24 };
25 window.addEventListener("pointermove", move);
26 window.addEventListener("pointerup", up);
27 document.body.style.cursor = "col-resize";
28 document.body.style.userSelect = "none";
29 };
30 return <div className="divider" onPointerDown={onPointerDown} role="separator" />;
31}
33export function App() {
34 const [figure, setFigure] = useState<FigureState | null>(null);
35 const [fileName, setFileName] = useState("figure");
36 const [error, setError] = useState<string | null>(null);
37 const [loading, setLoading] = useState(false);
38 const [selectedId, setSelectedId] = useState<string | null>(null);
39 const [dragOver, setDragOver] = useState(false);
41 const isMobile = useMediaQuery("(max-width: 820px)");
42 const [leftOpen, setLeftOpen] = useState(true);
43 const [rightOpen, setRightOpen] = useState(true);
44 const [leftW, setLeftW] = useState(260);
45 const [rightW, setRightW] = useState(320);
46 const inputRef = useRef<HTMLInputElement>(null);
48 // Panels are inline columns on desktop, slide-in drawers on mobile.
49 useEffect(() => {
50 setLeftOpen(!isMobile);
51 setRightOpen(!isMobile);
52 }, [isMobile]);
54 const tree = useMemo(
55 () => (figure ? buildTree(figure, fileName) : null),
56 [figure, fileName]
57 );
58 const selectedNode =
59 tree && selectedId ? (tree.byId.get(selectedId) ?? null) : null;
61 const loadFile = useCallback(async (file: File) => {
62 setLoading(true);
63 setError(null);
64 try {
65 const bytes = new Uint8Array(await file.arrayBuffer());
66 const fig = await importFigureHdf5(bytes);
67 setFigure(fig);
68 setFileName(file.name);
69 setSelectedId("figure");
70 } catch (e) {
71 setError(e instanceof Error ? e.message : String(e));
72 setFigure(null);
73 } finally {
74 setLoading(false);
75 }
76 }, []);
78 const pickFile = () => inputRef.current?.click();
79 const onSelect = (id: string) => {
80 setSelectedId(id);
81 if (isMobile) setLeftOpen(false);
82 };
84 const fileInput = (
85 <input
86 ref={inputRef}
87 type="file"
88 accept=".h5,.hdf5"
89 style={{ display: "none" }}
90 onChange={e => {
91 const f = e.target.files?.[0];
92 if (f) loadFile(f);
93 e.target.value = "";
94 }}
95 />
96 );
98 const dropHandlers = {
99 onDragOver: (e: React.DragEvent) => {
100 e.preventDefault();
101 setDragOver(true);
102 },
103 onDragLeave: () => setDragOver(false),
104 onDrop: (e: React.DragEvent) => {
105 e.preventDefault();
106 setDragOver(false);
107 const f = e.dataTransfer.files[0];
108 if (f) loadFile(f);
109 },
110 };
112 return (
113 <div className="app">
114 {fileInput}
116 <header className="titlebar">
117 {figure && (
118 <button
119 className="icon-btn"
120 title="Toggle object tree"
121 onClick={() => setLeftOpen(o => !o)}
122 >
123 ☰
124 </button>
125 )}
126 <span className="app-title">numbl figure viewer</span>
127 {figure && <span className="file-name">{fileName}</span>}
128 <span className="spacer" />
129 <button className="text-btn" onClick={pickFile} disabled={loading}>
130 {loading ? "Loading…" : "Open .h5…"}
131 </button>
132 {figure && (
133 <button
134 className="icon-btn"
135 title="Toggle details panel"
136 onClick={() => setRightOpen(o => !o)}
137 >
138 ⓘ
139 </button>
140 )}
141 </header>
143 {error && <div className="error-bar">Failed to open file: {error}</div>}
145 {!figure ? (
146 <div
147 className={"empty-state" + (dragOver ? " over" : "")}
148 onClick={pickFile}
149 {...dropHandlers}
150 >
151 <div className="empty-inner">
152 <div className="empty-icon">▦</div>
153 <h2>Open a numbl figure</h2>
154 <p>
155 Drag a <code>.h5</code> figure file here, or click to choose one.
156 </p>
157 <p className="muted small">
158 Exported from numbl via “Download data (.h5)”.
159 </p>
160 </div>
161 </div>
162 ) : (
163 <div className="body">
164 {/* Left: object tree */}
165 {leftOpen && (
166 <aside
167 className={"panel left" + (isMobile ? " drawer" : "")}
168 style={isMobile ? undefined : { width: leftW }}
169 >
170 <div className="panel-head">
171 <span>Objects</span>
172 {isMobile && (
173 <button className="icon-btn" onClick={() => setLeftOpen(false)}>
174 ✕
175 </button>
176 )}
177 </div>
178 <div className="panel-body">
179 {tree && (
180 <ObjectTree
181 root={tree.root}
182 selectedId={selectedId}
183 onSelect={onSelect}
184 />
185 )}
186 </div>
187 </aside>
188 )}
189 {leftOpen && !isMobile && (
190 <Divider onResize={dx => setLeftW(w => clamp(w + dx, 180, 520))} />
191 )}
193 {/* Center: figure */}
194 <main className="center" {...dropHandlers}>
195 <div className="figure-host">
196 <FigureView figure={figure} />
197 </div>
198 {dragOver && <div className="drop-hint">Drop to open</div>}
199 </main>
201 {/* Right: details */}
202 {rightOpen && !isMobile && (
203 <Divider onResize={dx => setRightW(w => clamp(w - dx, 220, 620))} />
204 )}
205 {rightOpen && (
206 <aside
207 className={"panel right" + (isMobile ? " drawer" : "")}
208 style={isMobile ? undefined : { width: rightW }}
209 >
210 <div className="panel-head">
211 <span>Details</span>
212 {isMobile && (
213 <button
214 className="icon-btn"
215 onClick={() => setRightOpen(false)}
216 >
217 ✕
218 </button>
219 )}
220 </div>
221 <div className="panel-body">
222 <InfoPanel node={selectedNode} />
223 </div>
224 </aside>
225 )}
227 {/* Mobile drawer backdrop */}
228 {isMobile && (leftOpen || rightOpen) && (
229 <div
230 className="backdrop"
231 onClick={() => {
232 setLeftOpen(false);
233 setRightOpen(false);
234 }}
235 />
236 )}
237 </div>
238 )}
239 </div>
240 );
241}