132 lines
5.7 KiB
JavaScript
132 lines
5.7 KiB
JavaScript
// Top toolbar: project identity, save state, simulation transport, zoom.
|
||
|
||
import { el, button, setText, createListenerBag } from './dom.js';
|
||
|
||
/** Simulation speed presets, in engine events per second. */
|
||
const SPEED_STEPS = Object.freeze([1, 10, 100, 1000, 10000, 100000, 1000000, 10000000]);
|
||
const DEFAULT_SPEED_INDEX = 5;
|
||
|
||
function formatSpeed(eventsPerSecond) {
|
||
if (eventsPerSecond >= 1000000) return `${eventsPerSecond / 1000000} M events/s`;
|
||
if (eventsPerSecond >= 1000) return `${eventsPerSecond / 1000} k events/s`;
|
||
return `${eventsPerSecond} events/s`;
|
||
}
|
||
|
||
export function createToolbar(root, handlers) {
|
||
const bag = createListenerBag();
|
||
|
||
const title = el('h1', { className: 'bb-title', text: handlers.projectName || 'Breadboard' });
|
||
const saveState = el('span', { className: 'bb-save-state', text: 'Loading…' });
|
||
const saveButton = button('Save', { className: 'bb-btn bb-btn-primary', attrs: { 'aria-keyshortcuts': 'Control+S' } });
|
||
|
||
const runButton = button('Run', { className: 'bb-btn bb-btn-run', title: 'Start the simulation' });
|
||
const pauseButton = button('Pause', { className: 'bb-btn', title: 'Pause the simulation' });
|
||
const stepButton = button('Step', { className: 'bb-btn', title: 'Advance one event' });
|
||
const resetButton = button('Reset', { className: 'bb-btn', title: 'Reload the circuit and clear burned-out parts' });
|
||
const simState = el('span', { className: 'bb-sim-state', text: 'idle' });
|
||
|
||
const speedInput = el('input', {
|
||
className: 'bb-speed',
|
||
attrs: {
|
||
type: 'range', min: '0', max: String(SPEED_STEPS.length - 1),
|
||
step: '1', value: String(DEFAULT_SPEED_INDEX),
|
||
'aria-label': 'Simulation speed'
|
||
}
|
||
});
|
||
const speedLabel = el('span', { className: 'bb-speed-label', text: formatSpeed(SPEED_STEPS[DEFAULT_SPEED_INDEX]) });
|
||
|
||
const zoomOut = button('−', { className: 'bb-btn bb-btn-icon', title: 'Zoom out' });
|
||
const zoomIn = button('+', { className: 'bb-btn bb-btn-icon', title: 'Zoom in' });
|
||
const zoomFit = button('Fit', { className: 'bb-btn', title: 'Fit all boards in view' });
|
||
const zoomLabel = el('span', { className: 'bb-zoom-label', text: '100%' });
|
||
|
||
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', {
|
||
className: 'bb-toolbar',
|
||
children: [
|
||
group('bb-group-project', [title, saveState, saveButton]),
|
||
group('bb-group-sim', [runButton, pauseButton, stepButton, resetButton, simState]),
|
||
group('bb-group-speed', [speedLabel, speedInput]),
|
||
group('bb-group-view', [addBoardButton, zoomOut, zoomLabel, zoomIn, zoomFit])
|
||
]
|
||
});
|
||
root.appendChild(bar);
|
||
|
||
bag.on(saveButton, 'click', () => handlers.onSave());
|
||
bag.on(runButton, 'click', () => handlers.onRun());
|
||
bag.on(pauseButton, 'click', () => handlers.onPause());
|
||
bag.on(stepButton, 'click', () => handlers.onStep());
|
||
bag.on(resetButton, 'click', () => handlers.onReset());
|
||
bag.on(addBoardButton, 'click', () => handlers.onAddBoard());
|
||
bag.on(zoomIn, 'click', () => handlers.onZoom(1.25));
|
||
bag.on(zoomOut, 'click', () => handlers.onZoom(1 / 1.25));
|
||
bag.on(zoomFit, 'click', () => handlers.onZoomFit());
|
||
bag.on(speedInput, 'input', () => {
|
||
const speed = SPEED_STEPS[Number(speedInput.value)] || SPEED_STEPS[DEFAULT_SPEED_INDEX];
|
||
setText(speedLabel, formatSpeed(speed));
|
||
handlers.onSpeed(speed);
|
||
});
|
||
|
||
return {
|
||
get initialSpeed() { return SPEED_STEPS[DEFAULT_SPEED_INDEX]; },
|
||
|
||
setProjectName(name) {
|
||
setText(title, name || 'Breadboard');
|
||
},
|
||
|
||
/**
|
||
* Reflect save state.
|
||
* @param {'clean'|'dirty'|'saving'|'error'} kind
|
||
*/
|
||
setSaveState(kind, text) {
|
||
saveState.className = `bb-save-state bb-save-${kind}`;
|
||
setText(saveState, text);
|
||
saveButton.disabled = kind === 'saving' || kind === 'clean';
|
||
},
|
||
|
||
/**
|
||
* Reflect simulation state. `halted` means the engine refused to run - a
|
||
* 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;
|
||
resetButton.disabled = !available;
|
||
speedInput.disabled = !available;
|
||
|
||
let label = 'idle';
|
||
if (!available) label = 'engine unavailable';
|
||
else if (halted) label = 'halted — fix the fault, then reset';
|
||
else if (running) label = 'running';
|
||
else if (settled) label = 'settled';
|
||
else if (loaded) label = 'paused';
|
||
simState.className = `bb-sim-state${halted ? ' bb-sim-halted' : ''}${running ? ' bb-sim-running' : ''}`;
|
||
setText(simState, label);
|
||
},
|
||
|
||
setZoom(zoom) {
|
||
setText(zoomLabel, `${Math.round(zoom * 100)}%`);
|
||
},
|
||
|
||
destroy() {
|
||
bag.removeAll();
|
||
if (bar.parentNode) bar.parentNode.removeChild(bar);
|
||
}
|
||
};
|
||
}
|
||
|
||
export { SPEED_STEPS };
|