feat: add responsive calc-02 HP48GX demo

This commit is contained in:
2026-05-15 18:45:44 +02:00
parent 95eb1d265f
commit 48a262eb87
7 changed files with 775 additions and 4 deletions
+2 -1
View File
@@ -1,6 +1,7 @@
# State # State
- Core engine: `src/rpn-calculator.js` - 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` - Public API: `push`, `pop`, `clear`, `swap`, `remove`, `edit`, `isValidIndex`, `input`, `command`, `getOperationsByCategory`, `getConstants`
- Config: `maxSize`, `base`, `angleMode`, `enabledCommands` - Config: `maxSize`, `base`, `angleMode`, `enabledCommands`
- Commands: arithmetic, stack, trigonometry, constants `pi` and `e` - Commands: arithmetic, stack, trigonometry, constants `pi` and `e`
+8 -3
View File
@@ -22,9 +22,9 @@ The main class is `RpnCalculator`.
- `samples/calc-01/index.html`: active browser demo entry point - `samples/calc-01/index.html`: active browser demo entry point
- `samples/calc-01/index.css`: demo styles - `samples/calc-01/index.css`: demo styles
- `samples/calc-01/index.js`: demo UI and keyboard logic - `samples/calc-01/index.js`: demo UI and keyboard logic
- `samples/calc-01/index.html`: alternate browser demo entry point - `samples/calc-02/index.html`: new responsive HP48GX-style demo entry point
- `samples/calc-01/index.css`: alternate demo styles - `samples/calc-02/index.css`: new responsive demo styles
- `samples/calc-01/index.js`: alternate demo UI and keyboard logic - `samples/calc-02/index.js`: new demo UI and keyboard logic
- `samples/calc-XX/`: placeholder name for future demo variants - `samples/calc-XX/`: placeholder name for future demo variants
## Public API ## 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. 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. 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 ## Exports
`RpnCalculator` is exposed in both environments: `RpnCalculator` is exposed in both environments:
+367
View File
@@ -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;
}
}
+52
View File
@@ -0,0 +1,52 @@
<!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">
</head>
<body>
<main class="app-shell">
<section class="calculator calculator-portrait" aria-label="HP48GX style RPN calculator">
<div class="display-panel">
<div class="display-frame">
<div class="display-grid">
<div class="stack-cell"><span class="stack-label">T:</span><span id="stackT" class="stack-value"></span></div>
<div class="stack-cell"><span class="stack-label">Z:</span><span id="stackZ" class="stack-value"></span></div>
<div class="stack-cell"><span class="stack-label">Y:</span><span id="stackY" class="stack-value"></span></div>
<div class="stack-cell"><span class="stack-label">X:</span><span id="stackX" class="stack-value"></span></div>
</div>
</div>
</div>
<div class="display-buttons-panel">
<button id="modeButton" class="display-button">Mode</button>
<button id="pasteButton" class="display-button">Paste</button>
<button id="upButton" class="display-button">Up</button>
<button id="constButton" class="display-button">Const</button>
<button id="rightButton" class="display-button display-button-offset">Right</button>
<button id="downButton" class="display-button">Down</button>
<button id="constRightButton" class="display-button">Right</button>
</div>
<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>
<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>
+310
View File
@@ -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();
+10
View File
@@ -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 | └──────────────────────┘
└─────────────────────────────────┘
+26
View File
@@ -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 | |
└─────────────────────────────────┘