155 lines
5.8 KiB
JavaScript
155 lines
5.8 KiB
JavaScript
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;
|
|
}
|