← All gadgets

Scientific Calculator

Trig, logs, powers and factorials -- type it or click it

Running live from https://fun.whitneys.co, cross-origin and sandboxed.

Put it on your site

<iframe
  src="https://fun.whitneys.co/g/scientific-calc"
  width="300"
  height="468"
  title="Scientific Calculator"
  sandbox="allow-scripts allow-popups-to-escape-sandbox"
  loading="lazy"
  style="border:0"></iframe>

An iframe, not a script tag -- so this gadget cannot touch the page you paste it into, and neither can we. Your settings above are baked into the URL.

Optional: let it resize itself to fit
<script>
addEventListener('message', function (e) {
  var d = e.data;
  if (!d || d.type !== 'fwf:height') return;
  if (typeof d.height !== 'number' || !isFinite(d.height)) return;
  document.querySelectorAll('iframe').forEach(function (f) {
    // Identity by source window, not by origin -- sandboxed frames report
    // their origin as "null". Do not "fix" this into an origin check.
    if (f.contentWindow !== e.source) return;
    f.style.height = Math.max(40, Math.min(2000, Math.ceil(d.height))) + 'px';
  });
});
</script>

The whole thing

Raw file

Every gadget is one file, and this is it -- all 14.6 kB. Take it, change it, ship it. Just keep the credit.

