/ concept-collection / numbl-figure-viewer
Sign in
concept-collection / numbl-figure-viewer
numbl-figure-viewer / src / tree.ts
314 lines · 9.7 KBCodeBlameHistory
6ab510bVS Code-style layout: object tree + figure + details panelJeremy Magland 1import type { FigureState, AxesState } from "numbl/graphics";
3// ── Node / detail types ────────────────────────────────────────────────────
5export 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};
13export type ObjectDetail = {
14 type: "object";
15 kind: string;
16 properties: PropEntry[];
17};
19export 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};
28export type NodeIcon = "figure" | "axes" | "trace" | "dataset";
30export type TreeNode = {
31 id: string;
32 label: string;
33 icon: NodeIcon;
34 detail: ObjectDetail | DatasetDetail;
35 children: TreeNode[];
36};
38export type Tree = { root: TreeNode; byId: Map<string, TreeNode> };
40// ── Field classification ────────────────────────────────────────────────────
42const COLOR_FIELDS = new Set([
43 "color",
44 "edgeColor",
45 "faceColor",
46 "markerEdgeColor",
47 "markerFaceColor",
48 "lineColor",
49]);
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.
53const 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];
74// ── Formatting helpers ──────────────────────────────────────────────────────
76function 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();
83function 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;
89function 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]})`;
95function 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) };
122function 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 }
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 }
170 const previewVals = flat.slice(0, 16).map(fmtNum).join(", ");
171 const preview =
172 flat.length > 16 ? `${previewVals}, … (${flat.length} total)` : previewVals;
174 return { type: "dataset", name, shapeLabel, count: flat.length, stats, preview };
177// ── Trace flattening ────────────────────────────────────────────────────────
179function 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;
193function 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[] = [];
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 }
222 const label = `${kind} #${index}`;
223 return {
224 id,
225 label,
226 icon: "trace",
227 detail: { type: "object", kind, properties },
228 children: datasets,
229 };
232// ── Axes / figure ──────────────────────────────────────────────────────────
234function 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;
259export 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 };
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) });
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);
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 }
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 });
313 return { root, byId };
moveopenescclose