Add prev/current/next graph panels to Path Navigator
This commit is contained in:
parent
6924c85d19
commit
2a027ba552
|
|
@ -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
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue