AI先生のロボットキャラクター
第12章 - セクション1

ブラウザ「戻るボタン」風の履歴機能付き電卓

「←」クリックで履歴を見れて、「←」長押しで、計算結果と計算式を選択できます。計算を途中から再開でき、計算履歴はローカルストレージに保存されます。

電卓アプリケーションは、Web開発の定番プロジェクトですが、多くの電卓は計算履歴機能が費弱です。このパーツでは、ブラウザの「戻る」ボタンに着想を得た、直感的な履歴機能付き電卓を実装します。

「←」ボタンの短いクリックで履歴を順次閲覧でき、長押しで履歴一覧を開いて直接選択できる仕様です。また、localStorageを活用して計算履歴を永続化し、ブラウザを閉じても履歴が残る実用的な電卓を目指します。

HTML
<section class="shell" aria-label="電卓">
    <div class="screen">
        <div class="display" role="status" aria-live="polite" aria-atomic="true">
            <div id="expr" class="expr"></div>
            <div id="value" class="value">0</div>
        </div>
    </div>

    <div class="pad">
        <div class="keys" role="group" aria-label="電卓ボタン">
            <button id="backBtn" class="key" data-action="back" data-kind="util" type="button" aria-label="前の結果" title="前の結果(長押しで履歴)">
                <svg width="22" height="22" viewbox="0 0 24 24" aria-hidden="true" focusable="false" style="display:block;margin:auto">
                    <path d="M19 12H5M5 12l6-6M5 12l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"></path>
                </svg>
            </button>
            <button class="key" data-action="backspace" data-kind="util" type="button" aria-label="1文字削除">⌫</button>
            <button class="key" data-action="clear" data-kind="danger" type="button" aria-label="クリア">C</button>
            <button class="key" data-action="op" data-value="/" data-kind="op" type="button" aria-label="割り算">÷</button>

            <button class="key" data-action="digit" data-value="7" type="button">7</button>
            <button class="key" data-action="digit" data-value="8" type="button">8</button>
            <button class="key" data-action="digit" data-value="9" type="button">9</button>
            <button class="key" data-action="op" data-value="*" data-kind="op" type="button" aria-label="掛け算">×</button>

            <button class="key" data-action="digit" data-value="4" type="button">4</button>
            <button class="key" data-action="digit" data-value="5" type="button">5</button>
            <button class="key" data-action="digit" data-value="6" type="button">6</button>
            <button class="key" data-action="op" data-value="-" data-kind="op" type="button" aria-label="引き算">−</button>

            <button class="key" data-action="digit" data-value="1" type="button">1</button>
            <button class="key" data-action="digit" data-value="2" type="button">2</button>
            <button class="key" data-action="digit" data-value="3" type="button">3</button>
            <button class="key" data-action="op" data-value="+" data-kind="op" type="button" aria-label="足し算">+</button>

            <button class="key" data-action="digit" data-value="0" type="button">0</button>
            <button class="key" data-action="dot" data-value="." type="button">.</button>
            <button class="key" data-action="equals" data-kind="equals" type="button" style="grid-column: span 2;">=</button>
        </div>
    </div>

    <aside id="historyPanel" class="history-panel" aria-hidden="true" aria-label="計算履歴">
        <div class="history-head">
            <h2 class="history-title">計算履歴</h2>
            <button id="historyClose" class="history-close" type="button" aria-label="履歴を閉じる">
                <svg width="16" height="16" viewbox="0 0 24 24" aria-hidden="true" focusable="false">
                    <path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"></path>
                </svg>
            </button>
        </div>
        <ul id="historyList" class="history-list"></ul>
    </aside>
</section>
CSS
:root {
    --calc-bg: #b8dcc6;
    --calc-border: #9bc5a7;
    --text: #2c3e3f;
    --display-bg: #f8fdf9;
    --display-text: #1a2322;
    --key-bg: #c8e6d0;
    --key-hover: #b5dac3;
    --key-border: #a5d0b5;
    --op-key: #9bc5a7;
    --util-key: #a8ceb5;
    --util-key-hover: #95bcaa;
    --danger-key: #e8b5b5;
    --equals-key: #7ab68f;
    --shadow: 0 8px 20px rgba(44, 62, 63, .15);
    --radius: 8px;
}

* {
    box-sizing: border-box;
}

body {
    margin: 0;
    font-family: 'Poppins', system-ui, sans-serif;
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 100vh;
}

.shell {
    width: min(340px, 100%);
    border-radius: var(--radius);
    background: var(--calc-bg);
    box-shadow: var(--shadow);
    border: 2px solid var(--calc-border);
    overflow: hidden;
    padding: 16px;
    position: relative;
}

.screen {
    padding: 0 0 16px 0;
}

.display {
    border-radius: 6px;
    background: var(--display-bg);
    color: var(--display-text);
    border: 2px inset var(--calc-border);
    padding: 16px 12px;
    box-shadow: inset 0 2px 4px rgba(44, 62, 63, .08);
    min-height: 70px;
    display: grid;
    gap: 4px;
    align-content: center;
}

.expr {
    font-size: 12px;
    color: rgba(44, 62, 63, .60);
    min-height: 16px;
    word-break: break-all;
}

.value {
    font-size: clamp(20px, 5vw, 28px);
    font-weight: 700;
    letter-spacing: .02em;
    text-align: right;
    min-height: 32px;
    word-break: break-all;
    color: var(--display-text);
}

.pad {
    padding: 0;
    display: grid;
    gap: 10px;
}

.keys {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 10px;
}

.key {
    appearance: none;
    border: 1px outset var(--key-border);
    background: var(--key-bg);
    border-radius: var(--radius);
    padding: 14px 10px;
    min-height: 52px;
    font-size: 16px;
    font-weight: 600;
    color: var(--text);
    cursor: pointer;
    transition: background .12s ease;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
}

.key:hover {
    background: var(--key-hover);
}

.key:active {
    border-style: inset;
}

.key:focus-visible {
    outline: 3px solid rgba(31, 111, 235, .35);
    outline-offset: 2px;
}

.key[data-kind="op"] {
    background: var(--op-key);
    border-color: #8bb89e;
}

.key[data-kind="util"] {
    background: var(--util-key);
    border-color: #92b9a7;
    color: #2a3837;
}

.key[data-kind="util"]:hover {
    background: var(--util-key-hover);
}

.key[data-kind="danger"] {
    background: var(--danger-key);
    border-color: #d89e9e;
    color: #8b4545;
}

.key[data-kind="equals"] {
    grid-column: span 4;
    background: var(--equals-key);
    color: white;
    border-color: #6da081;
}

.history-panel {
    position: absolute;
    left: 12px;
    right: 12px;
    top: 92px;
    background: linear-gradient(180deg, rgba(248, 253, 249, .98), rgba(240, 248, 243, .98));
    border: 2px solid rgba(155, 197, 167, .85);
    border-radius: 12px;
    box-shadow: 0 12px 32px rgba(44, 62, 63, .22), 0 0 0 1px rgba(255, 255, 255, .4) inset;
    padding: 0;
    display: none;
    z-index: 10;
    overflow: hidden;
}

.history-panel[aria-hidden="false"] {
    display: block;
}

.history-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    padding: 14px 16px 12px;
    background: linear-gradient(180deg, rgba(184, 220, 198, .45), rgba(184, 220, 198, .25));
    border-bottom: 1px solid rgba(155, 197, 167, .35);
}

.history-title {
    margin: 0;
    font-size: 14px;
    font-weight: 700;
    color: rgba(44, 62, 63, .92);
    letter-spacing: .01em;
}

.history-close {
    appearance: none;
    border: 1px solid rgba(155, 197, 167, .6);
    background: rgba(255, 255, 255, .75);
    border-radius: 8px;
    width: 32px;
    height: 32px;
    padding: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-size: 18px;
    font-weight: 400;
    color: rgba(44, 62, 63, .76);
    cursor: pointer;
    transition: background .15s ease, border-color .15s ease;
}

