compact_sets/webapp/path_navigator.html

886 lines
31 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Path Navigator</title>
<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;
// Strong boundary force
if(A.x < b.min + margin){
A.fx += (b.min + margin - A.x) * 0.8;
}
if(A.x > b.max - margin){
A.fx -= (A.x - (b.max - margin)) * 0.8;
}
}
state.forEach(s => {
s.vx = (s.vx + s.fx) * opts.damping;
s.x += s.vx;
// HARD boundary clamp - absolute enforcement
s.x = Math.max(s.bounds.min, Math.min(s.bounds.max, s.x));
});
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;
margin: 0;
padding: 20px;
background: #000000;
color: #cccccc;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 10px;
font-weight: 300;
letter-spacing: 2px;
color: #ffffff;
}
.controls {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 20px;
align-items: center;
}
.controls button {
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
background: #0a0a0a;
color: #888888;
border: 1px solid #222222;
border-radius: 4px;
transition: all 0.2s ease;
}
.controls button:hover {
background: #151515;
color: #ffffff;
border-color: #444444;
}
.controls button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.index-display {
font-size: 14px;
color: #666666;
letter-spacing: 1px;
}
#graph-container {
width: 100%;
height: 450px;
border: 1px solid #1a1a1a;
border-radius: 4px;
background: #050505;
position: relative;
}
.chord-info {
display: flex;
justify-content: space-around;
margin-top: 20px;
}
.chord-panel {
background: #0a0a0a;
padding: 15px;
border-radius: 4px;
min-width: 200px;
border: 1px solid #1a1a1a;
}
.chord-panel h3 {
margin-top: 0;
color: #666666;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 500;
}
.chord-panel.current {
border: 1px solid #333333;
}
.chord-panel.current h3 {
color: #00d4ff;
}
.chord-panel.prev, .chord-panel.next {
opacity: 0.6;
}
.pitch-list {
list-style: none;
padding: 0;
font-size: 12px;
}
.pitch-list li {
padding: 4px 0;
color: #888888;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
}
.file-input {
margin-bottom: 20px;
text-align: center;
}
.file-input input {
padding: 8px;
background: #0a0a0a;
color: #888888;
border: 1px solid #222222;
border-radius: 4px;
}
.file-select {
padding: 8px;
background: #0a0a0a;
color: #888888;
border: 1px solid #222222;
border-radius: 4px;
margin-left: 10px;
}
</style>
</head>
<body>
<div class="container">
<h1>Path Navigator</h1>
<div class="file-input">
<select id="fileSelect">
<option value="">Loading files...</option>
</select>
<button onclick="loadSelectedFile()">Load</button>
<span style="margin-left: 10px;">or upload:</span>
<input type="file" id="fileInput" accept=".json">
</div>
<div class="controls">
<button id="prevBtn" disabled>← Previous</button>
<span class="index-display">Index: <span id="currentIndex">0</span> / <span id="totalSteps">0</span></span>
<button id="nextBtn" disabled>Next →</button>
</div>
<div id="graph-container"></div>
<div class="chord-info">
<div class="chord-panel prev">
<h3>Previous</h3>
<ul class="pitch-list" id="prevPitches"></ul>
</div>
<div class="chord-panel current">
<h3>Current</h3>
<ul class="pitch-list" id="currentPitches"></ul>
</div>
<div class="chord-panel next">
<h3>Next</h3>
<ul class="pitch-list" id="nextPitches"></ul>
</div>
</div>
</div>
<script>
// Global state
let chords = [];
let currentIndex = 0;
let totalSteps = 0;
let hasPrev = false;
let hasNext = false;
let allGraphsData = null;
// Cytoscape instance
let cy = null;
// 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 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: [
{
selector: 'node',
style: {
'background-color': 'data(color)',
'width': 32,
'height': 32,
'label': 'data(cents)',
'text-valign': 'center',
'text-halign': 'center',
'color': '#000000',
'font-size': '10px',
'font-family': 'monospace',
'font-weight': 'bold',
'text-outline-width': 0,
'border-width': 0,
}
},
{
selector: 'edge',
style: {
'width': 1.5,
'line-color': '#555555',
'curve-style': 'straight',
'target-arrow-shape': 'none',
'label': 'data(ratio)',
'font-size': '12px',
'color': '#ffffff',
'text-rotation': 'autorotate',
'text-margin-y': -10,
'text-background-color': '#000000',
'text-background-opacity': 0.8,
'text-background-padding': '2px',
}
},
{
selector: ':selected',
style: {
'border-width': 2,
'border-color': '#00d4ff',
}
}
],
layout: {
name: 'preset',
},
minZoom: 1,
maxZoom: 1,
zoomingEnabled: false,
panningEnabled: false,
autounselectify: true,
boxSelectionEnabled: false,
});
// 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 (legacy - not used with all-chords approach)
function renderAllThree(prevData, currentData, nextData) {
if (!cy) initCytoscapeAll(3);
// 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();
}
// 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}`);
if (!response.ok) throw new Error("API not available");
const data = await response.json();
currentIndex = data.index;
totalSteps = data.total - 1;
hasPrev = data.has_prev;
hasNext = data.has_next;
// Render all three graphs in single panel
renderAllThree(data.prev, data.current, data.next);
// Update displays
document.getElementById("currentIndex").textContent = currentIndex;
document.getElementById("totalSteps").textContent = totalSteps;
document.getElementById("prevBtn").disabled = !hasPrev;
document.getElementById("nextBtn").disabled = !hasNext;
// Update chord panels
updateChordPanel("prevPitches", data.prev ? data.prev.nodes : null);
updateChordPanel("currentPitches", data.current ? data.current.nodes : null);
updateChordPanel("nextPitches", data.next ? data.next.nodes : null);
} catch (e) {
console.log("API not available, trying local file", e);
loadFromLocalFile();
}
}
// Fallback to local file
// Load file list and setup file selection
async function loadFileList() {
try {
const response = await fetch("/api/files");
const data = await response.json();
const select = document.getElementById("fileSelect");
select.innerHTML = "";
data.files.forEach(f => {
const opt = document.createElement("option");
opt.value = f;
opt.textContent = f;
if (f === "output_chords.json") opt.selected = true;
select.appendChild(opt);
});
} catch (e) {
console.log("Could not load file list", e);
}
}
async function loadSelectedFile() {
const select = document.getElementById("fileSelect");
const filename = select.value;
if (!filename) return;
try {
const response = await fetch(`/api/set-file/${filename}`, { method: "POST" });
const data = await response.json();
currentIndex = 0;
loadFromAPI();
} catch (e) {
console.log("Error loading file", e);
}
}
async function loadFromLocalFile() {
try {
const response = await fetch("output/output_chords.json");
if (!response.ok) throw new Error("File not found");
const data = await response.json();
loadChords(data.chords);
} catch (e) {
console.log("No default file, waiting for user upload");
}
}
// File input handler
document.getElementById("fileInput").addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = JSON.parse(event.target.result);
loadChords(data.chords || data);
} catch (err) {
alert("Invalid JSON file");
}
};
reader.readAsText(file);
});
function loadChords(chordData) {
chords = chordData;
currentIndex = 0;
updateDisplay();
}
function updateChordPanel(elementId, data) {
const container = document.getElementById(elementId);
container.innerHTML = "";
if (!data || (Array.isArray(data) && data.length === 0)) {
container.innerHTML = "<div>(none)</div>";
return;
}
const items = Array.isArray(data) ? data : (data.nodes || []);
if (items.length === 0) return;
// Determine number of columns from first node's hs_array
const cols = items[0].hs_array ? items[0].hs_array.length : 0;
if (cols === 0) return;
// Create table - let it size based on content
const table = document.createElement("table");
table.style.fontFamily = "monospace";
table.style.fontSize = "10px";
table.style.borderCollapse = "collapse";
table.style.tableLayout = "auto";
table.style.lineHeight = "1.2";
table.style.margin = "0 auto";
// Header row - split into separate cells to match row structure
const headerRow = document.createElement("tr");
const headerParts = ["2", "3", "5", "7"];
headerParts.forEach((part, idx) => {
const th = document.createElement("th");
th.textContent = part;
th.style.padding = "0px 4px";
th.style.textAlign = idx === 0 ? "left" : "right";
th.style.borderBottom = "1px solid #444";
th.style.paddingBottom = "1px";
th.style.fontWeight = "normal";
th.style.color = "#666";
th.style.whiteSpace = "nowrap";
2026-04-01 09:18:39 +02:00
th.style.width = "28px";
headerRow.appendChild(th);
});
table.appendChild(headerRow);
// Data rows (all nodes)
items.forEach((item) => {
const row = document.createElement("tr");
const hs = item.hs_array || [];
hs.forEach((val, j) => {
const td = document.createElement("td");
td.textContent = val;
td.style.padding = "0px 4px";
td.style.textAlign = "right";
td.style.color = "#888";
td.style.whiteSpace = "nowrap";
2026-04-01 09:18:39 +02:00
td.style.width = "28px";
if (j === 0) {
td.style.textAlign = "left";
}
row.appendChild(td);
});
table.appendChild(row);
});
container.appendChild(table);
}
// Update display - now using pan navigation with all chords loaded
function updateDisplay() {
updateUI();
}
// Navigation with pan animation
async function navigate(direction) {
if (direction === 'prev' && currentIndex > 0) {
currentIndex--;
} else if (direction === 'next' && currentIndex < totalSteps) {
currentIndex++;
} else {
return;
}
// Pan to new chord position
panToIndex(currentIndex);
// Update UI
updateUI();
}
// Navigation
document.getElementById("prevBtn").addEventListener("click", () => {
navigate('prev');
});
document.getElementById("nextBtn").addEventListener("click", () => {
navigate('next');
});
// Keyboard navigation
document.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft") {
navigate('prev');
} else if (e.key === "ArrowRight") {
navigate('next');
}
});
// Initialize
loadFileList();
loadAllGraphs();
</script>
</body>
</html>