(() => { const shapeList = document.getElementById('shape-list'); const btnAdd = document.getElementById('btn-add-shape'); const btnRestore = document.getElementById('btn-restore-shapes'); const editor = document.getElementById('shape-editor'); const nameInput = document.getElementById('shape-name-input'); const fretInputs = document.getElementById('shape-fret-inputs'); const btnEditorSave = document.getElementById('btn-shape-save'); const btnEditorCancel = document.getElementById('btn-shape-cancel'); const qualitySelect = document.getElementById('shape-quality-select'); const rootSelect = document.getElementById('shape-root-select'); const voicingRow = document.getElementById('voicing-row'); const btnSearch = document.getElementById('btn-shape-search'); const resultsContainer = document.getElementById('shapes-results'); const loadingEl = document.getElementById('shapes-loading'); const roots = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; let shapes = []; let selectedIndex = 0; let editingIndex = -1; function init() { if (!window.go || !window.go.main || !window.go.main.App) return; window.go.main.App.GetDefaultShapes().then(defaults => { shapes = defaults; renderShapeList(); buildVoicingInputs(); }); roots.forEach((r, i) => { const opt = document.createElement('option'); opt.value = i; opt.textContent = r; rootSelect.appendChild(opt); }); window.go.main.App.GetChordDefinitions().then(defs => { const qualities = new Set(); for (const cat of Object.keys(defs)) { for (const q of Object.keys(defs[cat])) { qualities.add(q); } } Array.from(qualities).sort().forEach(q => { const opt = document.createElement('option'); opt.value = q; opt.textContent = q; qualitySelect.appendChild(opt); }); }); btnAdd.addEventListener('click', () => openEditor(-1)); btnRestore.addEventListener('click', restoreDefaults); btnEditorSave.addEventListener('click', saveEditor); btnEditorCancel.addEventListener('click', closeEditor); btnSearch.addEventListener('click', doSearch); resultsContainer.addEventListener('click', (e) => { const item = e.target.closest('[data-chord-midi]'); if (!item || !window.playChord) return; window.playChord(JSON.parse(item.dataset.chordMidi)); }); } function renderShapeList() { shapeList.innerHTML = ''; shapes.forEach((s, i) => { const item = document.createElement('div'); item.className = 'shape-item' + (i === selectedIndex ? ' selected' : ''); const name = document.createElement('span'); name.className = 'shape-name'; name.textContent = s.name; const frets = document.createElement('span'); frets.className = 'shape-frets'; frets.textContent = s.frets.map(f => f === -1 ? 'x' : f).join(' '); const actions = document.createElement('span'); actions.className = 'shape-actions'; const editBtn = document.createElement('button'); editBtn.textContent = 'edit'; editBtn.addEventListener('click', e => { e.stopPropagation(); openEditor(i); }); const delBtn = document.createElement('button'); delBtn.textContent = 'del'; delBtn.addEventListener('click', e => { e.stopPropagation(); shapes.splice(i, 1); if (selectedIndex >= shapes.length) selectedIndex = Math.max(0, shapes.length - 1); renderShapeList(); buildVoicingInputs(); }); actions.appendChild(editBtn); actions.appendChild(delBtn); item.appendChild(name); item.appendChild(frets); item.appendChild(actions); item.addEventListener('click', () => { selectedIndex = i; renderShapeList(); buildVoicingInputs(); }); shapeList.appendChild(item); }); } function buildVoicingInputs() { voicingRow.innerHTML = ''; const shape = shapes[selectedIndex]; if (!shape) return; const labels = ['1','2','3','4','5','6']; for (let i = 0; i < 6; i++) { const inp = document.createElement('input'); inp.type = 'text'; inp.placeholder = labels[i]; inp.dataset.string = i; if (shape.frets[i] === -1) { inp.disabled = true; inp.value = ''; inp.placeholder = 'x'; } voicingRow.appendChild(inp); } } function openEditor(idx) { editingIndex = idx; editor.style.display = ''; fretInputs.innerHTML = ''; if (idx >= 0) { nameInput.value = shapes[idx].name; for (let i = 0; i < 6; i++) { const inp = document.createElement('input'); inp.type = 'text'; inp.value = shapes[idx].frets[i] === -1 ? 'x' : shapes[idx].frets[i]; fretInputs.appendChild(inp); } } else { nameInput.value = ''; for (let i = 0; i < 6; i++) { const inp = document.createElement('input'); inp.type = 'text'; inp.value = '0'; fretInputs.appendChild(inp); } } } function closeEditor() { editor.style.display = 'none'; editingIndex = -1; } function saveEditor() { const name = nameInput.value.trim(); if (!name) return; const inputs = fretInputs.querySelectorAll('input'); const frets = []; for (const inp of inputs) { const v = inp.value.trim().toLowerCase(); if (v === 'x' || v === '-1') { frets.push(-1); } else { const n = parseInt(v); if (isNaN(n) || n < 0) { frets.push(0); } else { frets.push(n); } } } const shape = { name: name, frets: frets }; if (editingIndex >= 0) { shapes[editingIndex] = shape; } else { shapes.push(shape); selectedIndex = shapes.length - 1; } closeEditor(); renderShapeList(); buildVoicingInputs(); } function restoreDefaults() { if (!window.go) return; const currentSearch = shapes[selectedIndex]; window.go.main.App.GetDefaultShapes().then(defaults => { shapes = defaults; // preserve search shape if custom if (currentSearch) { const exists = shapes.some(s => s.name === currentSearch.name); if (!exists) { shapes.push(currentSearch); selectedIndex = shapes.length - 1; } else { selectedIndex = shapes.findIndex(s => s.name === currentSearch.name); } } else { selectedIndex = 0; } renderShapeList(); buildVoicingInputs(); }); } function doSearch() { if (!window.go) return; const shape = shapes[selectedIndex]; if (!shape) return; const voicingInputs = voicingRow.querySelectorAll('input'); const voicing = []; voicingInputs.forEach(inp => { voicing.push(inp.disabled ? '' : inp.value.trim()); }); const query = { shape: shape, target_quality: qualitySelect.value, target_root: parseInt(rootSelect.value), voicing: voicing }; loadingEl.style.display = ''; loadingEl.textContent = 'Searching tunings...'; resultsContainer.innerHTML = ''; window.go.main.App.FindShapeTunings(query, shapes).then(results => { loadingEl.style.display = 'none'; renderResults(results || [], shape.name); }).catch(err => { loadingEl.style.display = 'none'; loadingEl.style.display = ''; loadingEl.textContent = 'Error: ' + err; }); } const PAGE_SIZE = 10; function renderResults(results, searchShapeName) { resultsContainer.innerHTML = ''; if (results.length === 0) { const msg = document.createElement('div'); msg.className = 'loading'; msg.textContent = 'No tunings found.'; resultsContainer.appendChild(msg); return; } const compat = results.filter(r => r.high_compat).length; const summary = document.createElement('div'); summary.style.cssText = 'font-size:0.8rem;color:#888;margin-bottom:0.75rem;'; summary.textContent = results.length.toLocaleString() + ' possible tuning' + (results.length !== 1 ? 's' : '') + ' found' + (compat ? ' \u2014 ' + compat.toLocaleString() + ' highly compatible' : ''); resultsContainer.appendChild(summary); const listEl = document.createElement('div'); resultsContainer.appendChild(listEl); let shown = 0; let loadMoreBtn = null; function renderPage() { const end = Math.min(shown + PAGE_SIZE, results.length); for (let idx = shown; idx < end; idx++) { listEl.appendChild(buildTuningCard(results[idx], searchShapeName)); } shown = end; if (shown < results.length) { if (!loadMoreBtn) { loadMoreBtn = document.createElement('button'); loadMoreBtn.className = 'btn-load-more'; loadMoreBtn.addEventListener('click', renderPage); resultsContainer.appendChild(loadMoreBtn); } loadMoreBtn.textContent = 'Load more (' + (results.length - shown) + ' remaining)'; } else if (loadMoreBtn) { loadMoreBtn.remove(); loadMoreBtn = null; } } renderPage(); } function buildTuningCard(tc, searchShapeName) { const card = document.createElement('div'); card.className = 'tuning-card'; const header = document.createElement('div'); header.className = 'tuning-card-header'; const h3 = document.createElement('h3'); h3.textContent = tc.chord; if (tc.high_compat) { card.classList.add('high-compat'); const star = document.createElement('span'); star.className = 'compat-star'; star.textContent = '\u2605'; star.title = 'Highly compatible: ' + tc.maj_min_count + ' major/minor triads'; h3.appendChild(star); } const stats = document.createElement('span'); stats.className = 'tuning-stats'; stats.textContent = tc.valid_chords + '/' + (tc.companions ? tc.companions.length : 0) + ' chords'; const notes = document.createElement('span'); notes.className = 'tuning-notes'; notes.textContent = tc.tuning.join(' '); const arrow = document.createElement('span'); arrow.className = 'expand-icon'; arrow.textContent = '\u25B6'; header.appendChild(h3); header.appendChild(stats); header.appendChild(notes); header.appendChild(arrow); header.addEventListener('click', () => { card.classList.toggle('expanded'); }); const body = document.createElement('div'); body.className = 'tuning-card-body'; if (tc.companions && tc.companions.length > 0) { const grid = document.createElement('div'); grid.className = 'companion-grid'; tc.companions.forEach(comp => { const item = document.createElement('div'); item.className = 'companion-item'; const label = document.createElement('div'); label.className = 'companion-label'; label.textContent = comp.shape; if (comp.shape === searchShapeName) { label.style.color = '#1dc9fe'; } const chord = document.createElement('div'); chord.className = 'companion-chord'; chord.textContent = comp.chord; item.appendChild(label); item.appendChild(chord); const shapeDef = shapes.find(s => s.name === comp.shape); if (shapeDef) { const fingering = shapeDef.frets.map(f => f === -1 ? 'x' : String(f)); const maxFret = Math.max(...shapeDef.frets.filter(f => f >= 0), 1); const fb = document.createElement('div'); fb.className = 'fretboard alternative-fretboard'; renderSingleFretboard(fb, fingering, Math.max(maxFret, 4)); item.appendChild(fb); const midi = []; for (let si = 0; si < 6; si++) { if (shapeDef.frets[si] !== -1 && tc.tuning_midi && tc.tuning_midi[si] != null) { midi.push(tc.tuning_midi[si] + shapeDef.frets[si]); } } if (midi.length) item.dataset.chordMidi = JSON.stringify(midi); } const notesDiv = document.createElement('div'); notesDiv.className = 'companion-notes'; notesDiv.textContent = (comp.notes || []).join(' '); item.appendChild(notesDiv); grid.appendChild(item); }); body.appendChild(grid); } card.appendChild(header); card.appendChild(body); return card; } function renderSingleFretboard(fb, fingering, maxFret) { const numStrings = fingering.length; fb.innerHTML = ''; fb.style.display = 'inline-block'; const fretCounts = {}; fingering.forEach(f => { if (!isNaN(f)) fretCounts[f] = (fretCounts[f] || 0) + 1; }); const entries = Object.entries(fretCounts) .filter(([, count]) => count >= 2) .map(([f]) => parseInt(f)); let barreFretNum = null; for (const f of entries.sort((a, b) => a - b)) { if (fingering.every(x => x === 'x' || isNaN(x) || parseInt(x) >= f)) { barreFretNum = f; break; } } const fretMatrix = []; for (let s = 0; s < numStrings; s++) { const stringRow = []; for (let f = 1; f <= maxFret; f++) { const fret = document.createElement('div'); fret.className = 'fret'; const fretValue = fingering[s]; const numericFret = parseInt(fretValue, 10); if (fretValue === 'x' && f === 1) { fret.setAttribute('muted', ''); fret.textContent = 'x'; } else if (fretValue !== 'x' && numericFret === f) { fret.dataset.dot = 'true'; if (barreFretNum !== null && numericFret === barreFretNum) { fret.classList.add('barre'); } } stringRow.push(fret); } fretMatrix.push(stringRow); } const wrapper = document.createElement('div'); wrapper.className = 'fretboard'; for (let s = numStrings - 1; s >= 0; s--) { const row = document.createElement('div'); row.className = 'fret-row'; for (let f = 1; f <= maxFret; f++) { row.appendChild(fretMatrix[s][f - 1]); } wrapper.appendChild(row); } fb.appendChild(wrapper); if (barreFretNum !== null) { const barreCols = []; for (let s = 0; s < numStrings; s++) { if (parseInt(fingering[s]) === barreFretNum) barreCols.push(s); } if (barreCols.length >= 2) { const start = Math.min(...barreCols); const end = Math.max(...barreCols); const line = document.createElement('div'); line.className = 'barre-line'; requestAnimationFrame(() => { let totalDotCenter = 0; let dotCount = 0; for (let s = start; s <= end; s++) { const dotFret = fretMatrix[s][barreFretNum - 1]; if (dotFret) { const rect = dotFret.getBoundingClientRect(); totalDotCenter += (rect.left + rect.right) / 2; dotCount++; } } if (!dotCount) return; const avgDotCenter = totalDotCenter / dotCount; const parentRect = wrapper.getBoundingClientRect(); const dotCenter = avgDotCenter - parentRect.left; const firstDot = fretMatrix[start][barreFretNum - 1]; const lastDot = fretMatrix[end][barreFretNum - 1]; const rect1 = firstDot.getBoundingClientRect(); const rect2 = lastDot.getBoundingClientRect(); const top = Math.min(rect1.top, rect2.top) - parentRect.top; const bottom = Math.max(rect1.bottom, rect2.bottom) - parentRect.top; const height = bottom - top; line.style.top = Math.round(top) + 'px'; line.style.height = Math.round(height) + 'px'; line.style.left = Math.round(dotCenter) + 'px'; line.textContent = '|'; wrapper.appendChild(line); }); } } } window.initShapeExplorer = init; })();