.history-close:hover {
    background: rgba(255, 255, 255, .92);
    border-color: rgba(155, 197, 167, .8);
    color: rgba(44, 62, 63, .92);
}

.history-close:active {
    background: rgba(200, 230, 208, .45);
    border-color: rgba(155, 197, 167, .9);
}

.history-list {
    list-style: none;
    padding: 12px 16px 48px;
    margin: 0;
    display: grid;
    gap: 8px;
    max-height: 340px;
    overflow: auto;
    background: rgba(248, 253, 249, .3);
    border-bottom: 1px solid rgba(155, 197, 167, .2);
}

.history-list::-webkit-scrollbar {
    width: 8px;
}

.history-list::-webkit-scrollbar-track {
    background: rgba(155, 197, 167, .15);
    border-radius: 4px;
}

.history-list::-webkit-scrollbar-thumb {
    background: rgba(155, 197, 167, .45);
    border-radius: 4px;
}

.history-list::-webkit-scrollbar-thumb:hover {
    background: rgba(155, 197, 167, .65);
}

.history-item {
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    gap: 8px;
    align-items: center;
}

.history-item .equals-sign {
    font-size: 16px;
    font-weight: 600;
    color: var(--text);
    text-align: center;
}

.history-item button {
    text-align: left;
    appearance: none;
    cursor: pointer;
    font-family: 'Poppins', system-ui, sans-serif;
    font-size: 13px;
    font-weight: 600;
    background: rgba(200, 230, 208, .5);
    border: 1px solid var(--key-border);
    border-radius: var(--radius);
    padding: 12px 14px;
    line-height: 1.5;
    color: var(--text);
    word-break: break-word;
    transition: all .12s ease;
    box-shadow: none;
}

.history-item button:hover {
    background: var(--key-hover);
    box-shadow: 0 2px 8px rgba(44, 62, 63, .15), 0 1px 3px rgba(44, 62, 63, .12);
}

.history-item button:active {
    background: var(--key-hover);
    box-shadow: 0 1px 3px rgba(44, 62, 63, .1) inset;
}

.history-item button:focus-visible {
    outline: 3px solid rgba(31, 111, 235, .35);
    outline-offset: 2px;
}
JavaScript
const exprEl = document.getElementById('expr');
const valueEl = document.getElementById('value');
const backBtn = document.getElementById('backBtn');
const historyPanel = document.getElementById('historyPanel');
const historyList = document.getElementById('historyList');
const historyClose = document.getElementById('historyClose');

let flashTimer = null;
let longPressTimer = null;
let longPressFired = false;
let historyBrowseIndex = -1;
let historyBrowseExpr = '';
let expression = '';
let lastWasResult = false;

function setStatus(message){
  console.log('Status:', message);
}

function flashMessage(message, ms = 1200){
  if (flashTimer) window.clearTimeout(flashTimer);
  const prev = exprEl.textContent;
  exprEl.textContent = message;
  flashTimer = window.setTimeout(() => {
    if (prev === message) {
      exprEl.textContent = historyBrowseIndex >= 0 && historyBrowseExpr ? formatExpression(historyBrowseExpr) : formatExpression(expression);
    } else {
      exprEl.textContent = prev;
    }
  }, ms);
}

const CALC_HISTORY_KEY = 'pt_calc_history_v1';
const CALC_HISTORY_LIMIT = 30;

function readCalcHistory(){
  try {
    const raw = localStorage.getItem(CALC_HISTORY_KEY);
    return raw ? JSON.parse(raw) : [];
  } catch (_){
    return [];
  }
}

function writeCalcHistory(arr){
  try {
    localStorage.setItem(CALC_HISTORY_KEY, JSON.stringify(arr));
  } catch (_){}
}

