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 fileEvery 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>