Feature/breadboard #4
@@ -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; }
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user