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

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;
}