diff --git a/webapp/path_navigator.html b/webapp/path_navigator.html
index 4a6ac39..d2dc2c9 100644
--- a/webapp/path_navigator.html
+++ b/webapp/path_navigator.html
@@ -56,12 +56,25 @@
font-weight: bold;
}
- #graph-container {
- width: 100%;
- height: 500px;
+ #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 {
@@ -162,7 +175,11 @@
-
@@ -184,26 +201,39 @@
// Global state
let chords = [];
let currentIndex = 0;
- let simulation = null;
+ let simulations = {};
- // D3 setup
- const width = 1100;
- const height = 500;
+ // D3 setup - three graphs
+ const graphWidth = 360;
+ const graphHeight = 400;
- const svg = d3.select("#graph-container")
- .append("svg")
- .attr("viewBox", `0 0 ${width} ${height}`);
+ // 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 g = svg.append("g");
+ const groups = {
+ prev: svgs.prev.append("g"),
+ current: svgs.current.append("g"),
+ next: svgs.next.append("g")
+ };
- // Zoom behavior
- const zoom = d3.zoom()
- .scaleExtent([0.5, 4])
- .on("zoom", (event) => {
- g.attr("transform", event.transform);
- });
-
- svg.call(zoom);
+ // 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 default file
async function loadDefaultFile() {
@@ -348,7 +378,7 @@
return ratio.toFixed(2);
}
- function buildGraph(chord) {
+ function buildGraph(chord, w, h) {
// Calculate cents for each pitch
const centsData = chord.map((pitch, i) => {
const fr = parseFraction(pitch.fraction);
@@ -366,9 +396,9 @@
const fr = parseFraction(pitch.fraction);
const cents = 1200 * Math.log2(fr);
// Map to screen: top = high cents, bottom = node 0
- const y = height * 0.1 + (height * 0.8) * (1 - (cents - node0Cents) / range);
+ const y = h * 0.1 + (h * 0.8) * (1 - (cents - node0Cents) / range);
// Initial x position: spread horizontally
- const x = width * 0.2 + (width * 0.6) * (i / (chord.length - 1 || 1));
+ const x = w * 0.2 + (w * 0.6) * (i / (chord.length - 1 || 1));
return {
id: i,
pitch: pitch,
@@ -398,63 +428,64 @@
return { nodes, links };
}
- function renderGraph(chord) {
- const { nodes, links } = buildGraph(chord);
+ function renderSingleGraph(chord, group) {
+ const { nodes, links } = buildGraph(chord, graphWidth, graphHeight);
// Clear previous
- g.selectAll("*").remove();
+ group.selectAll("*").remove();
if (nodes.length === 0) {
- g.append("text")
- .attr("x", width / 2)
- .attr("y", height / 2)
+ group.append("text")
+ .attr("x", graphWidth / 2)
+ .attr("y", graphHeight / 2)
.attr("text-anchor", "middle")
- .attr("fill", "#888")
- .text("No chord data");
+ .attr("fill", "#666")
+ .style("font-size", "14px")
+ .text("(empty)");
return;
}
// Force simulation with fixed y positions
- simulation = d3.forceSimulation(nodes)
- .force("link", d3.forceLink(links).id(d => d.id).distance(100))
- .force("charge", d3.forceManyBody().strength(-300))
- .force("y", d3.forceY(d => d.y).strength(1)) // Fix y position
- .force("collision", d3.forceCollide().radius(40));
+ 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 = g.selectAll(".link")
+ const link = group.selectAll(".link")
.data(links)
.enter()
.append("line")
.attr("class", "link");
// Draw link labels
- const linkLabel = g.selectAll(".link-label")
+ 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 = g.selectAll(".node")
+ const node = group.selectAll(".node")
.data(nodes)
.enter()
.append("g")
- .attr("class", "node")
- .call(d3.drag()
- .on("start", dragstarted)
- .on("drag", dragged)
- .on("end", dragended));
+ .attr("class", "node");
node.append("circle")
- .attr("r", 20)
+ .attr("r", 15)
.attr("fill", (d, i) => d3.schemeCategory10[i % 10]);
node.append("text")
.attr("class", "node-label")
- .attr("dy", 4)
+ .attr("dy", 3)
+ .style("font-size", "9px")
.text(d => d.cents);
// Update positions on tick
@@ -467,27 +498,16 @@
linkLabel
.attr("x", d => (d.source.x + d.target.x) / 2)
- .attr("y", d => (d.source.y + d.target.y) / 2 - 10);
+ .attr("y", d => (d.source.y + d.target.y) / 2 - 8);
node.attr("transform", d => `translate(${d.x},${d.y})`);
});
-
- function dragstarted(event, d) {
- if (!event.active) simulation.alphaTarget(0.3).restart();
- d.fx = d.x;
- d.fy = d.y;
- }
-
- function dragged(event, d) {
- d.fx = event.x;
- d.fy = event.y;
- }
-
- function dragended(event, d) {
- if (!event.active) simulation.alphaTarget(0);
- d.fx = null;
- d.fy = null;
- }
+ }
+
+ function renderAllGraphs(prevChord, currentChord, nextChord) {
+ renderSingleGraph(prevChord, groups.prev);
+ renderSingleGraph(currentChord, groups.current);
+ renderSingleGraph(nextChord, groups.next);
}
function updateChordPanel(elementId, chord) {
@@ -506,7 +526,7 @@
});
}
- function updateDisplay() {
+ function updateDisplay(animateDirection = null) {
document.getElementById("currentIndex").textContent = currentIndex;
document.getElementById("totalSteps").textContent = chords.length - 1;
@@ -522,37 +542,57 @@
updateChordPanel("currentPitches", currentChord);
updateChordPanel("nextPitches", nextChord);
- // Render graph
- renderGraph(currentChord);
+ // Render all three graphs
+ renderAllGraphs(prevChord, currentChord, nextChord);
+ }
+
+ // Navigation with animation
+ function navigate(direction) {
+ const oldIndex = currentIndex;
+
+ 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", () => {
- if (currentIndex > 0) {
- currentIndex--;
- updateDisplay();
- }
+ navigate('prev');
});
document.getElementById("nextBtn").addEventListener("click", () => {
- if (currentIndex < chords.length - 1) {
- currentIndex++;
- updateDisplay();
- }
+ navigate('next');
});
// Keyboard navigation
document.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft") {
- if (currentIndex > 0) {
- currentIndex--;
- updateDisplay();
- }
+ navigate('prev');
} else if (e.key === "ArrowRight") {
- if (currentIndex < chords.length - 1) {
- currentIndex++;
- updateDisplay();
- }
+ navigate('next');
}
});