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