2026-03-30 21:06:14 +02:00
|
|
|
<!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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:17:01 +02:00
|
|
|
#graphs-container {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.graph-panel {
|
|
|
|
|
flex: 1;
|
|
|
|
|
height: 400px;
|
2026-03-30 21:06:14 +02:00
|
|
|
border: 1px solid #0f3460;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
background: #16213e;
|
2026-03-30 22:17:01 +02:00
|
|
|
position: relative;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.graph-panel svg {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 100%;
|
2026-03-30 21:06:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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">
|
2026-03-30 22:38:52 +02:00
|
|
|
<select id="fileSelect">
|
|
|
|
|
<option value="">Loading files...</option>
|
|
|
|
|
</select>
|
|
|
|
|
<button onclick="loadSelectedFile()">Load</button>
|
|
|
|
|
<span style="margin-left: 10px;">or upload:</span>
|
2026-03-30 21:06:14 +02:00
|
|
|
<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>
|
|
|
|
|
|
2026-03-30 22:17:01 +02:00
|
|
|
<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>
|
2026-03-30 21:06:14 +02:00
|
|
|
|
|
|
|
|
<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;
|
2026-03-30 22:38:52 +02:00
|
|
|
let totalSteps = 0;
|
|
|
|
|
let hasPrev = false;
|
|
|
|
|
let hasNext = false;
|
2026-03-30 22:17:01 +02:00
|
|
|
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);
|
|
|
|
|
});
|
2026-03-30 21:06:14 +02:00
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
// Load from Flask API - get computed graph from Python
|
|
|
|
|
async function loadFromAPI() {
|
2026-03-30 21:06:14 +02:00
|
|
|
try {
|
2026-03-30 22:38:52 +02:00
|
|
|
const response = await fetch(`/api/graph/${currentIndex}`);
|
|
|
|
|
if (!response.ok) throw new Error("API not available");
|
2026-03-30 21:06:14 +02:00
|
|
|
const data = await response.json();
|
2026-03-30 22:38:52 +02:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-03-30 21:06:14 +02:00
|
|
|
} catch (e) {
|
2026-03-30 22:38:52 +02:00
|
|
|
console.log("API not available, trying local file", e);
|
|
|
|
|
loadFromLocalFile();
|
2026-03-30 21:06:14 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
// Render graph from API data (nodes + edges already calculated by Python)
|
|
|
|
|
function renderGraphFromData(graphData, group) {
|
|
|
|
|
const { nodes, edges } = graphData || { nodes: [], edges: [] };
|
2026-03-30 21:06:14 +02:00
|
|
|
|
|
|
|
|
// Clear previous
|
2026-03-30 22:17:01 +02:00
|
|
|
group.selectAll("*").remove();
|
2026-03-30 21:06:14 +02:00
|
|
|
|
|
|
|
|
if (nodes.length === 0) {
|
2026-03-30 22:17:01 +02:00
|
|
|
group.append("text")
|
|
|
|
|
.attr("x", graphWidth / 2)
|
|
|
|
|
.attr("y", graphHeight / 2)
|
2026-03-30 21:06:14 +02:00
|
|
|
.attr("text-anchor", "middle")
|
2026-03-30 22:17:01 +02:00
|
|
|
.attr("fill", "#666")
|
|
|
|
|
.style("font-size", "14px")
|
|
|
|
|
.text("(empty)");
|
2026-03-30 21:06:14 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
// 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)
|
|
|
|
|
}));
|
|
|
|
|
|
2026-03-30 22:09:07 +02:00
|
|
|
// Force simulation with fixed y positions
|
2026-03-30 22:38:52 +02:00
|
|
|
const simulation = d3.forceSimulation(nodesWithPos)
|
|
|
|
|
.force("link", d3.forceLink(edges).id(d => d.id).distance(60))
|
2026-03-30 22:17:01 +02:00
|
|
|
.force("charge", d3.forceManyBody().strength(-200))
|
|
|
|
|
.force("y", d3.forceY(d => d.y).strength(1))
|
|
|
|
|
.force("collision", d3.forceCollide().radius(25));
|
|
|
|
|
|
2026-03-30 21:06:14 +02:00
|
|
|
// Draw links
|
2026-03-30 22:17:01 +02:00
|
|
|
const link = group.selectAll(".link")
|
2026-03-30 22:38:52 +02:00
|
|
|
.data(edges)
|
2026-03-30 21:06:14 +02:00
|
|
|
.enter()
|
|
|
|
|
.append("line")
|
|
|
|
|
.attr("class", "link");
|
|
|
|
|
|
|
|
|
|
// Draw link labels
|
2026-03-30 22:17:01 +02:00
|
|
|
const linkLabel = group.selectAll(".link-label")
|
2026-03-30 22:38:52 +02:00
|
|
|
.data(edges)
|
2026-03-30 21:06:14 +02:00
|
|
|
.enter()
|
|
|
|
|
.append("text")
|
|
|
|
|
.attr("class", "link-label")
|
|
|
|
|
.attr("text-anchor", "middle")
|
2026-03-30 22:17:01 +02:00
|
|
|
.style("font-size", "10px")
|
2026-03-30 22:38:52 +02:00
|
|
|
.text(d => d.ratio);
|
2026-03-30 21:06:14 +02:00
|
|
|
|
|
|
|
|
// Draw nodes
|
2026-03-30 22:17:01 +02:00
|
|
|
const node = group.selectAll(".node")
|
2026-03-30 22:38:52 +02:00
|
|
|
.data(nodesWithPos)
|
2026-03-30 21:06:14 +02:00
|
|
|
.enter()
|
|
|
|
|
.append("g")
|
2026-03-30 22:17:01 +02:00
|
|
|
.attr("class", "node");
|
2026-03-30 21:06:14 +02:00
|
|
|
|
|
|
|
|
node.append("circle")
|
2026-03-30 22:17:01 +02:00
|
|
|
.attr("r", 15)
|
2026-03-30 21:06:14 +02:00
|
|
|
.attr("fill", (d, i) => d3.schemeCategory10[i % 10]);
|
|
|
|
|
|
|
|
|
|
node.append("text")
|
|
|
|
|
.attr("class", "node-label")
|
2026-03-30 22:17:01 +02:00
|
|
|
.attr("dy", 3)
|
|
|
|
|
.style("font-size", "9px")
|
2026-03-30 22:09:07 +02:00
|
|
|
.text(d => d.cents);
|
2026-03-30 21:06:14 +02:00
|
|
|
|
|
|
|
|
// 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)
|
2026-03-30 22:17:01 +02:00
|
|
|
.attr("y", d => (d.source.y + d.target.y) / 2 - 8);
|
2026-03-30 21:06:14 +02:00
|
|
|
|
|
|
|
|
node.attr("transform", d => `translate(${d.x},${d.y})`);
|
|
|
|
|
});
|
2026-03-30 22:17:01 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
// 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);
|
|
|
|
|
}
|
2026-03-30 21:06:14 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
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) {
|
2026-03-30 21:06:14 +02:00
|
|
|
const ul = document.getElementById(elementId);
|
|
|
|
|
ul.innerHTML = "";
|
|
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
if (!data || (Array.isArray(data) && data.length === 0)) {
|
2026-03-30 21:06:14 +02:00
|
|
|
ul.innerHTML = "<li>(none)</li>";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
// Handle both formats: raw chord array or nodes array from graph API
|
|
|
|
|
const items = Array.isArray(data) ? data : (data.nodes || []);
|
|
|
|
|
|
|
|
|
|
items.forEach(item => {
|
2026-03-30 21:06:14 +02:00
|
|
|
const li = document.createElement("li");
|
2026-03-30 22:38:52 +02:00
|
|
|
const fraction = item.fraction || item.fraction;
|
|
|
|
|
const hs_array = item.hs_array || [];
|
|
|
|
|
li.textContent = `${fraction} (${hs_array.join(", ")})`;
|
2026-03-30 21:06:14 +02:00
|
|
|
ul.appendChild(li);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
// Update display - now handled by loadFromAPI
|
|
|
|
|
function updateDisplay() {
|
|
|
|
|
loadFromAPI();
|
2026-03-30 21:06:14 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-30 22:17:01 +02:00
|
|
|
// Navigation with animation
|
2026-03-30 22:38:52 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-30 22:17:01 +02:00
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
// Fallback to local navigation
|
2026-03-30 22:17:01 +02:00
|
|
|
if (direction === 'prev' && currentIndex > 0) {
|
2026-03-30 21:06:14 +02:00
|
|
|
currentIndex--;
|
2026-03-30 22:17:01 +02:00
|
|
|
} else if (direction === 'next' && currentIndex < chords.length - 1) {
|
|
|
|
|
currentIndex++;
|
|
|
|
|
} else {
|
|
|
|
|
return;
|
2026-03-30 21:06:14 +02:00
|
|
|
}
|
2026-03-30 22:17:01 +02:00
|
|
|
|
|
|
|
|
// 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');
|
2026-03-30 21:06:14 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById("nextBtn").addEventListener("click", () => {
|
2026-03-30 22:17:01 +02:00
|
|
|
navigate('next');
|
2026-03-30 21:06:14 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Keyboard navigation
|
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
|
|
|
if (e.key === "ArrowLeft") {
|
2026-03-30 22:17:01 +02:00
|
|
|
navigate('prev');
|
2026-03-30 21:06:14 +02:00
|
|
|
} else if (e.key === "ArrowRight") {
|
2026-03-30 22:17:01 +02:00
|
|
|
navigate('next');
|
2026-03-30 21:06:14 +02:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Initialize
|
2026-03-30 22:38:52 +02:00
|
|
|
loadFileList();
|
|
|
|
|
loadFromAPI();
|
2026-03-30 21:06:14 +02:00
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|