Compare commits

..

100 Commits

Author SHA1 Message Date
matmoul 9abdc33713 fix(calc-02): update stack labels for navigation and move modes 2026-05-17 05:34:06 +02:00
matmoul 6dd9550890 feat: add stack navigation and reordering controls 2026-05-17 04:24:43 +02:00
matmoul f44fb8c252 docs: align calculator visual portrait table cell 2026-05-17 03:23:47 +02:00
matmoul 5cc97f754d fix(calc-02): restore edited value on cancel
Canceling stack-top editing now pushes the original value back before
leaving edit mode, including Escape and ArrowDown handling.
2026-05-17 01:31:43 +02:00
matmoul 736154110d fix: correct calc-02 function key shortcuts 2026-05-17 00:38:32 +02:00
matmoul 62a0f447c5 feat: update calculator function key labels and shortcuts
Add titles for function and trig buttons to expose keyboard hints, and remap reciprocal/power10 shortcuts to match the new key layout.
Update the portrait visual spec to reflect the revised keypad and function ordering.
2026-05-17 00:35:50 +02:00
matmoul 16b567731f docs: update calc-02 shortcut reference and key mappings 2026-05-17 00:14:24 +02:00
matmoul b710d5f0eb refactor(calc-02): simplify stack display and edit cursor handling 2026-05-16 23:43:49 +02:00
matmoul 07a4c533fb fix: preserve full calc-02 stack while limiting display to top 4
Keep the calculator stack unlimited in the demo and only constrain the rendered stack view. Also restore the edit cursor position when pulling a value into the input from the stack.
2026-05-16 23:40:13 +02:00
matmoul acc075d30c feat(calc-02): add cursor-aware editing in the X stack display 2026-05-16 23:30:50 +02:00
matmoul 6a37c7c1e8 feat: add pow10 and exp arithmetic operations 2026-05-16 23:15:22 +02:00
matmoul 5f2d18df99 fix(calc-02): constrain keypad area with internal scrolling
Wrap the keypad, function, and trig panels in a dedicated scroll area so the calculator shell can use a fixed grid layout without overflowing the viewport. Also update panel styling to preserve spacing and scrollbar behavior.
2026-05-16 22:40:23 +02:00
matmoul c69380d057 refactor(samples): reorder calculator keypad keys 2026-05-16 21:58:51 +02:00
matmoul c11a75d7f7 fix: bust calc sample stylesheet cache 2026-05-16 21:52:05 +02:00
matmoul 813fae1b61 refactor(calc-02): simplify calculator layout structure
Move the display and button panels into a single wrapper and rely on
viewport-height sizing with hidden page overflow to keep the calculator
contained. Also trim redundant panel-specific layout rules and styling.
2026-05-16 21:41:47 +02:00
matmoul 208db58fe7 fix: make calc-02 panels stretch and scroll within container 2026-05-16 20:24:43 +02:00
matmoul ea6401a006 fix: constrain calculator width on calc-02 sample 2026-05-16 20:17:19 +02:00
matmoul ea61ec17d5 fix: remove portrait class from calc-02 calculator shell 2026-05-16 19:44:09 +02:00
matmoul a4bbffe65b feat: replace calculator enter key label with symbol 2026-05-16 18:41:03 +02:00
matmoul a24142cd72 docs: remove obsolete temporary README file 2026-05-16 05:23:42 +02:00
matmoul 4df99d0738 docs: update README for default demo and command aliases 2026-05-16 05:01:17 +02:00
matmoul 1cef2d2d6a fix(calc-02): reorder display buttons in portrait layout 2026-05-16 04:44:18 +02:00
matmoul 442a97d573 fix: reorder calc display buttons in portrait layout 2026-05-16 04:34:20 +02:00
matmoul 44ce67c5cc docs: align calc-02 visual portrait tables 2026-05-16 04:20:22 +02:00
matmoul a7411243de fix: reorder display buttons in calc-02 sample 2026-05-16 04:14:28 +02:00
matmoul f8de4e1709 docs: update project memory for calc-02 UI and command notes 2026-05-16 04:10:19 +02:00
matmoul 003d4fde1b feat(calc-02): add shared popup menus for mode and constants
Refactor the calc-02 demo to use a single popup menu component for angle mode and constants, align the menus to their trigger buttons, and update the README/project notes to reflect the portrait-first demo layout and constant API.
2026-05-16 04:04:01 +02:00
matmoul e5f50aee0a fix: reorder calc-02 display buttons 2026-05-16 03:44:20 +02:00
matmoul 6a7a60a9bc fix: align calc menus with trigger width 2026-05-16 03:41:25 +02:00
matmoul a37ed59b40 fix: reorder calc-02 display buttons 2026-05-16 03:37:04 +02:00
matmoul c09fdc7e0f refactor(calc-02): simplify display and button styling
Remove layered shadows and pressed-state transforms from the calculator UI, and update the stack copy icon markup and active feedback to match the new flat design.
2026-05-16 03:21:49 +02:00
matmoul 849170ea49 docs: update calc-02 portrait visual reference
Move the portrait layout documentation to samples/calc-02/visual-portrait.md and refresh the project memory note to point to the new reference.
2026-05-16 03:11:00 +02:00
matmoul 0100da80bb fix(calc-02): adjust keypad grid sizing 2026-05-16 03:02:43 +02:00
matmoul e1fd730db5 fix: remove panel border from calc sample 2026-05-16 02:48:59 +02:00
matmoul ba53f51bf7 refactor(samples): simplify calc-02 background styling 2026-05-16 02:48:00 +02:00
matmoul 534bbc0afb feat: add dynamic constant management to the calculator core 2026-05-16 02:23:20 +02:00
matmoul 2504716c64 fix: tighten calc stack display layout
Reorder the stack cell grid so the copy button sits beside the label and reduce spacing to better align the value column.
2026-05-16 02:04:47 +02:00
matmoul 6c8c032f7a fix: stabilize calculator display sizing 2026-05-16 02:02:45 +02:00
matmoul d8d0556822 style: tighten calculator sample layout spacing 2026-05-16 01:48:52 +02:00
matmoul 426fd326a5 fix(calc-02): simplify button spacing and menu background 2026-05-16 01:43:00 +02:00
matmoul 5364208491 feat(calc-02): refine display button layout and icon styling 2026-05-16 01:38:53 +02:00
matmoul 1e703bebe8 fix(calc-02): adjust display button layout and enter key spacing 2026-05-16 01:28:30 +02:00
matmoul 54797f9dd9 refactor(samples/calc-02): simplify layout to portrait-only single column
Remove the responsive two-column desktop arrangement and make the calculator stack vertically with a 4-column display button row that preserves alignment. Also align the display buttons with the shared base button styling.
2026-05-16 01:16:47 +02:00
matmoul 75bf6d69df fix(calc-02): align display buttons in a grid
Wrap the display controls in a dedicated grid container, add a spacer for the missing cell, and simplify the button styling so the layout stays consistent across sizes.
2026-05-16 01:08:11 +02:00
matmoul 256e9f2b33 refactor(calc-02): move display buttons into their own grid area 2026-05-16 01:01:14 +02:00
matmoul 77fb671dcf fix: use typographic operator symbols in calculator keypad 2026-05-16 00:37:16 +02:00
matmoul ae11cb8007 fix: tighten calculator layout for full-screen mobile sizing 2026-05-16 00:33:58 +02:00
matmoul ba7fc8b4d6 fix: right-align calculator display text 2026-05-16 00:02:10 +02:00
matmoul b45cfe8091 feat: add root command and fix calc-02 exponent shortcuts 2026-05-15 23:05:43 +02:00
matmoul cb45efff43 feat: add root operation to RPN calculator 2026-05-15 22:58:21 +02:00
matmoul 4e8155b5f0 fix(calc-02): tighten calculator layout and panel styling
Wrap the display and control buttons in a shared display block so the
stack and button panels align as a single unit. Reduce corner radii and
adjust gaps/padding across the calculator to better fit the updated
portrait and desktop layouts.
2026-05-15 22:35:19 +02:00
matmoul 432523c23f fix: toggle calc sample menus on repeated clicks 2026-05-15 21:50:36 +02:00
matmoul 9cbddfa0c2 fix: align constant menu to the right edge 2026-05-15 21:46:49 +02:00
matmoul db3bee6e89 feat(calc-02): restyle enter key label and colors 2026-05-15 21:44:41 +02:00
matmoul 62221a9baa feat: add arrow key navigation to calculator sample 2026-05-15 21:38:14 +02:00
matmoul c47c46ad64 fix: clear status after successful clipboard and constant actions 2026-05-15 21:33:38 +02:00
matmoul 80bcdac320 feat: add stack copy buttons to calculator display 2026-05-15 21:32:43 +02:00
matmoul 39659745a6 feat(calc-02): reorder keypad actions for safer input flow 2026-05-15 21:18:47 +02:00
matmoul 75fe72412e fix(samples): keep hidden input focused for keyboard input 2026-05-15 21:03:16 +02:00
matmoul ef0e0c8dd2 feat: show calculator status messages as overlay bar 2026-05-15 20:56:05 +02:00
matmoul 6444357444 fix: ignore backspace when the stack is empty 2026-05-15 20:47:19 +02:00
matmoul d1a1d44577 feat: support pasting numbers into the calculator stack
Add clipboard paste handling for the hidden input and the paste button so pasted text is parsed as a numeric value before being pushed. Also add the eˣ function key in the sample calculator and keep the hidden input selected on focus for Ctrl+V support.
2026-05-15 20:45:30 +02:00
matmoul 02b3b280f8 feat: enable editing from the down button in calc sample 2026-05-15 20:24:21 +02:00
matmoul 40e1043a03 feat: add swap action and HP48-style stack editing 2026-05-15 20:19:39 +02:00
matmoul f679b0d952 feat: add constants popup to calculator sample 2026-05-15 19:12:36 +02:00
matmoul 2505a102df feat(calc-02): replace display button labels with symbols 2026-05-15 19:09:24 +02:00
matmoul 9bca077347 fix: change default serve port to 3000 2026-05-15 19:00:50 +02:00
matmoul d88722030a Merge branch 'dev' into calc-02 2026-05-15 18:56:57 +02:00
matmoul 324f203d23 chore: add portable local static file server script 2026-05-15 18:56:22 +02:00
matmoul 48a262eb87 feat: add responsive calc-02 HP48GX demo 2026-05-15 18:45:44 +02:00
matmoul 95eb1d265f docs: update README and memory notes 2026-05-02 00:23:11 +02:00
matmoul 197cbb161c docs: refresh project rules and README for current engine API 2026-04-25 04:36:53 +02:00
matmoul fbbc9455fb fix: improve calc topbar responsiveness on smaller screens 2026-04-25 04:06:45 +02:00
matmoul 6beefe82bc refactor(calc-01): simplify keyboard layout and consts label 2026-04-25 04:03:48 +02:00
matmoul e1c87de626 docs: add vertical calculator visual sample 2026-04-25 03:57:49 +02:00
matmoul 69c4bfbfab fix: preserve stack until operation completes 2026-04-25 03:41:01 +02:00
matmoul eaedcb6b74 refactor(calc-01): remove redundant keyboard group titles 2026-04-25 03:32:26 +02:00
matmoul 0e72f64c3b fix(calc-01): show errors in the display area 2026-04-25 03:24:07 +02:00
matmoul c9be42f252 docs: update README sample demo paths 2026-04-25 03:18:54 +02:00
matmoul 86617e1048 feat: make calc enter key label vertical 2026-04-25 03:13:04 +02:00
matmoul 1396a16de6 fix: align calc mode menu popup styling 2026-04-25 03:09:56 +02:00
matmoul 84451d0abc feat(calc-01): add distinct styles for backspace and escape keys 2026-04-25 02:54:23 +02:00
matmoul 223bf56339 feat: refresh calculator keypad labels and layout
Update the sample calculator UI to use compact math symbols, uppercase action labels, and a revised top bar layout. Also uppercase the angle mode button text and remove the backspace-specific width override.
2026-04-25 02:47:41 +02:00
matmoul 144c334fe5 fix: tighten calc-01 topbar layout 2026-04-25 02:32:16 +02:00
matmoul 3b7f35a00d refactor(samples): simplify calc-01 topbar layout and remove debug labels 2026-04-25 02:27:25 +02:00
matmoul 3d58309e0d feat: replace calc topbar controls with popup menus 2026-04-25 02:14:40 +02:00
matmoul 784c470b67 feat(calc-01): move controls into a compact top bar 2026-04-25 02:10:04 +02:00
matmoul 6a28aaaac6 feat: add HP48-style RPN calculator sample 2026-04-25 01:43:05 +02:00
matmoul 4ef19b1339 docs: update project rules for demo angle modes and UI structure 2026-04-25 01:23:51 +02:00
matmoul f30bdb9946 fix: restore edited x value on escape 2026-04-25 01:17:32 +02:00
matmoul 2857df2c6f feat: add scroll offset for stack view in dev sample 2026-04-25 01:14:15 +02:00
matmoul 06f915f3e1 feat: show stack indexes during selection and move mode 2026-04-25 01:10:43 +02:00
matmoul 98bc887a6e fix: tighten stack selection keyboard handling 2026-04-25 01:06:44 +02:00
matmoul 09e4d94908 fix: skip stack arrow handling while editing 2026-04-25 00:57:01 +02:00
matmoul 679ecbef7d chore: remove hp48 sample calculator page 2026-04-25 00:53:15 +02:00
matmoul 11d3c1da1f refactor(samples/dev): extract calculator styles and script files 2026-04-25 00:51:48 +02:00
matmoul 202edb47f4 fix: improve hp48 keypad layout on narrow screens 2026-04-25 00:13:47 +02:00
matmoul 26814bee3c feat(hp48): color-code keypad and inline error display 2026-04-25 00:04:33 +02:00
matmoul e9a6ad49d1 refactor(samples/hp48): unify popup menu handling and move angle mode into keypad 2026-04-24 23:55:00 +02:00
matmoul 613e688608 refactor: simplify hp48 sample UI 2026-04-24 23:53:25 +02:00
22 changed files with 3698 additions and 1835 deletions
+71 -20
View File
@@ -1,20 +1,24 @@
# Project rules — RPN Virtual Calculator
- Build a browser-friendly RPN calculator as a JavaScript class, preferably in a single file.
- Build a browser-friendly RPN calculator as a JavaScript class, currently implemented in `src/rpn-calculator.js`.
- Keep code names, categories, and API identifiers in English.
- Use only read-only / generic public API methods: `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.
- Constructor options:
- Keep the public calculator API centered on the generic methods `push`, `pop`, `clear`, `swap(index1, index2)`, `remove(index)`, `edit(index)`, `isValidIndex(index)`, `input(command)`, and `command(name, ...args)`.
- `inputValue` must remain a string and `isEditing` must remain a boolean.
- Keep constructor options aligned with the current engine:
- `maxSize` (default `2048`)
- `base` (default `10`)
- `angleMode` (`deg` default; also `rad` and `grad`)
- `base` (default `10`, accepted range `2..16`)
- `angleMode` (`deg` by default; also `rad` and `grad`)
- `enabledCommands`
- Available constants: `pi`, `e`.
- Supported operations must be centralized in one dictionary containing at least:
- Available constants are `pi` and `e`.
- Supported operations must stay centralized in one dictionary containing at least:
- `argCount`
- `category`
- `aliases`
- Allowed categories are limited to: `Stack`, `Arithmetic`, and `Trigonometry`.
- `execute`
- Allowed operation categories are limited to `Stack`, `Arithmetic`, and `Trigonometry`.
- The engine currently exposes static helpers for category discovery: `getOperationCategories()` and `getOperationsByCategory()`.
- The instance currently exposes `getOperationsByCategory()` and `getConstants()` helpers in addition to the generic API.
- Preserve browser and CommonJS exports for `RpnCalculator`.
## Supported commands
@@ -22,23 +26,70 @@
- `add`, `sub`, `mul`, `div`, `mod`, `pow`, `sqr`, `neg`, `sqrt`, `recip`,
`sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `log`, `ln`,
`dup`, `drop`, `swap`, `clear`, `enter`
- Aliases:
- Current aliases include:
- `+`, `-`, `*`, `/`, `%`, `^`, `y^x`, `1/x`
- Existing extra alias also present in code:
- `sqrt(x)` for `sqrt`
## Behavior rules
- `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`
- Trigonometric functions use degrees in the demo:
- `sin`, `cos`, `tan` convert degrees to radians
- inverse trig functions return degrees
- `inputValue` must remain a string to preserve future hexadecimal input support
- The example HTML must group buttons by `Stack`, `Arithmetic`, and `Trigonometry`
- The example HTML must call `command(...)` for actions
- `mod` is the percentage operator: `a b % => (a * b) / 100`.
- `div` and `recip` must throw `Division by zero` on zero divisors.
- `sqrt`, `asin`, `acos`, `log`, and `ln` must throw explicit domain errors.
- `log` uses `Math.log10`.
- `ln` uses `Math.log`.
- Trigonometric functions must support `deg`, `rad`, and `grad`.
- Direct trigonometric functions convert input angles with `toRadians(...)`.
- Inverse trigonometric functions convert results back using the current angle mode via `toDegrees(...)`.
- The engine rounds formatted numeric results to 12 decimal places and normalizes `-0` to `0`.
- `inputValue` must remain a string to preserve future hexadecimal-style input support.
- `parseInputValue(...)` currently uses `Number(...)` in base 10 and `parseInt(..., base)` for other bases.
- `input(command)` currently accepts single-character numeric editing input, including `0-9`, `A-F`, `a-f`, `+`, `-`, and `.`.
- `command(name, ...args)` currently resolves aliases, supports constants, commits pending input before execution, checks `enabledCommands`, and throws clear `Unknown command`, `Command disabled`, `Stack overflow`, `Stack underflow`, `Invalid stack index`, `Invalid number`, and `Invalid input value` errors where appropriate.
## Demo rules
- The active browser demo lives under `samples/dev/`.
- `samples/dev/index.html` is the demo entry page.
- `samples/dev/index.css` contains the calculator visual theme.
- `samples/dev/index.js` contains demo-side presentation helpers and UI logic.
- The demo currently exposes:
- a stack display with four visible lines
- a main display/status area
- a visible angle mode indicator
- an angle mode selector for `deg`, `rad`, and `grad`
- status pills showing `inputValue` and `isEditing`
- grouped command panels for `Stack`, `Arithmetic`, `Trigonometry`, and `Constants`
- an error display area
- The demo may present user-facing labels such as `+`, ``, `×`, `÷`, `y^x`, `1/x`, and `x²` while still using English command identifiers internally.
- The example HTML groups buttons by `Stack`, `Arithmetic`, and `Trigonometry`, plus a dedicated `Constants` section.
- Demo buttons are wired through demo helpers that eventually call `command(...)` for calculator commands.
- The demo default angle mode is degrees.
- Keyboard support in the demo must remain consistent with the displayed help text.
- The current demo keyboard behavior supports:
- digits and decimal point
- numpad digits and numpad arithmetic keys
- `Enter` to validate input or toggle stack-item move mode
- `Backspace` to edit input or drop from the stack
- `Delete` to clear
- `Escape` to cancel editing, cancel move mode, or clear selection
- `ArrowUp` / `ArrowDown` to navigate or move selected stack items
- `ArrowRight` for swap
- `+`, `-`, `*`, `/`, `%`, `^`
- `q`, `n`, `r`, `i`, `g`, `l`, `s`, `c`, `S`, `C`
- `x`, `y`, `z`, `t` to select visible stack registers
- The demo currently implements stack selection and stack-item move mode in its own UI logic using the public stack methods.
- The demo currently duplicates X on `enter` when not editing, matching classic RPN-style behavior in the UI layer.
## File references
- `samples/dev/index.html` references `../../src/rpn-calculator.js` as the calculator engine used by the demo.
- Keep the demo aligned with the public calculator API exposed by `src/rpn-calculator.js`.
- `README.md` is currently outdated and duplicated in places; if documentation work is done later, align it with the actual engine and demo behavior.
## Maintenance
- Re-read `src/rpn-calculator.js` and `samples/dev/` before updating these rules after project changes.
- Keep this file updated after each project change using the provided editing tools.
- When changing the demo UI, keyboard help, exported API, command metadata, or calculator behavior, update these rules so they continue to match the current project behavior.
+3
View File
@@ -0,0 +1,3 @@
- README is more detailed than memory files; keep memory compact.
- Use `state.md` for evolving engine/demo/API details.
- `project.md` should stay as a short pointer file.
+9
View File
@@ -0,0 +1,9 @@
# State
- Core engine: `src/rpn-calculator.js`
- Reference demo: `samples/calc-02/` (portrait-first HP48GX layout; calc-02 keyboard shortcuts are the reference; compact mode/constants popups; Const button comes before Mode in the display row)
- Important UI behavior: mode button shows the current angle mode; keyboard focus stays on the hidden input on desktop; clipboard paste is supported
- Note: keep scrolling behavior in mind for the calc-02 demo when changing the stack/UI layout
- Public API: `push`, `pop`, `clear`, `swap`, `remove`, `edit`, `isValidIndex`, `input`, `command`, `getOperationsByCategory`, `getConstants`, `listConstants`, `setConstant`, `removeConstant`, `hasConstant`
- Config: `maxSize`, `base`, `angleMode`, `enabledCommands`
- Commands: arithmetic, stack, trigonometry, constants `pi` and `e`; arithmetic includes `root`; constants can be added or removed dynamically through the core API
- Exports: browser `window.RpnCalculator`, CommonJS `module.exports`
+8
View File
@@ -0,0 +1,8 @@
# State
- Core engine: `src/rpn-calculator.js`
- Default demo: `samples/dev/` classic browser demo; `samples/calc-02/` remains the portrait-first HP48GX-inspired reference
- Public API: `push`, `pop`, `clear`, `swap`, `remove`, `edit`, `isValidIndex`, `input`, `command`, `getOperationsByCategory`, `getConstants`, `listConstants`, `setConstant`, `removeConstant`, `hasConstant`
- Config: `maxSize`, `base`, `angleMode`, `enabledCommands`
- Commands: arithmetic, stack, trigonometry, constants `pi`, `e`, `phi`, `g`, and `c`; arithmetic includes `root` with `y√x`, `yroot`, and `nroot` aliases; constants can be added or removed dynamically through the core API
- Demo behavior: `samples/dev/` keeps hidden input focus on desktop and supports clipboard paste; `samples/calc-02/` uses compact mode/constants popups, a 4-column display-adjacent row, stack selection/move mode, and paste through the hidden input
- Exports: browser `window.RpnCalculator`, CommonJS `module.exports`
+4
View File
@@ -0,0 +1,4 @@
- Keep `.memory/project.md` short.
- Update `.memory/state.md` after project changes.
- Reconcile README and demo docs if behavior changes.
- Track future work around keyboard UX, stack selection, and command docs.
+239 -92
View File
@@ -1,46 +1,34 @@
# RPN Virtual Calculator
A browser-friendly RPN calculator built around a small, generic JavaScript API.
A browser-friendly RPN calculator built around a small JavaScript API.
## Goal
## Overview
This project defines a reusable RPN calculator engine with:
This project provides:
- a simple stack-based public API
- configurable numeric and UI behavior
- centralized command metadata
- a browser demo that uses the same public API as any consumer code
- a reusable JavaScript RPN engine in `src/rpn-calculator.js`
- a classic browser demo in `samples/dev/`
- a portrait-first HP48GX-inspired demo in `samples/calc-02/`
- a centralized command system with aliases
- a compact public API focused on stack operations, editing, and command dispatch
# RPN Virtual Calculator
A browser-friendly RPN calculator built around a small, generic JavaScript API.
## Goal
This project provides a reusable Reverse Polish Notation (RPN) calculator engine with:
- a simple stack-based public API
- configurable numeric behavior
- centralized command metadata
- browser demos that use the same public API as any consumer code
The main class is `RpnCalculator`.
## Project structure
- `src/rpn-calculator.js`: calculator engine
- `samples/dev/index.html`: browser demo
- `samples/hp48/index.html`: HP48-style browser demo
- `samples/dev/index.html`: browser demo entry point
- `samples/dev/index.css`: demo styles
- `samples/dev/index.js`: demo UI and keyboard logic
- `samples/calc-02/index.html`: portrait-first HP48GX-style demo entry point
- `samples/calc-02/index.css`: portrait-first demo styles
- `samples/calc-02/index.js`: portrait-first HP48GX-style demo UI and keyboard logic
- `samples/calc-XX/`: placeholder name for future demo variants
## Main features
## Public API
The calculator API is centered on these methods:
- Single JavaScript class: `RpnCalculator`
- Configurable stack size via `maxSize` (default: `2048`)
- Configurable numeric base via `base` (default: `10`)
- Configurable angle mode via `angleMode`:
- `deg` (default)
- `rad`
- `grad`
- Optional command filtering through `enabledCommands`
- Public API limited to generic methods:
- `push(value)`
- `pop()`
- `clear()`
@@ -50,38 +38,94 @@ This project provides a reusable Reverse Polish Notation (RPN) calculator engine
- `isValidIndex(index)`
- `input(command)`
- `command(name, ...args)`
- `inputValue` is always stored as a string
- `isEditing` is exposed as a boolean
- Supported operations are centralized in one dictionary with metadata such as:
- `argCount`
- `category`
- `aliases`
- Supported categories are limited to:
- `Stack`
- `Arithmetic`
- `Trigonometry`
## Available constants
Instance helpers also available:
- `getOperationsByCategory()`
- `getConstants()`
- `listConstants()`
- `setConstant(name, value)`
- `removeConstant(name)`
- `hasConstant(name)`
Static helpers also available:
- `RpnCalculator.getOperationCategories()`
- `RpnCalculator.getOperationsByCategory()`
State exposed on instances:
- `inputValue` as a string
- `isEditing` as a boolean
- `stack` as the current internal stack array used by the demo
- `angleMode`
- `base`
- `maxSize`
## Constructor options
```js README.md
const calc = new RpnCalculator({
maxSize: 2048,
base: 10,
angleMode: 'deg',
enabledCommands: ['add', 'sub', 'mul', 'div']
});
```
Supported options:
- `maxSize`: maximum stack size, default `2048`
- `base`: numeric base, default `10`, accepted range `2..16`
- `angleMode`: `deg`, `rad`, or `grad`, default `deg`
- `enabledCommands`: optional whitelist of enabled commands and aliases
## Constants
Available constants:
- `pi`
- `e`
- `phi`
- `g`
- `c`
- plus any user-defined constants added through the engine API
They can be used through `command(...)`:
```js README.md
calc.command('pi');
calc.command('e');
```
## Supported commands
### Stack
- `enter`
- `dup`
- `drop`
- `swap`
- `clear`
### Arithmetic
- `add` (`+`)
- `sub` (`-`)
- `mul` (`*`)
- `div` (`/`)
- `mod` (`%`)
- `pow` (`^`, `y^x`)
- `add` alias: `+`
- `sub` alias: `-`
- `mul` alias: `*`
- `div` alias: `/`
- `mod` alias: `%`
- `pow` aliases: `^`, `y^x`
- `root` aliases: `y√x`, `yroot`, `nroot`
- `sqr`
- `neg`
- `sqrt`
- `recip` (`1/x`)
- `sqrt` alias: `sqrt(x)`
- `recip` alias: `1/x`
- `log`
- `ln`
### Trigonometry
- `sin`
- `cos`
- `tan`
@@ -89,57 +133,160 @@ This project provides a reusable Reverse Polish Notation (RPN) calculator engine
- `acos`
- `atan`
### Stack
- `dup`
- `drop`
- `swap`
- `clear`
- `enter`
## Behavior rules
## Behavior notes
- `mod` is a percentage operator:
- `a b % => (a * b) / 100`
- `pow` accepts aliases `^` and `y^x`
- `sqrt` accepts alias `sqrt(x)`
- `recip` accepts alias `1/x`
- `div` and `recip` throw `Division by zero` when needed
- `root` computes the y-th root as `x^(1/y)` and throws `Invalid input for root` for invalid inputs
- `sqrt` throws `Invalid input for sqrt` for negative values
- `asin` and `acos` throw explicit domain errors outside `[-1, 1]`
- `log` throws `Invalid input for log` for values `<= 0`
- `ln` throws `Invalid input for ln` for values `<= 0`
- `log` uses `Math.log10`
- `ln` uses `Math.log`
- `sqrt`, `asin`, `acos`, `log`, and `ln` throw explicit domain errors on invalid input
- Trigonometric behavior depends on `angleMode`
- In degree mode:
- `sin`, `cos`, `tan` convert degrees to radians internally
- `asin`, `acos`, `atan` return degrees
- `inputValue` remains a string to preserve future support for formats such as hexadecimal input
- direct trigonometric functions convert input using `toRadians(...)`
- inverse trigonometric functions convert results back using the current angle mode
- `angleMode` supports `deg`, `rad`, and `grad`
- formatted numeric values are rounded to 12 decimal places
- `-0` is normalized to `0`
- `inputValue` remains a string to preserve future non-decimal input support
- base 10 input is parsed with `Number(...)`
- non-decimal input currently uses `parseInt(..., base)`
## Input handling
`input(command)` supports two modes:
- single-character editing input
- command dispatch
Accepted single-character editing input currently includes:
- `0-9`
- `A-F`
- `a-f`
- `+`
- `-`
- `.`
Everything else is forwarded to `command(...)`.
## Basic usage
### In a browser
- `rpn-calculator.js`: calculator engine
- `rpn-example.html`: example browser interface
### CommonJS
## Main features
- Single JavaScript class
- Configurable stack size (`maxSize`, default: `2048`)
- Configurable numeric base (`base`, default: `10`)
- Configurable angle mode (`angleMode`, default: `deg`)
- Optional command filtering through `enabledCommands`
- Generic public API centered on:
- `push`
- `pop`
- `clear`
- `swap(index1, index2)`
- `remove(index)`
- `edit(index)`
- `isValidIndex(index)`
- `input(command)`
- `command(name, ...args)`
- `inputValue` is kept as a string to preserve future input formats
- `isEditing` is exposed as a boolean state
- All supported commands are described in one centralized dictionary
- Supported categories are limited to:
- `Stack`
- `Arithmetic`
- `Trigonometry`
```js README.md
const RpnCalculator = require('./src/rpn-calculator');
## Basic usage
const calc = new RpnCalculator();
calc.push(2);
calc.push(3);
calc.command('add');
### In a browser
console.log(calc.pop()); // 5
```
### Browser
```html README.md
<script src="./src/rpn-calculator.js"></script>
<script>
const calc = new RpnCalculator({ angleMode: 'deg' });
calc.push(9);
calc.command('sqrt');
console.log(calc.pop());
</script>
```
### Editing values through `input(...)`
```js README.md
const calc = new RpnCalculator();
calc.input('1');
calc.input('2');
calc.input('.');
calc.input('5');
calc.command('enter');
console.log(calc.pop()); // 12.5
```
### Aliases
```js README.md
const calc = new RpnCalculator();
calc.push(6);
calc.push(7);
calc.command('+');
console.log(calc.pop()); // 13
```
### Constants and trigonometry
```js README.md
const calc = new RpnCalculator({ angleMode: 'deg' });
calc.command('pi');
console.log(calc.pop());
calc.push(30);
calc.command('sin');
console.log(calc.pop()); // 0.5
```
## Demo
The default demo lives in `samples/dev/`.
Main UI features:
- four visible stack lines
- main display/status area
- visible angle mode indicator
- angle mode selector for `deg`, `rad`, and `grad`
- status pills for `inputValue` and `isEditing`
- grouped panels for `Stack`, `Arithmetic`, `Trigonometry`, and `Constants`
- keyboard-friendly hidden input on desktop
## Calc 02 demo
`samples/calc-02/` is a portrait-first HP48GX-inspired demo.
It keeps the display-adjacent button row aligned in four columns, uses compact popup menus for mode and constants, and supports clipboard paste plus the `y√x` root operation.
The demo loads the engine from:
```html README.md
<script src="../../src/rpn-calculator.js"></script>
```
### Demo keyboard support
The current demo supports:
- digits and decimal point
- numpad digits and numpad arithmetic keys
- `Enter`
- `Backspace`
- `Delete`
- `Escape`
- `ArrowUp`, `ArrowDown`, `ArrowRight`
- `+`, `-`, `*`, `/`, `%`, `^`
- `s`, `S`, `r`, `R`, `v`, `u`
- `l`, `L`, `n`, `N`, `e`, `E`
- `i`, `o`, `a`, `I`, `O`, `A`
The demo also implements stack selection and stack-item move mode in its UI layer using the public calculator methods.
It keeps the calculator screen focused and updates the visible stack window as the selection moves.
Note: `calc-02` keyboard shortcuts are the reference for this project.
## Exports
`RpnCalculator` is exposed in both environments:
- browser: `window.RpnCalculator`
- CommonJS: `module.exports = RpnCalculator`
Executable
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail
PORT="${1:-3000}"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
if command -v python3 >/dev/null 2>&1; then
exec python3 -m http.server "$PORT" --directory "$ROOT_DIR"
elif command -v python >/dev/null 2>&1; then
exec python -m SimpleHTTPServer "$PORT"
elif command -v node >/dev/null 2>&1; then
exec node -e "const http=require('http'); const fs=require('fs'); const path=require('path'); const root=process.argv[1]; const port=Number(process.argv[2]); const mime={'.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.svg':'image/svg+xml','.ico':'image/x-icon'}; http.createServer((req,res)=>{let url=decodeURIComponent(req.url.split('?')[0]); if(url==='/' ) url='/'; let filePath=path.join(root,url); fs.stat(filePath,(err,stat)=>{if(!err&&stat.isDirectory()) filePath=path.join(filePath,'index.html'); fs.readFile(filePath,(err,data)=>{if(err){res.statusCode=404; return res.end('Not Found');} res.setHeader('Content-Type', mime[path.extname(filePath)]||'application/octet-stream'); res.end(data);});});}).listen(port,()=>console.log('Serving '+root+' on http://localhost:'+port));" "$ROOT_DIR" "$PORT"
else
echo "Error: python3, python, or node is required to serve the project root." >&2
exit 1
fi
+399
View File
@@ -0,0 +1,399 @@
:root {
--body-top: #1d2430;
--body-bottom: #0c1017;
--shell-top: #4b5567;
--shell-bottom: #252d39;
--shell-edge: #141922;
--screen: #dce7b3;
--screen-top: #e8f0c3;
--screen-text: #1c2910;
--screen-dim: #60714a;
--key-text: #f4f7fb;
--key-fn-top: #677287;
--key-fn-bottom: #424b5d;
--key-num-top: #5d6470;
--key-num-bottom: #383f49;
--key-op-top: #4f6b95;
--key-op-bottom: #304661;
--key-danger-top: #7a5050;
--key-danger-bottom: #553636;
--key-soft-danger-top: #86605a;
--key-soft-danger-bottom: #61433e;
--key-cancel-top: #5f6b7c;
--key-cancel-bottom: #3f4857;
--key-enter-top: #7d9079;
--key-enter-bottom: #4d614b;
--border: #11151c;
--shadow: rgba(0, 0, 0, 0.35);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: Arial, sans-serif;
background:
radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 30%),
linear-gradient(180deg, var(--body-top), var(--body-bottom));
color: #f3f6fb;
}
.wrap {
max-width: 760px;
margin: 0 auto;
padding: 28px 18px 40px;
}
.calc {
background: linear-gradient(180deg, var(--shell-top), var(--shell-bottom));
border: 1px solid var(--shell-edge);
border-radius: 28px;
padding: 18px;
box-shadow:
0 24px 60px var(--shadow),
inset 0 1px 0 rgba(255, 255, 255, 0.12),
inset 0 -2px 0 rgba(0, 0, 0, 0.28);
}
.screen {
background: linear-gradient(180deg, var(--screen-top), var(--screen));
color: var(--screen-text);
border: 3px solid #829366;
border-radius: 14px;
padding: 14px 16px;
min-height: 196px;
font-family: "Courier New", monospace;
display: grid;
grid-template-rows: auto auto 1fr;
gap: 10px;
box-shadow:
inset 0 2px 8px rgba(60, 80, 28, 0.18),
0 8px 16px rgba(0, 0, 0, 0.18);
}
.screen-top {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: var(--screen-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.stack {
border-top: 1px solid rgba(28, 41, 16, 0.24);
padding-top: 10px;
line-height: 1.5;
font-size: 18px;
white-space: pre-wrap;
}
.stack-line {
display: grid;
grid-template-columns: 26px 1fr;
gap: 8px;
padding: 2px 4px;
border-radius: 4px;
}
.stack-line.selected {
background: rgba(28, 41, 16, 0.12);
outline: 1px dashed rgba(28, 41, 16, 0.4);
}
.stack-line.moving {
background: rgba(117, 160, 90, 0.2);
outline: 1px solid rgba(28, 41, 16, 0.5);
}
.stack-line .label {
text-align: right;
color: var(--screen-dim);
}
.stack-line.selected .label,
.stack-line.moving .label {
color: var(--screen-text);
font-weight: bold;
}
#display {
font-size: 18px;
font-weight: bold;
letter-spacing: 0.04em;
}
.hidden-input {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.topbar {
margin-top: 14px;
background: rgba(6, 10, 16, 0.18);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 16px;
padding: 6px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.topbar-group {
display: grid;
grid-template-columns: minmax(92px, 112px) minmax(92px, 112px) 1fr minmax(60px, 72px) minmax(60px, 72px) minmax(60px, 72px);
gap: 6px;
align-items: stretch;
}
.menu-cell {
position: relative;
}
.topbar-spacer {
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.04);
}
.menu-trigger {
width: 100%;
min-height: 26px;
padding: 4px 8px;
border-radius: 10px;
font-size: 12px;
}
.popup-menu {
position: absolute;
top: calc(100% + 6px);
left: 10px;
right: 10px;
z-index: 20;
display: grid;
gap: 8px;
padding: 10px;
border-radius: 14px;
background: rgba(18, 24, 34, 0.96);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 16px 30px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
#modeMenu {
left: 0;
right: auto;
min-width: 100%;
margin-right: 0;
}
.popup-menu {
text-align: left;
}
.popup-menu button {
text-align: left;
justify-content: flex-start;
}
.popup-menu[hidden] {
display: none;
}
.popup-menu-compact {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.hidden-select {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.action-cell {
display: flex;
align-items: stretch;
}
.action-cell > button {
width: 100%;
min-height: 26px;
padding: 4px 8px;
border-radius: 10px;
font-size: 12px;
}
select,
button {
border-radius: 12px;
border: 1px solid var(--border);
font: inherit;
}
select {
padding: 10px 12px;
background: #edf1f7;
color: #111;
}
.keyboard-layout {
margin-top: 18px;
display: grid;
grid-template-columns: 4fr 3fr 2fr;
grid-template-areas: "functions numbers operators";
gap: 12px;
}
.key-group {
background: rgba(6, 10, 16, 0.18);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 18px;
padding: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.key-group-functions {
grid-area: functions;
}
.key-group-numbers {
grid-area: numbers;
}
.key-group-operators {
grid-area: operators;
}
.key-grid {
display: grid;
gap: 10px;
}
.functions-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(4, 56px);
}
.numbers-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-rows: repeat(4, 56px);
}
.operators-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(4, 56px);
}
.key-spacer {
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
border: 1px dashed rgba(255, 255, 255, 0.04);
}
button {
padding: 10px 8px;
color: var(--key-text);
cursor: pointer;
font-weight: bold;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 3px 0 rgba(0, 0, 0, 0.25);
transition: filter 120ms ease, transform 120ms ease;
}
button:hover { filter: brightness(1.08); }
button:active {
transform: translateY(2px);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
0 1px 0 rgba(0, 0, 0, 0.25);
}
.key-function {
background: linear-gradient(180deg, var(--key-fn-top), var(--key-fn-bottom));
}
.key-number {
background: linear-gradient(180deg, var(--key-num-top), var(--key-num-bottom));
}
.key-operator {
background: linear-gradient(180deg, var(--key-op-top), var(--key-op-bottom));
}
.key-danger {
background: linear-gradient(180deg, var(--key-danger-top), var(--key-danger-bottom));
}
.key-soft-danger {
background: linear-gradient(180deg, var(--key-soft-danger-top), var(--key-soft-danger-bottom));
}
.key-cancel {
background: linear-gradient(180deg, var(--key-cancel-top), var(--key-cancel-bottom));
}
.key-enter {
background: linear-gradient(180deg, var(--key-enter-top), var(--key-enter-bottom));
grid-row: span 2;
writing-mode: vertical-rl;
text-orientation: upright;
letter-spacing: 0.08em;
}
.hint {
color: #cfd7e3;
margin-top: 10px;
font-size: 12px;
line-height: 1.5;
}
@media (max-width: 980px) {
.topbar-group {
grid-template-columns: minmax(92px, 112px) minmax(92px, 112px) 1fr minmax(60px, 72px) minmax(60px, 72px) minmax(60px, 72px);
}
}
@media (max-width: 860px) {
.keyboard-layout {
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
grid-template-areas:
"numbers operators"
"functions functions";
}
}
@media (max-width: 640px) {
.wrap {
padding: 14px;
}
.calc {
padding: 14px;
border-radius: 22px;
}
.topbar {
overflow-x: auto;
}
.topbar-group {
grid-template-columns: minmax(92px, 112px) minmax(92px, 112px) 1fr minmax(60px, 72px) minmax(60px, 72px) minmax(60px, 72px);
min-width: 520px;
}
.functions-grid,
.numbers-grid,
.operators-grid {
grid-template-rows: repeat(4, 52px);
}
}
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HP48-style RPN Calculator</title>
<link rel="stylesheet" href="./index.css">
</head>
<body>
<div class="wrap">
<div class="calc">
<div class="screen" id="screen" tabindex="0" role="application" aria-label="HP48 style calculator screen">
<div class="screen-top">
<div>RPN stack</div>
<div id="modeLabel">deg</div>
</div>
<div id="stack" class="stack"></div>
<div id="display"></div>
</div>
<input id="input" class="hidden-input" type="text" autocomplete="off" aria-hidden="true" tabindex="-1">
<div class="topbar">
<div class="topbar-group">
<div class="menu-cell" id="modeMenuWrap">
<button type="button" class="menu-trigger key-function" id="modeMenuButton" aria-haspopup="true" aria-expanded="false">mode</button>
<div class="popup-menu" id="modeMenu" hidden></div>
<select id="angleMode" class="hidden-select" aria-hidden="true" tabindex="-1">
<option value="deg">Degrees</option>
<option value="rad">Radians</option>
<option value="grad">Grads</option>
</select>
</div>
<div class="menu-cell" id="constsMenuWrap">
<button type="button" class="menu-trigger key-function" id="constsMenuButton" aria-haspopup="true" aria-expanded="false">consts</button>
<div class="popup-menu popup-menu-compact" id="constsMenu" hidden></div>
</div>
<div class="topbar-spacer" aria-hidden="true"></div>
<div class="action-cell" id="deleteButton"></div>
<div class="action-cell" id="backspaceButton"></div>
<div class="action-cell" id="escapeButton"></div>
</div>
</div>
<div class="keyboard-layout">
<section class="key-group key-group-functions" aria-label="Functions">
<div class="key-grid functions-grid" id="functionsButtons"></div>
</section>
<section class="key-group key-group-numbers" aria-label="Numbers">
<div class="key-grid numbers-grid" id="numbersButtons"></div>
</section>
<section class="key-group key-group-operators" aria-label="Operators">
<div class="key-grid operators-grid" id="operatorsButtons"></div>
</section>
</div>
</div>
</div>
<script src="../../src/rpn-calculator.js"></script>
<script src="./index.js"></script>
</body>
</html>
+673
View File
@@ -0,0 +1,673 @@
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 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;
let statusMessage = '';
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) {
statusMessage = '';
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 if (statusMessage) {
displayEl.textContent = statusMessage;
} else {
displayEl.textContent = 'READY';
}
modeLabel.textContent = calc.angleMode;
modeMenuButton.textContent = calc.angleMode.toUpperCase();
angleMode.value = calc.angleMode;
}
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 {
statusMessage = '';
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) {
statusMessage = error.message;
render();
}
}
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 {
statusMessage = '';
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) {
statusMessage = error.message;
render();
}
}
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();
+21
View File
@@ -0,0 +1,21 @@
┌─────────────────────────────────────────────┐
| T: |
| Z: |
| Y: |
| X: |
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
| mode | consts | | del | backspace | esc |
└─────────────────────────────────────────────┘
┌─────────── Numbers ──────────┬─ Operators ─┐
| 7 | 8 | 9 | / | |
| 4 | 5 | 6 | * | |
| 1 | 2 | 3 | - | Enter |
| 0 | . | +/- | + | Enter |
└──────────────────────────────┴──────────────┘
┌───────────────── Functions ─────────────────┐
| sqrt | y^x | x² | 1/x |
| log | ln | | % |
| sin | cos | tan | |
| asin | acos | atan | |
└─────────────────────────────────────────────┘
+15
View File
@@ -0,0 +1,15 @@
┌──────────────────────────────────────────────────────────────────────────────┐
| T: |
| Z: |
| Y: |
| X: |
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
| mode | consts | | del | backspace | esc |
└──────────────────────────────────────────────────────────────────────────────┘
┌───────────── Functions ───────────────────┬───── Numbers ─────┬─ Operators ─┐
| sqrt | y^x | x² | 1/x | 7 | 8 | 9 | / | |
| log | ln | | % | 4 | 5 | 6 | * | |
| sin | cos | tan | | 1 | 2 | 3 | - | Enter |
| asin | acos | atan | | 0 | . | +/- | + | Enter |
└───────────────────────────────────────────┴───────────────────┴──────────────┘
+473
View File
@@ -0,0 +1,473 @@
:root {
--bg0: #10151e;
--bg1: #1b2432;
--panel: #2c3442;
--panel2: #394354;
--edge: #0c1118;
--display: #cfe0ae;
--display2: #b9cd8a;
--displayText: #1f2a12;
--buttonText: #f4f7fb;
--shadow: rgba(0, 0, 0, 0.35);
--btnTop: #444c58;
--btnBottom: #2f3640;
--btnAccentTop: #3f526b;
--btnAccentBottom: #2b394c;
--btnAltTop: #525c69;
--btnAltBottom: #3a434f;
--btnDangerTop: #584042;
--btnDangerBottom: #402d2f;
--btnEscapeTop: #6a4a2a;
--btnEscapeBottom: #4a331d;
--btnEnterTop: #4f7f4d;
--btnEnterBottom: #355a34;
--btnText: #eef2f7;
}
* {
box-sizing: border-box;
}
html, body {
margin: 0;
min-height: 100%;
overflow-y: hidden;
}
body {
min-height: 100vh;
font-family: Arial, sans-serif;
color: var(--buttonText);
background: var(--bg0);
}
.app-shell {
min-height: 100vh;
display: grid;
align-items: start;
justify-items: center;
padding: 0;
}
.calculator {
width: 100%;
max-width: 460px;
height: 100vh;
max-height: 100vh;
padding: 8px;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 8px;
border-radius: 8px;
background: var(--panel);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
justify-content: stretch;
align-content: start;
overflow: hidden;
}
.display-block,
.display-panel,
.display-buttons-panel,
.keypad-panel,
.functions-panel,
.trigo-panel {
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(6, 10, 16, 0.16);
box-shadow: none;
}
.display-block {
display: grid;
gap: 0;
align-self: start;
justify-items: stretch;
grid-template-rows: auto auto;
row-gap: 0;
}
.display-panel {
position: relative;
width: 100%;
padding: 16px;
padding-bottom: 8px;
background: var(--display);
color: var(--displayText);
font-family: "Courier New", monospace;
overflow: hidden;
box-sizing: border-box;
height: 138px;
min-height: 138px;
max-height: 138px;
margin-bottom: 0;
}
.display-grid {
height: 100%;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: repeat(4, minmax(0, auto));
align-content: start;
gap: 2px;
}
.stack-cell {
display: grid;
grid-template-columns: 2.2ch auto minmax(0, 1fr);
align-items: center;
gap: 8px;
font-size: 20px;
line-height: 1;
min-height: 0;
padding-block: 0;
}
/* removed extra first-column styling */
.stack-value {
min-height: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: right;
justify-self: end;
font-size: 20px;
}
.stack-value.is-editing {
letter-spacing: 0.02em;
}
.stack-cell.is-selected {
background: transparent;
border-radius: 6px;
outline: 2px solid rgba(31, 42, 18, 0.2);
outline-offset: -2px;
}
.stack-cell.is-moving {
background: rgba(31, 42, 18, 0.18);
}
.display-buttons-panel {
padding: 8px;
min-height: 0;
background: rgba(6, 10, 16, 0.16);
}
.display-buttons-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
align-content: start;
align-items: stretch;
}
.display-button-spacer {
pointer-events: none;
visibility: hidden;
background: transparent;
border-color: transparent;
box-shadow: none;
}
.display-button {
background: linear-gradient(180deg, var(--btnTop), var(--btnBottom));
color: #eef2f7;
box-shadow: none;
}
.display-button-symbol {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.2em;
font-size: 1.05em;
line-height: 1;
font-weight: 800;
transform: translateY(-0.01em);
}
.paste-symbol {
font-size: 1em;
}
.display-buttons-grid > button {
padding: 6px 8px;
}
.menu-popup {
position: fixed;
z-index: 20;
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
border-radius: 6px;
background: rgba(16, 21, 30, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.32);
backdrop-filter: blur(2px);
}
.menu-popup-item {
width: 100%;
min-width: 0;
padding: 6px 10px;
border-radius: 5px;
background: linear-gradient(180deg, var(--btnAltTop), var(--btnAltBottom));
text-align: left;
font-weight: 700;
}
.menu-popup-item.is-active {
outline: 1px solid rgba(207, 224, 174, 0.7);
outline-offset: 0;
}
.key-escape {
background: linear-gradient(180deg, var(--btnEscapeTop), var(--btnEscapeBottom));
color: #eef2f7;
}
.keypad-panel,
.functions-panel,
.trigo-panel {
padding: 8px;
min-height: 0;
}
.calculator > .display-block,
.calculator > .display-buttons-panel,
.calculator > .keypad-scroll-area {
justify-self: stretch;
}
.keypad-scroll-area {
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(46, 105, 135, 1);
}
.status-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 2;
padding: 8px 12px;
min-height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
color: rgba(31, 42, 18, 0.96);
background: rgba(255, 246, 170, 0.92);
border-bottom: 1px solid rgba(31, 42, 18, 0.2);
transform: translateY(-100%);
opacity: 0;
transition: transform 180ms ease, opacity 180ms ease;
pointer-events: none;
}
.status-bar.is-visible {
transform: translateY(0);
opacity: 1;
}
.status-bar.is-error {
color: #fff;
background: rgba(72, 14, 14, 0.98);
border-bottom-color: rgba(255, 255, 255, 0.12);
}
.keypad-grid,
.functions-grid,
.trigo-grid {
display: grid;
gap: 8px;
}
.keypad-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(5, minmax(0, 1.7fr));
}
.functions-grid,
.trigo-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
}
button {
border: 1px solid rgba(14, 18, 25, 0.85);
border-radius: 8px;
padding: 8px 8px;
font: inherit;
font-weight: 700;
color: var(--btnText);
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.35);
cursor: pointer;
box-shadow: none;
transition: filter 120ms ease, opacity 120ms ease;
line-height: 1;
}
button:hover {
filter: brightness(1.06);
}
button:active {
transform: none;
box-shadow: none;
}
.stack-copy-button:active {
animation: stack-copy-flash 140ms ease-out;
}
@keyframes stack-copy-flash {
0% {
opacity: 0.7;
}
50% {
opacity: 1;
}
100% {
opacity: 0.7;
}
}
.stack-copy-button {
padding: 4px;
min-width: 24px;
width: 24px;
height: 24px;
display: inline-grid;
place-items: center;
opacity: 0;
pointer-events: none;
transform: scale(1);
background: transparent;
border: none;
box-shadow: none;
color: rgba(31, 42, 18, 0.58);
margin-left: 0;
}
.stack-cell:last-child {
margin-bottom: 4px;
}
.stack-copy-button svg {
width: 15px;
height: 15px;
fill: #0a0a0a;
display: block;
}
.stack-copy-button.is-visible {
opacity: 0.7;
pointer-events: auto;
transform: scale(1);
}
.stack-copy-button:hover {
opacity: 1;
filter: none;
color: rgba(31, 42, 18, 0.85);
}
.stack-copy-button:active {
transform: scale(1);
color: rgba(31, 42, 18, 0.95);
}
.stack-copy-button:focus-visible {
outline: 1px solid rgba(31, 42, 18, 0.35);
outline-offset: 2px;
}
.stack-value.is-editing {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 0;
}
.edit-text {
display: inline-block;
white-space: pre;
}
.edit-caret {
display: inline-block;
width: 1px;
height: 1em;
margin: 0 0.12ch;
background: currentColor;
animation: caret-blink 1s steps(1, end) infinite;
transform: translateY(0.02em);
}
@keyframes caret-blink {
0%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0.15;
}
}
.key-default {
background: linear-gradient(180deg, var(--btnTop), var(--btnBottom));
color: #eef2f7;
}
.keypad-grid > button {
min-height: 1.7em;
}
#keypadGrid > button {
min-height: calc(1.7em * 1.7);
padding-top: calc(8px * 1.7);
padding-bottom: calc(8px * 1.7);
}
#keypadGrid > button.key-default,
#keypadGrid > button.key-accent,
#keypadGrid > button.key-danger,
#keypadGrid > button.key-enter {
min-height: calc(1.7em * 1.7);
}
.key-accent {
background: linear-gradient(180deg, var(--btnAccentTop), var(--btnAccentBottom));
color: #eef2f7;
}
.key-danger {
background: linear-gradient(180deg, var(--btnDangerTop), var(--btnDangerBottom));
color: #eef2f7;
}
.key-enter {
background: linear-gradient(180deg, var(--btnEnterTop), var(--btnEnterBottom));
color: #eef2f7;
padding-inline: 6px;
}
.hidden-input {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HP48GX RPN Calculator</title>
<link rel="stylesheet" href="./index.css?ver=1">
</head>
<body>
<main class="app-shell">
<section class="calculator" aria-label="HP48GX style RPN calculator">
<div class="display-block">
<div class="display-panel">
<div class="status-bar" id="statusLine" aria-live="polite"></div>
<div class="display-frame">
<div class="display-grid">
<div class="stack-cell"><span class="stack-label">T:</span><button type="button" class="stack-copy-button" data-copy-stack="T" aria-label="Copy T value"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M15.5 4h-7A2.5 2.5 0 0 0 6 6.5V8H5.5A1.5 1.5 0 0 0 4 9.5v8A2.5 2.5 0 0 0 6.5 20H13a1 1 0 0 0 1-1v-1.5H17.5A2.5 2.5 0 0 0 20 15V6.5A2.5 2.5 0 0 0 17.5 4h-2Zm.5 2h1.5a.5.5 0 0 1 .5.5V14a.5.5 0 0 1-.5.5H13V6.5A2.5 2.5 0 0 0 15.5 6Zm-1.5 12H6.5a.5.5 0 0 1-.5-.5v-8a.5.5 0 0 1 .5-.5h7.5V18Zm-1.5-8h-4V8.5A.5.5 0 0 1 9 8h4a.5.5 0 0 1 .5.5V10Z"/></svg></button><span id="stackT" class="stack-value"></span></div>
<div class="stack-cell"><span class="stack-label">Z:</span><button type="button" class="stack-copy-button" data-copy-stack="Z" aria-label="Copy Z value"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M15.5 4h-7A2.5 2.5 0 0 0 6 6.5V8H5.5A1.5 1.5 0 0 0 4 9.5v8A2.5 2.5 0 0 0 6.5 20H13a1 1 0 0 0 1-1v-1.5H17.5A2.5 2.5 0 0 0 20 15V6.5A2.5 2.5 0 0 0 17.5 4h-2Zm.5 2h1.5a.5.5 0 0 1 .5.5V14a.5.5 0 0 1-.5.5H13V6.5A2.5 2.5 0 0 0 15.5 6Zm-1.5 12H6.5a.5.5 0 0 1-.5-.5v-8a.5.5 0 0 1 .5-.5h7.5V18Zm-1.5-8h-4V8.5A.5.5 0 0 1 9 8h4a.5.5 0 0 1 .5.5V10Z"/></svg></button><span id="stackZ" class="stack-value"></span></div>
<div class="stack-cell"><span class="stack-label">Y:</span><button type="button" class="stack-copy-button" data-copy-stack="Y" aria-label="Copy Y value"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M15.5 4h-7A2.5 2.5 0 0 0 6 6.5V8H5.5A1.5 1.5 0 0 0 4 9.5v8A2.5 2.5 0 0 0 6.5 20H13a1 1 0 0 0 1-1v-1.5H17.5A2.5 2.5 0 0 0 20 15V6.5A2.5 2.5 0 0 0 17.5 4h-2Zm.5 2h1.5a.5.5 0 0 1 .5.5V14a.5.5 0 0 1-.5.5H13V6.5A2.5 2.5 0 0 0 15.5 6Zm-1.5 12H6.5a.5.5 0 0 1-.5-.5v-8a.5.5 0 0 1 .5-.5h7.5V18Zm-1.5-8h-4V8.5A.5.5 0 0 1 9 8h4a.5.5 0 0 1 .5.5V10Z"/></svg></button><span id="stackY" class="stack-value"></span></div>
<div class="stack-cell"><span class="stack-label">X:</span><button type="button" class="stack-copy-button" data-copy-stack="X" aria-label="Copy X value"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M15.5 4h-7A2.5 2.5 0 0 0 6 6.5V8H5.5A1.5 1.5 0 0 0 4 9.5v8A2.5 2.5 0 0 0 6.5 20H13a1 1 0 0 0 1-1v-1.5H17.5A2.5 2.5 0 0 0 20 15V6.5A2.5 2.5 0 0 0 17.5 4h-2Zm.5 2h1.5a.5.5 0 0 1 .5.5V14a.5.5 0 0 1-.5.5H13V6.5A2.5 2.5 0 0 0 15.5 6Zm-1.5 12H6.5a.5.5 0 0 1-.5-.5v-8a.5.5 0 0 1 .5-.5h7.5V18Zm-1.5-8h-4V8.5A.5.5 0 0 1 9 8h4a.5.5 0 0 1 .5.5V10Z"/></svg></button><span id="stackX" class="stack-value"></span></div>
</div>
</div>
</div>
</div>
<div class="display-buttons-panel">
<div class="display-buttons-grid">
<button id="constButton" class="display-button">π</button>
<button id="pasteButton" class="display-button"><span class="display-button-symbol paste-symbol"></span></button>
<button id="upButton" class="display-button"><span class="display-button-symbol arrow-symbol"></span></button>
<button id="modeButton" class="display-button">Mode</button>
<div class="display-button-spacer"></div>
<button id="leftButton" class="display-button display-button-offset"><span class="display-button-symbol arrow-symbol"></span></button>
<button id="downButton" class="display-button"><span class="display-button-symbol arrow-symbol"></span></button>
<button id="rightButton" class="display-button"><span class="display-button-symbol arrow-symbol"></span></button>
</div>
</div>
<div class="keypad-scroll-area">
<div class="keypad-panel">
<div class="keypad-grid" id="keypadGrid"></div>
</div>
<div class="functions-panel">
<div class="functions-grid" id="functionsGrid"></div>
</div>
<div class="trigo-panel">
<div class="trigo-grid" id="trigoGrid"></div>
</div>
</div>
<input id="hiddenInput" class="hidden-input" type="text" autocomplete="off" aria-hidden="true" tabindex="-1">
</section>
</main>
<script src="../../src/rpn-calculator.js"></script>
<script src="./index.js"></script>
</body>
</html>
+790
View File
@@ -0,0 +1,790 @@
const calc = new RpnCalculator({ angleMode: 'deg' });
const hiddenInput = document.getElementById('hiddenInput');
const modeButton = document.getElementById('modeButton');
const pasteButton = document.getElementById('pasteButton');
const upButton = document.getElementById('upButton');
const constButton = document.getElementById('constButton');
const leftButton = document.getElementById('leftButton');
const downButton = document.getElementById('downButton');
const rightButton = document.getElementById('rightButton');
const stackEls = {
T: document.getElementById('stackT'),
Z: document.getElementById('stackZ'),
Y: document.getElementById('stackY'),
X: document.getElementById('stackX'),
};
const stackCopyButtons = {
T: document.querySelector('[data-copy-stack="T"]'),
Z: document.querySelector('[data-copy-stack="Z"]'),
Y: document.querySelector('[data-copy-stack="Y"]'),
X: document.querySelector('[data-copy-stack="X"]'),
};
const keypadGrid = document.getElementById('keypadGrid');
const functionsGrid = document.getElementById('functionsGrid');
const trigoGrid = document.getElementById('trigoGrid');
const calculatorEl = document.querySelector('.calculator');
const statusLine = document.getElementById('statusLine');
const keypadKeys = [
{ label: 'C', action: 'clear', className: 'key-danger' },
{ label: '⌫', action: 'backspace', className: 'key-danger' },
{ label: '⎋', action: 'escape', className: 'key-escape' },
{ label: '⏎', action: 'enter', className: 'key-enter' },
{ label: '7', input: '7', className: 'key-default' },
{ label: '8', input: '8', className: 'key-default' },
{ label: '9', input: '9', className: 'key-default' },
{ label: '÷', action: 'div', className: 'key-accent' },
{ label: '4', input: '4', className: 'key-default' },
{ label: '5', input: '5', className: 'key-default' },
{ label: '6', input: '6', className: 'key-default' },
{ label: '×', action: 'mul', className: 'key-accent' },
{ label: '1', input: '1', className: 'key-default' },
{ label: '2', input: '2', className: 'key-default' },
{ label: '3', input: '3', className: 'key-default' },
{ label: '', action: 'sub', className: 'key-accent' },
{ label: '0', input: '0', className: 'key-default' },
{ label: '.', input: '.', className: 'key-default' },
{ label: '±', action: 'neg', className: 'key-default' },
{ label: '+', action: 'add', className: 'key-accent' },
];
const functionKeys = [
{ label: 'x²', action: 'sqr', className: 'key-default', title: 's' },
{ label: '√x', action: 'sqrt', className: 'key-default', title: 'r' },
{ label: '1/x', action: 'recip', className: 'key-default', title: 'x' },
{ label: '%', action: 'mod', className: 'key-default' },
{ label: 'yˣ', action: 'pow', className: 'key-default', title: 'S' },
{ label: 'y√x', action: 'root', className: 'key-default', title: 'R' },
{ label: '10ˣ', action: 'pow10', className: 'key-default', title: 'd' },
{ label: '', spacer: true },
{ label: 'log', action: 'log', className: 'key-default', title: 'l / L' },
{ label: 'ln', action: 'ln', className: 'key-default', title: 'n / N' },
{ label: 'eˣ', action: 'exp', className: 'key-default', title: 'e / E' },
{ label: '', spacer: true },
];
const trigoKeys = [
{ label: 'sin', action: 'sin', className: 'key-default', title: 'i' },
{ label: 'cos', action: 'cos', className: 'key-default', title: 'o' },
{ label: 'tan', action: 'tan', className: 'key-default', title: 'a' },
{ label: '', spacer: true },
{ label: 'asin', action: 'asin', className: 'key-default', title: 'I' },
{ label: 'acos', action: 'acos', className: 'key-default', title: 'O' },
{ label: 'atan', action: 'atan', className: 'key-default', title: 'A' },
{ label: '', spacer: true },
];
const isTouchDevice = window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window;
function focusInput() {
if (!hiddenInput || isTouchDevice) return;
hiddenInput.focus({ preventScroll: true });
window.requestAnimationFrame(() => {
if (document.activeElement !== hiddenInput) {
hiddenInput.focus({ preventScroll: true });
}
if (typeof hiddenInput.select === 'function') {
hiddenInput.select();
}
});
}
let statusTimer = null;
let editCursor = 0;
let editRestoreValue = null;
let stackMode = 'normal';
let stackSelection = 0;
let stackMoveSnapshot = null;
function setStatus(message, isError = false, timeoutMs = 1400) {
if (!statusLine) return;
clearTimeout(statusTimer);
statusLine.textContent = message;
statusLine.classList.toggle('is-error', isError);
statusLine.classList.toggle('is-visible', Boolean(message));
if (!message || timeoutMs <= 0) return;
statusTimer = window.setTimeout(() => {
statusLine.textContent = '';
statusLine.classList.remove('is-visible');
statusLine.classList.remove('is-error');
}, timeoutMs);
}
function clearStatus() {
clearTimeout(statusTimer);
statusTimer = null;
if (!statusLine) return;
statusLine.textContent = '';
statusLine.classList.remove('is-visible');
statusLine.classList.remove('is-error');
}
function getStackLine(indexFromTop) {
return indexFromTop >= 0 && indexFromTop < calc.stack.length ? calc.stack[indexFromTop] : '';
}
function getStackDisplayValue(label) {
if (label === 'X' && calc.isEditing) {
return calc.inputValue;
}
const indexMap = { X: 0, Y: 1, Z: 2, T: 3 };
const indexFromTop = calc.isEditing ? Math.max(0, indexMap[label] - 1) : indexMap[label];
return calc.formatNumber(getStackLine(indexFromTop)) || '';
}
function getVisibleStackLabel(label) {
if (stackMode === 'navigation' || stackMode === 'move') {
const indexMap = { X: 0, Y: 1, Z: 2, T: 3 };
return String(indexMap[label]);
}
return label;
}
function updateStackLabels() {
const stackLabels = ['T', 'Z', 'Y', 'X'];
for (const label of stackLabels) {
const stackCell = stackEls[label].parentElement;
if (!stackCell) continue;
const labelEl = stackCell.querySelector('.stack-label');
if (labelEl) {
labelEl.textContent = `${getVisibleStackLabel(label)}:`;
}
}
}
function stackModeToLabel(index) {
return ['X', 'Y', 'Z', 'T'][Math.max(0, Math.min(3, index))] ?? 'X';
}
function updateCopyButtons() {
for (const label of ['T', 'Z', 'Y', 'X']) {
const value = getStackDisplayValue(label);
const button = stackCopyButtons[label];
if (!button) continue;
button.classList.toggle('is-visible', Boolean(value));
button.disabled = !value;
button.setAttribute('aria-hidden', value ? 'false' : 'true');
}
}
function renderEditValue() {
const cursor = Math.max(0, Math.min(editCursor, calc.inputValue.length));
stackEls.X.innerHTML = `<span class="edit-text">${calc.inputValue.slice(0, cursor)}</span><span class="edit-caret" aria-hidden="true"></span><span class="edit-text">${calc.inputValue.slice(cursor)}</span>`;
}
function render() {
const isPortrait = window.matchMedia('(orientation: portrait)').matches || window.innerWidth <= 860;
calculatorEl?.classList.toggle('portrait', isPortrait);
calculatorEl?.classList.toggle('landscape', !isPortrait);
const stackLabels = ['T', 'Z', 'Y', 'X'];
for (const label of stackLabels) {
const isSelected = stackMode !== 'normal' && stackModeToLabel(stackSelection) === label;
const value = label === 'X' && calc.isEditing ? '' : getStackDisplayValue(label);
stackEls[label].textContent = value;
stackEls[label].classList.toggle('is-editing', label === 'X' && calc.isEditing);
stackEls[label].classList.toggle('is-caret-visible', label === 'X' && calc.isEditing);
stackEls[label].parentElement?.classList.toggle('is-selected', isSelected);
stackEls[label].parentElement?.classList.toggle('is-moving', stackMode === 'move' && stackModeToLabel(stackSelection) === label);
}
if (calc.isEditing) {
renderEditValue();
}
updateStackLabels();
updateCopyButtons();
modeButton.textContent = calc.angleMode;
}
function stopEditing(clearValue = false) {
if (clearValue) {
calc.inputValue = '';
}
calc.isEditing = false;
editCursor = 0;
}
function cancelEditing() {
if (editRestoreValue !== null) {
calc.push(editRestoreValue);
}
editRestoreValue = null;
stopEditing(true);
}
function enterNavigationMode() {
if (calc.isEditing) return;
stackMode = 'navigation';
stackSelection = 0;
stackMoveSnapshot = null;
render();
}
function exitStackMode() {
stackMode = 'normal';
stackSelection = 0;
stackMoveSnapshot = null;
render();
}
function moveNavigationSelection(delta) {
const maxIndex = Math.max(0, calc.stack.length - 1);
stackSelection = Math.max(0, Math.min(maxIndex, stackSelection + delta));
render();
}
function beginStackMove() {
stackMode = 'move';
stackMoveSnapshot = calc.stack.slice();
render();
}
function restoreStackMoveSnapshot() {
if (!stackMoveSnapshot) return;
calc.stack = stackMoveSnapshot.slice();
stackMoveSnapshot = null;
render();
}
function validateStackMove() {
stackMoveSnapshot = null;
stackMode = 'normal';
render();
}
function moveSelectedStackValue(delta) {
const index = stackSelection;
const target = Math.max(0, Math.min(calc.stack.length - 1, index + delta));
if (target === index) return;
const value = calc.stack.splice(index, 1)[0];
calc.stack.splice(target, 0, value);
stackSelection = target;
render();
}
function enterNavigationMode() {
if (calc.isEditing) return;
stackMode = 'navigation';
stackSelection = 0;
render();
}
function exitStackMode() {
stackMode = 'normal';
stackSelection = 0;
stackMoveSnapshot = null;
render();
}
function moveNavigationSelection(delta) {
const maxIndex = Math.max(0, calc.stack.length - 1);
stackSelection = Math.max(0, Math.min(maxIndex, stackSelection + delta));
render();
}
function beginStackMove() {
stackMode = 'move';
stackMoveSnapshot = calc.stack.slice();
render();
}
function restoreStackMoveSnapshot() {
if (!stackMoveSnapshot) return;
calc.stack = stackMoveSnapshot.slice();
stackMoveSnapshot = null;
render();
}
function validateStackMove() {
stackMoveSnapshot = null;
stackMode = 'normal';
render();
}
function moveSelectedStackValue(delta) {
const index = stackSelection;
const target = Math.max(0, Math.min(calc.stack.length - 1, index + delta));
if (target === index) return;
const value = calc.stack.splice(index, 1)[0];
calc.stack.splice(target, 0, value);
stackSelection = target;
render();
}
function moveEditCursor(delta) {
editCursor = Math.max(0, Math.min(calc.inputValue.length, editCursor + delta));
}
function pushEditingValueIfNeeded() {
if (!calc.isEditing) return;
if (calc.inputValue !== '') {
calc.push(calc.parseInputValue(calc.inputValue));
}
editRestoreValue = null;
calc.inputValue = '';
calc.isEditing = false;
editCursor = 0;
}
function startEditingFromStackTop() {
if (!calc.isValidIndex(0)) return false;
const value = calc.stack[0];
editRestoreValue = value;
calc.remove(0);
calc.isEditing = true;
calc.inputValue = calc.formatNumber(value);
editCursor = calc.inputValue.length;
return true;
}
function inputToX(value) {
if (!calc.isEditing) {
calc.isEditing = true;
calc.inputValue = '';
editCursor = 0;
}
if (value === 'Backspace') {
if (editCursor > 0) {
calc.inputValue = `${calc.inputValue.slice(0, editCursor - 1)}${calc.inputValue.slice(editCursor)}`;
editCursor -= 1;
}
} else {
calc.inputValue = `${calc.inputValue.slice(0, editCursor)}${value}${calc.inputValue.slice(editCursor)}`;
editCursor += value.length;
}
if (calc.inputValue === '') {
stopEditing();
}
}
function pasteTextIntoStack(text) {
if (!text) {
setStatus('Clipboard empty');
return;
}
const value = calc.parseInputValue(text);
if (!Number.isFinite(value)) {
setStatus('Clipboard is not a number');
return;
}
pushEditingValueIfNeeded();
calc.push(value);
calc.isEditing = false;
calc.inputValue = '';
setStatus('Pasted');
render();
}
function execute(name) {
try {
if (name === 'enter') {
if (calc.isEditing) {
pushEditingValueIfNeeded();
} else if (calc.isValidIndex(0)) {
calc.push(calc.stack[0]);
}
} else if (name === 'clear') {
calc.clear();
stopEditing(true);
} else if (name === 'escape') {
cancelEditing();
} else if (name === 'backspace') {
if (calc.isEditing) {
inputToX('Backspace');
} else if (calc.isValidIndex(0)) {
calc.remove(0);
}
} else if (name === 'swap') {
pushEditingValueIfNeeded();
if (calc.isValidIndex(1)) {
calc.swap(0, 1);
}
} else if (name === 'neg') {
if (calc.isEditing) {
const hasSign = calc.inputValue.startsWith('-');
calc.inputValue = hasSign ? calc.inputValue.slice(1) : `-${calc.inputValue}`;
moveEditCursor(hasSign ? -1 : 1);
} else {
calc.command('neg');
}
} else {
pushEditingValueIfNeeded();
calc.command(name);
}
if (!calc.isEditing) {
editCursor = 0;
}
render();
} catch (error) {
setStatus(error?.message || 'Operation error', true);
}
}
function createKeyButton({ label, input, action, spacer, className, title }) {
if (spacer) {
const div = document.createElement('div');
return div;
}
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.className = className;
if (title) button.title = title;
button.addEventListener('click', () => {
if (input) {
inputToX(input);
render();
return;
}
execute(action);
});
return button;
}
function buildGrid(container, keys) {
container.innerHTML = '';
keys.forEach((key) => container.appendChild(createKeyButton(key)));
}
async function copyStackValue(label) {
const value = getStackDisplayValue(label);
if (!value) return;
try {
await navigator.clipboard.writeText(value);
clearStatus();
} catch (error) {
setStatus('Copy unavailable', true);
}
}
function handleKeyboard(event) {
if (event.defaultPrevented) return;
const key = event.key;
if (stackMode === 'navigation') {
if (key === 'ArrowUp') {
event.preventDefault();
moveNavigationSelection(1);
return;
}
if (key === 'ArrowDown') {
event.preventDefault();
moveNavigationSelection(-1);
return;
}
if (key === 'Escape' || key === 'ArrowLeft') {
event.preventDefault();
exitStackMode();
return;
}
if (key === 'Enter') {
event.preventDefault();
beginStackMove();
return;
}
return;
}
if (stackMode === 'move') {
if (key === 'ArrowUp') {
event.preventDefault();
moveSelectedStackValue(1);
return;
}
if (key === 'ArrowDown') {
event.preventDefault();
moveSelectedStackValue(-1);
return;
}
if (key === 'Escape') {
event.preventDefault();
restoreStackMoveSnapshot();
exitStackMode();
return;
}
if (key === 'Enter') {
event.preventDefault();
validateStackMove();
return;
}
return;
}
if (/^[0-9.]$/.test(key)) {
event.preventDefault();
inputToX(key);
render();
return;
}
const map = {
Enter: 'enter',
Backspace: 'backspace',
Escape: 'escape',
Delete: 'clear',
'+': 'add',
'-': 'sub',
'*': 'mul',
'/': 'div',
'%': 'mod',
'^': 'pow',
s: 'sqr',
S: 'pow',
r: 'sqrt',
R: 'root',
x: 'recip',
d: 'pow10',
l: 'log',
L: 'log',
n: 'ln',
N: 'ln',
e: 'exp',
E: 'exp',
i: 'sin',
o: 'cos',
a: 'tan',
I: 'asin',
O: 'acos',
A: 'atan',
};
if (key === 'ArrowLeft') {
event.preventDefault();
if (calc.isEditing) {
moveEditCursor(-1);
render();
return;
}
leftButton.click();
return;
}
if (key === 'ArrowRight') {
event.preventDefault();
if (calc.isEditing) {
moveEditCursor(1);
render();
return;
}
rightButton.click();
return;
}
if (key === 'ArrowUp') {
event.preventDefault();
upButton.click();
return;
}
if (key === 'ArrowDown') {
event.preventDefault();
if (calc.isEditing) {
cancelEditing();
render();
return;
}
downButton.click();
return;
}
if (map[key]) {
event.preventDefault();
execute(map[key]);
}
}
const modeOptions = ['deg', 'rad', 'grad'];
let activeMenuEl = null;
function closeModeMenu() {
if (activeMenuEl) {
activeMenuEl.remove();
activeMenuEl = null;
}
}
function openMenu(anchorButton, items, onSelect) {
const rect = anchorButton.getBoundingClientRect();
const menu = document.createElement('div');
menu.className = 'menu-popup';
menu.style.top = `${rect.bottom + 6 + window.scrollY}px`;
menu.style.left = `${rect.left + window.scrollX}px`;
menu.style.minWidth = `${rect.width}px`;
for (const item of items) {
const button = document.createElement('button');
button.type = 'button';
button.className = `menu-popup-item${item.active ? ' is-active' : ''}`;
button.textContent = item.label;
button.addEventListener('click', () => onSelect(item.value));
menu.appendChild(button);
}
document.body.appendChild(menu);
return menu;
}
function toggleModeMenu() {
if (activeMenuEl) {
closeModeMenu();
return;
}
closeConstMenu();
activeMenuEl = openMenu(modeButton, modeOptions.map((mode) => ({
label: mode,
value: mode,
active: mode === calc.angleMode,
})), (mode) => {
calc.angleMode = mode;
render();
closeModeMenu();
});
}
modeButton.addEventListener('click', (event) => {
event.stopPropagation();
toggleModeMenu();
});
window.addEventListener('resize', () => {
closeModeMenu();
closeConstMenu();
render();
});
window.addEventListener('scroll', () => {
closeModeMenu();
closeConstMenu();
}, true);
window.addEventListener('click', (event) => {
const stackCopyButton = event.target.closest('.stack-copy-button');
if (stackCopyButton) {
const label = stackCopyButton.dataset.copyStack;
if (label) copyStackValue(label);
return;
}
if (activeMenuEl && !event.target.closest('.menu-popup') && event.target !== modeButton && event.target !== constButton) {
closeModeMenu();
closeConstMenu();
}
});
pasteButton.addEventListener('click', async () => {
try {
const text = await navigator.clipboard.readText();
pasteTextIntoStack(text);
clearStatus();
} catch (error) {
setStatus('Paste unavailable', true);
}
});
hiddenInput.addEventListener('paste', (event) => {
event.preventDefault();
const text = event.clipboardData?.getData('text') ?? '';
pasteTextIntoStack(text);
});
upButton.addEventListener('click', () => {
if (!calc.isEditing && stackMode === 'normal') {
enterNavigationMode();
focusInput();
}
});
const constantLabels = {
pi: 'π',
e: 'e',
phi: 'φ',
g: 'g',
c: 'C',
};
const constantOrder = ['pi', 'e', 'phi', 'g', 'c'];
function closeConstMenu() {
if (activeMenuEl) {
activeMenuEl.remove();
activeMenuEl = null;
}
}
function toggleConstMenu() {
if (activeMenuEl) {
closeConstMenu();
return;
}
closeModeMenu();
const availableConstants = calc.listConstants();
const keys = [...constantOrder, ...Object.keys(availableConstants).filter((name) => !constantOrder.includes(name))]
.filter((name) => Object.prototype.hasOwnProperty.call(availableConstants, name));
activeMenuEl = openMenu(constButton, keys.map((name) => ({
label: constantLabels[name] ?? name,
value: name,
})), (name) => {
pushEditingValueIfNeeded();
calc.push(availableConstants[name]);
render();
clearStatus();
closeConstMenu();
focusInput();
});
}
constButton.addEventListener('click', (event) => {
event.stopPropagation();
toggleConstMenu();
});
leftButton.addEventListener('click', () => {
if (calc.isEditing) {
moveEditCursor(-1);
render();
focusInput();
return;
}
if (stackMode === 'navigation') {
exitStackMode();
focusInput();
}
});
downButton.addEventListener('click', () => {
if (!calc.isEditing && startEditingFromStackTop()) {
render();
focusInput();
}
});
rightButton.addEventListener('click', () => {
if (calc.isEditing) {
moveEditCursor(1);
render();
focusInput();
return;
}
execute('swap');
});
window.addEventListener('keydown', handleKeyboard, { capture: true });
window.addEventListener('load', focusInput);
window.addEventListener('pageshow', focusInput);
window.addEventListener('focus', focusInput);
window.addEventListener('pointerdown', focusInput, true);
window.addEventListener('mousedown', focusInput, true);
hiddenInput.setAttribute('inputmode', 'none');
hiddenInput.setAttribute('readonly', 'readonly');
hiddenInput.addEventListener('focus', () => {
if (isTouchDevice) {
hiddenInput.blur();
return;
}
window.requestAnimationFrame(() => {
hiddenInput.select();
});
});
document.addEventListener('click', (event) => {
if (!isTouchDevice && !event.target.closest('.menu-popup')) {
focusInput();
}
});
buildGrid(keypadGrid, keypadKeys);
buildGrid(functionsGrid, functionKeys);
buildGrid(trigoGrid, trigoKeys);
render();
focusInput();
+52
View File
@@ -0,0 +1,52 @@
# Calc 02 Visual Portrait
## Display
```
┌──────────── Display ────────────┐
| T: |
| Z: |
| Y: |
| X: |
└─────────────────────────────────┘
```
## Display Buttons
```
┌──────── Display Buttons ────────┐
| Const | Paste | Up | Mode |
| | Left | Down | Right |
└─────────────────────────────────┘
```
## Keypad
```
┌──────────── Keypad ─────────────┐
| Clear | Backspace | Esc | Enter |
| 7 | 8 | 9 | / |
| 4 | 5 | 6 | * |
| 1 | 2 | 3 | - |
| 0 | . | +/- | + |
└─────────────────────────────────┘
```
## Functions
```
┌──────────── Functions ──────────┐
| x^2 | √x | 1/x | % |
| y^x | y√x | 10^x | |
| log | ln | e^x | |
└─────────────────────────────────┘
```
## Trigo
```
┌──────────── Trigo ──────────────┐
| sin | cos | tan | |
| asin | acos | atan | |
└─────────────────────────────────┘
```
+204
View File
@@ -0,0 +1,204 @@
:root {
--body: #d8d8d8;
--panel: #202020;
--panel-2: #2b2b2b;
--screen: #d8e7b8;
--screen-text: #1b2a12;
--screen-dim: #5b6f45;
--key: #3a3a3a;
--key-text: #f2f2f2;
--accent: #8cff6d;
--border: #111;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, sans-serif;
background: linear-gradient(180deg, #efefef, var(--body));
color: #111;
}
.wrap {
max-width: 980px;
margin: 0 auto;
padding: 24px;
}
.calc {
background: linear-gradient(180deg, #2f2f2f, #1f1f1f);
border: 1px solid #111;
border-radius: 20px;
padding: 18px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.25);
}
.brand {
color: #fafafa;
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
gap: 12px;
}
.brand h1 {
margin: 0;
font-size: 18px;
letter-spacing: 0.06em;
}
.brand small {
color: #c9c9c9;
}
.screen {
background: linear-gradient(180deg, #dbe8b8, var(--screen));
color: var(--screen-text);
border: 2px inset #8aa36b;
border-radius: 10px;
padding: 14px;
min-height: 190px;
font-family: "Courier New", monospace;
display: grid;
grid-template-rows: auto auto 1fr;
gap: 10px;
}
.screen-top {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: var(--screen-dim);
}
.stack {
border-top: 1px solid rgba(27, 42, 18, 0.35);
padding-top: 10px;
line-height: 1.5;
font-size: 18px;
white-space: pre-wrap;
}
.stack-line {
display: grid;
grid-template-columns: 26px 1fr;
gap: 8px;
padding: 1px 4px;
border-radius: 4px;
}
.stack-line.selected {
background: rgba(27, 42, 18, 0.14);
outline: 1px dashed rgba(27, 42, 18, 0.45);
}
.stack-line.moving {
background: rgba(140, 255, 109, 0.18);
outline: 1px solid rgba(27, 42, 18, 0.55);
}
.stack-line .label {
text-align: right;
color: var(--screen-dim);
}
.stack-line.selected .label,
.stack-line.moving .label {
color: var(--screen-text);
font-weight: bold;
}
.hidden-input {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.input-row {
display: grid;
grid-template-columns: 1fr 150px;
gap: 12px;
margin-top: 14px;
}
input, select, button {
border-radius: 10px;
border: 1px solid #000;
font: inherit;
}
input, select {
padding: 12px 14px;
background: #f7f7f7;
color: #111;
}
.panel {
margin-top: 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 14px;
}
.title {
color: #fff;
margin: 0 0 10px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.buttons {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
}
button {
padding: 12px 10px;
background: linear-gradient(180deg, #4a4a4a, var(--key));
color: var(--key-text);
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
button:hover { filter: brightness(1.08); }
button:active { transform: translateY(1px); }
.status {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 10px;
color: #ececec;
font-size: 13px;
}
.pill {
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 999px;
padding: 5px 10px;
background: rgba(255, 255, 255, 0.05);
}
.error {
margin-top: 10px;
min-height: 20px;
color: #ff8a8a;
font-family: "Courier New", monospace;
font-size: 13px;
}
.hint {
color: #ddd;
margin-top: 10px;
font-size: 13px;
line-height: 1.5;
}
+2 -644
View File
@@ -4,212 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HP48-style RPN Calculator</title>
<style>
:root {
--body: #d8d8d8;
--panel: #202020;
--panel-2: #2b2b2b;
--screen: #d8e7b8;
--screen-text: #1b2a12;
--screen-dim: #5b6f45;
--key: #3a3a3a;
--key-text: #f2f2f2;
--accent: #8cff6d;
--border: #111;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, sans-serif;
background: linear-gradient(180deg, #efefef, var(--body));
color: #111;
}
.wrap {
max-width: 980px;
margin: 0 auto;
padding: 24px;
}
.calc {
background: linear-gradient(180deg, #2f2f2f, #1f1f1f);
border: 1px solid #111;
border-radius: 20px;
padding: 18px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.25);
}
.brand {
color: #fafafa;
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
gap: 12px;
}
.brand h1 {
margin: 0;
font-size: 18px;
letter-spacing: 0.06em;
}
.brand small {
color: #c9c9c9;
}
.screen {
background: linear-gradient(180deg, #dbe8b8, var(--screen));
color: var(--screen-text);
border: 2px inset #8aa36b;
border-radius: 10px;
padding: 14px;
min-height: 190px;
font-family: "Courier New", monospace;
display: grid;
grid-template-rows: auto auto 1fr;
gap: 10px;
}
.screen-top {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: var(--screen-dim);
}
.stack {
border-top: 1px solid rgba(27, 42, 18, 0.35);
padding-top: 10px;
line-height: 1.5;
font-size: 18px;
white-space: pre-wrap;
}
.stack-line {
display: grid;
grid-template-columns: 26px 1fr;
gap: 8px;
padding: 1px 4px;
border-radius: 4px;
}
.stack-line.selected {
background: rgba(27, 42, 18, 0.14);
outline: 1px dashed rgba(27, 42, 18, 0.45);
}
.stack-line.moving {
background: rgba(140, 255, 109, 0.18);
outline: 1px solid rgba(27, 42, 18, 0.55);
}
.stack-line .label {
text-align: right;
color: var(--screen-dim);
}
.stack-line.selected .label,
.stack-line.moving .label {
color: var(--screen-text);
font-weight: bold;
}
.hidden-input {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.input-row {
display: grid;
grid-template-columns: 1fr 150px;
gap: 12px;
margin-top: 14px;
}
input, select, button {
border-radius: 10px;
border: 1px solid #000;
font: inherit;
}
input, select {
padding: 12px 14px;
background: #f7f7f7;
color: #111;
}
.panel {
margin-top: 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 14px;
}
.title {
color: #fff;
margin: 0 0 10px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.buttons {
display: grid;
gap: 8px;
grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
}
button {
padding: 12px 10px;
background: linear-gradient(180deg, #4a4a4a, var(--key));
color: var(--key-text);
cursor: pointer;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
button:hover { filter: brightness(1.08); }
button:active { transform: translateY(1px); }
.status {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 10px;
color: #ececec;
font-size: 13px;
}
.pill {
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 999px;
padding: 5px 10px;
background: rgba(255, 255, 255, 0.05);
}
.error {
margin-top: 10px;
min-height: 20px;
color: #ff8a8a;
font-family: "Courier New", monospace;
font-size: 13px;
}
.hint {
color: #ddd;
margin-top: 10px;
font-size: 13px;
line-height: 1.5;
}
</style>
<link rel="stylesheet" href="./index.css">
</head>
<body>
<div class="wrap">
@@ -270,443 +65,6 @@
</div>
<script src="../../src/rpn-calculator.js"></script>
<script>
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 inputValueLabel = document.getElementById('inputValueLabel');
const editingLabel = document.getElementById('editingLabel');
const modeLabel = document.getElementById('modeLabel');
const angleMode = document.getElementById('angleMode');
const groups = {
stack: ['enter', 'dup', 'drop', 'swap', 'clear'],
arithmetic: ['add', 'sub', 'mul', 'div', 'mod', 'pow', 'sqr', 'neg', 'sqrt', 'recip', 'log', 'ln'],
trig: ['sin', 'cos', 'tan', 'asin', 'acos', 'atan'],
const: ['pi', 'e'],
};
let stackCursor = null;
let isMovingStackItem = false;
let stackSnapshotBeforeMove = null;
function labelFor(command) {
return ({ add: '+', sub: '', mul: '×', div: '÷', pow: 'y^x', recip: '1/x', sqr: 'x²' }[command] || command);
}
function addButtons(container, commands) {
container.innerHTML = '';
commands.forEach((commandName) => {
const button = document.createElement('button');
button.textContent = labelFor(commandName);
button.addEventListener('click', () => execute(commandName));
container.appendChild(button);
});
}
function getStackValue(index) {
return calc.isValidIndex(index) ? calc.stack[index] : undefined;
}
function getLineValue(line) {
if (calc.isEditing) {
if (line === 0) {
return calc.inputValue;
}
return getStackValue(line - 1);
}
return getStackValue(line);
}
function hasStackSelection() {
return stackCursor !== null && calc.isValidIndex(stackCursor);
}
function clearStackSelection() {
stackCursor = null;
isMovingStackItem = false;
stackSnapshotBeforeMove = null;
}
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;
} else {
calc.inputValue = '';
calc.isEditing = true;
}
syncInputFromState();
}
function moveStackSelection(direction) {
if (!hasStackSelection()) {
if (direction === 'up') {
ensureValidSelection();
} else {
reactivateEditOnX();
}
return;
}
const nextIndex = direction === 'up' ? stackCursor + 1 : stackCursor - 1;
if (calc.isValidIndex(nextIndex)) {
stackCursor = nextIndex;
return;
}
if (direction === 'down' && stackCursor === 0) {
reactivateEditOnX();
}
}
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 render() {
const names = ['T', 'Z', 'Y', 'X'];
const lines = [];
for (let line = 3; line >= 0; line -= 1) {
const value = getLineValue(line);
const isSelected = stackCursor === line;
const classes = ['stack-line'];
if (isSelected) {
classes.push(isMovingStackItem ? 'moving' : 'selected');
}
lines.push(`<div class="${classes.join(' ')}"><div class="label">${names[3 - line]}</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: ${['X', 'Y', 'Z', 'T'][stackCursor] || '?'}`;
} else if (hasStackSelection()) {
displayEl.textContent = `SELECTED: ${['X', 'Y', 'Z', 'T'][stackCursor] || '?'}`;
} else {
displayEl.textContent = 'READY';
}
inputValueLabel.textContent = calc.inputValue || '∅';
editingLabel.textContent = String(calc.isEditing);
modeLabel.textContent = calc.angleMode;
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;
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 = '';
}
if (key === 'Backspace') {
calc.inputValue = calc.inputValue.slice(0, -1);
} else {
calc.inputValue += key;
}
if (calc.inputValue === '') {
calc.isEditing = false;
}
syncInputFromState();
}
function handleKeydown(event) {
if (shouldIgnoreKeyboardEvent(event)) {
return;
}
const action = getKeyboardAction(event);
if (!action) {
return;
}
try {
if (action.type === 'escapeKey') {
if (calc.isEditing) {
event.preventDefault();
calc.inputValue = '';
calc.isEditing = false;
syncInputFromState();
render();
return;
}
if (isMovingStackItem) {
event.preventDefault();
cancelMoveMode();
render();
}
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 (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', focusScreen);
window.addEventListener('load', focusScreen);
angleMode.addEventListener('change', (event) => {
calc.angleMode = event.target.value;
render();
});
addButtons(document.getElementById('stackButtons'), groups.stack);
addButtons(document.getElementById('arithButtons'), groups.arithmetic);
addButtons(document.getElementById('trigButtons'), groups.trig);
addButtons(document.getElementById('constButtons'), groups.const);
render();
focusScreen();
</script>
<script src="./index.js"></script>
</body>
</html>
+492
View File
@@ -0,0 +1,492 @@
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 inputValueLabel = document.getElementById('inputValueLabel');
const editingLabel = document.getElementById('editingLabel');
const modeLabel = document.getElementById('modeLabel');
const angleMode = document.getElementById('angleMode');
const groups = {
stack: ['enter', 'dup', 'drop', 'swap', 'clear'],
arithmetic: ['add', 'sub', 'mul', 'div', 'mod', 'pow', 'sqr', 'neg', 'sqrt', 'recip', 'log', 'ln'],
trig: ['sin', 'cos', 'tan', 'asin', 'acos', 'atan'],
const: ['pi', 'e'],
};
let stackCursor = null;
let isMovingStackItem = false;
let stackSnapshotBeforeMove = null;
let stackViewOffset = 0;
let editRestoreValue = null;
function labelFor(command) {
return ({ add: '+', sub: '', mul: '×', div: '÷', pow: 'y^x', recip: '1/x', sqr: 'x²' }[command] || command);
}
function addButtons(container, commands) {
container.innerHTML = '';
commands.forEach((commandName) => {
const button = document.createElement('button');
button.textContent = labelFor(commandName);
button.addEventListener('click', () => execute(commandName));
container.appendChild(button);
});
}
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';
}
inputValueLabel.textContent = calc.inputValue || '∅';
editingLabel.textContent = String(calc.isEditing);
modeLabel.textContent = calc.angleMode;
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') {
if (calc.isEditing) {
event.preventDefault();
if (editRestoreValue !== null) {
calc.push(editRestoreValue);
editRestoreValue = null;
}
calc.inputValue = '';
calc.isEditing = false;
syncInputFromState();
render();
return;
}
if (isMovingStackItem) {
event.preventDefault();
cancelMoveMode();
clearStackSelection();
render();
return;
}
if (hasStackSelection()) {
event.preventDefault();
clearStackSelection();
render();
}
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', focusScreen);
window.addEventListener('load', focusScreen);
angleMode.addEventListener('change', (event) => {
calc.angleMode = event.target.value;
render();
});
addButtons(document.getElementById('stackButtons'), groups.stack);
addButtons(document.getElementById('arithButtons'), groups.arithmetic);
addButtons(document.getElementById('trigButtons'), groups.trig);
addButtons(document.getElementById('constButtons'), groups.const);
render();
focusScreen();
File diff suppressed because it is too large Load Diff
+87 -2
View File
@@ -2,6 +2,9 @@ class RpnCalculator {
static CONSTANTS = {
pi: Math.PI,
e: Math.E,
phi: (1 + Math.sqrt(5)) / 2,
g: 9.80665,
c: 299792458,
};
static OPERATIONS = {
@@ -46,6 +49,32 @@ class RpnCalculator {
aliases: ['^', 'y^x'],
execute: (calc, a, b) => Math.pow(a, b),
},
pow10: {
category: 'Arithmetic',
argCount: 1,
aliases: ['10^x'],
execute: (calc, a) => Math.pow(10, a),
},
exp: {
category: 'Arithmetic',
argCount: 1,
aliases: ['e^x'],
execute: (calc, a) => Math.exp(a),
},
root: {
category: 'Arithmetic',
argCount: 2,
aliases: ['y√x', 'yroot', 'nroot'],
execute: (calc, a, b) => {
if (b === 0) {
throw new Error('Invalid input for root');
}
if (a < 0 && b % 2 === 0) {
throw new Error('Invalid input for root');
}
return Math.pow(a, 1 / b);
},
},
sqr: {
category: 'Arithmetic',
argCount: 1,
@@ -178,7 +207,7 @@ class RpnCalculator {
static getOperationsByCategory() {
return {
Stack: ['dup', 'drop', 'swap', 'clear', 'enter'],
Arithmetic: ['add', 'sub', 'mul', 'div', 'mod', 'pow', 'sqr', 'neg', 'sqrt', 'recip', 'log', 'ln'],
Arithmetic: ['add', 'sub', 'mul', 'div', 'mod', 'pow', 'pow10', 'exp', 'root', 'sqr', 'neg', 'sqrt', 'recip', 'log', 'ln'],
Trigonometry: ['sin', 'cos', 'tan', 'asin', 'acos', 'atan'],
};
}
@@ -198,6 +227,15 @@ class RpnCalculator {
this.enabledCommands = new Set(selectedCommands.map((name) => this.normalizeCommandName(name)).filter((name) => RpnCalculator.OPERATIONS[name]));
}
static isValidConstantName(name) {
return typeof name === 'string' && name.trim() !== '';
}
static isReservedName(name) {
const normalized = typeof name === 'string' ? name.toLowerCase() : '';
return Boolean(RpnCalculator.OPERATIONS[normalized]);
}
toRadians(value) {
if (this.angleMode === 'grad') {
return (value * Math.PI) / 200;
@@ -243,6 +281,45 @@ class RpnCalculator {
return typeof name === 'string' && Object.prototype.hasOwnProperty.call(this.constants, name.toLowerCase());
}
setConstant(name, value) {
if (!RpnCalculator.isValidConstantName(name)) {
throw new Error('Invalid constant name');
}
if (!Number.isFinite(value)) {
throw new Error('Invalid constant value');
}
const normalized = name.trim().toLowerCase();
if (RpnCalculator.isReservedName(normalized)) {
throw new Error(`Constant name conflicts with a command: ${name}`);
}
this.constants[normalized] = value;
return value;
}
removeConstant(name) {
if (!RpnCalculator.isValidConstantName(name)) {
throw new Error('Invalid constant name');
}
const normalized = name.trim().toLowerCase();
if (!this.isConstantName(normalized)) {
return false;
}
delete this.constants[normalized];
return true;
}
hasConstant(name) {
if (!RpnCalculator.isValidConstantName(name)) {
return false;
}
return this.isConstantName(name.trim().toLowerCase());
}
push(value) {
if (this.stack.length >= this.maxSize) {
throw new Error('Stack overflow');
@@ -369,9 +446,13 @@ class RpnCalculator {
throw new Error('Stack underflow');
}
const operands = argCount > 0 ? this.stack.splice(0, argCount).reverse() : [];
const operands = argCount > 0 ? this.stack.slice(0, argCount).reverse() : [];
const result = operation.execute(this, ...operands, ...args);
if (argCount > 0) {
this.stack.splice(0, argCount);
}
if (Array.isArray(result)) {
for (let i = result.length - 1; i >= 0; i -= 1) {
this.push(result[i]);
@@ -390,6 +471,10 @@ class RpnCalculator {
getConstants() {
return { ...this.constants };
}
listConstants() {
return this.getConstants();
}
}
if (typeof window !== 'undefined') {