feat: add browser RPN calculator engine and demo

This commit is contained in:
2026-04-22 22:08:01 +02:00
parent 707c6f3f73
commit e23373fee0
4 changed files with 851 additions and 1 deletions
+401
View File
@@ -0,0 +1,401 @@
class RpnCalculator {
static CONSTANTS = {
pi: Math.PI,
e: Math.E,
};
static OPERATIONS = {
add: {
category: 'Arithmetic',
argCount: 2,
aliases: ['+'],
execute: (calc, a, b) => a + b,
},
sub: {
category: 'Arithmetic',
argCount: 2,
aliases: ['-'],
execute: (calc, a, b) => a - b,
},
mul: {
category: 'Arithmetic',
argCount: 2,
aliases: ['*'],
execute: (calc, a, b) => a * b,
},
div: {
category: 'Arithmetic',
argCount: 2,
aliases: ['/'],
execute: (calc, a, b) => {
if (b === 0) {
throw new Error('Division by zero');
}
return a / b;
},
},
mod: {
category: 'Arithmetic',
argCount: 2,
aliases: ['%'],
execute: (calc, a, b) => (a * b) / 100,
},
pow: {
category: 'Arithmetic',
argCount: 2,
aliases: ['^', 'y^x'],
execute: (calc, a, b) => Math.pow(a, b),
},
sqr: {
category: 'Arithmetic',
argCount: 1,
execute: (calc, a) => a * a,
},
neg: {
category: 'Arithmetic',
argCount: 1,
execute: (calc, a) => -a,
},
sqrt: {
category: 'Arithmetic',
argCount: 1,
aliases: ['sqrt(x)'],
execute: (calc, value) => {
if (value < 0) {
throw new Error('Invalid input for sqrt');
}
return Math.sqrt(value);
},
},
recip: {
category: 'Arithmetic',
argCount: 1,
aliases: ['1/x'],
execute: (calc, a) => {
if (a === 0) {
throw new Error('Division by zero');
}
return 1 / a;
},
},
sin: {
category: 'Trigonometry',
argCount: 1,
execute: (calc, a) => Math.sin(calc.toRadians(a)),
},
cos: {
category: 'Trigonometry',
argCount: 1,
execute: (calc, a) => Math.cos(calc.toRadians(a)),
},
tan: {
category: 'Trigonometry',
argCount: 1,
execute: (calc, a) => Math.tan(calc.toRadians(a)),
},
asin: {
category: 'Trigonometry',
argCount: 1,
execute: (calc, a) => {
if (a < -1 || a > 1) {
throw new Error('Invalid input for asin');
}
return calc.toDegrees(Math.asin(a));
},
},
acos: {
category: 'Trigonometry',
argCount: 1,
execute: (calc, a) => {
if (a < -1 || a > 1) {
throw new Error('Invalid input for acos');
}
return calc.toDegrees(Math.acos(a));
},
},
atan: {
category: 'Trigonometry',
argCount: 1,
execute: (calc, a) => calc.toDegrees(Math.atan(a)),
},
log: {
category: 'Arithmetic',
argCount: 1,
execute: (calc, a) => {
if (a <= 0) {
throw new Error('Invalid input for log');
}
return Math.log10(a);
},
},
ln: {
category: 'Arithmetic',
argCount: 1,
execute: (calc, a) => {
if (a <= 0) {
throw new Error('Invalid input for ln');
}
return Math.log(a);
},
},
dup: {
category: 'Stack',
argCount: 1,
execute: (calc, a) => [a, a],
},
drop: {
category: 'Stack',
argCount: 1,
execute: () => [],
},
swap: {
category: 'Stack',
argCount: 2,
execute: (calc, a, b) => [b, a],
},
clear: {
category: 'Stack',
argCount: 0,
execute: (calc) => {
calc.stack.length = 0;
return [];
},
},
enter: {
category: 'Stack',
argCount: 0,
execute: (calc) => {
calc.commitInput();
return [];
},
},
};
static getOperationCategories() {
return ['Stack', 'Arithmetic', 'Trigonometry'];
}
static getOperationsByCategory() {
return {
Stack: ['dup', 'drop', 'swap', 'clear', 'enter'],
Arithmetic: ['add', 'sub', 'mul', 'div', 'mod', 'pow', 'sqr', 'neg', 'sqrt', 'recip', 'log', 'ln'],
Trigonometry: ['sin', 'cos', 'tan', 'asin', 'acos', 'atan'],
};
}
constructor(options = {}) {
this.maxSize = Number.isInteger(options.maxSize) && options.maxSize > 0 ? options.maxSize : 2048;
this.stack = [];
this.inputValue = '';
this.isEditing = false;
this.base = Number.isInteger(options.base) && options.base >= 2 && options.base <= 16 ? options.base : 10;
this.angleMode = ['deg', 'rad', 'grad'].includes(options.angleMode) ? options.angleMode : 'deg';
this.constants = { ...RpnCalculator.CONSTANTS };
const enabled = options.enabledCommands;
const defaultCommands = Object.keys(RpnCalculator.OPERATIONS);
const selectedCommands = Array.isArray(enabled) && enabled.length > 0 ? enabled : defaultCommands;
this.enabledCommands = new Set(selectedCommands.map((name) => this.normalizeCommandName(name)).filter((name) => RpnCalculator.OPERATIONS[name]));
}
toRadians(value) {
if (this.angleMode === 'grad') {
return (value * Math.PI) / 200;
}
return this.angleMode === 'deg' ? (value * Math.PI) / 180 : value;
}
toDegrees(value) {
if (this.angleMode === 'grad') {
return (value * 200) / Math.PI;
}
return this.angleMode === 'deg' ? (value * 180) / Math.PI : value;
}
roundResult(value) {
if (!Number.isFinite(value)) {
return value;
}
const rounded = Math.round((value + Number.EPSILON) * 1e12) / 1e12;
return Object.is(rounded, -0) ? 0 : rounded;
}
formatNumber(value) {
if (!Number.isFinite(value)) {
return String(value);
}
const normalized = this.roundResult(value);
return Number.isInteger(normalized)
? String(normalized)
: normalized.toFixed(12).replace(/\.0+$/, '').replace(/0+$/, '').replace(/\.$/, '');
}
normalizeCommandName(name) {
if (typeof name !== 'string') return '';
const lower = name.toLowerCase();
for (const [key, def] of Object.entries(RpnCalculator.OPERATIONS)) {
if (key === lower || (def.aliases && def.aliases.includes(lower))) return key;
}
return lower;
}
isConstantName(name) {
return typeof name === 'string' && Object.prototype.hasOwnProperty.call(this.constants, name.toLowerCase());
}
push(value) {
if (this.stack.length >= this.maxSize) {
throw new Error('Stack overflow');
}
this.stack.unshift(value);
return value;
}
pop() {
if (this.stack.length === 0) {
throw new Error('Stack underflow');
}
return this.stack.shift();
}
clear() {
this.stack.length = 0;
this.inputValue = '';
this.isEditing = false;
}
swap(index1, index2) {
if (!this.isValidIndex(index1) || !this.isValidIndex(index2)) {
throw new Error('Invalid stack index');
}
const temp = this.stack[index1];
this.stack[index1] = this.stack[index2];
this.stack[index2] = temp;
}
remove(index) {
if (!this.isValidIndex(index)) {
throw new Error('Invalid stack index');
}
this.stack.splice(index, 1);
}
edit(index) {
if (!this.isValidIndex(index)) {
throw new Error('Invalid stack index');
}
this.inputValue = this.formatNumber(this.stack[index]);
this.isEditing = true;
return this.inputValue;
}
isValidIndex(index) {
return Number.isInteger(index) && index >= 0 && index < this.stack.length;
}
commitInput() {
if (!this.isEditing || this.inputValue === '') {
this.isEditing = false;
return null;
}
const value = this.parseInputValue(this.inputValue);
this.push(value);
this.inputValue = '';
this.isEditing = false;
return value;
}
parseInputValue(value) {
if (typeof value !== 'string' || value.trim() === '') {
throw new Error('Invalid input value');
}
const parsed = this.base === 10 ? Number(value) : parseInt(value, this.base);
if (Number.isNaN(parsed)) {
throw new Error('Invalid number');
}
return parsed;
}
input(command) {
if (typeof command !== 'string') {
throw new Error('Command must be a string');
}
const trimmed = command.trim();
if (trimmed === '') return null;
if (this.isInputCharacter(trimmed)) {
this.isEditing = true;
this.inputValue += trimmed;
return this.inputValue;
}
return this.command(trimmed);
}
isInputCharacter(value) {
if (value.length !== 1) return false;
const allowed = '0123456789';
const letters = 'ABCDEFabcdef';
const sign = '+-';
const decimal = '.';
return allowed.includes(value) || letters.includes(value) || sign.includes(value) || decimal === value;
}
command(name, ...args) {
const normalized = this.normalizeCommandName(name);
if (this.isConstantName(normalized)) {
if (this.isEditing) {
this.commitInput();
}
return this.push(this.constants[normalized]);
}
const operation = RpnCalculator.OPERATIONS[normalized];
if (!operation) {
throw new Error(`Unknown command: ${name}`);
}
if (!this.enabledCommands.has(normalized)) {
throw new Error(`Command disabled: ${name}`);
}
if (this.isEditing) {
this.commitInput();
}
const argCount = operation.argCount ?? 0;
if (this.stack.length < argCount) {
throw new Error('Stack underflow');
}
const operands = argCount > 0 ? this.stack.splice(0, argCount).reverse() : [];
const result = operation.execute(this, ...operands, ...args);
if (Array.isArray(result)) {
for (let i = result.length - 1; i >= 0; i -= 1) {
this.push(result[i]);
}
} else if (result !== undefined) {
this.push(result);
}
return result;
}
getOperationsByCategory() {
return RpnCalculator.getOperationsByCategory();
}
getConstants() {
return { ...this.constants };
}
}
if (typeof window !== 'undefined') {
window.RpnCalculator = RpnCalculator;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = RpnCalculator;
}