Skill: proportions
Proportion Balance Lab
Drag the sliders and watch the scale. It balances only when the cross-products match — a × d = b × c — and the lab always reveals the d that would balance.
1 Balance the scale
Four sliders, one rule: the beam sits level exactly when a × d equals b × c. Find the d that balances each setup.
Controls
The scale
2 Recipe scaler
2 eggs make 3 pancakes — a proportion that must stay true at every serving size. Drag the servings and watch both sides scale together.
Controls
Base recipe: 2 eggs and 3 pancakes per serving.
Scaled recipe
/* ===== CORE:fibo_core ===== */ 'use strict'; /* ===================================================================== Fibo shared core — pure JavaScript, no DOM access. Inlined (verbatim) into practice / tool / lab / worksheet / game modules. Node tests load this file directly. Rule: no double-ampersand operator anywhere in this file (WordPress-safe). Use De Morgan !(notA || notB) or nested ifs instead. ===================================================================== */
/* ---------------- exact rational arithmetic ---------------- */ function igcd(a, b) { a = Math.abs(a); b = Math.abs(b); while (b !== 0) { var t = a % b; a = b; b = t; } return a || 1; } function red(n, d) { if (d === 0) throw new Error('red: zero denominator'); if (d < 0) { n = -n; d = -d; } if (n === 0) return { n: 0, d: 1 }; var g = igcd(n, d); return { n: n / g, d: d / g }; } function addFrac(a, b) { return red(a.n * b.d + b.n * a.d, a.d * b.d); } function subFrac(a, b) { return red(a.n * b.d - b.n * a.d, a.d * b.d); } function mulFrac(a, b) { return red(a.n * b.n, a.d * b.d); } function divFrac(a, b) { return red(a.n * b.d, a.d * b.n); } function sameFrac(a, b) { return a.n * b.d === b.n * a.d; } function cmpFrac(a, b) { var l = a.n * b.d, r = b.n * a.d; if (l < r) return -1; if (l > r) return 1; return 0; } function fracText(f) { return f.d === 1 ? String(f.n) : f.n + '/' + f.d; } function fracHTML(n, d) { var f = red(n, d); if (f.d === 1) return '' + f.n + ''; return '' + f.n + '' + f.d + ''; } function moneyText(n, d) { var totalCents = Math.round(n * 100 / d); var neg = totalCents < 0; var ac = Math.abs(totalCents); var dol = Math.floor(ac / 100), c = ac % 100; return (neg ? '-$' : '$') + dol + '.' + (c < 10 ? '0' + c : String(c)); } function plural(n, word) { return n + ' ' + word + (n === 1 ? '' : 's'); } function esc(s) { return String(s).replace(/\x26/g, '&').replace(//g, '>').replace(/"/g, '"'); }
/* ---------------- seeded PRNG ---------------- */ function mulberry32(seed) { var a = (seed >>> 0) || 1; return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; var t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function shuffle(arr, rand) { for (var i = arr.length - 1; i > 0; i--) { var j = Math.floor(rand() * (i + 1)); var t = arr[i]; arr[i] = arr[j]; arr[j] = t; } return arr; } function ri(rand, lo, hi) { return lo + Math.floor(rand() * (hi - lo + 1)); } function pick(rand, arr) { return arr[Math.floor(rand() * arr.length)]; } function chance(rand, p) { return rand() < p; } /* ---------------- input parsing ---------------- */ function scaleDec(s) { var i = s.indexOf('.'); if (i < 0) return { n: parseInt(s, 10), d: 1 }; var places = s.length - i - 1; var d = Math.pow(10, places); return { n: Math.round(parseFloat(s) * d), d: d }; } /* parseNum: integer | a/b | decimal | mixed "1 1/2" -> {n,d} reduced | null */ function parseNum(str) { var s = String(str).replace(/−/g, '-').trim(); if (!s) return null; var sign = 1; if (s.charAt(0) === '-') { sign = -1; s = s.slice(1).trim(); } else if (s.charAt(0) === '+') { s = s.slice(1).trim(); } var m; if ((m = /^(\d+)\s+(\d+)\s*\/\s*(\d+)$/.exec(s))) { var d0 = parseInt(m[3], 10); if (d0 === 0) return null; return red(sign * (parseInt(m[1], 10) * d0 + parseInt(m[2], 10)), d0); } s = s.replace(/\s+/g, ''); if ((m = /^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/.exec(s))) { var an = scaleDec(m[1]), ad = scaleDec(m[2]); if (ad.n === 0) return null; return red(sign * an.n * ad.d, ad.n * an.d); } if ((m = /^(\d*\.?\d+)$/.exec(s))) { var a = scaleDec(m[1]); return red(sign * a.n, a.d); } return null; } /* parseProb: parseNum plus trailing % -> {n,d} reduced | null */ function parseProb(str) { var s = String(str).trim(); var pct = /%$/.test(s); if (pct) s = s.slice(0, -1).trim(); var f = parseNum(s); if (!f) return null; if (pct) f = red(f.n, f.d * 100); return f; } /* parseRatio: "a:b" or "a to b" (integers or decimals) -> {value:{n,d} reduced value of a/b, intParts:bool, a, b} | null */ function parseRatio(str) { var s = String(str).replace(/−/g, '-').trim(); var m = /^(-?\d+(?:\.\d+)?)\s*:\s*(-?\d+(?:\.\d+)?)$/.exec(s); if (!m) { m = /^(-?\d+(?:\.\d+)?)\s+to\s+(-?\d+(?:\.\d+)?)$/i.exec(s); if (!m) return null; } var fa = parseNum(m[1]), fb = parseNum(m[2]); if (!fa) return null; if (!fb) return null; if (fb.n === 0) return null; var value = red(fa.n * fb.d, fa.d * fb.n); var intParts = /^-?\d+$/.test(m[1].trim()); if (intParts) intParts = /^-?\d+$/.test(m[2].trim()); return { value: value, intParts: intParts, a: parseInt(m[1], 10), b: parseInt(m[2], 10) }; } function parseChoice(str) { var s = String(str).trim().toUpperCase().replace(/[^A-Z]/g, ''); if (s === 'A') return 'A'; if (s === 'B') return 'B'; if (s === 'C') return 'C'; if (s === 'D') return 'D'; return null; } function parseYN(str) { var s = String(str).trim().toLowerCase(); if (s === 'yes' || s === 'y' || s === 'true' || s === 't' || s === '1') return true; if (s === 'no' || s === 'n' || s === 'false' || s === 'f' || s === '0') return false; return null; }
/* ---------------- unified answer checking ---------------- answer shapes: {kind:'ratio', a, b, simplest?} expected ratio a:b (positive ints) {kind:'num', n, d} exact rational {kind:'money', n, d} exact rational dollars {kind:'prob', n, d} exact rational in [0,1] {kind:'choice', letter} 'A'..'D' {kind:'yn', yes} boolean returns {ok, correct, note?} ; note 'not-simplest' when the value is right but the form is not reduced and simplest was required. */ function checkAnswer(ans, raw) { var out = { ok: false, correct: false }; var s = String(raw); if (ans.kind === 'ratio') { var r = parseRatio(s); if (!r) return out; out.ok = true; out.correct = sameFrac(red(ans.a, ans.b), r.value); if (ans.simplest) { var reduced = r.intParts; if (reduced) reduced = igcd(Math.abs(r.a), Math.abs(r.b)) === 1; if (reduced) reduced = r.b > 0; if (reduced) reduced = r.a >= 0; if (!reduced) { out.correct = false; out.note = 'not-simplest'; } } return out; } if (ans.kind === 'num' || ans.kind === 'money') { var f = parseNum(s); if (!f) return out; out.ok = true; out.correct = sameFrac({ n: ans.n, d: ans.d }, f); return out; } if (ans.kind === 'prob') { var p = parseProb(s); if (!p) return out; out.ok = true; out.correct = sameFrac({ n: ans.n, d: ans.d }, p); return out; } if (ans.kind === 'choice') { var c = parseChoice(s); if (!c) return out; out.ok = true; out.correct = (c === ans.letter); return out; } if (ans.kind === 'yn') { var y = parseYN(s); if (y === null) return out; out.ok = true; out.correct = (y === ans.yes); return out; } return out; }
/* ---------------- practice session ---------------- TEMPLATES: [{id, tag, diff, enumerate()->[{key,...}], make(p)->prob}] prob: {promptHTML, answer, hintHTML, whyHTML, answerText, accept[], tag, diff} */ function PracticeSession(templates, seed) { this.templates = templates; this._rand = mulberry32(seed >>> 0); this.pools = {}; this.seen = {}; this.score = 0; this.streak = 0; this.problemNum = 0; this.current = null; this.wrongCount = 0; this.revealed = false; this._done = false; } PracticeSession.prototype._draw = function () { for (var guard = 0; guard < 300; guard++) { var t = this.templates[Math.floor(this._rand() * this.templates.length)]; var pool = this.pools[t.id]; if (!pool || pool.length === 0) { pool = shuffle(t.enumerate().slice(), this._rand); this.pools[t.id] = pool; } var p = pool.pop(); var key = t.id + '|' + p.key; if (this.seen[key]) continue; this.seen[key] = 1; var prob = t.make(p); prob.templateId = t.id; prob.tag = t.tag; prob.diff = t.diff; prob.key = key; return prob; } this.pools = {}; this.seen = {}; var t2 = this.templates[0], p2 = t2.enumerate()[0]; var pr = t2.make(p2); pr.templateId = t2.id; pr.tag = t2.tag; pr.diff = t2.diff; pr.key = t2.id + '|' + p2.key; return pr; }; PracticeSession.prototype.next = function () { this.current = this._draw(); this.wrongCount = 0; this.revealed = false; this._done = false; this.problemNum++; return this.current; }; PracticeSession.prototype.submit = function (raw) { var pr = this.current; if (!pr) return { status: 'none' }; if (this._done) return { status: 'already' }; var chk = checkAnswer(pr.answer, raw); if (!chk.ok) return { status: 'invalid' }; if (chk.correct) { this._done = true; var firstTry = this.wrongCount === 0; if (!this.revealed) { this.streak++; if (firstTry) this.score++; } else { firstTry = false; } return { status: 'correct', firstTry: firstTry, score: this.score, streak: this.streak, answerHTML: pr.answerText }; } this.wrongCount++; this.streak = 0; if (chk.note === 'not-simplest') { if (this.wrongCount >= 2) { this.revealed = true; return { status: 'why', whyHTML: pr.whyHTML, streak: 0 }; } return { status: 'not-simplest', streak: 0 }; } if (this.wrongCount === 1) return { status: 'hint', hintHTML: pr.hintHTML, streak: 0 }; this.revealed = true; return { status: 'why', whyHTML: pr.whyHTML, streak: 0 }; }; PracticeSession.prototype.reveal = function () { if (!this.current) return null; this.revealed = true; return this.current.whyHTML; };
/* ---------------- game engine ----------------
Timed sprint: score/streak/lives, difficulty ramp by question index.
opts: {seed, lives} ; time is tracked by the page (DOM) via isOver(). */
function GameEngine(templates, opts) {
opts = opts || {};
this.templates = templates;
this._rand = mulberry32((opts.seed >>> 0) || 1);
this.lives = opts.lives || 3;
this.pools = {};
this.seen = {};
this.score = 0;
this.streak = 0;
this.bestStreak = 0;
this.qIndex = 0;
this.answered = 0;
this.correctCount = 0;
this.current = null;
this.over = false;
}
GameEngine.prototype._allowedDiff = function (diff) {
if (this.qIndex < 4) return diff === 'easy';
if (this.qIndex < 9) return diff === 'easy' || diff === 'medium';
return true;
};
GameEngine.prototype.nextQuestion = function () {
if (this.over) return null;
var allowed = [];
for (var i = 0; i < this.templates.length; i++) {
if (this._allowedDiff(this.templates[i].diff)) allowed.push(this.templates[i]);
}
if (allowed.length === 0) allowed = this.templates;
for (var guard = 0; guard < 300; guard++) {
var t = allowed[Math.floor(this._rand() * allowed.length)];
var pool = this.pools[t.id];
if (!pool || pool.length === 0) {
pool = shuffle(t.enumerate().slice(), this._rand);
this.pools[t.id] = pool;
}
var p = pool.pop();
var key = t.id + '|' + p.key;
if (this.seen[key]) continue;
this.seen[key] = 1;
var q = t.make(p);
q.templateId = t.id; q.tag = t.tag; q.diff = t.diff; q.key = key;
this.current = q;
this.qIndex++;
return q;
}
return null;
};
GameEngine.prototype.submit = function (raw) {
var q = this.current;
if (!q || this.over) return { status: 'none' };
var chk = checkAnswer(q.answer, raw);
if (!chk.ok) return { status: 'invalid' };
this.answered++;
if (chk.correct) {
this.correctCount++;
this.streak++;
if (this.streak > this.bestStreak) this.bestStreak = this.streak;
var gained = 10;
if (this.streak >= 4) gained += 2 * (this.streak - 3);
this.score += gained;
this.current = null;
return { status: 'correct', gained: gained, score: this.score, streak: this.streak };
}
this.streak = 0;
this.lives--;
var explanation = q.whyHTML;
if (this.lives <= 0) this.over = true;
this.current = null;
return {
status: 'wrong', lives: this.lives, over: this.over,
explanation: explanation, answerText: q.answerText
};
};
GameEngine.prototype.finish = function () { this.over = true; };
GameEngine.prototype.stats = function () {
return {
score: this.score, bestStreak: this.bestStreak, answered: this.answered,
correctCount: this.correctCount, lives: this.lives,
accuracy: this.answered === 0 ? 0 : Math.round(100 * this.correctCount / this.answered)
};
};
/* ---------------- worksheet generator ----------------
plan: [{diff:'easy', count:7}, ...] ; returns array of problems. */
function genWorksheet(templates, seed, plan) {
var rand = mulberry32(seed >>> 0);
var byDiff = { easy: [], medium: [], hard: [] };
templates.forEach(function (t) { byDiff[t.diff].push(t); });
var pools = {}, seen = {}, out = [], n = 0;
function draw(diff) {
var cands = byDiff[diff].length ? byDiff[diff] : templates;
for (var guard = 0; guard < 300; guard++) {
var t = cands[Math.floor(rand() * cands.length)];
var pool = pools[t.id];
if (!pool || pool.length === 0) {
pool = shuffle(t.enumerate().slice(), rand);
pools[t.id] = pool;
}
var p = pool.pop();
var key = t.id + '|' + p.key;
if (seen[key]) continue;
seen[key] = 1;
var prob = t.make(p);
prob.tag = t.tag; prob.diff = t.diff; prob.key = key; prob.templateId = t.id;
return prob;
}
return null;
}
plan.forEach(function (step) {
for (var i = 0; i < step.count; i++) {
var pr = draw(step.diff);
if (!pr) continue;
n++;
pr.num = n;
out.push(pr);
}
});
return out;
}
/* ---- export for node; browser gets globals ---- */
(function (root, factory) {
'use strict';
var api = factory();
if (typeof module !== 'undefined') {
if (module.exports) module.exports = api;
} else {
root.FiboCore = api;
}
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
return {
igcd: igcd, red: red,
addFrac: addFrac, subFrac: subFrac, mulFrac: mulFrac, divFrac: divFrac,
sameFrac: sameFrac, cmpFrac: cmpFrac,
fracText: fracText, fracHTML: fracHTML, moneyText: moneyText,
plural: plural, esc: esc,
mulberry32: mulberry32, shuffle: shuffle, ri: ri, pick: pick, chance: chance,
parseNum: parseNum, parseProb: parseProb, parseRatio: parseRatio,
parseChoice: parseChoice, parseYN: parseYN,
checkAnswer: checkAnswer,
PracticeSession: PracticeSession,
GameEngine: GameEngine,
genWorksheet: genWorksheet
};
});
/* ===== /CORE:fibo_core ===== */
/* ===== CORE:lab ===== */ 'use strict'; /* ===================================================================== PROPORTIONS lab core — pure JS, no DOM. Loaded after fibo_core.js. Proportion Balance Lab: cross-product beams, balance tilt, and a recipe scaler. All math exact (rational) where it matters. ===================================================================== */
/* balanceFor(a,b,c): the d that balances a/b = c/d, as a reduced fraction, plus the cross-products in lowest terms. */ function balanceFor(a, b, c) { var d = red(b * c, a); var left = red(a * d.n, d.d); // a*d var right = b * c; // b*c (integer) return { d: d, left: left, right: { n: right, d: 1 } }; }
/* crossProducts(a,b,c,d): plain cross-product comparison. */ function crossProducts(a, b, c, d) { var left = a * d, right = b * c; return { left: left, right: right, balanced: left === right }; }
/* beamTilt(left, right): beam angle in degrees for the SVG scale. 0 = balanced; positive tips toward the left pan. Clamped to [-18, 18]. */ function beamTilt(left, right) { if (left === right) return 0; var ratio = left > right ? left / right : right / left; var tilt = Math.min(18, 4 * Math.log(ratio + 1)); return left > right ? tilt : -tilt; }
/* recipeScale(servings): 2 eggs -> 3 pancakes per serving batch. */ function recipeScale(servings) { var eggs = 2 * servings, pancakes = 3 * servings; return { servings: servings, eggs: eggs, pancakes: pancakes, check: crossProducts(2, 3, eggs, pancakes) // 2/3 = eggs/pancakes ? }; }
/* propStepsHTML(a,b,c,dFrac): worked cross-multiplication for the lab readout. */ function propStepsHTML(a, b, c, dFrac) { var dTxt = fracText(dFrac); return '
- ' +
'
- Proportion: ' + a + '/' + b + ' = ' + c + '/d.
- Cross-multiply: ' + a + ' × d = ' + b + ' × ' + c + ', so ' + a + 'd = ' + (b * c) + '.
- d = ' + (b * c) + ' ÷ ' + a + ' = ' + dTxt + '.
' + '
' + '
'; }
/* ===== /CORE:lab ===== */
/* ===== CORE:glue_lab ===== */ 'use strict'; /* Proportions Lab glue: Proportion Balance Lab + recipe scaler. IDs: sA, sB, sC, sD, outA, outB, outC, outD, dVal, beamSvg, xSvg, balSteps, rServ, outServ, recipeOut. Drag a, b, c, d: the scale balances only when a*d = b*c. The lab also reveals the d that would balance. */ (function () { function el(id) { return document.getElementById(id); }
function rot(x, y, cx, cy, deg) { var rad = deg * Math.PI / 180; var dx = x - cx, dy = y - cy; var c = Math.cos(rad), s = Math.sin(rad); return { x: cx + dx * c - dy * s, y: cy + dx * s + dy * c }; }
function beamSVG(left, right) {
var tilt = beamTilt(left, right);
var cx = 200, cy = 150;
var L = rot(80, cy, cx, cy, -tilt), R = rot(320, cy, cx, cy, -tilt);
function pan(P, val, color) {
var topX = P.x, topY = P.y + 44;
return '
function xSVG(a, b, c, dTxt, left, right) {
function T(x, y, txt, anchor) {
return '
function renderBalance() { var a = parseInt(el('sA').value, 10); var b = parseInt(el('sB').value, 10); var c = parseInt(el('sC').value, 10); var du = parseInt(el('sD').value, 10); el('outA').textContent = a; el('outB').textContent = b; el('outC').textContent = c; el('outD').textContent = du; var bal = balanceFor(a, b, c); var cp = crossProducts(a, b, c, du); el('dVal').innerHTML = 'Your proportion: ' + a + '/' + b + ' = ' + c + '/' + du + ' — ' + (cp.balanced ? 'balanced! The d that balances is ' + fracText(bal.d) + '.' : 'not balanced. The d that would balance is ' + fracText(bal.d) + ' — drag d there.'); el('beamSvg').innerHTML = beamSVG(cp.left, cp.right); el('xSvg').innerHTML = xSVG(a, b, c, du, cp.left, cp.right); el('balSteps').innerHTML = propStepsHTML(a, b, c, bal.d); }
function renderRecipe() { var s = parseInt(el('rServ').value, 10); el('outServ').textContent = s; var r = recipeScale(s); el('recipeOut').innerHTML = '
' + '
' + r.eggs + ' eggs and ' + r.pancakes + ' pancakes for ' + s + (s === 1 ? ' serving' : ' servings') + '.
' + '
Check: 2 × ' + r.pancakes + ' = ' + (2 * r.pancakes) + ' and 3 × ' + r.eggs + ' = ' + (3 * r.eggs) + ' — cross-products match, still a true proportion.
'; }
['sA', 'sB', 'sC', 'sD'].forEach(function (id) { el(id).addEventListener('input', renderBalance); }); el('rServ').addEventListener('input', renderRecipe); renderBalance(); renderRecipe(); })();
/* ===== /CORE:glue_lab ===== */