function addCalcHistoryItem(item){
  const list = readCalcHistory();
  const expr = item.expression || '';
  const hasOperator = new RegExp('[+*/-]').test(expr);
  if (!hasOperator) return;
  if (list.length > 0) {
    const last = list[0];
    if (last.expression === item.expression && last.result === item.result) return;
  }
  list.unshift(item);
  writeCalcHistory(list.slice(0, CALC_HISTORY_LIMIT));
}

function renderCalcHistory(){
  const list = readCalcHistory();
  historyList.innerHTML = '';
  if (!list.length){
    const li = document.createElement('li');
    li.className = 'history-item';
    li.textContent = '履歴がありません';
    historyList.appendChild(li);
    return;
  }
  list.forEach((item, index) => {
    const li = document.createElement('li');
    li.className = 'history-item';
    const expr = typeof item?.expression === 'string' ? item.expression : '';
    const res = typeof item?.result === 'string' ? item.result : '';
    const formattedExpr = expr ? formatExpression(expr) : '';
    const exprBtn = document.createElement('button');
    exprBtn.type = 'button';
    exprBtn.dataset.action = 'resume';
    exprBtn.dataset.expr = expr;
    exprBtn.textContent = formattedExpr || '—';
    exprBtn.title = '計算を続ける';
    const equalsSpan = document.createElement('span');
    equalsSpan.className = 'equals-sign';
    equalsSpan.textContent = '=';
    const resBtn = document.createElement('button');
    resBtn.type = 'button';
    resBtn.dataset.action = 'result';
    resBtn.dataset.index = String(index);
    resBtn.textContent = res;
    resBtn.title = '結果を使用';
    li.appendChild(exprBtn);
    li.appendChild(equalsSpan);
    li.appendChild(resBtn);
    historyList.appendChild(li);
  });
}

function openHistory(){
  renderCalcHistory();
  historyPanel.setAttribute('aria-hidden', 'false');
  historyClose.focus();
}

function closeHistory(){
  historyPanel.setAttribute('aria-hidden', 'true');
  backBtn.focus();
}

function resetHistoryBrowse(){
  historyBrowseIndex = -1;
  historyBrowseExpr = '';
}

function showHistoryItemAt(index){
  const list = readCalcHistory();
  const item = list[index];
  if (!item) return false;
  historyBrowseIndex = index;
  const resStr = typeof item.result === 'string' ? item.result : '0';
  const exprStr = typeof item.expression === 'string' ? item.expression : '';
  historyBrowseExpr = exprStr;
  expression = resStr;
  lastWasResult = true;
  // 計算式と結果を両方表示
  exprEl.textContent = formatExpression(exprStr);
  valueEl.textContent = resStr;
  setStatus(`履歴 ${index + 1} を表示`);
  return true;
}

function onBackShortPress(){
  const list = readCalcHistory();
  if (!list.length){
    flashMessage('履歴がありません');
    return;
  }
  const nextIndex = Math.min(historyBrowseIndex + 1, list.length - 1);
  if (nextIndex === historyBrowseIndex){
    flashMessage('これ以上ありません');
    return;
  }
  showHistoryItemAt(nextIndex);
}

function formatExpression(expr){
  let formatted = expr.replace(/(\\d+)(\\.\\d*)?/g, (match, integer, decimal) => {
    const withComma = integer.replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',');
    return decimal ? withComma + decimal : withComma;
  });
  formatted = formatted.replace(/\\*/g, '×').replace(/\\//g, '÷');
  return formatted;
}

function render(){
  const formatted = formatExpression(expression);
  exprEl.textContent = formatted;
  valueEl.textContent = formatted ? formatted : '0';
}

function isOperator(ch){
  return ch === '+' || ch === '-' || ch === '*' || ch === '/';
}

function appendDigit(d){
  if (lastWasResult){
    expression = '';
    lastWasResult = false;
    resetHistoryBrowse();
  }
  expression += d;
  render();
}

