Add Cytoscape.js with custom xforce layout for chord visualization
This commit is contained in:
parent
88a9528e12
commit
00cfac2e5c
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue