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 /** The raw field value (number[] | number[][] | (number|number[])[]) for
26 * full data display in the details panel. */
27 data: unknown;
28};
30export type NodeIcon = "figure" | "axes" | "trace" | "dataset";
32export type TreeNode = {
33 id: string;
34 label: string;
35 icon: NodeIcon;
36 detail: ObjectDetail | DatasetDetail;
37 children: TreeNode[];
38};
40export type Tree = { root: TreeNode; byId: Map<string, TreeNode> };
42// ── Field classification ────────────────────────────────────────────────────
44const COLOR_FIELDS = new Set([
45 "color",
46 "edgeColor",
47 "faceColor",
48 "markerEdgeColor",
49 "markerFaceColor",
50 "lineColor",
51]);
53// Trace categories in the same order as the HDF5 writer, so tree trace indices
54// line up with the file's trace indices. `single` marks one-per-axes traces.
55// Exported so the view-state transform can re-derive the same trace ordering.
56export const TRACE_CATEGORIES: [keyof AxesState, string, boolean][] = [
57 ["traces", "plot", false],
58 ["plot3Traces", "plot3", false],
59 ["areaTraces", "area", false],
60 ["patchTraces", "patch", false],
61 ["surfTraces", "surf", false],
62 ["imagescTrace", "imagesc", true],
63 ["pcolorTraces", "pcolor", false],
64 ["contourTraces", "contour", false],
65 ["barTraces", "bar", false],
66 ["barhTraces", "barh", false],
67 ["bar3Traces", "bar3", false],
68 ["bar3hTraces", "bar3h", false],
69 ["errorBarTraces", "errorbar", false],
70 ["boxTraces", "boxchart", false],
71 ["pieTrace", "piechart", true],
72 ["heatmapTrace", "heatmap", true],
73 ["quiverTraces", "quiver", false],
74 ["quiver3Traces", "quiver3", false],
75];
77// ── Formatting helpers ──────────────────────────────────────────────────────
79function fmtNum(x: number): string {
80 if (Number.isNaN(x)) return "NaN";
81 if (!Number.isFinite(x)) return x > 0 ? "Inf" : "-Inf";
82 if (Number.isInteger(x)) return String(x);
83 return Number(x.toPrecision(6)).toString();
84}
86function flattenNumbers(v: unknown, out: number[] = []): number[] {
87 if (typeof v === "number") out.push(v);
88 else if (Array.isArray(v)) for (const e of v) flattenNumbers(e, out);
89 return out;
90}
92function rgbSwatch(v: number[]): string | undefined {
93 if (v.length !== 3 || v.some(c => typeof c !== "number")) return undefined;
94 const c = v.map(x => Math.round(Math.max(0, Math.min(1, x)) * 255));
95 return `rgb(${c[0]}, ${c[1]}, ${c[2]})`;
96}
98function formatProp(key: string, value: unknown): PropEntry {
99 if (typeof value === "boolean")
100 return { key, value: value ? "true" : "false" };
101 if (typeof value === "number") return { key, value: fmtNum(value) };
102 if (typeof value === "string") return { key, value };
103 if (Array.isArray(value)) {
104 if (COLOR_FIELDS.has(key) && value.every(e => typeof e === "number")) {
105 const swatch = rgbSwatch(value as number[]);
106 return {
107 key,
108 value: `[${(value as number[]).map(fmtNum).join(", ")}]`,
109 swatch,
110 };
111 }
112 if (value.every(e => typeof e === "string"))
113 return { key, value: (value as string[]).join(", ") };
114 if (value.every(e => e === null || typeof e === "number"))
115 return {
116 key,
117 value: `[${value.map(e => (e === null ? "auto" : fmtNum(e as number))).join(", ")}]`,
118 };
119 }
120 if (value && typeof value === "object")
121 return { key, value: JSON.stringify(value) };
122 return { key, value: String(value) };
123}
125function makeDataset(
126 name: string,
127 raw: unknown,
128 rows?: number,
129 cols?: number
130): DatasetDetail {
131 const flat = flattenNumbers(raw);
132 let shapeLabel: string;
133 if (Array.isArray(raw) && raw.every(e => Array.isArray(e))) {
134 const inner = (raw as unknown[][]).map(r => r.length);
135 const uniform = inner.every(n => n === inner[0]);
136 shapeLabel = uniform
137 ? `${raw.length} × ${inner[0] ?? 0}`
138 : `${raw.length} × (ragged)`;
139 } else if (
140 rows &&
141 cols &&
142 rows > 1 &&
143 cols > 1 &&
144 flat.length === rows * cols
145 ) {
146 shapeLabel = `${rows} × ${cols}`;
147 } else {
148 shapeLabel = String(flat.length);
149 }
151 let stats: DatasetDetail["stats"];
152 if (flat.length > 0) {
153 let min = Infinity,
154 max = -Infinity,
155 sum = 0,
156 nan = 0,
157 n = 0;
158 for (const x of flat) {
159 if (Number.isNaN(x)) {
160 nan++;
161 continue;
162 }
163 if (x < min) min = x;
164 if (x > max) max = x;
165 sum += x;
166 n++;
167 }
168 stats = n
169 ? { min, max, mean: sum / n, nan }
170 : { min: NaN, max: NaN, mean: NaN, nan };
171 }
173 return { type: "dataset", name, shapeLabel, count: flat.length, stats, data: raw };
174}
176// ── Trace flattening ────────────────────────────────────────────────────────
178function flattenTraces(ax: AxesState): { kind: string; trace: Record<string, unknown> }[] {
179 const out: { kind: string; trace: Record<string, unknown> }[] = [];
180 for (const [field, kind, single] of TRACE_CATEGORIES) {
181 const v = ax[field];
182 if (single) {
183 if (v) out.push({ kind, trace: v as unknown as Record<string, unknown> });
184 } else if (Array.isArray(v)) {
185 for (const t of v)
186 out.push({ kind, trace: t as unknown as Record<string, unknown> });
187 }
188 }
189 return out;
190}
192function buildTraceNode(
193 id: string,
194 index: number,
195 kind: string,
196 trace: Record<string, unknown>
197): TreeNode {
198 const rows = typeof trace.rows === "number" ? trace.rows : undefined;
199 const cols = typeof trace.cols === "number" ? trace.cols : undefined;
200 const properties: PropEntry[] = [{ key: "kind", value: kind }];
201 const datasets: TreeNode[] = [];
203 for (const [key, val] of Object.entries(trace)) {
204 if (val === null || val === undefined || key === "id") continue;
205 const isNumArray = Array.isArray(val) && val.every(e => typeof e === "number");
206 const isNestedArray = Array.isArray(val) && val.length > 0 && val.some(e => Array.isArray(e));
207 const isDataArray = (isNumArray || isNestedArray) && !COLOR_FIELDS.has(key);
208 if (isDataArray) {
209 datasets.push({
210 id: `${id}/${key}`,
211 label: key,
212 icon: "dataset",
213 detail: makeDataset(key, val, rows, cols),
214 children: [],
215 });
216 } else {
217 properties.push(formatProp(key, val));
218 }
219 }
221 const label = `${kind} #${index}`;
222 return {
223 id,
224 label,
225 icon: "trace",
226 detail: { type: "object", kind, properties },
227 children: datasets,
228 };
229}
231// ── Axes / figure ──────────────────────────────────────────────────────────
233function axesProperties(ax: AxesState): PropEntry[] {
234 const props: PropEntry[] = [];
235 const add = (key: string, v: unknown) => {
236 if (v !== undefined && v !== null) props.push(formatProp(key, v));
237 };
238 add("title", ax.title);
239 add("xlabel", ax.xlabel);
240 add("ylabel", ax.ylabel);
241 add("zlabel", ax.zlabel);
242 add("legend", ax.legend);
243 add("xlim", ax.xlim);
244 add("ylim", ax.ylim);
245 add("zlim", ax.zlim);
246 add("colormap", ax.colormap);
247 add("caxis", ax.caxis);
248 add("axisScale", ax.axisScale);
249 add("gridOn", ax.gridOn);
250 add("boxOn", ax.boxOn);
251 add("holdOn", ax.holdOn);
252 add("colorbar", ax.colorbar);
253 add("shading", ax.shading);
254 if (ax.view) add("view", [ax.view.az, ax.view.el]);
255 return props;
256}
258export function buildTree(figure: FigureState, fileName: string): Tree {
259 const byId = new Map<string, TreeNode>();
260 const register = (n: TreeNode) => {
261 byId.set(n.id, n);
262 return n;
263 };
265 const figProps: PropEntry[] = [];
266 if (figure.sgtitle) figProps.push({ key: "sgtitle", value: figure.sgtitle });
267 if (figure.subplotGrid)
268 figProps.push({
269 key: "subplots",
270 value: `${figure.subplotGrid.rows} × ${figure.subplotGrid.cols}`,
271 });
272 const axesIndices = Object.keys(figure.axes)
273 .map(Number)
274 .sort((a, b) => a - b);
275 figProps.push({ key: "axes", value: String(axesIndices.length) });
277 const axesNodes: TreeNode[] = [];
278 if (figure.uihtml) {
279 figProps.push({ key: "type", value: "HTML component (uihtml)" });
280 } else {
281 for (const idx of axesIndices) {
282 const ax = figure.axes[idx];
283 const axId = `axes/${idx}`;
284 const traces = flattenTraces(ax);
285 const traceNodes = traces.map((t, k) =>
286 register(buildTraceNode(`${axId}/trace/${k}`, k, t.kind, t.trace))
287 );
288 // also surface registered dataset children
289 for (const tn of traceNodes) for (const d of tn.children) byId.set(d.id, d);
291 const axTitle = ax.title ? `Axes ${idx} — ${ax.title}` : `Axes ${idx}`;
292 axesNodes.push(
293 register({
294 id: axId,
295 label: axTitle,
296 icon: "axes",
297 detail: { type: "object", kind: "Axes", properties: axesProperties(ax) },
298 children: traceNodes,
299 })
300 );
301 }
302 }
304 const root = register({
305 id: "figure",
306 label: fileName,
307 icon: "figure",
308 detail: { type: "object", kind: "Figure", properties: figProps },
309 children: axesNodes,
310 });
312 return { root, byId };
313}