function appendOperator(op){
  if (!expression || lastWasResult){
    if (lastWasResult && expression){
      lastWasResult = false;
      resetHistoryBrowse();
    } else {
      return;
    }
  }
  const last = expression.slice(-1);
  if (isOperator(last)){
    expression = expression.slice(0, -1) + op;
  } else {
    expression += op;
  }
  render();
}

function appendDot(){
  if (lastWasResult){
    expression = '0';
    lastWasResult = false;
    resetHistoryBrowse();
  }
  if (!expression){
    expression = '0';
  }
  const tokens = expression.split(new RegExp('[+*/-]'));
  const current = tokens[tokens.length - 1];
  if (current.includes('.')) return;
  expression += '.';
  render();
}

function backspace(){
  if (lastWasResult){
    lastWasResult = false;
    expression = '';
    resetHistoryBrowse();
  }
  expression = expression.slice(0, -1);
  render();
}

function clear(){
  expression = '';
  lastWasResult = false;
  resetHistoryBrowse();
  render();
  setStatus('クリアしました');
}

function tokenize(expr){
  const tokens = [];
  let num = '';
  let expectUnary = true;
  for (let i = 0; i < expr.length; i++){
    const c = expr[i];
    if (c === ' ') continue;
    if (/\\d/.test(c) || c === '.'){
      num += c;
      expectUnary = false;
    } else if (isOperator(c)){
      if (c === '-' && expectUnary){
        num += c;
      } else {
        if (num) tokens.push({type: 'number', value: parseFloat(num)});
        num = '';
        tokens.push({type: 'operator', value: c});
        expectUnary = true;
      }
    }
  }
  if (num) tokens.push({type: 'number', value: parseFloat(num)});
  return tokens;
}

function toRpn(tokens){
  const out = [];
  const ops = [];
  const precedence = {'+': 1, '-': 1, '*': 2, '/': 2};
  for (const t of tokens){
    if (t.type === 'number'){
      out.push(t);
    } else {
      while (ops.length && precedence[ops[ops.length - 1].value] >= precedence[t.value]){
        out.push(ops.pop());
      }
      ops.push(t);
    }
  }
  while (ops.length) out.push(ops.pop());
  return out;
}

function evalRpn(rpn){
  const stack = [];
  for (const t of rpn){
    if (t.type === 'number'){
      stack.push(t.value);
      continue;
    }
    const b = stack.pop();
    const a = stack.pop();
    if (typeof a !== 'number' || typeof b !== 'number') throw new Error('Invalid expression');
    let r;
    switch (t.value){
      case '+': r = a + b; break;
      case '-': r = a - b; break;
      case '*': r = a * b; break;
      case '/':
        if (b === 0) throw new Error('Division by zero');
        r = a / b;
        break;
      default:
        throw new Error('Invalid operator');
    }
    stack.push(r);
  }
  if (stack.length !== 1) throw new Error('Invalid expression');
  return stack[0];
}

function formatResult(n){
  if (!Number.isFinite(n)) return 'Error';
  const s = Math.abs(n) < 1e-12 ? '0' : String(n);
  if (s.includes('e') || s.includes('E')) return s;
  const rounded = Math.round(n * 1e12) / 1e12;
  const parts = String(rounded).split('.');
  parts[0] = parts[0].replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',');
  return parts.join('.');
}

function equals(){
  if (!expression) return;
  if (lastWasResult) return;
  const last = expression.slice(-1);
  if (isOperator(last) || last === '.'){
    setStatus('式が未完成です');
    return;
  }
  try {
    resetHistoryBrowse();
    const beforeExpression = expression;
    const tokens = tokenize(expression);
    const rpn = toRpn(tokens);
    const result = evalRpn(rpn);
    const out = formatResult(result);
    exprEl.textContent = formatExpression(expression);
    valueEl.textContent = out;
    expression = out === 'Error' ? '' : out;
    lastWasResult = true;
    setStatus('計算しました');
    if (out !== 'Error'){
      addCalcHistoryItem({
        expression: beforeExpression,
        result: out,
        ts: Date.now()
      });
    }
  } catch (e){
    valueEl.textContent = 'Error';
    setStatus('計算できませんでした');
  }
}

