Add all-chords continuous canvas with xforce bounds and pan navigation
This commit is contained in:
parent
2b7c882bc9
commit
dff8a4e6c2
|
|
@ -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();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue