compact_sets/webapp/path_navigator.html
Michael Winter b8f50a4563 Add ghost nodes for Y-drag with frequency ratios, fix display and play handling
- Y-drag creates ghost nodes at quantized ratios (2/1, 3/2, 5/4, etc.)
- Ghost nodes store their own frequency (fundamental * new fraction)
- Preview ghost shows frequency in real-time while dragging
- Final ghost created on release (if dragged >100 cents from original)
- Server API accepts frequency directly for ghost nodes
- Fix frequency display: fundamental * fraction (not cents-adjusted)
- Fix negative snap ratios to use correct reciprocals
- Add colored circle on click (both ramp and non-ramp modes)
- Ghost nodes: no border initially, opacity 0.7, border shows on click
- Chord label clicks exclude ghost nodes
- Remove octaveOffset, use cents or direct frequency instead
2026-04-21 16:55:40 +02:00

1884 lines
79 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>
<button id="deleteBtn" class="toggle-btn">DEL: OFF</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 deleteMode = false; // true = delete ghost on click
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 updateDeleteButton() {
const btn = document.getElementById('deleteBtn');
if (btn) {
btn.textContent = deleteMode ? 'DEL: ON' : 'DEL: OFF';
btn.classList.toggle('active', deleteMode);
}
}
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);
}
// Multiply two fractions and return as string
function multiplyFractions(frac1, frac2) {
const f1 = parseFraction(frac1);
const f2 = parseFraction(frac2);
const result = f1 * f2;
// Try to express as simple fraction
// Find GCD to simplify
function gcd(a, b) {
return b === 0 ? a : gcd(b, a % b);
}
// Convert to fraction approximation
const tolerance = 0.0001;
for (let den = 1; den <= 1000; den++) {
const num = Math.round(result * den);
if (Math.abs(num / den - result) < tolerance) {
const common = gcd(num, den);
return (num / common) + '/' + (den / common);
}
}
// Fall back to decimal
return result.toString();
}
// 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 and ghost nodes)
const nodes = cy.nodes().filter(n =>
n.data('chordIndex') === chordIdx && !n.data('isLabel') && n.data('isGhost') !== 'true'
);
// 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, frequency = null, targetNode = 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 and ghost nodes)
const nodes = cy.nodes().filter(n =>
n.data('chordIndex') === chordIdx && !n.data('isLabel') && n.data('isGhost') !== 'true'
);
// 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', '');
n.style('border-color', '');
n.style('border-width', 0);
}
});
const targetNodeToUse = targetNode || nodes.find(n => n.data('localId') === nodeIdx);
if (targetNodeToUse) {
targetNodeToUse.data('sirenActive', 'true');
targetNodeToUse.data('borderColor', nodeBorderColor);
targetNodeToUse.style('border-color', nodeBorderColor);
targetNodeToUse.style('border-width', 3);
}
} 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 frequency if provided (ghost node), otherwise server calculates from chordIndex/nodeIndex
if (frequency !== null) {
rampBody.frequency = frequency;
}
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;
let previewGhostId = null;
let previewEdgeId = 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';
// Original follows drag freely - just show preview ghost
// Show preview ghost at nearest snap position during drag
const currentY = node.position('y');
const currentCents = yToCents(currentY);
const centsDelta = currentCents - grabPos.cents;
const snapRatiosPositive = [
{ ratio: 2/1, cents: 1200, label: '2/1' },
{ ratio: 3/2, cents: Math.round(1200 * Math.log2(1.5)), label: '3/2' },
{ ratio: 5/4, cents: Math.round(1200 * Math.log2(1.25)), label: '5/4' },
{ ratio: 7/4, cents: Math.round(1200 * Math.log2(1.75)), label: '7/4' },
{ ratio: 11/8, cents: Math.round(1200 * Math.log2(1.375)), label: '11/8' },
{ ratio: 13/8, cents: Math.round(1200 * Math.log2(1.625)), label: '13/8' },
];
const snapRatios = [
...snapRatiosPositive,
...snapRatiosPositive.map(s => {
const parts = s.label.split('/');
return { ratio: 1/s.ratio, cents: -s.cents, label: parts[1] + '/' + parts[0] };
})
];
let nearestSnap = snapRatios[0];
let minDiff = Math.abs(centsDelta - nearestSnap.cents);
snapRatios.forEach(snap => {
const diff = Math.abs(centsDelta - snap.cents);
if (diff < minDiff) {
minDiff = diff;
nearestSnap = snap;
}
});
// Remove old preview
if (previewGhostId) {
cy.getElementById(previewGhostId).remove();
previewGhostId = null;
}
if (previewEdgeId) {
cy.getElementById(previewEdgeId).remove();
previewEdgeId = null;
}
// Always show preview of closest snap ratio
const snappedCents = grabPos.cents + nearestSnap.cents;
const snappedY = centsToY(snappedCents);
const sirenColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
const actualSiren = selectedSiren > 0 ? selectedSiren : (node.data('localId') + 1);
previewGhostId = `preview_${Date.now()}`;
previewEdgeId = `pedge_${previewGhostId}`;
// Add preview ghost
const originalFraction = node.data('fraction');
const newFraction = multiplyFractions(originalFraction, nearestSnap.label);
const fundamental = parseFloat(document.getElementById("fundamentalInput").value) || 110;
const newFrequency = fundamental * parseFraction(newFraction);
cy.add({
group: 'nodes',
data: {
id: previewGhostId,
localId: node.data('localId'),
chordIndex: node.data('chordIndex'),
cents: snappedCents,
fraction: newFraction,
ratioLabel: nearestSnap.label,
isGhost: 'preview'
},
position: { x: node.position('x') + 60, y: snappedY }
});
const pGhost = cy.getElementById(previewGhostId);
pGhost.style({
'background-color': sirenColors[(actualSiren - 1) % 4],
'border-width': 3,
'border-color': sirenColors[(actualSiren - 1) % 4],
'border-style': 'dashed',
'opacity': 0.5,
'label': (() => {
if (displayMode === 'frequency') {
return Math.round(newFrequency * 10) / 10;
}
return Math.round(snappedCents);
})(),
'text-opacity': 0.8
});
// Add preview edge
cy.add({
group: 'edges',
data: {
id: previewEdgeId,
source: node.id(),
target: previewGhostId,
ratio: nearestSnap.label,
}
});
const pEdge = cy.getElementById(previewEdgeId);
pEdge.style({
'line-color': sirenColors[(actualSiren - 1) % 4],
'line-style': 'dashed',
'opacity': 0.5,
'line-dash-pattern': [4, 4],
});
} else {
dragDirection = 'x';
// Snap Y to original, free X
node.position('y', grabPos.y);
}
}
}
});
cy.on('dragfree', 'node', function(evt) {
const node = evt.target;
// Remove preview ghost and edge
if (previewGhostId) {
cy.getElementById(previewGhostId).remove();
previewGhostId = null;
}
if (previewEdgeId) {
cy.getElementById(previewEdgeId).remove();
previewEdgeId = null;
}
if (grabPos && isDragging && dragDirection === 'y') {
// Calculate current cents from Y position using reverse formula
const currentY = node.position('y');
const currentCents = yToCents(currentY);
// Calculate offset from original
// Quantization ratios (cents from original - positive for up, negative for down)
const snapRatiosPositive = [
{ ratio: 2/1, cents: 1200, label: '2/1' },
{ ratio: 3/2, cents: Math.round(1200 * Math.log2(1.5)), label: '3/2' },
{ ratio: 5/4, cents: Math.round(1200 * Math.log2(1.25)), label: '5/4' },
{ ratio: 7/4, cents: Math.round(1200 * Math.log2(1.75)), label: '7/4' },
{ ratio: 11/8, cents: Math.round(1200 * Math.log2(1.375)), label: '11/8' },
{ ratio: 13/8, cents: Math.round(1200 * Math.log2(1.625)), label: '13/8' },
];
// Add negative versions for dragging down
const snapRatios = [
...snapRatiosPositive,
...snapRatiosPositive.map(s => {
const parts = s.label.split('/');
return { ratio: 1/s.ratio, cents: -s.cents, label: parts[1] + '/' + parts[0] };
})
];
// Find nearest snap point
const centsDelta = currentCents - grabPos.cents;
let nearestSnap = snapRatios[0];
let minDiff = Math.abs(centsDelta - nearestSnap.cents);
snapRatios.forEach(snap => {
const diff = Math.abs(centsDelta - snap.cents);
if (diff < minDiff) {
minDiff = diff;
nearestSnap = snap;
}
});
// Only create ghost if moved significantly (more than 100 cents from original)
if (Math.abs(centsDelta) > 100) {
// Snap to nearest ratio
const snappedCents = grabPos.cents + nearestSnap.cents;
const snappedY = centsToY(snappedCents);
// Compute new fraction: original fraction * snap ratio
const originalFraction = node.data('fraction');
const newFraction = multiplyFractions(originalFraction, nearestSnap.label);
const fundamental = parseFloat(document.getElementById("fundamentalInput").value || 110);
const newFrequency = fundamental * parseFraction(newFraction);
// Create ghost node at new position
const ghostId = `ghost_${Date.now()}`;
const sirenColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
const actualSiren = selectedSiren > 0 ? selectedSiren : (node.data('localId') + 1);
cy.add({
group: 'nodes',
data: {
id: ghostId,
localId: node.data('localId'),
chordIndex: node.data('chordIndex'),
cents: snappedCents,
fraction: newFraction,
ratioLabel: nearestSnap.label,
frequency: newFrequency,
displayLabel: (() => {
return displayMode === 'frequency' ?
Math.round(newFrequency * 10) / 10 :
snappedCents;
})(),
color: node.data('color'),
isGhost: 'true'
},
position: { x: node.position('x') + 60, y: snappedY }
});
// Add edge between original and ghost to show ratio
const edgeId = `edge_${ghostId}`;
cy.add({
group: 'edges',
data: {
id: edgeId,
source: node.id(),
target: ghostId,
ratio: nearestSnap.label,
}
});
// Style the ghost node (slightly transparent, no border until clicked)
const ghostNode = cy.getElementById(ghostId);
ghostNode.style({
'opacity': 0.7,
'label': ghostNode.data('displayLabel')
});
console.log('Ghost node created:', ghostId, 'cents=', snappedCents, 'ratio=', nearestSnap.label);
}
// Reset original node to original position
node.position('y', grabPos.y);
if (node.data('isGhost') !== 'true') {
node.data('cents', grabPos.cents);
node.data('displayLabel', displayMode === 'frequency' ?
Math.round((parseFloat(document.getElementById("fundamentalInput").value || 110) * parseFraction(node.data('fraction')) * 10) / 10) :
Math.round(grabPos.cents));
node.style('label', node.data('displayLabel'));
}
console.log('Y-drag: originalY=', grabPos.y, 'currentY=', currentY, 'originalCents=', grabPos.cents, 'currentCents=', currentCents.toFixed(1));
}
// 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';
// Original follows drag freely
} else {
dragDirection = 'x';
// Original follows drag freely
}
}
}
});
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 centsOffset = -dy * centsPerPixel;
// Calculate new cents and update position
const newCents = originalCents + centsOffset;
const newY = grabPos.y - centsOffset / 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, 'centsOffset=', centsOffset, '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;
}
// Delete ghost node if delete mode is on and node is a ghost
if (deleteMode && node.data('isGhost') === 'true') {
node.remove();
console.log('Ghost deleted');
deleteMode = false;
updateDeleteButton();
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) {
const nodeFrequency = node.data('frequency');
rampToChord(chordIndex, localId, nodeFrequency, node);
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);
// If ghost node with its own frequency, send directly; otherwise use chordIndex/nodeIndex
const requestBody = node.data('frequency') ? {
frequency: node.data('frequency'),
ip: sirenIp,
sirenNumber: actualSiren,
} : {
chordIndex: chordIndex,
nodeIndex: localId,
ip: sirenIp,
sirenNumber: actualSiren,
};
console.log('Click node:', node.id(), 'isGhost=', node.data('isGhost'), 'frequency=', node.data('frequency'), 'cents=', node.data('cents'), 'fraction=', node.data('fraction'));
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', '');
n.style('border-color', '');
n.style('border-width', 0);
}
});
// Add sirenActive to clicked node
node.data('sirenActive', 'true');
node.data('borderColor', borderColor);
node.style('border-color', borderColor);
node.style('border-width', 3);
}).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 === 'd' || e.key === 'D') {
// Toggle delete mode (for removing ghost nodes)
deleteMode = !deleteMode;
console.log('Delete mode:', deleteMode);
updateDeleteButton();
} 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');
});
// Remove all ghost nodes
const ghosts = cy.nodes().filter(n => n.data('isGhost') === 'true');
ghosts.remove();
}).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');
});
// Remove all ghost nodes
const ghosts = cy.nodes().filter(n => n.data('isGhost') === 'true');
ghosts.remove();
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>