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
|
||||
// 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) {
|
||||
const targetNode = nodes.find(n => n.data('localId') === nodeIdx);
|
||||
if (targetNode) {
|
||||
const nodeColor = targetNode.data('color');
|
||||
// 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('color') === nodeColor && n.data('sirenActive')) {
|
||||
if (n.data('borderColor') === nodeBorderColor && 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('borderColor', nodeBorderColor);
|
||||
}
|
||||
} else {
|
||||
cy.nodes().forEach(n => n.data('sirenActive', ''));
|
||||
nodes.forEach(node => node.data('sirenActive', 'true'));
|
||||
// Entire chord ramp - each node gets its own color
|
||||
cy.nodes().forEach(n => {
|
||||
n.data('sirenActive', '');
|
||||
n.data('borderColor', '');
|
||||
});
|
||||
|
||||
nodes.forEach(node => {
|
||||
const nodeLocalId = node.data('localId');
|
||||
const nodeActualSiren = selectedSiren > 0 ? selectedSiren : (nodeLocalId + 1);
|
||||
node.data('sirenActive', 'true');
|
||||
node.data('borderColor', sirenColors[(nodeActualSiren - 1) % 4]);
|
||||
});
|
||||
}
|
||||
|
||||
const rampBody = {
|
||||
|
|
@ -532,6 +552,14 @@
|
|||
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', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
|
|
@ -588,6 +616,9 @@
|
|||
const graphHeight = 450;
|
||||
let chordSpacing = 350;
|
||||
|
||||
// Fixed pixels per cent (0.15 = 360 pixels per octave = 2 octaves on screen)
|
||||
const PIXELS_PER_CENT = 0.15;
|
||||
|
||||
// Voice colors - sleek pastel scheme
|
||||
const voiceColors = ['#7eb5a6', '#c5a3ff', '#ffb3b3', '#ffd700'];
|
||||
|
||||
|
|
@ -688,35 +719,183 @@
|
|||
boxSelectionEnabled: false,
|
||||
});
|
||||
|
||||
// Lock y on drag - only allow x movement
|
||||
// Track drag for X-move and Y-octave drag
|
||||
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) {
|
||||
console.log('GRAB event');
|
||||
const node = evt.target;
|
||||
grabPosition = node.position('y');
|
||||
node.data('originalY', grabPosition);
|
||||
// Only set trueOriginalCents on first grab (never overwritten)
|
||||
if (!node.data('trueOriginalCents')) {
|
||||
node.data('trueOriginalCents', node.data('cents'));
|
||||
}
|
||||
grabPos = {
|
||||
x: node.position('x'),
|
||||
y: node.position('y'),
|
||||
cents: node.data('trueOriginalCents'), // Use preserved original
|
||||
};
|
||||
node.data('originalCents', grabPos.cents);
|
||||
});
|
||||
|
||||
cy.on('drag', 'node', function(evt) {
|
||||
const node = evt.target;
|
||||
const originalY = node.data('originalY');
|
||||
|
||||
// Only mark as dragging if it actually moved from grab position
|
||||
if (grabPosition !== null && Math.abs(node.position('y') - grabPosition) > 1) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
if (originalY !== undefined) {
|
||||
node.position('y', originalY);
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
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
|
||||
|
|
@ -788,7 +967,8 @@
|
|||
chordIndex: chordIndex,
|
||||
nodeIndex: localId,
|
||||
ip: sirenIp,
|
||||
sirenNumber: actualSiren
|
||||
sirenNumber: actualSiren,
|
||||
octaveOffset: node.data('octaveOffset') || 0
|
||||
};
|
||||
|
||||
fetch(endpoint, {
|
||||
|
|
@ -891,7 +1071,7 @@
|
|||
|
||||
// Spread nodes within each chord
|
||||
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({
|
||||
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)
|
||||
elements.push({
|
||||
group: 'nodes',
|
||||
|
|
@ -929,7 +1113,7 @@
|
|||
chordIndex: chordIdx,
|
||||
isLabel: "true",
|
||||
},
|
||||
position: { x: xBase + (chordSpacing * 0.15), y: 30 },
|
||||
position: { x: xBase + (chordSpacing * 0.15), y: labelY },
|
||||
locked: true,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -144,7 +144,10 @@ def play_siren():
|
|||
chord_index = data.get("chordIndex")
|
||||
node_index = data.get("nodeIndex")
|
||||
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:
|
||||
return jsonify({"error": "Missing chordIndex/nodeIndex"}), 400
|
||||
|
|
@ -158,7 +161,8 @@ def play_siren():
|
|||
|
||||
pitch = chord[node_index]
|
||||
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
|
||||
|
||||
# Send to siren using cached sender
|
||||
|
|
@ -171,6 +175,7 @@ def play_siren():
|
|||
"frequency": frequency,
|
||||
"voice": voice,
|
||||
"siren": siren_number,
|
||||
"octaveOffset": octave_offset,
|
||||
"destination": "siren",
|
||||
"ip": siren_ip,
|
||||
}
|
||||
|
|
@ -216,7 +221,10 @@ def ramp_to_chord():
|
|||
duration_ms = data.get("duration", 3000)
|
||||
exponent = data.get("exponent", 1.0)
|
||||
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:
|
||||
return jsonify({"error": "Missing chordIndex"}), 400
|
||||
|
|
@ -236,7 +244,8 @@ def ramp_to_chord():
|
|||
return jsonify({"error": "Invalid node index"}), 400
|
||||
pitch = chord[node_index]
|
||||
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
|
||||
|
||||
# Ramp single voice - let sender get start frequency from current position
|
||||
|
|
@ -246,7 +255,8 @@ def ramp_to_chord():
|
|||
else:
|
||||
# Ramp entire chord - let sender get start frequencies from current positions
|
||||
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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue