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 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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue