diff --git a/webapp/path_navigator.html b/webapp/path_navigator.html
index d2dc2c9..54bf16c 100644
--- a/webapp/path_navigator.html
+++ b/webapp/path_navigator.html
@@ -165,8 +165,12 @@
Path Navigator
+
+
+ or upload:
- (default: output/output_chords.json)
@@ -201,6 +205,9 @@
// Global state
let chords = [];
let currentIndex = 0;
+ let totalSteps = 0;
+ let hasPrev = false;
+ let hasNext = false;
let simulations = {};
// D3 setup - three graphs
@@ -235,8 +242,164 @@
svg.call(zoom);
});
- // Load default file
- async function loadDefaultFile() {
+ // 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");
@@ -270,286 +433,52 @@
updateDisplay();
}
- // Compute edge between two pitches if they differ by ±1 in exactly one non-dim-0 dimension
- function getEdgeInfo(pitch1, pitch2) {
- const hs1 = pitch1.hs_array;
- const hs2 = pitch2.hs_array;
-
- // Check if they differ by ±1 in exactly one dimension (ignoring dim 0)
- let diffCount = 0;
- let diffDim = -1;
-
- for (let i = 1; i < hs1.length; i++) {
- const diff = hs2[i] - hs1[i];
- if (Math.abs(diff) === 1) {
- diffCount++;
- diffDim = i;
- } else if (diff !== 0) {
- return null; // Difference > 1 in this dimension
- }
- }
-
- // Must differ by exactly ±1 in exactly one dimension
- if (diffCount !== 1) return null;
-
- // Calculate frequency ratio from pitch difference (hs1 - hs2)
- // Using the formula from pitch.py:
- // numerator *= dims[i] ** diff[i] for diff[i] >= 0
- // denominator *= dims[i] ** (-diff[i]) for diff[i] < 0
- const dims = [2, 3, 5, 7];
- let numerator = 1;
- let denominator = 1;
-
- for (let i = 0; i < hs1.length; i++) {
- const diff = hs1[i] - hs2[i]; // pitch1 - pitch2
- if (diff > 0) {
- numerator *= Math.pow(dims[i], diff);
- } else if (diff < 0) {
- denominator *= Math.pow(dims[i], -diff);
- }
- }
-
- const ratio = numerator + "/" + denominator;
-
- return {
- dim: diffDim,
- ratio: ratio
- };
- }
-
- function parseFraction(frac) {
- if (typeof frac === 'number') return frac;
- const parts = frac.split('/');
- if (parts.length === 1) return parseFloat(parts[0]);
- return parseInt(parts[0]) / parseInt(parts[1]);
- }
-
- function formatRatio(ratio) {
- // Simplify ratio to simple intervals
- const tolerance = 0.01;
-
- // Common ratios
- const commonRatios = [
- { r: 2, label: "2/1" },
- { r: 3/2, label: "3/2" },
- { r: 4/3, label: "4/3" },
- { r: 5/4, label: "5/4" },
- { r: 6/5, label: "6/5" },
- { r: 5/3, label: "5/3" },
- { r: 8/5, label: "8/5" },
- { r: 7/4, label: "7/4" },
- { r: 7/5, label: "7/5" },
- { r: 7/6, label: "7/6" },
- { r: 9/8, label: "9/8" },
- { r: 10/9, label: "10/9" },
- { r: 16/15, label: "16/15" },
- { r: 15/14, label: "15/14" },
- { r: 9/7, label: "9/7" },
- { r: 10/7, label: "10/7" },
- { r: 1, label: "1/1" },
- ];
-
- // Check both ratio and its inverse
- for (const {r, label} of commonRatios) {
- if (Math.abs(ratio - r) < tolerance || Math.abs(ratio - 1/r) < tolerance) {
- return ratio < 1 ? label : reverseRatio(label);
- }
- }
-
- // Default: show simplified fraction
- return ratio < 1 ? simplifyRatio(ratio) : simplifyRatio(1/ratio);
- }
-
- function reverseRatio(ratio) {
- const parts = ratio.split('/');
- if (parts.length !== 2) return ratio;
- return parts[1] + "/" + parts[0];
- }
-
- function simplifyRatio(ratio) {
- // Simple fraction simplification
- for (let d = 2; d <= 16; d++) {
- for (let n = 1; n < d; n++) {
- if (Math.abs(ratio - n/d) < 0.01) {
- return n + "/" + d;
- }
- }
- }
- return ratio.toFixed(2);
- }
-
- function buildGraph(chord, w, h) {
- // Calculate cents for each pitch
- const centsData = chord.map((pitch, i) => {
- const fr = parseFraction(pitch.fraction);
- const cents = 1200 * Math.log2(fr);
- return { i, cents };
- });
-
- // Find cents for node 0 (voice 0) - use as bottom reference
- const node0Cents = centsData.find(d => d.i === 0)?.cents || 0;
- const maxCents = Math.max(...centsData.map(d => d.cents));
- const range = maxCents - node0Cents || 1;
-
- // Create nodes with y position: node 0 at bottom, others relative to it
- const nodes = chord.map((pitch, i) => {
- const fr = parseFraction(pitch.fraction);
- const cents = 1200 * Math.log2(fr);
- // Map to screen: top = high cents, bottom = node 0
- const y = h * 0.1 + (h * 0.8) * (1 - (cents - node0Cents) / range);
- // Initial x position: spread horizontally
- const x = w * 0.2 + (w * 0.6) * (i / (chord.length - 1 || 1));
- return {
- id: i,
- pitch: pitch,
- label: String(i),
- cents: cents.toFixed(0),
- hs: pitch.hs_array,
- x: x, // Initial x
- y: y // Initial y (will be fixed by forceY)
- };
- });
-
- const links = [];
-
- for (let i = 0; i < nodes.length; i++) {
- for (let j = i + 1; j < nodes.length; j++) {
- const edgeInfo = getEdgeInfo(nodes[i].pitch, nodes[j].pitch);
- if (edgeInfo) {
- links.push({
- source: i,
- target: j,
- label: edgeInfo.ratio
- });
- }
- }
- }
-
- return { nodes, links };
- }
-
- function renderSingleGraph(chord, group) {
- const { nodes, links } = buildGraph(chord, graphWidth, graphHeight);
-
- // 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;
- }
-
- // Force simulation with fixed y positions
- const simulation = d3.forceSimulation(nodes)
- .force("link", d3.forceLink(links).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));
-
- simulations[group.attr("id")] = simulation;
-
- // Draw links
- const link = group.selectAll(".link")
- .data(links)
- .enter()
- .append("line")
- .attr("class", "link");
-
- // Draw link labels
- const linkLabel = group.selectAll(".link-label")
- .data(links)
- .enter()
- .append("text")
- .attr("class", "link-label")
- .attr("text-anchor", "middle")
- .style("font-size", "10px")
- .text(d => d.label);
-
- // Draw nodes
- const node = group.selectAll(".node")
- .data(nodes)
- .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})`);
- });
- }
-
- function renderAllGraphs(prevChord, currentChord, nextChord) {
- renderSingleGraph(prevChord, groups.prev);
- renderSingleGraph(currentChord, groups.current);
- renderSingleGraph(nextChord, groups.next);
- }
-
- function updateChordPanel(elementId, chord) {
+ function updateChordPanel(elementId, data) {
const ul = document.getElementById(elementId);
ul.innerHTML = "";
- if (!chord || chord.length === 0) {
+ if (!data || (Array.isArray(data) && data.length === 0)) {
ul.innerHTML = "
(none)";
return;
}
- chord.forEach(pitch => {
+ // 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");
- li.textContent = `${pitch.fraction} (${pitch.hs_array.join(", ")})`;
+ const fraction = item.fraction || item.fraction;
+ const hs_array = item.hs_array || [];
+ li.textContent = `${fraction} (${hs_array.join(", ")})`;
ul.appendChild(li);
});
}
- function updateDisplay(animateDirection = null) {
- document.getElementById("currentIndex").textContent = currentIndex;
- document.getElementById("totalSteps").textContent = chords.length - 1;
-
- document.getElementById("prevBtn").disabled = currentIndex === 0;
- document.getElementById("nextBtn").disabled = currentIndex >= chords.length - 1;
-
- // Update chord panels
- const prevChord = currentIndex > 0 ? chords[currentIndex - 1] : null;
- const currentChord = chords[currentIndex];
- const nextChord = currentIndex < chords.length - 1 ? chords[currentIndex + 1] : null;
-
- updateChordPanel("prevPitches", prevChord);
- updateChordPanel("currentPitches", currentChord);
- updateChordPanel("nextPitches", nextChord);
-
- // Render all three graphs
- renderAllGraphs(prevChord, currentChord, nextChord);
+ // Update display - now handled by loadFromAPI
+ function updateDisplay() {
+ loadFromAPI();
}
// Navigation with animation
- function navigate(direction) {
- const oldIndex = currentIndex;
+ 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) {
@@ -597,7 +526,8 @@
});
// Initialize
- loadDefaultFile();
+ loadFileList();
+ loadFromAPI();