Move chord inversions to fretboard with visual overlays
- Remove chord inversion display from chord diagram section - Add UI controls on fretboard page (toggle + root note selector) - Implement calculateMajorInversions() for computing inversion positions - Add renderInversionsOnFretboard() with: - Colored polygon overlays (blue=root, red=1st, yellow=2nd) - Position dots on chord frets - Annotation blocks with chord name, inversion type, bass note, intervals - 5-fret rule connector arrows between consecutive inversions - Cycling indicator at bottom of fretboard - Add arrowhead marker definition in SVG defs https://claude.ai/code/session_01VSHtwaMJKL1ZgH33JDcwbY
+236 −56 · 1 file changed
-
index.html+236 −56
@@ -1167,6 +1167,30 @@ </div> </div> + <div class="control-group"> + <label>Chord Inversions</label> + <div class="checkbox-container"> + <input type="checkbox" id="showInversionsToggle"> + <span>Show inversions on fretboard</span> + </div> + <div style="margin-top: 8px;"> + <select id="inversionRootNote" style="width: 100%;"> + <option value="C">C Major</option> + <option value="C#">C#/Db Major</option> + <option value="D">D Major</option> + <option value="D#">D#/Eb Major</option> + <option value="E">E Major</option> + <option value="F">F Major</option> + <option value="F#">F#/Gb Major</option> + <option value="G" selected>G Major</option> + <option value="G#">G#/Ab Major</option> + <option value="A">A Major</option> + <option value="A#">A#/Bb Major</option> + <option value="B">B Major</option> + </select> + </div> + </div> + <div class="control-group"> <label>Actions</label> <div style="display: flex; gap: 10px;"> @@ -1752,40 +1776,14 @@ return null; } - // Determine chord inversion based on bass note (lowest non-muted string) - function getChordInversion(positions) { - // Find the lowest (first non-muted) string's degree - // Positions are ordered: string 4 (bass), 3, 2, 1 - for (const pos of positions) { - if (!pos.muted && pos.degree) { - const degree = pos.degree; - // Root position: bass is root (1) - if (degree === '1') { - return { type: 'root', label: 'Root', color: null, pattern: 'solid' }; - } - // First inversion: bass is third (3 or ♭3) - if (degree === '3' || degree === '♭3') { - return { type: 'first', label: '1st Inv.', color: '#cc0000', pattern: 'diagonal' }; - } - // Second inversion: bass is fifth (5, ♭5, or #5) - if (degree === '5' || degree === '♭5' || degree === '#5') { - return { type: 'second', label: '2nd Inv.', color: '#cc9900', pattern: 'crosshatch' }; - } - // Other degrees (7th, etc.) - treat as extended inversion - return { type: 'extended', label: degree + ' bass', color: '#666', pattern: 'dots' }; - } - } - return { type: 'unknown', label: '', color: null, pattern: null }; - } - // Generate SVG chord diagram for 4 main strings function generateChordDiagramSVG(voicing, chordName, tuningName, fingerNotation = 'numbers') { const width = 140; - const height = 220; // Increased height for inversion label + const height = 200; const marginLeft = 35; const marginTop = 35; const marginRight = 15; - const marginBottom = 45; // More space for inversion label + const marginBottom = 25; const positions = voicing.positions; const numStrings = 4; // Always 4 main strings for chord diagrams @@ -1795,30 +1793,11 @@ const stringSpacing = (width - marginLeft - marginRight) / (numStrings - 1); const fretSpacing = (height - marginTop - marginBottom) / numFrets; - // Determine chord inversion - const inversion = getChordInversion(positions); - let svg = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">`; - // Pattern definitions for B&W printing - svg += `<defs> - <pattern id="diagonal-${chordName.replace(/[^a-zA-Z0-9]/g, '')}" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"> - <line x1="0" y1="0" x2="0" y2="6" stroke="#cc0000" stroke-width="2"/> - </pattern> - <pattern id="crosshatch-${chordName.replace(/[^a-zA-Z0-9]/g, '')}" width="6" height="6" patternUnits="userSpaceOnUse"> - <line x1="0" y1="0" x2="6" y2="6" stroke="#cc9900" stroke-width="1"/> - <line x1="6" y1="0" x2="0" y2="6" stroke="#cc9900" stroke-width="1"/> - </pattern> - </defs>`; - - // Background with inversion color indicator + // Background svg += `<rect width="${width}" height="${height}" fill="white"/>`; - // Inversion color border/indicator at top - if (inversion.color) { - svg += `<rect x="0" y="0" width="${width}" height="4" fill="${inversion.color}"/>`; - } - // Nut (thick line at top) - only show if baseFret is 1 if (baseFret === 1) { svg += `<rect x="${marginLeft - 3}" y="${marginTop - 3}" width="${(numStrings - 1) * stringSpacing + 6}" height="5" fill="#1a1a1a"/>`; @@ -1913,13 +1892,6 @@ } }); - // Inversion label at bottom - if (inversion.label) { - const invLabelY = marginTop + numFrets * fretSpacing + 32; - const labelColor = inversion.color || '#333'; - svg += `<text x="${width / 2}" y="${invLabelY}" font-size="10" font-weight="bold" text-anchor="middle" fill="${labelColor}">${inversion.label}</text>`; - } - svg += '</svg>'; return svg; } @@ -2241,6 +2213,9 @@ selectedFretboardTuning: 'Open G', fretboardFamilyFilter: 'all', useSolfege: false, + // Chord inversion state + showInversions: false, + inversionRootNote: 'G', // Chord tab state chordTuning: 'Open G', chordKey: 'G', @@ -2388,6 +2363,184 @@ container.innerHTML = html; } + // ============================================ + // CHORD INVERSION CALCULATION + // ============================================ + + // Calculate all major chord inversions for a root note on strings 4-3-2-1 + // Returns array of inversion objects with fret positions + function calculateMajorInversions(rootNote, tuningStrings, maxFret = 15) { + // Major chord intervals: Root=0, 3rd=4, 5th=7 + const ROOT = 0, THIRD = 4, FIFTH = 7; + + // Get pitch class of root note + const rootPitch = noteToPitchClass(rootNote); + const thirdPitch = (rootPitch + THIRD) % 12; + const fifthPitch = (rootPitch + FIFTH) % 12; + + // For Open G tuning, strings 4-3-2-1 are indices 1,2,3,4 (D,G,B,D) + // We use the 4 main strings for chord shapes + const mainStrings = tuningStrings.slice(1, 5); // D, G, B, D + const stringOpenPitches = mainStrings.map(s => noteToPitchClass(normalizeNote(s))); + + // Find fret for a target pitch on a given string + function findFret(stringPitch, targetPitch, startFret = 0) { + const diff = (targetPitch - stringPitch + 12) % 12; + let fret = diff; + if (fret < startFret) fret += 12; + return fret; + } + + // Three inversion patterns (intervals on strings 4-3-2-1): + // Root Form: 1-3-5-1 (root on string 4) + // 1st Inversion: 3-5-1-3 (3rd on string 4) + // 2nd Inversion: 5-1-3-5 (5th on string 4) + const inversionPatterns = [ + { name: 'Root Form', type: 'root', intervals: [ROOT, THIRD, FIFTH, ROOT], bassNote: 'Root', color: '#3366cc', label: '1 - 3 - 5 - 1' }, + { name: '1st Inversion', type: 'first', intervals: [THIRD, FIFTH, ROOT, THIRD], bassNote: '3rd', color: '#cc3333', label: '3 - 5 - 1 - 3' }, + { name: '2nd Inversion', type: 'second', intervals: [FIFTH, ROOT, THIRD, FIFTH], bassNote: '5th', color: '#cc9900', label: '5 - 1 - 3 - 5' } + ]; + + const inversions = []; + + // Find all occurrences of each inversion pattern + for (const pattern of inversionPatterns) { + // Calculate target pitches for each string + const targetPitches = pattern.intervals.map(interval => (rootPitch + interval) % 12); + + // Find base fret positions (lowest occurrence) + const baseFrets = []; + for (let i = 0; i < 4; i++) { + baseFrets.push(findFret(stringOpenPitches[i], targetPitches[i], 0)); + } + + // Find the minimum fret to normalize the shape + const minFret = Math.min(...baseFrets.filter(f => f > 0)); + const maxFretInShape = Math.max(...baseFrets); + + // Find all occurrences within the fret range + for (let offset = 0; offset <= maxFret; offset += 12) { + const frets = baseFrets.map(f => f === 0 ? 0 : f + offset - (minFret > 0 ? minFret - baseFrets.find(bf => bf === minFret) : 0)); + const adjustedFrets = baseFrets.map(f => { + let adjusted = f + offset; + // Handle open strings (fret 0) + if (f === 0 && offset === 0) return 0; + if (f === 0 && offset > 0) adjusted = 12 + offset; + while (adjusted > maxFret + 5) adjusted -= 12; + return adjusted; + }); + + // Check if this shape is within the displayable range + const shapeFrets = adjustedFrets.filter(f => f >= 0); + if (shapeFrets.length > 0 && Math.min(...shapeFrets) <= maxFret) { + inversions.push({ + ...pattern, + frets: adjustedFrets, + baseFret: Math.min(...adjustedFrets.filter(f => f > 0)) || 0 + }); + } + } + } + + // Sort by base fret position + inversions.sort((a, b) => a.baseFret - b.baseFret); + + return inversions; + } + + // Render chord inversions on fretboard SVG + function renderInversionsOnFretboard(svg, rootNote, tuning, fbLeft, fbTop, stringSpacing, fretHeight, numFrets, svgWidth) { + const inversions = calculateMajorInversions(rootNote, tuning.strings, numFrets); + const displayedNote = displayNote(rootNote); + + // Filter inversions that are fully visible + const visibleInversions = inversions.filter(inv => { + const maxFretInShape = Math.max(...inv.frets); + return maxFretInShape <= numFrets && inv.baseFret >= 1; + }); + + // Render each inversion + visibleInversions.forEach((inv, idx) => { + // Calculate positions (strings 4-3-2-1 are at indices 1,2,3,4 in the display) + const positions = []; + for (let i = 0; i < 4; i++) { + const stringIndex = i + 1; // Skip 5th string (index 0) + const x = fbLeft + stringIndex * stringSpacing; + const fret = inv.frets[i]; + const y = fbTop + (fret - 0.5) * fretHeight; + positions.push({ x, y, fret, stringIndex }); + } + + // Draw colored overlay connecting the notes (triangle or polygon) + const validPositions = positions.filter(p => p.fret > 0 && p.fret <= numFrets); + if (validPositions.length >= 3) { + // Create polygon points + const points = validPositions.map(p => `${p.x},${p.y}`).join(' '); + svg += `<polygon points="${points}" fill="${inv.color}" fill-opacity="0.25" stroke="${inv.color}" stroke-width="2"/>`; + } + + // Draw dots on chord positions + positions.forEach(pos => { + if (pos.fret > 0 && pos.fret <= numFrets) { + svg += `<circle cx="${pos.x}" cy="${pos.y}" r="12" fill="${inv.color}" fill-opacity="0.8" stroke="white" stroke-width="2"/>`; + } + }); + + // Annotation box on the right side + const annotationX = fbLeft + 5 * stringSpacing + 30; + const annotationY = fbTop + (inv.baseFret - 0.5) * fretHeight; + + if (inv.baseFret <= numFrets - 2) { + // Background for annotation + svg += `<rect x="${annotationX}" y="${annotationY - 30}" width="140" height="70" fill="white" stroke="${inv.color}" stroke-width="2" rx="5"/>`; + + // Chord name and inversion type + svg += `<text x="${annotationX + 70}" y="${annotationY - 12}" text-anchor="middle" font-size="12" font-weight="bold" fill="${inv.color}">"${displayedNote}" CHORD</text>`; + svg += `<text x="${annotationX + 70}" y="${annotationY + 5}" text-anchor="middle" font-size="11" font-weight="bold" fill="#333">${inv.name.toUpperCase()}</text>`; + + // Bass note info + svg += `<text x="${annotationX + 70}" y="${annotationY + 20}" text-anchor="middle" font-size="10" fill="#666">${inv.bassNote} is bass note</text>`; + + // Interval structure + svg += `<text x="${annotationX + 70}" y="${annotationY + 35}" text-anchor="middle" font-size="10" fill="#666">[${inv.label}]</text>`; + } + }); + + // Draw 5-fret rule connectors between consecutive inversions + for (let i = 0; i < visibleInversions.length - 1; i++) { + const current = visibleInversions[i]; + const next = visibleInversions[i + 1]; + + // The rule: 3rd string of current -> 4th string of next (5 frets higher) + // String 3 is at index 2 (0-indexed in our positions), String 4 is at index 1 + const fromStringIdx = 2; // 3rd string (G string) + const toStringIdx = 1; // 4th string (D string) + + const fromX = fbLeft + (fromStringIdx + 1) * stringSpacing; + const fromY = fbTop + (current.frets[fromStringIdx] - 0.5) * fretHeight; + const toX = fbLeft + (toStringIdx + 1) * stringSpacing; + const toY = fbTop + (next.frets[toStringIdx] - 0.5) * fretHeight; + + if (current.frets[fromStringIdx] <= numFrets && next.frets[toStringIdx] <= numFrets) { + // Draw arrow + svg += `<line x1="${fromX}" y1="${fromY}" x2="${toX}" y2="${toY}" stroke="#cc0000" stroke-width="3" marker-end="url(#arrowhead)"/>`; + + // Label showing "5 frets" + const midX = (fromX + toX) / 2 - 25; + const midY = (fromY + toY) / 2; + svg += `<text x="${midX}" y="${midY}" font-size="10" font-weight="bold" fill="#cc0000">5 frets</text>`; + } + } + + // Add cycling indicator at bottom + if (visibleInversions.length > 0) { + const bottomY = fbTop + numFrets * fretHeight + 10; + svg += `<text x="${fbLeft + 2.5 * stringSpacing}" y="${bottomY}" text-anchor="middle" font-size="11" fill="#666">↓ Inversions keep cycling ↓</text>`; + } + + return svg; + } + // ============================================ // FRETBOARD SVG GENERATOR (Enhanced from Python) // ============================================ @@ -2442,8 +2595,13 @@ let svg = `<svg class="fretboard-svg" width="${svgWidth}" height="${svgHeight}" xmlns="http://www.w3.org/2000/svg">`; - // Styles + // Styles and definitions svg += ` + <defs> + <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto"> + <polygon points="0 0, 10 3.5, 0 7" fill="#cc0000"/> + </marker> + </defs> <style> .title { font-family: Georgia, serif; font-size: 24px; font-weight: bold; fill: #000; } .subtitle { font-family: Georgia, serif; font-size: 16px; font-style: italic; fill: #333; } @@ -2695,6 +2853,11 @@ svg += `<text x="${fbLeft + fretboardWidth/2}" y="${capoY + 4}" text-anchor="middle" font-size="10" fill="white" font-weight="bold">CAPO ${capo}</text>`; } + // Chord inversions overlay (if enabled) + if (state.showInversions && numStrings >= 5) { + svg = renderInversionsOnFretboard(svg, state.inversionRootNote, tuning, fbLeft, fbTop, stringSpacing, fretHeight, numFrets, svgWidth); + } + // Footer const footerY = fbTop + fretboardHeight + 30; svg += `<rect x="${marginLeft-50}" y="${footerY}" width="${fretboardWidth+150}" height="30" rx="5" fill="#f5f5f5" stroke="#ccc"/>`; @@ -2824,6 +2987,19 @@ populateTuningList(); }); + // Chord inversion controls + document.getElementById('showInversionsToggle').addEventListener('change', (e) => { + state.showInversions = e.target.checked; + saveState(); + renderFretboard(); + }); + + document.getElementById('inversionRootNote').addEventListener('change', (e) => { + state.inversionRootNote = e.target.value; + saveState(); + renderFretboard(); + }); + // Fretboard family filter document.getElementById('fretboardFamilyFilter').addEventListener('change', (e) => { state.fretboardFamilyFilter = e.target.value; @@ -2851,6 +3027,8 @@ selectedFretboardTuning: 'Open G', fretboardFamilyFilter: 'all', useSolfege: false, + showInversions: false, + inversionRootNote: 'G', chordTuning: 'Open G', chordKey: 'G', chordTypeFilter: 'all', @@ -2872,6 +3050,8 @@ document.getElementById('showNotesToggle').checked = state.showNotes; document.getElementById('solfegeToggle').checked = state.useSolfege || false; document.getElementById('fretboardFamilyFilter').value = state.fretboardFamilyFilter || 'all'; + document.getElementById('showInversionsToggle').checked = state.showInversions || false; + document.getElementById('inversionRootNote').value = state.inversionRootNote || 'G'; // Chord tab UI const chordTuningSelect = document.getElementById('chordTuningSelect');