Show source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script type="application/json" id="gadget-manifest">
{
  "name": "Scientific Calculator",
  "tagline": "Trig, logs, powers and factorials -- type it or click it",
  "category": "tools",
  "tags": ["calculator", "math", "science", "utility"],
  "order": 7,
  "author": "rwhitney",
  "background": "dark",
  "size": { "w": 300, "h": 468, "resizable": true },
  "config": {
    "angle":  { "type": "select", "label": "Angles", "options": ["deg", "rad"], "default": "deg" },
    "accent": { "type": "color",  "label": "Accent", "default": "#A6E22E" },
    "op":     { "type": "color",  "label": "Operators", "default": "#FD971F" }
  },
  "permissions": []
}
</script>
<style>
  :root {
    --ink:#F8F8F2; --dim:#A59F85; --line:rgb(248 248 242 / .12);
    --key:#3a3930; --key-hi:#47463b; --sci:#2f2e27;
  }
  html, body {
    margin:0; height:100%; background:transparent; color:var(--ink);
    font:15px/1.3 ui-sans-serif, system-ui, sans-serif;
    user-select:none; -webkit-user-select:none;
  }
  #calc {
    display:flex; flex-direction:column; height:100%;
    padding:12px; box-sizing:border-box; gap:10px;
  }

  /* ---- display --------------------------------------------------------- */
  #screen {
    background:rgb(0 0 0 / .28);
    border:1px solid var(--line);
    border-radius:10px;
    padding:10px 12px;
    display:flex; flex-direction:column; justify-content:flex-end;
    min-height:74px;
  }
  #expr {
    font:12px/1.3 ui-monospace, Menlo, Consolas, monospace;
    color:var(--dim);
    text-align:right; white-space:nowrap; overflow-x:auto;
    min-height:1.3em;
    scrollbar-width:none;
  }
  #expr::-webkit-scrollbar { display:none; }
  #out {
    font:24px/1.2 ui-monospace, Menlo, Consolas, monospace;
    font-weight:700; text-align:right;
    white-space:nowrap; overflow-x:auto;
    scrollbar-width:none;
  }
  #out::-webkit-scrollbar { display:none; }
  #out.error { color:#F92672; }

  /* ---- keypad ---------------------------------------------------------- */
  #pad {
    flex:1;
    display:grid;
    grid-template-columns:repeat(5, 1fr);
    gap:6px;
  }
  button.k {
    font:inherit; font-weight:600;
    border:1px solid var(--line);
    border-radius:8px;
    background:var(--key); color:var(--ink);
    cursor:pointer; padding:0;
    display:flex; align-items:center; justify-content:center;
    transition:background 70ms ease, transform 70ms ease, border-color 70ms ease;
  }
  button.k:hover { background:var(--key-hi); }
  button.k:active { transform:translateY(1px); }
  button.k:focus { outline:none; }
  button.k:focus-visible { outline:2px solid #66D9EF; outline-offset:1px; z-index:1; }

  /* Scientific functions sit back a shade so the number pad reads first. */
  button.fn { background:var(--sci); color:#66D9EF; font-size:13px; }
  button.fn:hover { background:#38372f; }
  button.ctrl { background:var(--sci); color:var(--dim); font-size:13px; }
  button.ctrl:hover { background:#38372f; }
  button.op { color:var(--op, #FD971F); font-size:19px; }
  button.eq { background:var(--accent, #A6E22E); color:#1d1e18; font-weight:800; }
  button.eq:hover { filter:brightness(1.08); }
  button.wide { grid-column:span 2; }
  #angle.on { color:var(--accent, #A6E22E); border-color:var(--accent, #A6E22E); }
  .sr { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); }
</style>
</head>
<body>
  <div id="calc">
    <div id="screen">
      <div id="expr" aria-hidden="true"></div>
      <div id="out" role="status" aria-live="polite">0</div>
    </div>
    <div id="pad"></div>
  </div>

<script>
(function () {
  var cfg = window.gadget ? window.gadget.config : {};
  var angleMode = cfg.angle === 'rad' ? 'rad' : 'deg';
  document.documentElement.style.setProperty('--accent', cfg.accent || '#A6E22E');
  document.documentElement.style.setProperty('--op', cfg.op || '#FD971F');

  /* =====================================================================
     Expression evaluator -- tokenizer + recursive-descent parser.

     Deliberately NOT eval(). eval on a user-built string is an injection
     surface and mis-handles things like implicit multiplication and
     degree-mode trig anyway. A real parser is only ~90 lines and gets the
     precedence right: ^ is right-associative and binds tighter than unary
     minus, functions and constants multiply implicitly (2π, 3(4+1)), and
     factorial/percent are postfix.
     ===================================================================== */

  var FUNCS = {
    sin: 1, cos: 1, tan: 1, asin: 1, acos: 1, atan: 1,
    ln: 1, log: 1, sqrt: 1, abs: 1, exp: 1
  };

  function tokenize(src) {
    // Normalise the pretty display characters to plain operators first.
    var s = src.replace(/×/g, '*').replace(/÷/g, '/')
               .replace(/−/g, '-').replace(/√/g, 'sqrt')
               .replace(/π/g, 'P');
    var toks = [], i = 0, n = s.length;
    while (i < n) {
      var c = s[i];
      if (c === ' ') { i++; continue; }
      if (c >= '0' && c <= '9' || c === '.') {
        var j = i + 1;
        while (j < n && (s[j] >= '0' && s[j] <= '9' || s[j] === '.')) j++;
        var numStr = s.slice(i, j);
        // Reject malformed numbers -- parseFloat('3.4.5') silently returns
        // 3.4, so a double-dot would otherwise pass unnoticed.
        if (!/^\d*\.?\d+$|^\d+\.?$/.test(numStr)) {
          throw new Error('bad number: ' + numStr);
        }
        toks.push({ t: 'num', v: parseFloat(numStr) });
        i = j; continue;
      }
      if (c === 'P') { toks.push({ t: 'const', v: Math.PI }); i++; continue; }
      if (c >= 'a' && c <= 'z') {
        var k = i + 1;
        while (k < n && s[k] >= 'a' && s[k] <= 'z') k++;
        var word = s.slice(i, k);
        if (word === 'e') toks.push({ t: 'const', v: Math.E });
        else if (word === 'pi') toks.push({ t: 'const', v: Math.PI });
        else if (FUNCS[word]) toks.push({ t: 'func', v: word });
        else throw new Error('unknown: ' + word);
        i = k; continue;
      }
      if ('+-*/^()!%'.indexOf(c) !== -1) { toks.push({ t: c }); i++; continue; }
      throw new Error('bad char: ' + c);
    }
    return toks;
  }

  function parse(toks) {
    var pos = 0;
    var peek = function () { return toks[pos]; };
    var atomStart = function (tk) {
      return tk && (tk.t === 'num' || tk.t === 'const' ||
                    tk.t === 'func' || tk.t === '(');
    };

    function expr() {          // + and -
      var v = mul();
      while (peek() && (peek().t === '+' || peek().t === '-')) {
        var op = toks[pos++].t;
        var r = mul();
        v = op === '+' ? v + r : v - r;
      }
      return v;
    }
    function mul() {           // * and /, plus implicit multiplication
      var v = unary();
      while (peek()) {
        var tk = peek();
        if (tk.t === '*' || tk.t === '/') {
          pos++;
          var r = unary();
          v = tk.t === '*' ? v * r : v / r;
        } else if (atomStart(tk)) {
          v = v * unary();     // 2π, 3(4), 2sin(1)
        } else break;
      }
      return v;
    }
    function unary() {         // leading + / -
      if (peek() && peek().t === '-') { pos++; return -unary(); }
      if (peek() && peek().t === '+') { pos++; return unary(); }
      return power();
    }
    function power() {         // ^ , right-associative, above unary minus
      var base = postfix();
      if (peek() && peek().t === '^') { pos++; return Math.pow(base, unary()); }
      return base;
    }
    function postfix() {       // ! and %
      var v = atom();
      while (peek() && (peek().t === '!' || peek().t === '%')) {
        var op = toks[pos++].t;
        v = op === '!' ? factorial(v) : v / 100;
      }
      return v;
    }
    function atom() {
      var tk = peek();
      if (!tk) throw new Error('unexpected end');
      if (tk.t === 'num' || tk.t === 'const') { pos++; return tk.v; }
      if (tk.t === '(') { pos++; var v = expr(); expect(')'); return v; }
      if (tk.t === 'func') {
        pos++; expect('('); var a = expr(); expect(')');
        return applyFunc(tk.v, a);
      }
      throw new Error('unexpected: ' + tk.t);
    }
    function expect(t) {
      if (!peek() || peek().t !== t) throw new Error('expected ' + t);
      pos++;
    }

    var result = expr();
    if (pos !== toks.length) throw new Error('trailing input');
    return result;
  }

  function applyFunc(name, x) {
    var rad = angleMode === 'deg' ? x * Math.PI / 180 : x;
    switch (name) {
      case 'sin': return Math.sin(rad);
      case 'cos': return Math.cos(rad);
      case 'tan': return Math.tan(rad);
      case 'asin': return conv(Math.asin(x));
      case 'acos': return conv(Math.acos(x));
      case 'atan': return conv(Math.atan(x));
      case 'ln': return Math.log(x);
      case 'log': return Math.log10(x);
      case 'sqrt': return Math.sqrt(x);
      case 'abs': return Math.abs(x);
      case 'exp': return Math.exp(x);
    }
    throw new Error('fn');
  }
  function conv(rad) { return angleMode === 'deg' ? rad * 180 / Math.PI : rad; }

  function factorial(n) {
    if (n < 0 || n !== Math.floor(n)) throw new Error('factorial needs a whole number >= 0');
    if (n > 170) return Infinity;      // beyond double range anyway
    var r = 1;
    for (var i = 2; i <= n; i++) r *= i;
    return r;
  }

  function evaluate(src) { return parse(tokenize(src)); }

  /* Trim float noise and pick a readable form. */
  function fmt(x) {
    if (typeof x !== 'number' || Number.isNaN(x)) return 'Error';
    if (!isFinite(x)) return x > 0 ? '∞' : '-∞';
    var r = Math.round(x * 1e12) / 1e12;
    if (Object.is(r, -0)) r = 0;
    if (r !== 0 && (Math.abs(r) >= 1e15 || Math.abs(r) < 1e-9)) {
      return r.toExponential(6).replace(/(\.\d*?)0+e/, '$1e').replace(/\.e/, 'e');
    }
    return String(r);
  }

  /* =====================================================================
     Input model: build a display string, evaluate on '='. Every key just
     appends or edits text, so there is no operator-precedence logic in the
     UI at all -- the parser owns all of that.
     ===================================================================== */
  var exprStr = '';
  var ans = 0;
  var justEval = false;

  var outEl = document.getElementById('out');
  var exprEl = document.getElementById('expr');

  function render(preview) {
    exprEl.textContent = exprStr;
    exprEl.scrollLeft = exprEl.scrollWidth;
    if (preview !== undefined) {
      outEl.textContent = preview;
      outEl.classList.toggle('error', preview === 'Error');
    }
    outEl.scrollLeft = outEl.scrollWidth;
  }

  // Live preview as you type, so the answer updates before you press equals.
  function livePreview() {
    if (!exprStr) { render('0'); return; }
    try {
      render(fmt(evaluate(exprStr)));
    } catch (e) {
      // Incomplete expression mid-typing is normal; keep the last good number.
      exprEl.textContent = exprStr;
      exprEl.scrollLeft = exprEl.scrollWidth;
    }
  }

  var OPS = '+−×÷^';

  function insert(txt, kind) {
    if (justEval) {
      // After '=', a number/function/paren starts fresh; an operator
      // continues from the answer.
      if (kind === 'op') exprStr = fmt(ans);
      else exprStr = '';
      justEval = false;
    }
    // Block a second decimal point in the number currently being typed, so
    // the display can never build "3.4.5" in the first place.
    if (txt === '.' && /[0-9.]*\.[0-9]*$/.test(exprStr)) return;
    exprStr += txt;
    livePreview();
  }

  function equals() {
    if (!exprStr) return;
    try {
      var v = evaluate(exprStr);
      var shown = fmt(v);
      if (shown === 'Error') throw new Error('e');
      exprEl.textContent = exprStr + ' =';
      exprEl.scrollLeft = exprEl.scrollWidth;
      outEl.textContent = shown;
      outEl.classList.remove('error');
      ans = v;
      justEval = true;
    } catch (e) {
      outEl.textContent = 'Error';
      outEl.classList.add('error');
    }
  }

  function clearAll() { exprStr = ''; justEval = false; render('0'); }
  function backspace() {
    if (justEval) { clearAll(); return; }
    exprStr = exprStr.slice(0, -1);
    livePreview();
  }

  /* =====================================================================
     Keypad. Each entry: [label, className, action]. A string action is the
     text to insert; a function runs directly.
     ===================================================================== */
  var pad = document.getElementById('pad');
  var angleBtn;

  function toggleAngle() {
    angleMode = angleMode === 'deg' ? 'rad' : 'deg';
    angleBtn.textContent = angleMode.toUpperCase();
    livePreview();
  }

  var KEYS = [
    ['AC', 'ctrl', clearAll], ['⌫', 'ctrl', backspace],
    ['(', 'fn', '('], [')', 'fn', ')'],
    ['DEG', 'ctrl angle', toggleAngle],

    ['sin', 'fn', 'sin('], ['cos', 'fn', 'cos('], ['tan', 'fn', 'tan('],
    ['ln', 'fn', 'ln('], ['log', 'fn', 'log('],

    ['√', 'fn', '√('], ['x²', 'fn', '^2'], ['xʸ', 'fn', '^'],
    ['π', 'fn', 'π'], ['e', 'fn', 'e'],

    ['7', '', '7'], ['8', '', '8'], ['9', '', '9'],
    ['÷', 'op', '÷'], ['!', 'fn', '!'],

    ['4', '', '4'], ['5', '', '5'], ['6', '', '6'],
    ['×', 'op', '×'], ['%', 'fn', '%'],

    ['1', '', '1'], ['2', '', '2'], ['3', '', '3'],
    ['−', 'op', '−'], ['Ans', 'ctrl', function () { insert(fmt(ans), 'num'); }],

    ['0', '', '0'], ['.', '', '.'],
    ['+', 'op', '+'], ['=', 'eq wide', equals]
  ];

  KEYS.forEach(function (spec) {
    var b = document.createElement('button');
    b.type = 'button';
    b.className = 'k ' + spec[1];
    b.textContent = spec[0];
    if (spec[1].indexOf('angle') !== -1) { b.id = 'angle'; angleBtn = b; }
    var action = spec[2];
    b.addEventListener('click', function () {
      if (typeof action === 'function') action();
      else insert(action, action.length === 1 && OPS.indexOf(action) !== -1 ? 'op' : 'txt');
    });
    pad.appendChild(b);
  });
  angleBtn.textContent = angleMode.toUpperCase();

  /* ---- physical keyboard ---------------------------------------------- */
  var KEYMAP = {
    '*': ['×', 'op'], '/': ['÷', 'op'], '-': ['−', 'op'],
    '+': ['+', 'op'], '^': ['^', 'txt'], '(': ['(', 'txt'], ')': [')', 'txt'],
    '!': ['!', 'txt'], '%': ['%', 'txt'], '.': ['.', 'txt']
  };
  addEventListener('keydown', function (e) {
    if (e.ctrlKey || e.metaKey || e.altKey) return;
    var k = e.key;
    if (k >= '0' && k <= '9') { e.preventDefault(); insert(k, 'num'); return; }
    if (KEYMAP[k]) { e.preventDefault(); insert(KEYMAP[k][0], KEYMAP[k][1]); return; }
    if (k === 'Enter' || k === '=') { e.preventDefault(); equals(); return; }
    if (k === 'Backspace') { e.preventDefault(); backspace(); return; }
    if (k === 'Escape') { e.preventDefault(); clearAll(); return; }
  });

  render('0');
})();
</script>
</body>
</html>