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;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
#graph-container {
|
#graphs-container {
|
||||||
width: 100%;
|
display: flex;
|
||||||
height: 500px;
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-panel {
|
||||||
|
flex: 1;
|
||||||
|
height: 400px;
|
||||||
border: 1px solid #0f3460;
|
border: 1px solid #0f3460;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #16213e;
|
background: #16213e;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-panel svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chord-info {
|
.chord-info {
|
||||||
|
|
@ -162,7 +175,11 @@
|
||||||
<button id="nextBtn" disabled>Next →</button>
|
<button id="nextBtn" disabled>Next →</button>
|
||||||
</div>
|
</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-info">
|
||||||
<div class="chord-panel prev">
|
<div class="chord-panel prev">
|
||||||
|
|
@ -184,26 +201,39 @@
|
||||||
// Global state
|
// Global state
|
||||||
let chords = [];
|
let chords = [];
|
||||||
let currentIndex = 0;
|
let currentIndex = 0;
|
||||||
let simulation = null;
|
let simulations = {};
|
||||||
|
|
||||||
// D3 setup
|
// D3 setup - three graphs
|
||||||
const width = 1100;
|
const graphWidth = 360;
|
||||||
const height = 500;
|
const graphHeight = 400;
|
||||||
|
|
||||||
const svg = d3.select("#graph-container")
|
// Create three SVGs
|
||||||
.append("svg")
|
const svgs = {
|
||||||
.attr("viewBox", `0 0 ${width} ${height}`);
|
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()
|
const zoom = d3.zoom()
|
||||||
.scaleExtent([0.5, 4])
|
.scaleExtent([0.5, 4])
|
||||||
.on("zoom", (event) => {
|
.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);
|
svg.call(zoom);
|
||||||
|
});
|
||||||
|
|
||||||
// Load default file
|
// Load default file
|
||||||
async function loadDefaultFile() {
|
async function loadDefaultFile() {
|
||||||
|
|
@ -348,7 +378,7 @@
|
||||||
return ratio.toFixed(2);
|
return ratio.toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGraph(chord) {
|
function buildGraph(chord, w, h) {
|
||||||
// Calculate cents for each pitch
|
// Calculate cents for each pitch
|
||||||
const centsData = chord.map((pitch, i) => {
|
const centsData = chord.map((pitch, i) => {
|
||||||
const fr = parseFraction(pitch.fraction);
|
const fr = parseFraction(pitch.fraction);
|
||||||
|
|
@ -366,9 +396,9 @@
|
||||||
const fr = parseFraction(pitch.fraction);
|
const fr = parseFraction(pitch.fraction);
|
||||||
const cents = 1200 * Math.log2(fr);
|
const cents = 1200 * Math.log2(fr);
|
||||||
// Map to screen: top = high cents, bottom = node 0
|
// 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
|
// 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 {
|
return {
|
||||||
id: i,
|
id: i,
|
||||||
pitch: pitch,
|
pitch: pitch,
|
||||||
|
|
@ -398,63 +428,64 @@
|
||||||
return { nodes, links };
|
return { nodes, links };
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGraph(chord) {
|
function renderSingleGraph(chord, group) {
|
||||||
const { nodes, links } = buildGraph(chord);
|
const { nodes, links } = buildGraph(chord, graphWidth, graphHeight);
|
||||||
|
|
||||||
// Clear previous
|
// Clear previous
|
||||||
g.selectAll("*").remove();
|
group.selectAll("*").remove();
|
||||||
|
|
||||||
if (nodes.length === 0) {
|
if (nodes.length === 0) {
|
||||||
g.append("text")
|
group.append("text")
|
||||||
.attr("x", width / 2)
|
.attr("x", graphWidth / 2)
|
||||||
.attr("y", height / 2)
|
.attr("y", graphHeight / 2)
|
||||||
.attr("text-anchor", "middle")
|
.attr("text-anchor", "middle")
|
||||||
.attr("fill", "#888")
|
.attr("fill", "#666")
|
||||||
.text("No chord data");
|
.style("font-size", "14px")
|
||||||
|
.text("(empty)");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force simulation with fixed y positions
|
// Force simulation with fixed y positions
|
||||||
simulation = d3.forceSimulation(nodes)
|
const simulation = d3.forceSimulation(nodes)
|
||||||
.force("link", d3.forceLink(links).id(d => d.id).distance(100))
|
.force("link", d3.forceLink(links).id(d => d.id).distance(60))
|
||||||
.force("charge", d3.forceManyBody().strength(-300))
|
.force("charge", d3.forceManyBody().strength(-200))
|
||||||
.force("y", d3.forceY(d => d.y).strength(1)) // Fix y position
|
.force("y", d3.forceY(d => d.y).strength(1))
|
||||||
.force("collision", d3.forceCollide().radius(40));
|
.force("collision", d3.forceCollide().radius(25));
|
||||||
|
|
||||||
|
simulations[group.attr("id")] = simulation;
|
||||||
|
|
||||||
// Draw links
|
// Draw links
|
||||||
const link = g.selectAll(".link")
|
const link = group.selectAll(".link")
|
||||||
.data(links)
|
.data(links)
|
||||||
.enter()
|
.enter()
|
||||||
.append("line")
|
.append("line")
|
||||||
.attr("class", "link");
|
.attr("class", "link");
|
||||||
|
|
||||||
// Draw link labels
|
// Draw link labels
|
||||||
const linkLabel = g.selectAll(".link-label")
|
const linkLabel = group.selectAll(".link-label")
|
||||||
.data(links)
|
.data(links)
|
||||||
.enter()
|
.enter()
|
||||||
.append("text")
|
.append("text")
|
||||||
.attr("class", "link-label")
|
.attr("class", "link-label")
|
||||||
.attr("text-anchor", "middle")
|
.attr("text-anchor", "middle")
|
||||||
|
.style("font-size", "10px")
|
||||||
.text(d => d.label);
|
.text(d => d.label);
|
||||||
|
|
||||||
// Draw nodes
|
// Draw nodes
|
||||||
const node = g.selectAll(".node")
|
const node = group.selectAll(".node")
|
||||||
.data(nodes)
|
.data(nodes)
|
||||||
.enter()
|
.enter()
|
||||||
.append("g")
|
.append("g")
|
||||||
.attr("class", "node")
|
.attr("class", "node");
|
||||||
.call(d3.drag()
|
|
||||||
.on("start", dragstarted)
|
|
||||||
.on("drag", dragged)
|
|
||||||
.on("end", dragended));
|
|
||||||
|
|
||||||
node.append("circle")
|
node.append("circle")
|
||||||
.attr("r", 20)
|
.attr("r", 15)
|
||||||
.attr("fill", (d, i) => d3.schemeCategory10[i % 10]);
|
.attr("fill", (d, i) => d3.schemeCategory10[i % 10]);
|
||||||
|
|
||||||
node.append("text")
|
node.append("text")
|
||||||
.attr("class", "node-label")
|
.attr("class", "node-label")
|
||||||
.attr("dy", 4)
|
.attr("dy", 3)
|
||||||
|
.style("font-size", "9px")
|
||||||
.text(d => d.cents);
|
.text(d => d.cents);
|
||||||
|
|
||||||
// Update positions on tick
|
// Update positions on tick
|
||||||
|
|
@ -467,27 +498,16 @@
|
||||||
|
|
||||||
linkLabel
|
linkLabel
|
||||||
.attr("x", d => (d.source.x + d.target.x) / 2)
|
.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})`);
|
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) {
|
function renderAllGraphs(prevChord, currentChord, nextChord) {
|
||||||
d.fx = event.x;
|
renderSingleGraph(prevChord, groups.prev);
|
||||||
d.fy = event.y;
|
renderSingleGraph(currentChord, groups.current);
|
||||||
}
|
renderSingleGraph(nextChord, groups.next);
|
||||||
|
|
||||||
function dragended(event, d) {
|
|
||||||
if (!event.active) simulation.alphaTarget(0);
|
|
||||||
d.fx = null;
|
|
||||||
d.fy = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateChordPanel(elementId, chord) {
|
function updateChordPanel(elementId, chord) {
|
||||||
|
|
@ -506,7 +526,7 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDisplay() {
|
function updateDisplay(animateDirection = null) {
|
||||||
document.getElementById("currentIndex").textContent = currentIndex;
|
document.getElementById("currentIndex").textContent = currentIndex;
|
||||||
document.getElementById("totalSteps").textContent = chords.length - 1;
|
document.getElementById("totalSteps").textContent = chords.length - 1;
|
||||||
|
|
||||||
|
|
@ -522,37 +542,57 @@
|
||||||
updateChordPanel("currentPitches", currentChord);
|
updateChordPanel("currentPitches", currentChord);
|
||||||
updateChordPanel("nextPitches", nextChord);
|
updateChordPanel("nextPitches", nextChord);
|
||||||
|
|
||||||
// Render graph
|
// Render all three graphs
|
||||||
renderGraph(currentChord);
|
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
|
// Navigation
|
||||||
document.getElementById("prevBtn").addEventListener("click", () => {
|
document.getElementById("prevBtn").addEventListener("click", () => {
|
||||||
if (currentIndex > 0) {
|
navigate('prev');
|
||||||
currentIndex--;
|
|
||||||
updateDisplay();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("nextBtn").addEventListener("click", () => {
|
document.getElementById("nextBtn").addEventListener("click", () => {
|
||||||
if (currentIndex < chords.length - 1) {
|
navigate('next');
|
||||||
currentIndex++;
|
|
||||||
updateDisplay();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard navigation
|
// Keyboard navigation
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "ArrowLeft") {
|
if (e.key === "ArrowLeft") {
|
||||||
if (currentIndex > 0) {
|
navigate('prev');
|
||||||
currentIndex--;
|
|
||||||
updateDisplay();
|
|
||||||
}
|
|
||||||
} else if (e.key === "ArrowRight") {
|
} else if (e.key === "ArrowRight") {
|
||||||
if (currentIndex < chords.length - 1) {
|
navigate('next');
|
||||||
currentIndex++;
|
|
||||||
updateDisplay();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue