diff --git a/bun.lock b/bun.lock index 631f0d85..02557ab6 100644 --- a/bun.lock +++ b/bun.lock @@ -20,7 +20,7 @@ "@electron-toolkit/utils": "^4.0.0", "@tailwindcss/postcss": "^4.3.0", "@tailwindcss/vite": "^4.3.0", - "@types/node": "^25.9.2", + "@types/node": "^25.9.3", "@types/plotly.js": "^3.0.10", "@vitejs/plugin-vue": "^6.0.7", "@vue/tsconfig": "^0.9.1", @@ -380,7 +380,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/plotly.js": ["@types/plotly.js@3.0.10", "", {}, "sha512-q+MgO4aajC2HrO7FllTYWzrpdfbTjboSMfjkz/aXKjg1v7HNo1zMEFfAW7quKfk6SL+bH74A5ThBEps/7hZxOA=="], @@ -1236,7 +1236,7 @@ "dmg-builder/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], - "electron/@types/node": ["@types/node@24.13.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg=="], + "electron/@types/node": ["@types/node@24.13.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA=="], "electron-builder/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], diff --git a/package.json b/package.json index 77d56f83..8c362050 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "url": "git+https://github.com/opencor/webapp.git" }, "type": "module", - "version": "0.20260611.0", + "version": "0.20260611.1", "engines": { "bun": ">=1.2.0" }, @@ -69,7 +69,7 @@ "@electron-toolkit/utils": "^4.0.0", "@tailwindcss/postcss": "^4.3.0", "@tailwindcss/vite": "^4.3.0", - "@types/node": "^25.9.2", + "@types/node": "^25.9.3", "@types/plotly.js": "^3.0.10", "@vitejs/plugin-vue": "^6.0.7", "@vue/tsconfig": "^0.9.1", diff --git a/src/renderer/bun.lock b/src/renderer/bun.lock index adb7caad..3ced1cae 100644 --- a/src/renderer/bun.lock +++ b/src/renderer/bun.lock @@ -18,7 +18,7 @@ "@biomejs/biome": "^2.4.16", "@tailwindcss/postcss": "^4.3.0", "@tailwindcss/vite": "^4.3.0", - "@types/node": "^25.9.2", + "@types/node": "^25.9.3", "@types/plotly.js": "^3.0.10", "@vitejs/plugin-vue": "^6.0.7", "@vue/tsconfig": "^0.9.1", @@ -289,7 +289,7 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - "@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], + "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], "@types/plotly.js": ["@types/plotly.js@3.0.10", "", {}, "sha512-q+MgO4aajC2HrO7FllTYWzrpdfbTjboSMfjkz/aXKjg1v7HNo1zMEFfAW7quKfk6SL+bH74A5ThBEps/7hZxOA=="], diff --git a/src/renderer/package.json b/src/renderer/package.json index 7622966c..5e4d3c99 100644 --- a/src/renderer/package.json +++ b/src/renderer/package.json @@ -42,7 +42,7 @@ }, "./style.css": "./dist/opencor.css" }, - "version": "0.20260611.0", + "version": "0.20260611.1", "libopencorVersion": "0.20260604.0", "scripts": { "build": "vite build && bun scripts/generate.version.js", @@ -79,7 +79,7 @@ "@biomejs/biome": "^2.4.16", "@tailwindcss/postcss": "^4.3.0", "@tailwindcss/vite": "^4.3.0", - "@types/node": "^25.9.2", + "@types/node": "^25.9.3", "@types/plotly.js": "^3.0.10", "@vitejs/plugin-vue": "^6.0.7", "@vue/tsconfig": "^0.9.1", diff --git a/src/renderer/src/common/math.ts b/src/renderer/src/common/math.ts index 550d3672..74d85b4d 100644 --- a/src/renderer/src/common/math.ts +++ b/src/renderer/src/common/math.ts @@ -33,6 +33,7 @@ const ELEMENTWISE_REGEX = /\.([*/^])/g; export class Float64ArrayMath { private readonly math: IMathJsLike; private readonly compiledExpressions = new Map(); + private readonly validatedTokens = new Map(); constructor() { this.math = dependencies._mathJs.create(dependencies._mathJs.all, {}); @@ -785,10 +786,11 @@ export class Float64ArrayMath { ); } - // A method to reset the compiled expressions. + // Reset both the compiled expressions and the token validation cache. resetCompiledExpressions(): void { this.compiledExpressions.clear(); + this.validatedTokens.clear(); } // A method to compile expressions, normalising element-wise operators so that `.*`, `./`, and `.^` behave like `*`, @@ -819,8 +821,6 @@ export class Float64ArrayMath { // is used for expression results. evaluate(expression: string, scope: ExpressionScope): MathJsResult { - const tokens = expression.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []; - const allowedVariables = new Set(Object.keys(scope)); const allowedFunctions = new Set([ // Note: this list must be kept in sync with the functions imported into Math.js in the constructor, as well as // some constants. @@ -870,18 +870,30 @@ export class Float64ArrayMath { 'e', 'pi' ]); - const seenTokens = new Set(); - for (const token of tokens) { - if (seenTokens.has(token)) { - continue; - } + // If this expression has already been validated and its scope-allowed variables haven't changed, skip token + // scanning entirely. + + const normalisedExpression = this.normaliseExpression(expression); + + if (!this.validatedTokens.has(normalisedExpression)) { + const tokens = expression.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) ?? []; + const allowedVariables = new Set(Object.keys(scope)); + const seenTokens = new Set(); - seenTokens.add(token); + for (const token of tokens) { + if (seenTokens.has(token)) { + continue; + } + + seenTokens.add(token); - if (!allowedVariables.has(token) && !allowedFunctions.has(token)) { - throw new MathError(expression, `Unknown symbol ${token}.`); + if (!allowedVariables.has(token) && !allowedFunctions.has(token)) { + throw new MathError(expression, `Unknown symbol ${token}.`); + } } + + this.validatedTokens.set(normalisedExpression, true); } return this.compile(expression).evaluate(scope); diff --git a/src/renderer/src/components/views/SimulationExperimentView.vue b/src/renderer/src/components/views/SimulationExperimentView.vue index 0cfad492..43c8577a 100644 --- a/src/renderer/src/components/views/SimulationExperimentView.vue +++ b/src/renderer/src/components/views/SimulationExperimentView.vue @@ -340,14 +340,24 @@ const populateParameters = ( parameters.value.sort((parameter1: string, parameter2: string) => parameter1.localeCompare(parameter2)); }; +// Small helper to yield to the UI thread. + +const yieldToUi = (): Promise => { + return new Promise((resolve) => { + requestAnimationFrame(() => { + setTimeout(resolve, 0); + }); + }); +}; + // Event handlers. -const onRun = (): void => { +const onRun = async (): Promise => { // Run either the standard or interactive simulation. if (!interactiveModeEnabled.value) { // Run the standard simulation, i.e. run the instance, output the simulation time to the console, and update the - // plot. + // plot. We yield to the UI thread between the simulation run and the plot update to keep the UI responsive. const simulationTime = standardInstance.run(); @@ -381,6 +391,8 @@ const onRun = (): void => { } }); + await yieldToUi(); + updatePlot(); return; diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index 9bae973b..3d261f9d 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -111,11 +111,21 @@ const resize = (): Promise => { return Promise.resolve(); } - return Promise.resolve(dependencies._plotlyJs.Plots.resize(mainDivRef.value)).then(() => { - trackSize(); + if (resizeRafId !== undefined) { + return Promise.resolve(); + } + + resizeRafId = requestAnimationFrame(() => { + resizeRafId = undefined; - updateMarginsAsync(); + dependencies._plotlyJs.Plots.resize(mainDivRef.value).then(() => { + trackSize(); + + updateMarginsAsync(); + }); }); + + return Promise.resolve(); }; defineExpose({ @@ -129,13 +139,14 @@ const theme = vueCommon.useTheme(); const contextMenuRef = vue.ref | null>(null); const appendTarget = vueCommon.useAppendTarget(); const progressMessage = vue.inject('progressMessage'); -let updatingMargins = false; let plotIsReady = false; let resizeQueued = false; let trackedWidth = 0; let trackedHeight = 0; let trackedMargins: IGraphPanelMargins | undefined; let stopTrackingContainerSize: (() => void) | undefined; +let marginsRafId: number | undefined; +let resizeRafId: number | undefined; // Context menu functionality. @@ -352,7 +363,9 @@ const exportToCsv = async (): Promise => { // Headers. - const allXValuesEqual = props.data.traces.every((trace) => trace.xValue === firstTrace.xValue); + const allXValuesEqual = props.data.traces.every( + (trace: IGraphPanelPlotTrace) => trace.xValue === firstTrace.xValue + ); const headerParts: string[] = [allXValuesEqual ? firstTrace.xValue : 'X']; for (const trace of props.data.traces) { @@ -457,11 +470,21 @@ interface IThemeData { yaxis: IAxisThemeData; } +let cachedThemeMode: boolean | undefined; +let cachedThemeData: IThemeData | undefined; + const themeData = (): IThemeData => { - // Note: the various keys can be found at https://plotly.com/javascript/reference/. + // Use our cached theme data if the theme mode hasn't changed since the last time we generated it. const useLightMode = theme.useLightMode(); + if (cachedThemeData !== undefined && cachedThemeMode === useLightMode) { + return cachedThemeData; + } + + // Generate our theme data. + // Note: the various keys can be found at https://plotly.com/javascript/reference/. + const axisThemeData = (): IAxisThemeData => { return { zerolinecolor: useLightMode ? '#94a3b8' : '#71717a', // --p-surface-400 / --p-surface-500 @@ -472,7 +495,8 @@ const themeData = (): IThemeData => { }; }; - return { + cachedThemeMode = useLightMode; + cachedThemeData = { paper_bgcolor: useLightMode ? '#ffffff' : '#18181b', // --p-content-background plot_bgcolor: useLightMode ? '#ffffff' : '#18181b', // --p-content-background font: { @@ -482,6 +506,8 @@ const themeData = (): IThemeData => { xaxis: axisThemeData(), yaxis: axisThemeData() }; + + return cachedThemeData; }; interface IAxesDataAxis { @@ -501,10 +527,29 @@ interface IAxesData { yaxis: IAxesDataAxis; } +let cachedAxesData: IAxesData | undefined; +let cachedXAxisTitle: string | undefined; +let cachedYAxisTitle: string | undefined; + const axesData = (): IAxesData => { + // Use our cached axes data if the axis titles haven't changed since the last time we generated it. + + const xTitle = props.data.xAxisTitle; + const yTitle = props.data.yAxisTitle; + + if (cachedAxesData !== undefined && cachedXAxisTitle === xTitle && cachedYAxisTitle === yTitle) { + return cachedAxesData; + } + + // Generate our axes data. + // Note: the various keys can be found at https://plotly.com/javascript/reference/. + + cachedXAxisTitle = xTitle; + cachedYAxisTitle = yTitle; + const axisTickFontSize = 10; - return { + cachedAxesData = { xaxis: { automargin: true, tickangle: 0, @@ -513,7 +558,7 @@ const axesData = (): IAxesData => { }, title: { standoff: 8, - text: props.data.xAxisTitle + text: xTitle } }, yaxis: { @@ -524,10 +569,12 @@ const axesData = (): IAxesData => { }, title: { standoff: 8, - text: props.data.yAxisTitle + text: yTitle } } }; + + return cachedAxesData; }; const resolvedMargin = (propValue: number | undefined, compValue: number): number => { @@ -610,22 +657,18 @@ const canMeasureMargins = (): boolean => { }; const updateMarginsAsync = (): void => { - // Skip if we are already updating our margins. + // Coalesce multiple margin update requests into a single requestAnimationFrame() callback. - if (updatingMargins) { + if (marginsRafId !== undefined) { return; } - updatingMargins = true; - - // Use requestAnimationFrame for optimal timing. + marginsRafId = requestAnimationFrame(() => { + marginsRafId = undefined; - requestAnimationFrame(() => { // Make sure that we can measure our margins before proceeding. if (!canMeasureMargins()) { - updatingMargins = false; - return; } @@ -660,8 +703,6 @@ const updateMarginsAsync = (): void => { if (Object.keys(relayoutUpdates).length) { dependencies._plotlyJs.relayout(mainDivRef.value, relayoutUpdates); } - - updatingMargins = false; }); }; @@ -700,17 +741,25 @@ const updatePlot = (): void => { } } - const traces = props.data.traces.map((trace) => { - const traceKey = traceVisibilityKey(trace); - const visible = traceKey ? previousTraceVisibilityByKey[traceKey] : undefined; + // Pre-allocate the traces array and build each trace inline to avoid .map() closure overhead. - return { - ...trace, - visible, - line: { color: trace.color }, - legendrank: trace.zorder + const dataTraces = props.data.traces; + const dataTraceCount = dataTraces.length; + const traces: unknown[] = new Array(dataTraceCount); + + for (let i = 0; i < dataTraceCount; ++i) { + const dataTrace = dataTraces[i]; + const dataTraceKey = traceVisibilityKey(dataTrace); + + traces[i] = { + x: dataTrace.x, + y: dataTrace.y, + name: dataTrace.name, + visible: dataTraceKey ? previousTraceVisibilityByKey[dataTraceKey] : undefined, + line: { color: dataTrace.color }, + legendrank: dataTrace.zorder }; - }); + } dependencies._plotlyJs .react(