diff --git a/Media.JoshHeaps.Net/wwwroot/css/breadboard.css b/Media.JoshHeaps.Net/wwwroot/css/breadboard.css index 8c7239d..9733488 100644 --- a/Media.JoshHeaps.Net/wwwroot/css/breadboard.css +++ b/Media.JoshHeaps.Net/wwwroot/css/breadboard.css @@ -502,7 +502,18 @@ /* --- Status strip ---------------------------------------------------------- */ -.bb-status-host { flex: 0 0 auto; } +/* Floats over the bottom of the canvas, NOT stacked below it. Sharing the column + * meant every message that appeared or timed out resized the canvas, which reallocated + * the backing stores and forced a full repaint. */ +.bb-status-host { + position: absolute; + left: 0; + right: 0; + bottom: 0; + z-index: 3; + /* The strip is often empty; the canvas under it must stay clickable. */ + pointer-events: none; +} .bb-status { display: flex; @@ -510,9 +521,14 @@ max-height: 11rem; overflow-y: auto; background: var(--bg-secondary); - border-top: 1px solid var(--border-primary); + pointer-events: auto; } +/* Border on the groups rather than the panel: both collapse when empty, so an idle + * editor shows no stray rule across the canvas. */ +.bb-status-messages, +.bb-status-warnings { border-top: 1px solid var(--border-primary); } + .bb-status-messages:empty, .bb-status-warnings:not(.is-visible) { display: none; } diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/main.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/main.js index 1a8719e..25f22f9 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/main.js +++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/main.js @@ -67,9 +67,15 @@ export function boot(root) { className: 'bb-workspace', children: [paletteHost, canvasContainer, propsHost] }); - const shell = el('div', { className: 'bb-editor-shell', children: [toolbarHost, workspace, statusHost] }); + const shell = el('div', { className: 'bb-editor-shell', children: [toolbarHost, workspace] }); root.appendChild(shell); + // The status strip floats over the bottom of the canvas rather than sitting below + // it in the column. In the column, every message that appeared or timed out resized + // the canvas container, which reallocated all four backing stores and forced a full + // repaint - twice per message. + canvasContainer.appendChild(statusHost); + const status = createStatus(statusHost); if (!projectId) { @@ -91,6 +97,8 @@ export function boot(root) { let destroyed = false; let saving = false; + /** Pending properties-panel rebuild. See scheduleProperties. */ + let propertiesFrame = null; let simLoadedOnce = false; /** Components named by a simulation warning, highlighted on the canvas. */ const warnedUids = new Set(); @@ -121,7 +129,7 @@ export function boot(root) { if (!failed) status.clearWarnings(); for (const warning of warnings) status.addWarning(warning); refreshSimState(); - pushSimScene(); + pushSimScene('dynamic', 'overlay'); if (!failed && netCount > 0) { status.info(`Simulation ready — ${netCount} net${netCount === 1 ? '' : 's'}.`); } @@ -136,7 +144,7 @@ export function boot(root) { // Repaint here rather than waiting for an unrelated message: shortCircuit // and oscillation HALT the engine, so there may be no further frame at all - // and those are exactly the warnings whose components most need pointing at. - pushSimScene(); + pushSimScene('dynamic', 'overlay'); // A halt is worth an explicit message; the engine refuses to run into it. if (warning.kind === 'shortCircuit') { status.error('Simulation halted: the supply rails are shorted together.'); @@ -151,7 +159,14 @@ export function boot(root) { } }); - function pushSimScene() { + /** + * Hand the current simulation state to the renderer. + * + * Defaults to the dynamic layer alone. The overlay only shows simulation state + * through `warnedUids`, which changes on a warning - not on every frame - so + * callers that touch warnedUids pass 'dynamic', 'overlay' explicitly. + */ + function pushSimScene(...layers) { renderer.setScene({ netLevels: sim.state.netLevels, netOfStrip: sim.state.netOfStrip, @@ -159,7 +174,20 @@ export function boot(root) { burned: sim.state.burned, warnedUids, simActive: sim.state.loaded - }, 'dynamic', 'overlay'); + }, ...(layers.length > 0 ? layers : ['dynamic'])); + } + + /** + * Rebuild the properties panel at most once per frame. render() throws away and + * rebuilds the whole panel, and a component drag fires a circuit change on every + * pointermove, so calling it directly meant a full DOM teardown per mouse move. + */ + function scheduleProperties() { + if (propertiesFrame !== null) return; + propertiesFrame = requestAnimationFrame(() => { + propertiesFrame = null; + if (!destroyed) properties.render(state); + }); } function refreshSimState() { @@ -237,7 +265,7 @@ export function boot(root) { status.clearWarnings(); sim.reset(); status.info('Simulation reset.'); - pushSimScene(); + pushSimScene('dynamic', 'overlay'); }, onSpeed: (eventsPerSecond) => sim.setSpeed(eventsPerSecond), onAddBoard: () => { @@ -311,7 +339,7 @@ export function boot(root) { toolbar.setZoom(viewport.zoom); persistView(); }, - onSelectionChange: () => properties.render(state) + onSelectionChange: () => scheduleProperties() }); palette.setActiveTool({ kind: 'select', type: null }); @@ -331,9 +359,14 @@ export function boot(root) { afterViewportChange(); } - bag.on(window, 'resize', () => renderer.resize()); + function onContainerResize() { + tools.invalidateRect(); + renderer.resize(); + } + + bag.on(window, 'resize', onContainerResize); const resizeObserver = typeof ResizeObserver === 'function' - ? new ResizeObserver(() => renderer.resize()) + ? new ResizeObserver(onContainerResize) : null; if (resizeObserver) resizeObserver.observe(canvasContainer); @@ -353,7 +386,7 @@ export function boot(root) { state.subscribe((change) => { if (change.kind === 'circuit') { tools.refresh('board', 'static', 'dynamic', 'overlay'); - properties.render(state); + scheduleProperties(); reloadSim(); autosave(); } else if (change.kind === 'runtime') { @@ -387,7 +420,7 @@ export function boot(root) { else afterViewportChange(); tools.refresh('board', 'static', 'dynamic', 'overlay'); - properties.render(state); + scheduleProperties(); updateSaveState(); if (sim.start()) sim.load(circuit); @@ -408,6 +441,7 @@ export function boot(root) { reloadSim.cancel(); autosave.cancel(); persistView.cancel(); + if (propertiesFrame !== null) cancelAnimationFrame(propertiesFrame); bag.removeAll(); if (resizeObserver) resizeObserver.disconnect(); tools.destroy(); diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/renderer.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/renderer.js index 2fda55b..49bd807 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/renderer.js +++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/renderer.js @@ -62,9 +62,16 @@ export function createRenderer(container, viewport, palette, options = {}) { } canvases.overlay.tabIndex = 0; // focusable, so the canvas can own keyboard + // Latest MEASURED size, in CSS pixels. Read straight after resize() so callers + // that fit the view can trust it. let cssWidth = 0; let cssHeight = 0; let dpr = 1; + // Size the backing stores currently hold. Reallocating them costs tens of MB of + // churn, so it happens once per frame at most, inside paint(). + let appliedWidth = 0; + let appliedHeight = 0; + let appliedDpr = 0; const dirty = { board: true, static: true, dynamic: true, overlay: true }; let frameHandle = null; @@ -86,25 +93,33 @@ export function createRenderer(container, viewport, palette, options = {}) { simActive: false }; - function syncCanvasSize() { + /** Read the container size. Cheap - one layout read, no canvas work. */ + function measureContainer() { const rect = container.getBoundingClientRect(); const nextDpr = window.devicePixelRatio || 1; const width = Math.max(1, Math.round(rect.width)); const height = Math.max(1, Math.round(rect.height)); if (width === cssWidth && height === cssHeight && nextDpr === dpr) return false; - cssWidth = width; cssHeight = height; dpr = nextDpr; + return true; + } + + /** Resize the backing stores to the measured size. Wipes them, so all layers dirty. */ + function applyCanvasSize() { + if (cssWidth === appliedWidth && cssHeight === appliedHeight && dpr === appliedDpr) return; + appliedWidth = cssWidth; + appliedHeight = cssHeight; + appliedDpr = dpr; for (const name of LAYER_NAMES) { const canvas = canvases[name]; - canvas.width = Math.round(width * dpr); - canvas.height = Math.round(height * dpr); - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; + canvas.width = Math.round(cssWidth * dpr); + canvas.height = Math.round(cssHeight * dpr); + canvas.style.width = `${cssWidth}px`; + canvas.style.height = `${cssHeight}px`; } - invalidateAll(); - return true; + for (const name of LAYER_NAMES) dirty[name] = true; } /** Apply the base dpr transform, then the world transform. */ @@ -335,6 +350,7 @@ export function createRenderer(container, viewport, palette, options = {}) { function paint() { frameHandle = null; + applyCanvasSize(); for (const name of LAYER_NAMES) { if (!dirty[name]) continue; try { @@ -387,7 +403,7 @@ export function createRenderer(container, viewport, palette, options = {}) { }, resize() { - if (syncCanvasSize()) schedule(); + if (measureContainer()) invalidateAll(); }, invalidate, diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/toolbar.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/toolbar.js index 1dc878f..c538eb0 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/toolbar.js +++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/toolbar.js @@ -42,6 +42,11 @@ export function createToolbar(root, handlers) { const addBoardButton = button('Add board', { className: 'bb-btn' }); + // Last state written to the DOM. setSimState is called on every simulation frame, + // and writing unchanged text/disabled flags still invalidates layout on a + // wrapping toolbar, so identical calls stop here. + let lastSimSignature = null; + const group = (className, children) => el('div', { className: `bb-toolbar-group ${className}`, children }); const bar = el('div', { @@ -92,6 +97,10 @@ export function createToolbar(root, handlers) { * rail-to-rail short - so the run button must visibly not take. */ setSimState({ available, running, settled, halted, loaded }) { + const signature = `${!!available}|${!!running}|${!!settled}|${!!halted}|${!!loaded}`; + if (signature === lastSimSignature) return; + lastSimSignature = signature; + runButton.disabled = !available || running || halted; pauseButton.disabled = !available || !running; stepButton.disabled = !available || running; diff --git a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/tools.js b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/tools.js index 3b0471a..34cfdeb 100644 --- a/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/tools.js +++ b/Media.JoshHeaps.Net/wwwroot/js/breadboard/editor/tools.js @@ -72,14 +72,32 @@ export function createTools(options) { let hoverHole = null; let hoverComponentUid = null; + // Cached canvas rect. pointermove is bound to `window` and needs the rect twice per + // event, and a high-poll mouse fires far more often than once per frame - reading it + // live forces a layout every time. Resize and scroll invalidate it explicitly; the + // TTL is the backstop for a layout shift that moves the canvas without either, so + // the rect is never more than RECT_TTL_MS stale. + let canvasRect = null; + let canvasRectAt = 0; + const RECT_TTL_MS = 250; + + function bounds() { + const now = performance.now(); + if (canvasRect === null || now - canvasRectAt > RECT_TTL_MS) { + canvasRect = canvas.getBoundingClientRect(); + canvasRectAt = now; + } + return canvasRect; + } + function screenPoint(event) { - const rect = canvas.getBoundingClientRect(); + const rect = bounds(); return { x: event.clientX - rect.left, y: event.clientY - rect.top }; } /** Whether a pointer event happened over the canvas itself. */ function isOverCanvas(event) { - const rect = canvas.getBoundingClientRect(); + const rect = bounds(); return event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom; } @@ -480,6 +498,11 @@ export function createTools(options) { } } + const invalidateRect = () => { canvasRect = null; }; + + bag.on(window, 'resize', invalidateRect); + // Capture, so an ancestor scrolling the canvas out from under us also counts. + bag.on(window, 'scroll', invalidateRect, true); bag.on(canvas, 'pointerdown', onPointerDown); bag.on(window, 'pointermove', onPointerMove); bag.on(window, 'pointerup', onPointerUp); @@ -504,6 +527,8 @@ export function createTools(options) { publishScene('dynamic', 'overlay'); }, refresh: publishScene, + /** Drop the cached canvas rect. Call whenever the canvas may have moved. */ + invalidateRect, destroy() { bag.removeAll(); }