feat: add browser RPN calculator engine and demo
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user