From dff8a4e6c25f810d17b9cdd0effd1e3109462232 Mon Sep 17 00:00:00 2001 From: Michael Winter Date: Wed, 1 Apr 2026 09:59:30 +0200 Subject: [PATCH] Add all-chords continuous canvas with xforce bounds and pan navigation --- webapp/path_navigator.html | 251 +++++++++++++++++++++++++++++-------- webapp/server.py | 20 +++ 2 files changed, 218 insertions(+), 53 deletions(-) diff --git a/webapp/path_navigator.html b/webapp/path_navigator.html index 1f865d7..6dda9c4 100644 --- a/webapp/path_navigator.html +++ b/webapp/path_navigator.html @@ -83,12 +83,12 @@ const b = A.bounds; const margin = 30; - // Soft boundary force + // Strong boundary force 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){ - 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.x += s.vx; - // Hard boundary clamp - if(s.x < s.bounds.min) s.x = s.bounds.min; - if(s.x > s.bounds.max) s.x = s.bounds.max; + // HARD boundary clamp - absolute enforcement + s.x = Math.max(s.bounds.min, Math.min(s.bounds.max, s.x)); }); cy.batch(()=>{ @@ -311,19 +310,24 @@ let totalSteps = 0; let hasPrev = false; let hasNext = false; + let allGraphsData = null; // Cytoscape instance let cy = null; - // Graph dimensions - const graphWidth = 1100; + // Graph dimensions - will expand based on number of chords + let graphWidth = 1100; const graphHeight = 450; + const chordSpacing = 350; // Voice colors - sleek pastel scheme const voiceColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700']; - // Create Cytoscape instance - function initCytoscape() { + // Create Cytoscape instance for all chords + function initCytoscapeAll(totalChords) { + // Calculate canvas width based on number of chords + graphWidth = Math.max(1100, totalChords * chordSpacing + 400); + cy = cytoscape({ container: document.getElementById('graph-container'), 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) { - if (!cy) initCytoscape(); + if (!cy) initCytoscapeAll(3); // Clear previous elements cy.elements().remove(); @@ -491,7 +495,179 @@ 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() { try { const response = await fetch(`/api/graph/${currentIndex}`); @@ -661,57 +837,26 @@ container.appendChild(table); } - // Update display - now handled by loadFromAPI + // Update display - now using pan navigation with all chords loaded function updateDisplay() { - loadFromAPI(); + updateUI(); } - // Navigation with animation + // Navigation with pan animation 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) { currentIndex--; - } else if (direction === 'next' && currentIndex < chords.length - 1) { + } else if (direction === 'next' && currentIndex < totalSteps) { currentIndex++; } else { return; } - // Animate the graph panels - const prevPanel = document.getElementById('graph-prev'); - const currentPanel = document.getElementById('graph-current'); - const nextPanel = document.getElementById('graph-next'); + // Pan to new chord position + panToIndex(currentIndex); - // Simple fade transition - const panels = [prevPanel, currentPanel, nextPanel]; - 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); + // Update UI + updateUI(); } // Navigation @@ -734,7 +879,7 @@ // Initialize loadFileList(); - loadFromAPI(); + loadAllGraphs(); diff --git a/webapp/server.py b/webapp/server.py index 23d04da..865083b 100644 --- a/webapp/server.py +++ b/webapp/server.py @@ -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"]) def navigate(): global current_index