first commit

This commit is contained in:
Tobias Nauen
2026-04-29 15:57:37 +02:00
commit e79260231f
15 changed files with 1216 additions and 0 deletions

393
js/app.js Normal file
View File

@@ -0,0 +1,393 @@
import { noteDisplay, CHROMATIC, ENHARMONIC } from './music.js';
import { buildFretboard } from './fretboard.js';
import { genMode1Question, genMode2Question, evaluateMode2 } from './quiz.js';
// ─── Persistent stats ─────────────────────────────────────────────────────────
const STORAGE_KEY = 'guitarTrainerStats';
function defaultLifetimeStats() {
return { total: 0, correct: 0, bestStreak: 0, mode1: { total: 0, correct: 0 }, mode2: { total: 0, correct: 0 }, notes: {} };
}
function loadLifetimeStats() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return { ...defaultLifetimeStats(), ...JSON.parse(raw) };
} catch {}
return defaultLifetimeStats();
}
function saveLifetimeStats() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(lifetimeStats));
}
let lifetimeStats = loadLifetimeStats();
// ─── Note keyboard input ──────────────────────────────────────────────────────
const NOTE_LETTERS = new Set(['a','b','c','d','e','f','g']);
const FLAT_TO_SHARP = Object.fromEntries(
Object.entries(ENHARMONIC).map(([sharp, flat]) => [flat, sharp])
);
let noteBuffer = '';
let noteTimer = null;
function resolveNoteInput(raw) {
const letter = raw[0].toUpperCase();
const mod = raw[1] || '';
if (mod === '#') { const n = letter + '#'; return CHROMATIC.includes(n) ? n : null; }
if (mod === 'b') return FLAT_TO_SHARP[letter + 'b'] || null;
return CHROMATIC.includes(letter) ? letter : null;
}
function submitNoteBuffer() {
clearTimeout(noteTimer);
noteTimer = null;
const raw = noteBuffer;
noteBuffer = '';
if (!raw || mode !== 1 || answered) return;
const note = resolveNoteInput(raw);
if (note) checkMode1(note);
}
// ─── State ────────────────────────────────────────────────────────────────────
let mode = 1; // 1, 2, or 'stats'
let currentQ = null;
let answered = false;
let mode2Selection = new Set();
let stats = { correct: 0, total: 0, streak: 0 };
// ─── Settings helpers ─────────────────────────────────────────────────────────
const getNumStrings = () => parseInt(document.getElementById('stringCount').value);
const getSharpsOnly = () => document.getElementById('sharpsOnly').checked;
const getNaturalOnly = () => document.getElementById('naturalOnly').checked;
// ─── Rendering ────────────────────────────────────────────────────────────────
function renderQuestion() {
answered = false;
mode2Selection.clear();
clearTimeout(noteTimer); noteBuffer = '';
const fb = document.getElementById('feedback');
fb.textContent = '';
fb.className = 'feedback';
document.getElementById('nextBtn').disabled = true;
if (mode === 1) renderMode1();
else renderMode2();
}
function renderMode1() {
currentQ = genMode1Question(getNumStrings(), getNaturalOnly());
const numStrings = getNumStrings();
const stringNames = ['1st', '2nd', '3rd', '4th', '5th', '6th', '7th'];
const stringNumber = numStrings - currentQ.stringIdx;
document.getElementById('promptLabel').textContent = 'What note is this?';
document.getElementById('promptText').innerHTML =
`<span style="color:var(--accent2)">${stringNames[stringNumber - 1]} string</span>, ` +
`fret <span style="color:var(--accent2)">${currentQ.fret}</span>`;
document.getElementById('fretboardContainer').innerHTML = buildFretboard({
numStrings,
highlightPos: { stringIdx: currentQ.stringIdx, fret: currentQ.fret },
});
const btns = document.getElementById('noteButtons');
btns.innerHTML = '';
currentQ.choices.forEach((note, i) => {
const btn = document.createElement('button');
btn.className = 'note-btn';
btn.textContent = noteDisplay(note, getSharpsOnly());
btn.dataset.note = note;
if (i < 9) btn.title = `Press ${i + 1}`;
btns.appendChild(btn);
});
document.getElementById('noteButtons').style.display = 'flex';
document.getElementById('mode2Controls').style.display = 'none';
}
function renderMode2() {
currentQ = genMode2Question(getNumStrings(), getNaturalOnly());
document.getElementById('promptLabel').textContent = 'Find all occurrences of:';
document.getElementById('promptText').innerHTML =
`<span class="prompt-note">${noteDisplay(currentQ.targetNote, getSharpsOnly())}</span>` +
`<span style="font-size:1rem; color:var(--text-dim); margin-left:12px;">in frets ${currentQ.fretStart}${currentQ.fretEnd}</span>`;
refreshMode2Fretboard();
document.getElementById('noteButtons').style.display = 'none';
document.getElementById('mode2Controls').style.display = 'flex';
}
function refreshMode2Fretboard(showCorrect = false) {
document.getElementById('fretboardContainer').innerHTML = buildFretboard({
numStrings: getNumStrings(),
interactive: true,
fretRange: [currentQ.fretStart, currentQ.fretEnd],
mode2Sel: mode2Selection,
mode2Correct: showCorrect ? currentQ.correctSet : null,
showLabels: showCorrect,
sharpsOnly: getSharpsOnly(),
});
}
// ─── Answer checking ──────────────────────────────────────────────────────────
function recordAnswer(isCorrect, note) {
lifetimeStats.total++;
if (isCorrect) lifetimeStats.correct++;
const modeKey = mode === 1 ? 'mode1' : 'mode2';
lifetimeStats[modeKey].total++;
if (isCorrect) lifetimeStats[modeKey].correct++;
if (!lifetimeStats.notes[note]) lifetimeStats.notes[note] = { total: 0, correct: 0 };
lifetimeStats.notes[note].total++;
if (isCorrect) lifetimeStats.notes[note].correct++;
if (stats.streak > lifetimeStats.bestStreak) lifetimeStats.bestStreak = stats.streak;
saveLifetimeStats();
}
function checkMode1(note) {
if (answered) return;
answered = true;
stats.total++;
const fb = document.getElementById('feedback');
document.querySelectorAll('.note-btn').forEach(b => {
if (b.dataset.note === currentQ.note) b.classList.add('correct');
});
const clickedBtn = document.querySelector(`.note-btn[data-note="${note}"]`);
const isCorrect = note === currentQ.note;
if (isCorrect) {
fb.textContent = '✓ Correct!';
fb.className = 'feedback correct';
stats.correct++;
stats.streak++;
} else {
if (clickedBtn) clickedBtn.classList.add('wrong');
fb.textContent = `✗ It's ${currentQ.note}`;
fb.className = 'feedback wrong';
stats.streak = 0;
}
recordAnswer(isCorrect, currentQ.note);
updateStats();
document.getElementById('nextBtn').disabled = false;
document.getElementById('fretboardContainer').innerHTML = buildFretboard({
numStrings: getNumStrings(),
highlightPos: { stringIdx: currentQ.stringIdx, fret: currentQ.fret },
showLabels: true,
sharpsOnly: getSharpsOnly(),
});
}
function checkMode2() {
if (answered) return;
answered = true;
stats.total++;
const { allCorrect, missed, extra } = evaluateMode2(mode2Selection, currentQ.correctSet);
refreshMode2Fretboard(true);
const fb = document.getElementById('feedback');
if (allCorrect) {
const n = currentQ.correctSet.size;
fb.textContent = `✓ Perfect! Found all ${n} position${n !== 1 ? 's' : ''}`;
fb.className = 'feedback correct';
stats.correct++;
stats.streak++;
} else {
fb.textContent = `✗ Missed ${missed}, extra ${extra} — green = correct positions`;
fb.className = 'feedback wrong';
stats.streak = 0;
}
recordAnswer(allCorrect, currentQ.targetNote);
updateStats();
document.getElementById('nextBtn').disabled = false;
}
function updateStats() {
document.getElementById('statCorrect').textContent = stats.correct;
document.getElementById('statTotal').textContent = stats.total;
document.getElementById('statStreak').textContent = stats.streak;
}
// ─── Stats screen ─────────────────────────────────────────────────────────────
function accColor(acc) {
if (acc >= 80) return 'var(--green)';
if (acc >= 50) return 'var(--accent2)';
return 'var(--accent)';
}
function modeRow(label, mStat) {
const acc = mStat.total ? Math.round(mStat.correct / mStat.total * 100) : 0;
return `
<div class="mode-stat-row">
<div class="mode-stat-label">${label}</div>
<div class="mode-stat-nums">${mStat.correct}/${mStat.total}</div>
<div class="acc-bar-wrap">
<div class="acc-bar" style="width:${acc}%; background:${accColor(acc)}"></div>
<span>${mStat.total ? acc + '%' : '—'}</span>
</div>
</div>`;
}
function renderStats() {
const screen = document.getElementById('statsScreen');
const ls = lifetimeStats;
const overallAcc = ls.total ? Math.round(ls.correct / ls.total * 100) : 0;
const noteRows = Object.entries(ls.notes)
.map(([note, { correct, total }]) => ({ note, correct, total, acc: Math.round(correct / total * 100) }))
.sort((a, b) => a.acc - b.acc || b.total - a.total);
screen.innerHTML = `
<div class="stats-page">
<div class="stats-overview">
<div class="stat-card">
<div class="stat-value">${overallAcc}%</div>
<div class="stat-label">Accuracy</div>
</div>
<div class="stat-card">
<div class="stat-value">${ls.total}</div>
<div class="stat-label">Questions</div>
</div>
<div class="stat-card">
<div class="stat-value">${ls.bestStreak}</div>
<div class="stat-label">Best Streak</div>
</div>
</div>
<div class="stats-modes">
<div class="stats-section-title">By Mode</div>
${modeRow('Name the Note', ls.mode1)}
${modeRow('Find Occurrences', ls.mode2)}
</div>
${noteRows.length ? `
<div class="stats-notes">
<div class="stats-section-title">Accuracy by Note</div>
<table class="note-table">
<thead><tr><th>Note</th><th>Correct</th><th>Seen</th><th>Accuracy</th></tr></thead>
<tbody>
${noteRows.map(r => `
<tr>
<td class="note-cell">${noteDisplay(r.note, false)}</td>
<td>${r.correct}</td>
<td>${r.total}</td>
<td>
<div class="acc-bar-wrap">
<div class="acc-bar" style="width:${r.acc}%; background:${accColor(r.acc)}"></div>
<span>${r.acc}%</span>
</div>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : '<div class="stats-empty">No questions answered yet — start practising!</div>'}
<button class="reset-stats-btn" id="resetStatsBtn">Reset All Stats</button>
</div>
`;
document.getElementById('resetStatsBtn').addEventListener('click', () => {
if (confirm('Reset all lifetime stats? This cannot be undone.')) {
lifetimeStats = defaultLifetimeStats();
saveLifetimeStats();
renderStats();
}
});
}
// ─── Controls ─────────────────────────────────────────────────────────────────
function setMode(m) {
mode = m;
document.getElementById('tab1').classList.toggle('active', m === 1);
document.getElementById('tab2').classList.toggle('active', m === 2);
document.getElementById('tabStats').classList.toggle('active', m === 'stats');
const isStats = m === 'stats';
document.getElementById('quizArea').style.display = isStats ? 'none' : '';
document.getElementById('statsScreen').style.display = isStats ? 'flex' : 'none';
if (isStats) renderStats();
else renderQuestion();
}
function resetQuiz() {
stats = { correct: 0, total: 0, streak: 0 };
updateStats();
if (mode !== 'stats') renderQuestion();
}
// ─── Event wiring ─────────────────────────────────────────────────────────────
document.getElementById('tab1').addEventListener('click', () => setMode(1));
document.getElementById('tab2').addEventListener('click', () => setMode(2));
document.getElementById('tabStats').addEventListener('click', () => setMode('stats'));
document.getElementById('nextBtn').addEventListener('click', renderQuestion);
document.getElementById('submitBtn').addEventListener('click', checkMode2);
document.getElementById('clearBtn').addEventListener('click', () => {
if (!answered) { mode2Selection.clear(); refreshMode2Fretboard(); }
});
document.getElementById('stringCount').addEventListener('change', resetQuiz);
document.getElementById('sharpsOnly').addEventListener('change', resetQuiz);
document.getElementById('naturalOnly').addEventListener('change', resetQuiz);
document.getElementById('noteButtons').addEventListener('click', e => {
const btn = e.target.closest('.note-btn');
if (btn) checkMode1(btn.dataset.note);
});
document.getElementById('fretboardContainer').addEventListener('click', e => {
if (mode !== 2 || answered) return;
const cell = e.target.closest('[data-s]');
if (!cell) return;
const s = parseInt(cell.dataset.s);
const f = parseInt(cell.dataset.f);
const key = `${s},${f}`;
if (mode2Selection.has(key)) mode2Selection.delete(key);
else mode2Selection.add(key);
refreshMode2Fretboard();
});
document.addEventListener('keydown', e => {
if (e.target.tagName === 'SELECT') return;
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (mode === 'stats') return;
if (!document.getElementById('nextBtn').disabled) renderQuestion();
else if (mode === 2 && !answered) checkMode2();
}
if (mode === 1 && !answered && e.key >= '1' && e.key <= '9') {
const btns = document.querySelectorAll('.note-btn');
btns[parseInt(e.key) - 1]?.click();
}
if (mode === 1 && !answered) {
const k = e.key.toLowerCase();
if (noteBuffer && k === 'b') {
noteBuffer += 'b'; submitNoteBuffer();
} else if (noteBuffer && k === '#') {
noteBuffer += '#'; submitNoteBuffer();
} else if (NOTE_LETTERS.has(k)) {
clearTimeout(noteTimer);
noteBuffer = k;
noteTimer = setTimeout(submitNoteBuffer, 600);
}
}
if (e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
if (mode === 1) setMode(2);
else if (mode === 2) setMode('stats');
else setMode(1);
}
});
// ─── Init ─────────────────────────────────────────────────────────────────────
renderQuestion();

154
js/fretboard.js Normal file
View File

@@ -0,0 +1,154 @@
import { OPEN_NOTES, getNoteAt, noteDisplay } from './music.js';
export const FRET_COUNT = 12;
const STRING_SPACING = 28;
// Width of each playable fret slot (index = fret number, index 0 unused)
const FRET_WIDTHS = [0, 52, 50, 47, 45, 43, 41, 39, 37, 35, 34, 32, 31];
const MARKER_FRETS = [3, 5, 7, 9, 12];
// Returns cumulative x positions of fret wires 0..FRET_COUNT.
// fretXs[f] is the right edge of the f-th fret slot (= left edge of slot f+1).
function computeFretXs() {
const xs = [0];
for (let i = 1; i <= FRET_COUNT; i++) xs.push(xs[i - 1] + FRET_WIDTHS[i]);
return xs;
}
const FRET_XS = computeFretXs();
/**
* Builds an SVG fretboard string.
*
* opts:
* numStrings {number} 6 or 7
* highlightPos {stringIdx, fret} | null — mode 1 position dot
* mode2Sel {Set<"s,f">} — user's current selections
* mode2Correct {Set<"s,f">} | null — revealed after submission
* showLabels {boolean} — show note names on dots
* interactive {boolean} — add data-* click targets
* fretRange [start, end] | null — highlighted fret window
* sharpsOnly {boolean}
*/
export function buildFretboard({
numStrings = 6,
highlightPos = null,
mode2Sel = new Set(),
mode2Correct = null,
showLabels = false,
interactive = false,
fretRange = null,
sharpsOnly = false,
} = {}) {
const ns = numStrings;
const PAD_LEFT = 30;
const PAD_RIGHT = 16;
const PAD_TOP = 20;
const PAD_BOTTOM = 20;
const totalW = FRET_XS[FRET_COUNT];
const svgW = totalW + PAD_LEFT + PAD_RIGHT;
const svgH = (ns - 1) * STRING_SPACING + PAD_TOP + PAD_BOTTOM;
const parts = [];
const p = s => parts.push(s);
p(`<svg class="fretboard-svg" width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}" xmlns="http://www.w3.org/2000/svg">`);
// Fret-range highlight band
if (fretRange) {
const [fr1, fr2] = fretRange;
const x1 = PAD_LEFT + (fr1 > 0 ? FRET_XS[fr1 - 1] : 0);
const x2 = PAD_LEFT + FRET_XS[fr2];
p(`<rect x="${x1}" y="${PAD_TOP - 8}" width="${x2 - x1}" height="${(ns - 1) * STRING_SPACING + 16}" fill="rgba(245,166,35,0.08)" rx="6"/>`);
}
// Nut
p(`<rect x="${PAD_LEFT}" y="${PAD_TOP - 4}" width="5" height="${(ns - 1) * STRING_SPACING + 8}" fill="var(--nut)" rx="2"/>`);
// Fret wires
for (let f = 1; f <= FRET_COUNT; f++) {
const x = PAD_LEFT + FRET_XS[f];
p(`<line x1="${x}" y1="${PAD_TOP}" x2="${x}" y2="${PAD_TOP + (ns - 1) * STRING_SPACING}" stroke="var(--fret)" stroke-width="${f === 12 ? 3 : 1.5}"/>`);
}
// Fret numbers
for (let f = 1; f <= FRET_COUNT; f++) {
const x = PAD_LEFT + (FRET_XS[f - 1] + FRET_XS[f]) / 2;
const inRange = !fretRange || (f >= fretRange[0] && f <= fretRange[1]);
p(`<text x="${x}" y="${PAD_TOP - 6}" text-anchor="middle" font-size="9" fill="${inRange ? '#aaa' : '#444'}">${f}</text>`);
}
// Position marker dots
for (const mf of MARKER_FRETS) {
const x = PAD_LEFT + (FRET_XS[mf - 1] + FRET_XS[mf]) / 2;
const midY = PAD_TOP + (ns - 1) * STRING_SPACING / 2;
if (mf === 12) {
p(`<circle cx="${x}" cy="${midY - 8}" r="4" fill="var(--marker)"/>`);
p(`<circle cx="${x}" cy="${midY + 8}" r="4" fill="var(--marker)"/>`);
} else {
p(`<circle cx="${x}" cy="${midY}" r="4" fill="var(--marker)"/>`);
}
}
// Strings
const opens = OPEN_NOTES[ns];
for (let s = 0; s < ns; s++) {
const y = PAD_TOP + (ns - 1 - s) * STRING_SPACING;
const thickness = 1 + (ns - 1 - s) * 0.22;
p(`<line x1="${PAD_LEFT}" y1="${y}" x2="${PAD_LEFT + totalW}" y2="${y}" stroke="var(--string)" stroke-width="${thickness}"/>`);
p(`<text x="${PAD_LEFT - 8}" y="${y + 4}" text-anchor="end" font-size="11" fill="var(--text-dim)">${opens[s]}</text>`);
}
// Dots and click targets
for (let s = 0; s < ns; s++) {
const y = PAD_TOP + (ns - 1 - s) * STRING_SPACING;
for (let f = 0; f <= FRET_COUNT; f++) {
const x = f === 0 ? PAD_LEFT - 22 : PAD_LEFT + (FRET_XS[f - 1] + FRET_XS[f]) / 2;
const key = `${s},${f}`;
const inRange = !fretRange || (f >= fretRange[0] && f <= fretRange[1]);
const dotColor = resolveDotColor({ key, s, f, highlightPos, mode2Sel, mode2Correct, interactive, inRange });
if (dotColor) {
p(`<circle cx="${x}" cy="${y}" r="10" fill="${dotColor.fill}" stroke="${dotColor.stroke}" stroke-width="2"/>`);
if (showLabels) {
const label = noteDisplay(getNoteAt(s, f, ns), sharpsOnly);
p(`<text x="${x}" y="${y + 4}" text-anchor="middle" font-size="9" font-weight="700" fill="#fff">${label}</text>`);
}
}
if (interactive && inRange) {
const cellX = f === 0 ? PAD_LEFT - 36 : PAD_LEFT + FRET_XS[f - 1];
const cellW = f === 0 ? 30 : FRET_XS[f] - FRET_XS[f - 1];
p(`<rect x="${cellX}" y="${y - STRING_SPACING / 2}" width="${cellW}" height="${STRING_SPACING}" fill="transparent" style="cursor:pointer" data-s="${s}" data-f="${f}"/>`);
}
}
}
p('</svg>');
return parts.join('');
}
function resolveDotColor({ key, s, f, highlightPos, mode2Sel, mode2Correct, interactive, inRange }) {
// Mode 1 highlight
if (highlightPos && highlightPos.stringIdx === s && highlightPos.fret === f) {
return { fill: '#e94560', stroke: 'none' };
}
if (!interactive || !inRange) return null;
if (mode2Correct) {
// Post-submission state
const isCorrect = mode2Correct.has(key);
const isSelected = mode2Sel.has(key);
if (isCorrect && isSelected) return { fill: '#4caf50', stroke: '#4caf50' };
if (isCorrect && !isSelected) return { fill: 'rgba(76,175,80,0.5)', stroke: '#4caf50' };
if (!isCorrect && isSelected) return { fill: 'rgba(233,69,96,0.7)', stroke: '#e94560' };
return null;
}
// Pre-submission: show selected positions
if (mode2Sel.has(key)) return { fill: 'var(--accent2)', stroke: 'none' };
return null;
}

31
js/music.js Normal file
View File

@@ -0,0 +1,31 @@
// Canonical pitch representation: sharps only.
// Flats are derived via ENHARMONIC for display purposes only.
export const CHROMATIC = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
export const ENHARMONIC = { 'C#': 'Db', 'D#': 'Eb', 'F#': 'Gb', 'G#': 'Ab', 'A#': 'Bb' };
export const NATURAL = ['C', 'D', 'E', 'F', 'G', 'A', 'B'];
// Standard tuning, index 0 = lowest string.
// 6-string: strings 6→1 (low E → high e)
// 7-string: strings 7→1 (low B → high e)
export const OPEN_NOTES = {
6: ['E', 'A', 'D', 'G', 'B', 'E'],
7: ['B', 'E', 'A', 'D', 'G', 'B', 'E'],
};
export function getNoteAt(stringIdx, fret, numStrings) {
const openIdx = CHROMATIC.indexOf(OPEN_NOTES[numStrings][stringIdx]);
return CHROMATIC[(openIdx + fret) % 12];
}
// Returns "D#/Eb" for accidentals unless sharpsOnly is set.
export function noteDisplay(note, sharpsOnly) {
if (sharpsOnly || !ENHARMONIC[note]) return note;
return `${note}/${ENHARMONIC[note]}`;
}
// Returns 9 answer choices including the correct note (all in sharp form).
export function getAnswerChoices(correct, naturalOnly) {
const pool = naturalOnly ? NATURAL : CHROMATIC;
const others = pool.filter(n => n !== correct).sort(() => Math.random() - 0.5).slice(0, 8);
return [...others, correct].sort(() => Math.random() - 0.5);
}

44
js/quiz.js Normal file
View File

@@ -0,0 +1,44 @@
import { CHROMATIC, NATURAL, getNoteAt, getAnswerChoices } from './music.js';
import { FRET_COUNT } from './fretboard.js';
export function genMode1Question(numStrings, naturalOnly) {
let stringIdx, fret, note;
let attempts = 0;
do {
stringIdx = Math.floor(Math.random() * numStrings);
fret = Math.floor(Math.random() * (FRET_COUNT + 1));
note = getNoteAt(stringIdx, fret, numStrings);
attempts++;
} while (naturalOnly && !NATURAL.includes(note) && attempts < 100);
const choices = getAnswerChoices(note, naturalOnly);
return { stringIdx, fret, note, choices };
}
export function genMode2Question(numStrings, naturalOnly) {
const pool = naturalOnly ? NATURAL : CHROMATIC;
const targetNote = pool[Math.floor(Math.random() * pool.length)];
const rangeLen = Math.random() < 0.5 ? 4 : 5;
const fretStart = Math.floor(Math.random() * (FRET_COUNT - rangeLen + 1));
const fretEnd = fretStart + rangeLen;
const correctSet = new Set();
for (let s = 0; s < numStrings; s++) {
for (let f = fretStart; f <= fretEnd; f++) {
if (getNoteAt(s, f, numStrings) === targetNote) {
correctSet.add(`${s},${f}`);
}
}
}
return { targetNote, fretStart, fretEnd, correctSet };
}
export function evaluateMode2(selection, correctSet) {
const allCorrect = [...correctSet].every(k => selection.has(k)) &&
[...selection].every(k => correctSet.has(k));
const missed = [...correctSet].filter(k => !selection.has(k)).length;
const extra = [...selection].filter(k => !correctSet.has(k)).length;
return { allCorrect, missed, extra };
}