Calculadora para niños
- Alejandro Rivero
- 19 oct 2025
- 10 Min. de lectura
(si estás viendo esta entrada en el ordenador, reduce la anchura de la pantalla al tamaño aproximado de un telefono movil, para poder ver el prototipo de calculadora)
<!doctype html><html lang="en"><head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" /> <title>Swipe Calculator — Canvas</title> <style> :root { --bg:#0f1525; --card:#111a2c; --ink:#eaf2ff; --mut:#96a1b2; --accent:#6ea8fe; --accent2:#3dd9b6; } html, body { height: 100%; margin: 0; } body { background: radial-gradient(1200px 800px at 50% -10%, #1a2340, var(--bg)); font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial; color: var(--ink); } header { text-align:center; padding: 10px 0 0 0; font-weight: 700; letter-spacing:.2px; } #wrap { display:grid; place-items:center; width:100%; height: calc(100% - 42px); } canvas { width: min(820px, 100%); height: 100%; display:block; touch-action: none; } .hint { position: fixed; bottom: 8px; left: 0; right:0; text-align:center; color: var(--mut); font-size: 12px; pointer-events: none; } </style></head><body> <header>Swipe Calculator — <span style="color:#6ea8fe">Canvas</span></header> <div id="wrap"><canvas id="cv"></canvas></div> <div class="hint">Glide on the round dial: <strong>12 = decimal point</strong>, <strong>11 = ± (toggle sign)</strong>. Swipe to type; lift (or chord two fingers) to switch A?B. Tap center to reset. Wheel picks + × ? : (× by default). No equals.</div> <script> ;(() => { const cv = document.getElementById('cv'); const ctx = cv.getContext('2d'); // --- State --- let aStr = ""; // operand A (can be negative & decimal) let bStr = ""; // operand B let enteringA = true; // which operand we append to const OPS = ["+", "×", "?", ":"]; // order is important for wheel let opIndex = 1; // default to × // Focus & swipe control let focusedField = null, // 'A' | 'B' | null pointerMode = null; // 'dial' | 'wheel' | 'btnClear' | 'btnBack' | 'btnSwap' | 'fieldA' | 'fieldB' | 'center' | null let swipeDisabled = false; // when a field is focused, disable swipe keypad // Pointer interaction state let pointerActive = false; let lastKeyCell = null; // track the last sector we were over // Layout cache (CSS pixel units) const L = { dial: {cx:0, cy:0, rOuter:0, rInner:0, cells:[]}, fieldA: {x:0,y:0,w:0,h:0}, fieldB: {x:0,y:0,w:0,h:0}, result: {x:0,y:0,w:0,h:0}, wheel: {x:0,y:0,r:0, innerR:0}, btnClear: {x:0,y:0,w:0,h:0}, btnBack: {x:0,y:0,w:0,h:0}, btnSwap: {x:0,y:0,w:0,h:0}, }; const DPR = Math.max(1, window.devicePixelRatio || 1); function resize() { const r = cv.getBoundingClientRect(); cv.width = Math.max(1, Math.floor(r.width * DPR)); cv.height = Math.max(1, Math.floor(r.height * DPR)); ctx.setTransform(DPR, 0, 0, DPR, 0, 0); // draw in CSS pixels computeLayout(r.width, r.height); draw(); maybeRunTests(); } function computeLayout(W, H) { const padOuter = 16; const gap = 12; // Top region heights const fieldH = Math.min(72, Math.max(56, H * 0.08)); const resultH = Math.min(110, Math.max(86, H * 0.12)); // Fields A [left] and B [right] with a small operation wheel in between const wheelSize = Math.min(88, Math.max(72, W * 0.12)); const wheelR = wheelSize/2; const wheelCx = W/2; const wheelCy = padOuter + fieldH/2; // center with fields const fieldW = (W - padOuter*2 - wheelSize - 3*gap) / 2; // breathing room around the wheel L.fieldA = {x: padOuter, y: padOuter, w: fieldW, h: fieldH}; L.fieldB = {x: W - padOuter - fieldW, y: padOuter, w: fieldW, h: fieldH}; L.wheel = {x: wheelCx, y: wheelCy, r: wheelR, innerR: wheelR * 0.6}; // Result card under A/B L.result = {x: padOuter, y: L.fieldA.y + fieldH + gap, w: W - padOuter*2, h: resultH}; // Dial region centered below the result const dialAreaTop = L.result.y + resultH + 16; const dialAreaH = H - dialAreaTop - 70; // leave space for bottom buttons const dialAreaW = Math.min(W - padOuter*2, 520); const cx = W/2; const cy = dialAreaTop + dialAreaH/2; const rOuter = Math.min(dialAreaW, dialAreaH) * 0.46; // margin to edges const rInner = rOuter * 0.58; // hollow center L.dial = {cx, cy, rOuter, rInner, cells: []}; buildDialCells(); // Buttons under dial const btnW = Math.min(140, (W - padOuter*2 - gap*2)/3); const btnH = 44; const btnY = Math.min(H - btnH - 10, cy + rOuter + 16); let bx = (W - (btnW*3 + gap*2))/2; L.btnClear = {x: bx, y: btnY, w: btnW, h: btnH}; bx += btnW + gap; L.btnBack = {x: bx, y: btnY, w: btnW, h: btnH}; bx += btnW + gap; L.btnSwap = {x: bx, y: btnY, w: btnW, h: btnH}; } function buildDialCells() { const {cx, cy, rOuter, rInner} = L.dial; // 12 sectors clockwise from 12 o'clock: '.' (12), '±' (11), then digits 1..9,0 // Clock layout: 12='.', 11='±', then 1..9, 0 at 10 o'clock const labels = ['.','1','2','3','4','5','6','7','8','9','0','±']; const step = Math.PI * 2 / labels.length; const startAtTop = -Math.PI/2 - step/2; // shift half-step so numbers land at 0°,30°,… and separators at 15°,45°,… L.dial.cells = labels.map((label, i) => { const a0 = startAtTop + i * step; const a1 = a0 + step; const am = (a0 + a1)/2; return { id: 'seg'+i, label, a0, a1, am, cx, cy, rOuter, rInner }; }); } // --- Drawing helpers --- function roundRectPath(x,y,w,h,r=14) { const rr = Math.min(r, Math.min(w,h)/2); ctx.beginPath(); ctx.moveTo(x+rr,y); ctx.arcTo(x+w,y,x+w,y+h,rr); ctx.arcTo(x+w,y+h,x,y+h,rr); ctx.arcTo(x,y+h,x,y,rr); ctx.arcTo(x,y,x+w,y,rr); ctx.closePath(); } function drawField(box, label, value, active) { roundRectPath(box.x, box.y, box.w, box.h, 14); ctx.fillStyle = '#111a2e'; ctx.fill(); ctx.lineWidth = 1; ctx.strokeStyle = active ? 'rgba(110,168,254,1)' : 'rgba(255,255,255,0.25)'; ctx.stroke(); // label ctx.fillStyle = '#98a2b3'; ctx.font = '12px system-ui, -apple-system, Segoe UI, Roboto'; ctx.textBaseline = 'top'; ctx.fillText(label, box.x+10, box.y+6); // value (auto size & vertically centered) const vf = Math.floor(Math.min(40, Math.max(20, box.h * 0.5))); ctx.fillStyle = '#eaf2ff'; ctx.font = vf + 'px system-ui, -apple-system, Segoe UI, Roboto'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.fillText(value.length ? value : ' ', box.x + box.w/2, box.y + box.h/2 + 2); } function drawResult(box, resultText) { roundRectPath(box.x, box.y, box.w, box.h, 16); ctx.fillStyle = '#0b1222'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.stroke(); // Title ctx.fillStyle = '#98a2b3'; ctx.font = '13px system-ui, -apple-system, Segoe UI, Roboto'; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText('Result', box.x+12, box.y+8); // Value centered vertically ctx.fillStyle = '#eaf2ff'; const rf = Math.floor(Math.min(72, Math.max(28, box.h * 0.55))); ctx.font = rf + 'px system-ui, -apple-system, Segoe UI, Roboto'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(resultText, box.x + box.w/2, box.y + box.h*0.62); } function drawWheel() { const {x:cx, y:cy, r, innerR} = L.wheel; // outer ring ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2); ctx.fillStyle = '#141b2d'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke(); // sectors and labels for (let i=0;i<4;i++){ const angle0 = (-Math.PI/2) + i*(Math.PI/2); const angle1 = angle0 + Math.PI/2; // sector highlight if selected if (i === opIndex){ ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, r, angle0, angle1); ctx.closePath(); ctx.fillStyle = 'rgba(110,168,254,0.22)'; ctx.fill(); } // label at mid-angle const mid = (angle0+angle1)/2; const rx = cx + Math.cos(mid) * (r*0.58); const ry = cy + Math.sin(mid) * (r*0.58); ctx.fillStyle = i === opIndex ? '#eaf2ff' : '#cbd5e1'; ctx.font = '22px system-ui, -apple-system, Segoe UI, Roboto'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(OPS[i], rx, ry); } // inner disk (shows current op) ctx.beginPath(); ctx.arc(cx, cy, innerR, 0, Math.PI*2); ctx.fillStyle = '#0f1628'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.stroke(); ctx.fillStyle = '#eaf2ff'; ctx.font = Math.floor(innerR * 0.7) + 'px system-ui, -apple-system, Segoe UI, Roboto'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(OPS[opIndex], cx, cy); } function drawDial() { const {cx, cy, rOuter, rInner, cells} = L.dial; for (const cell of cells) { // ring segment ctx.beginPath(); ctx.arc(cx, cy, rOuter, cell.a0, cell.a1); ctx.arc(cx, cy, rInner, cell.a1, cell.a0, true); ctx.closePath(); const isActive = (pointerMode==='dial' && lastKeyCell && lastKeyCell.id===cell.id); ctx.fillStyle = '#111a2e'; ctx.fill(); ctx.strokeStyle = isActive ? 'rgba(110,168,254,1)' : 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.stroke(); // label const rText = (rOuter + rInner) / 2; const tx = cx + Math.cos(cell.am) * rText; const ty = cy + Math.sin(cell.am) * rText; ctx.fillStyle = '#eaf2ff'; const fs = Math.floor(Math.min(42, (rOuter - rInner) * 0.7)); ctx.font = fs + 'px system-ui, -apple-system, Segoe UI, Roboto'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(cell.label, tx, ty); } // center (empty) — tap here to reset (visual hint only) ctx.beginPath(); ctx.arc(cx, cy, rInner * 0.5, 0, Math.PI*2); ctx.strokeStyle = 'rgba(255,255,255,0.12)'; ctx.lineWidth = 1; ctx.stroke(); } function drawButton(box, label, filled=false) { roundRectPath(box.x, box.y, box.w, box.h, 12); ctx.fillStyle = filled ? '#203455' : '#101a2e'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.18)'; ctx.lineWidth = 1; ctx.stroke(); ctx.fillStyle = '#eaf2ff'; ctx.font = '14px system-ui, -apple-system, Segoe UI, Roboto'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, box.x + box.w/2, box.y + box.h/2); } // === NEW: master draw() === function draw() { const W = cv.width / DPR, H = cv.height / DPR; ctx.clearRect(0,0,W,H); drawField(L.fieldA, enteringA ? 'A (active)' : 'A', aStr, enteringA); drawField(L.fieldB, !enteringA ? 'B (active)' : 'B', bStr, !enteringA); drawWheel(); drawResult(L.result, computeResult()); drawDial(); drawButton(L.btnClear, 'Clear', pointerMode==='btnClear' && pointerActive); drawButton(L.btnBack, 'Backspace', pointerMode==='btnBack' && pointerActive); drawButton(L.btnSwap, 'Swap A?B', pointerMode==='btnSwap' && pointerActive); } function computeResult() { const A = Number(aStr); const B = Number(bStr); if (!Number.isFinite(A) || !Number.isFinite(B)) return '—'; switch (OPS[opIndex]) { case '+': return String(A + B); case '?': return String(A - B); case '×': return String(A * B); case ':' : return B === 0 ? '?' : String(A / B); } return '—'; } function normalizeZeros(s) { // Preserve sign and leading zeros only when followed by '.' const neg = s.startsWith('-'); let t = neg ? s.slice(1) : s; if (t.startsWith('0') && !t.startsWith('0.')) { t = t.replace(/^0+(?=\d)/, ''); if (t === '') t = '0'; } return neg ? '-' + t : t; } function appendDigit(d) { if (enteringA) { let s = aStr; if (s === '-') s = '-0'; if (s === '') s = '0'; s += d; aStr = normalizeZeros(s); } else { let s = bStr; if (s === '-') s = '-0'; if (s === '') s = '0'; s += d; bStr = normalizeZeros(s); } } function appendDot() { if (enteringA) { let s = aStr; if (s === '' || s === '-') s += '0'; if (!s.includes('.')) s += '.'; aStr = s; } else { let s = bStr; if (s === '' || s === '-') s += '0'; if (!s.includes('.')) s += '.'; bStr = s; } } function toggleSign() { if (enteringA) { aStr = aStr.startsWith('-') ? aStr.slice(1) : ('-' + (aStr || '')); } else { bStr = bStr.startsWith('-') ? bStr.slice(1) : ('-' + (bStr || '')); } } function applyCell(label){ if (label === '±') return toggleSign(); if (label === '.') return appendDot(); return appendDigit(label); } function startNewPair() { aStr=''; bStr=''; enteringA = true; focusedField=null; swipeDisabled=false; } function onDown(ev) { ev.preventDefault(); pointerActive = true; lastKeyCell = null; pointerMode = null; cv.setPointerCapture?.(ev.pointerId); const {x,y} = toLocal(ev); // Focus fields: clicking A or B toggles focus and disables swipe if (inRect(L.fieldA,x,y)) { pointerMode='fieldA'; focusedField = (focusedField==='A'? null : 'A'); enteringA = true; swipeDisabled = !!focusedField; draw(); return; } if (inRect(L.fieldB,x,y)) { pointerMode='fieldB'; focusedField = (focusedField==='B'? null : 'B'); enteringA = false; swipeDisabled = !!focusedField; draw(); return; } if (inCenter(x,y)) { pointerMode='center'; startNewPair(); draw(); return; } if (hitWheel(x,y)) { pointerMode = 'wheel'; opIndex = angleToIndex(x,y); draw(); return; } if (inRect(L.btnClear,x,y)) { pointerMode='btnClear'; startNewPair(); draw(); return; } if (inRect(L.btnBack,x,y)) { pointerMode='btnBack'; if (focusedField==='A') aStr=aStr.slice(0,-1); else if (focusedField==='B') bStr=bStr.slice(0,-1); draw(); return; } if (inRect(L.btnSwap,x,y)) { pointerMode='btnSwap'; enteringA=!enteringA; focusedField=null; swipeDisabled=false; draw(); return; } const key = hitDial(x,y); if (key) { if (swipeDisabled) { draw(); return; } pointerMode = 'dial'; // Start of a new pair when beginning a swipe on A if (enteringA) { aStr=''; bStr=''; } lastKeyCell = key; applyCell(key.label); draw(); return; } draw(); } function onMove(ev) { if (!pointerActive) return; const {x,y} = toLocal(ev); if (pointerMode === 'wheel') { opIndex = angleToIndex(x,y); draw(); return; } if (pointerMode === 'dial') { const key = hitDial(x,y); if (key && (!lastKeyCell || key.id !== lastKeyCell.id)) { lastKeyCell = key; applyCell(key.label); draw(); return; } if (!key && lastKeyCell) { lastKeyCell = null; draw(); return; } } } function onUp(ev) { if (!pointerActive) return; pointerActive = false; if (pointerMode === 'dial') { // switch operand after a swipe sequence ends enteringA = !enteringA; } lastKeyCell = null; pointerMode = null; draw(); } // Utility function inRect(box, x, y) { return x>=box.x && y>=box.y && x<=box.x+box.w && y<=box.y+box.h; } function toLocal(ev) { const r = cv.getBoundingClientRect(); return { x: (ev.clientX - r.left), y: (ev.clientY - r.top) }; } function hitDial(x,y) { const {cx, cy, rOuter, rInner, cells} = L.dial; const dx = x - cx, dy = y - cy; const r = Math.hypot(dx, dy); if (r < rInner || r > rOuter) return null; // not in ring // angle 0 at top, clockwise let a = Math.atan2(dy, dx) + Math.PI/2; if (a < 0) a += Math.PI*2; const step = (Math.PI*2) / cells.length; // align hit-test with rotated segments (separators at 15°,45°,…) let aa = a + step/2; if (aa >= Math.PI*2) aa -= Math.PI*2; const idx = Math.floor(aa / step) % cells.length; return cells[idx]; } function inCenter(x,y) { const {cx, cy, rInner} = L.dial; return Math.hypot(x-cx, y-cy) <= rInner * 0.5; } function hitWheel(x,y) { const dx = x - L.wheel.x, dy = y - L.wheel.y; const d2 = dx*dx + dy*dy; const r = L.wheel.r, r2 = r*r; const ir2 = L.wheel.innerR * L.wheel.innerR; return d2 <= r2 && d2 >= ir2; } function angleToIndex(x,y) { const ang = Math.atan2(y - L.wheel.y, x - L.wheel.x); // -PI..PI, 0 at +x // Map so that index 0 (+) is at top (-PI/2), then clockwise in quadrants let a = ang + Math.PI/2; // now 0 at top if (a < 0) a += Math.PI*2; const idx = Math.floor(a / (Math.PI/2)) % 4; // 4 sectors return idx; } cv.addEventListener('pointerdown', onDown, {passive:false}); cv.addEventListener('pointermove', onMove, {passive:true}); cv.addEventListener('pointerup', onUp, {passive:true}); cv.addEventListener('pointercancel', onUp, {passive:true}); window.addEventListener('resize', resize); // --- Minimal tests (open with #test in URL to run) --- let testsRun = false; function maybeRunTests(){ if (testsRun) return; if (!location.hash.includes('test')) return; testsRun = true; runTests(); } function runTests(){ console.log('%cRunning canvas calculator tests…','color:#6ea8fe'); // 1) math with decimals aStr='12.5'; bStr='3'; opIndex=1; console.assert(computeResult()==='37.5','12.5×3 should be 37.5'); // 1b) colon division math aStr='5'; bStr='2'; opIndex=3; console.assert(computeResult()==='2.5','5:2 should be 2.5'); // 2) wheel angles mapping const cxw=L.wheel.x, cyw=L.wheel.y, rw=L.wheel.r*0.8; console.assert(angleToIndex(cxw, cyw - rw)===0,'Top sector should be +'); console.assert(angleToIndex(cxw + rw, cyw)===1,'Right sector should be ×'); console.assert(angleToIndex(cxw, cyw + rw)===2,'Bottom sector should be ?'); console.assert(angleToIndex(cxw - rw, cyw)===3,'Left sector should be :'); // 3) dial order and top hits const order = L.dial.cells.map(c=>c.label).join(''); console.assert(order==='.1234567890±','Dial order should be . ± 1..9 0 clockwise from top'); const rt=(L.dial.rInner+L.dial.rOuter)/2; const topHit=hitDial(L.dial.cx, L.dial.cy-rt); console.assert(topHit && topHit.label==='.' ,'Top of dial should be decimal point'); // 4) sign toggle enteringA=true; aStr='12'; toggleSign(); console.assert(aStr==='-12','± should toggle sign to negative'); toggleSign(); console.assert(aStr==='12','± toggles back to positive'); // 5) dot append rules aStr=''; enteringA=true; appendDot(); console.assert(aStr==='0.','Empty + dot yields 0.'); appendDot(); console.assert(aStr==='0.','Only one dot allowed'); // 6) draw() exists and runs console.assert(typeof draw === 'function','draw() should be defined'); draw(); // sanity call // 7) hitDial for 11 o'clock (±) const cellPM = L.dial.cells.find(c=>c.label==='±'); const rr2=(L.dial.rInner+L.dial.rOuter)/2; const hx = L.dial.cx + Math.cos(cellPM.am)*rr2; const hy = L.dial.cy + Math.sin(cellPM.am)*rr2; const h = hitDial(hx,hy); console.assert(h && h.label==='±','11 o\'clock sector should be ±'); // 7b) 3 o'clock should be '3', 6 o'clock should be '6' const rr3=(L.dial.rInner+L.dial.rOuter)/2; const rightHit = hitDial(L.dial.cx + rr3, L.dial.cy); console.assert(rightHit && rightHit.label==='3','3 o\'clock sector should be 3'); const bottomHit = hitDial(L.dial.cx, L.dial.cy + rr3); console.assert(bottomHit && bottomHit.label==='6','6 o\'clock sector should be 6'); // 8) leading zero normalization enteringA=true; aStr=''; appendDigit('0'); appendDigit('0'); appendDigit('5'); console.assert(aStr==='5','Leading zeros should collapse to 5'); // 9) B dot + sign behavior enteringA=false; bStr=''; appendDot(); toggleSign(); console.assert(bStr==='-0.','B supports sign then dot as -0.'); // 10) separators offset check: +15° from 12? '1', -15° from 12? '±' const step12 = (Math.PI*2)/12; const rr4 = (L.dial.rInner+L.dial.rOuter)/2; const angCW = -Math.PI/2 + (step12/2 + 0.02); const angCCW = -Math.PI/2 - (step12/2 + 0.02); const xCW = L.dial.cx + Math.cos(angCW)*rr4, yCW = L.dial.cy + Math.sin(angCW)*rr4; const xCCW = L.dial.cx + Math.cos(angCCW)*rr4, yCCW = L.dial.cy + Math.sin(angCCW)*rr4; console.assert(hitDial(xCW,yCW)?.label==='1', '+15° from 12 o\'clock should be 1'); console.assert(hitDial(xCCW,yCCW)?.label==='±', '-15° from 12 o\'clock should be ±'); console.log('%cAll tests passed','color:#3dd9b6'); } // Initial mount resize(); })(); </script></body></html>
// Create a separate document for the iframe via a Blob URL document.getElementById('game0').srcdoc = document.getElementById('game0-html').value;
En el caso de que se tengan las tablas de multiplicar sólo medio aprendidas pero queramos seguir avanzando en matemáticas, se puede recurrir a la calculadora. Lo difícil es conseguir una calculadora que sea lo menos intrusiva posible. La situación ideal es que una vez se ha decidido la operación a calcular, no haya que teclear ni el signo de la operación ni el signo igual. Este segundo objetivo se puede conseguir si se teclea sin levantar el dedo, y eso a su vez nos obliga a usar un diseño de calculadora circular.



Comentarios