import type { Workbench, AuxiliaryView } from 'minwebide'; import type { PlotInstruction } from 'numbl'; import type { FiguresState } from 'numbl/graphics'; import type { Root } from 'react-dom/client'; // Renders a run's figures the way numbl.org does — one pane per MATLAB-style // figure handle, rendered by numbl's own — but docked as views // in the secondary side bar ("Figure 1", "Figure 2", ...), the minwebide // analog of numbl.org's figure tabs. The renderer stack (numbl/graphics + // react + three.js) is loaded on demand the first time a run plots anything, // so it stays out of the initial bundle. interface GraphicsStack { FigureView: typeof import('numbl/graphics').FigureView; figuresReducer: typeof import('numbl/graphics').figuresReducer; createElement: typeof import('react').createElement; createRoot: typeof import('react-dom/client').createRoot; } interface MountedFigure { view: AuxiliaryView; root: Root; } export class FigureManager { private gfx: GraphicsStack | undefined; private loading: Promise | undefined; private state: FiguresState | undefined; private pending: PlotInstruction[] = []; private readonly mounted = new Map(); private revealedThisRun = false; constructor(private readonly workbench: Workbench) {} /** Clears all figures — call at the start of each run. */ beginRun(): void { this.pending = []; if (this.gfx && this.state) { this.state = this.gfx.figuresReducer(this.state, { type: 'clear' }); } for (const [handle, figure] of this.mounted) { figure.root.unmount(); figure.view.dispose(); this.mounted.delete(handle); } this.revealedThisRun = false; } /** Folds a batch of plot instructions into the figure panes. */ apply(instructions: PlotInstruction[]): void { if (!this.gfx || !this.state) { this.pending.push(...instructions); void this.ensureLoaded(); return; } for (const instruction of instructions) { this.state = this.gfx.figuresReducer(this.state, instruction); } this.sync(); } private ensureLoaded(): Promise { if (!this.loading) { this.loading = (async () => { const [graphics, react, reactDom] = await Promise.all([ import('numbl/graphics'), import('react'), import('react-dom/client'), ]); this.gfx = { FigureView: graphics.FigureView, figuresReducer: graphics.figuresReducer, createElement: react.createElement, createRoot: reactDom.createRoot, }; this.state = graphics.initialFiguresState; const buffered = this.pending; this.pending = []; if (buffered.length > 0) { this.apply(buffered); } })(); } return this.loading; } private sync(): void { const gfx = this.gfx!; const state = this.state!; const handles = Object.keys(state.figs).map(Number).sort((a, b) => a - b); // figures closed by the script (close/close all) for (const [handle, figure] of this.mounted) { if (!state.figs[handle]) { figure.root.unmount(); figure.view.dispose(); this.mounted.delete(handle); } } for (const handle of handles) { let figure = this.mounted.get(handle); if (!figure) { const view = this.workbench.createAuxiliaryView(`numbl.figure.${handle}`, `Figure ${handle}`); view.element.style.overflow = 'hidden'; const host = document.createElement('div'); host.style.width = '100%'; host.style.height = '100%'; host.style.backgroundColor = '#fff'; view.element.appendChild(host); figure = { view, root: gfx.createRoot(host) }; this.mounted.set(handle, figure); if (!this.revealedThisRun) { this.revealedThisRun = true; view.show(); } } figure.root.render(gfx.createElement(gfx.FigureView, { figure: state.figs[handle] })); } } dispose(): void { for (const [handle, figure] of this.mounted) { figure.root.unmount(); figure.view.dispose(); this.mounted.delete(handle); } } }