diff --git a/internal/static/dist.zip b/internal/static/dist.zip index 77d9ff0a..96846ce5 100644 Binary files a/internal/static/dist.zip and b/internal/static/dist.zip differ diff --git a/internal/static/package-lock.json b/internal/static/package-lock.json index ab07bae1..e1533a5c 100644 --- a/internal/static/package-lock.json +++ b/internal/static/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "bootstrap": "^5.3.5", "bootstrap-icons": "^1.12.1", - "plotly.js-cartesian-dist": "^3.0.1", + "plotly.js-cartesian-dist": "^3.3.0", "tippy.js": "^6.3.7" }, "devDependencies": { @@ -885,9 +885,9 @@ } }, "node_modules/plotly.js-cartesian-dist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/plotly.js-cartesian-dist/-/plotly.js-cartesian-dist-3.0.1.tgz", - "integrity": "sha512-wT4VbSBtjOpYDvk/L2gkAAbN3NtxUZ7UpMS+IJ0MBBtBEPIZKC4dQ0fMR+k3NneTvizhqnPseDwV9SY3CRiTMQ==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/plotly.js-cartesian-dist/-/plotly.js-cartesian-dist-3.3.0.tgz", + "integrity": "sha512-h2Q4fAVhA5N9+R3AFMtxzkz185khnOVEZsJ/hBzx/CaOWbTtvM0xKtSghfU5gbWmVWjNoTgd3f1IRPm0lhYG9Q==", "license": "MIT" }, "node_modules/postcss": { diff --git a/internal/static/package.json b/internal/static/package.json index f8c03cc9..30ff1376 100644 --- a/internal/static/package.json +++ b/internal/static/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "preview": "vite preview", - "build": "vite build" + "build": "vite build", + "release": "bash -lc \"rm -rf ./dist && vite build && zip -r dist.zip dist/*\"" }, "devDependencies": { "vite": "^6.4.1" @@ -14,7 +15,7 @@ "dependencies": { "bootstrap": "^5.3.5", "bootstrap-icons": "^1.12.1", - "plotly.js-cartesian-dist": "^3.0.1", + "plotly.js-cartesian-dist": "^3.3.0", "tippy.js": "^6.3.7" } } diff --git a/internal/static/src/PlotManager.js b/internal/static/src/PlotManager.js index 211a9312..46f3b8fa 100644 --- a/internal/static/src/PlotManager.js +++ b/internal/static/src/PlotManager.js @@ -12,9 +12,14 @@ function debounce(fn, delay) { } export default class PlotManager { + #shapesCache; + #lastGcEnabled; + constructor(config) { this.container = document.getElementById("plots"); this.plots = config.series.map((pd) => new Plot(pd)); + this.#shapesCache = new Map(); + this.#lastGcEnabled = null; this.#attach(); window.addEventListener( @@ -41,14 +46,39 @@ export default class PlotManager { } update(data, gcEnabled, timeRange, force = false) { - // Create GC vertical lines. + // Create GC vertical lines - only if needed. const shapes = new Map(); if (gcEnabled) { + // Only recreate shapes if GC state changed or events changed + const gcStateChanged = this.#lastGcEnabled !== gcEnabled; + for (const [name, serie] of data.events) { - shapes.set(name, createVerticalLines(serie)); + // Check if we need to regenerate shapes for this event + const cached = this.#shapesCache.get(name); + const eventsChanged = + !cached || + cached.length !== serie.length || + (serie.length > 0 && + cached[cached.length - 1]?.x0?.getTime() !== + serie[serie.length - 1]?.getTime()); + + if (gcStateChanged || eventsChanged || !this.#shapesCache.has(name)) { + const newShapes = createVerticalLines(serie); + this.#shapesCache.set(name, newShapes); + shapes.set(name, newShapes); + } else { + shapes.set(name, this.#shapesCache.get(name)); + } + } + } else { + // GC disabled, clear all shapes + if (this.#lastGcEnabled !== false) { + this.#shapesCache.clear(); } } + this.#lastGcEnabled = gcEnabled; + // X-axis range. const now = data.times[data.times.length - 1]; const xrange = [now - timeRange * 1000, now]; @@ -63,11 +93,11 @@ export default class PlotManager { const gd = document.getElementById(p.name()); // We're being super defensive here to ensure that the div is // actually there (or Plotly.resize would fail). - if (!gd) return; - if (!p.isVisible()) return; + if (!gd || !p.isVisible()) return; const { offsetWidth: w, offsetHeight: h } = gd; if (w === 0 || h === 0) return; + p.updateCachedWidth(); Plotly.Plots.resize(gd); }); } diff --git a/internal/static/src/RingBuffer.js b/internal/static/src/RingBuffer.js index 35f66069..e1ccc3b7 100644 --- a/internal/static/src/RingBuffer.js +++ b/internal/static/src/RingBuffer.js @@ -20,11 +20,14 @@ export default class RingBuffer { slice(lastN) { const n = Math.min(lastN, this.#size); - const result = []; - for (let i = this.#size - n; i < this.#size; i++) { - result.push(this.#buf[(this.#start + i) % this.#buf.length]); + const result = new Float64Array(n); + + const startIdx = this.#size - n; + for (let i = 0; i < n; i++) { + result[i] = this.#buf[(this.#start + startIdx + i) % this.#buf.length]; } - return result; + + return Array.from(result); } get first() { diff --git a/internal/static/src/app.js b/internal/static/src/app.js index 78388720..75d6bfc6 100644 --- a/internal/static/src/app.js +++ b/internal/static/src/app.js @@ -8,11 +8,31 @@ import "bootstrap/dist/js/bootstrap.min.js"; export let statsMgr; export let plotMgr; +// RAF-based throttling for plot updates +let rafId = null; +let pendingUpdate = false; +let forceNextUpdate = false; + +const scheduleUpdate = () => { + if (rafId !== null) return; // Already scheduled + + rafId = requestAnimationFrame(() => { + rafId = null; + if (pendingUpdate && running) { + const data = statsMgr.slice(timerange); + plotMgr.update(data, gcEnabled, timerange, forceNextUpdate); + pendingUpdate = false; + forceNextUpdate = false; + } + }); +}; + export const drawPlots = (force) => { - if (running) { - const data = statsMgr.slice(timerange); - plotMgr.update(data, gcEnabled, timerange, force); + pendingUpdate = true; + if (force) { + forceNextUpdate = true; } + scheduleUpdate(); }; export const connect = () => { diff --git a/internal/static/src/plot.js b/internal/static/src/plot.js index 0c0bf6da..cdfde204 100644 --- a/internal/static/src/plot.js +++ b/internal/static/src/plot.js @@ -22,6 +22,7 @@ class Plot { #maximized; #cfg; #dataTemplate; + #cachedWidth; constructor(cfg) { cfg.layout.paper_bgcolor = themeColors[theme.getThemeMode()].paper_bgcolor; @@ -33,6 +34,7 @@ class Plot { this.#updateCount = 0; this.#dataTemplate = []; this.#lastData = [{ x: new Date() }]; + this.#cachedWidth = null; if (this.#cfg.type == "heatmap") { this.#dataTemplate.push({ @@ -80,8 +82,8 @@ class Plot { this.#htmlElt = div; // Measure the final CSS width. - const initialWidth = div.clientWidth; - this.#plotlyLayout.width = initialWidth; + this.#cachedWidth = div.clientWidth; + this.#plotlyLayout.width = this.#cachedWidth; this.#plotlyLayout.height = defaultPlotHeight; // Pass a single data with no data to create an empty plot (this removes @@ -154,6 +156,7 @@ class Plot { #extractData(data) { const serie = data.series.get(this.#cfg.name); + if (this.#cfg.type == "heatmap") { this.#dataTemplate[0].x = data.times; this.#dataTemplate[0].z = serie; @@ -163,6 +166,7 @@ class Plot { for (let i = 0; i < this.#dataTemplate.length; i++) { this.#dataTemplate[i].x = data.times; this.#dataTemplate[i].y = serie[i]; + this.#dataTemplate[i].stackgroup = this.#cfg.subplots[i].stackgroup; this.#dataTemplate[i].hoveron = this.#cfg.subplots[i].hoveron; this.#dataTemplate[i].type = @@ -198,11 +202,8 @@ class Plot { this.#plotlyConfig.responsive = false; } - // **Re‐measure** container width each time - const newWidth = this.#maximized - ? plotsDiv.clientWidth - : this.#htmlElt.clientWidth; - this.#plotlyLayout.width = newWidth; + // Use cached width - only recalculated on resize + this.#plotlyLayout.width = this.#cachedWidth; this.#react(); } @@ -215,21 +216,33 @@ class Plot { this.#plotlyLayout = newLayoutObject(this.#cfg, this.#maximized); this.#plotlyConfig = newConfigObject(this.#cfg, this.#maximized); - this.#plotlyLayout.width = plotsDiv.clientWidth; + this.#cachedWidth = plotsDiv.clientWidth; + this.#plotlyLayout.width = this.#cachedWidth; this.#plotlyLayout.height = plotsDiv.parentElement.clientHeight - 50; this.#react(); } minimize() { - (this.#maximized = false), this.#maximized; + this.#maximized = false; this.#plotlyLayout = newLayoutObject(this.#cfg, this.#maximized); this.#plotlyConfig = newConfigObject(this.#cfg, this.#maximized); + this.#cachedWidth = this.#htmlElt.clientWidth; + this.#plotlyLayout.width = this.#cachedWidth; + this.#react(); } + updateCachedWidth() { + if (this.#maximized) { + this.#cachedWidth = plotsDiv.clientWidth; + } else { + this.#cachedWidth = this.#htmlElt.clientWidth; + } + } + #react() { Plotly.react( this.#htmlElt,