Skill: ratios
Ratio Table Lab
Drag the sliders and watch a ratio scale. The table, the bar model, and the cross-product checker all move together — equivalent ratios are the same comparison at different sizes.
1 Scale a ratio
Pick a base ratio a : b and a scale. Every row of the table is an equivalent ratio.
Controls
Base ratio: 3 : 2
Bar model
Ratio table
2 Are these ratios equivalent?
Enter any ratio. The lab cross-multiplies it against your base ratio a : b and shows the verdict.
The test: a:b and c:d are equivalent exactly when the cross-products match, a × d = b × c.
/* ===== 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'; /* ===================================================================== RATIOS lab core — pure JS, no DOM. Loaded after fibo_core.js. Ratio Table Lab: scaling sliders, ratio table, bar-model data, equivalent-ratio checker with cross-products. ===================================================================== */
/* ratioTable(a,b,n): rows k=1..n -> {k, A:a*k, B:b*k} */ function ratioTable(a, b, n) { var rows = []; for (var k = 1; k <= n; k++) rows.push({ k: k, A: a * k, B: b * k }); return rows; } /* equivCheck(a,b,c,d): are a:b and c:d equivalent? via cross-products. */ function equivCheck(a, b, c, d) { var left = a * d, right = b * c; return { eq: left === right, left: left, right: right }; } /* barModelData(a,b,k): segment counts for the SVG bar model at scale k. */ function barModelData(a, b, k) { return { a: a, b: b, k: k, segA: a * k, segB: b * k, label: (a * k) + ' : ' + (b * k) }; } /* scaleStepsHTML(a,b,k): worked steps for scaling a:b by k. */ function scaleStepsHTML(a, b, k) { return '
- ' +
'
- Start with the ratio ' + a + ' : ' + b + '.
- Multiply both sides by ' + k + ': ' + a + ' × ' + k + ' = ' + (a * k) + ', ' + b + ' × ' + k + ' = ' + (b * k) + '.
- The scaled ratio is ' + (a * k) + ' : ' + (b * k) + ' — same comparison, bigger numbers.
' + '
' + '
'; }
/* parsePositiveInt: strict positive integer for lab inputs. */ function parsePositiveInt(raw) { var s = String(raw).trim(); if (!/^\d+$/.test(s)) return null; var v = parseInt(s, 10); if (v <= 0 || v > 9999) return null; return v; }
/* ===== /CORE:lab ===== */
/* ===== CORE:glue_lab ===== */ 'use strict'; /* Ratios Lab glue: Ratio Table Lab. IDs: sliderA, sliderB, sliderN, outA, outB, outN, ratioTableWrap, barSvg, scaleSteps, chkC, chkD, chkBtn, chkVerdict. */ (function () { function el(id) { return document.getElementById(id); }
function barSVG(a, b, k) {
var d = barModelData(a, b, k);
var W = 560, H = 150, maxSeg = Math.max(d.segA, d.segB, 1);
var unit = Math.min(44, (W - 130) / maxSeg);
function bar(y, segs, color, label) {
var s = '';
for (var i = 0; i < segs; i++) {
s += '
function renderTable(a, b, n) { var rows = ratioTable(a, b, n); var h = '
| Scale k | First term (a × k) | Second term (b × k) | Ratio |
|---|---|---|---|
| ' + r.k + ' | ' + r.A + ' | ' + r.B + ' | ' + r.A + ' : ' + r.B + ' |
'; el('ratioTableWrap').innerHTML = h; }
function renderAll() { var a = parseInt(el('sliderA').value, 10); var b = parseInt(el('sliderB').value, 10); var n = parseInt(el('sliderN').value, 10); el('outA').textContent = a; el('outB').textContent = b; el('outN').textContent = n; el('baseRatio').textContent = a + ' : ' + b; renderTable(a, b, n); el('barSvg').innerHTML = barSVG(a, b, n); el('scaleSteps').innerHTML = scaleStepsHTML(a, b, n); }
el('chkBtn').addEventListener('click', function () { var v = el('chkVerdict'); var a = parseInt(el('sliderA').value, 10); var b = parseInt(el('sliderB').value, 10); var c = parsePositiveInt(el('chkC').value); var d = parsePositiveInt(el('chkD').value); v.className = 'verdict'; if (c === null || d === null) { v.classList.add('show', 'bad'); v.textContent = 'Enter two positive whole numbers for the ratio to test.'; return; } var r = equivCheck(a, b, c, d); v.classList.add('show'); if (r.eq) { v.classList.add('good'); v.innerHTML = 'Equivalent! Cross-products match: ' + a + ' × ' + d + ' = ' + r.left + ' and ' + b + ' × ' + c + ' = ' + r.right + '.'; } else { v.classList.add('bad'); v.innerHTML = 'Not equivalent. Cross-products differ: ' + a + ' × ' + d + ' = ' + r.left + ' but ' + b + ' × ' + c + ' = ' + r.right + '.'; } });
['sliderA', 'sliderB', 'sliderN'].forEach(function (id) { el(id).addEventListener('input', renderAll); }); renderAll(); })();
/* ===== /CORE:glue_lab ===== */