Add Y-axis octave drag, fix label positions, display toggle, ramp coloring, server octaveOffset support

This commit is contained in:
Michael Winter 2026-04-20 22:46:41 +02:00
parent a28ebb8a71
commit e709d1a7a7
2 changed files with 225 additions and 31 deletions

View file

@ -503,20 +503,40 @@
); );
// If nodeIdx is provided, clear only same-color nodes; otherwise clear all // 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) { if (nodeIdx !== null) {
const targetNode = nodes.find(n => n.data('localId') === nodeIdx); // Single node ramp - use actualSiren
if (targetNode) { const actualSiren = selectedSiren > 0 ? selectedSiren : (nodeIdx + 1);
const nodeColor = targetNode.data('color'); const nodeBorderColor = sirenColors[(actualSiren - 1) % 4];
cy.nodes().forEach(n => { cy.nodes().forEach(n => {
if (n.data('color') === nodeColor && n.data('sirenActive')) { if (n.data('borderColor') === nodeBorderColor && n.data('sirenActive')) {
n.data('sirenActive', ''); n.data('sirenActive', '');
n.data('borderColor', '');
} }
}); });
const targetNode = nodes.find(n => n.data('localId') === nodeIdx);
if (targetNode) {
targetNode.data('sirenActive', 'true'); targetNode.data('sirenActive', 'true');
targetNode.data('borderColor', nodeBorderColor);
} }
} else { } else {
cy.nodes().forEach(n => n.data('sirenActive', '')); // Entire chord ramp - each node gets its own color
nodes.forEach(node => node.data('sirenActive', 'true')); 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 = { const rampBody = {
@ -532,6 +552,14 @@
rampBody.sirenNumber = selectedSiren; rampBody.sirenNumber = selectedSiren;
} }
// Add octaveOffset if single node was transposed
if (nodeIdx !== null) {
const targetNode = nodes.find(n => n.data('localId') === nodeIdx);
if (targetNode) {
rampBody.octaveOffset = targetNode.data('octaveOffset') || 0;
}
}
fetch('/api/ramp-to-chord', { fetch('/api/ramp-to-chord', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
@ -588,6 +616,9 @@
const graphHeight = 450; const graphHeight = 450;
let chordSpacing = 350; 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 // Voice colors - sleek pastel scheme
const voiceColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700']; const voiceColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
@ -688,35 +719,183 @@
boxSelectionEnabled: false, boxSelectionEnabled: false,
}); });
// Lock y on drag - only allow x movement // Track drag for X-move and Y-octave drag
let isDragging = false; let isDragging = false;
let grabPosition = null; let dragDirection = null; // 'x' or 'y'
let grabPos = null;
// Calculate Y from cents using the same formula as rendering
function centsToY(cents, globalMinCents, globalCentsRange) {
const graphHeight = 450;
const yBase = graphHeight * 0.1;
const ySpread = graphHeight * 0.8;
return yBase + ySpread - (cents * PIXELS_PER_CENT);
}
// Calculate cents from Y using the reverse formula
function yToCents(y, globalMinCents, globalCentsRange) {
const graphHeight = 450;
const yBase = graphHeight * 0.1;
const ySpread = graphHeight * 0.8;
return (yBase + ySpread - y) / PIXELS_PER_CENT;
}
cy.on('grab', 'node', function(evt) { cy.on('grab', 'node', function(evt) {
console.log('GRAB event');
const node = evt.target; const node = evt.target;
grabPosition = node.position('y'); // Only set trueOriginalCents on first grab (never overwritten)
node.data('originalY', grabPosition); 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) { cy.on('drag', 'node', function(evt) {
const node = evt.target; const node = evt.target;
const originalY = node.data('originalY');
// Only mark as dragging if it actually moved from grab position // Track X vs Y movement to determine drag direction
if (grabPosition !== null && Math.abs(node.position('y') - grabPosition) > 1) { 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; 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);
}
} }
if (originalY !== undefined) {
node.position('y', originalY);
} }
}); });
cy.on('dragfree', 'node', function(evt) { cy.on('dragfree', 'node', function(evt) {
console.log('DRAGFREE event'); const node = evt.target;
if (grabPos && isDragging && dragDirection === 'y') {
// Calculate current cents from Y position using reverse formula
const currentY = node.position('y');
const currentCents = yToCents(currentY);
// Calculate octave offset from original
const octaveOffset = Math.round((currentCents - grabPos.cents) / 1200);
// Snap to nearest octave
const snappedCents = grabPos.cents + (octaveOffset * 1200);
const snappedY = centsToY(snappedCents);
node.data('cents', snappedCents);
node.position('y', snappedY);
node.data('octaveOffset', octaveOffset); // Store for click handler
// Update display label based on displayMode
if (displayMode === 'frequency') {
const frac = parseFraction(node.data('fraction'));
const fundamental = parseFloat(document.getElementById("fundamentalInput").value) || 110;
const freq = fundamental * frac * Math.pow(2, octaveOffset);
node.data('displayLabel', Math.round(freq * 10) / 10);
} else {
node.data('displayLabel', Math.round(snappedCents));
}
node.style('label', node.data('displayLabel'));
console.log('Y-drag: originalY=', grabPos.y, 'currentY=', currentY, 'originalCents=', grabPos.cents, 'currentCents=', currentCents.toFixed(1), 'octaveOffset=', octaveOffset, 'snappedCents=', snappedCents);
}
// For X drag, just reset Y to original (visual only - allow X to stay where dragged)
if (grabPos && isDragging && dragDirection === 'x') {
node.position('y', grabPos.y);
console.log('X-drag: visual only');
}
isDragging = false; isDragging = false;
grabPosition = null; dragDirection = null;
grabPos = null;
});
cy.on('drag', 'node', function(evt) {
const node = evt.target;
// Track X vs Y movement to determine drag direction
if (grabPos) {
const dx = Math.abs(node.position('x') - grabPos.x);
const dy = Math.abs(node.position('y') - grabPos.y);
if (dx > 3 || dy > 3) {
isDragging = true;
// Determine which direction dominates
if (dy > dx) {
dragDirection = 'y';
// Snap X to original, free Y
node.position('x', grabPos.x);
} else {
dragDirection = 'x';
// Snap Y to original, free X
node.position('y', grabPos.y);
}
}
}
});
cy.on('dragfree', 'node', function(evt) {
const node = evt.target;
const originalCents = node.data('originalCents');
if (grabPos && isDragging && dragDirection === 'y') {
const dy = node.position('y') - grabPos.y;
// Calculate octave offset from drag distance
// Each octave = 1200 cents
// Get global cents range from graph data
const allGraphsData_json = document.getElementById('allGraphsData');
let globalRange = 1200;
if (allGraphsData_json) {
const data = JSON.parse(allGraphsData_json.textContent);
const allCents = [];
data.graphs.forEach(g => {
if (g.nodes) allCents.push(...g.nodes.map(n => n.cents));
});
if (allCents.length > 0) {
globalRange = Math.max(...allCents) - Math.min(...allCents);
}
}
const graphHeight = 450;
const centsPerPixel = globalRange / graphHeight;
// Invert: dragging UP (negative dy) = higher cents
const centsDelta = -dy * centsPerPixel;
const octaveOffset = Math.round(centsDelta / 1200);
// Calculate new cents and update position
const newCents = originalCents + (octaveOffset * 1200);
const newY = grabPos.y - (octaveOffset * 1200) / centsPerPixel;
node.data('cents', newCents);
node.position('y', newY);
node.data('displayLabel', Math.round(newCents));
node.style('label', Math.round(newCents));
console.log('Y-drag: dy=', dy, 'centsDelta=', centsDelta, 'octaves=', octaveOffset, 'newCents=', newCents);
}
// For X drag, just reset X to original (visual only)
if (grabPos && isDragging && dragDirection === 'x') {
node.position('x', grabPos.x);
console.log('X-drag: visual only');
}
isDragging = false;
dragDirection = null;
grabPos = null;
}); });
// Click to play - send OSC // Click to play - send OSC
@ -788,7 +967,8 @@
chordIndex: chordIndex, chordIndex: chordIndex,
nodeIndex: localId, nodeIndex: localId,
ip: sirenIp, ip: sirenIp,
sirenNumber: actualSiren sirenNumber: actualSiren,
octaveOffset: node.data('octaveOffset') || 0
}; };
fetch(endpoint, { fetch(endpoint, {
@ -891,7 +1071,7 @@
// Spread nodes within each chord // Spread nodes within each chord
const x = xBase + (chordSpacing * 0.15) * (i / (nodes.length - 1 || 1)); const x = xBase + (chordSpacing * 0.15) * (i / (nodes.length - 1 || 1));
const y = yBase + ySpread - ((n.cents - globalMinCents) / globalCentsRange) * ySpread; const y = yBase + ySpread - (n.cents * PIXELS_PER_CENT);
elements.push({ elements.push({
group: 'nodes', group: 'nodes',
@ -921,6 +1101,10 @@
}); });
}); });
// 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) // Add label node for this chord (locked so layout doesn't move it)
elements.push({ elements.push({
group: 'nodes', group: 'nodes',
@ -929,7 +1113,7 @@
chordIndex: chordIdx, chordIndex: chordIdx,
isLabel: "true", isLabel: "true",
}, },
position: { x: xBase + (chordSpacing * 0.15), y: 30 }, position: { x: xBase + (chordSpacing * 0.15), y: labelY },
locked: true, locked: true,
}); });
}); });

