2026-03-30 21:06:14 +02:00
|
|
|
|
<!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>
|
2026-03-31 18:44:47 +02:00
|
|
|
|
<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 => {
|
2026-04-04 19:56:39 +02:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-03-31 18:44:47 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-01 17:48:47 +02:00
|
|
|
|
const edgeList = edges.map(e => ({
|
|
|
|
|
|
s: e.source().id(),
|
|
|
|
|
|
t: e.target().id(),
|
|
|
|
|
|
isCrossChord: e.data('isCrossChord')
|
|
|
|
|
|
}));
|
2026-03-31 18:44:47 +02:00
|
|
|
|
|
|
|
|
|
|
const iter = (i) => {
|
|
|
|
|
|
state.forEach(s => s.fx = 0);
|
|
|
|
|
|
|
2026-04-01 17:48:47 +02:00
|
|
|
|
edgeList.forEach(({s, t, isCrossChord})=>{
|
2026-03-31 18:44:47 +02:00
|
|
|
|
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;
|
2026-04-01 17:48:47 +02:00
|
|
|
|
const strength = isCrossChord ? (opts.crossChordStrength || 0.001) : opts.linkStrength;
|
|
|
|
|
|
const spring = strength * (dist - opts.linkDistance);
|
2026-03-31 18:44:47 +02:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-04-01 09:59:30 +02:00
|
|
|
|
// Strong boundary force
|
2026-03-31 18:44:47 +02:00
|
|
|
|
if(A.x < b.min + margin){
|
2026-04-01 09:59:30 +02:00
|
|
|
|
A.fx += (b.min + margin - A.x) * 0.8;
|
2026-03-31 18:44:47 +02:00
|
|
|
|
}
|
|
|
|
|
|
if(A.x > b.max - margin){
|
2026-04-01 09:59:30 +02:00
|
|
|
|
A.fx -= (A.x - (b.max - margin)) * 0.8;
|
2026-03-31 18:44:47 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
state.forEach(s => {
|
|
|
|
|
|
s.vx = (s.vx + s.fx) * opts.damping;
|
|
|
|
|
|
s.x += s.vx;
|
|
|
|
|
|
|
2026-04-01 09:59:30 +02:00
|
|
|
|
// HARD boundary clamp - absolute enforcement
|
|
|
|
|
|
s.x = Math.max(s.bounds.min, Math.min(s.bounds.max, s.x));
|
2026-03-31 18:44:47 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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>
|
2026-03-30 21:06:14 +02:00
|
|
|
|
<style>
|
|
|
|
|
|
body {
|
|
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 20px;
|
2026-03-31 19:17:37 +02:00
|
|
|
|
background: #000000;
|
|
|
|
|
|
color: #cccccc;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.container {
|
|
|
|
|
|
max-width: 1200px;
|
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
h1 {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
margin-bottom: 10px;
|
2026-03-31 19:17:37 +02:00
|
|
|
|
font-weight: 300;
|
|
|
|
|
|
letter-spacing: 2px;
|
|
|
|
|
|
color: #ffffff;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.controls {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: 20px;
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.controls button {
|
2026-03-31 19:17:37 +02:00
|
|
|
|
padding: 8px 16px;
|
|
|
|
|
|
font-size: 14px;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
cursor: pointer;
|
2026-03-31 19:17:37 +02:00
|
|
|
|
background: #0a0a0a;
|
|
|
|
|
|
color: #888888;
|
|
|
|
|
|
border: 1px solid #222222;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
transition: all 0.2s ease;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.controls button:hover {
|
2026-03-31 19:17:37 +02:00
|
|
|
|
background: #151515;
|
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
|
border-color: #444444;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.controls button:disabled {
|
2026-03-31 19:17:37 +02:00
|
|
|
|
opacity: 0.3;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.index-display {
|
2026-03-31 19:17:37 +02:00
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
color: #666666;
|
|
|
|
|
|
letter-spacing: 1px;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 18:44:47 +02:00
|
|
|
|
#graph-container {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 450px;
|
2026-03-31 19:17:37 +02:00
|
|
|
|
border: 1px solid #1a1a1a;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
background: #050505;
|
2026-03-30 22:17:01 +02:00
|
|
|
|
position: relative;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chord-info {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-around;
|
|
|
|
|
|
margin-top: 20px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chord-panel {
|
2026-03-31 19:17:37 +02:00
|
|
|
|
background: #0a0a0a;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
padding: 15px;
|
2026-03-31 19:17:37 +02:00
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
min-width: 200px;
|
|
|
|
|
|
border: 1px solid #1a1a1a;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chord-panel h3 {
|
|
|
|
|
|
margin-top: 0;
|
2026-03-31 19:17:37 +02:00
|
|
|
|
color: #666666;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: 1px;
|
|
|
|
|
|
font-weight: 500;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chord-panel.current {
|
2026-03-31 19:17:37 +02:00
|
|
|
|
border: 1px solid #333333;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chord-panel.current h3 {
|
|
|
|
|
|
color: #00d4ff;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chord-panel.prev, .chord-panel.next {
|
2026-03-31 19:17:37 +02:00
|
|
|
|
opacity: 0.6;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pitch-list {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
padding: 0;
|
2026-03-31 19:17:37 +02:00
|
|
|
|
font-size: 12px;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pitch-list li {
|
2026-03-31 19:17:37 +02:00
|
|
|
|
padding: 4px 0;
|
|
|
|
|
|
color: #888888;
|
|
|
|
|
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', monospace;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.file-input {
|
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.file-input input {
|
2026-03-31 19:17:37 +02:00
|
|
|
|
padding: 8px;
|
|
|
|
|
|
background: #0a0a0a;
|
|
|
|
|
|
color: #888888;
|
|
|
|
|
|
border: 1px solid #222222;
|
|
|
|
|
|
border-radius: 4px;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 11:07:18 +02:00
|
|
|
|
/* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-31 19:17:37 +02:00
|
|
|
|
.file-select {
|
|
|
|
|
|
padding: 8px;
|
|
|
|
|
|
background: #0a0a0a;
|
|
|
|
|
|
color: #888888;
|
|
|
|
|
|
border: 1px solid #222222;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
margin-left: 10px;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
2026-04-09 11:07:18 +02:00
|
|
|
|
|
|
|
|
|
|
/* 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;
|
|
|
|
|
|
}
|
2026-04-20 17:56:11 +02:00
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-03-30 21:06:14 +02:00
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="container">
|
|
|
|
|
|
<h1>Path Navigator</h1>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="file-input">
|
2026-04-01 16:33:13 +02:00
|
|
|
|
<span>File:</span>
|
|
|
|
|
|
<input type="text" id="filepathInput" value="output/output_chords.json" style="width: 250px;">
|
|
|
|
|
|
<button id="loadFileBtn">Load</button>
|
2026-04-01 11:23:24 +02:00
|
|
|
|
<span style="margin-left: 20px;">Fundamental (Hz):</span>
|
2026-04-09 11:07:18 +02:00
|
|
|
|
<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>
|
2026-04-01 18:39:13 +02:00
|
|
|
|
<span style="margin-left: 15px;">Octave:</span>
|
2026-04-09 11:07:18 +02:00
|
|
|
|
<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>
|
2026-04-04 21:46:15 +02:00
|
|
|
|
<span style="margin-left: 15px;">Ramp (s):</span>
|
2026-04-09 11:07:18 +02:00
|
|
|
|
<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>
|
2026-04-07 08:48:06 +02:00
|
|
|
|
<span style="margin-left: 10px;">Exponent:</span>
|
2026-04-09 11:07:18 +02:00
|
|
|
|
<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>
|
2026-04-04 21:46:15 +02:00
|
|
|
|
<span style="margin-left: 15px;">Siren IP:</span>
|
|
|
|
|
|
<input type="text" id="sirenIp" value="192.168.4.200" style="width: 100px;">
|
2026-04-20 17:56:11 +02:00
|
|
|
|
<button id="toggleUnitBtn" class="toggle-btn">Show: Cents</button>
|
2026-03-30 21:06:14 +02:00
|
|
|
|
</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;">
|
2026-04-20 17:56:11 +02:00
|
|
|
|
<span style="margin-left: 20px; border-left: 1px solid #333; padding-left: 20px;">
|
|
|
|
|
|
<button id="rampBtn" class="toggle-btn">RAMP: OFF</button>
|
|
|
|
|
|
<button id="siren1Btn" class="siren-btn">1</button>
|
|
|
|
|
|
<button id="siren2Btn" class="siren-btn">2</button>
|
|
|
|
|
|
<button id="siren3Btn" class="siren-btn">3</button>
|
|
|
|
|
|
<button id="siren4Btn" class="siren-btn">4</button>
|
|
|
|
|
|
<button id="sirenABtn" class="siren-btn">A</button>
|
|
|
|
|
|
</span>
|
2026-03-30 21:06:14 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-31 18:44:47 +02:00
|
|
|
|
<div id="graph-container"></div>
|
2026-03-30 21:06:14 +02:00
|
|
|
|
|
|
|
|
|
|
<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 = [];
|
2026-04-20 17:56:11 +02:00
|
|
|
|
let selectedSiren = 0; // 0 = use node's voice, 1-4 = selected siren
|
|
|
|
|
|
let rampMode = false; // false = play, true = ramp
|
2026-03-30 21:06:14 +02:00
|
|
|
|
let currentIndex = 0;
|
2026-03-30 22:38:52 +02:00
|
|
|
|
let totalSteps = 0;
|
|
|
|
|
|
let hasPrev = false;
|
|
|
|
|
|
let hasNext = false;
|
2026-04-01 09:59:30 +02:00
|
|
|
|
let allGraphsData = null;
|
2026-04-04 13:56:42 +02:00
|
|
|
|
let displayMode = 'cents';
|
|
|
|
|
|
|
2026-04-20 17:56:11 +02:00
|
|
|
|
// Update UI buttons based on state
|
|
|
|
|
|
function updateRampButton() {
|
|
|
|
|
|
const btn = document.getElementById('rampBtn');
|
|
|
|
|
|
if (btn) {
|
|
|
|
|
|
btn.textContent = rampMode ? 'RAMP: ON' : 'RAMP: OFF';
|
|
|
|
|
|
btn.classList.toggle('active', rampMode);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateSirenButtons() {
|
|
|
|
|
|
const buttons = [
|
|
|
|
|
|
{ id: 'siren1Btn', value: 1 },
|
|
|
|
|
|
{ id: 'siren2Btn', value: 2 },
|
|
|
|
|
|
{ id: 'siren3Btn', value: 3 },
|
|
|
|
|
|
{ id: 'siren4Btn', value: 4 },
|
|
|
|
|
|
{ id: 'sirenABtn', value: 0 },
|
|
|
|
|
|
];
|
|
|
|
|
|
buttons.forEach(({ id, value }) => {
|
|
|
|
|
|
const btn = document.getElementById(id);
|
|
|
|
|
|
if (btn) {
|
|
|
|
|
|
btn.classList.toggle('active', selectedSiren === value);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 11:07:18 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 13:56:42 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 19:56:39 +02:00
|
|
|
|
// Play all nodes in a chord on the siren
|
|
|
|
|
|
function playChordOnSiren(chordIdx) {
|
2026-04-04 21:46:15 +02:00
|
|
|
|
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
|
|
|
|
|
|
|
2026-04-04 19:56:39 +02:00
|
|
|
|
// Find all nodes in this chord (exclude label nodes)
|
|
|
|
|
|
const nodes = cy.nodes().filter(n =>
|
|
|
|
|
|
n.data('chordIndex') === chordIdx && !n.data('isLabel')
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// Clear old sirenActive markers
|
|
|
|
|
|
cy.nodes().forEach(n => n.data('sirenActive', ''));
|
|
|
|
|
|
|
|
|
|
|
|
// Play each node using chordIndex + localId (let server calculate frequency)
|
|
|
|
|
|
nodes.forEach((node) => {
|
|
|
|
|
|
const localId = node.data('localId');
|
2026-04-20 18:18:30 +02:00
|
|
|
|
// Label always uses auto (node's voice) - never send sirenNumber
|
2026-04-20 17:56:11 +02:00
|
|
|
|
const body = {
|
|
|
|
|
|
chordIndex: chordIdx,
|
|
|
|
|
|
nodeIndex: localId,
|
|
|
|
|
|
ip: sirenIp,
|
|
|
|
|
|
};
|
2026-04-04 19:56:39 +02:00
|
|
|
|
|
|
|
|
|
|
fetch('/api/play-siren', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {'Content-Type': 'application/json'},
|
2026-04-20 17:56:11 +02:00
|
|
|
|
body: JSON.stringify(body)
|
2026-04-04 19:56:39 +02:00
|
|
|
|
});
|
|
|
|
|
|
node.data('sirenActive', 'true');
|
2026-04-20 17:56:11 +02:00
|
|
|
|
node.data('borderColor', voiceColors[localId % 4]);
|
2026-04-04 19:56:39 +02:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 21:46:15 +02:00
|
|
|
|
// Ramp to a chord or single node on the siren
|
|
|
|
|
|
function rampToChord(chordIdx, nodeIdx = null) {
|
|
|
|
|
|
const durationSeconds = parseFloat(document.getElementById('rampDuration').value) || 3;
|
|
|
|
|
|
const durationMs = durationSeconds * 1000;
|
2026-04-07 08:48:06 +02:00
|
|
|
|
const exponent = parseFloat(document.getElementById('rampExponent').value) || 1.0;
|
2026-04-04 21:46:15 +02:00
|
|
|
|
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
|
|
|
|
|
|
|
|
|
|
|
|
// Find nodes in this chord (exclude label nodes)
|
|
|
|
|
|
const nodes = cy.nodes().filter(n =>
|
|
|
|
|
|
n.data('chordIndex') === chordIdx && !n.data('isLabel')
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// If nodeIdx is provided, clear only same-color nodes; otherwise clear all
|
2026-04-20 22:46:41 +02:00
|
|
|
|
// 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'];
|
|
|
|
|
|
|
2026-04-04 21:46:15 +02:00
|
|
|
|
if (nodeIdx !== null) {
|
2026-04-20 22:46:41 +02:00
|
|
|
|
// 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', '');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-04 21:46:15 +02:00
|
|
|
|
const targetNode = nodes.find(n => n.data('localId') === nodeIdx);
|
|
|
|
|
|
if (targetNode) {
|
|
|
|
|
|
targetNode.data('sirenActive', 'true');
|
2026-04-20 22:46:41 +02:00
|
|
|
|
targetNode.data('borderColor', nodeBorderColor);
|
2026-04-04 21:46:15 +02:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
2026-04-20 22:46:41 +02:00
|
|
|
|
// 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]);
|
|
|
|
|
|
});
|
2026-04-04 21:46:15 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 17:56:11 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 22:46:41 +02:00
|
|
|
|
// Add octaveOffset if single node was transposed
|
|
|
|
|
|
if (nodeIdx !== null) {
|
|
|
|
|
|
const targetNode = nodes.find(n => n.data('localId') === nodeIdx);
|
|
|
|
|
|
if (targetNode) {
|
|
|
|
|
|
rampBody.octaveOffset = targetNode.data('octaveOffset') || 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 21:46:15 +02:00
|
|
|
|
fetch('/api/ramp-to-chord', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {'Content-Type': 'application/json'},
|
2026-04-20 17:56:11 +02:00
|
|
|
|
body: JSON.stringify(rampBody)
|
2026-04-04 21:46:15 +02:00
|
|
|
|
}).then(r => r.json()).then(data => {
|
|
|
|
|
|
console.log('Ramping:', data);
|
|
|
|
|
|
}).catch(err => {
|
|
|
|
|
|
console.log('Error ramping:', err);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 13:56:42 +02:00
|
|
|
|
// 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';
|
2026-04-20 17:56:11 +02:00
|
|
|
|
btn.classList.toggle('active', displayMode === 'frequency');
|
2026-04-04 13:56:42 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-03-30 22:17:01 +02:00
|
|
|
|
|
2026-03-31 18:44:47 +02:00
|
|
|
|
// Cytoscape instance
|
|
|
|
|
|
let cy = null;
|
|
|
|
|
|
|
2026-04-01 09:59:30 +02:00
|
|
|
|
// Graph dimensions - will expand based on number of chords
|
|
|
|
|
|
let graphWidth = 1100;
|
2026-03-31 18:44:47 +02:00
|
|
|
|
const graphHeight = 450;
|
2026-04-01 16:33:13 +02:00
|
|
|
|
let chordSpacing = 350;
|
2026-03-31 18:44:47 +02:00
|
|
|
|
|
2026-04-20 22:46:41 +02:00
|
|
|
|
// Fixed pixels per cent (0.15 = 360 pixels per octave = 2 octaves on screen)
|
|
|
|
|
|
const PIXELS_PER_CENT = 0.15;
|
|
|
|
|
|
|
2026-03-31 18:44:47 +02:00
|
|
|
|
// Voice colors - sleek pastel scheme
|
2026-03-31 19:17:37 +02:00
|
|
|
|
const voiceColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
|
2026-03-31 18:44:47 +02:00
|
|
|
|
|
2026-04-01 09:59:30 +02:00
|
|
|
|
// Create Cytoscape instance for all chords
|
|
|
|
|
|
function initCytoscapeAll(totalChords) {
|
|
|
|
|
|
// Calculate canvas width based on number of chords
|
|
|
|
|
|
graphWidth = Math.max(1100, totalChords * chordSpacing + 400);
|
|
|
|
|
|
|
2026-03-31 18:44:47 +02:00
|
|
|
|
cy = cytoscape({
|
|
|
|
|
|
container: document.getElementById('graph-container'),
|
|
|
|
|
|
style: [
|
|
|
|
|
|
{
|
2026-04-04 19:56:39 +02:00
|
|
|
|
selector: 'node[color]',
|
2026-03-31 18:44:47 +02:00
|
|
|
|
style: {
|
|
|
|
|
|
'background-color': 'data(color)',
|
2026-03-31 19:17:37 +02:00
|
|
|
|
'width': 32,
|
|
|
|
|
|
'height': 32,
|
2026-04-04 13:56:42 +02:00
|
|
|
|
'label': function(ele) { return ele.data('displayLabel'); },
|
2026-03-31 18:44:47 +02:00
|
|
|
|
'text-valign': 'center',
|
|
|
|
|
|
'text-halign': 'center',
|
2026-03-31 19:17:37 +02:00
|
|
|
|
'color': '#000000',
|
|
|
|
|
|
'font-size': '10px',
|
|
|
|
|
|
'font-family': 'monospace',
|
|
|
|
|
|
'font-weight': 'bold',
|
|
|
|
|
|
'text-outline-width': 0,
|
2026-03-31 18:44:47 +02:00
|
|
|
|
'border-width': 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-04 19:56:39 +02:00
|
|
|
|
{
|
|
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-01 18:01:09 +02:00
|
|
|
|
{
|
|
|
|
|
|
selector: 'node[sirenActive = "true"]',
|
|
|
|
|
|
style: {
|
|
|
|
|
|
'border-width': 4,
|
2026-04-20 17:56:11 +02:00
|
|
|
|
'border-color': 'data(borderColor)',
|
2026-04-01 18:01:09 +02:00
|
|
|
|
'border-opacity': 1,
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-03-31 18:44:47 +02:00
|
|
|
|
{
|
|
|
|
|
|
selector: 'edge',
|
|
|
|
|
|
style: {
|
2026-04-01 17:48:47 +02:00
|
|
|
|
'width': 2.5,
|
|
|
|
|
|
'line-color': '#ffffff',
|
2026-03-31 18:44:47 +02:00
|
|
|
|
'curve-style': 'straight',
|
|
|
|
|
|
'target-arrow-shape': 'none',
|
|
|
|
|
|
'label': 'data(ratio)',
|
2026-03-31 19:17:37 +02:00
|
|
|
|
'font-size': '12px',
|
|
|
|
|
|
'color': '#ffffff',
|
2026-03-31 18:44:47 +02:00
|
|
|
|
'text-rotation': 'autorotate',
|
2026-03-31 19:17:37 +02:00
|
|
|
|
'text-margin-y': -10,
|
|
|
|
|
|
'text-background-color': '#000000',
|
2026-03-31 18:44:47 +02:00
|
|
|
|
'text-background-opacity': 0.8,
|
2026-03-31 19:17:37 +02:00
|
|
|
|
'text-background-padding': '2px',
|
2026-03-31 18:44:47 +02:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-01 17:48:47 +02:00
|
|
|
|
{
|
|
|
|
|
|
selector: 'edge[isCrossChord = "true"]',
|
|
|
|
|
|
style: {
|
|
|
|
|
|
'width': 1,
|
|
|
|
|
|
'line-color': '#aaaaaa',
|
|
|
|
|
|
'line-style': 'dashed',
|
|
|
|
|
|
'curve-style': 'straight',
|
|
|
|
|
|
'target-arrow-shape': 'none',
|
|
|
|
|
|
'label': '',
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-03-31 18:44:47 +02:00
|
|
|
|
{
|
|
|
|
|
|
selector: ':selected',
|
|
|
|
|
|
style: {
|
2026-03-31 19:17:37 +02:00
|
|
|
|
'border-width': 2,
|
|
|
|
|
|
'border-color': '#00d4ff',
|
2026-03-31 18:44:47 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
layout: {
|
|
|
|
|
|
name: 'preset',
|
|
|
|
|
|
},
|
2026-04-01 16:33:13 +02:00
|
|
|
|
minZoom: 0.3,
|
|
|
|
|
|
maxZoom: 3,
|
|
|
|
|
|
zoomingEnabled: true,
|
|
|
|
|
|
panningEnabled: true,
|
|
|
|
|
|
wheelSensitivity: 0.2, // Reduce wheel zoom sensitivity
|
2026-03-31 18:44:47 +02:00
|
|
|
|
autounselectify: true,
|
|
|
|
|
|
boxSelectionEnabled: false,
|
2026-04-01 16:33:13 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 22:46:41 +02:00
|
|
|
|
// Track drag for X-move and Y-octave drag
|
2026-04-01 11:23:24 +02:00
|
|
|
|
let isDragging = false;
|
2026-04-20 22:46:41 +02:00
|
|
|
|
let dragDirection = null; // 'x' or 'y'
|
|
|
|
|
|
let grabPos = null;
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate Y from cents using the same formula as rendering
|
|
|
|
|
|
function centsToY(cents, globalMinCents, globalCentsRange) {
|
|
|
|
|
|
const graphHeight = 450;
|
|
|
|
|
|
const yBase = graphHeight * 0.1;
|
|
|
|
|
|
const ySpread = graphHeight * 0.8;
|
|
|
|
|
|
return yBase + ySpread - (cents * PIXELS_PER_CENT);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate cents from Y using the reverse formula
|
|
|
|
|
|
function yToCents(y, globalMinCents, globalCentsRange) {
|
|
|
|
|
|
const graphHeight = 450;
|
|
|
|
|
|
const yBase = graphHeight * 0.1;
|
|
|
|
|
|
const ySpread = graphHeight * 0.8;
|
|
|
|
|
|
return (yBase + ySpread - y) / PIXELS_PER_CENT;
|
|
|
|
|
|
}
|
2026-04-01 11:23:24 +02:00
|
|
|
|
|
2026-03-31 18:44:47 +02:00
|
|
|
|
cy.on('grab', 'node', function(evt) {
|
|
|
|
|
|
const node = evt.target;
|
2026-04-20 22:46:41 +02:00
|
|
|
|
// 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);
|
2026-03-31 18:44:47 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
cy.on('drag', 'node', function(evt) {
|
|
|
|
|
|
const node = evt.target;
|
2026-04-01 11:23:24 +02:00
|
|
|
|
|
2026-04-20 22:46:41 +02:00
|
|
|
|
// Track X vs Y movement to determine drag direction
|
|
|
|
|
|
if (grabPos) {
|
|
|
|
|
|
const dx = Math.abs(node.position('x') - grabPos.x);
|
|
|
|
|
|
const dy = Math.abs(node.position('y') - grabPos.y);
|
|
|
|
|
|
|
|
|
|
|
|
if (dx > 3 || dy > 3) {
|
|
|
|
|
|
isDragging = true;
|
|
|
|
|
|
// Determine which direction dominates
|
|
|
|
|
|
if (dy > dx) {
|
|
|
|
|
|
dragDirection = 'y';
|
|
|
|
|
|
// Snap X to original, free Y
|
|
|
|
|
|
node.position('x', grabPos.x);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
dragDirection = 'x';
|
|
|
|
|
|
// Snap Y to original, free X
|
|
|
|
|
|
node.position('y', grabPos.y);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
cy.on('dragfree', 'node', function(evt) {
|
|
|
|
|
|
const node = evt.target;
|
|
|
|
|
|
|
|
|
|
|
|
if (grabPos && isDragging && dragDirection === 'y') {
|
|
|
|
|
|
// Calculate current cents from Y position using reverse formula
|
|
|
|
|
|
const currentY = node.position('y');
|
|
|
|
|
|
const currentCents = yToCents(currentY);
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate octave offset from original
|
|
|
|
|
|
const octaveOffset = Math.round((currentCents - grabPos.cents) / 1200);
|
|
|
|
|
|
|
|
|
|
|
|
// Snap to nearest octave
|
|
|
|
|
|
const snappedCents = grabPos.cents + (octaveOffset * 1200);
|
|
|
|
|
|
const snappedY = centsToY(snappedCents);
|
|
|
|
|
|
|
|
|
|
|
|
node.data('cents', snappedCents);
|
|
|
|
|
|
node.position('y', snappedY);
|
|
|
|
|
|
node.data('octaveOffset', octaveOffset); // Store for click handler
|
|
|
|
|
|
|
|
|
|
|
|
// Update display label based on displayMode
|
|
|
|
|
|
if (displayMode === 'frequency') {
|
|
|
|
|
|
const frac = parseFraction(node.data('fraction'));
|
|
|
|
|
|
const fundamental = parseFloat(document.getElementById("fundamentalInput").value) || 110;
|
|
|
|
|
|
const freq = fundamental * frac * Math.pow(2, octaveOffset);
|
|
|
|
|
|
node.data('displayLabel', Math.round(freq * 10) / 10);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
node.data('displayLabel', Math.round(snappedCents));
|
|
|
|
|
|
}
|
|
|
|
|
|
node.style('label', node.data('displayLabel'));
|
|
|
|
|
|
|
|
|
|
|
|
console.log('Y-drag: originalY=', grabPos.y, 'currentY=', currentY, 'originalCents=', grabPos.cents, 'currentCents=', currentCents.toFixed(1), 'octaveOffset=', octaveOffset, 'snappedCents=', snappedCents);
|
2026-04-01 11:23:24 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 22:46:41 +02:00
|
|
|
|
// For X drag, just reset Y to original (visual only - allow X to stay where dragged)
|
|
|
|
|
|
if (grabPos && isDragging && dragDirection === 'x') {
|
|
|
|
|
|
node.position('y', grabPos.y);
|
|
|
|
|
|
console.log('X-drag: visual only');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
isDragging = false;
|
|
|
|
|
|
dragDirection = null;
|
|
|
|
|
|
grabPos = null;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
cy.on('drag', 'node', function(evt) {
|
|
|
|
|
|
const node = evt.target;
|
|
|
|
|
|
|
|
|
|
|
|
// Track X vs Y movement to determine drag direction
|
|
|
|
|
|
if (grabPos) {
|
|
|
|
|
|
const dx = Math.abs(node.position('x') - grabPos.x);
|
|
|
|
|
|
const dy = Math.abs(node.position('y') - grabPos.y);
|
|
|
|
|
|
|
|
|
|
|
|
if (dx > 3 || dy > 3) {
|
|
|
|
|
|
isDragging = true;
|
|
|
|
|
|
// Determine which direction dominates
|
|
|
|
|
|
if (dy > dx) {
|
|
|
|
|
|
dragDirection = 'y';
|
|
|
|
|
|
// Snap X to original, free Y
|
|
|
|
|
|
node.position('x', grabPos.x);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
dragDirection = 'x';
|
|
|
|
|
|
// Snap Y to original, free X
|
|
|
|
|
|
node.position('y', grabPos.y);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-31 18:44:47 +02:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-04-01 11:23:24 +02:00
|
|
|
|
|
|
|
|
|
|
cy.on('dragfree', 'node', function(evt) {
|
2026-04-20 22:46:41 +02:00
|
|
|
|
const node = evt.target;
|
|
|
|
|
|
const originalCents = node.data('originalCents');
|
|
|
|
|
|
|
|
|
|
|
|
if (grabPos && isDragging && dragDirection === 'y') {
|
|
|
|
|
|
const dy = node.position('y') - grabPos.y;
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate octave offset from drag distance
|
|
|
|
|
|
// Each octave = 1200 cents
|
|
|
|
|
|
// Get global cents range from graph data
|
|
|
|
|
|
const allGraphsData_json = document.getElementById('allGraphsData');
|
|
|
|
|
|
let globalRange = 1200;
|
|
|
|
|
|
if (allGraphsData_json) {
|
|
|
|
|
|
const data = JSON.parse(allGraphsData_json.textContent);
|
|
|
|
|
|
const allCents = [];
|
|
|
|
|
|
data.graphs.forEach(g => {
|
|
|
|
|
|
if (g.nodes) allCents.push(...g.nodes.map(n => n.cents));
|
|
|
|
|
|
});
|
|
|
|
|
|
if (allCents.length > 0) {
|
|
|
|
|
|
globalRange = Math.max(...allCents) - Math.min(...allCents);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const graphHeight = 450;
|
|
|
|
|
|
const centsPerPixel = globalRange / graphHeight;
|
|
|
|
|
|
// Invert: dragging UP (negative dy) = higher cents
|
|
|
|
|
|
const centsDelta = -dy * centsPerPixel;
|
|
|
|
|
|
const octaveOffset = Math.round(centsDelta / 1200);
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate new cents and update position
|
|
|
|
|
|
const newCents = originalCents + (octaveOffset * 1200);
|
|
|
|
|
|
const newY = grabPos.y - (octaveOffset * 1200) / centsPerPixel;
|
|
|
|
|
|
|
|
|
|
|
|
node.data('cents', newCents);
|
|
|
|
|
|
node.position('y', newY);
|
|
|
|
|
|
node.data('displayLabel', Math.round(newCents));
|
|
|
|
|
|
node.style('label', Math.round(newCents));
|
|
|
|
|
|
|
|
|
|
|
|
console.log('Y-drag: dy=', dy, 'centsDelta=', centsDelta, 'octaves=', octaveOffset, 'newCents=', newCents);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For X drag, just reset X to original (visual only)
|
|
|
|
|
|
if (grabPos && isDragging && dragDirection === 'x') {
|
|
|
|
|
|
node.position('x', grabPos.x);
|
|
|
|
|
|
console.log('X-drag: visual only');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 11:23:24 +02:00
|
|
|
|
isDragging = false;
|
2026-04-20 22:46:41 +02:00
|
|
|
|
dragDirection = null;
|
|
|
|
|
|
grabPos = null;
|
2026-04-01 11:23:24 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-04 21:46:15 +02:00
|
|
|
|
// Check modifiers
|
|
|
|
|
|
const isShift = evt.originalEvent && evt.originalEvent.shiftKey;
|
|
|
|
|
|
|
2026-04-04 19:56:39 +02:00
|
|
|
|
// Handle label node clicks
|
|
|
|
|
|
if (node.data('isLabel')) {
|
|
|
|
|
|
const chordIdx = node.data('chordIndex');
|
2026-04-20 18:18:30 +02:00
|
|
|
|
if (rampMode) {
|
2026-04-04 21:46:15 +02:00
|
|
|
|
rampToChord(chordIdx);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
playChordOnSiren(chordIdx);
|
|
|
|
|
|
}
|
2026-04-04 19:56:39 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 11:23:24 +02:00
|
|
|
|
const chordIndex = node.data('chordIndex');
|
|
|
|
|
|
const localId = node.data('localId');
|
2026-04-20 17:56:11 +02:00
|
|
|
|
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
|
2026-04-01 11:23:24 +02:00
|
|
|
|
|
2026-04-20 18:18:30 +02:00
|
|
|
|
// 1. Shift+click → SuperCollider (FIRST)
|
2026-04-20 17:56:11 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 18:18:30 +02:00
|
|
|
|
// 2. Ramp mode ON → ramp to siren
|
|
|
|
|
|
if (rampMode) {
|
|
|
|
|
|
rampToChord(chordIndex, localId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. Plain click: play to siren
|
2026-04-20 17:56:11 +02:00
|
|
|
|
const endpoint = '/api/play-siren';
|
|
|
|
|
|
const destination = 'siren';
|
2026-04-01 11:23:24 +02:00
|
|
|
|
|
2026-04-20 17:56:11 +02:00
|
|
|
|
// Determine which siren: if selectedSiren > 0 use it, otherwise use node's voice (localId + 1)
|
|
|
|
|
|
const actualSiren = selectedSiren > 0 ? selectedSiren : localId + 1;
|
|
|
|
|
|
|
|
|
|
|
|
console.log('Sending to siren:', chordIndex, localId, 'siren:', actualSiren);
|
|
|
|
|
|
|
|
|
|
|
|
const requestBody = {
|
|
|
|
|
|
chordIndex: chordIndex,
|
|
|
|
|
|
nodeIndex: localId,
|
|
|
|
|
|
ip: sirenIp,
|
2026-04-20 22:46:41 +02:00
|
|
|
|
sirenNumber: actualSiren,
|
|
|
|
|
|
octaveOffset: node.data('octaveOffset') || 0
|
2026-04-20 17:56:11 +02:00
|
|
|
|
};
|
2026-04-01 13:01:50 +02:00
|
|
|
|
|
|
|
|
|
|
fetch(endpoint, {
|
2026-04-01 11:23:24 +02:00
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {'Content-Type': 'application/json'},
|
2026-04-20 17:56:11 +02:00
|
|
|
|
body: JSON.stringify(requestBody)
|
2026-04-01 11:23:24 +02:00
|
|
|
|
}).then(r => r.json()).then(data => {
|
2026-04-20 17:56:11 +02:00
|
|
|
|
console.log('Playing on', destination + ':', data.frequency.toFixed(2), 'Hz on siren', data.siren);
|
2026-04-01 18:01:09 +02:00
|
|
|
|
|
2026-04-20 17:56:11 +02:00
|
|
|
|
// Add colored circle around node based on siren
|
|
|
|
|
|
const sirenColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
|
|
|
|
|
|
const borderColor = sirenColors[(actualSiren - 1) % 4];
|
|
|
|
|
|
|
|
|
|
|
|
// Find any existing node with same border color that has sirenActive and remove it
|
|
|
|
|
|
cy.nodes().forEach(n => {
|
|
|
|
|
|
if (n.data('borderColor') === borderColor && n.data('sirenActive')) {
|
|
|
|
|
|
n.data('sirenActive', '');
|
|
|
|
|
|
n.data('borderColor', '');
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
// Add sirenActive to clicked node
|
|
|
|
|
|
node.data('sirenActive', 'true');
|
|
|
|
|
|
node.data('borderColor', borderColor);
|
|
|
|
|
|
console.log('Added sirenActive to', node.id(), 'color:', borderColor);
|
2026-04-01 11:23:24 +02:00
|
|
|
|
}).catch(err => {
|
|
|
|
|
|
console.log('Error playing freq:', err);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-03-31 18:44:47 +02:00
|
|
|
|
}
|
2026-03-30 22:17:01 +02:00
|
|
|
|
|
2026-04-01 09:59:30 +02:00
|
|
|
|
// 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 = [];
|
|
|
|
|
|
|
2026-04-01 17:48:47 +02:00
|
|
|
|
// 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"
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 09:59:30 +02:00
|
|
|
|
allGraphsData.graphs.forEach((graph, chordIdx) => {
|
|
|
|
|
|
if (!graph || !graph.nodes) return;
|
|
|
|
|
|
|
2026-04-04 19:56:39 +02:00
|
|
|
|
const nodes = graph.nodes;
|
|
|
|
|
|
const edges = graph.edges || [];
|
|
|
|
|
|
const gap = 50;
|
|
|
|
|
|
const xBase = 100 + chordIdx * (chordSpacing + gap);
|
|
|
|
|
|
const idMap = {};
|
2026-04-01 09:59:30 +02:00
|
|
|
|
|
|
|
|
|
|
// 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));
|
2026-04-20 22:46:41 +02:00
|
|
|
|
const y = yBase + ySpread - (n.cents * PIXELS_PER_CENT);
|
2026-04-01 09:59:30 +02:00
|
|
|
|
|
|
|
|
|
|
elements.push({
|
|
|
|
|
|
group: 'nodes',
|
|
|
|
|
|
data: {
|
|
|
|
|
|
id: nodeId,
|
|
|
|
|
|
localId: n.id,
|
|
|
|
|
|
cents: n.cents,
|
2026-04-04 13:56:42 +02:00
|
|
|
|
displayLabel: n.cents,
|
|
|
|
|
|
fraction: n.fraction || "1",
|
2026-04-01 09:59:30 +02:00
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-04-04 19:56:39 +02:00
|
|
|
|
|
2026-04-20 22:46:41 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
|
2026-04-04 19:56:39 +02:00
|
|
|
|
// 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",
|
|
|
|
|
|
},
|
2026-04-20 22:46:41 +02:00
|
|
|
|
position: { x: xBase + (chordSpacing * 0.15), y: labelY },
|
2026-04-04 19:56:39 +02:00
|
|
|
|
locked: true,
|
|
|
|
|
|
});
|
2026-04-01 09:59:30 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (elements.length === 0) return;
|
|
|
|
|
|
|
2026-04-01 17:48:47 +02:00
|
|
|
|
// Add cross-chord edges to elements BEFORE layout (so xforce considers them)
|
|
|
|
|
|
elements.push(...crossChordEdges);
|
|
|
|
|
|
|
2026-04-01 09:59:30 +02:00
|
|
|
|
// 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
|
2026-04-01 17:48:47 +02:00
|
|
|
|
const layout = cy.layout({
|
2026-04-01 09:59:30 +02:00
|
|
|
|
name: 'xforce',
|
|
|
|
|
|
linkDistance: 60,
|
|
|
|
|
|
linkStrength: 0.1,
|
2026-04-01 17:48:47 +02:00
|
|
|
|
crossChordStrength: 0.00005,
|
2026-04-01 09:59:30 +02:00
|
|
|
|
charge: -60,
|
|
|
|
|
|
collisionDistance: 35,
|
|
|
|
|
|
damping: 0.7,
|
|
|
|
|
|
iterations: 250,
|
|
|
|
|
|
bounds: bounds,
|
2026-04-01 17:48:47 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
layout.run();
|
2026-04-01 09:59:30 +02:00
|
|
|
|
|
|
|
|
|
|
// Set canvas size
|
2026-04-01 13:18:09 +02:00
|
|
|
|
cy.style().json()[0].value = graphWidth;
|
2026-04-01 09:59:30 +02:00
|
|
|
|
|
|
|
|
|
|
// 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'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 16:37:45 +02:00
|
|
|
|
// Load from Flask API - get chords and compute graphs client-side
|
2026-04-01 09:59:30 +02:00
|
|
|
|
async function loadAllGraphs() {
|
|
|
|
|
|
try {
|
2026-04-01 16:44:20 +02:00
|
|
|
|
// Step 1: Get raw chord data
|
2026-04-01 16:37:45 +02:00
|
|
|
|
const response = await fetch("/api/chords");
|
2026-04-01 09:59:30 +02:00
|
|
|
|
if (!response.ok) throw new Error("API not available");
|
2026-04-01 16:37:45 +02:00
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
2026-04-01 16:44:20 +02:00
|
|
|
|
// 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;
|
2026-04-01 16:37:45 +02:00
|
|
|
|
const graphs = data.chords.map((chord, index) => {
|
2026-04-01 16:44:20 +02:00
|
|
|
|
return calculateGraph(chord, index, () => allCents[centsIndex++]);
|
2026-04-01 16:37:45 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
allGraphsData = {
|
|
|
|
|
|
total: data.total,
|
|
|
|
|
|
graphs: graphs
|
|
|
|
|
|
};
|
2026-04-01 09:59:30 +02:00
|
|
|
|
|
|
|
|
|
|
totalSteps = allGraphsData.total - 1;
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize Cytoscape with total chord count
|
|
|
|
|
|
initCytoscapeAll(allGraphsData.total);
|
|
|
|
|
|
|
|
|
|
|
|
// Render all chords
|
|
|
|
|
|
renderAllChords();
|
|
|
|
|
|
|
|
|
|
|
|
// Update UI
|
|
|
|
|
|
updateUI();
|
|
|
|
|
|
|
|
|
|
|
|
} catch (e) {
|
2026-04-01 16:37:45 +02:00
|
|
|
|
console.log("Error loading chords:", e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 16:44:20 +02:00
|
|
|
|
// Compute graph (nodes + edges) from raw chord data - using API for cents
|
|
|
|
|
|
function calculateGraph(chord, index, getNextCent) {
|
2026-04-01 16:37:45 +02:00
|
|
|
|
if (!chord) return { nodes: [], edges: [] };
|
|
|
|
|
|
|
|
|
|
|
|
const nodes = [];
|
|
|
|
|
|
const dims = [2, 3, 5, 7];
|
|
|
|
|
|
|
2026-04-01 16:44:20 +02:00
|
|
|
|
// Calculate cents for each pitch (fetched from server)
|
2026-04-01 16:37:45 +02:00
|
|
|
|
for (let i = 0; i < chord.length; i++) {
|
|
|
|
|
|
const pitch = chord[i];
|
2026-04-01 16:44:20 +02:00
|
|
|
|
const cents = getNextCent();
|
2026-04-01 16:37:45 +02:00
|
|
|
|
|
|
|
|
|
|
nodes.push({
|
|
|
|
|
|
id: i,
|
|
|
|
|
|
cents: Math.round(cents),
|
2026-04-04 13:56:42 +02:00
|
|
|
|
displayLabel: Math.round(cents),
|
2026-04-01 16:37:45 +02:00
|
|
|
|
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;
|
2026-04-01 18:01:09 +02:00
|
|
|
|
let broke = false;
|
2026-04-01 16:37:45 +02:00
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-04-01 18:01:09 +02:00
|
|
|
|
broke = true;
|
2026-04-01 16:37:45 +02:00
|
|
|
|
break; // diff > 1 in this dimension
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 18:01:09 +02:00
|
|
|
|
// Check if exactly one dimension differs AND loop didn't break early
|
|
|
|
|
|
if (!broke && diffCount === 1 && diffDim > 0) {
|
2026-04-01 16:37:45 +02:00
|
|
|
|
// 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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 09:59:30 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 16:55:29 +02:00
|
|
|
|
// Fundamental input - send on Enter, Up/Down arrows
|
2026-04-01 13:18:09 +02:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-01 17:02:08 +02:00
|
|
|
|
// Trigger on spinner button clicks (change event fires on blur after value change)
|
|
|
|
|
|
document.getElementById("fundamentalInput").addEventListener("change", () => {
|
2026-04-01 16:57:00 +02:00
|
|
|
|
setFundamental();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-01 16:55:29 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-01 13:18:09 +02:00
|
|
|
|
|
2026-04-01 16:33:13 +02:00
|
|
|
|
// File input handler
|
|
|
|
|
|
document.getElementById("loadFileBtn").addEventListener("click", async () => {
|
|
|
|
|
|
const filepath = document.getElementById("filepathInput").value;
|
|
|
|
|
|
if (!filepath) return;
|
|
|
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
|
try {
|
2026-04-01 16:33:13 +02:00
|
|
|
|
const response = await fetch("/api/load-file", {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: {"Content-Type": "application/json"},
|
|
|
|
|
|
body: JSON.stringify({ filepath: filepath })
|
|
|
|
|
|
});
|
2026-03-30 22:38:52 +02:00
|
|
|
|
const data = await response.json();
|
2026-04-01 16:33:13 +02:00
|
|
|
|
if (data.error) {
|
|
|
|
|
|
alert(data.error);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
loadAllGraphs();
|
2026-03-30 22:38:52 +02:00
|
|
|
|
}
|
2026-04-01 16:33:13 +02:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
alert("Error loading file: " + err);
|
|
|
|
|
|
}
|
2026-03-30 22:38:52 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-01 16:33:13 +02:00
|
|
|
|
// Also allow Enter key in filepath input
|
|
|
|
|
|
document.getElementById("filepathInput").addEventListener("keydown", (e) => {
|
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
|
document.getElementById("loadFileBtn").click();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-03-30 22:38:52 +02:00
|
|
|
|
|
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 = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
|
function updateChordPanel(elementId, data) {
|
2026-03-31 19:17:37 +02:00
|
|
|
|
const container = document.getElementById(elementId);
|
|
|
|
|
|
container.innerHTML = "";
|
2026-03-30 21:06:14 +02:00
|
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
|
if (!data || (Array.isArray(data) && data.length === 0)) {
|
2026-03-31 19:17:37 +02:00
|
|
|
|
container.innerHTML = "<div>(none)</div>";
|
2026-03-30 21:06:14 +02:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 22:38:52 +02:00
|
|
|
|
const items = Array.isArray(data) ? data : (data.nodes || []);
|
|
|
|
|
|
|
2026-03-31 19:17:37 +02:00
|
|
|
|
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";
|
2026-04-01 09:18:39 +02:00
|
|
|
|
th.style.width = "28px";
|
2026-03-31 19:17:37 +02:00
|
|
|
|
headerRow.appendChild(th);
|
2026-03-30 21:06:14 +02:00
|
|
|
|
});
|
2026-03-31 19:17:37 +02:00
|
|
|
|
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";
|
2026-04-01 09:18:39 +02:00
|
|
|
|
td.style.width = "28px";
|
2026-03-31 19:17:37 +02:00
|
|
|
|
if (j === 0) {
|
|
|
|
|
|
td.style.textAlign = "left";
|
|
|
|
|
|
}
|
|
|
|
|
|
row.appendChild(td);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
table.appendChild(row);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
container.appendChild(table);
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-01 09:59:30 +02:00
|
|
|
|
// Navigation with pan animation
|
2026-03-30 22:38:52 +02:00
|
|
|
|
async function navigate(direction) {
|
2026-03-30 22:17:01 +02:00
|
|
|
|
if (direction === 'prev' && currentIndex > 0) {
|
2026-03-30 21:06:14 +02:00
|
|
|
|
currentIndex--;
|
2026-04-01 09:59:30 +02:00
|
|
|
|
} else if (direction === 'next' && currentIndex < totalSteps) {
|
2026-03-30 22:17:01 +02:00
|
|
|
|
currentIndex++;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return;
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
2026-03-30 22:17:01 +02:00
|
|
|
|
|
2026-04-01 09:59:30 +02:00
|
|
|
|
// Pan to new chord position
|
|
|
|
|
|
panToIndex(currentIndex);
|
2026-03-30 22:17:01 +02:00
|
|
|
|
|
2026-04-01 09:59:30 +02:00
|
|
|
|
// Update UI
|
|
|
|
|
|
updateUI();
|
2026-03-30 22:17:01 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Navigation
|
|
|
|
|
|
document.getElementById("prevBtn").addEventListener("click", () => {
|
|
|
|
|
|
navigate('prev');
|
2026-03-30 21:06:14 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
document.getElementById("nextBtn").addEventListener("click", () => {
|
2026-03-30 22:17:01 +02:00
|
|
|
|
navigate('next');
|
2026-03-30 21:06:14 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-20 17:56:11 +02:00
|
|
|
|
// 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();
|
|
|
|
|
|
|
2026-03-30 21:06:14 +02:00
|
|
|
|
// Keyboard navigation
|
|
|
|
|
|
document.addEventListener("keydown", (e) => {
|
2026-04-20 17:56:11 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 21:06:14 +02:00
|
|
|
|
if (e.key === "ArrowLeft") {
|
2026-03-30 22:17:01 +02:00
|
|
|
|
navigate('prev');
|
2026-03-30 21:06:14 +02:00
|
|
|
|
} else if (e.key === "ArrowRight") {
|
2026-03-30 22:17:01 +02:00
|
|
|
|
navigate('next');
|
2026-04-01 16:33:13 +02:00
|
|
|
|
} 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 } });
|
|
|
|
|
|
}
|
2026-04-20 17:56:11 +02:00
|
|
|
|
} 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();
|
2026-04-01 13:01:50 +02:00
|
|
|
|
} else if (e.key === "k") {
|
|
|
|
|
|
// Soft kill - send 20 Hz to stop voices gently
|
2026-04-04 21:46:15 +02:00
|
|
|
|
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
|
2026-04-01 13:01:50 +02:00
|
|
|
|
fetch('/api/kill-siren', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {'Content-Type': 'application/json'},
|
2026-04-04 21:46:15 +02:00
|
|
|
|
body: JSON.stringify({ soft: true, ip: sirenIp })
|
2026-04-01 13:01:50 +02:00
|
|
|
|
}).then(r => r.json()).then(data => {
|
|
|
|
|
|
console.log('Soft kill sent (20 Hz)');
|
2026-04-01 18:01:09 +02:00
|
|
|
|
// Clear all siren circles
|
2026-04-20 17:56:11 +02:00
|
|
|
|
cy.nodes().forEach(n => {
|
|
|
|
|
|
n.removeData('sirenActive');
|
|
|
|
|
|
n.removeData('borderColor');
|
|
|
|
|
|
});
|
2026-04-01 13:01:50 +02:00
|
|
|
|
}).catch(err => {
|
|
|
|
|
|
console.log('Error sending kill:', err);
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if (e.key === "K") {
|
|
|
|
|
|
// Hard kill - send 0 Hz to stop voices immediately
|
2026-04-04 21:46:15 +02:00
|
|
|
|
const sirenIp = document.getElementById('sirenIp').value || "192.168.4.200";
|
2026-04-01 13:01:50 +02:00
|
|
|
|
fetch('/api/kill-siren', {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {'Content-Type': 'application/json'},
|
2026-04-04 21:46:15 +02:00
|
|
|
|
body: JSON.stringify({ soft: false, ip: sirenIp })
|
2026-04-01 13:01:50 +02:00
|
|
|
|
}).then(r => r.json()).then(data => {
|
|
|
|
|
|
console.log('Hard kill sent (0 Hz)');
|
2026-04-01 18:01:09 +02:00
|
|
|
|
// Clear all siren circles
|
2026-04-20 17:56:11 +02:00
|
|
|
|
cy.nodes().forEach(n => {
|
|
|
|
|
|
n.removeData('sirenActive');
|
|
|
|
|
|
n.removeData('borderColor');
|
|
|
|
|
|
});
|
2026-04-01 13:01:50 +02:00
|
|
|
|
}).catch(err => {
|
|
|
|
|
|
console.log('Error sending kill:', err);
|
|
|
|
|
|
});
|
2026-04-04 13:56:42 +02:00
|
|
|
|
} else if (e.key === "f" || e.key === "F") {
|
|
|
|
|
|
toggleDisplayUnit();
|
2026-03-30 21:06:14 +02:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize
|
2026-04-01 09:59:30 +02:00
|
|
|
|
loadAllGraphs();
|
2026-03-30 21:06:14 +02:00
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|