Add prev/current/next graph panels to Path Navigator

This commit is contained in:
Michael Winter 2026-03-30 22:17:01 +02:00
parent 6924c85d19
commit 2a027ba552

View file

@ -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 @@
<button id="nextBtn" disabled>Next →</button>
</div>
<div id="graph-container"></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">
@ -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
// Zoom behavior for each
Object.values(svgs).forEach(svg => {
const zoom = d3.zoom()
.scaleExtent([0.5, 4])
.on("zoom", (event) => {
g.attr("transform", event.transform);
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');
}
});