View file

@ -144,7 +144,10 @@ def play_siren():
chord_index = data.get("chordIndex") chord_index = data.get("chordIndex")
node_index = data.get("nodeIndex") node_index = data.get("nodeIndex")
siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP
siren_number = data.get("sirenNumber") # NEW: optional 1-4 siren_number = data.get("sirenNumber") # optional 1-4
octave_offset = data.get(
"octaveOffset", 0
) # optional octave offset for Y-drag transposition
if chord_index is None or node_index is None: if chord_index is None or node_index is None:
return jsonify({"error": "Missing chordIndex/nodeIndex"}), 400 return jsonify({"error": "Missing chordIndex/nodeIndex"}), 400
@ -158,7 +161,8 @@ def play_siren():
pitch = chord[node_index] pitch = chord[node_index]
fraction = Fraction(pitch.get("fraction", "1")) fraction = Fraction(pitch.get("fraction", "1"))
frequency = fundamental * float(fraction) base_frequency = fundamental * float(fraction)
frequency = base_frequency * (2**octave_offset) # Apply octave offset
voice = siren_number if siren_number else node_index + 1 # 1-indexed voice = siren_number if siren_number else node_index + 1 # 1-indexed
# Send to siren using cached sender # Send to siren using cached sender
@ -171,6 +175,7 @@ def play_siren():
"frequency": frequency, "frequency": frequency,
"voice": voice, "voice": voice,
"siren": siren_number, "siren": siren_number,
"octaveOffset": octave_offset,
"destination": "siren", "destination": "siren",
"ip": siren_ip, "ip": siren_ip,
} }
@ -216,7 +221,10 @@ def ramp_to_chord():
duration_ms = data.get("duration", 3000) duration_ms = data.get("duration", 3000)
exponent = data.get("exponent", 1.0) exponent = data.get("exponent", 1.0)
siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP siren_ip = data.get("ip", "192.168.4.200") # Default to actual siren IP
siren_number = data.get("sirenNumber") # NEW: optional 1-4 siren_number = data.get("sirenNumber") # optional 1-4
octave_offset = data.get(
"octaveOffset", 0
) # optional octave offset for Y-drag transposition
if chord_index is None: if chord_index is None:
return jsonify({"error": "Missing chordIndex"}), 400 return jsonify({"error": "Missing chordIndex"}), 400
@ -236,7 +244,8 @@ def ramp_to_chord():
return jsonify({"error": "Invalid node index"}), 400 return jsonify({"error": "Invalid node index"}), 400
pitch = chord[node_index] pitch = chord[node_index]
fraction = Fraction(pitch.get("fraction", "1")) fraction = Fraction(pitch.get("fraction", "1"))
frequency = fundamental * float(fraction) base_frequency = fundamental * float(fraction)
frequency = base_frequency * (2**octave_offset) # Apply octave offset
voice = siren_number if siren_number else (node_index + 1) # 1-indexed voice = siren_number if siren_number else (node_index + 1) # 1-indexed
# Ramp single voice - let sender get start frequency from current position # Ramp single voice - let sender get start frequency from current position
@ -246,7 +255,8 @@ def ramp_to_chord():
else: else:
# Ramp entire chord - let sender get start frequencies from current positions # Ramp entire chord - let sender get start frequencies from current positions
frequencies = [ frequencies = [
fundamental * float(Fraction(p.get("fraction", "1"))) for p in chord fundamental * float(Fraction(p.get("fraction", "1"))) * (2**octave_offset)
for p in chord
] ]
siren_sender.ramp_to_chord( siren_sender.ramp_to_chord(