compact_sets/webapp/path_navigator.html

1622 lines
64 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Path Navigator</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.28.1/cytoscape.min.js"></script>
<script>
// Register minimal "xforce" layout - force-directed in x only, y stays fixed, with bounds
(function(){
const XForceLayout = function(options){
this.options = Object.assign({
name: 'xforce',
nodes: undefined,
edges: undefined,
linkDistance: 80,
linkStrength: 0.1,
charge: -30,
collisionDistance: 20,
damping: 0.6,
iterations: 300,
stepDelay: 0,
// bounds for each chord: { prev: {min, max}, current: {min, max}, next: {min, max} }
bounds: {},
}, options);
this.cy = options.cy;
};
XForceLayout.prototype.run = function(){
const cy = this.cy;
const opts = this.options;
const nodes = (opts.nodes || cy.nodes());
const edges = (opts.edges || cy.edges());
if(!nodes.length){ this.trigger('stop'); return; }
const state = new Map();
nodes.forEach(n => {
if (n.data('isLabel')) return; // Skip label nodes
const chordLabel = n.data('chordLabel');
const bounds = opts.bounds[chordLabel] || { min: 0, max: graphWidth };
state.set(n.id(), {
x: n.position('x'),
y: n.position('y'),
vx: 0,
fx: 0,
bounds: bounds,
});
});
const edgeList = edges.map(e => ({
s: e.source().id(),
t: e.target().id(),
isCrossChord: e.data('isCrossChord')
}));
const iter = (i) => {
state.forEach(s => s.fx = 0);
edgeList.forEach(({s, t, isCrossChord})=>{
const a = state.get(s), b = state.get(t);
if(!a || !b) return;
const dx = b.x - a.x;
const dist = Math.abs(dx) || 0.0001;
const dir = dx / dist;
const strength = isCrossChord ? (opts.crossChordStrength || 0.001) : opts.linkStrength;
const spring = strength * (dist - opts.linkDistance);
a.fx += spring * dir;
b.fx -= spring * dir;
});
const arr = Array.from(state.values());
for(let u=0; u<arr.length; u++){
for(let v=u+1; v<arr.length; v++){
const A = arr[u], B = arr[v];
const dx = B.x - A.x;
let dist = Math.abs(dx) || 0.0001;
const dir = dx / dist;
const force = opts.charge / (dist*dist);
A.fx -= force * dir;
B.fx += force * dir;
}
}
// Boundary forces - push nodes back into their thirds
const ids = Array.from(state.keys());
for(let i1=0;i1<ids.length;i1++){
const idA = ids[i1];
const A = state.get(idA);
const b = A.bounds;
const margin = 30;
// Strong boundary force
if(A.x < b.min + margin){
A.fx += (b.min + margin - A.x) * 0.8;
}
if(A.x > b.max - margin){
A.fx -= (A.x - (b.max - margin)) * 0.8;
}
}
state.forEach(s => {
s.vx = (s.vx + s.fx) * opts.damping;
s.x += s.vx;
// HARD boundary clamp - absolute enforcement
s.x = Math.max(s.bounds.min, Math.min(s.bounds.max, s.x));
});
cy.batch(()=>{
state.forEach((s, id) => {
const n = cy.getElementById(id);
if(n) n.position({ x: s.x, y: s.y });
});
});
if(i < opts.iterations - 1){
if(opts.stepDelay > 0){
setTimeout(()=> iter(i+1), opts.stepDelay);
} else {
iter(i+1);
}
} else {
this.trigger('stop');
}
};
iter(0);
};
XForceLayout.prototype.stop = function(){};
XForceLayout.prototype.on = function(){};
XForceLayout.prototype.once = function(){};
XForceLayout.prototype.trigger = function(name){};
cytoscape('layout', 'xforce', XForceLayout);
})();
</script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background: #000000;
color: #cccccc;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 10px;
font-weight: 300;
letter-spacing: 2px;
color: #ffffff;
}
.controls {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 20px;
align-items: center;
}
.controls button {
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
background: #0a0a0a;
color: #888888;
border: 1px solid #222222;
border-radius: 4px;
transition: all 0.2s ease;
}
.controls button:hover {
background: #151515;
color: #ffffff;
border-color: #444444;
}
.controls button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.index-display {
font-size: 14px;
color: #666666;
letter-spacing: 1px;
}
#graph-container {
width: 100%;
height: 450px;
border: 1px solid #1a1a1a;
border-radius: 4px;
background: #050505;
position: relative;
}
.chord-info {
display: flex;
justify-content: space-around;
margin-top: 20px;
}
.chord-panel {
background: #0a0a0a;
padding: 15px;
border-radius: 4px;
min-width: 200px;
border: 1px solid #1a1a1a;
}
.chord-panel h3 {
margin-top: 0;
color: #666666;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 500;
}
.chord-panel.current {
border: 1px solid #333333;
}
.chord-panel.current h3 {
color: #00d4ff;
}
.chord-panel.prev, .chord-panel.next {
opacity: 0.6;
}
.pitch-list {
list-style: none;
padding: 0;
font-size: 12px;
}
.pitch-list li {
padding: 4px 0;
color: #888888;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
}
.file-input {
margin-bottom: 20px;
text-align: center;
}
.file-input input {
padding: 8px;
background: #0a0a0a;
color: #888888;
border: 1px solid #222222;
border-radius: 4px;
}
/* Hide default number input spinners */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
.file-select {
padding: 8px;
background: #0a0a0a;
color: #888888;
border: 1px solid #222222;
border-radius: 4px;
margin-left: 10px;
}
/* Number input with inline +/- buttons */
.number-input-group {
display: inline-flex;
align-items: center;
}
.number-input-group input {
width: 50px;
text-align: center;
margin: 0 2px;
}
.number-btn {
width: 24px;
height: 24px;
padding: 0;
font-size: 14px;
cursor: pointer;
background: #0a0a0a;
color: #666666;
border: 1px solid #222222;
border-radius: 3px;
}
.number-btn:hover {
background: #151515;
color: #999999;
border-color: #444444;
}
.toggle-btn, .siren-btn {
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
background: #0a0a0a;
color: #666666;
border: 1px solid #222222;
border-radius: 3px;
margin: 0 2px;
}
.toggle-btn:hover, .siren-btn:hover {
background: #151515;
color: #999999;
border-color: #444444;
}
.toggle-btn.active, .siren-btn.active {
background: #1a3a1a;
color: #66cc66;
border-color: #2a5a2a;
}
</style>
</head>
<body>
<div class="container">
<h1>Path Navigator</h1>
<div class="file-input">
<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>
<div class="number-input-group">
<button type="button" class="number-btn" onclick="adjustValue('fundamentalInput', -1)"></button>
<input type="number" id="fundamentalInput" value="110" style="width: 60px;">
<button type="button" class="number-btn" onclick="adjustValue('fundamentalInput', 1)">+</button>
</div>
<span style="margin-left: 15px;">Octave:</span>
<div class="number-input-group">
<button type="button" class="number-btn" onclick="adjustValue('octaveInput', -1)"></button>
<input type="number" id="octaveInput" value="2" style="width: 40px;">
<button type="button" class="number-btn" onclick="adjustValue('octaveInput', 1)">+</button>
</div>
<span style="margin-left: 15px;">Ramp (s):</span>
<div class="number-input-group">
<button type="button" class="number-btn" onclick="adjustValue('rampDuration', -1)"></button>
<input type="number" id="rampDuration" value="3" step="0.1" style="width: 60px;">
<button type="button" class="number-btn" onclick="adjustValue('rampDuration', 1)">+</button>
</div>
<span style="margin-left: 10px;">Exponent:</span>
<div class="number-input-group">
<button type="button" class="number-btn" onclick="adjustValue('rampExponent', -1)"></button>
<input type="number" id="rampExponent" value="1" min="0.1" max="5" step="0.1" style="width: 50px;">
<button type="button" class="number-btn" onclick="adjustValue('rampExponent', 1)">+</button>
</div>
<span style="margin-left: 15px;">Siren IP:</span>
<input type="text" id="sirenIp" value="192.168.4.200" style="width: 100px;">
<button id="toggleUnitBtn" class="toggle-btn">Show: Cents</button>
</div>
<div class="controls">
<button id="prevBtn" disabled>← Previous</button>
<span class="index-display">Index: <span id="currentIndex">0</span> / <span id="totalSteps">0</span></span>
<button id="nextBtn" disabled>Next →</button>
<input type="number" id="gotoIndex" min="0" placeholder="Go to" style="width: 50px; margin-left: 10px;">
<span style="margin-left: 20px; border-left: 1px solid #333; padding-left: 20px;">
<button id="rampBtn" class="toggle-btn">RAMP: OFF</button>
<button id="siren1Btn" class="siren-btn">1</button>
<button id="siren2Btn" class="siren-btn">2</button>
<button id="siren3Btn" class="siren-btn">3</button>
<button id="siren4Btn" class="siren-btn">4</button>
<button id="sirenABtn" class="siren-btn">A</button>
</span>
</div>
<div id="graph-container"></div>
<div class="chord-info">
<div class="chord-panel prev">
<h3>Previous</h3>
<ul class="pitch-list" id="prevPitches"></ul>
</div>
<div class="chord-panel current">
<h3>Current</h3>
<ul class="pitch-list" id="currentPitches"></ul>
</div>
<div class="chord-panel next">
<h3>Next</h3>
<ul class="pitch-list" id="nextPitches"></ul>
</div>
</div>
</div>
<script>
// Global state
let chords = [];
let selectedSiren = 0; // 0 = use node's voice, 1-4 = selected siren
let rampMode = false; // false = play, true = ramp
let currentIndex = 0;
let totalSteps = 0;
let hasPrev = false;
let hasNext = false;
let allGraphsData = null;
let displayMode = 'cents';
// Update UI buttons based on state
function updateRampButton() {
const btn = document.getElementById('rampBtn');
if (btn) {
btn.textContent = rampMode ? 'RAMP: ON' : 'RAMP: OFF';
btn.classList.toggle('active', rampMode);
}
}
function updateSirenButtons() {
const buttons = [
{ id: 'siren1Btn', value: 1 },
{ id: 'siren2Btn', value: 2 },
{ id: 'siren3Btn', value: 3 },
{ id: 'siren4Btn', value: 4 },
{ id: 'sirenABtn', value: 0 },
];
buttons.forEach(({ id, value }) => {
const btn = document.getElementById(id);
if (btn) {
btn.classList.toggle('active', selectedSiren === value);
}
});
}
function adjustValue(id, delta) {
const input = document.getElementById(id);
const min = parseFloat(input.min) || -Infinity;
const max = parseFloat(input.max) || Infinity;
const step = parseFloat(input.step) || 1;
let val = parseFloat(input.value) || 0;
val = Math.max(min, Math.min(max, val + (delta * step)));
input.value = step % 1 !== 0 ? parseFloat(val.toFixed(2)) : Math.round(val);
}
// Parse fraction string like "3/2" or "1" into a number
function parseFraction(fracStr) {
if (!fracStr || fracStr === "1") return 1;
const parts = fracStr.split('/');
if (parts.length === 2) {
return parseInt(parts[0]) / parseInt(parts[1]);
}
return parseFloat(fracStr);
}
// Play all nodes in a chord on the siren
function playChordOnSiren(chordIdx) {
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
// Find all nodes in this chord (exclude label nodes)
const nodes = cy.nodes().filter(n =>
n.data('chordIndex') === chordIdx && !n.data('isLabel')
);
// Clear old sirenActive markers
cy.nodes().forEach(n => n.data('sirenActive', ''));
// Play each node using chordIndex + localId (let server calculate frequency)
nodes.forEach((node) => {
const localId = node.data('localId');
// Label always uses auto (node's voice) - never send sirenNumber
const body = {
chordIndex: chordIdx,
nodeIndex: localId,
ip: sirenIp,
};
fetch('/api/play-siren', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
node.data('sirenActive', 'true');
node.data('borderColor', voiceColors[localId % 4]);
});
}
// Ramp to a chord or single node on the siren
function rampToChord(chordIdx, nodeIdx = null) {
const durationSeconds = parseFloat(document.getElementById('rampDuration').value) || 3;
const durationMs = durationSeconds * 1000;
const exponent = parseFloat(document.getElementById('rampExponent').value) || 1.0;
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
// Find nodes in this chord (exclude label nodes)
const nodes = cy.nodes().filter(n =>
n.data('chordIndex') === chordIdx && !n.data('isLabel')
);
// If nodeIdx is provided, clear only same-color nodes; otherwise clear all
// Use siren's color if selected, otherwise use node's voice color
// Use exact same coloring logic as click handler
const sirenColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
if (nodeIdx !== null) {
// Single node ramp - use actualSiren
const actualSiren = selectedSiren > 0 ? selectedSiren : (nodeIdx + 1);
const nodeBorderColor = sirenColors[(actualSiren - 1) % 4];
cy.nodes().forEach(n => {
if (n.data('borderColor') === nodeBorderColor && n.data('sirenActive')) {
n.data('sirenActive', '');
n.data('borderColor', '');
}
});
const targetNode = nodes.find(n => n.data('localId') === nodeIdx);
if (targetNode) {
targetNode.data('sirenActive', 'true');
targetNode.data('borderColor', nodeBorderColor);
}
} else {
// Entire chord ramp - each node gets its own color
cy.nodes().forEach(n => {
n.data('sirenActive', '');
n.data('borderColor', '');
});
nodes.forEach(node => {
const nodeLocalId = node.data('localId');
const nodeActualSiren = selectedSiren > 0 ? selectedSiren : (nodeLocalId + 1);
node.data('sirenActive', 'true');
node.data('borderColor', sirenColors[(nodeActualSiren - 1) % 4]);
});
}
const rampBody = {
chordIndex: chordIdx,
nodeIndex: nodeIdx,
duration: durationMs,
exponent: exponent,
ip: sirenIp
};
// Only add sirenNumber if a specific siren is selected (> 0), otherwise use node's voice
if (selectedSiren > 0) {
rampBody.sirenNumber = selectedSiren;
}
// Add octaveOffset if single node was transposed
if (nodeIdx !== null) {
const targetNode = nodes.find(n => n.data('localId') === nodeIdx);
if (targetNode) {
rampBody.octaveOffset = targetNode.data('octaveOffset') || 0;
}
}
fetch('/api/ramp-to-chord', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(rampBody)
}).then(r => r.json()).then(data => {
console.log('Ramping:', data);
}).catch(err => {
console.log('Error ramping:', err);
});
}
// Toggle between cents and frequency display
function toggleDisplayUnit() {
displayMode = displayMode === 'cents' ? 'frequency' : 'cents';
const btn = document.getElementById('toggleUnitBtn');
if (btn) {
btn.textContent = displayMode === 'cents' ? 'Show: Cents' : 'Show: Frequency';
btn.classList.toggle('active', displayMode === 'frequency');
}
if (!cy) return;
const fundamental = parseFloat(document.getElementById("fundamentalInput").value) || 110;
cy.nodes().forEach(node => {
const cents = node.data('cents');
const fraction = node.data('fraction');
if (displayMode === 'frequency' && fraction) {
const frac = parseFraction(fraction);
const freq = fundamental * frac;
node.data('displayLabel', Math.round(freq * 10) / 10);
} else {
node.data('displayLabel', cents);
}
node.style('label', node.data('displayLabel'));
});
}
// Set up toggle button listener after DOM loads
document.addEventListener('DOMContentLoaded', () => {
const toggleBtn = document.getElementById('toggleUnitBtn');
if (toggleBtn) {
toggleBtn.addEventListener('click', toggleDisplayUnit);
}
});
// Cytoscape instance
let cy = null;
// Graph dimensions - will expand based on number of chords
let graphWidth = 1100;
const graphHeight = 450;
let chordSpacing = 350;
// Fixed pixels per cent (0.15 = 360 pixels per octave = 2 octaves on screen)
const PIXELS_PER_CENT = 0.15;
// Voice colors - sleek pastel scheme
const voiceColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
// Create Cytoscape instance for all chords
function initCytoscapeAll(totalChords) {
// Calculate canvas width based on number of chords
graphWidth = Math.max(1100, totalChords * chordSpacing + 400);
cy = cytoscape({
container: document.getElementById('graph-container'),
style: [
{
selector: 'node[color]',
style: {
'background-color': 'data(color)',
'width': 32,
'height': 32,
'label': function(ele) { return ele.data('displayLabel'); },
'text-valign': 'center',
'text-halign': 'center',
'color': '#000000',
'font-size': '10px',
'font-family': 'monospace',
'font-weight': 'bold',
'text-outline-width': 0,
'border-width': 0,
}
},
{
selector: 'node[isLabel = "true"]',
style: {
'background-color': '#888888',
'width': 40,
'height': 40,
'label': 'data(chordIndex)',
'color': '#ffffff',
'font-size': '18px',
'font-weight': 'bold',
'text-valign': 'center',
'text-halign': 'center',
'text-outline-width': 0,
'border-width': 0,
}
},
{
selector: 'node[sirenActive = "true"]',
style: {
'border-width': 4,
'border-color': 'data(borderColor)',
'border-opacity': 1,
}
},
{
selector: 'edge',
style: {
'width': 2.5,
'line-color': '#ffffff',
'curve-style': 'straight',
'target-arrow-shape': 'none',
'label': 'data(ratio)',
'font-size': '12px',
'color': '#ffffff',
'text-rotation': 'autorotate',
'text-margin-y': -10,
'text-background-color': '#000000',
'text-background-opacity': 0.8,
'text-background-padding': '2px',
}
},
{
selector: 'edge[isCrossChord = "true"]',
style: {
'width': 1,
'line-color': '#aaaaaa',
'line-style': 'dashed',
'curve-style': 'straight',
'target-arrow-shape': 'none',
'label': '',
}
},
{
selector: ':selected',
style: {
'border-width': 2,
'border-color': '#00d4ff',
}
}
],
layout: {
name: 'preset',
},
minZoom: 0.3,
maxZoom: 3,
zoomingEnabled: true,
panningEnabled: true,
wheelSensitivity: 0.2, // Reduce wheel zoom sensitivity
autounselectify: true,
boxSelectionEnabled: false,
});
// Track drag for X-move and Y-octave drag
let isDragging = false;
let dragDirection = null; // 'x' or 'y'
let grabPos = null;
// Calculate Y from cents using the same formula as rendering
function centsToY(cents, globalMinCents, globalCentsRange) {
const graphHeight = 450;
const yBase = graphHeight * 0.1;
const ySpread = graphHeight * 0.8;
return yBase + ySpread - (cents * PIXELS_PER_CENT);
}
// Calculate cents from Y using the reverse formula
function yToCents(y, globalMinCents, globalCentsRange) {
const graphHeight = 450;
const yBase = graphHeight * 0.1;
const ySpread = graphHeight * 0.8;
return (yBase + ySpread - y) / PIXELS_PER_CENT;
}
cy.on('grab', 'node', function(evt) {
const node = evt.target;
// Only set trueOriginalCents on first grab (never overwritten)
if (!node.data('trueOriginalCents')) {
node.data('trueOriginalCents', node.data('cents'));
}
grabPos = {
x: node.position('x'),
y: node.position('y'),
cents: node.data('trueOriginalCents'), // Use preserved original
};
node.data('originalCents', grabPos.cents);
});
cy.on('drag', 'node', function(evt) {
const node = evt.target;
// Track X vs Y movement to determine drag direction
if (grabPos) {
const dx = Math.abs(node.position('x') - grabPos.x);
const dy = Math.abs(node.position('y') - grabPos.y);
if (dx > 3 || dy > 3) {
isDragging = true;
// Determine which direction dominates
if (dy > dx) {
dragDirection = 'y';
// Snap X to original, free Y
node.position('x', grabPos.x);
} else {
dragDirection = 'x';
// Snap Y to original, free X
node.position('y', grabPos.y);
}
}
}
});
cy.on('dragfree', 'node', function(evt) {
const node = evt.target;
if (grabPos && isDragging && dragDirection === 'y') {
// Calculate current cents from Y position using reverse formula
const currentY = node.position('y');
const currentCents = yToCents(currentY);
// Calculate octave offset from original
const octaveOffset = Math.round((currentCents - grabPos.cents) / 1200);
// Snap to nearest octave
const snappedCents = grabPos.cents + (octaveOffset * 1200);
const snappedY = centsToY(snappedCents);
node.data('cents', snappedCents);
node.position('y', snappedY);
node.data('octaveOffset', octaveOffset); // Store for click handler
// Update display label based on displayMode
if (displayMode === 'frequency') {
const frac = parseFraction(node.data('fraction'));
const fundamental = parseFloat(document.getElementById("fundamentalInput").value) || 110;
const freq = fundamental * frac * Math.pow(2, octaveOffset);
node.data('displayLabel', Math.round(freq * 10) / 10);
} else {
node.data('displayLabel', Math.round(snappedCents));
}
node.style('label', node.data('displayLabel'));
console.log('Y-drag: originalY=', grabPos.y, 'currentY=', currentY, 'originalCents=', grabPos.cents, 'currentCents=', currentCents.toFixed(1), 'octaveOffset=', octaveOffset, 'snappedCents=', snappedCents);
}
// For X drag, just reset Y to original (visual only - allow X to stay where dragged)
if (grabPos && isDragging && dragDirection === 'x') {
node.position('y', grabPos.y);
console.log('X-drag: visual only');
}
isDragging = false;
dragDirection = null;
grabPos = null;
});
cy.on('drag', 'node', function(evt) {
const node = evt.target;
// Track X vs Y movement to determine drag direction
if (grabPos) {
const dx = Math.abs(node.position('x') - grabPos.x);
const dy = Math.abs(node.position('y') - grabPos.y);
if (dx > 3 || dy > 3) {
isDragging = true;
// Determine which direction dominates
if (dy > dx) {
dragDirection = 'y';
// Snap X to original, free Y
node.position('x', grabPos.x);
} else {
dragDirection = 'x';
// Snap Y to original, free X
node.position('y', grabPos.y);
}
}
}
});
cy.on('dragfree', 'node', function(evt) {
const node = evt.target;
const originalCents = node.data('originalCents');
if (grabPos && isDragging && dragDirection === 'y') {
const dy = node.position('y') - grabPos.y;
// Calculate octave offset from drag distance
// Each octave = 1200 cents
// Get global cents range from graph data
const allGraphsData_json = document.getElementById('allGraphsData');
let globalRange = 1200;
if (allGraphsData_json) {
const data = JSON.parse(allGraphsData_json.textContent);
const allCents = [];
data.graphs.forEach(g => {
if (g.nodes) allCents.push(...g.nodes.map(n => n.cents));
});
if (allCents.length > 0) {
globalRange = Math.max(...allCents) - Math.min(...allCents);
}
}
const graphHeight = 450;
const centsPerPixel = globalRange / graphHeight;
// Invert: dragging UP (negative dy) = higher cents
const centsDelta = -dy * centsPerPixel;
const octaveOffset = Math.round(centsDelta / 1200);
// Calculate new cents and update position
const newCents = originalCents + (octaveOffset * 1200);
const newY = grabPos.y - (octaveOffset * 1200) / centsPerPixel;
node.data('cents', newCents);
node.position('y', newY);
node.data('displayLabel', Math.round(newCents));
node.style('label', Math.round(newCents));
console.log('Y-drag: dy=', dy, 'centsDelta=', centsDelta, 'octaves=', octaveOffset, 'newCents=', newCents);
}
// For X drag, just reset X to original (visual only)
if (grabPos && isDragging && dragDirection === 'x') {
node.position('x', grabPos.x);
console.log('X-drag: visual only');
}
isDragging = false;
dragDirection = null;
grabPos = null;
});
// Click to play - send OSC
cy.on('tap', 'node', function(evt) {
console.log('TAP event fired', isDragging);
const node = evt.target;
console.log('Node data:', node.data());
if (isDragging) {
console.log('Was dragging, skipping');
return;
}
// Check modifiers
const isShift = evt.originalEvent && evt.originalEvent.shiftKey;
// Handle label node clicks
if (node.data('isLabel')) {
const chordIdx = node.data('chordIndex');
if (rampMode) {
rampToChord(chordIdx);
} else {
playChordOnSiren(chordIdx);
}
return;
}
const chordIndex = node.data('chordIndex');
const localId = node.data('localId');
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
// 1. Shift+click → SuperCollider (FIRST)
if (isShift) {
const endpoint = '/api/play-freq';
const requestBody = {
chordIndex: chordIndex,
nodeIndex: localId,
octave: parseInt(document.getElementById('octaveInput').value) || 0,
};
console.log('Sending to SuperCollider:', chordIndex, localId);
fetch(endpoint, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(requestBody)
});
return;
}
// 2. Ramp mode ON → ramp to siren
if (rampMode) {
rampToChord(chordIndex, localId);
return;
}
// 3. Plain click: play to siren
const endpoint = '/api/play-siren';
const destination = 'siren';
// Determine which siren: if selectedSiren > 0 use it, otherwise use node's voice (localId + 1)
const actualSiren = selectedSiren > 0 ? selectedSiren : localId + 1;
console.log('Sending to siren:', chordIndex, localId, 'siren:', actualSiren);
const requestBody = {
chordIndex: chordIndex,
nodeIndex: localId,
ip: sirenIp,
sirenNumber: actualSiren,
octaveOffset: node.data('octaveOffset') || 0
};
fetch(endpoint, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(requestBody)
}).then(r => r.json()).then(data => {
console.log('Playing on', destination + ':', data.frequency.toFixed(2), 'Hz on siren', data.siren);
// Add colored circle around node based on siren
const sirenColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
const borderColor = sirenColors[(actualSiren - 1) % 4];
// Find any existing node with same border color that has sirenActive and remove it
cy.nodes().forEach(n => {
if (n.data('borderColor') === borderColor && n.data('sirenActive')) {
n.data('sirenActive', '');
n.data('borderColor', '');
}
});
// Add sirenActive to clicked node
node.data('sirenActive', 'true');
node.data('borderColor', borderColor);
console.log('Added sirenActive to', node.id(), 'color:', borderColor);
}).catch(err => {
console.log('Error playing freq:', err);
});
});
}
// Render ALL chords at once for continuous canvas
function renderAllChords() {
if (!allGraphsData || !cy) return;
// Clear previous elements
cy.elements().remove();
// Calculate graph dimensions
const totalChords = allGraphsData.graphs.length;
graphWidth = Math.max(1100, totalChords * chordSpacing + 400);
// Collect all cents for global scale
let allCents = [];
allGraphsData.graphs.forEach(g => {
if (g.nodes) {
allCents = allCents.concat(g.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;
// Build elements array for all chords
let elements = [];
// Collect cross-chord edges (same hs_array between adjacent chords)
const crossChordEdges = [];
for (let chordIdx = 1; chordIdx < allGraphsData.graphs.length; chordIdx++) {
const prevGraph = allGraphsData.graphs[chordIdx - 1];
const currGraph = allGraphsData.graphs[chordIdx];
if (!prevGraph || !prevGraph.nodes || !currGraph || !currGraph.nodes) continue;
currGraph.nodes.forEach(n => {
const prevNode = prevGraph.nodes.find(pn =>
JSON.stringify(pn.hs_array) === JSON.stringify(n.hs_array)
);
if (prevNode) {
const prevNodeId = `c${chordIdx - 1}_${prevNode.id}`;
const currNodeId = `c${chordIdx}_${n.id}`;
crossChordEdges.push({
group: 'edges',
data: {
source: prevNodeId,
target: currNodeId,
ratio: "1/1",
isCrossChord: "true"
},
});
}
});
}
allGraphsData.graphs.forEach((graph, chordIdx) => {
if (!graph || !graph.nodes) return;
const nodes = graph.nodes;
const edges = graph.edges || [];
const gap = 50;
const xBase = 100 + chordIdx * (chordSpacing + gap);
const idMap = {};
// Create unique IDs per chord to avoid collisions
const chordPrefix = `c${chordIdx}_`;
nodes.forEach((n, i) => {
const nodeId = chordPrefix + n.id;
idMap[n.id] = nodeId;
// Spread nodes within each chord
const x = xBase + (chordSpacing * 0.15) * (i / (nodes.length - 1 || 1));
const y = yBase + ySpread - (n.cents * PIXELS_PER_CENT);
elements.push({
group: 'nodes',
data: {
id: nodeId,
localId: n.id,
cents: n.cents,
displayLabel: n.cents,
fraction: n.fraction || "1",
chordIndex: chordIdx,
chordLabel: `c${chordIdx}`,
color: voiceColors[n.id % voiceColors.length],
},
position: { x: x, y: y },
});
});
// Add edges within chord
edges.forEach(e => {
elements.push({
group: 'edges',
data: {
source: idMap[e.source],
target: idMap[e.target],
ratio: e.ratio,
},
});
});
// Find max cents in this chord for label positioning
const maxCents = Math.max(...nodes.map(n => n.cents));
const labelY = 405 - ((maxCents + 400) * PIXELS_PER_CENT);
// Add label node for this chord (locked so layout doesn't move it)
elements.push({
group: 'nodes',
data: {
id: `label_${chordIdx}`,
chordIndex: chordIdx,
isLabel: "true",
},
position: { x: xBase + (chordSpacing * 0.15), y: labelY },
locked: true,
});
});
if (elements.length === 0) return;
// Add cross-chord edges to elements BEFORE layout (so xforce considers them)
elements.push(...crossChordEdges);
// Add all elements
cy.add(elements);
console.log('Added', elements.length, 'elements');
// Build bounds for each chord - with gaps between them
const bounds = {};
const gap = 50; // gap between chord regions
allGraphsData.graphs.forEach((graph, idx) => {
const chordLabel = `c${idx}`;
const chordMin = 100 + idx * (chordSpacing + gap);
bounds[chordLabel] = {
min: chordMin,
max: chordMin + chordSpacing
};
});
// Run xforce layout to optimize x positions while keeping y fixed
const layout = cy.layout({
name: 'xforce',
linkDistance: 60,
linkStrength: 0.1,
crossChordStrength: 0.00005,
charge: -60,
collisionDistance: 35,
damping: 0.7,
iterations: 250,
bounds: bounds,
});
layout.run();
// Set canvas size
cy.style().json()[0].value = graphWidth;
// Fit to show initial position centered
panToIndex(currentIndex);
}
// Pan to show a specific chord index centered
function panToIndex(index) {
if (!cy) return;
const gap = 50;
const targetX = 100 + index * (chordSpacing + gap) + chordSpacing / 2;
const centerX = 550; // Half of default viewport
const panX = centerX - targetX;
cy.animate({
pan: { x: panX, y: 0 },
duration: 350,
easing: 'ease-out'
});
}
// Load from Flask API - get chords and compute graphs client-side
async function loadAllGraphs() {
try {
// Step 1: Get raw chord data
const response = await fetch("/api/chords");
if (!response.ok) throw new Error("API not available");
const data = await response.json();
// Step 2: Collect all fractions from all chords
const allFractions = [];
const chordFractions = []; // Track which fractions belong to which chord
for (const chord of data.chords) {
const fractions = [];
for (const pitch of chord) {
fractions.push(pitch.fraction || "1");
}
chordFractions.push(fractions);
allFractions.push(...fractions);
}
// Step 3: Batch fetch cents from server (avoid N+1 problem)
const centsResponse = await fetch("/api/batch-calculate-cents", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ fractions: allFractions })
});
const centsData = await centsResponse.json();
const allCents = centsData.results.map(r => r.cents);
// Step 4: Build graphs with cached cents values
let centsIndex = 0;
const graphs = data.chords.map((chord, index) => {
return calculateGraph(chord, index, () => allCents[centsIndex++]);
});
allGraphsData = {
total: data.total,
graphs: graphs
};
totalSteps = allGraphsData.total - 1;
// Initialize Cytoscape with total chord count
initCytoscapeAll(allGraphsData.total);
// Render all chords
renderAllChords();
// Update UI
updateUI();
} catch (e) {
console.log("Error loading chords:", e);
}
}
// Compute graph (nodes + edges) from raw chord data - using API for cents
function calculateGraph(chord, index, getNextCent) {
if (!chord) return { nodes: [], edges: [] };
const nodes = [];
const dims = [2, 3, 5, 7];
// Calculate cents for each pitch (fetched from server)
for (let i = 0; i < chord.length; i++) {
const pitch = chord[i];
const cents = getNextCent();
nodes.push({
id: i,
cents: Math.round(cents),
displayLabel: Math.round(cents),
fraction: pitch.fraction || "1",
hs_array: pitch.hs_array || []
});
}
// Find edges: differ by ±1 in exactly one dimension (ignoring dim 0)
const edges = [];
for (let i = 0; i < chord.length; i++) {
for (let j = i + 1; j < chord.length; j++) {
const hs1 = chord[i].hs_array || [];
const hs2 = chord[j].hs_array || [];
if (!hs1.length || !hs2.length) continue;
// Count differences in dims 1, 2, 3
let diffCount = 0;
let diffDim = -1;
let broke = false;
for (let d = 1; d < hs1.length; d++) {
const diff = hs2[d] - hs1[d];
if (Math.abs(diff) === 1) {
diffCount++;
diffDim = d;
} else if (diff !== 0) {
broke = true;
break; // diff > 1 in this dimension
}
}
// Check if exactly one dimension differs AND loop didn't break early
if (!broke && diffCount === 1 && diffDim > 0) {
// Calculate frequency ratio
const diffHs = [];
for (let d = 0; d < hs1.length; d++) {
diffHs.push(hs1[d] - hs2[d]);
}
let numerator = 1;
let denominator = 1;
for (let dIdx = 0; dIdx < dims.length; dIdx++) {
const exp = diffHs[dIdx];
if (exp > 0) {
numerator *= Math.pow(dims[dIdx], exp);
} else if (exp < 0) {
denominator *= Math.pow(dims[dIdx], -exp);
}
}
const ratio = denominator > 1 ? `${numerator}/${denominator}` : `${numerator}`;
edges.push({
source: i,
target: j,
ratio: ratio,
dim: diffDim
});
}
}
}
return { nodes, edges, index };
}
// Update UI elements
function updateUI() {
hasPrev = currentIndex > 0;
hasNext = currentIndex < totalSteps;
document.getElementById("currentIndex").textContent = currentIndex;
document.getElementById("totalSteps").textContent = totalSteps;
document.getElementById("prevBtn").disabled = !hasPrev;
document.getElementById("nextBtn").disabled = !hasNext;
// Update chord panels - get data from allGraphsData
if (allGraphsData && allGraphsData.graphs) {
const prevIdx = currentIndex - 1;
const currIdx = currentIndex;
const nextIdx = currentIndex + 1;
updateChordPanel("prevPitches", prevIdx >= 0 ? allGraphsData.graphs[prevIdx]?.nodes : null);
updateChordPanel("currentPitches", allGraphsData.graphs[currIdx]?.nodes);
updateChordPanel("nextPitches", nextIdx < allGraphsData.total ? allGraphsData.graphs[nextIdx]?.nodes : null);
}
}
// Fundamental input - send on Enter, Up/Down arrows
document.getElementById("fundamentalInput").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
setFundamental();
} else if (e.key === "ArrowUp") {
e.preventDefault();
const input = document.getElementById("fundamentalInput");
input.value = parseFloat(input.value || 110) + 1;
setFundamental();
} else if (e.key === "ArrowDown") {
e.preventDefault();
const input = document.getElementById("fundamentalInput");
input.value = parseFloat(input.value || 110) - 1;
setFundamental();
}
});
// Trigger on spinner button clicks (change event fires on blur after value change)
document.getElementById("fundamentalInput").addEventListener("change", () => {
setFundamental();
});
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);
}
}
// File input handler
document.getElementById("loadFileBtn").addEventListener("click", async () => {
const filepath = document.getElementById("filepathInput").value;
if (!filepath) return;
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();
}
} catch (err) {
alert("Error loading file: " + err);
}
});
// Also allow Enter key in filepath input
document.getElementById("filepathInput").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
document.getElementById("loadFileBtn").click();
}
});
// Go to index on Enter
document.getElementById("gotoIndex").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const idx = parseInt(e.target.value);
if (!isNaN(idx) && idx >= 0 && idx <= totalSteps) {
currentIndex = idx;
panToIndex(currentIndex);
updateUI();
}
e.target.value = '';
}
});
function updateChordPanel(elementId, data) {
const container = document.getElementById(elementId);
container.innerHTML = "";
if (!data || (Array.isArray(data) && data.length === 0)) {
container.innerHTML = "<div>(none)</div>";
return;
}
const items = Array.isArray(data) ? data : (data.nodes || []);
if (items.length === 0) return;
// Determine number of columns from first node's hs_array
const cols = items[0].hs_array ? items[0].hs_array.length : 0;
if (cols === 0) return;
// Create table - let it size based on content
const table = document.createElement("table");
table.style.fontFamily = "monospace";
table.style.fontSize = "10px";
table.style.borderCollapse = "collapse";
table.style.tableLayout = "auto";
table.style.lineHeight = "1.2";
table.style.margin = "0 auto";
// Header row - split into separate cells to match row structure
const headerRow = document.createElement("tr");
const headerParts = ["2", "3", "5", "7"];
headerParts.forEach((part, idx) => {
const th = document.createElement("th");
th.textContent = part;
th.style.padding = "0px 4px";
th.style.textAlign = idx === 0 ? "left" : "right";
th.style.borderBottom = "1px solid #444";
th.style.paddingBottom = "1px";
th.style.fontWeight = "normal";
th.style.color = "#666";
th.style.whiteSpace = "nowrap";
th.style.width = "28px";
headerRow.appendChild(th);
});
table.appendChild(headerRow);
// Data rows (all nodes)
items.forEach((item) => {
const row = document.createElement("tr");
const hs = item.hs_array || [];
hs.forEach((val, j) => {
const td = document.createElement("td");
td.textContent = val;
td.style.padding = "0px 4px";
td.style.textAlign = "right";
td.style.color = "#888";
td.style.whiteSpace = "nowrap";
td.style.width = "28px";
if (j === 0) {
td.style.textAlign = "left";
}
row.appendChild(td);
});
table.appendChild(row);
});
container.appendChild(table);
}
// Navigation with pan animation
async function navigate(direction) {
if (direction === 'prev' && currentIndex > 0) {
currentIndex--;
} else if (direction === 'next' && currentIndex < totalSteps) {
currentIndex++;
} else {
return;
}
// Pan to new chord position
panToIndex(currentIndex);
// Update UI
updateUI();
}
// Navigation
document.getElementById("prevBtn").addEventListener("click", () => {
navigate('prev');
});
document.getElementById("nextBtn").addEventListener("click", () => {
navigate('next');
});
// Toggle buttons
document.getElementById("rampBtn").addEventListener("click", () => {
rampMode = !rampMode;
console.log('Ramp mode:', rampMode);
updateRampButton();
});
document.getElementById("siren1Btn").addEventListener("click", () => { selectedSiren = 1; updateSirenButtons(); });
document.getElementById("siren2Btn").addEventListener("click", () => { selectedSiren = 2; updateSirenButtons(); });
document.getElementById("siren3Btn").addEventListener("click", () => { selectedSiren = 3; updateSirenButtons(); });
document.getElementById("siren4Btn").addEventListener("click", () => { selectedSiren = 4; updateSirenButtons(); });
document.getElementById("sirenABtn").addEventListener("click", () => { selectedSiren = 0; updateSirenButtons(); });
// Initialize button states
updateRampButton();
updateSirenButtons();
// Keyboard navigation
document.addEventListener("keydown", (e) => {
// Don't trigger siren/ramp hotkeys when typing in input fields
if (e.target.tagName === 'INPUT') {
// Skip siren/ramp hotkeys but allow other keys
if (e.key >= '1' && e.key <= '4' ||
e.key === '0' || e.key === 'a' || e.key === 'A' ||
e.key === 'r' || e.key === 'R') {
return;
}
}
if (e.key === "ArrowLeft") {
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 >= '1' && e.key <= '4') {
// Select siren 1-4
selectedSiren = parseInt(e.key);
console.log('Selected siren:', selectedSiren);
updateSirenButtons();
} else if (e.key === '0' || e.key === 'a' || e.key === 'A') {
// Auto mode - use node's voice
selectedSiren = 0;
console.log('Auto mode: use node voice');
updateSirenButtons();
} else if (e.key === 'r' || e.key === 'R') {
// Toggle ramp mode
rampMode = !rampMode;
console.log('Ramp mode:', rampMode);
updateRampButton();
} else if (e.key === "k") {
// Soft kill - send 20 Hz to stop voices gently
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
fetch('/api/kill-siren', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ soft: true, ip: sirenIp })
}).then(r => r.json()).then(data => {
console.log('Soft kill sent (20 Hz)');
// Clear all siren circles
cy.nodes().forEach(n => {
n.removeData('sirenActive');
n.removeData('borderColor');
});
}).catch(err => {
console.log('Error sending kill:', err);
});
} else if (e.key === "K") {
// Hard kill - send 0 Hz to stop voices immediately
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
fetch('/api/kill-siren', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ soft: false, ip: sirenIp })
}).then(r => r.json()).then(data => {
console.log('Hard kill sent (0 Hz)');
// Clear all siren circles
cy.nodes().forEach(n => {
n.removeData('sirenActive');
n.removeData('borderColor');
});
}).catch(err => {
console.log('Error sending kill:', err);
});
} else if (e.key === "f" || e.key === "F") {
toggleDisplayUnit();
}
});
// Initialize
loadAllGraphs();
</script>
</body>
</html>