compact_sets/webapp/path_navigator.html

1786 lines
75 KiB
HTML
Raw Normal View History

<!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;
}
.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>
2026-04-20 17:56:53 +02:00
<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>
<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);
}
// Normalize display ratio so numerator >= denominator
function normalizeDisplayRatio(label) {
const parts = label.split('/');
if (parts.length === 2) {
const num = parseInt(parts[0]);
const den = parseInt(parts[1]);
if (num < den) {
return den + '/' + num;
}
}
return label;
}
// Calculate edge opacity based on harmonic distance: normalized 0→0.5, 1→1
function calcEdgeOpacity(ratio) {
const parts = ratio.split('/');
if (parts.length === 2) {
const n = parseInt(parts[0]);
const d = parseInt(parts[1]);
const product = n * d;
if (product <= 1) return 1; // handle 1/1 edge case
const rawOpacity = 1 / Math.log2(product);
return 0.5 + (rawOpacity / 2); // normalize: 0→0.5, 1→1
}
return 1;
}
// Calculate edge width: inverse of harmonic distance (2-5px range)
function calcEdgeWidth(ratio) {
const parts = ratio.split('/');
if (parts.length === 2) {
const n = parseInt(parts[0]);
const d = parseInt(parts[1]);
const product = n * d;
if (product <= 1) return 5; // handle 1/1 edge case
return (1 / Math.log2(product) * 3) + 2;
}
return 3.5;
}
// 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': function(ele) { return calcEdgeWidth(ele.data('ratio')); },
'line-color': '#ffffff',
'curve-style': 'straight',
'target-arrow-shape': 'none',
'line-opacity': function(ele) { return calcEdgeOpacity(ele.data('ratio')); },
'label': 'data(ratio)',
'font-size': '12px',
'color': '#ffffff',
'text-rotation': '0deg',
'text-margin-y': 0,
'text-opacity': 1,
'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',
'line-opacity': 1,
'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: normalizeDisplayRatio(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: normalizeDisplayRatio(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 octave = parseInt(document.getElementById('octaveInput').value) || 0;
// If ghost node with its own frequency, send directly; otherwise use chordIndex/nodeIndex
const requestBody = node.data('frequency') ? {
frequency: node.data('frequency'),
octave: octave,
} : {
chordIndex: chordIndex,
nodeIndex: localId,
octave: octave,
};
console.log('Sending to SuperCollider:', node.data('frequency') ? 'direct frequency' : 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: normalizeDisplayRatio(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;
}
}
// 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();
}
});
2026-04-20 17:56:53 +02:00
// 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 = '';
}
});
// 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');
});
}).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);
});
}
});
// Initialize
loadAllGraphs();
</script>
</body>
</html>