web-tuner/frontend/dist/shapes.js

799 lines
26 KiB
JavaScript

(() => {
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 setSelect = document.getElementById('score-set-select');
const roots = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
let scoreSetsData = { sets: [], selected: 0 };
let shapes = [];
let selectedIndex = 0;
let editingIndex = -1;
function activeSet() {
return scoreSetsData.sets[scoreSetsData.selected];
}
function syncShapesRef() {
const set = activeSet();
shapes = set ? set.shapes : [];
}
function init() {
if (!window.go || !window.go.main || !window.go.main.App) return;
window.go.main.App.GetScoreSets().then(data => {
scoreSetsData = data;
if (!scoreSetsData.sets || scoreSetsData.sets.length === 0) {
scoreSetsData = { sets: [{ name: 'Standard Shapes', type: '', shapes: [] }], selected: 0 };
window.go.main.App.GetDefaultShapes().then(defaults => {
scoreSetsData.sets[0].shapes = defaults;
syncShapesRef();
renderSetSelector();
renderShapeList();
buildVoicingInputs();
});
return;
}
syncShapesRef();
selectedIndex = 0;
renderSetSelector();
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);
setSelect.addEventListener('change', () => {
if (window.dbg) window.dbg('[shapes] setSelect change:', setSelect.value);
if (setSelect.value === '__editor__') {
setSelect.value = scoreSetsData.selected;
if (window.dbg) window.dbg('[shapes] opening editor, sets:', scoreSetsData.sets.length);
window.switchToView('sets-editor');
if (window.initSetsEditor) window.initSetsEditor();
return;
}
switchSet(parseInt(setSelect.value));
});
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 isDensitySet() {
const set = activeSet();
return set && set.type === 'density';
}
function renderSetSelector() {
setSelect.innerHTML = '';
scoreSetsData.sets.forEach((set, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = set.name;
if (i === scoreSetsData.selected) opt.selected = true;
setSelect.appendChild(opt);
});
const sep = document.createElement('option');
sep.disabled = true;
sep.textContent = '───';
setSelect.appendChild(sep);
const editorOpt = document.createElement('option');
editorOpt.value = '__editor__';
editorOpt.textContent = 'Open Sets Editor';
setSelect.appendChild(editorOpt);
}
function switchSet(i) {
if (i < 0 || i >= scoreSetsData.sets.length) return;
scoreSetsData.selected = i;
syncShapesRef();
selectedIndex = 0;
closeEditor();
renderSetSelector();
renderShapeList();
buildVoicingInputs();
persistSets();
}
function persistSets() {
if (!window.go) return;
const set = activeSet();
if (set) set.shapes = shapes;
window.go.main.App.SaveScoreSets(scoreSetsData);
}
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';
if (i > 0) {
const upBtn = document.createElement('button');
upBtn.textContent = '\u25B2';
upBtn.title = 'Move up';
upBtn.addEventListener('click', e => {
e.stopPropagation();
[shapes[i - 1], shapes[i]] = [shapes[i], shapes[i - 1]];
if (selectedIndex === i) selectedIndex = i - 1;
else if (selectedIndex === i - 1) selectedIndex = i;
renderShapeList();
buildVoicingInputs();
persistSets();
});
actions.appendChild(upBtn);
}
if (i < shapes.length - 1) {
const downBtn = document.createElement('button');
downBtn.textContent = '\u25BC';
downBtn.title = 'Move down';
downBtn.addEventListener('click', e => {
e.stopPropagation();
[shapes[i], shapes[i + 1]] = [shapes[i + 1], shapes[i]];
if (selectedIndex === i) selectedIndex = i + 1;
else if (selectedIndex === i + 1) selectedIndex = i;
renderShapeList();
buildVoicingInputs();
persistSets();
});
actions.appendChild(downBtn);
}
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();
persistSets();
});
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();
persistSets();
}
function restoreDefaults() {
if (!window.go) return;
window.go.main.App.GetDefaultShapes().then(defaults => {
shapes.length = 0;
defaults.forEach(s => shapes.push(s));
const set = activeSet();
if (set) set.shapes = shapes;
selectedIndex = 0;
renderShapeList();
buildVoicingInputs();
persistSets();
});
}
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,
base_tuning: window.currentTuningMIDI ? window.currentTuningMIDI.slice() : [],
baseline_shift: parseInt(document.getElementById('baseline-shift').value) || 0,
range_down: parseInt(document.getElementById('range-down').value) || 0,
range_up: parseInt(document.getElementById('range-up').value) || 0,
};
loadingEl.style.display = '';
loadingEl.textContent = 'Searching tunings...';
resultsContainer.innerHTML = '';
if (isDensitySet()) {
window.go.main.App.FindDensityTunings(query).then(results => {
loadingEl.style.display = 'none';
renderDensityResults(results || []);
}).catch(err => {
loadingEl.style.display = 'none';
loadingEl.style.display = '';
loadingEl.textContent = 'Error: ' + err;
});
} else {
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);
}
const setBtn = document.createElement('button');
setBtn.className = 'btn-small';
setBtn.textContent = 'Set to instrument tuning';
setBtn.style.marginTop = '0.75rem';
setBtn.addEventListener('click', () => {
if (window.setTuningFromExplorer) window.setTuningFromExplorer(tc.tuning);
});
body.appendChild(setBtn);
card.appendChild(header);
card.appendChild(body);
return card;
}
function renderDensityResults(results) {
resultsContainer.innerHTML = '';
if (results.length === 0) {
const msg = document.createElement('div');
msg.className = 'loading';
msg.textContent = 'No tunings found.';
resultsContainer.appendChild(msg);
return;
}
const explain = document.createElement('p');
explain.className = 'density-explanation';
explain.textContent = 'Tunings ranked by total playable chords within current fret/finger settings. Expand a card to see all identified chords with their simplest fingerings.';
resultsContainer.appendChild(explain);
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() + ' 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(buildDensityCard(results[idx]));
}
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 buildDensityCard(dc) {
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 = dc.chord;
if (dc.high_compat) {
card.classList.add('high-compat');
const star = document.createElement('span');
star.className = 'compat-star';
star.textContent = '\u2605';
star.title = dc.maj_min_count + ' major/minor triads';
h3.appendChild(star);
}
const stats = document.createElement('span');
stats.className = 'tuning-stats';
stats.textContent = dc.valid_chords + ' chords, avg ' + dc.avg_fingers.toFixed(1) + ' fingers';
const notes = document.createElement('span');
notes.className = 'tuning-notes';
notes.textContent = dc.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 (dc.chords && dc.chords.length > 0) {
const grid = document.createElement('div');
grid.className = 'companion-grid';
dc.chords.forEach(ch => {
const item = document.createElement('div');
item.className = 'companion-item';
const chordLabel = document.createElement('div');
chordLabel.className = 'companion-chord';
chordLabel.textContent = ch.chord;
const fingerBadge = document.createElement('span');
fingerBadge.className = 'finger-count';
fingerBadge.textContent = ch.fingers + 'f';
chordLabel.appendChild(fingerBadge);
item.appendChild(chordLabel);
const fingering = ch.frets.map(f => f === -1 ? 'x' : String(f));
const maxFretVal = Math.max(...ch.frets.filter(f => f >= 0), 1);
const fb = document.createElement('div');
fb.className = 'fretboard alternative-fretboard';
renderSingleFretboard(fb, fingering, Math.max(maxFretVal, 4));
item.appendChild(fb);
const midi = [];
for (let si = 0; si < dc.tuning_midi.length; si++) {
if (ch.frets[si] !== -1 && dc.tuning_midi[si] != null) {
midi.push(dc.tuning_midi[si] + ch.frets[si]);
}
}
if (midi.length) item.dataset.chordMidi = JSON.stringify(midi);
const notesDiv = document.createElement('div');
notesDiv.className = 'companion-notes';
notesDiv.textContent = (ch.notes || []).join(' ');
item.appendChild(notesDiv);
grid.appendChild(item);
});
body.appendChild(grid);
}
const setBtn = document.createElement('button');
setBtn.className = 'btn-small';
setBtn.textContent = 'Set to instrument tuning';
setBtn.style.marginTop = '0.75rem';
setBtn.addEventListener('click', () => {
if (window.setTuningFromExplorer) window.setTuningFromExplorer(dc.tuning);
});
body.appendChild(setBtn);
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;
window.renderSingleFretboard = renderSingleFretboard;
window.getScoreSetsData = () => scoreSetsData;
window.setScoreSetsData = (data) => {
scoreSetsData = data;
syncShapesRef();
renderSetSelector();
renderShapeList();
buildVoicingInputs();
};
window.persistSets = persistSets;
})();