diff --git a/.continue/rules/project.md b/.continue/rules/project.md new file mode 100644 index 0000000..3a81a37 --- /dev/null +++ b/.continue/rules/project.md @@ -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. + diff --git a/README.md b/README.md index c506d0e..67cfc97 100644 --- a/README.md +++ b/README.md @@ -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 + + +``` + +### 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. diff --git a/samples/dev/rpn-example.html b/samples/dev/rpn-example.html new file mode 100644 index 0000000..f33d1e0 --- /dev/null +++ b/samples/dev/rpn-example.html @@ -0,0 +1,308 @@ + + + + + + RPN Calculator Demo + + + +
+
+

RPN Calculator Demo

+ +
+
+ +
+ + + +
+ +
+
Mode:
+
Base:
+
+ +
Constants
+
+ + +
+ +
Stack
+
+ + + + + +
+ +
Arithmetic
+
+ + + + + + + + + + + + +
+ +
Trigonometry
+
+ + + + + + +
+ +

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.

+
+
+ + + + + diff --git a/src/rpn-calculator.js b/src/rpn-calculator.js new file mode 100644 index 0000000..106eed2 --- /dev/null +++ b/src/rpn-calculator.js @@ -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; +}