/ concept-collection / numbl-web-ide
Sign in
concept-collection / numbl-web-ide
numbl-web-ide / src / numbl / figures.ts
129 lines · 3.9 KBCodeBlameHistory
ecdc868numbl web IDE: run .m files in the browser with numblJeremy Magland 1import type { Workbench, AuxiliaryView } from 'minwebide';
2import type { PlotInstruction } from 'numbl';
3import type { FiguresState } from 'numbl/graphics';
4import type { Root } from 'react-dom/client';
6// Renders a run's figures the way numbl.org does — one pane per MATLAB-style
7// figure handle, rendered by numbl's own <FigureView> — but docked as views
8// in the secondary side bar ("Figure 1", "Figure 2", ...), the minwebide
9// analog of numbl.org's figure tabs. The renderer stack (numbl/graphics +
10// react + three.js) is loaded on demand the first time a run plots anything,
11// so it stays out of the initial bundle.
13interface GraphicsStack {
14 FigureView: typeof import('numbl/graphics').FigureView;
15 figuresReducer: typeof import('numbl/graphics').figuresReducer;
16 createElement: typeof import('react').createElement;
17 createRoot: typeof import('react-dom/client').createRoot;
20interface MountedFigure {
21 view: AuxiliaryView;
22 root: Root;
25export class FigureManager {
26 private gfx: GraphicsStack | undefined;
27 private loading: Promise<void> | undefined;
28 private state: FiguresState | undefined;
29 private pending: PlotInstruction[] = [];
30 private readonly mounted = new Map<number, MountedFigure>();
31 private revealedThisRun = false;
33 constructor(private readonly workbench: Workbench) {}
35 /** Clears all figures — call at the start of each run. */
36 beginRun(): void {
37 this.pending = [];
38 if (this.gfx && this.state) {
39 this.state = this.gfx.figuresReducer(this.state, { type: 'clear' });
40 }
41 for (const [handle, figure] of this.mounted) {
42 figure.root.unmount();
43 figure.view.dispose();
44 this.mounted.delete(handle);
45 }
46 this.revealedThisRun = false;
47 }
49 /** Folds a batch of plot instructions into the figure panes. */
50 apply(instructions: PlotInstruction[]): void {
51 if (!this.gfx || !this.state) {
52 this.pending.push(...instructions);
53 void this.ensureLoaded();
54 return;
55 }
56 for (const instruction of instructions) {
57 this.state = this.gfx.figuresReducer(this.state, instruction);
58 }
59 this.sync();
60 }
62 private ensureLoaded(): Promise<void> {
63 if (!this.loading) {
64 this.loading = (async () => {
65 const [graphics, react, reactDom] = await Promise.all([
66 import('numbl/graphics'),
67 import('react'),
68 import('react-dom/client'),
69 ]);
70 this.gfx = {
71 FigureView: graphics.FigureView,
72 figuresReducer: graphics.figuresReducer,
73 createElement: react.createElement,
74 createRoot: reactDom.createRoot,
75 };
76 this.state = graphics.initialFiguresState;
77 const buffered = this.pending;
78 this.pending = [];
79 if (buffered.length > 0) {
80 this.apply(buffered);
81 }
82 })();
83 }
84 return this.loading;
85 }
87 private sync(): void {
88 const gfx = this.gfx!;
89 const state = this.state!;
90 const handles = Object.keys(state.figs).map(Number).sort((a, b) => a - b);
92 // figures closed by the script (close/close all)
93 for (const [handle, figure] of this.mounted) {
94 if (!state.figs[handle]) {
95 figure.root.unmount();
96 figure.view.dispose();
97 this.mounted.delete(handle);
98 }
99 }
101 for (const handle of handles) {
102 let figure = this.mounted.get(handle);
103 if (!figure) {
104 const view = this.workbench.createAuxiliaryView(`numbl.figure.${handle}`, `Figure ${handle}`);
105 view.element.style.overflow = 'hidden';
106 const host = document.createElement('div');
107 host.style.width = '100%';
108 host.style.height = '100%';
109 host.style.backgroundColor = '#fff';
110 view.element.appendChild(host);
111 figure = { view, root: gfx.createRoot(host) };
112 this.mounted.set(handle, figure);
113 if (!this.revealedThisRun) {
114 this.revealedThisRun = true;
115 view.show();
116 }
117 }
118 figure.root.render(gfx.createElement(gfx.FigureView, { figure: state.figs[handle] }));
119 }
120 }
122 dispose(): void {
123 for (const [handle, figure] of this.mounted) {
124 figure.root.unmount();
125 figure.view.dispose();
126 this.mounted.delete(handle);
127 }
128 }
moveopenescclose