From 3e4d68313af15fd4c4c6ea9c1b8d29809158f3c7 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 11 Jun 2026 12:46:42 +1200 Subject: [PATCH 01/10] New version. --- package.json | 2 +- src/renderer/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 77d56f83..5c336d04 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" }, diff --git a/src/renderer/package.json b/src/renderer/package.json index 7622966c..079301b7 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", From 08454e1faa5920071df63b8f4c6ca2f2fcc0e878 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 11 Jun 2026 12:47:33 +1200 Subject: [PATCH 02/10] Updated our dependencies. --- bun.lock | 6 +++--- package.json | 2 +- src/renderer/bun.lock | 4 ++-- src/renderer/package.json | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) 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 5c336d04..8c362050 100644 --- a/package.json +++ b/package.json @@ -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 079301b7..5e4d3c99 100644 --- a/src/renderer/package.json +++ b/src/renderer/package.json @@ -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", From ea501cc9d0d794bb8e1684cf350cee6f094cc38c Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 11 Jun 2026 14:13:40 +1200 Subject: [PATCH 03/10] Some minor cleaning up. --- src/renderer/src/components/widgets/GraphPanelWidget.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index 9bae973b..3a4a2f79 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -352,7 +352,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) { From bcab2269e59513e21f0a49ebb99d9e2bae2ae39f Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 11 Jun 2026 12:46:30 +1200 Subject: [PATCH 04/10] Math: cache validations. --- src/renderer/src/common/math.ts | 34 ++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) 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); From 024f45edc0439eabae87db16af82a4696ddbb09f Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 11 Jun 2026 13:22:09 +1200 Subject: [PATCH 05/10] Simulation Experiment view: yield to the UI between a simulation run and a simulation plot. --- .../views/SimulationExperimentView.vue | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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; From df983902612ba8a1f9de99763505b64e7ce54a82 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 11 Jun 2026 13:28:46 +1200 Subject: [PATCH 06/10] Graph Panel widget: rely on requestAnimationFrame() to coalesce resize calls. --- .../src/components/widgets/GraphPanelWidget.vue | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index 3a4a2f79..6b496815 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({ @@ -136,6 +146,7 @@ let trackedWidth = 0; let trackedHeight = 0; let trackedMargins: IGraphPanelMargins | undefined; let stopTrackingContainerSize: (() => void) | undefined; +let resizeRafId: number | undefined; // Context menu functionality. From ca61d38d9a56f7fb996238e29d38001b550b99fc Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 11 Jun 2026 13:31:49 +1200 Subject: [PATCH 07/10] Graph Panel widget: rely on requestAnimationFrame() to coalesce margins calculations. --- .../src/components/widgets/GraphPanelWidget.vue | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index 6b496815..d90b8b1b 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -139,13 +139,13 @@ 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. @@ -623,22 +623,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; + marginsRafId = requestAnimationFrame(() => { + marginsRafId = undefined; - // Use requestAnimationFrame for optimal timing. - - requestAnimationFrame(() => { // Make sure that we can measure our margins before proceeding. if (!canMeasureMargins()) { - updatingMargins = false; - return; } @@ -673,8 +669,6 @@ const updateMarginsAsync = (): void => { if (Object.keys(relayoutUpdates).length) { dependencies._plotlyJs.relayout(mainDivRef.value, relayoutUpdates); } - - updatingMargins = false; }); }; From 0b7fa766aa513409e74531f44ee956e83d1c0bba Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 11 Jun 2026 14:08:16 +1200 Subject: [PATCH 08/10] Graph Panel widget: cache our theme data. --- .../src/components/widgets/GraphPanelWidget.vue | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index d90b8b1b..92232e7d 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -470,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 @@ -485,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: { @@ -495,6 +506,8 @@ const themeData = (): IThemeData => { xaxis: axisThemeData(), yaxis: axisThemeData() }; + + return cachedThemeData; }; interface IAxesDataAxis { From 5b4f32ca8ad731de7c0a486bea598e5c39ece590 Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 11 Jun 2026 14:12:14 +1200 Subject: [PATCH 09/10] Graph Panel widget: cache our axes data. --- .../components/widgets/GraphPanelWidget.vue | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index 92232e7d..c86c81fe 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -527,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, @@ -539,7 +558,7 @@ const axesData = (): IAxesData => { }, title: { standoff: 8, - text: props.data.xAxisTitle + text: xTitle } }, yaxis: { @@ -550,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 => { From 0fd426531c11f48ebdfa42ee3274f200ad9be2ab Mon Sep 17 00:00:00 2001 From: Alan Garny Date: Thu, 11 Jun 2026 14:22:57 +1200 Subject: [PATCH 10/10] Graph Panel widget: pre-allocate the traces array and build each trace inline to avoid .map() closure overhead. --- .../components/widgets/GraphPanelWidget.vue | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/renderer/src/components/widgets/GraphPanelWidget.vue b/src/renderer/src/components/widgets/GraphPanelWidget.vue index c86c81fe..3d261f9d 100644 --- a/src/renderer/src/components/widgets/GraphPanelWidget.vue +++ b/src/renderer/src/components/widgets/GraphPanelWidget.vue @@ -741,17 +741,25 @@ const updatePlot = (): void => { } } - const traces = props.data.traces.map((trace) => { - const traceKey = traceVisibilityKey(trace); - const visible = traceKey ? previousTraceVisibilityByKey[traceKey] : undefined; - - return { - ...trace, - visible, - line: { color: trace.color }, - legendrank: trace.zorder + // Pre-allocate the traces array and build each trace inline to avoid .map() closure overhead. + + 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(