Add Cytoscape.js with custom xforce layout for chord visualization

This commit is contained in:
Michael Winter 2026-03-31 18:44:47 +02:00
parent 88a9528e12
commit 00cfac2e5c

View file

@ -4,7 +4,132 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Path Navigator</title> <title>Path Navigator</title>
<script src="https://d3js.org/d3.v7.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js"></script>
<script>
// Register minimal "xforce" layout - force-directed in x only, y stays fixed, with bounds
(function(){
const XForceLayout = function(options){
this.options = Object.assign({
name: 'xforce',
nodes: undefined,
edges: undefined,
linkDistance: 80,
linkStrength: 0.1,
charge: -30,
collisionDistance: 20,
damping: 0.6,
iterations: 300,
stepDelay: 0,
// bounds for each chord: { prev: {min, max}, current: {min, max}, next: {min, max} }
bounds: {},
}, options);
this.cy = options.cy;
};
XForceLayout.prototype.run = function(){
const cy = this.cy;
const opts = this.options;
const nodes = (opts.nodes || cy.nodes());
const edges = (opts.edges || cy.edges());
if(!nodes.length){ this.trigger('stop'); return; }
const state = new Map();
nodes.forEach(n => {
const chordLabel = n.data('chordLabel');
const bounds = opts.bounds[chordLabel] || { min: 0, max: graphWidth };
state.set(n.id(), {
x: n.position('x'),
y: n.position('y'),
vx: 0,
fx: 0,
bounds: bounds,
});
});
const edgeList = edges.map(e => ({ s: e.source().id(), t: e.target().id() }));
const iter = (i) => {
state.forEach(s => s.fx = 0);
edgeList.forEach(({s,t})=>{
const a = state.get(s), b = state.get(t);
if(!a || !b) return;
const dx = b.x - a.x;
const dist = Math.abs(dx) || 0.0001;
const dir = dx / dist;
const spring = opts.linkStrength * (dist - opts.linkDistance);
a.fx += spring * dir;
b.fx -= spring * dir;
});
const arr = Array.from(state.values());
for(let u=0; u<arr.length; u++){
for(let v=u+1; v<arr.length; v++){
const A = arr[u], B = arr[v];
const dx = B.x - A.x;
let dist = Math.abs(dx) || 0.0001;
const dir = dx / dist;
const force = opts.charge / (dist*dist);
A.fx -= force * dir;
B.fx += force * dir;
}
}
// Boundary forces - push nodes back into their thirds
const ids = Array.from(state.keys());
for(let i1=0;i1<ids.length;i1++){
const idA = ids[i1];
const A = state.get(idA);
const b = A.bounds;
const margin = 30;
// Soft boundary force
if(A.x < b.min + margin){
A.fx += (b.min + margin - A.x) * 0.3;
}
if(A.x > b.max - margin){
A.fx -= (A.x - (b.max - margin)) * 0.3;
}
}
state.forEach(s => {
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;
});
cy.batch(()=>{
state.forEach((s, id) => {
const n = cy.getElementById(id);
if(n) n.position({ x: s.x, y: s.y });
});
});
if(i < opts.iterations - 1){
if(opts.stepDelay > 0){
setTimeout(()=> iter(i+1), opts.stepDelay);
} else {
iter(i+1);
}
} else {
this.trigger('stop');
}
};
iter(0);
};
XForceLayout.prototype.stop = function(){};
XForceLayout.prototype.on = function(){};
XForceLayout.prototype.once = function(){};
XForceLayout.prototype.trigger = function(name){};
cytoscape('layout', 'xforce', XForceLayout);
})();
</script>
<style> <style>
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@ -56,25 +181,13 @@
font-weight: bold; font-weight: bold;
} }
#graphs-container { #graph-container {
display: flex; width: 100%;
justify-content: space-between; height: 450px;
gap: 10px;
}
.graph-panel {
flex: 1;
height: 400px;
border: 1px solid #0f3460; border: 1px solid #0f3460;
border-radius: 8px; border-radius: 8px;
background: #16213e; background: #16213e;
position: relative; position: relative;
overflow: hidden;
}
.graph-panel svg {
width: 100%;
height: 100%;
} }
.chord-info { .chord-info {
@ -179,11 +292,7 @@
<button id="nextBtn" disabled>Next →</button> <button id="nextBtn" disabled>Next →</button>
</div> </div>
<div id="graphs-container"> <div id="graph-container"></div>
<div id="graph-prev" class="graph-panel"></div>
<div id="graph-current" class="graph-panel"></div>
<div id="graph-next" class="graph-panel"></div>
</div>
<div class="chord-info"> <div class="chord-info">
<div class="chord-panel prev"> <div class="chord-panel prev">
@ -208,40 +317,185 @@
let totalSteps = 0; let totalSteps = 0;
let hasPrev = false; let hasPrev = false;
let hasNext = false; let hasNext = false;
let simulations = {};
// D3 setup - three graphs // Cytoscape instance
const graphWidth = 360; let cy = null;
const graphHeight = 400;
// Create three SVGs // Graph dimensions
const svgs = { const graphWidth = 1100;
prev: d3.select("#graph-prev").append("svg") const graphHeight = 450;
.attr("viewBox", `0 0 ${graphWidth} ${graphHeight}`),
current: d3.select("#graph-current").append("svg")
.attr("viewBox", `0 0 ${graphWidth} ${graphHeight}`),
next: d3.select("#graph-next").append("svg")
.attr("viewBox", `0 0 ${graphWidth} ${graphHeight}`)
};
const groups = { // Voice colors - sleek pastel scheme
prev: svgs.prev.append("g"), const voiceColors = ['#5EAFD6', '#CE8E94', '#8FCEA4', '#E5C686'];
current: svgs.current.append("g"),
next: svgs.next.append("g")
};
// Zoom behavior for each // Create Cytoscape instance
Object.values(svgs).forEach(svg => { function initCytoscape() {
const zoom = d3.zoom() cy = cytoscape({
.scaleExtent([0.5, 4]) container: document.getElementById('graph-container'),
.on("zoom", (event) => { style: [
groups.prev.attr("transform", event.transform); {
groups.current.attr("transform", event.transform); selector: 'node',
groups.next.attr("transform", event.transform); style: {
'background-color': 'data(color)',
'width': 36,
'height': 36,
'label': 'data(cents)',
'text-valign': 'center',
'text-halign': 'center',
'color': '#eee',
'font-size': '9px',
'text-outline-width': 2,
'text-outline-color': 'data(color)',
'border-width': 0,
}
},
{
selector: 'edge',
style: {
'width': 2,
'line-color': '#556',
'curve-style': 'straight',
'target-arrow-shape': 'none',
'label': 'data(ratio)',
'font-size': '10px',
'color': '#aaa',
'text-rotation': 'autorotate',
'text-margin-y': -12,
'text-background-color': '#16213e',
'text-background-opacity': 0.8,
'text-background-padding': '3px',
}
},
{
selector: ':selected',
style: {
'border-width': 3,
'border-color': '#fff',
}
}
],
layout: {
name: 'preset',
},
minZoom: 1,
maxZoom: 1,
zoomingEnabled: false,
panningEnabled: false,
autounselectify: true,
boxSelectionEnabled: false,
}); });
svg.call(zoom);
// Lock y on drag - only allow x movement
cy.on('grab', 'node', function(evt) {
const node = evt.target;
node.data('originalY', node.position('y'));
}); });
cy.on('drag', 'node', function(evt) {
const node = evt.target;
const originalY = node.data('originalY');
if (originalY !== undefined) {
node.position('y', originalY);
}
});
}
// Render all three chords using Cytoscape
function renderAllThree(prevData, currentData, nextData) {
if (!cy) initCytoscape();
// Clear previous elements
cy.elements().remove();
// Build elements array
let elements = [];
// Collect all chords
const allChords = [];
if (prevData && prevData.nodes) allChords.push({data: prevData, xPos: graphWidth * 0.2, label: "prev"});
if (currentData && currentData.nodes) allChords.push({data: currentData, xPos: graphWidth * 0.5, label: "current"});
if (nextData && nextData.nodes) allChords.push({data: nextData, xPos: graphWidth * 0.8, label: "next"});
// Collect all cents from all chords for global scale
let allCents = [];
allChords.forEach(c => {
allCents = allCents.concat(c.data.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;
// Add nodes for each chord
let nodeOffset = 0;
allChords.forEach(({data, xPos, label: chordLabel}) => {
const nodes = data.nodes;
const edges = data.edges || [];
const idMap = {};
nodes.forEach((n, i) => {
const globalId = nodeOffset + n.id;
idMap[n.id] = globalId;
// Wider initial spread: 25% of graphWidth
const x = xPos + (graphWidth * 0.25) * (i / (nodes.length - 1 || 1));
const y = yBase + ySpread - ((n.cents - globalMinCents) / globalCentsRange) * ySpread;
elements.push({
group: 'nodes',
data: {
id: String(globalId),
localId: n.id,
cents: n.cents,
fraction: n.fraction,
chordLabel: chordLabel,
color: voiceColors[n.id % voiceColors.length],
},
position: { x: x, y: y },
});
});
// Add edges
edges.forEach(e => {
elements.push({
group: 'edges',
data: {
source: String(idMap[e.source]),
target: String(idMap[e.target]),
ratio: e.ratio,
},
});
});
nodeOffset += nodes.length;
});
if (elements.length === 0) return;
// Add elements to Cytoscape
cy.add(elements);
// Run xforce layout to optimize x positions while keeping y fixed
// Bounds: each chord stays in its third of the container
cy.layout({
name: 'xforce',
linkDistance: 60,
linkStrength: 0.1,
charge: -60,
collisionDistance: 35,
damping: 0.7,
iterations: 150,
bounds: {
'prev': { min: 50, max: 400 },
'current': { min: 350, max: 750 },
'next': { min: 700, max: 1050 },
},
}).run();
cy.fit();
}
// Load from Flask API - get computed graph from Python // Load from Flask API - get computed graph from Python
async function loadFromAPI() { async function loadFromAPI() {
try { try {
@ -254,10 +508,8 @@
hasPrev = data.has_prev; hasPrev = data.has_prev;
hasNext = data.has_next; hasNext = data.has_next;
// Render graphs from API data // Render all three graphs in single panel
renderGraphFromData(data.prev, groups.prev); renderAllThree(data.prev, data.current, data.next);
renderGraphFromData(data.current, groups.current);
renderGraphFromData(data.next, groups.next);
// Update displays // Update displays
document.getElementById("currentIndex").textContent = currentIndex; document.getElementById("currentIndex").textContent = currentIndex;
@ -276,94 +528,6 @@
} }
} }
// Render graph from API data (nodes + edges already calculated by Python)
function renderGraphFromData(graphData, group) {
const { nodes, edges } = graphData || { nodes: [], edges: [] };
// Clear previous
group.selectAll("*").remove();
if (nodes.length === 0) {
group.append("text")
.attr("x", graphWidth / 2)
.attr("y", graphHeight / 2)
.attr("text-anchor", "middle")
.attr("fill", "#666")
.style("font-size", "14px")
.text("(empty)");
return;
}
// Calculate y positions (Python only gives cents, not positions)
const centsList = nodes.map(n => n.cents);
const minCents = Math.min(...centsList);
const maxCents = Math.max(...centsList);
const range = maxCents - minCents || 1;
// Add positions to nodes
const nodesWithPos = nodes.map((n, i) => ({
...n,
x: graphWidth * 0.2 + (graphWidth * 0.6) * (i / (nodes.length - 1 || 1)),
y: graphHeight * 0.1 + (graphHeight * 0.8) * (1 - (n.cents - minCents) / range)
}));
// Force simulation with fixed y positions
const simulation = d3.forceSimulation(nodesWithPos)
.force("link", d3.forceLink(edges).id(d => d.id).distance(60))
.force("charge", d3.forceManyBody().strength(-200))
.force("y", d3.forceY(d => d.y).strength(1))
.force("collision", d3.forceCollide().radius(25));
// Draw links
const link = group.selectAll(".link")
.data(edges)
.enter()
.append("line")
.attr("class", "link");
// Draw link labels
const linkLabel = group.selectAll(".link-label")
.data(edges)
.enter()
.append("text")
.attr("class", "link-label")
.attr("text-anchor", "middle")
.style("font-size", "10px")
.text(d => d.ratio);
// Draw nodes
const node = group.selectAll(".node")
.data(nodesWithPos)
.enter()
.append("g")
.attr("class", "node");
node.append("circle")
.attr("r", 15)
.attr("fill", (d, i) => d3.schemeCategory10[i % 10]);
node.append("text")
.attr("class", "node-label")
.attr("dy", 3)
.style("font-size", "9px")
.text(d => d.cents);
// Update positions on tick
simulation.on("tick", () => {
link
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
linkLabel
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2 - 8);
node.attr("transform", d => `translate(${d.x},${d.y})`);
});
}
// Fallback to local file // Fallback to local file
// Load file list and setup file selection // Load file list and setup file selection
async function loadFileList() { async function loadFileList() {