diff --git a/.memory/state.md b/.memory/state.md index e95da20..0ad015f 100644 --- a/.memory/state.md +++ b/.memory/state.md @@ -1,6 +1,7 @@ # State - Core engine: `src/rpn-calculator.js` -- Active demo: `samples/dev/` (HP48-style UI) +- Active demo: `samples/calc-02/` responsive HP48GX layout driven by portrait/landscape text mockups; display-adjacent button row stays in 4 columns +- Mode button shows the current angle mode only; selecting a mode uses a popup menu - Public API: `push`, `pop`, `clear`, `swap`, `remove`, `edit`, `isValidIndex`, `input`, `command`, `getOperationsByCategory`, `getConstants` - Config: `maxSize`, `base`, `angleMode`, `enabledCommands` - Commands: arithmetic, stack, trigonometry, constants `pi` and `e` diff --git a/README.md b/README.md index 703033d..45a926b 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ The main class is `RpnCalculator`. - `samples/calc-01/index.html`: active browser demo entry point - `samples/calc-01/index.css`: demo styles - `samples/calc-01/index.js`: demo UI and keyboard logic -- `samples/calc-01/index.html`: alternate browser demo entry point -- `samples/calc-01/index.css`: alternate demo styles -- `samples/calc-01/index.js`: alternate demo UI and keyboard logic +- `samples/calc-02/index.html`: new responsive HP48GX-style demo entry point +- `samples/calc-02/index.css`: new responsive demo styles +- `samples/calc-02/index.js`: new demo UI and keyboard logic - `samples/calc-XX/`: placeholder name for future demo variants ## Public API @@ -268,6 +268,11 @@ The current demo supports: 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. +## Calc 02 demo + +`samples/calc-02/` is a new responsive HP48GX-inspired demo. +It adapts its layout to the browser window and switches between the supplied portrait and landscape arrangements. + ## Exports `RpnCalculator` is exposed in both environments: diff --git a/samples/calc-02/index.css b/samples/calc-02/index.css new file mode 100644 index 0000000..1c5978e --- /dev/null +++ b/samples/calc-02/index.css @@ -0,0 +1,367 @@ +: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; + --btnEnterTop: #465349; + --btnEnterBottom: #303a31; + --btnText: #eef2f7; +} + +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + min-height: 100%; +} + +body { + min-height: 100vh; + font-family: Arial, sans-serif; + color: var(--buttonText); + background: + radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 32%), + linear-gradient(180deg, var(--bg1), var(--bg0)); +} + +.app-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: clamp(12px, 2vw, 28px); +} + +.calculator { + width: min(100vw - 24px, 1120px); + height: min(100vh - 24px, 900px); + display: grid; + gap: clamp(10px, 1.4vw, 18px); + padding: clamp(12px, 1.8vw, 18px); + border-radius: 28px; + background: linear-gradient(180deg, var(--panel2), var(--panel)); + border: 1px solid var(--edge); + box-shadow: 0 26px 70px var(--shadow), inset 0 1px 0 rgba(255, 255, 255, 0.08); + grid-template-columns: 1.3fr 0.9fr; + grid-template-rows: minmax(0, 0.62fr) min-content minmax(180px, 1fr) minmax(180px, 1fr); + align-content: start; + grid-template-areas: + "display functions" + "buttons functions" + "keypad functions" + "keypad trigo"; +} + +.display-panel, +.display-buttons-panel, +.keypad-panel, +.functions-panel, +.trigo-panel, +.status-line { + border-radius: 18px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(6, 10, 16, 0.16); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.display-panel { + grid-area: display; + padding: clamp(12px, 1.5vw, 16px); + background: linear-gradient(180deg, var(--display), var(--display2)); + color: var(--displayText); + font-family: "Courier New", monospace; + overflow: hidden; + height: clamp(112px, 18vw, 160px); + max-height: 140px; + align-self: start; + margin-bottom: 0; + min-height: 0; +} + +.display-grid { + height: 100%; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: repeat(4, minmax(0, 1fr)); + gap: 2px; +} + +.stack-cell { + display: grid; + grid-template-columns: 2.2ch 1fr; + align-items: center; + gap: 12px; + font-size: clamp(18px, 3vw, 30px); + line-height: 1; + min-height: 0; + padding-block: 0; +} + +.stack-label { + text-align: right; + opacity: 0.78; +} + +.stack-value { + min-height: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.display-buttons-panel { + grid-area: buttons; + padding: 8px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-rows: repeat(2, auto); + gap: 8px; + align-content: start; + align-items: stretch; + grid-auto-flow: row; + grid-auto-rows: auto; + background: linear-gradient(180deg, #242a33, #1a1f27); + border-color: rgba(255, 255, 255, 0.04); + margin-top: 0; +} + +.display-button { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 3px 0 rgba(0, 0, 0, 0.34); + background-clip: padding-box; + background: linear-gradient(180deg, #3a414c, #252b34); + color: #e8edf3; + border-color: rgba(255, 255, 255, 0.05); +} + +.display-button-offset { + grid-column-start: 2; +} + +.display-buttons-panel > button { + width: 100%; +} + +.mode-menu { + position: fixed; + z-index: 20; + display: grid; + gap: 6px; + padding: 10px; + border-radius: 14px; + background: rgba(18, 24, 33, 0.98); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 14px 30px rgba(0, 0, 0, 0.35); +} + +.mode-menu-item { + min-width: 120px; + background: linear-gradient(180deg, var(--btnAltTop), var(--btnAltBottom)); +} + +.mode-menu-item.is-active { + outline: 2px solid rgba(207, 224, 174, 0.7); +} + +.display-button:nth-child(6), +.display-button:nth-child(7) { + background: linear-gradient(180deg, #343b46, #20262e); +} + +.keypad-panel { + grid-area: keypad; + padding: 10px; +} + +.functions-panel { + grid-area: functions; + padding: 10px; + align-self: start; + min-height: 0; + padding-top: 10px; +} + +.trigo-panel { + grid-area: trigo; + padding: 10px; + align-self: start; + min-height: 0; + padding-top: 10px; +} + +.status-line { + grid-area: status; + padding: 10px 14px; + display: flex; + align-items: center; + min-height: 42px; + font-size: 14px; + color: rgba(255, 255, 255, 0.85); +} + +.keypad-grid, +.functions-grid, +.trigo-grid { + display: grid; + gap: 8px; + grid-auto-rows: minmax(0, 1fr); +} + +.keypad-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-rows: repeat(5, minmax(0, 1fr)); +} + +.functions-grid, +.trigo-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.functions-grid, +.trigo-grid { + grid-template-rows: repeat(2, minmax(0, 1fr)); +} + +button { + border: 1px solid rgba(14, 18, 25, 0.85); + border-radius: 12px; + padding: 10px 8px; + font: inherit; + font-weight: 700; + color: var(--btnText); + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.35); + cursor: pointer; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 3px 0 rgba(0, 0, 0, 0.28); + transition: transform 120ms ease, filter 120ms ease, box-shadow 120ms ease; +} + +button:hover { + filter: brightness(1.06); +} + +.display-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); +} + +.display-button:active { + transform: translateY(2px); +} + +.key-default { + background: linear-gradient(180deg, var(--btnTop), var(--btnBottom)); + color: #eef2f7; +} + +.key-accent { + background: linear-gradient(180deg, var(--btnAccentTop), var(--btnAccentBottom)); + color: #eef2f7; +} + +.key-alt { + background: linear-gradient(180deg, var(--btnAltTop), var(--btnAltBottom)); + 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; +} + +.hidden-input { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +@media (orientation: portrait), (max-width: 860px) { + .calculator { + width: min(100vw - 16px, 760px); + height: auto; + min-height: calc(100vh - 16px); + grid-template-columns: 1fr; + grid-template-rows: minmax(160px, auto) auto minmax(220px, auto) auto auto; + grid-template-areas: + "display" + "buttons" + "keypad" + "functions" + "trigo"; + } + + .display-buttons-panel { + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-rows: repeat(2, auto); + margin-top: 0; + } + + .keypad-grid { + grid-template-rows: repeat(5, minmax(42px, 1fr)); + } + + .functions-grid, + .trigo-grid { + grid-auto-rows: minmax(0, 1fr); + grid-template-rows: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 520px) { + .app-shell { + padding: 8px; + } + + .calculator { + width: 100%; + min-height: calc(100vh - 16px); + border-radius: 20px; + padding: 10px; + gap: 10px; + } + + .display-panel { + padding: 10px; + } + + .stack-cell { + font-size: clamp(16px, 5.2vw, 22px); + gap: 8px; + } + + button { + border-radius: 10px; + padding: 8px 6px; + font-size: 13px; + } + + .display-buttons-panel { + gap: 6px; + } + +} diff --git a/samples/calc-02/index.html b/samples/calc-02/index.html new file mode 100644 index 0000000..e69cdf3 --- /dev/null +++ b/samples/calc-02/index.html @@ -0,0 +1,52 @@ + + + + + + HP48GX RPN Calculator + + + +
+
+
+
+
+
T:
+
Z:
+
Y:
+
X:
+
+
+
+ +
+ + + + + + + +
+ +
+
+
+ +
+
+
+ +
+
+
+ + +
+
+ + + + + \ No newline at end of file diff --git a/samples/calc-02/index.js b/samples/calc-02/index.js new file mode 100644 index 0000000..b5baea9 --- /dev/null +++ b/samples/calc-02/index.js @@ -0,0 +1,310 @@ +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 rightButton = document.getElementById('rightButton'); +const downButton = document.getElementById('downButton'); +const constRightButton = document.getElementById('constRightButton'); + +const stackEls = { + T: document.getElementById('stackT'), + Z: document.getElementById('stackZ'), + Y: document.getElementById('stackY'), + X: document.getElementById('stackX'), +}; + +const keypadGrid = document.getElementById('keypadGrid'); +const functionsGrid = document.getElementById('functionsGrid'); +const trigoGrid = document.getElementById('trigoGrid'); +const calculatorEl = document.querySelector('.calculator'); + +const keypadKeys = [ + { label: '±', action: 'neg', className: 'key-default' }, + { label: 'C', action: 'clear', className: 'key-danger' }, + { label: '⎋', action: 'escape', className: 'key-danger' }, + { label: '⌫', action: 'backspace', className: 'key-danger' }, + { 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: 'Enter', action: 'enter', className: 'key-enter' }, + { label: '+', action: 'add', className: 'key-accent' }, +]; + +const functionKeys = [ + { label: 'x²', action: 'sqr', className: 'key-default' }, + { label: 'yˣ', action: 'pow', className: 'key-default' }, + { label: '1/x', action: 'recip', className: 'key-default' }, + { label: '%', action: 'mod', className: 'key-default' }, + { label: '√x', action: 'sqrt', className: 'key-default' }, + { label: 'y√x', action: 'pow', className: 'key-default' }, + { label: '10ˣ', action: 'pow10', className: 'key-default' }, + { label: '', spacer: true }, + { label: 'log', action: 'log', className: 'key-default' }, + { label: 'ln', action: 'ln', className: 'key-default' }, + { label: '', spacer: true }, + { label: '', spacer: true }, +]; + +const trigoKeys = [ + { label: 'sin', action: 'sin', className: 'key-default' }, + { label: 'cos', action: 'cos', className: 'key-default' }, + { label: 'tan', action: 'tan', className: 'key-default' }, + { label: '', spacer: true }, + { label: 'asin', action: 'asin', className: 'key-default' }, + { label: 'acos', action: 'acos', className: 'key-default' }, + { label: 'atan', action: 'atan', className: 'key-default' }, + { label: '', spacer: true }, +]; + + +function focusInput() { + hiddenInput.focus(); +} + +function setStatus(message) { + console.log(message); +} + +function normalizeStack() { + while (calc.stack.length > 4) { + calc.stack.shift(); + } +} + +function getStackLine(indexFromTop) { + const index = calc.stack.length - 1 - indexFromTop; + return index >= 0 ? calc.stack[index] : ''; +} + +function render() { + normalizeStack(); + const isPortrait = window.matchMedia('(orientation: portrait)').matches || window.innerWidth <= 860; + calculatorEl?.classList.toggle('portrait', isPortrait); + calculatorEl?.classList.toggle('landscape', !isPortrait); + stackEls.T.textContent = calc.formatNumber(getStackLine(3)) || ''; + stackEls.Z.textContent = calc.formatNumber(getStackLine(2)) || ''; + stackEls.Y.textContent = calc.formatNumber(getStackLine(1)) || ''; + stackEls.X.textContent = calc.formatNumber(getStackLine(0)) || (calc.isEditing ? calc.inputValue : ''); + modeButton.textContent = calc.angleMode; +} + +function pushEditingValueIfNeeded() { + if (!calc.isEditing) return; + if (calc.inputValue !== '') { + calc.push(calc.parseInputValue(calc.inputValue)); + } + calc.inputValue = ''; + calc.isEditing = false; +} + +function inputToX(value) { + if (!calc.isEditing) { + calc.isEditing = true; + calc.inputValue = ''; + } + if (value === 'Backspace') { + calc.inputValue = calc.inputValue.slice(0, -1); + } else { + calc.inputValue += value; + } + if (calc.inputValue === '') { + calc.isEditing = false; + } +} + +function execute(name) { + try { + if (name === 'enter') { + pushEditingValueIfNeeded(); + } else if (name === 'clear') { + calc.clear(); + calc.inputValue = ''; + calc.isEditing = false; + } else if (name === 'escape') { + calc.inputValue = ''; + calc.isEditing = false; + } else if (name === 'backspace') { + if (calc.isEditing) { + inputToX('Backspace'); + } else { + calc.remove(0); + } + } else if (name === 'neg') { + if (calc.isEditing) { + calc.inputValue = calc.inputValue.startsWith('-') ? calc.inputValue.slice(1) : `-${calc.inputValue}`; + } else { + calc.push(calc.pop() * -1); + } + } else if (name === 'pow10') { + pushEditingValueIfNeeded(); + calc.push(10); + calc.command('pow'); + } else { + pushEditingValueIfNeeded(); + calc.command(name); + } + render(); + } catch (error) { + console.error(error); + } +} + +function createKeyButton({ label, input, action, spacer, className }) { + if (spacer) { + const div = document.createElement('div'); + return div; + } + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + button.className = className; + button.addEventListener('click', () => { + focusInput(); + if (input) { + inputToX(input); + render(); + return; + } + execute(action); + }); + return button; +} + +function buildGrid(container, keys) { + container.innerHTML = ''; + keys.forEach((key) => container.appendChild(createKeyButton(key))); +} + +function handleKeyboard(event) { + if (event.target === hiddenInput) return; + const key = event.key; + 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', + }; + if (map[key]) { + event.preventDefault(); + execute(map[key]); + } +} + +const modeOptions = ['deg', 'rad', 'grad']; +let modeMenuEl = null; + +function closeModeMenu() { + if (modeMenuEl) { + modeMenuEl.remove(); + modeMenuEl = null; + } +} + +function openModeMenu() { + closeModeMenu(); + const rect = modeButton.getBoundingClientRect(); + modeMenuEl = document.createElement('div'); + modeMenuEl.className = 'mode-menu'; + modeMenuEl.style.top = `${rect.bottom + 6 + window.scrollY}px`; + modeMenuEl.style.left = `${rect.left + window.scrollX}px`; + modeOptions.forEach((mode) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `mode-menu-item${mode === calc.angleMode ? ' is-active' : ''}`; + button.textContent = mode; + button.addEventListener('click', () => { + calc.angleMode = mode; + render(); + closeModeMenu(); + }); + modeMenuEl.appendChild(button); + }); + document.body.appendChild(modeMenuEl); + const menuRect = modeMenuEl.getBoundingClientRect(); + const maxLeft = Math.max(8, window.innerWidth - menuRect.width - 8); + modeMenuEl.style.left = `${Math.max(8, Math.min(maxLeft, rect.left + window.scrollX))}px`; +} + +modeButton.addEventListener('click', (event) => { + event.stopPropagation(); + openModeMenu(); +}); + +window.addEventListener('resize', closeModeMenu); +window.addEventListener('scroll', closeModeMenu, true); + +window.addEventListener('click', (event) => { + if (modeMenuEl && !event.target.closest('.mode-menu') && event.target !== modeButton) { + closeModeMenu(); + } +}); + +pasteButton.addEventListener('click', async () => { + try { + const text = await navigator.clipboard.readText(); + if (!text) { + setStatus('Clipboard empty'); + return; + } + if (calc.isEditing) { + calc.inputValue += text; + } else { + calc.isEditing = true; + calc.inputValue = text; + } + setStatus('Pasted'); + render(); + } catch (error) { + setStatus('Paste unavailable'); + } +}); + +upButton.addEventListener('click', () => {}); + +constButton.addEventListener('click', () => {}); + +rightButton.addEventListener('click', () => {}); + +downButton.addEventListener('click', () => {}); + +constRightButton.addEventListener('click', () => {}); + +window.addEventListener('keydown', handleKeyboard); +window.addEventListener('load', focusInput); + +document.addEventListener('click', (event) => { + if (!event.target.closest('.calculator')) { + focusInput(); + } +}); + +buildGrid(keypadGrid, keypadKeys); +buildGrid(functionsGrid, functionKeys); +buildGrid(trigoGrid, trigoKeys); +render(); diff --git a/samples/calc-02/visual-landscape.txt b/samples/calc-02/visual-landscape.txt new file mode 100644 index 0000000..02f6667 --- /dev/null +++ b/samples/calc-02/visual-landscape.txt @@ -0,0 +1,10 @@ +┌──────────── Display ────────────┐ ┌────── Functions ─────┐ ┌──────────── Keypad ─────────────┐ +| T: | | x^2 | y^x | 1/x | % | | +/- | Clear | Esc | backspace | +| Z: | | √x | y√x | 10^x | | | 7 | 8 | 9 | / | +| Y: | | log | ln | | | | 4 | 5 | 6 | * | +| X: | └──────────────────────┘ | 1 | 2 | 3 | - | +└─────────────────────────────────┘ ┌─────── Trigo ────────┐ | 0 | . | Enter | + | +┌──────── Display Buttons ────────┐ | sin | cos | tan | └─────────────────────────────────┘ +| Mode | Paste | Up | Const | | asin | acos | atan | +| | Right | Down | Right | └──────────────────────┘ +└─────────────────────────────────┘ diff --git a/samples/calc-02/visual-portrait.txt b/samples/calc-02/visual-portrait.txt new file mode 100644 index 0000000..ccfcb6f --- /dev/null +++ b/samples/calc-02/visual-portrait.txt @@ -0,0 +1,26 @@ +┌──────────── Display ────────────┐ +| T: | +| Z: | +| Y: | +| X: | +└─────────────────────────────────┘ +┌──────── Display Buttons ────────┐ +| Mode | Paste | Up | Const | +| | Right | Down | Right | +└─────────────────────────────────┘ +┌──────────── Keypad ─────────────┐ +| +/- | Clear | Esc | backspace | +| 7 | 8 | 9 | / | +| 4 | 5 | 6 | * | +| 1 | 2 | 3 | - | +| 0 | . | Enter | + | +└─────────────────────────────────┘ +┌─────────── Functions ───────────┐ +| x^2 | y^x | 1/x | % | +| √x | y√x | 10^x | | +| log | ln | | | +└─────────────────────────────────┘ +┌───────────── Trigo ─────────────┐ +| sin | cos | tan | | +| asin | acos | atan | | +└─────────────────────────────────┘