-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathAbacus.html
More file actions
511 lines (466 loc) · 19.5 KB
/
Abacus.html
File metadata and controls
511 lines (466 loc) · 19.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="theme-color" content="#0c1016">
<title>Abacus — Board Gaming Hub</title>
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="description" content="A playable digital soroban abacus with live conversion to Arabic and Roman numerals. Tap beads or use the calculator pad. Free, browser-based, single HTML file.">
<link rel="canonical" href="https://boardgaminghub.com/Abacus.html">
<meta property="og:type" content="website">
<meta property="og:title" content="Abacus — a calculator for the calculator">
<meta property="og:description" content="A playable digital soroban abacus with Arabic + Roman numeral conversion.">
<style>
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; user-select: none; }
html, body { margin: 0; padding: 0; background: #1a0f08; color: #f0d89c; font-family: Georgia, "Times New Roman", serif; min-height: 100vh; }
#app { max-width: 760px; margin: 0 auto; padding: 16px 14px 80px; }
header { text-align: center; margin-bottom: 14px; }
header h1 { font-size: 1.5em; letter-spacing: 10px; margin: 4px 0 2px; color: #f0d89c; }
header .tag { font-size: 0.72em; letter-spacing: 3px; color: #a89070; font-style: italic; }
.display {
background: #0c0905;
border: 1px solid #3a2818;
border-radius: 6px;
padding: 12px 14px;
margin: 12px 0;
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.drow { display: grid; grid-template-columns: 70px 1fr; align-items: baseline; gap: 10px; }
.dlabel { font-size: 0.62em; letter-spacing: 2px; color: #806040; text-transform: uppercase; }
.dnum { font-family: "Courier New", monospace; font-size: 1.4em; color: #f0d89c; min-height: 1.3em; word-break: break-all; line-height: 1.25; }
.dnum.roman { font-family: "Times New Roman", serif; letter-spacing: 1px; font-size: 1.3em; }
.dnum.bead { font-size: 0.85em; color: #a89070; letter-spacing: 1.5px; }
.m1 { border-top: 1.5px solid currentColor; padding: 0 1.5px; display: inline-block; line-height: 1; }
.m2 { border-top: 4px double currentColor; padding-top: 1px; padding-left: 1.5px; padding-right: 1.5px; display: inline-block; line-height: 1; }
.m3 { border-top: 1.5px solid currentColor; box-shadow: inset 0 4px 0 -2.5px currentColor, inset 0 7px 0 -5.5px currentColor; padding: 5px 1.5px 0; display: inline-block; line-height: 1; }
.frame {
background: linear-gradient(180deg, #6b3f1f 0%, #4d2d15 50%, #6b3f1f 100%);
border: 3px solid #2a1808;
border-radius: 8px;
padding: 6px;
box-shadow: inset 0 0 22px rgba(0,0,0,.55), 0 4px 8px rgba(0,0,0,.35);
}
.frame svg { display: block; width: 100%; height: auto; }
.expr {
background: #0c0905; border: 1px solid #3a2818; border-radius: 4px;
padding: 8px 12px; margin-top: 12px; min-height: 28px;
font-family: "Courier New", monospace; font-size: 1.0em; color: #d4a060;
letter-spacing: 2px; text-align: right;
}
.expr:empty::before { content: "·"; color: #604030; }
.pad {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px;
margin-top: 10px;
}
.pad button {
background: #2a1808; color: #f0d89c; border: 1px solid #4d2d15;
font-family: Georgia, serif; font-size: 1.3em; padding: 14px 0;
border-radius: 5px; cursor: pointer; letter-spacing: 1px;
transition: background .08s, transform .05s;
}
.pad button:active { background: #4d2d15; transform: scale(0.97); }
.pad button.op { color: #d4a060; }
.pad button.eq { background: #704020; color: #fce8b8; }
.pad button.eq:active { background: #855030; }
.pad button.span2 { grid-column: span 2; }
.pad button.danger { color: #c08060; font-size: 0.85em; letter-spacing: 2px; }
.modes { margin-top: 14px; display: flex; gap: 6px; justify-content: center; font-size: 0.78em; letter-spacing: 2px; }
.modes button {
background: transparent; border: 1px solid #4d2d15; color: #a89070;
padding: 8px 18px; cursor: pointer; font-family: Georgia, serif;
text-transform: uppercase; border-radius: 3px;
}
.modes button.active { background: #4d2d15; color: #f0d89c; }
.help { margin-top: 18px; color: #a89070; font-size: 0.82em; line-height: 1.55; }
.help b { color: #f0d89c; }
.help details { margin-top: 8px; }
.help summary { cursor: pointer; color: #d4a060; letter-spacing: 1.5px; font-size: 0.78em; text-transform: uppercase; }
.help p { margin: 8px 0; }
footer { text-align: center; padding: 18px 0 0; color: #604030; font-size: 0.72em; letter-spacing: 2px; }
footer a { color: #a89070; text-decoration: none; }
</style>
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-2835365593874464"
crossorigin="anonymous"></script>
</head>
<body>
<div id="app">
<header>
<h1>ABACUS</h1>
<div class="tag">a calculator for the calculator</div>
</header>
<div class="display">
<div class="drow"><span class="dlabel">Arabic</span><div class="dnum arabic" id="d-arabic">0</div></div>
<div class="drow"><span class="dlabel">Roman</span><div class="dnum roman" id="d-roman">nulla</div></div>
<div class="drow"><span class="dlabel">Per rod</span><div class="dnum bead" id="d-bead">·</div></div>
</div>
<div class="frame">
<svg id="abacus" viewBox="0 0 600 260" preserveAspectRatio="xMidYMid meet"></svg>
</div>
<div class="expr" id="expr"></div>
<div class="pad" id="pad">
<button data-k="7">7</button>
<button data-k="8">8</button>
<button data-k="9">9</button>
<button data-k="/" class="op">÷</button>
<button data-k="4">4</button>
<button data-k="5">5</button>
<button data-k="6">6</button>
<button data-k="*" class="op">×</button>
<button data-k="1">1</button>
<button data-k="2">2</button>
<button data-k="3">3</button>
<button data-k="-" class="op">−</button>
<button data-k="0">0</button>
<button data-k="bksp" class="op">←</button>
<button data-k="C" class="op danger">C</button>
<button data-k="+" class="op">+</button>
<button data-k="=" class="span2 eq">=</button>
<button data-k="reset" class="span2 danger">reset abacus</button>
</div>
<div class="modes">
<button id="m-practice" class="active">Practice</button>
<button id="m-calc">Calculator</button>
</div>
<div class="help">
<details>
<summary>How to use</summary>
<p><b>Practice mode</b> — tap any bead to slide it. Heaven beads (above the bar) are worth 5; earth beads (below the bar) are worth 1. A bead "counts" only when pushed against the bar. Each rod is a decimal place — leftmost is trillions, rightmost is ones. The dot under a rod marks the unit rod. Tap a digit on the pad to push it in from the right; tap ← to shift back; tap C to clear the abacus.</p>
<p><b>Calculator mode</b> — type an expression (e.g. <code>137 × 89</code>) using the pad, then tap =. The beads animate to the result. Integer arithmetic; division truncates. Switch back to practice anytime to manipulate the result by hand.</p>
<p><b>Roman numerals</b> use the standard subtractive notation (IV, IX, XL, etc.) up to 3,999. Above that, single-overlined groups represent thousands (V̄ = 5,000), double-overlined represent millions, triple-overlined represent billions — the same vinculum convention Romans actually used.</p>
</details>
<details>
<summary>About</summary>
<p>The soroban (Japanese 4+1 abacus, refined from the Chinese suanpan) is a 2,000-year-old mechanical computer. A trained operator can add long columns of numbers faster than someone with a calculator, because the answer is already half-formed by the bead positions during entry. This is a faithful digital rendering — the mechanics are exactly what a physical soroban does.</p>
<p>The irony: this calculator app contains an abacus simulating a calculator running on a calculator (your phone) that contains roughly a billion abacuses' worth of computational power. Use it to add 2 + 2.</p>
</details>
</div>
<footer>
<a href="/">← Board Gaming Hub</a>
</footer>
</div>
<script>
(function () {
const NUM_RODS = 13;
const MAX = Math.pow(10, NUM_RODS) - 1;
let rods = new Array(NUM_RODS).fill(0);
let mode = "practice";
let expr = "";
// SVG layout constants (viewBox 600x260)
const PAD_X = 22;
const ROD_W = 44;
const TOP_Y = 8;
const BAR_Y = 80;
const BAR_H = 6;
const BOT_Y = 252;
const HEAVEN_TOP_Y = 28; // heaven bead inactive (resting against top rail)
const HEAVEN_BAR_Y = BAR_Y - 12; // heaven bead active (just above bar)
const EARTH_BAR_Y = BAR_Y + BAR_H + 12; // first earth bead active y
const EARTH_BOT_Y = BOT_Y - 12; // last earth bead inactive y
const EARTH_GAP = 18;
const BEAD_RX = 17;
const BEAD_RY = 8;
const UNIT_ROD = NUM_RODS - 1;
const padEl = document.getElementById("pad");
const svg = document.getElementById("abacus");
const exprEl = document.getElementById("expr");
function rodCx(i) { return PAD_X + i * ROD_W + ROD_W / 2; }
function heavenY(active) {
return active ? HEAVEN_BAR_Y : HEAVEN_TOP_Y;
}
function earthY(slot, count) {
// slot 0 = closest to bar, slot 3 = furthest
if (slot < count) {
return EARTH_BAR_Y + slot * EARTH_GAP;
}
// inactive: stack against bottom rail
// slot 3 sits at EARTH_BOT_Y; slot 0 (if inactive) sits 3 gaps above
return EARTH_BOT_Y - (3 - slot) * EARTH_GAP;
}
function svgEsc(s) { return String(s).replace(/&/g,"&").replace(/</g,"<"); }
function render() {
const W = PAD_X * 2 + NUM_RODS * ROD_W;
const parts = [];
// Outer wood
parts.push(`<rect x="0" y="0" width="${W}" height="260" fill="none"/>`);
// Top rail
parts.push(`<rect x="6" y="${TOP_Y}" width="${W - 12}" height="6" fill="#2a1808" rx="2"/>`);
// Reckoning bar
parts.push(`<rect x="6" y="${BAR_Y}" width="${W - 12}" height="${BAR_H}" fill="#2a1808" rx="2"/>`);
// Bottom rail
parts.push(`<rect x="6" y="${BOT_Y - 4}" width="${W - 12}" height="6" fill="#2a1808" rx="2"/>`);
// Side rails
parts.push(`<rect x="0" y="0" width="6" height="260" fill="#2a1808"/>`);
parts.push(`<rect x="${W - 6}" y="0" width="6" height="260" fill="#2a1808"/>`);
for (let i = 0; i < NUM_RODS; i++) {
const cx = rodCx(i);
// Rod (vertical line behind beads)
parts.push(`<line x1="${cx}" y1="14" x2="${cx}" y2="${BOT_Y - 8}" stroke="#3a1f0a" stroke-width="2"/>`);
// Unit-rod marker
if (i === UNIT_ROD) {
parts.push(`<circle cx="${cx}" cy="${BOT_Y + 5}" r="2.5" fill="#fce8b8"/>`);
}
// Decimal marker every 3 rods leftward from unit rod
if (i !== UNIT_ROD && (UNIT_ROD - i) % 3 === 0) {
parts.push(`<circle cx="${cx}" cy="${BOT_Y + 5}" r="1.5" fill="#a89070"/>`);
}
const rod = rods[i];
const heavenAct = rod >= 5;
const earthCount = rod % 5;
// Heaven bead
const hy = heavenY(heavenAct);
const hFill = heavenAct ? "url(#beadOn)" : "url(#beadOff)";
parts.push(beadSvg(cx, hy, hFill, i, "h", -1));
// Earth beads
for (let s = 0; s < 4; s++) {
const ey = earthY(s, earthCount);
const act = s < earthCount;
const fill = act ? "url(#beadOn)" : "url(#beadOff)";
parts.push(beadSvg(cx, ey, fill, i, "e", s));
}
}
// Bead gradients
const defs = `
<defs>
<radialGradient id="beadOn" cx="0.35" cy="0.35" r="0.7">
<stop offset="0%" stop-color="#ffe8b8"/>
<stop offset="55%" stop-color="#d4a060"/>
<stop offset="100%" stop-color="#704020"/>
</radialGradient>
<radialGradient id="beadOff" cx="0.35" cy="0.35" r="0.7">
<stop offset="0%" stop-color="#c89868"/>
<stop offset="55%" stop-color="#9c6c3c"/>
<stop offset="100%" stop-color="#502810"/>
</radialGradient>
</defs>`;
svg.innerHTML = defs + parts.join("");
svg.setAttribute("viewBox", `0 0 ${W} 260`);
updateDisplays();
}
function beadSvg(cx, cy, fill, rodIdx, beadKind, slot) {
return `<ellipse class="bead" data-rod="${rodIdx}" data-b="${beadKind}" data-slot="${slot}" cx="${cx}" cy="${cy}" rx="${BEAD_RX}" ry="${BEAD_RY}" fill="${fill}" stroke="#1a0a02" stroke-width="1"/>`;
}
function getValue() {
let v = 0;
for (let i = 0; i < NUM_RODS; i++) v = v * 10 + rods[i];
return v;
}
function setValue(n) {
if (n < 0) n = 0;
if (n > MAX) n = MAX;
n = Math.floor(n);
const out = new Array(NUM_RODS).fill(0);
for (let i = NUM_RODS - 1; i >= 0; i--) {
out[i] = n % 10;
n = Math.floor(n / 10);
}
rods = out;
render();
}
function formatArabic(n) { return n.toLocaleString("en-US"); }
function toRomanGroup(n) {
if (n === 0) return "";
const parts = [
[1000,"M"],[900,"CM"],[500,"D"],[400,"CD"],
[100,"C"],[90,"XC"],[50,"L"],[40,"XL"],
[10,"X"],[9,"IX"],[5,"V"],[4,"IV"],[1,"I"]
];
let out = "";
for (const [v,s] of parts) {
while (n >= v) { out += s; n -= v; }
}
return out;
}
function toRoman(n) {
if (n === 0) return "nulla";
const groups = [];
while (n > 0) { groups.unshift(n % 1000); n = Math.floor(n / 1000); }
return groups.map((g, i) => {
const level = groups.length - 1 - i;
const r = toRomanGroup(g);
if (!r) return "";
if (level === 0) return r;
const cls = level === 1 ? "m1" : level === 2 ? "m2" : "m3";
return `<span class="${cls}">${r}</span>`;
}).filter(Boolean).join("");
}
function formatBead() {
let out = [];
let started = false;
for (let i = 0; i < NUM_RODS; i++) {
const d = rods[i];
if (d > 0) started = true;
if (!started && i < UNIT_ROD) continue;
if (d === 0) { out.push("·"); continue; }
const h = d >= 5 ? "5" : "0";
const e = d % 5;
out.push(h + "+" + e);
}
return out.join(" ");
}
function updateDisplays() {
const v = getValue();
document.getElementById("d-arabic").textContent = formatArabic(v);
document.getElementById("d-roman").innerHTML = toRoman(v);
document.getElementById("d-bead").textContent = formatBead();
exprEl.textContent = expr;
}
function toggleBead(t) {
if (!t || !t.classList || !t.classList.contains("bead")) return false;
const i = +t.getAttribute("data-rod");
const b = t.getAttribute("data-b");
const heaven = rods[i] >= 5 ? 5 : 0;
let earth = rods[i] % 5;
let next;
if (b === "h") {
next = (heaven ? 0 : 5) + earth;
} else {
const slot = +t.getAttribute("data-slot");
// If clicked bead was active, push it (and any below) away from bar.
// If inactive, push it (and any between it and bar) toward bar.
const wasActive = slot < earth;
const newCount = wasActive ? slot : slot + 1;
next = heaven + newCount;
}
if (next === rods[i]) return false;
rods[i] = next;
if (mode === "calc") {
// Manual bead manipulation in calc mode acts as user override
// — sync expression to current value.
expr = String(getValue());
}
render();
return true;
}
function beadAtPoint(x, y) {
const el = document.elementFromPoint(x, y);
if (el && el.classList && el.classList.contains("bead")) return el;
return null;
}
// Bead taps (mouse / non-touch)
svg.addEventListener("click", function (e) {
if (e.target && e.target.classList && e.target.classList.contains("bead")) {
toggleBead(e.target);
}
});
// Touch: tap-to-toggle plus drag along a rod toggles each new bead crossed.
let touchActive = false;
let touchSeen = null; // last bead element toggled during this touch
svg.addEventListener("touchstart", function (e) {
const t = e.touches[0];
if (!t) return;
const bead = beadAtPoint(t.clientX, t.clientY);
if (!bead) return;
e.preventDefault();
touchActive = true;
touchSeen = bead;
toggleBead(bead);
}, { passive: false });
svg.addEventListener("touchmove", function (e) {
if (!touchActive) return;
const t = e.touches[0];
if (!t) return;
const bead = beadAtPoint(t.clientX, t.clientY);
if (!bead || bead === touchSeen) return;
// Only toggle if same rod as the start (drag along rod, not across rods)
if (touchSeen && bead.getAttribute("data-rod") !== touchSeen.getAttribute("data-rod")) return;
e.preventDefault();
touchSeen = bead;
toggleBead(bead);
}, { passive: false });
function endTouch() { touchActive = false; touchSeen = null; }
svg.addEventListener("touchend", endTouch);
svg.addEventListener("touchcancel", endTouch);
// Pad
function setMode(m) {
mode = m;
document.getElementById("m-practice").classList.toggle("active", m === "practice");
document.getElementById("m-calc").classList.toggle("active", m === "calc");
if (m === "calc" && !expr) expr = String(getValue());
if (m === "practice") expr = "";
updateDisplays();
}
function evalExpr(s) {
// Allow only digits and + - * / and whitespace
if (!/^[\d+\-*/\s]+$/.test(s)) return null;
try {
const r = Function('"use strict"; return (' + s + ');')();
if (!Number.isFinite(r)) return null;
return Math.trunc(r);
} catch (e) { return null; }
}
function handleKey(k) {
if (k === "reset") {
rods.fill(0); expr = ""; render(); return;
}
if (mode === "practice") {
if (/^\d$/.test(k)) {
// Shift-in digit from the right
for (let i = 0; i < NUM_RODS - 1; i++) rods[i] = rods[i + 1];
rods[NUM_RODS - 1] = +k;
render();
return;
}
if (k === "C") { rods.fill(0); render(); return; }
if (k === "bksp") {
for (let i = NUM_RODS - 1; i > 0; i--) rods[i] = rods[i - 1];
rods[0] = 0;
render();
return;
}
if (k === "+" || k === "-" || k === "*" || k === "/" || k === "=") {
// Auto-switch to calculator mode and start expression
setMode("calc");
if (k === "=") return;
expr = String(getValue()) + k;
updateDisplays();
return;
}
} else {
if (/^\d$/.test(k)) { expr += k; }
else if (k === "+" || k === "-" || k === "*" || k === "/") {
if (!expr) expr = String(getValue());
if (/[+\-*/]$/.test(expr)) expr = expr.slice(0, -1) + k;
else expr += k;
} else if (k === "bksp") {
expr = expr.slice(0, -1);
} else if (k === "C") {
expr = ""; rods.fill(0);
} else if (k === "=") {
const r = evalExpr(expr);
if (r !== null) {
setValue(r);
expr = String(r);
}
updateDisplays();
return;
}
// Live-update beads to current trailing operand
const m = expr.match(/(-?\d+)\s*$/);
if (m) setValue(parseInt(m[1], 10));
else { rods.fill(0); render(); }
}
}
padEl.addEventListener("click", function (e) {
const k = e.target && e.target.getAttribute && e.target.getAttribute("data-k");
if (k) handleKey(k);
});
document.getElementById("m-practice").addEventListener("click", () => setMode("practice"));
document.getElementById("m-calc").addEventListener("click", () => setMode("calc"));
// Keyboard support
window.addEventListener("keydown", function (e) {
const k = e.key;
if (/^[0-9]$/.test(k)) handleKey(k);
else if (k === "+" || k === "-" || k === "*" || k === "/" || k === "=") handleKey(k);
else if (k === "Enter") handleKey("=");
else if (k === "Backspace") handleKey("bksp");
else if (k === "Escape" || k === "c" || k === "C") handleKey("C");
else return;
e.preventDefault();
});
render();
})();
</script>
</body>
</html>