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();