concept-collection / numbl-figure-viewer
VS Code-style layout: object tree + figure + details panel
Replace the single-figure view with a three-pane workspace that fills the window: an expandable object tree (figure → axes → traces → data arrays) on the left, the rendered figure in the center, and a details panel (properties / dataset shape+stats+preview) on the right. Panes are resizable on desktop and become slide-in drawers on mobile. Remove the sample feature; a file upload is now required.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 6ab510b8656b parent a9daa5b Browse files
8 changed files+1142−128
README.mdmodified+18−3View file
@@ -1,12 +1,26 @@
11 # numbl figure viewer
22
3-A small browser app that opens a `.h5` **figure file** exported from
3+A browser app that opens a `.h5` **figure file** exported from
44 [numbl](https://numbl.org) and renders it — reusing numbl's own figure
55 components via the `numbl/graphics` package export.
66
77 In numbl (or its IDE / plot viewer) you can download any figure's data as a
88 self-describing HDF5 file (numeric data as gzip-compressed datasets, styling as
9-attributes). Drop that file here to view it again outside numbl.
9+attributes). Open that file here to view and inspect it outside numbl.
10+
11+## Interface
12+
13+A VS Code-style three-pane layout that fills the window and is responsive /
14+mobile-friendly:
15+
16+- **Left** — an expandable tree of the figure's objects (figure → axes → traces
17+ → data arrays).
18+- **Center** — the rendered figure (numbl's `FigureView`, which resizes with the
19+ pane).
20+- **Right** — details of the selected object: properties (with colour swatches)
21+ for figure/axes/traces, and shape / stats / a value preview for data arrays.
22+
23+On narrow screens the side panels become slide-in drawers.
1024
1125 ## How it works
1226
@@ -29,7 +43,8 @@ npm run dev
2943
3044 > This app consumes `numbl` as `file:../../numbl`. Build the numbl graphics
3145 > bundle first (`npm run build:graphics` in the numbl repo) so
32-> `numbl/graphics` resolves.
46+> `numbl/graphics` resolves. The deployed site (GitHub Pages) builds against
47+> numbl's `main` branch automatically — see `.github/workflows/deploy.yml`.
3348
3449 ## Build
3550
public/sample.h5deleted+0−0View file
Binary file not shown.
src/App.tsxmodified+204−68View file
@@ -1,104 +1,240 @@
1-import { useCallback, useRef, useState } from "react";
1+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22 import { importFigureHdf5, FigureView, type FigureState } from "numbl/graphics";
3+import { buildTree } from "./tree";
4+import { ObjectTree } from "./ObjectTree";
5+import { InfoPanel } from "./InfoPanel";
6+import { useMediaQuery } from "./useMediaQuery";
7+
8+const clamp = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x));
9+
10+/** A draggable vertical divider; reports incremental drag deltas in px. */
11+function 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+}
332
433 export function App() {
534 const [figure, setFigure] = useState<FigureState | null>(null);
6- const [fileName, setFileName] = useState<string | null>(null);
35+ const [fileName, setFileName] = useState("figure");
736 const [error, setError] = useState<string | null>(null);
837 const [loading, setLoading] = useState(false);
38+ const [selectedId, setSelectedId] = useState<string | null>(null);
939 const [dragOver, setDragOver] = useState(false);
40+
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);
1046 const inputRef = useRef<HTMLInputElement>(null);
1147
12- const loadBytes = useCallback(async (bytes: Uint8Array, name: string) => {
48+ // Panels are inline columns on desktop, slide-in drawers on mobile.
49+ useEffect(() => {
50+ setLeftOpen(!isMobile);
51+ setRightOpen(!isMobile);
52+ }, [isMobile]);
53+
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;
60+
61+ const loadFile = useCallback(async (file: File) => {
1362 setLoading(true);
1463 setError(null);
1564 try {
65+ const bytes = new Uint8Array(await file.arrayBuffer());
1666 const fig = await importFigureHdf5(bytes);
1767 setFigure(fig);
18- setFileName(name);
68+ setFileName(file.name);
69+ setSelectedId("figure");
1970 } catch (e) {
2071 setError(e instanceof Error ? e.message : String(e));
2172 setFigure(null);
22- setFileName(null);
2373 } finally {
2474 setLoading(false);
2575 }
2676 }, []);
2777
28- const loadFile = useCallback(
29- async (file: File) => loadBytes(new Uint8Array(await file.arrayBuffer()), file.name),
30- [loadBytes]
78+ const pickFile = () => inputRef.current?.click();
79+ const onSelect = (id: string) => {
80+ setSelectedId(id);
81+ if (isMobile) setLeftOpen(false);
82+ };
83+
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+ />
3196 );
3297
33- const loadSample = useCallback(async () => {
34- setLoading(true);
35- setError(null);
36- try {
37- const resp = await fetch(`${import.meta.env.BASE_URL}sample.h5`);
38- await loadBytes(new Uint8Array(await resp.arrayBuffer()), "sample.h5");
39- } catch (e) {
40- setError(e instanceof Error ? e.message : String(e));
41- setLoading(false);
42- }
43- }, [loadBytes]);
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+ };
44111
45112 return (
46113 <div className="app">
47- <header>
48- <h1>numbl figure viewer</h1>
49- <p className="sub">
50- Open a <code>.h5</code> figure file exported from{" "}
51- <a href="https://numbl.org">numbl</a> and view it here. Rendering uses
52- numbl&rsquo;s own figure components.
53- </p>
54- </header>
114+ {fileInput}
55115
56- <div
57- className={"dropzone" + (dragOver ? " over" : "")}
58- onClick={() => inputRef.current?.click()}
59- onDragOver={e => {
60- e.preventDefault();
61- setDragOver(true);
62- }}
63- onDragLeave={() => setDragOver(false)}
64- onDrop={e => {
65- e.preventDefault();
66- setDragOver(false);
67- const f = e.dataTransfer.files[0];
68- if (f) loadFile(f);
69- }}
70- >
71- <input
72- ref={inputRef}
73- type="file"
74- accept=".h5,.hdf5"
75- style={{ display: "none" }}
76- onChange={e => {
77- const f = e.target.files?.[0];
78- if (f) loadFile(f);
79- }}
80- />
81- {loading
82- ? "Loading…"
83- : fileName
84- ? `Loaded: ${fileName} — click or drop to open another`
85- : "Click to choose, or drag a .h5 figure file here"}
86- </div>
87-
88- <div className="actions">
89- <button onClick={loadSample} disabled={loading}>
90- Try a sample figure
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…"}
91131 </button>
92- </div>
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>
93142
94- {error && <div className="error">Failed to open file: {error}</div>}
143+ {error && <div className="error-bar">Failed to open file: {error}</div>}
95144
96- {figure && (
97- <div className="figure-wrap">
98- <div className="figure-inner">
99- <FigureView figure={figure} />
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>
100160 </div>
101161 </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+ )}
192+
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>
200+
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+ )}
226+
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>
102238 )}
103239 </div>
104240 );
src/InfoPanel.tsxadded+94−0View file
@@ -0,0 +1,94 @@
1+import type { TreeNode } from "./tree";
2+
3+function fmt(x: number): string {
4+ if (Number.isNaN(x)) return "NaN";
5+ if (!Number.isFinite(x)) return x > 0 ? "Inf" : "-Inf";
6+ return Number.isInteger(x) ? String(x) : Number(x.toPrecision(6)).toString();
7+}
8+
9+export function InfoPanel({ node }: { node: TreeNode | null }) {
10+ if (!node) {
11+ return (
12+ <div className="info empty">
13+ <p>Select an object in the tree to inspect it.</p>
14+ </div>
15+ );
16+ }
17+
18+ const { detail } = node;
19+
20+ return (
21+ <div className="info">
22+ <div className="info-header">
23+ <span className={"node-icon icon-" + node.icon}>
24+ {detail.type === "dataset" ? "⋯" : ""}
25+ </span>
26+ <span className="info-title">{node.label}</span>
27+ <span className="info-kind">
28+ {detail.type === "dataset" ? "dataset" : detail.kind}
29+ </span>
30+ </div>
31+
32+ {detail.type === "object" ? (
33+ detail.properties.length === 0 ? (
34+ <p className="muted">No properties.</p>
35+ ) : (
36+ <table className="props">
37+ <tbody>
38+ {detail.properties.map(p => (
39+ <tr key={p.key}>
40+ <td className="pk">{p.key}</td>
41+ <td className="pv">
42+ {p.swatch && (
43+ <span className="swatch" style={{ background: p.swatch }} />
44+ )}
45+ {p.value}
46+ </td>
47+ </tr>
48+ ))}
49+ </tbody>
50+ </table>
51+ )
52+ ) : (
53+ <div className="dataset-detail">
54+ <table className="props">
55+ <tbody>
56+ <tr>
57+ <td className="pk">shape</td>
58+ <td className="pv">{detail.shapeLabel}</td>
59+ </tr>
60+ <tr>
61+ <td className="pk">count</td>
62+ <td className="pv">{detail.count}</td>
63+ </tr>
64+ {detail.stats && (
65+ <>
66+ <tr>
67+ <td className="pk">min</td>
68+ <td className="pv">{fmt(detail.stats.min)}</td>
69+ </tr>
70+ <tr>
71+ <td className="pk">max</td>
72+ <td className="pv">{fmt(detail.stats.max)}</td>
73+ </tr>
74+ <tr>
75+ <td className="pk">mean</td>
76+ <td className="pv">{fmt(detail.stats.mean)}</td>
77+ </tr>
78+ {detail.stats.nan > 0 && (
79+ <tr>
80+ <td className="pk">NaN</td>
81+ <td className="pv">{detail.stats.nan}</td>
82+ </tr>
83+ )}
84+ </>
85+ )}
86+ </tbody>
87+ </table>
88+ <div className="preview-label">values</div>
89+ <pre className="preview">{detail.preview}</pre>
90+ </div>
91+ )}
92+ </div>
93+ );
94+}
src/ObjectTree.tsxadded+103−0View file
@@ -0,0 +1,103 @@
1+import { useState } from "react";
2+import type { TreeNode, NodeIcon } from "./tree";
3+
4+const ICONS: Record<NodeIcon, string> = {
5+ figure: "▦",
6+ axes: "▢",
7+ trace: "∿",
8+ dataset: "⋯",
9+};
10+
11+function NodeRow({
12+ node,
13+ depth,
14+ expanded,
15+ toggle,
16+ selectedId,
17+ onSelect,
18+}: {
19+ node: TreeNode;
20+ depth: number;
21+ expanded: Set<string>;
22+ toggle: (id: string) => void;
23+ selectedId: string | null;
24+ onSelect: (id: string) => void;
25+}) {
26+ const hasChildren = node.children.length > 0;
27+ const isOpen = expanded.has(node.id);
28+ const isSelected = selectedId === node.id;
29+ return (
30+ <>
31+ <div
32+ className={"tree-row" + (isSelected ? " selected" : "")}
33+ style={{ paddingLeft: 4 + depth * 14 }}
34+ onClick={() => onSelect(node.id)}
35+ role="treeitem"
36+ aria-selected={isSelected}
37+ aria-expanded={hasChildren ? isOpen : undefined}
38+ >
39+ <span
40+ className={"twisty" + (hasChildren ? "" : " leaf")}
41+ onClick={e => {
42+ e.stopPropagation();
43+ if (hasChildren) toggle(node.id);
44+ }}
45+ >
46+ {hasChildren ? (isOpen ? "▾" : "▸") : ""}
47+ </span>
48+ <span className={"node-icon icon-" + node.icon}>{ICONS[node.icon]}</span>
49+ <span className="node-label">{node.label}</span>
50+ </div>
51+ {hasChildren &&
52+ isOpen &&
53+ node.children.map(child => (
54+ <NodeRow
55+ key={child.id}
56+ node={child}
57+ depth={depth + 1}
58+ expanded={expanded}
59+ toggle={toggle}
60+ selectedId={selectedId}
61+ onSelect={onSelect}
62+ />
63+ ))}
64+ </>
65+ );
66+}
67+
68+export function ObjectTree({
69+ root,
70+ selectedId,
71+ onSelect,
72+}: {
73+ root: TreeNode;
74+ selectedId: string | null;
75+ onSelect: (id: string) => void;
76+}) {
77+ // Expand the figure and its axes by default.
78+ const [expanded, setExpanded] = useState<Set<string>>(() => {
79+ const s = new Set<string>([root.id]);
80+ for (const axes of root.children) s.add(axes.id);
81+ return s;
82+ });
83+ const toggle = (id: string) =>
84+ setExpanded(prev => {
85+ const next = new Set(prev);
86+ if (next.has(id)) next.delete(id);
87+ else next.add(id);
88+ return next;
89+ });
90+
91+ return (
92+ <div className="tree" role="tree">
93+ <NodeRow
94+ node={root}
95+ depth={0}
96+ expanded={expanded}
97+ toggle={toggle}
98+ selectedId={selectedId}
99+ onSelect={onSelect}
100+ />
101+ </div>
102+ );
103+}
src/index.cssmodified+393−57View file
@@ -1,98 +1,434 @@
11 :root {
2- font-family: system-ui, -apple-system, sans-serif;
3- color: #1a1a1a;
4- background: #fafafa;
2+ --bg: #ffffff;
3+ --chrome: #f3f3f3;
4+ --panel: #f8f8f8;
5+ --border: #e2e2e2;
6+ --text: #1f1f1f;
7+ --muted: #6b6b6b;
8+ --accent: #0a66c2;
9+ --hover: #ececec;
10+ --selected: #d6e8fb;
11+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
12+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
13+ color: var(--text);
514 }
615
716 * {
817 box-sizing: border-box;
918 }
1019
11-body {
20+html,
21+body,
22+#root {
23+ height: 100%;
1224 margin: 0;
1325 }
1426
15-.app {
16- max-width: 960px;
17- margin: 0 auto;
18- padding: 24px 16px 48px;
27+#root {
28+ height: 100vh;
1929 }
2030
21-header h1 {
22- margin: 0 0 4px;
23- font-size: 1.5rem;
31+.app {
32+ display: flex;
33+ flex-direction: column;
34+ height: 100%;
35+ overflow: hidden;
36+ background: var(--bg);
2437 }
2538
26-.sub {
27- margin: 0 0 20px;
28- color: #555;
29- font-size: 0.9rem;
39+/* ── Title bar ─────────────────────────────────────────────────────────── */
40+.titlebar {
41+ display: flex;
42+ align-items: center;
43+ gap: 8px;
44+ height: 40px;
45+ flex-shrink: 0;
46+ padding: 0 8px;
47+ background: var(--chrome);
48+ border-bottom: 1px solid var(--border);
49+ font-size: 13px;
3050 }
3151
32-.dropzone {
33- border: 2px dashed #bbb;
34- border-radius: 8px;
35- padding: 36px 16px;
36- text-align: center;
37- color: #666;
38- cursor: pointer;
39- background: #fff;
40- transition:
41- border-color 0.15s,
42- background 0.15s;
52+.app-title {
53+ font-weight: 600;
54+ white-space: nowrap;
55+ flex-shrink: 0;
4356 }
4457
45-.dropzone:hover,
46-.dropzone.over {
47- border-color: #4a90d9;
48- background: #f0f7ff;
58+.file-name {
59+ color: var(--muted);
60+ font-size: 12px;
61+ overflow: hidden;
62+ text-overflow: ellipsis;
63+ white-space: nowrap;
64+ max-width: 40vw;
4965 }
5066
51-.actions {
52- margin-top: 12px;
67+.spacer {
68+ flex: 1;
5369 }
5470
55-.actions button {
71+.icon-btn,
72+.text-btn {
5673 font: inherit;
57- font-size: 0.85rem;
58- padding: 6px 14px;
59- border: 1px solid #c8c8c8;
60- border-radius: 6px;
61- background: #fff;
74+ border: 1px solid transparent;
75+ background: transparent;
76+ color: var(--text);
6277 cursor: pointer;
78+ border-radius: 5px;
79+ padding: 4px 8px;
80+ line-height: 1;
6381 }
6482
65-.actions button:hover:not(:disabled) {
66- border-color: #4a90d9;
67- color: #1a5fa8;
83+.icon-btn {
84+ font-size: 16px;
85+ width: 30px;
86+ height: 28px;
87+ display: inline-flex;
88+ align-items: center;
89+ justify-content: center;
6890 }
6991
70-.actions button:disabled {
92+.icon-btn:hover,
93+.text-btn:hover:not(:disabled) {
94+ background: var(--hover);
95+}
96+
97+.text-btn {
98+ border-color: var(--border);
99+ font-size: 12px;
100+ background: #fff;
101+}
102+
103+.text-btn:disabled {
71104 opacity: 0.5;
72105 cursor: default;
73106 }
74107
75-.error {
76- margin-top: 16px;
77- padding: 10px 14px;
78- border-radius: 6px;
108+.error-bar {
109+ flex-shrink: 0;
110+ padding: 8px 12px;
79111 background: #fdecea;
80- color: #a12;
81- font-size: 0.9rem;
112+ color: #a4231f;
113+ font-size: 12.5px;
114+ border-bottom: 1px solid #f3c2bf;
115+}
116+
117+/* ── Empty state ───────────────────────────────────────────────────────── */
118+.empty-state {
119+ flex: 1;
120+ display: flex;
121+ align-items: center;
122+ justify-content: center;
123+ cursor: pointer;
124+ padding: 24px;
125+}
126+
127+.empty-inner {
128+ border: 2px dashed #c4c4c4;
129+ border-radius: 12px;
130+ padding: 48px 56px;
131+ text-align: center;
132+ color: var(--muted);
133+ max-width: 460px;
134+}
135+
136+.empty-state.over .empty-inner {
137+ border-color: var(--accent);
138+ background: #f0f7ff;
139+}
140+
141+.empty-icon {
142+ font-size: 40px;
143+ color: #b9b9b9;
144+}
145+
146+.empty-inner h2 {
147+ margin: 12px 0 6px;
148+ color: var(--text);
149+ font-size: 18px;
150+}
151+
152+.empty-inner p {
153+ margin: 4px 0;
154+ font-size: 13px;
82155 }
83156
84-.figure-wrap {
85- margin-top: 20px;
157+code {
158+ font-family: var(--mono);
159+ background: #efefef;
160+ padding: 1px 5px;
161+ border-radius: 4px;
162+ font-size: 0.92em;
163+}
164+
165+.muted {
166+ color: var(--muted);
167+}
168+.small {
169+ font-size: 12px;
170+}
171+
172+/* ── Body / panels ─────────────────────────────────────────────────────── */
173+.body {
174+ flex: 1;
175+ display: flex;
176+ min-height: 0;
177+ position: relative;
178+}
179+
180+.panel {
181+ display: flex;
182+ flex-direction: column;
183+ background: var(--panel);
184+ min-width: 0;
185+ overflow: hidden;
186+}
187+
188+.panel.right {
189+ background: #fbfbfb;
190+}
191+
192+.panel-head {
193+ display: flex;
194+ align-items: center;
195+ justify-content: space-between;
196+ flex-shrink: 0;
197+ height: 30px;
198+ padding: 0 10px;
199+ font-size: 11px;
200+ font-weight: 600;
201+ letter-spacing: 0.05em;
202+ text-transform: uppercase;
203+ color: var(--muted);
204+ border-bottom: 1px solid var(--border);
205+}
206+
207+.panel-body {
208+ flex: 1;
209+ overflow: auto;
210+}
211+
212+.center {
213+ flex: 1;
214+ position: relative;
215+ min-width: 0;
86216 background: #fff;
87- border: 1px solid #e2e2e2;
88- border-radius: 8px;
89- padding: 8px;
90217 }
91218
92-/* FigureView fills its positioned parent, mirroring the numbl plot viewer. */
93-.figure-inner {
219+.figure-host {
220+ position: absolute;
221+ inset: 0;
222+}
223+
224+.drop-hint {
225+ position: absolute;
226+ inset: 0;
227+ display: flex;
228+ align-items: center;
229+ justify-content: center;
230+ background: rgba(10, 102, 194, 0.08);
231+ border: 2px dashed var(--accent);
232+ color: var(--accent);
233+ font-weight: 600;
234+ pointer-events: none;
235+}
236+
237+/* ── Divider ───────────────────────────────────────────────────────────── */
238+.divider {
239+ width: 6px;
240+ flex-shrink: 0;
241+ cursor: col-resize;
242+ background: transparent;
94243 position: relative;
244+}
245+
246+.divider::after {
247+ content: "";
248+ position: absolute;
249+ inset: 0 2px;
250+ background: var(--border);
251+}
252+
253+.divider:hover::after {
254+ background: var(--accent);
255+}
256+
257+/* ── Tree ──────────────────────────────────────────────────────────────── */
258+.tree {
259+ padding: 4px 0;
260+ font-size: 13px;
261+ user-select: none;
262+}
263+
264+.tree-row {
265+ display: flex;
266+ align-items: center;
267+ height: 24px;
268+ padding-right: 8px;
269+ cursor: pointer;
270+ white-space: nowrap;
271+}
272+
273+.tree-row:hover {
274+ background: var(--hover);
275+}
276+
277+.tree-row.selected {
278+ background: var(--selected);
279+}
280+
281+.twisty {
282+ width: 16px;
283+ flex-shrink: 0;
284+ text-align: center;
285+ color: var(--muted);
286+ font-size: 10px;
287+}
288+
289+.twisty.leaf {
290+ visibility: hidden;
291+}
292+
293+.node-icon {
294+ width: 18px;
295+ flex-shrink: 0;
296+ text-align: center;
297+ color: var(--muted);
298+}
299+
300+.icon-figure {
301+ color: #7a5cc4;
302+}
303+.icon-axes {
304+ color: #2f8f5b;
305+}
306+.icon-trace {
307+ color: #c2731f;
308+}
309+.icon-dataset {
310+ color: #8a8a8a;
311+}
312+
313+.node-label {
314+ overflow: hidden;
315+ text-overflow: ellipsis;
316+}
317+
318+/* ── Info panel ────────────────────────────────────────────────────────── */
319+.info {
320+ padding: 10px 12px;
321+ font-size: 13px;
322+}
323+
324+.info.empty {
325+ color: var(--muted);
326+}
327+
328+.info-header {
329+ display: flex;
330+ align-items: center;
331+ gap: 6px;
332+ margin-bottom: 10px;
333+}
334+
335+.info-title {
336+ font-weight: 600;
337+ word-break: break-word;
338+}
339+
340+.info-kind {
341+ margin-left: auto;
342+ font-size: 11px;
343+ color: var(--muted);
344+ background: #ededed;
345+ padding: 1px 7px;
346+ border-radius: 10px;
347+}
348+
349+table.props {
95350 width: 100%;
96- height: 70vh;
97- min-height: 420px;
351+ border-collapse: collapse;
352+}
353+
354+table.props td {
355+ padding: 3px 6px;
356+ vertical-align: top;
357+ border-bottom: 1px solid #efefef;
358+}
359+
360+td.pk {
361+ color: var(--muted);
362+ width: 38%;
363+ font-size: 12px;
364+}
365+
366+td.pv {
367+ font-family: var(--mono);
368+ font-size: 12px;
369+ word-break: break-word;
370+}
371+
372+.swatch {
373+ display: inline-block;
374+ width: 11px;
375+ height: 11px;
376+ border-radius: 2px;
377+ border: 1px solid #00000022;
378+ margin-right: 6px;
379+ vertical-align: middle;
380+}
381+
382+.preview-label {
383+ margin: 12px 0 4px;
384+ font-size: 11px;
385+ text-transform: uppercase;
386+ letter-spacing: 0.05em;
387+ color: var(--muted);
388+}
389+
390+.preview {
391+ margin: 0;
392+ font-family: var(--mono);
393+ font-size: 11.5px;
394+ background: #f4f4f4;
395+ border: 1px solid var(--border);
396+ border-radius: 6px;
397+ padding: 8px;
398+ white-space: pre-wrap;
399+ word-break: break-word;
400+ max-height: 40vh;
401+ overflow: auto;
402+}
403+
404+/* ── Mobile drawers ────────────────────────────────────────────────────── */
405+.panel.drawer {
406+ position: absolute;
407+ top: 0;
408+ bottom: 0;
409+ width: min(82%, 340px);
410+ z-index: 20;
411+ box-shadow: 0 0 24px rgba(0, 0, 0, 0.22);
412+}
413+
414+.panel.left.drawer {
415+ left: 0;
416+}
417+
418+.panel.right.drawer {
419+ right: 0;
420+}
421+
422+.backdrop {
423+ position: absolute;
424+ inset: 0;
425+ background: rgba(0, 0, 0, 0.28);
426+ z-index: 10;
427+}
428+
429+/* Narrow screens: drop the redundant filename (shown in the tree root). */
430+@media (max-width: 560px) {
431+ .file-name {
432+ display: none;
433+ }
98434 }
src/tree.tsadded+314−0View file
@@ -0,0 +1,314 @@
1+import type { FigureState, AxesState } from "numbl/graphics";
2+
3+// ── Node / detail types ────────────────────────────────────────────────────
4+
5+export type PropEntry = {
6+ key: string;
7+ /** Display string. */
8+ value: string;
9+ /** If the property is an RGB triple in [0,1], the CSS color for a swatch. */
10+ swatch?: string;
11+};
12+
13+export type ObjectDetail = {
14+ type: "object";
15+ kind: string;
16+ properties: PropEntry[];
17+};
18+
19+export type DatasetDetail = {
20+ type: "dataset";
21+ name: string;
22+ shapeLabel: string;
23+ count: number;
24+ stats?: { min: number; max: number; mean: number; nan: number };
25+ preview: string;
26+};
27+
28+export type NodeIcon = "figure" | "axes" | "trace" | "dataset";
29+
30+export type TreeNode = {
31+ id: string;
32+ label: string;
33+ icon: NodeIcon;
34+ detail: ObjectDetail | DatasetDetail;
35+ children: TreeNode[];
36+};
37+
38+export type Tree = { root: TreeNode; byId: Map<string, TreeNode> };
39+
40+// ── Field classification ────────────────────────────────────────────────────
41+
42+const COLOR_FIELDS = new Set([
43+ "color",
44+ "edgeColor",
45+ "faceColor",
46+ "markerEdgeColor",
47+ "markerFaceColor",
48+ "lineColor",
49+]);
50+
51+// Trace categories in the same order as the HDF5 writer, so tree trace indices
52+// line up with the file's trace indices. `single` marks one-per-axes traces.
53+const TRACE_CATEGORIES: [keyof AxesState, string, boolean][] = [
54+ ["traces", "plot", false],
55+ ["plot3Traces", "plot3", false],
56+ ["areaTraces", "area", false],
57+ ["patchTraces", "patch", false],
58+ ["surfTraces", "surf", false],
59+ ["imagescTrace", "imagesc", true],
60+ ["pcolorTraces", "pcolor", false],
61+ ["contourTraces", "contour", false],
62+ ["barTraces", "bar", false],
63+ ["barhTraces", "barh", false],
64+ ["bar3Traces", "bar3", false],
65+ ["bar3hTraces", "bar3h", false],
66+ ["errorBarTraces", "errorbar", false],
67+ ["boxTraces", "boxchart", false],
68+ ["pieTrace", "piechart", true],
69+ ["heatmapTrace", "heatmap", true],
70+ ["quiverTraces", "quiver", false],
71+ ["quiver3Traces", "quiver3", false],
72+];
73+
74+// ── Formatting helpers ──────────────────────────────────────────────────────
75+
76+function fmtNum(x: number): string {
77+ if (Number.isNaN(x)) return "NaN";
78+ if (!Number.isFinite(x)) return x > 0 ? "Inf" : "-Inf";
79+ if (Number.isInteger(x)) return String(x);
80+ return Number(x.toPrecision(6)).toString();
81+}
82+
83+function flattenNumbers(v: unknown, out: number[] = []): number[] {
84+ if (typeof v === "number") out.push(v);
85+ else if (Array.isArray(v)) for (const e of v) flattenNumbers(e, out);
86+ return out;
87+}
88+
89+function rgbSwatch(v: number[]): string | undefined {
90+ if (v.length !== 3 || v.some(c => typeof c !== "number")) return undefined;
91+ const c = v.map(x => Math.round(Math.max(0, Math.min(1, x)) * 255));
92+ return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
93+}
94+
95+function formatProp(key: string, value: unknown): PropEntry {
96+ if (typeof value === "boolean")
97+ return { key, value: value ? "true" : "false" };
98+ if (typeof value === "number") return { key, value: fmtNum(value) };
99+ if (typeof value === "string") return { key, value };
100+ if (Array.isArray(value)) {
101+ if (COLOR_FIELDS.has(key) && value.every(e => typeof e === "number")) {
102+ const swatch = rgbSwatch(value as number[]);
103+ return {
104+ key,
105+ value: `[${(value as number[]).map(fmtNum).join(", ")}]`,
106+ swatch,
107+ };
108+ }
109+ if (value.every(e => typeof e === "string"))
110+ return { key, value: (value as string[]).join(", ") };
111+ if (value.every(e => e === null || typeof e === "number"))
112+ return {
113+ key,
114+ value: `[${value.map(e => (e === null ? "auto" : fmtNum(e as number))).join(", ")}]`,
115+ };
116+ }
117+ if (value && typeof value === "object")
118+ return { key, value: JSON.stringify(value) };
119+ return { key, value: String(value) };
120+}
121+
122+function makeDataset(
123+ name: string,
124+ raw: unknown,
125+ rows?: number,
126+ cols?: number
127+): DatasetDetail {
128+ const flat = flattenNumbers(raw);
129+ let shapeLabel: string;
130+ if (Array.isArray(raw) && raw.every(e => Array.isArray(e))) {
131+ const inner = (raw as unknown[][]).map(r => r.length);
132+ const uniform = inner.every(n => n === inner[0]);
133+ shapeLabel = uniform
134+ ? `${raw.length} × ${inner[0] ?? 0}`
135+ : `${raw.length} × (ragged)`;
136+ } else if (
137+ rows &&
138+ cols &&
139+ rows > 1 &&
140+ cols > 1 &&
141+ flat.length === rows * cols
142+ ) {
143+ shapeLabel = `${rows} × ${cols}`;
144+ } else {
145+ shapeLabel = String(flat.length);
146+ }
147+
148+ let stats: DatasetDetail["stats"];
149+ if (flat.length > 0) {
150+ let min = Infinity,
151+ max = -Infinity,
152+ sum = 0,
153+ nan = 0,
154+ n = 0;
155+ for (const x of flat) {
156+ if (Number.isNaN(x)) {
157+ nan++;
158+ continue;
159+ }
160+ if (x < min) min = x;
161+ if (x > max) max = x;
162+ sum += x;
163+ n++;
164+ }
165+ stats = n
166+ ? { min, max, mean: sum / n, nan }
167+ : { min: NaN, max: NaN, mean: NaN, nan };
168+ }
169+
170+ const previewVals = flat.slice(0, 16).map(fmtNum).join(", ");
171+ const preview =
172+ flat.length > 16 ? `${previewVals}, … (${flat.length} total)` : previewVals;
173+
174+ return { type: "dataset", name, shapeLabel, count: flat.length, stats, preview };
175+}
176+
177+// ── Trace flattening ────────────────────────────────────────────────────────
178+
179+function flattenTraces(ax: AxesState): { kind: string; trace: Record<string, unknown> }[] {
180+ const out: { kind: string; trace: Record<string, unknown> }[] = [];
181+ for (const [field, kind, single] of TRACE_CATEGORIES) {
182+ const v = ax[field];
183+ if (single) {
184+ if (v) out.push({ kind, trace: v as unknown as Record<string, unknown> });
185+ } else if (Array.isArray(v)) {
186+ for (const t of v)
187+ out.push({ kind, trace: t as unknown as Record<string, unknown> });
188+ }
189+ }
190+ return out;
191+}
192+
193+function buildTraceNode(
194+ id: string,
195+ index: number,
196+ kind: string,
197+ trace: Record<string, unknown>
198+): TreeNode {
199+ const rows = typeof trace.rows === "number" ? trace.rows : undefined;
200+ const cols = typeof trace.cols === "number" ? trace.cols : undefined;
201+ const properties: PropEntry[] = [{ key: "kind", value: kind }];
202+ const datasets: TreeNode[] = [];
203+
204+ for (const [key, val] of Object.entries(trace)) {
205+ if (val === null || val === undefined || key === "id") continue;
206+ const isNumArray = Array.isArray(val) && val.every(e => typeof e === "number");
207+ const isNestedArray = Array.isArray(val) && val.length > 0 && val.some(e => Array.isArray(e));
208+ const isDataArray = (isNumArray || isNestedArray) && !COLOR_FIELDS.has(key);
209+ if (isDataArray) {
210+ datasets.push({
211+ id: `${id}/${key}`,
212+ label: key,
213+ icon: "dataset",
214+ detail: makeDataset(key, val, rows, cols),
215+ children: [],
216+ });
217+ } else {
218+ properties.push(formatProp(key, val));
219+ }
220+ }
221+
222+ const label = `${kind} #${index}`;
223+ return {
224+ id,
225+ label,
226+ icon: "trace",
227+ detail: { type: "object", kind, properties },
228+ children: datasets,
229+ };
230+}
231+
232+// ── Axes / figure ──────────────────────────────────────────────────────────
233+
234+function axesProperties(ax: AxesState): PropEntry[] {
235+ const props: PropEntry[] = [];
236+ const add = (key: string, v: unknown) => {
237+ if (v !== undefined && v !== null) props.push(formatProp(key, v));
238+ };
239+ add("title", ax.title);
240+ add("xlabel", ax.xlabel);
241+ add("ylabel", ax.ylabel);
242+ add("zlabel", ax.zlabel);
243+ add("legend", ax.legend);
244+ add("xlim", ax.xlim);
245+ add("ylim", ax.ylim);
246+ add("zlim", ax.zlim);
247+ add("colormap", ax.colormap);
248+ add("caxis", ax.caxis);
249+ add("axisScale", ax.axisScale);
250+ add("gridOn", ax.gridOn);
251+ add("boxOn", ax.boxOn);
252+ add("holdOn", ax.holdOn);
253+ add("colorbar", ax.colorbar);
254+ add("shading", ax.shading);
255+ if (ax.view) add("view", [ax.view.az, ax.view.el]);
256+ return props;
257+}
258+
259+export function buildTree(figure: FigureState, fileName: string): Tree {
260+ const byId = new Map<string, TreeNode>();
261+ const register = (n: TreeNode) => {
262+ byId.set(n.id, n);
263+ return n;
264+ };
265+
266+ const figProps: PropEntry[] = [];
267+ if (figure.sgtitle) figProps.push({ key: "sgtitle", value: figure.sgtitle });
268+ if (figure.subplotGrid)
269+ figProps.push({
270+ key: "subplots",
271+ value: `${figure.subplotGrid.rows} × ${figure.subplotGrid.cols}`,
272+ });
273+ const axesIndices = Object.keys(figure.axes)
274+ .map(Number)
275+ .sort((a, b) => a - b);
276+ figProps.push({ key: "axes", value: String(axesIndices.length) });
277+
278+ const axesNodes: TreeNode[] = [];
279+ if (figure.uihtml) {
280+ figProps.push({ key: "type", value: "HTML component (uihtml)" });
281+ } else {
282+ for (const idx of axesIndices) {
283+ const ax = figure.axes[idx];
284+ const axId = `axes/${idx}`;
285+ const traces = flattenTraces(ax);
286+ const traceNodes = traces.map((t, k) =>
287+ register(buildTraceNode(`${axId}/trace/${k}`, k, t.kind, t.trace))
288+ );
289+ // also surface registered dataset children
290+ for (const tn of traceNodes) for (const d of tn.children) byId.set(d.id, d);
291+
292+ const axTitle = ax.title ? `Axes ${idx} — ${ax.title}` : `Axes ${idx}`;
293+ axesNodes.push(
294+ register({
295+ id: axId,
296+ label: axTitle,
297+ icon: "axes",
298+ detail: { type: "object", kind: "Axes", properties: axesProperties(ax) },
299+ children: traceNodes,
300+ })
301+ );
302+ }
303+ }
304+
305+ const root = register({
306+ id: "figure",
307+ label: fileName,
308+ icon: "figure",
309+ detail: { type: "object", kind: "Figure", properties: figProps },
310+ children: axesNodes,
311+ });
312+
313+ return { root, byId };
314+}
src/useMediaQuery.tsadded+16−0View file
@@ -0,0 +1,16 @@
1+import { useEffect, useState } from "react";
2+
3+/** Subscribe to a CSS media query; re-renders when it changes. */
4+export function useMediaQuery(query: string): boolean {
5+ const [matches, setMatches] = useState(() =>
6+ typeof window !== "undefined" ? window.matchMedia(query).matches : false
7+ );
8+ useEffect(() => {
9+ const mql = window.matchMedia(query);
10+ const onChange = () => setMatches(mql.matches);
11+ onChange();
12+ mql.addEventListener("change", onChange);
13+ return () => mql.removeEventListener("change", onChange);
14+ }, [query]);
15+ return matches;
16+}