first commit
This commit is contained in:
393
js/app.js
Normal file
393
js/app.js
Normal 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
154
js/fretboard.js
Normal 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
31
js/music.js
Normal 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
44
js/quiz.js
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user