Add filepath input for loading any JSON file via API

This commit is contained in:
Michael Winter 2026-04-01 16:33:13 +02:00
parent a93ade34b4
commit 79e1259f5b
2 changed files with 67 additions and 189 deletions

View file

@ -271,8 +271,9 @@
<h1>Path Navigator</h1>
<div class="file-input">
<span>Upload file:</span>
<input type="file" id="fileInput" accept=".json">
<span>File:</span>
<input type="text" id="filepathInput" value="output/output_chords.json" style="width: 250px;">
<button id="loadFileBtn">Load</button>
<span style="margin-left: 20px;">Fundamental (Hz):</span>
<input type="number" id="fundamentalInput" value="110" style="width: 60px;">
</div>
@ -316,7 +317,7 @@
// Graph dimensions - will expand based on number of chords
let graphWidth = 1100;
const graphHeight = 450;
const chordSpacing = 350;
let chordSpacing = 350;
// Voice colors - sleek pastel scheme
const voiceColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
@ -374,14 +375,15 @@
layout: {
name: 'preset',
},
minZoom: 1,
maxZoom: 1,
zoomingEnabled: false,
panningEnabled: false,
minZoom: 0.3,
maxZoom: 3,
zoomingEnabled: true,
panningEnabled: true,
wheelSensitivity: 0.2, // Reduce wheel zoom sensitivity
autounselectify: true,
boxSelectionEnabled: false,
});
});
// Lock y on drag - only allow x movement
let isDragging = false;
let grabPosition = null;
@ -450,102 +452,6 @@
});
}
// 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;
@ -717,57 +623,6 @@
}
}
// 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();
}
}
async function setFundamental() {
const input = document.getElementById("fundamentalInput");
const fundamental = parseFloat(input.value);
if (!fundamental || fundamental <= 0) {
return;
}
try {
const response = await fetch("/api/set-fundamental", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ fundamental: fundamental })
});
const data = await response.json();
console.log("Fundamental set to:", data.fundamental, "Hz");
} catch (e) {
console.log("Error setting fundamental", e);
}
}
// Fundamental input - auto-send on Enter, Up/Down arrows, or input change
document.getElementById("fundamentalInput").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
@ -791,39 +646,34 @@
setFundamental();
});
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;
document.getElementById("loadFileBtn").addEventListener("click", async () => {
const filepath = document.getElementById("filepathInput").value;
if (!filepath) 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");
try {
const response = await fetch("/api/load-file", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ filepath: filepath })
});
const data = await response.json();
if (data.error) {
alert(data.error);
} else {
loadAllGraphs();
}
};
reader.readAsText(file);
} catch (err) {
alert("Error loading file: " + err);
}
});
function loadChords(chordData) {
chords = chordData;
currentIndex = 0;
updateDisplay();
}
// Also allow Enter key in filepath input
document.getElementById("filepathInput").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
document.getElementById("loadFileBtn").click();
}
});
function updateChordPanel(elementId, data) {
const container = document.getElementById(elementId);
@ -894,11 +744,6 @@
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) {
@ -931,6 +776,18 @@
navigate('prev');
} else if (e.key === "ArrowRight") {
navigate('next');
} else if (e.key === "+" || e.key === "=") {
// Zoom in
if (cy) {
const zoom = cy.zoom();
cy.zoom({ zoomLevel: Math.min(3, zoom * 1.1), renderedPosition: { x: cy.width()/2, y: cy.height()/2 } });
}
} else if (e.key === "-") {
// Zoom out
if (cy) {
const zoom = cy.zoom();
cy.zoom({ zoomLevel: Math.max(0.3, zoom / 1.1), renderedPosition: { x: cy.width()/2, y: cy.height()/2 } });
}
} else if (e.key === "k") {
// Soft kill - send 20 Hz to stop voices gently
fetch('/api/kill-siren', {

View file

@ -385,6 +385,27 @@ def kill_siren():
)
@app.route("/api/load-file", methods=["POST"])
def load_file():
global current_index, chords
data = request.json
filepath = data.get("filepath")
if not filepath:
return jsonify({"error": "No filepath provided"}), 400
try:
with open(filepath) as f:
chords_data = json.load(f)
chords = chords_data.get("chords", [])
current_index = 0
return jsonify({"loaded": len(chords), "filepath": filepath})
except FileNotFoundError:
return jsonify({"error": f"File not found: {filepath}"}), 404
except json.JSONDecodeError:
return jsonify({"error": f"Invalid JSON in: {filepath}"}), 400
if __name__ == "__main__":
print("Starting Path Navigator server...")
print(f"Loading chords from: {get_chords_file()}")