document.querySelector('.keys').addEventListener('click', (ev) => {
  const btn = ev.target.closest('button');
  if (!btn) return;
  const action = btn.dataset.action;
  const value = btn.dataset.value;
  switch (action){
    case 'digit': appendDigit(value); break;
    case 'op': appendOperator(value); break;
    case 'dot': appendDot(); break;
    case 'equals': equals(); break;
    case 'clear': clear(); break;
    case 'backspace': backspace(); break;
    default: break;
  }
});

backBtn.addEventListener('pointerdown', (ev) => {
  if (ev.button !== undefined && ev.button !== 0) return;
  longPressFired = false;
  if (longPressTimer) window.clearTimeout(longPressTimer);
  longPressTimer = window.setTimeout(() => {
    longPressFired = true;
    openHistory();
  }, 520);
});

function cancelLongPress(){
  if (longPressTimer) window.clearTimeout(longPressTimer);
  longPressTimer = null;
}

backBtn.addEventListener('pointerup', () => {
  cancelLongPress();
  if (!longPressFired) onBackShortPress();
});

backBtn.addEventListener('pointercancel', cancelLongPress);
backBtn.addEventListener('pointerleave', cancelLongPress);
historyClose.addEventListener('click', closeHistory);

document.addEventListener('click', (ev) => {
  if (historyPanel.getAttribute('aria-hidden') === 'false'){
    const target = ev.target;
    const isInsideCalc = target.closest('.shell');
    if (!isInsideCalc) closeHistory();
  }
});

historyPanel.addEventListener('click', (ev) => {
  ev.stopPropagation();
});

historyList.addEventListener('click', (ev) => {
  ev.stopPropagation();
  const btn = ev.target.closest('button');
  if (!btn) return;
  const action = btn.dataset.action;
  if (action === 'resume') {
    const expr = btn.dataset.expr;
    if (expr) {
      expression = expr;
      lastWasResult = false;
      resetHistoryBrowse();
      render();
      closeHistory();
    }
  } else if (action === 'result') {
    const index = Number(btn.dataset.index);
    if (Number.isFinite(index)) {
      if (showHistoryItemAt(index)) closeHistory();
    }
  }
});

document.addEventListener('keydown', (ev) => {
  const keysToPrevent = ['Enter', 'Backspace'];
  if (keysToPrevent.includes(ev.key)) ev.preventDefault();
  if (historyPanel.getAttribute('aria-hidden') === 'false'){
    if (ev.key === 'Escape'){
      ev.preventDefault();
      closeHistory();
    }
    return;
  }
  if (/^\\d$/.test(ev.key)) appendDigit(ev.key);
  else if (ev.key === '+') appendOperator('+');
  else if (ev.key === '-') appendOperator('-');
  else if (ev.key === '*') appendOperator('*');
  else if (ev.key === '/') appendOperator('/');
  else if (ev.key === '.') appendDot();
  else if (ev.key === 'Enter') equals();
  else if (ev.key === 'Backspace') backspace();
  else if (ev.key === 'Escape') clear();
});

render();

このパーツの特徴

  • ブラウザ風履歴UI 「←」ボタンの短いクリックで順次閲覧、長押しで一覧表示の直感的な操作
  • localStorage永続化 計算履歴をローカルストレージに保存し、ブラウザを閉じても残る
  • 履歴から再開 過去の計算結果を選択して、そこから計算を続けることが可能
  • RPN評価エンジン 逆ポーランド記法で正確な計算順序を実現
  • アクセシビリティ対応 キーボード操作とARIA属性で全ユーザーに優しい設計

コードのポイント

pointerdown/pointerupで長押し検知

pointerdownイベントでタイマーを開始し、pointerupで短いクリックか長押しかを判定します。

