Add all-chords continuous canvas with xforce bounds and pan navigation

This commit is contained in:
Michael Winter 2026-04-01 09:59:30 +02:00
parent 2b7c882bc9
commit dff8a4e6c2
2 changed files with 218 additions and 53 deletions

View file

@ -83,12 +83,12 @@
const b = A.bounds; const b = A.bounds;
const margin = 30; const margin = 30;
// Soft boundary force // Strong boundary force
if(A.x < b.min + margin){ if(A.x < b.min + margin){
A.fx += (b.min + margin - A.x) * 0.3; A.fx += (b.min + margin - A.x) * 0.8;
} }
if(A.x > b.max - margin){ if(A.x > b.max - margin){
A.fx -= (A.x - (b.max - margin)) * 0.3; A.fx -= (A.x - (b.max - margin)) * 0.8;
} }
} }
@ -96,9 +96,8 @@
s.vx = (s.vx + s.fx) * opts.damping; s.vx = (s.vx + s.fx) * opts.damping;
s.x += s.vx; s.x += s.vx;
// Hard boundary clamp // HARD boundary clamp - absolute enforcement
if(s.x < s.bounds.min) s.x = s.bounds.min; s.x = Math.max(s.bounds.min, Math.min(s.bounds.max, s.x));
if(s.x > s.bounds.max) s.x = s.bounds.max;
}); });
cy.batch(()=>{ cy.batch(()=>{
@ -311,19 +310,24 @@
let totalSteps = 0; let totalSteps = 0;
let hasPrev = false; let hasPrev = false;
let hasNext = false; let hasNext = false;
let allGraphsData = null;
// Cytoscape instance // Cytoscape instance
let cy = null; let cy = null;
// Graph dimensions // Graph dimensions - will expand based on number of chords
const graphWidth = 1100; let graphWidth = 1100;
const graphHeight = 450; const graphHeight = 450;
const chordSpacing = 350;
// Voice colors - sleek pastel scheme // Voice colors - sleek pastel scheme
const voiceColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700']; const voiceColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
// Create Cytoscape instance // Create Cytoscape instance for all chords
function initCytoscape() { function initCytoscapeAll(totalChords) {
// Calculate canvas width based on number of chords
graphWidth = Math.max(1100, totalChords * chordSpacing + 400);
cy = cytoscape({ cy = cytoscape({
container: document.getElementById('graph-container'), container: document.getElementById('graph-container'),
style: [ style: [
@ -395,9 +399,9 @@
}); });
} }
// Render all three chords using Cytoscape // Render all three chords using Cytoscape (legacy - not used with all-chords approach)
function renderAllThree(prevData, currentData, nextData) { function renderAllThree(prevData, currentData, nextData) {
if (!cy) initCytoscape(); if (!cy) initCytoscapeAll(3);
// Clear previous elements // Clear previous elements
cy.elements().remove(); cy.elements().remove();
@ -491,7 +495,179 @@
cy.fit(); cy.fit();
} }
// Load from Flask API - get computed graph from Python // Render ALL chords at once for continuous canvas
function renderAllChords() {
if (!allGraphsData || !cy) return;
// Clear previous elements
cy.elements().remove();
// Calculate graph dimensions
const totalChords = allGraphsData.graphs.length;
graphWidth = Math.max(1100, totalChords * chordSpacing + 400);
// Collect all cents for global scale
let allCents = [];
allGraphsData.graphs.forEach(g => {
if (g.nodes) {
allCents = allCents.concat(g.nodes.map(n => n.cents));
}
});
const globalMinCents = Math.min(...allCents);
const globalMaxCents = Math.max(...allCents);
const globalCentsRange = globalMaxCents - globalMinCents || 1;
const ySpread = graphHeight * 0.8;
const yBase = graphHeight * 0.1;
// Build elements array for all chords
let elements = [];
allGraphsData.graphs.forEach((graph, chordIdx) => {
if (!graph || !graph.nodes) return;
const nodes = graph.nodes;
const edges = graph.edges || [];
const xBase = 100 + chordIdx * chordSpacing;
const idMap = {};
// Create unique IDs per chord to avoid collisions
const chordPrefix = `c${chordIdx}_`;
nodes.forEach((n, i) => {
const nodeId = chordPrefix + n.id;
idMap[n.id] = nodeId;
// Spread nodes within each chord
const x = xBase + (chordSpacing * 0.15) * (i / (nodes.length - 1 || 1));
const y = yBase + ySpread - ((n.cents - globalMinCents) / globalCentsRange) * ySpread;
elements.push({
group: 'nodes',
data: {
id: nodeId,
localId: n.id,
cents: n.cents,
chordIndex: chordIdx,
chordLabel: `c${chordIdx}`,
color: voiceColors[n.id % voiceColors.length],
},
position: { x: x, y: y },
});
});
// Add edges within chord
edges.forEach(e => {
elements.push({
group: 'edges',
data: {
source: idMap[e.source],
target: idMap[e.target],
ratio: e.ratio,
},
});
});
});
if (elements.length === 0) return;
// Add all elements
cy.add(elements);
console.log('Added', elements.length, 'elements');
// Build bounds for each chord - with gaps between them
const bounds = {};
const gap = 50; // gap between chord regions
allGraphsData.graphs.forEach((graph, idx) => {
const chordLabel = `c${idx}`;
const chordMin = 100 + idx * (chordSpacing + gap);
bounds[chordLabel] = {
min: chordMin,
max: chordMin + chordSpacing
};
});
// Run xforce layout to optimize x positions while keeping y fixed
cy.layout({
name: 'xforce',
linkDistance: 60,
linkStrength: 0.1,
charge: -60,
collisionDistance: 35,
damping: 0.7,
iterations: 250,
bounds: bounds,
}).run();
// Set canvas size
cy.style().cssJson()[0].value = graphWidth;
// Fit to show initial position centered
panToIndex(currentIndex);
}
// Pan to show a specific chord index centered
function panToIndex(index) {
if (!cy) return;
const gap = 50;
const targetX = 100 + index * (chordSpacing + gap) + chordSpacing / 2;
const centerX = 550; // Half of default viewport
const panX = centerX - targetX;
cy.animate({
pan: { x: panX, y: 0 },
duration: 350,
easing: 'ease-out'
});
}
// Load from Flask API - get ALL graphs at once
async function loadAllGraphs() {
try {
const response = await fetch("/api/all-graphs");
if (!response.ok) throw new Error("API not available");
allGraphsData = await response.json();
totalSteps = allGraphsData.total - 1;
// Initialize Cytoscape with total chord count
initCytoscapeAll(allGraphsData.total);
// Render all chords
renderAllChords();
// Update UI
updateUI();
} catch (e) {
console.log("API not available, trying local file", e);
loadFromLocalFile();
}
}
// Update UI elements
function updateUI() {
hasPrev = currentIndex > 0;
hasNext = currentIndex < totalSteps;
document.getElementById("currentIndex").textContent = currentIndex;
document.getElementById("totalSteps").textContent = totalSteps;
document.getElementById("prevBtn").disabled = !hasPrev;
document.getElementById("nextBtn").disabled = !hasNext;
// Update chord panels - get data from allGraphsData
if (allGraphsData && allGraphsData.graphs) {
const prevIdx = currentIndex - 1;
const currIdx = currentIndex;
const nextIdx = currentIndex + 1;
updateChordPanel("prevPitches", prevIdx >= 0 ? allGraphsData.graphs[prevIdx]?.nodes : null);
updateChordPanel("currentPitches", allGraphsData.graphs[currIdx]?.nodes);
updateChordPanel("nextPitches", nextIdx < allGraphsData.total ? allGraphsData.graphs[nextIdx]?.nodes : null);
}
}
// Load from Flask API - get computed graph from Python (legacy)
async function loadFromAPI() { async function loadFromAPI() {
try { try {
const response = await fetch(`/api/graph/${currentIndex}`); const response = await fetch(`/api/graph/${currentIndex}`);
@ -661,57 +837,26 @@
container.appendChild(table); container.appendChild(table);
} }
// Update display - now handled by loadFromAPI // Update display - now using pan navigation with all chords loaded
function updateDisplay() { function updateDisplay() {
loadFromAPI(); updateUI();
} }
// Navigation with animation // Navigation with pan animation
async function navigate(direction) { async function navigate(direction) {
// Try to update via API first
try {
const response = await fetch("/api/navigate", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({direction: direction})
});
if (response.ok) {
const data = await response.json();
currentIndex = data.index;
await loadFromAPI();
return;
}
} catch (e) {
// API not available, use local navigation
}
// Fallback to local navigation
if (direction === 'prev' && currentIndex > 0) { if (direction === 'prev' && currentIndex > 0) {
currentIndex--; currentIndex--;
} else if (direction === 'next' && currentIndex < chords.length - 1) { } else if (direction === 'next' && currentIndex < totalSteps) {
currentIndex++; currentIndex++;
} else { } else {
return; return;
} }
// Animate the graph panels // Pan to new chord position
const prevPanel = document.getElementById('graph-prev'); panToIndex(currentIndex);
const currentPanel = document.getElementById('graph-current');
const nextPanel = document.getElementById('graph-next');
// Simple fade transition // Update UI
const panels = [prevPanel, currentPanel, nextPanel]; updateUI();
panels.forEach(p => {
p.style.transition = 'opacity 0.15s ease-in-out';
p.style.opacity = '0';
});
setTimeout(() => {
updateDisplay();
panels.forEach(p => {
p.style.opacity = '1';
});
}, 150);
} }
// Navigation // Navigation
@ -734,7 +879,7 @@
// Initialize // Initialize
loadFileList(); loadFileList();
loadFromAPI(); loadAllGraphs();
</script> </script>
</body> </body>
</html> </html>

View file

@ -232,6 +232,26 @@ def get_graph(index):
) )
@app.route("/api/all-graphs")
def get_all_graphs():
"""Get computed graph for ALL chords at once for single-canvas rendering."""
if not chords:
return jsonify({"error": "No chords loaded"}), 404
all_graphs = []
for i, chord in enumerate(chords):
graph = calculate_graph(chord)
graph["index"] = i
all_graphs.append(graph)
return jsonify(
{
"total": len(chords),
"graphs": all_graphs,
}
)
@app.route("/api/navigate", methods=["POST"]) @app.route("/api/navigate", methods=["POST"])
def navigate(): def navigate():
global current_index global current_index