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
+20
View File
@@ -0,0 +1,20 @@
# Project rules — RPN Virtual Calculator
- Build a browser-friendly RPN calculator as a JavaScript class, preferably in a single file.
- Constructor options: `maxSize` (default 2048), `base` (default 10), `angleMode` (`deg` default; also `rad` and `grad`), `enabledCommands`.
- Available constants: `pi`, `e`.
- Public API is generic only: `push`, `pop`, `clear`, `swap(index1, index2)`, `remove(index)`, `edit(index)`, `isValidIndex(index)`, `input(command)`, and `command(name, ...args)`.
- Expose `inputValue` as a string and `isEditing` as a boolean.
- Supported operations are centralized in one dictionary with `argCount`, category, and aliases.
- Categories are limited to `Stack`, `Arithmetic`, and `Trigonometry`.
- Current commands: `add`, `sub`, `mul`, `div`, `mod`, `pow`, `sqr`, `neg`, `sqrt`, `recip`, `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `log`, `ln`, `dup`, `drop`, `swap`, `clear`, `enter`.
- Aliases: `+`, `-`, `*`, `/`, `%`, `^`, `y^x`, `1/x`.
- `mod` is the percentage operator: `a b % => (a * b) / 100`.
- `sqrt`, `asin`, `acos`, `log`, and `ln` must throw clear, explicit domain errors.
- `log` uses `Math.log10`; `ln` uses `Math.log`.
- Trig functions use degrees in the demo; `sin`, `cos`, `tan` convert to radians, inverse trig returns degrees.
- `inputValue` stays a string to keep hexadecimal input possible later.
- Example HTML must group buttons by `Stack`, `Arithmetic`, and `Trigonometry`, and call `command(...)`.
- Keep code names, categories, and API identifiers in English.
- Keep this file updated after each project change using the provided editing tools.
+122 -1
View File
@@ -1,2 +1,123 @@
# mtm-rpn-js
# RPN Virtual Calculator
A browser-friendly RPN calculator implemented as a single JavaScript class, with a simple API and an example HTML interface.
## Overview
The project includes:
- `rpn-calculator.js`: calculator engine
- `rpn-example.html`: browser demo
## Highlights
- Self-contained JavaScript class
- Configurable stack size (`maxSize`, default: 2048)
- Configurable numeric base (`base`, default: 10)
- Configurable angle mode (`angleMode`, default: `deg`)
- Optional command enabling via `enabledCommands`
- Generic public API centered on `push`, `pop`, `clear`, `swap`, `remove`, `edit`, `isValidIndex`, `input`, and `command`
- `inputValue` stays a string to keep hexadecimal input possible later
- `isEditing` tracks typed input mode
- Operations are centralized with `argCount`, category, and aliases
- Categories are limited to `Stack`, `Arithmetic`, and `Trigonometry`
- Clear domain errors for invalid inputs
- Degree-based trig demo in the example HTML
## Quick start
### In the browser
```html
<script src="rpn-calculator.js"></script>
<script>
const calc = new RpnCalculator();
calc.input('1');
calc.input('2');
calc.command('enter');
calc.command('add');
console.log(calc.stack);
</script>
```
### With options
```js
const calc = new RpnCalculator({
maxSize: 1024,
base: 10,
angleMode: 'deg',
enabledCommands: ['add', 'sub', 'mul', 'div', 'enter', 'clear'],
});
```
## Public API
### Properties
- `stack`: current stack, with the top item at index `0`
- `inputValue`: input text as a string
- `isEditing`: whether input is currently being edited
### Generic methods
- `push(value)`
- `pop()`
- `clear()`
- `swap(index1, index2)`
- `remove(index)`
- `edit(index)`
- `isValidIndex(index)`
- `input(command)`
- `command(name, ...args)`
## Supported commands
### Stack
- `enter`
- `dup`
- `drop`
- `swap`
- `clear`
### Arithmetic
- `add` / `+`
- `sub` / `-`
- `mul` / `*`
- `div` / `/`
- `mod` / `%`
- `pow` / `^` / `y^x`
- `sqr`
- `neg`
- `sqrt`
- `recip` / `1/x`
- `log`
- `ln`
### Trigonometry
- `sin`
- `cos`
- `tan`
- `asin`
- `acos`
- `atan`
## Important behavior
- `%` behaves as the RPN percentage operator: `a b % => (a * b) / 100`
- `sqrt`, `asin`, `acos`, `log`, and `ln` throw clear errors for invalid inputs
- `log` uses `Math.log10`
- `ln` uses `Math.log`
- `sin`, `cos`, and `tan` convert degrees to radians in the default demo
- `asin`, `acos`, and `atan` return degrees in `deg` mode
## Example HTML
The example UI groups buttons into `Stack`, `Arithmetic`, and `Trigonometry` sections, and calls `command(...)` to execute operations.
## License
To be completed according to your project.
+308
View File
@@ -0,0 +1,308 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RPN Calculator Demo</title>
<style>
:root {
color-scheme: light dark;
--bg: #f4f4f4;
--panel: #ffffff;
--text: #111;
--muted: #666;
--button: #e9e9e9;
--button-text: #111;
--border: #d0d0d0;
--accent: #0a7;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111;
--panel: #1a1a1a;
--text: #f3f3f3;
--muted: #aaa;
--button: #2a2a2a;
--button-text: #f3f3f3;
--border: #333;
--accent: #3dc;
}
}
body {
margin: 0;
font-family: Arial, sans-serif;
background: var(--bg);
color: var(--text);
}
.app {
max-width: 860px;
margin: 24px auto;
padding: 16px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
}
.display {
background: #000;
color: #0f0;
border-radius: 10px;
padding: 12px;
font-family: monospace;
min-height: 72px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
gap: 8px;
margin-top: 12px;
}
button, select, input {
border: 1px solid var(--border);
background: var(--button);
color: var(--button-text);
border-radius: 8px;
padding: 10px 12px;
font-size: 15px;
}
button {
cursor: pointer;
}
input, select {
width: 100%;
box-sizing: border-box;
background: var(--panel);
color: var(--text);
font-size: 16px;
}
.row {
display: grid;
grid-template-columns: 1fr 180px;
gap: 12px;
margin-top: 12px;
align-items: end;
}
.stack {
margin-top: 12px;
font-family: monospace;
line-height: 1.5;
}
.muted {
color: var(--muted);
font-size: 14px;
}
.section-title {
margin: 16px 0 8px;
font-size: 14px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.status {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-top: 12px;
color: var(--muted);
font-size: 14px;
}
.badge {
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 10px;
background: rgba(0, 0, 0, 0.04);
}
.accent {
color: var(--accent);
}
</style>
</head>
<body>
<div class="app">
<div class="card">
<h1>RPN Calculator Demo</h1>
<div id="display" class="display"></div>
<div id="stack" class="stack"></div>
<div class="row">
<label>
<div class="section-title">Input</div>
<input id="input" type="text" placeholder="Type a number, pi, e, or a command, then press Enter" autocomplete="off">
</label>
<label>
<div class="section-title">Angle mode</div>
<select id="angleMode">
<option value="deg">Degrees</option>
<option value="rad">Radians</option>
<option value="grad">Grads</option>
</select>
</label>
</div>
<div class="status">
<div class="badge">Mode: <span id="angleModeLabel" class="accent"></span></div>
<div class="badge">Base: <span id="baseLabel" class="accent"></span></div>
</div>
<div class="section-title">Constants</div>
<div class="grid">
<button data-const="pi">pi</button>
<button data-const="e">e</button>
</div>
<div class="section-title">Stack</div>
<div class="grid">
<button data-cmd="enter">Enter</button>
<button data-cmd="dup">Dup</button>
<button data-cmd="swap">Swap top 2</button>
<button data-cmd="drop">Drop</button>
<button data-cmd="clear">Clear</button>
</div>
<div class="section-title">Arithmetic</div>
<div class="grid">
<button data-cmd="add">+</button>
<button data-cmd="sub"></button>
<button data-cmd="mul">×</button>
<button data-cmd="div">÷</button>
<button data-cmd="mod">%</button>
<button data-cmd="pow">y^x</button>
<button data-cmd="sqr"></button>
<button data-cmd="neg">±</button>
<button data-cmd="sqrt">sqrt</button>
<button data-cmd="recip">1/x</button>
<button data-cmd="log">log</button>
<button data-cmd="ln">ln</button>
</div>
<div class="section-title">Trigonometry</div>
<div class="grid">
<button data-cmd="sin">sin</button>
<button data-cmd="cos">cos</button>
<button data-cmd="tan">tan</button>
<button data-cmd="asin">asin</button>
<button data-cmd="acos">acos</button>
<button data-cmd="atan">atan</button>
</div>
<p class="muted">Tip: trig functions follow the selected angle mode. Domain errors are reported with clear messages. sqrt computes the square root of the top stack value.</p>
</div>
</div>
<script src="../../src/rpn-calculator.js"></script>
<script>
const enabledCommands = ['add', 'sub', 'mul', 'div', 'mod', 'pow', 'sqr', 'neg', 'sqrt', 'recip', 'sin', 'cos', 'tan', 'asin', 'acos', 'atan', 'log', 'ln', 'dup', 'swap', 'drop', 'clear', 'enter'];
const calc = new RpnCalculator({ angleMode: 'deg', enabledCommands });
const display = document.getElementById('display');
const stack = document.getElementById('stack');
const input = document.getElementById('input');
const angleMode = document.getElementById('angleMode');
const angleModeLabel = document.getElementById('angleModeLabel');
const baseLabel = document.getElementById('baseLabel');
function render() {
display.textContent = calc.isEditing ? `Editing: ${calc.inputValue}` : 'Ready';
stack.innerHTML = calc.stack.length
? calc.stack.map((value, index) => `<div>${index}: ${value}</div>`).join('')
: '<span class="muted">Stack empty</span>';
angleMode.value = calc.angleMode;
angleModeLabel.textContent = calc.angleMode;
baseLabel.textContent = String(calc.base);
}
function commitInput() {
if (input.value.trim() !== '') {
calc.inputValue = input.value;
calc.isEditing = true;
calc.command('enter');
input.value = '';
}
}
function runCommand(name) {
if (name === 'swap') {
if (calc.stack.length >= 2) calc.swap(0, 1);
return;
}
if (name === 'enter') {
commitInput();
return;
}
calc.command(name);
}
input.addEventListener('input', (event) => {
calc.inputValue = event.target.value;
calc.isEditing = event.target.value.length > 0;
render();
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
try {
commitInput();
render();
} catch (error) {
alert(error.message);
}
}
});
angleMode.addEventListener('change', (event) => {
calc.angleMode = ['deg', 'rad', 'grad'].includes(event.target.value) ? event.target.value : 'deg';
render();
});
document.querySelectorAll('button[data-cmd]').forEach((button) => {
button.addEventListener('click', () => {
try {
runCommand(button.dataset.cmd);
render();
} catch (error) {
alert(error.message);
}
});
});
document.querySelectorAll('button[data-const]').forEach((button) => {
button.addEventListener('click', () => {
try {
calc.inputValue = '';
calc.isEditing = false;
calc.command(button.dataset.const);
input.value = '';
render();
} catch (error) {
alert(error.message);
}
});
});
render();
</script>
</body>
</html>
+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;
}