first commit
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user