Fix jitter
This commit is contained in:
@@ -502,7 +502,18 @@
|
|||||||
|
|
||||||
/* --- Status strip ---------------------------------------------------------- */
|
/* --- 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 {
|
.bb-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -510,9 +521,14 @@
|
|||||||
max-height: 11rem;
|
max-height: 11rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--bg-secondary);
|
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-messages:empty,
|
||||||
.bb-status-warnings:not(.is-visible) { display: none; }
|
.bb-status-warnings:not(.is-visible) { display: none; }
|
||||||
|
|
||||||
|
|||||||
@@ -67,9 +67,15 @@ export function boot(root) {
|
|||||||
className: 'bb-workspace',
|
className: 'bb-workspace',
|
||||||
children: [paletteHost, canvasContainer, propsHost]
|
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);
|
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);
|
const status = createStatus(statusHost);
|
||||||
|
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
@@ -91,6 +97,8 @@ export function boot(root) {
|
|||||||
|
|
||||||
let destroyed = false;
|
let destroyed = false;
|
||||||
let saving = false;
|
let saving = false;
|
||||||
|
/** Pending properties-panel rebuild. See scheduleProperties. */
|
||||||
|
let propertiesFrame = null;
|
||||||
let simLoadedOnce = false;
|
let simLoadedOnce = false;
|
||||||
/** Components named by a simulation warning, highlighted on the canvas. */
|
/** Components named by a simulation warning, highlighted on the canvas. */
|
||||||
const warnedUids = new Set();
|
const warnedUids = new Set();
|
||||||
@@ -121,7 +129,7 @@ export function boot(root) {
|
|||||||
if (!failed) status.clearWarnings();
|
if (!failed) status.clearWarnings();
|
||||||
for (const warning of warnings) status.addWarning(warning);
|
for (const warning of warnings) status.addWarning(warning);
|
||||||
refreshSimState();
|
refreshSimState();
|
||||||
pushSimScene();
|
pushSimScene('dynamic', 'overlay');
|
||||||
if (!failed && netCount > 0) {
|
if (!failed && netCount > 0) {
|
||||||
status.info(`Simulation ready — ${netCount} net${netCount === 1 ? '' : 's'}.`);
|
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
|
// 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 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.
|
// 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.
|
// A halt is worth an explicit message; the engine refuses to run into it.
|
||||||
if (warning.kind === 'shortCircuit') {
|
if (warning.kind === 'shortCircuit') {
|
||||||
status.error('Simulation halted: the supply rails are shorted together.');
|
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({
|
renderer.setScene({
|
||||||
netLevels: sim.state.netLevels,
|
netLevels: sim.state.netLevels,
|
||||||
netOfStrip: sim.state.netOfStrip,
|
netOfStrip: sim.state.netOfStrip,
|
||||||
@@ -159,7 +174,20 @@ export function boot(root) {
|
|||||||
burned: sim.state.burned,
|
burned: sim.state.burned,
|
||||||
warnedUids,
|
warnedUids,
|
||||||
simActive: sim.state.loaded
|
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() {
|
function refreshSimState() {
|
||||||
@@ -237,7 +265,7 @@ export function boot(root) {
|
|||||||
status.clearWarnings();
|
status.clearWarnings();
|
||||||
sim.reset();
|
sim.reset();
|
||||||
status.info('Simulation reset.');
|
status.info('Simulation reset.');
|
||||||
pushSimScene();
|
pushSimScene('dynamic', 'overlay');
|
||||||
},
|
},
|
||||||
onSpeed: (eventsPerSecond) => sim.setSpeed(eventsPerSecond),
|
onSpeed: (eventsPerSecond) => sim.setSpeed(eventsPerSecond),
|
||||||
onAddBoard: () => {
|
onAddBoard: () => {
|
||||||
@@ -311,7 +339,7 @@ export function boot(root) {
|
|||||||
toolbar.setZoom(viewport.zoom);
|
toolbar.setZoom(viewport.zoom);
|
||||||
persistView();
|
persistView();
|
||||||
},
|
},
|
||||||
onSelectionChange: () => properties.render(state)
|
onSelectionChange: () => scheduleProperties()
|
||||||
});
|
});
|
||||||
|
|
||||||
palette.setActiveTool({ kind: 'select', type: null });
|
palette.setActiveTool({ kind: 'select', type: null });
|
||||||
@@ -331,9 +359,14 @@ export function boot(root) {
|
|||||||
afterViewportChange();
|
afterViewportChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
bag.on(window, 'resize', () => renderer.resize());
|
function onContainerResize() {
|
||||||
|
tools.invalidateRect();
|
||||||
|
renderer.resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
bag.on(window, 'resize', onContainerResize);
|
||||||
const resizeObserver = typeof ResizeObserver === 'function'
|
const resizeObserver = typeof ResizeObserver === 'function'
|
||||||
? new ResizeObserver(() => renderer.resize())
|
? new ResizeObserver(onContainerResize)
|
||||||
: null;
|
: null;
|
||||||
if (resizeObserver) resizeObserver.observe(canvasContainer);
|
if (resizeObserver) resizeObserver.observe(canvasContainer);
|
||||||
|
|
||||||
@@ -353,7 +386,7 @@ export function boot(root) {
|
|||||||
state.subscribe((change) => {
|
state.subscribe((change) => {
|
||||||
if (change.kind === 'circuit') {
|
if (change.kind === 'circuit') {
|
||||||
tools.refresh('board', 'static', 'dynamic', 'overlay');
|
tools.refresh('board', 'static', 'dynamic', 'overlay');
|
||||||
properties.render(state);
|
scheduleProperties();
|
||||||
reloadSim();
|
reloadSim();
|
||||||
autosave();
|
autosave();
|
||||||
} else if (change.kind === 'runtime') {
|
} else if (change.kind === 'runtime') {
|
||||||
@@ -387,7 +420,7 @@ export function boot(root) {
|
|||||||
else afterViewportChange();
|
else afterViewportChange();
|
||||||
|
|
||||||
tools.refresh('board', 'static', 'dynamic', 'overlay');
|
tools.refresh('board', 'static', 'dynamic', 'overlay');
|
||||||
properties.render(state);
|
scheduleProperties();
|
||||||
updateSaveState();
|
updateSaveState();
|
||||||
|
|
||||||
if (sim.start()) sim.load(circuit);
|
if (sim.start()) sim.load(circuit);
|
||||||
@@ -408,6 +441,7 @@ export function boot(root) {
|
|||||||
reloadSim.cancel();
|
reloadSim.cancel();
|
||||||
autosave.cancel();
|
autosave.cancel();
|
||||||
persistView.cancel();
|
persistView.cancel();
|
||||||
|
if (propertiesFrame !== null) cancelAnimationFrame(propertiesFrame);
|
||||||
bag.removeAll();
|
bag.removeAll();
|
||||||
if (resizeObserver) resizeObserver.disconnect();
|
if (resizeObserver) resizeObserver.disconnect();
|
||||||
tools.destroy();
|
tools.destroy();
|
||||||
|
|||||||
@@ -62,9 +62,16 @@ export function createRenderer(container, viewport, palette, options = {}) {
|
|||||||
}
|
}
|
||||||
canvases.overlay.tabIndex = 0; // focusable, so the canvas can own keyboard
|
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 cssWidth = 0;
|
||||||
let cssHeight = 0;
|
let cssHeight = 0;
|
||||||
let dpr = 1;
|
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 };
|
const dirty = { board: true, static: true, dynamic: true, overlay: true };
|
||||||
let frameHandle = null;
|
let frameHandle = null;
|
||||||
|
|
||||||
@@ -86,25 +93,33 @@ export function createRenderer(container, viewport, palette, options = {}) {
|
|||||||
simActive: false
|
simActive: false
|
||||||
};
|
};
|
||||||
|
|
||||||
function syncCanvasSize() {
|
/** Read the container size. Cheap - one layout read, no canvas work. */
|
||||||
|
function measureContainer() {
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
const nextDpr = window.devicePixelRatio || 1;
|
const nextDpr = window.devicePixelRatio || 1;
|
||||||
const width = Math.max(1, Math.round(rect.width));
|
const width = Math.max(1, Math.round(rect.width));
|
||||||
const height = Math.max(1, Math.round(rect.height));
|
const height = Math.max(1, Math.round(rect.height));
|
||||||
if (width === cssWidth && height === cssHeight && nextDpr === dpr) return false;
|
if (width === cssWidth && height === cssHeight && nextDpr === dpr) return false;
|
||||||
|
|
||||||
cssWidth = width;
|
cssWidth = width;
|
||||||
cssHeight = height;
|
cssHeight = height;
|
||||||
dpr = nextDpr;
|
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) {
|
for (const name of LAYER_NAMES) {
|
||||||
const canvas = canvases[name];
|
const canvas = canvases[name];
|
||||||
canvas.width = Math.round(width * dpr);
|
canvas.width = Math.round(cssWidth * dpr);
|
||||||
canvas.height = Math.round(height * dpr);
|
canvas.height = Math.round(cssHeight * dpr);
|
||||||
canvas.style.width = `${width}px`;
|
canvas.style.width = `${cssWidth}px`;
|
||||||
canvas.style.height = `${height}px`;
|
canvas.style.height = `${cssHeight}px`;
|
||||||
}
|
}
|
||||||
invalidateAll();
|
for (const name of LAYER_NAMES) dirty[name] = true;
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Apply the base dpr transform, then the world transform. */
|
/** Apply the base dpr transform, then the world transform. */
|
||||||
@@ -335,6 +350,7 @@ export function createRenderer(container, viewport, palette, options = {}) {
|
|||||||
|
|
||||||
function paint() {
|
function paint() {
|
||||||
frameHandle = null;
|
frameHandle = null;
|
||||||
|
applyCanvasSize();
|
||||||
for (const name of LAYER_NAMES) {
|
for (const name of LAYER_NAMES) {
|
||||||
if (!dirty[name]) continue;
|
if (!dirty[name]) continue;
|
||||||
try {
|
try {
|
||||||
@@ -387,7 +403,7 @@ export function createRenderer(container, viewport, palette, options = {}) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
resize() {
|
resize() {
|
||||||
if (syncCanvasSize()) schedule();
|
if (measureContainer()) invalidateAll();
|
||||||
},
|
},
|
||||||
|
|
||||||
invalidate,
|
invalidate,
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ export function createToolbar(root, handlers) {
|
|||||||
|
|
||||||
const addBoardButton = button('Add board', { className: 'bb-btn' });
|
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 group = (className, children) => el('div', { className: `bb-toolbar-group ${className}`, children });
|
||||||
|
|
||||||
const bar = el('div', {
|
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.
|
* rail-to-rail short - so the run button must visibly not take.
|
||||||
*/
|
*/
|
||||||
setSimState({ available, running, settled, halted, loaded }) {
|
setSimState({ available, running, settled, halted, loaded }) {
|
||||||
|
const signature = `${!!available}|${!!running}|${!!settled}|${!!halted}|${!!loaded}`;
|
||||||
|
if (signature === lastSimSignature) return;
|
||||||
|
lastSimSignature = signature;
|
||||||
|
|
||||||
runButton.disabled = !available || running || halted;
|
runButton.disabled = !available || running || halted;
|
||||||
pauseButton.disabled = !available || !running;
|
pauseButton.disabled = !available || !running;
|
||||||
stepButton.disabled = !available || running;
|
stepButton.disabled = !available || running;
|
||||||
|
|||||||
@@ -72,14 +72,32 @@ export function createTools(options) {
|
|||||||
let hoverHole = null;
|
let hoverHole = null;
|
||||||
let hoverComponentUid = 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) {
|
function screenPoint(event) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = bounds();
|
||||||
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a pointer event happened over the canvas itself. */
|
/** Whether a pointer event happened over the canvas itself. */
|
||||||
function isOverCanvas(event) {
|
function isOverCanvas(event) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = bounds();
|
||||||
return event.clientX >= rect.left && event.clientX <= rect.right
|
return event.clientX >= rect.left && event.clientX <= rect.right
|
||||||
&& event.clientY >= rect.top && event.clientY <= rect.bottom;
|
&& 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(canvas, 'pointerdown', onPointerDown);
|
||||||
bag.on(window, 'pointermove', onPointerMove);
|
bag.on(window, 'pointermove', onPointerMove);
|
||||||
bag.on(window, 'pointerup', onPointerUp);
|
bag.on(window, 'pointerup', onPointerUp);
|
||||||
@@ -504,6 +527,8 @@ export function createTools(options) {
|
|||||||
publishScene('dynamic', 'overlay');
|
publishScene('dynamic', 'overlay');
|
||||||
},
|
},
|
||||||
refresh: publishScene,
|
refresh: publishScene,
|
||||||
|
/** Drop the cached canvas rect. Call whenever the canvas may have moved. */
|
||||||
|
invalidateRect,
|
||||||
destroy() {
|
destroy() {
|
||||||
bag.removeAll();
|
bag.removeAll();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user