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 name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
|
|
@ -56,25 +181,13 @@
|
|||
font-weight: bold;
|
||||
}
|
||||
|
||||
#graphs-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.graph-panel {
|
||||
flex: 1;
|
||||
height: 400px;
|
||||
#graph-container {
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
border: 1px solid #0f3460;
|
||||
border-radius: 8px;
|
||||
background: #16213e;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-panel svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chord-info {
|
||||
|
|
@ -179,11 +292,7 @@
|
|||
<button id="nextBtn" disabled>Next →</button>
|
||||
</div>
|
||||
|
||||
<div id="graphs-container">
|
||||
<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 id="graph-container"></div>
|
||||
|
||||
<div class="chord-info">
|
||||
<div class="chord-panel prev">
|
||||
|
|
@ -208,40 +317,185 @@
|
|||
let totalSteps = 0;
|
||||
let hasPrev = false;
|
||||
let hasNext = false;
|
||||
let simulations = {};
|
||||
|
||||
// D3 setup - three graphs
|
||||
const graphWidth = 360;
|
||||
const graphHeight = 400;
|
||||
// Cytoscape instance
|
||||
let cy = null;
|
||||
|
||||
// Create three SVGs
|
||||
const svgs = {
|
||||
prev: d3.select("#graph-prev").append("svg")
|
||||
.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}`)
|
||||
};
|
||||
// Graph dimensions
|
||||
const graphWidth = 1100;
|
||||
const graphHeight = 450;
|
||||
|
||||
const groups = {
|
||||
prev: svgs.prev.append("g"),
|
||||
current: svgs.current.append("g"),
|
||||
next: svgs.next.append("g")
|
||||
};
|
||||
// Voice colors - sleek pastel scheme
|
||||
const voiceColors = ['#5EAFD6', '#CE8E94', '#8FCEA4', '#E5C686'];
|
||||
|
||||
// Zoom behavior for each
|
||||
Object.values(svgs).forEach(svg => {
|
||||
const zoom = d3.zoom()
|
||||
.scaleExtent([0.5, 4])
|
||||
.on("zoom", (event) => {
|
||||
groups.prev.attr("transform", event.transform);
|
||||
groups.current.attr("transform", event.transform);
|
||||
groups.next.attr("transform", event.transform);
|
||||
// Create Cytoscape instance
|
||||
function initCytoscape() {
|
||||
cy = cytoscape({
|
||||
container: document.getElementById('graph-container'),
|
||||
style: [
|
||||
{
|
||||
selector: 'node',
|
||||
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
|
||||
async function loadFromAPI() {
|
||||
try {
|
||||
|
|
@ -254,10 +508,8 @@
|
|||
hasPrev = data.has_prev;
|
||||
hasNext = data.has_next;
|
||||
|
||||
// Render graphs from API data
|
||||
renderGraphFromData(data.prev, groups.prev);
|
||||
renderGraphFromData(data.current, groups.current);
|
||||
renderGraphFromData(data.next, groups.next);
|
||||
// Render all three graphs in single panel
|
||||
renderAllThree(data.prev, data.current, data.next);
|
||||
|
||||
// Update displays
|
||||
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
|
||||
// Load file list and setup file selection
|
||||
async function loadFileList() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue