Details data view + per-element visibility controls
Details panel now shows full data arrays (1-D values / 2-D rows) with
incremental reveal (Show more / Show all) and a 5000-element render cap.
Object tree gains per-element visibility: hide/show (eye) and show-only/
isolate (solo) on axes and trace rows, a 'Show all' reset, dimmed hidden
rows, and a 'nothing visible' overlay. The rendered figure is derived from
an extensible ViewState layer (applyViewState) so future per-element controls
(highlight, pin, style overrides) slot into the same pipeline.
6 changed files+434−20
src/App.tsxmodified+56−7View file
@@ -4,6 +4,15 @@ import { buildTree } from "./tree";
44 import { ObjectTree } from "./ObjectTree";
55 import { InfoPanel } from "./InfoPanel";
66 import { useMediaQuery } from "./useMediaQuery";
7+import {
8+ applyViewState,
9+ emptyViewState,
10+ figureHasContent,
11+ isolate,
12+ showAll,
13+ toggleHidden,
14+ type ViewState,
15+} from "./viewState";
716
817 const clamp = (x: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, x));
918
@@ -36,6 +45,7 @@ export function App() {
3645 const [error, setError] = useState<string | null>(null);
3746 const [loading, setLoading] = useState(false);
3847 const [selectedId, setSelectedId] = useState<string | null>(null);
48+ const [viewState, setViewState] = useState<ViewState>(emptyViewState);
3949 const [dragOver, setDragOver] = useState(false);
4050
4151 const isMobile = useMediaQuery("(max-width: 820px)");
@@ -58,6 +68,19 @@ export function App() {
5868 const selectedNode =
5969 tree && selectedId ? (tree.byId.get(selectedId) ?? null) : null;
6070
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);
77+
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);
83+
6184 const loadFile = useCallback(async (file: File) => {
6285 setLoading(true);
6386 setError(null);
@@ -67,6 +90,7 @@ export function App() {
6790 setFigure(fig);
6891 setFileName(file.name);
6992 setSelectedId("figure");
93+ setViewState(emptyViewState());
7094 } catch (e) {
7195 setError(e instanceof Error ? e.message : String(e));
7296 setFigure(null);
@@ -169,11 +193,21 @@ export function App() {
169193 >
170194 <div className="panel-head">
171195 <span>Objects</span>
172- {isMobile && (
173- <button className="icon-btn" onClick={() => setLeftOpen(false)}>
174- ✕
175- </button>
176- )}
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>
177211 </div>
178212 <div className="panel-body">
179213 {tree && (
@@ -181,6 +215,9 @@ export function App() {
181215 root={tree.root}
182216 selectedId={selectedId}
183217 onSelect={onSelect}
218+ viewState={viewState}
219+ onToggleHidden={onToggleHidden}
220+ onIsolate={onIsolate}
184221 />
185222 )}
186223 </div>
@@ -190,11 +227,23 @@ export function App() {
190227 <Divider onResize={dx => setLeftW(w => clamp(w + dx, 180, 520))} />
191228 )}
192229
193- {/* Center: figure */}
230+ {/* Center: figure (rendered from the view-state-derived figure) */}
194231 <main className="center" {...dropHandlers}>
195232 <div className="figure-host">
196- <FigureView figure={figure} />
233+ {viewFigure && !nothingVisible && (
234+ <FigureView figure={viewFigure} />
235+ )}
197236 </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+ )}
198247 {dragOver && <div className="drop-hint">Drop to open</div>}
199248 </main>
200249
src/InfoPanel.tsxmodified+65−4View file
@@ -1,3 +1,4 @@
1+import { useState } from "react";
12 import type { TreeNode } from "./tree";
23
34 function fmt(x: number): string {
@@ -6,6 +7,69 @@ function fmt(x: number): string {
67 return Number.isInteger(x) ? String(x) : Number(x.toPrecision(6)).toString();
78 }
89
10+// Hard cap on how many values/rows are ever rendered as text, to keep large
11+// arrays (e.g. big surf grids) from freezing the UI.
12+const MAX_RENDER = 5000;
13+const STEP = 1000;
14+
15+function DataValues({ data }: { data: unknown }) {
16+ const [limit, setLimit] = useState(200);
17+
18+ if (!Array.isArray(data) || data.length === 0)
19+ return <p className="muted small">No data.</p>;
20+
21+ const isNumber = (e: unknown) => typeof e === "number";
22+ const allNumbers = (data as unknown[]).every(isNumber);
23+
24+ const total = data.length;
25+ const renderCap = Math.min(total, MAX_RENDER);
26+ const shown = Math.min(limit, renderCap);
27+ const hasMore = shown < renderCap;
28+ const capped = total > MAX_RENDER && shown >= MAX_RENDER;
29+ const unit = allNumbers ? "values" : "rows";
30+
31+ let body: string;
32+ if (allNumbers) {
33+ body = (data as number[]).slice(0, shown).map(fmt).join(", ");
34+ } else {
35+ body = (data as unknown[])
36+ .slice(0, shown)
37+ .map((r, i) => {
38+ const text = Array.isArray(r)
39+ ? `[${(r as number[]).map(fmt).join(", ")}]`
40+ : fmt(r as number);
41+ return `${i}: ${text}`;
42+ })
43+ .join("\n");
44+ }
45+ if (shown < total) body += allNumbers ? ", …" : "\n…";
46+
47+ return (
48+ <>
49+ <pre className="preview">{body}</pre>
50+ <div className="vfooter">
51+ <span className="muted small">
52+ {shown < total ? `Showing ${shown} of ${total} ${unit}` : `${total} ${unit}`}
53+ {capped ? " — display capped" : ""}
54+ </span>
55+ {hasMore && (
56+ <button
57+ className="text-btn sm"
58+ onClick={() => setLimit(l => Math.min(l + STEP, renderCap))}
59+ >
60+ Show more
61+ </button>
62+ )}
63+ {!capped && shown < total && total <= MAX_RENDER && (
64+ <button className="text-btn sm" onClick={() => setLimit(total)}>
65+ Show all
66+ </button>
67+ )}
68+ </div>
69+ </>
70+ );
71+}
72+
973 export function InfoPanel({ node }: { node: TreeNode | null }) {
1074 if (!node) {
1175 return (
@@ -20,9 +84,6 @@ export function InfoPanel({ node }: { node: TreeNode | null }) {
2084 return (
2185 <div className="info">
2286 <div className="info-header">
23- <span className={"node-icon icon-" + node.icon}>
24- {detail.type === "dataset" ? "⋯" : ""}
25- </span>
2687 <span className="info-title">{node.label}</span>
2788 <span className="info-kind">
2889 {detail.type === "dataset" ? "dataset" : detail.kind}
@@ -86,7 +147,7 @@ export function InfoPanel({ node }: { node: TreeNode | null }) {
86147 </tbody>
87148 </table>
88149 <div className="preview-label">values</div>
89- <pre className="preview">{detail.preview}</pre>
150+ <DataValues key={node.id} data={detail.data} />
90151 </div>
91152 )}
92153 </div>
src/ObjectTree.tsxmodified+87−1View file
@@ -1,5 +1,10 @@
11 import { useState } from "react";
22 import type { TreeNode, NodeIcon } from "./tree";
3+import {
4+ type ViewState,
5+ isHidden,
6+ isEffectivelyHidden,
7+} from "./viewState";
38
49 const ICONS: Record<NodeIcon, string> = {
510 figure: "▦",
@@ -8,6 +13,32 @@ const ICONS: Record<NodeIcon, string> = {
813 dataset: "⋯",
914 };
1015
16+function EyeIcon({ open }: { open: boolean }) {
17+ return (
18+ <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
19+ <path
20+ d="M8 3.2C4.4 3.2 1.9 6 1.2 8c.7 2 3.2 4.8 6.8 4.8S14.1 10 14.8 8C14.1 6 11.6 3.2 8 3.2z"
21+ fill="none"
22+ stroke="currentColor"
23+ strokeWidth="1.2"
24+ />
25+ <circle cx="8" cy="8" r="2" fill="currentColor" />
26+ {!open && (
27+ <line x1="2.5" y1="13.5" x2="13.5" y2="2.5" stroke="currentColor" strokeWidth="1.2" />
28+ )}
29+ </svg>
30+ );
31+}
32+
33+function SoloIcon() {
34+ return (
35+ <svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">
36+ <circle cx="8" cy="8" r="5" fill="none" stroke="currentColor" strokeWidth="1.2" />
37+ <circle cx="8" cy="8" r="1.8" fill="currentColor" />
38+ </svg>
39+ );
40+}
41+
1142 function NodeRow({
1243 node,
1344 depth,
@@ -15,6 +46,9 @@ function NodeRow({
1546 toggle,
1647 selectedId,
1748 onSelect,
49+ viewState,
50+ onToggleHidden,
51+ onIsolate,
1852 }: {
1953 node: TreeNode;
2054 depth: number;
@@ -22,14 +56,30 @@ function NodeRow({
2256 toggle: (id: string) => void;
2357 selectedId: string | null;
2458 onSelect: (id: string) => void;
59+ viewState: ViewState;
60+ onToggleHidden: (id: string) => void;
61+ onIsolate: (id: string) => void;
2562 }) {
2663 const hasChildren = node.children.length > 0;
2764 const isOpen = expanded.has(node.id);
2865 const isSelected = selectedId === node.id;
66+ const hasControls = node.icon === "axes" || node.icon === "trace";
67+ const ownHidden = isHidden(viewState, node.id);
68+ const dimmed = isEffectivelyHidden(viewState, node.id);
69+
70+ const cls = [
71+ "tree-row",
72+ isSelected ? "selected" : "",
73+ dimmed ? "dimmed" : "",
74+ ownHidden ? "el-hidden" : "",
75+ ]
76+ .filter(Boolean)
77+ .join(" ");
78+
2979 return (
3080 <>
3181 <div
32- className={"tree-row" + (isSelected ? " selected" : "")}
82+ className={cls}
3383 style={{ paddingLeft: 4 + depth * 14 }}
3484 onClick={() => onSelect(node.id)}
3585 role="treeitem"
@@ -47,6 +97,30 @@ function NodeRow({
4797 </span>
4898 <span className={"node-icon icon-" + node.icon}>{ICONS[node.icon]}</span>
4999 <span className="node-label">{node.label}</span>
100+ {hasControls && (
101+ <span className="row-actions">
102+ <button
103+ className="act-btn"
104+ title="Show only this"
105+ onClick={e => {
106+ e.stopPropagation();
107+ onIsolate(node.id);
108+ }}
109+ >
110+ <SoloIcon />
111+ </button>
112+ <button
113+ className={"act-btn" + (ownHidden ? " active" : "")}
114+ title={ownHidden ? "Show" : "Hide"}
115+ onClick={e => {
116+ e.stopPropagation();
117+ onToggleHidden(node.id);
118+ }}
119+ >
120+ <EyeIcon open={!ownHidden} />
121+ </button>
122+ </span>
123+ )}
50124 </div>
51125 {hasChildren &&
52126 isOpen &&
@@ -59,6 +133,9 @@ function NodeRow({
59133 toggle={toggle}
60134 selectedId={selectedId}
61135 onSelect={onSelect}
136+ viewState={viewState}
137+ onToggleHidden={onToggleHidden}
138+ onIsolate={onIsolate}
62139 />
63140 ))}
64141 </>
@@ -69,10 +146,16 @@ export function ObjectTree({
69146 root,
70147 selectedId,
71148 onSelect,
149+ viewState,
150+ onToggleHidden,
151+ onIsolate,
72152 }: {
73153 root: TreeNode;
74154 selectedId: string | null;
75155 onSelect: (id: string) => void;
156+ viewState: ViewState;
157+ onToggleHidden: (id: string) => void;
158+ onIsolate: (id: string) => void;
76159 }) {
77160 // Expand the figure and its axes by default.
78161 const [expanded, setExpanded] = useState<Set<string>>(() => {
@@ -97,6 +180,9 @@ export function ObjectTree({
97180 toggle={toggle}
98181 selectedId={selectedId}
99182 onSelect={onSelect}
183+ viewState={viewState}
184+ onToggleHidden={onToggleHidden}
185+ onIsolate={onIsolate}
100186 />
101187 </div>
102188 );
src/index.cssmodified+102−1View file
@@ -311,10 +311,91 @@ code {
311311 }
312312
313313 .node-label {
314+ flex: 1;
314315 overflow: hidden;
315316 text-overflow: ellipsis;
316317 }
317318
319+.tree-row.dimmed .node-label,
320+.tree-row.dimmed .node-icon {
321+ opacity: 0.4;
322+}
323+
324+/* ── Row visibility controls ───────────────────────────────────────────── */
325+.row-actions {
326+ display: flex;
327+ align-items: center;
328+ gap: 1px;
329+ margin-left: 4px;
330+ flex-shrink: 0;
331+ visibility: hidden;
332+}
333+
334+.tree-row:hover .row-actions,
335+.tree-row.selected .row-actions,
336+.tree-row.el-hidden .row-actions {
337+ visibility: visible;
338+}
339+
340+.act-btn {
341+ display: inline-flex;
342+ align-items: center;
343+ justify-content: center;
344+ width: 22px;
345+ height: 20px;
346+ border: none;
347+ background: transparent;
348+ color: var(--muted);
349+ cursor: pointer;
350+ border-radius: 4px;
351+ padding: 0;
352+}
353+
354+.act-btn:hover {
355+ background: rgba(0, 0, 0, 0.08);
356+ color: var(--text);
357+}
358+
359+.act-btn.active {
360+ color: var(--accent);
361+}
362+
363+.head-actions {
364+ display: flex;
365+ align-items: center;
366+ gap: 6px;
367+}
368+
369+.link-btn {
370+ font: inherit;
371+ font-size: 11px;
372+ text-transform: none;
373+ letter-spacing: 0;
374+ border: none;
375+ background: transparent;
376+ color: var(--accent);
377+ cursor: pointer;
378+ padding: 0;
379+}
380+
381+.link-btn:hover {
382+ text-decoration: underline;
383+}
384+
385+.center-overlay {
386+ position: absolute;
387+ inset: 0;
388+ display: flex;
389+ align-items: center;
390+ justify-content: center;
391+ text-align: center;
392+ color: var(--muted);
393+}
394+
395+.center-overlay button {
396+ margin-top: 8px;
397+}
398+
318399 /* ── Info panel ────────────────────────────────────────────────────────── */
319400 .info {
320401 padding: 10px 12px;
@@ -397,10 +478,23 @@ td.pv {
397478 padding: 8px;
398479 white-space: pre-wrap;
399480 word-break: break-word;
400- max-height: 40vh;
481+ max-height: 48vh;
401482 overflow: auto;
402483 }
403484
485+.vfooter {
486+ display: flex;
487+ align-items: center;
488+ gap: 8px;
489+ flex-wrap: wrap;
490+ margin-top: 6px;
491+}
492+
493+.text-btn.sm {
494+ padding: 2px 8px;
495+ font-size: 11px;
496+}
497+
404498 /* ── Mobile drawers ────────────────────────────────────────────────────── */
405499 .panel.drawer {
406500 position: absolute;
@@ -426,6 +520,13 @@ td.pv {
426520 z-index: 10;
427521 }
428522
523+/* Touch / narrow screens: no hover, so always show the row controls. */
524+@media (max-width: 820px) {
525+ .row-actions {
526+ visibility: visible;
527+ }
528+}
529+
429530 /* Narrow screens: drop the redundant filename (shown in the tree root). */
430531 @media (max-width: 560px) {
431532 .file-name {
src/tree.tsmodified+6−7View file
@@ -22,7 +22,9 @@ export type DatasetDetail = {
2222 shapeLabel: string;
2323 count: number;
2424 stats?: { min: number; max: number; mean: number; nan: number };
25- preview: string;
25+ /** The raw field value (number[] | number[][] | (number|number[])[]) for
26+ * full data display in the details panel. */
27+ data: unknown;
2628 };
2729
2830 export type NodeIcon = "figure" | "axes" | "trace" | "dataset";
@@ -50,7 +52,8 @@ const COLOR_FIELDS = new Set([
5052
5153 // Trace categories in the same order as the HDF5 writer, so tree trace indices
5254 // line up with the file's trace indices. `single` marks one-per-axes traces.
53-const TRACE_CATEGORIES: [keyof AxesState, string, boolean][] = [
55+// Exported so the view-state transform can re-derive the same trace ordering.
56+export const TRACE_CATEGORIES: [keyof AxesState, string, boolean][] = [
5457 ["traces", "plot", false],
5558 ["plot3Traces", "plot3", false],
5659 ["areaTraces", "area", false],
@@ -167,11 +170,7 @@ function makeDataset(
167170 : { min: NaN, max: NaN, mean: NaN, nan };
168171 }
169172
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 };
173+ return { type: "dataset", name, shapeLabel, count: flat.length, stats, data: raw };
175174 }
176175
177176 // ── Trace flattening ────────────────────────────────────────────────────────
src/viewState.tsadded+118−0View file
@@ -0,0 +1,118 @@
1+import type { FigureState, AxesState } from "numbl/graphics";
2+import { TRACE_CATEGORIES, type Tree } from "./tree";
3+
4+/**
5+ * Per-element view state layered on top of the immutable parsed figure. The
6+ * rendered figure is *derived* from (figure + ViewState) via `applyViewState`,
7+ * so new kinds of per-element view controls (highlight, pin, style overrides,
8+ * …) can be added as additional fields + transform steps without touching the
9+ * parse/tree code.
10+ *
11+ * Today it carries visibility: which axes/traces are hidden. "Show only" is
12+ * expressed as "hide everything else", so showing a set, hiding one, and
13+ * isolating one all reduce to manipulating `hidden`.
14+ */
15+export interface ViewState {
16+ /** Ids of axes/trace elements explicitly hidden from the figure. */
17+ hidden: Set<string>;
18+ // Future: highlighted: Set<string>; pinned: Set<string>; overrides: Map<…>;
19+}
20+
21+export const emptyViewState = (): ViewState => ({ hidden: new Set() });
22+
23+export function toggleHidden(vs: ViewState, id: string): ViewState {
24+ const hidden = new Set(vs.hidden);
25+ if (hidden.has(id)) hidden.delete(id);
26+ else hidden.add(id);
27+ return { ...vs, hidden };
28+}
29+
30+export function showAll(vs: ViewState): ViewState {
31+ return { ...vs, hidden: new Set() };
32+}
33+
34+/** The element itself was hidden. */
35+export function isHidden(vs: ViewState, id: string): boolean {
36+ return vs.hidden.has(id);
37+}
38+
39+/** Hidden itself, or its containing axes is hidden (so it won't render). */
40+export function isEffectivelyHidden(vs: ViewState, id: string): boolean {
41+ if (vs.hidden.has(id)) return true;
42+ const m = id.match(/^(axes\/\d+)\/trace\//);
43+ return m ? vs.hidden.has(m[1]) : false;
44+}
45+
46+/** Show only `id`: hide every other axes/trace. A dataset isolates its parent
47+ * trace; the figure root shows everything. */
48+export function isolate(vs: ViewState, tree: Tree, id: string): ViewState {
49+ if (id === "figure") return showAll(vs);
50+ const node = tree.byId.get(id);
51+ if (!node) return vs;
52+
53+ const keep = new Set<string>();
54+ if (node.icon === "axes") {
55+ keep.add(id);
56+ for (const c of node.children) if (c.icon === "trace") keep.add(c.id);
57+ } else {
58+ // trace or dataset → resolve to the trace and its axes
59+ const traceId = node.icon === "dataset" ? id.replace(/\/[^/]+$/, "") : id;
60+ keep.add(traceId);
61+ keep.add(traceId.replace(/\/trace\/\d+$/, ""));
62+ }
63+
64+ const hidden = new Set<string>();
65+ for (const [nid, n] of tree.byId)
66+ if ((n.icon === "axes" || n.icon === "trace") && !keep.has(nid))
67+ hidden.add(nid);
68+ return { ...vs, hidden };
69+}
70+
71+/** Whether a figure has anything to draw (any trace, or a uihtml component). */
72+export function figureHasContent(figure: FigureState): boolean {
73+ if (figure.uihtml) return true;
74+ for (const ax of Object.values(figure.axes)) {
75+ for (const [field, , single] of TRACE_CATEGORIES) {
76+ const cur = (ax as Record<string, unknown>)[field as string];
77+ if (single ? !!cur : Array.isArray(cur) && cur.length > 0) return true;
78+ }
79+ }
80+ return false;
81+}
82+
83+/** Derive the figure to render from the parsed figure + view state. */
84+export function applyViewState(figure: FigureState, vs: ViewState): FigureState {
85+ if (figure.uihtml || vs.hidden.size === 0) return figure;
86+
87+ const outAxes: FigureState["axes"] = {};
88+ for (const [idxStr, ax] of Object.entries(figure.axes)) {
89+ const idx = Number(idxStr);
90+ const axId = `axes/${idx}`;
91+ if (vs.hidden.has(axId)) continue; // whole axes hidden
92+
93+ const next = { ...(ax as Record<string, unknown>) };
94+ let k = 0; // flattened trace index, matching the object tree
95+ for (const [field, , single] of TRACE_CATEGORIES) {
96+ const cur = (ax as Record<string, unknown>)[field as string];
97+ if (single) {
98+ if (cur) {
99+ const tid = `${axId}/trace/${k++}`;
100+ if (vs.hidden.has(tid)) next[field as string] = undefined;
101+ }
102+ } else if (Array.isArray(cur)) {
103+ const kept: unknown[] = [];
104+ for (const t of cur) {
105+ const tid = `${axId}/trace/${k++}`;
106+ if (!vs.hidden.has(tid)) kept.push(t);
107+ }
108+ next[field as string] = kept;
109+ }
110+ }
111+ outAxes[idx] = next as unknown as AxesState;
112+ }
113+
114+ // If isolating collapsed a subplot grid to a single axes, let it fill.
115+ let subplotGrid = figure.subplotGrid;
116+ if (subplotGrid && Object.keys(outAxes).length <= 1) subplotGrid = undefined;
117+ return { ...figure, axes: outAxes, subplotGrid };
118+}