Add Y-axis octave drag, fix label positions, display toggle, ramp coloring, server octaveOffset support
This commit is contained in:
parent
a28ebb8a71
commit
e709d1a7a7
|
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue