compact_sets/webapp/path_navigator.html
Michael Winter 88a9528e12 Add Flask server with Python backend for Path Navigator
- Server calculates graph edges and ratios using src/pitch.py
- Frontend fetches computed graph from API
- Supports loading different output files via dropdown
- All pitch calculations now in Python, JS only for rendering
2026-03-30 22:38:52 +02:00

534 lines
17 KiB
HTML

<!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://d3js.org/d3.v7.min.js"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background: #1a1a2e;
color: #eee;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 10px;
}
.controls {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 20px;
align-items: center;
}
.controls button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background: #16213e;
color: #eee;
border: 1px solid #0f3460;
border-radius: 5px;
}
.controls button:hover {
background: #0f3460;
}
.controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.index-display {
font-size: 18px;
font-weight: bold;
}
#graphs-container {
display: flex;
justify-content: space-between;
gap: 10px;
}
.graph-panel {
flex: 1;
height: 400px;
border: 1px solid #0f3460;
border-radius: 8px;
background: #16213e;
position: relative;
overflow: hidden;
}
.graph-panel svg {
width: 100%;
height: 100%;
}
.chord-info {
display: flex;
justify-content: space-around;
margin-top: 20px;
}
.chord-panel {
background: #16213e;
padding: 15px;
border-radius: 8px;
min-width: 250px;
}
.chord-panel h3 {
margin-top: 0;
color: #e94560;
}
.chord-panel.current {
border: 2px solid #e94560;
}
.chord-panel.prev, .chord-panel.next {
opacity: 0.7;
}
.pitch-list {
list-style: none;
padding: 0;
}
.pitch-list li {
padding: 5px 0;
font-family: monospace;
}
.file-input {
margin-bottom: 20px;
text-align: center;
}
.file-input input {
padding: 10px;
background: #16213e;
color: #eee;
border: 1px solid #0f3460;
border-radius: 5px;
}
svg {
width: 100%;
height: 100%;
}
.node circle {
stroke: #fff;
stroke-width: 2px;
}
.node.current circle {
stroke: #e94560;
stroke-width: 3px;
}
.link {
stroke: #4a5568;
stroke-width: 2px;
}
.link-label {
fill: #a0aec0;
font-size: 12px;
font-family: monospace;
}
.node-label {
fill: #eee;
font-size: 11px;
font-family: monospace;
text-anchor: middle;
}
</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="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 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 simulations = {};
// D3 setup - three graphs
const graphWidth = 360;
const graphHeight = 400;
// 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}`)
};
const groups = {
prev: svgs.prev.append("g"),
current: svgs.current.append("g"),
next: svgs.next.append("g")
};
// 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);
});
svg.call(zoom);
});
// Load from Flask API - get computed graph from Python
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 graphs from API data
renderGraphFromData(data.prev, groups.prev);
renderGraphFromData(data.current, groups.current);
renderGraphFromData(data.next, groups.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();
}
}
// 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() {
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 ul = document.getElementById(elementId);
ul.innerHTML = "";
if (!data || (Array.isArray(data) && data.length === 0)) {
ul.innerHTML = "<li>(none)</li>";
return;
}
// Handle both formats: raw chord array or nodes array from graph API
const items = Array.isArray(data) ? data : (data.nodes || []);
items.forEach(item => {
const li = document.createElement("li");
const fraction = item.fraction || item.fraction;
const hs_array = item.hs_array || [];
li.textContent = `${fraction} (${hs_array.join(", ")})`;
ul.appendChild(li);
});
}
// Update display - now handled by loadFromAPI
function updateDisplay() {
loadFromAPI();
}
// Navigation with animation
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) {
currentIndex--;
} else if (direction === 'next' && currentIndex < chords.length - 1) {
currentIndex++;
} else {
return;
}
// Animate the graph panels
const prevPanel = document.getElementById('graph-prev');
const currentPanel = document.getElementById('graph-current');
const nextPanel = document.getElementById('graph-next');
// Simple fade transition
const panels = [prevPanel, currentPanel, nextPanel];
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
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();
loadFromAPI();
</script>
</body>
</html>