Files
mtm-rpn-js/samples/calc-01/index.js
T

668 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const calc = new RpnCalculator({ angleMode: 'deg' });
const input = document.getElementById('input');
const screen = document.getElementById('screen');
const stackEl = document.getElementById('stack');
const displayEl = document.getElementById('display');
const errorEl = document.getElementById('error');
const modeLabel = document.getElementById('modeLabel');
const angleMode = document.getElementById('angleMode');
const modeMenuButton = document.getElementById('modeMenuButton');
const modeMenu = document.getElementById('modeMenu');
const constsMenuButton = document.getElementById('constsMenuButton');
const constsMenu = document.getElementById('constsMenu');
const keyLayouts = {
functions: [
[
{ type: 'command', value: 'sqrt', label: '√x', className: 'key-function' },
{ type: 'command', value: 'pow', label: 'yˣ', className: 'key-function' },
{ type: 'command', value: 'sqr', label: 'x²', className: 'key-function' },
{ type: 'command', value: 'recip', label: '1/x', className: 'key-function' },
],
[
{ type: 'command', value: 'log', label: 'log', className: 'key-function' },
{ type: 'command', value: 'ln', label: 'ln', className: 'key-function' },
null,
{ type: 'command', value: 'mod', label: '%', className: 'key-function' },
],
[
{ type: 'command', value: 'sin', label: 'sin', className: 'key-function' },
{ type: 'command', value: 'cos', label: 'cos', className: 'key-function' },
{ type: 'command', value: 'tan', label: 'tan', className: 'key-function' },
null,
],
[
{ type: 'command', value: 'asin', label: 'asin', className: 'key-function' },
{ type: 'command', value: 'acos', label: 'acos', className: 'key-function' },
{ type: 'command', value: 'atan', label: 'atan', className: 'key-function' },
null,
],
],
numbers: [
[
{ type: 'input', value: '7', label: '7', className: 'key-number' },
{ type: 'input', value: '8', label: '8', className: 'key-number' },
{ type: 'input', value: '9', label: '9', className: 'key-number' },
],
[
{ type: 'input', value: '4', label: '4', className: 'key-number' },
{ type: 'input', value: '5', label: '5', className: 'key-number' },
{ type: 'input', value: '6', label: '6', className: 'key-number' },
],
[
{ type: 'input', value: '1', label: '1', className: 'key-number' },
{ type: 'input', value: '2', label: '2', className: 'key-number' },
{ type: 'input', value: '3', label: '3', className: 'key-number' },
],
[
{ type: 'input', value: '0', label: '0', className: 'key-number' },
{ type: 'input', value: '.', label: '.', className: 'key-number' },
{ type: 'command', value: 'neg', label: '±', className: 'key-number' },
],
],
operators: [
[
{ type: 'command', value: 'div', label: '÷', className: 'key-operator' },
null,
],
[
{ type: 'command', value: 'mul', label: '×', className: 'key-operator' },
null,
],
[
{ type: 'command', value: 'sub', label: '', className: 'key-operator' },
{ type: 'command', value: 'enter', label: 'ENTER', className: 'key-enter' },
],
[
{ type: 'command', value: 'add', label: '+', className: 'key-operator' },
null,
],
],
};
const topButtons = {
consts: [
{ type: 'command', value: 'pi', label: 'π', className: 'key-function' },
{ type: 'command', value: 'e', label: 'e', className: 'key-function' },
],
del: { type: 'command', value: 'clear', label: 'DEL', className: 'key-danger' },
backspace: { type: 'action', value: 'backspace', label: '⌫', className: 'key-soft-danger' },
escape: { type: 'action', value: 'escape', label: 'ESC', className: 'key-cancel' },
};
let stackCursor = null;
let isMovingStackItem = false;
let stackSnapshotBeforeMove = null;
let stackViewOffset = 0;
let editRestoreValue = null;
function handleEscapeAction() {
if (calc.isEditing) {
if (editRestoreValue !== null) {
calc.push(editRestoreValue);
editRestoreValue = null;
}
calc.inputValue = '';
calc.isEditing = false;
syncInputFromState();
render();
return;
}
if (isMovingStackItem) {
cancelMoveMode();
clearStackSelection();
render();
return;
}
if (hasStackSelection()) {
clearStackSelection();
render();
}
}
function pressKey(key) {
clearStackSelection();
editXWithKey(key);
render();
}
function handleBackspaceAction() {
if (calc.isEditing) {
editXWithKey('Backspace');
render();
return;
}
execute('drop');
}
function closePopupMenus() {
modeMenu.hidden = true;
constsMenu.hidden = true;
modeMenuButton.setAttribute('aria-expanded', 'false');
constsMenuButton.setAttribute('aria-expanded', 'false');
}
function togglePopupMenu(menuName) {
const isModeMenu = menuName === 'mode';
const targetMenu = isModeMenu ? modeMenu : constsMenu;
const targetButton = isModeMenu ? modeMenuButton : constsMenuButton;
const otherMenu = isModeMenu ? constsMenu : modeMenu;
const otherButton = isModeMenu ? constsMenuButton : modeMenuButton;
const willOpen = targetMenu.hidden;
otherMenu.hidden = true;
otherButton.setAttribute('aria-expanded', 'false');
targetMenu.hidden = !willOpen;
targetButton.setAttribute('aria-expanded', String(willOpen));
}
function createButton(cell) {
if (!cell) {
const spacer = document.createElement('div');
spacer.className = 'key-spacer';
spacer.setAttribute('aria-hidden', 'true');
return spacer;
}
const button = document.createElement('button');
button.type = 'button';
button.textContent = cell.label;
button.className = cell.className;
button.addEventListener('click', () => {
focusScreen();
closePopupMenus();
if (cell.type === 'input') {
pressKey(cell.value);
return;
}
if (cell.type === 'action') {
if (cell.value === 'escape') {
handleEscapeAction();
return;
}
if (cell.value === 'backspace') {
handleBackspaceAction();
return;
}
if (cell.value === 'setModeDeg' || cell.value === 'setModeRad' || cell.value === 'setModeGrad') {
angleMode.value = cell.value === 'setModeDeg' ? 'deg' : (cell.value === 'setModeRad' ? 'rad' : 'grad');
angleMode.dispatchEvent(new Event('change'));
return;
}
}
execute(cell.value);
});
return button;
}
function renderKeyLayout(container, rows) {
container.innerHTML = '';
rows.flat().forEach((cell) => {
container.appendChild(createButton(cell));
});
}
function getStackValue(index) {
return calc.isValidIndex(index) ? calc.stack[index] : undefined;
}
function getDisplayValue(index) {
if (calc.isEditing) {
if (index === 0) {
return calc.inputValue;
}
return getStackValue(index - 1);
}
return getStackValue(index);
}
function hasStackSelection() {
return stackCursor !== null && calc.isValidIndex(stackCursor);
}
function clearStackSelection() {
stackCursor = null;
isMovingStackItem = false;
stackSnapshotBeforeMove = null;
stackViewOffset = 0;
}
function ensureValidSelection() {
if (hasStackSelection()) {
return;
}
stackCursor = calc.isValidIndex(0) ? 0 : null;
}
function beginMoveMode() {
if (!hasStackSelection()) {
return;
}
isMovingStackItem = true;
stackSnapshotBeforeMove = calc.stack.slice();
}
function commitMoveMode() {
isMovingStackItem = false;
stackSnapshotBeforeMove = null;
}
function cancelMoveMode() {
if (!isMovingStackItem || !stackSnapshotBeforeMove) {
return;
}
const snapshot = stackSnapshotBeforeMove.slice();
calc.clear();
for (let index = snapshot.length - 1; index >= 0; index -= 1) {
calc.push(snapshot[index]);
}
isMovingStackItem = false;
stackSnapshotBeforeMove = null;
stackCursor = calc.isValidIndex(stackCursor) ? stackCursor : (calc.isValidIndex(0) ? 0 : null);
syncInputFromState();
}
function reactivateEditOnX() {
clearStackSelection();
if (calc.isValidIndex(0)) {
const value = getStackValue(0);
calc.remove(0);
calc.inputValue = calc.formatNumber(value);
calc.isEditing = true;
editRestoreValue = value;
} else {
calc.inputValue = '';
calc.isEditing = true;
editRestoreValue = null;
}
syncInputFromState();
}
function moveStackSelection(direction) {
if (!hasStackSelection()) {
if (direction === 'up') {
ensureValidSelection();
} else if (direction === 'down') {
reactivateEditOnX();
}
return;
}
const nextIndex = direction === 'up' ? stackCursor + 1 : stackCursor - 1;
if (calc.isValidIndex(nextIndex)) {
stackCursor = nextIndex;
}
}
function moveStackItem(direction) {
if (!hasStackSelection()) {
return;
}
const targetIndex = direction === 'up' ? stackCursor + 1 : stackCursor - 1;
if (!calc.isValidIndex(targetIndex)) {
return;
}
calc.swap(stackCursor, targetIndex);
stackCursor = targetIndex;
}
function getVisibleStackIndex(visualLine) {
return stackViewOffset + visualLine;
}
function clampStackViewOffset() {
const maxOffset = Math.max(0, calc.stack.length - 4);
if (stackViewOffset < 0) {
stackViewOffset = 0;
} else if (stackViewOffset > maxOffset) {
stackViewOffset = maxOffset;
}
}
function ensureSelectionVisible() {
if (!hasStackSelection()) {
stackViewOffset = 0;
return;
}
if (stackCursor < stackViewOffset) {
stackViewOffset = stackCursor;
} else if (stackCursor > stackViewOffset + 3) {
stackViewOffset = stackCursor - 3;
}
clampStackViewOffset();
}
function render() {
const names = ['T', 'Z', 'Y', 'X'];
const lines = [];
const showStackIndexes = hasStackSelection() || isMovingStackItem;
clampStackViewOffset();
ensureSelectionVisible();
for (let visualLine = 3; visualLine >= 0; visualLine -= 1) {
const stackIndex = getVisibleStackIndex(visualLine);
const value = getDisplayValue(stackIndex);
const isSelected = stackCursor === stackIndex;
const classes = ['stack-line'];
const label = showStackIndexes ? String(stackIndex) : names[3 - visualLine];
if (isSelected) {
classes.push(isMovingStackItem ? 'moving' : 'selected');
}
lines.push(`<div class="${classes.join(' ')}"><div class="label">${label}</div><div>${value !== undefined && value !== '' ? calc.formatNumber(value) : ''}</div></div>`);
}
stackEl.innerHTML = lines.join('');
if (calc.isEditing) {
displayEl.textContent = `ENTERING: ${calc.inputValue}`;
} else if (isMovingStackItem && hasStackSelection()) {
displayEl.textContent = `MOVING: ${stackCursor}`;
} else if (hasStackSelection()) {
displayEl.textContent = `SELECTED: ${stackCursor}`;
} else {
displayEl.textContent = 'READY';
}
modeLabel.textContent = calc.angleMode;
modeMenuButton.textContent = calc.angleMode.toUpperCase();
angleMode.value = calc.angleMode;
errorEl.textContent = '';
}
function pushEditingValueIfNeeded() {
if (!calc.isEditing) return;
if (calc.inputValue !== '') {
const value = calc.parseInputValue(calc.inputValue);
calc.push(value);
}
calc.inputValue = '';
calc.isEditing = false;
editRestoreValue = null;
syncInputFromState();
}
function execute(name) {
try {
if (name === 'enter') {
if (calc.isEditing) {
pushEditingValueIfNeeded();
} else if (calc.isValidIndex(0)) {
calc.push(getStackValue(0));
}
} else if (name === 'swap') {
pushEditingValueIfNeeded();
clearStackSelection();
if (calc.isValidIndex(1)) calc.swap(0, 1);
} else if (name === 'drop') {
pushEditingValueIfNeeded();
if (hasStackSelection()) {
calc.remove(stackCursor);
stackCursor = calc.isValidIndex(stackCursor) ? stackCursor : (calc.isValidIndex(stackCursor - 1) ? stackCursor - 1 : null);
} else if (calc.isValidIndex(0)) {
calc.remove(0);
}
commitMoveMode();
} else if (name === 'clear') {
calc.clear();
clearStackSelection();
} else {
pushEditingValueIfNeeded();
clearStackSelection();
calc.command(name);
}
syncInputFromState();
render();
} catch (error) {
errorEl.textContent = error.message;
}
}
function isInputChar(key) {
return /^[0-9a-fA-F.]$/.test(key);
}
function shouldIgnoreKeyboardEvent(event) {
const target = event.target;
if (!target) return false;
const tagName = target.tagName;
return (
tagName === 'INPUT' ||
tagName === 'TEXTAREA' ||
tagName === 'SELECT' ||
target.isContentEditable
);
}
function getKeyboardAction(event) {
const numpadMap = {
Numpad0: { type: 'input', value: '0' },
Numpad1: { type: 'input', value: '1' },
Numpad2: { type: 'input', value: '2' },
Numpad3: { type: 'input', value: '3' },
Numpad4: { type: 'input', value: '4' },
Numpad5: { type: 'input', value: '5' },
Numpad6: { type: 'input', value: '6' },
Numpad7: { type: 'input', value: '7' },
Numpad8: { type: 'input', value: '8' },
Numpad9: { type: 'input', value: '9' },
NumpadDecimal: { type: 'input', value: '.' },
NumpadAdd: { type: 'command', value: 'add' },
NumpadSubtract: { type: 'command', value: 'sub' },
NumpadMultiply: { type: 'command', value: 'mul' },
NumpadDivide: { type: 'command', value: 'div' },
NumpadEnter: { type: 'command', value: 'enter' },
};
if (numpadMap[event.code]) {
return numpadMap[event.code];
}
if (isInputChar(event.key)) {
return { type: 'input', value: event.key };
}
const keyMap = {
Enter: { type: 'enterKey' },
Backspace: { type: 'stackOrEdit', value: 'drop' },
Delete: { type: 'command', value: 'clear' },
Escape: { type: 'escapeKey' },
ArrowUp: { type: 'stackArrow', value: 'up' },
ArrowDown: { type: 'stackArrow', value: 'down' },
ArrowRight: { type: 'command', value: 'swap' },
'+': { type: 'command', value: 'add' },
'-': { type: 'command', value: 'sub' },
'*': { type: 'command', value: 'mul' },
'/': { type: 'command', value: 'div' },
'%': { type: 'command', value: 'mod' },
'^': { type: 'command', value: 'pow' },
q: { type: 'command', value: 'sqr' },
n: { type: 'command', value: 'neg' },
r: { type: 'command', value: 'sqrt' },
i: { type: 'command', value: 'recip' },
g: { type: 'command', value: 'log' },
l: { type: 'command', value: 'ln' },
s: { type: 'command', value: 'sin' },
c: { type: 'command', value: 'cos' },
S: { type: 'command', value: 'asin' },
C: { type: 'command', value: 'acos' },
x: { type: 'stackSelect', value: 0 },
y: { type: 'stackSelect', value: 1 },
z: { type: 'stackSelect', value: 2 },
t: { type: 'stackSelect', value: 3 },
X: { type: 'stackSelect', value: 0 },
Y: { type: 'stackSelect', value: 1 },
Z: { type: 'stackSelect', value: 2 },
T: { type: 'stackSelect', value: 3 },
};
return keyMap[event.key] || null;
}
function focusScreen() {
screen.focus();
}
function syncInputFromState() {
input.value = calc.inputValue;
}
function editXWithKey(key) {
if (!calc.isEditing) {
pushEditingValueIfNeeded();
calc.isEditing = true;
calc.inputValue = '';
editRestoreValue = null;
}
if (key === 'Backspace') {
calc.inputValue = calc.inputValue.slice(0, -1);
} else {
calc.inputValue += key;
}
if (calc.inputValue === '') {
calc.isEditing = false;
editRestoreValue = null;
}
syncInputFromState();
}
function handleKeydown(event) {
if (shouldIgnoreKeyboardEvent(event)) {
return;
}
const action = getKeyboardAction(event);
if (!action) {
return;
}
try {
if (action.type === 'escapeKey') {
event.preventDefault();
handleEscapeAction();
return;
}
event.preventDefault();
if (action.type === 'input') {
clearStackSelection();
editXWithKey(action.value);
render();
return;
}
if (action.type === 'stackSelect') {
if (calc.isEditing || isMovingStackItem) {
return;
}
stackCursor = calc.isValidIndex(action.value) ? action.value : null;
render();
return;
}
if (action.type === 'stackArrow') {
if (calc.isEditing) {
render();
return;
}
if (isMovingStackItem) {
moveStackItem(action.value);
} else {
moveStackSelection(action.value);
}
render();
return;
}
if (action.type === 'stackOrEdit') {
if (calc.isEditing) {
editXWithKey('Backspace');
render();
} else {
execute(action.value);
}
return;
}
if (action.type === 'enterKey') {
if (hasStackSelection()) {
if (isMovingStackItem) {
commitMoveMode();
} else {
beginMoveMode();
}
render();
return;
}
execute('enter');
return;
}
if (action.type === 'command') {
execute(action.value);
}
} catch (error) {
errorEl.textContent = error.message;
}
}
window.addEventListener('keydown', handleKeydown);
screen.addEventListener('click', () => {
closePopupMenus();
focusScreen();
});
window.addEventListener('load', focusScreen);
document.addEventListener('click', (event) => {
if (!event.target.closest('#modeMenuWrap') && !event.target.closest('#constsMenuWrap')) {
closePopupMenus();
}
});
modeMenuButton.addEventListener('click', (event) => {
event.stopPropagation();
togglePopupMenu('mode');
});
constsMenuButton.addEventListener('click', (event) => {
event.stopPropagation();
togglePopupMenu('consts');
});
angleMode.addEventListener('change', (event) => {
calc.angleMode = event.target.value;
closePopupMenus();
render();
});
renderKeyLayout(document.getElementById('functionsButtons'), keyLayouts.functions);
renderKeyLayout(document.getElementById('numbersButtons'), keyLayouts.numbers);
renderKeyLayout(document.getElementById('operatorsButtons'), keyLayouts.operators);
renderKeyLayout(modeMenu, [[
{ type: 'action', value: 'setModeDeg', label: 'Degrees', className: 'key-function' },
{ type: 'action', value: 'setModeRad', label: 'Radians', className: 'key-function' },
{ type: 'action', value: 'setModeGrad', label: 'Grads', className: 'key-function' },
]]);
renderKeyLayout(constsMenu, [topButtons.consts]);
document.getElementById('deleteButton').appendChild(createButton(topButtons.del));
document.getElementById('backspaceButton').appendChild(createButton(topButtons.backspace));
document.getElementById('escapeButton').appendChild(createButton(topButtons.escape));
render();
focusScreen();