backBtn.addEventListener('pointerdown', (ev) => {
    ev.preventDefault();
    longPressFired = false;
    longPressTimer = window.setTimeout(() => {
        longPressFired = true;
        openHistory();
    }, 520);
});

backBtn.addEventListener('pointerup', () => {
    if (longPressTimer) window.clearTimeout(longPressTimer);
    if (!longPressFired) onBackShortPress();
});

localStorageで履歴を永続化

JSON形式で計算履歴を保存し、ページを開くたびに読み込みます。

const CALC_HISTORY_KEY = 'pt_calc_history_v1';
const CALC_HISTORY_LIMIT = 30;

function readCalcHistory() {
    try {
        const raw = localStorage.getItem(CALC_HISTORY_KEY);
        return raw ? JSON.parse(raw) : [];
    } catch (_) {
        return [];
    }
}

function writeCalcHistory(arr) {
    try {
        localStorage.setItem(CALC_HISTORY_KEY, JSON.stringify(arr));
    } catch (_) {}
}

function addCalcHistoryItem(item) {
    const list = readCalcHistory();
    list.unshift(item);
    writeCalcHistory(list.slice(0, CALC_HISTORY_LIMIT));
}

RPN(逆ポーランド記法)で評価

中置記法の式をトークン分解し、RPNに変換してから評価することで、演算子の優先度を正確に処理します。

function toRpn(tokens) {
    const output = [];
    const stack = [];
    const precedence = { '+': 1, '-': 1, '*': 2, '/': 2 };
    
    for (const token of tokens) {
        if (!isNaN(token)) {
            output.push(token);
        } else if (token in precedence) {
            while (stack.length && precedence[stack[stack.length - 1]] >= precedence[token]) {
                output.push(stack.pop());
            }
            stack.push(token);
        }
    }
    
    while (stack.length) output.push(stack.pop());
    return output;
}

function evalRpn(rpn) {
    const stack = [];
    for (const token of rpn) {
        if (!isNaN(token)) {
            stack.push(Number.parseFloat(token));
        } else {
            const b = stack.pop();
            const a = stack.pop();
            if (token === '+') stack.push(a + b);
            else if (token === '-') stack.push(a - b);
            else if (token === '*') stack.push(a * b);
            else if (token === '/') stack.push(a / b);
        }
    }
    return stack[0];
}

履歴インデックスで順次閲覧

historyBrowseIndexで現在表示中の履歴位置を管理し、短いクリックで順次閲覧します。

function onBackShortPress() {
    const list = readCalcHistory();
    if (!list.length) {
        flashMessage('履歴がありません', 800);
        return;
    }
    const nextIndex = Math.min(historyBrowseIndex + 1, list.length - 1);
    showHistoryItemAt(nextIndex);
}

function showHistoryItemAt(index) {
    const list = readCalcHistory();
    const item = list[index];
    if (!item) return false;
    
    historyBrowseIndex = index;
    expression = String(item.result);
    lastWasResult = true;
    
    exprEl.textContent = formatExpression(item.expression);
    valueEl.textContent = item.result;
    return true;
}

まとめ

履歴機能付き電卓のポイント
  • 直感的な履歴UI ブラウザの戻るボタンを模した短いクリックと長押しの2種類の操作
  • 永続化データ localStorageで計算履歴を保存し、実用的な体験を提供
  • 履歴から再開 過去の計算結果を起点に、新たな計算を続けられる
  • RPN評価 逆ポーランド記法で正確な計算順序を実現
  • アクセシビリティ キーボード操作対応とARIA属性で全ユーザーが利用可能

電卓というシンプルなツールですが、履歴機能を加えることで実用性が大きく向上します。ブラウザの戻るボタンという馴染みのあるインターフェースを参考にすることで、ユーザーにとって直感的で使いやすいツールを実現できます。

学習チェック

このレッスンを理解できたら「完了」をクリックしてください。
後で見直したい場合は「未完了に戻す」で進捗をリセットできます。

レッスン完了!🎉

お疲れさまでした!