diff --git a/webapp/path_navigator.html b/webapp/path_navigator.html
index 373bd16..e499cff 100644
--- a/webapp/path_navigator.html
+++ b/webapp/path_navigator.html
@@ -35,15 +35,17 @@
const state = new Map();
nodes.forEach(n => {
- 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,
- });
+ if (n.data('isLabel')) return; // Skip label nodes
+
+ const chordLabel = n.data('chordLabel');
+ const bounds = opts.bounds[chordLabel] || { min: 0, max: graphWidth };
+ state.set(n.id(), {
+ x: n.position('x'),
+ y: n.position('y'),
+ vx: 0,
+ fx: 0,
+ bounds: bounds,
+ });
});
const edgeList = edges.map(e => ({
@@ -330,6 +332,32 @@
return parseFloat(fracStr);
}
+ // Play all nodes in a chord on the siren
+ function playChordOnSiren(chordIdx) {
+ // 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');
+
+ fetch('/api/play-siren', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({
+ chordIndex: chordIdx,
+ nodeIndex: localId
+ })
+ });
+ node.data('sirenActive', 'true');
+ });
+ }
+
// Toggle between cents and frequency display
function toggleDisplayUnit() {
displayMode = displayMode === 'cents' ? 'frequency' : 'cents';
@@ -386,7 +414,7 @@
container: document.getElementById('graph-container'),
style: [
{
- selector: 'node',
+ selector: 'node[color]',
style: {
'background-color': 'data(color)',
'width': 32,
@@ -402,6 +430,22 @@
'border-width': 0,
}
},
+ {
+ selector: 'node[isLabel = "true"]',
+ style: {
+ 'background-color': '#888888',
+ 'width': 40,
+ 'height': 40,
+ 'label': 'data(chordIndex)',
+ 'color': '#ffffff',
+ 'font-size': '18px',
+ 'font-weight': 'bold',
+ 'text-valign': 'center',
+ 'text-halign': 'center',
+ 'text-outline-width': 0,
+ 'border-width': 0,
+ }
+ },
{
selector: 'node[sirenActive = "true"]',
style: {
@@ -501,6 +545,13 @@
return;
}
+ // Handle label node clicks
+ if (node.data('isLabel')) {
+ const chordIdx = node.data('chordIndex');
+ playChordOnSiren(chordIdx);
+ return;
+ }
+
const chordIndex = node.data('chordIndex');
const localId = node.data('localId');
@@ -598,10 +649,11 @@
allGraphsData.graphs.forEach((graph, chordIdx) => {
if (!graph || !graph.nodes) return;
- const nodes = graph.nodes;
- const edges = graph.edges || [];
- const xBase = 100 + chordIdx * chordSpacing;
- const idMap = {};
+ const nodes = graph.nodes;
+ const edges = graph.edges || [];
+ const gap = 50;
+ const xBase = 100 + chordIdx * (chordSpacing + gap);
+ const idMap = {};
// Create unique IDs per chord to avoid collisions
const chordPrefix = `c${chordIdx}_`;
@@ -641,6 +693,18 @@
},
});
});
+
+ // Add label node for this chord (locked so layout doesn't move it)
+ elements.push({
+ group: 'nodes',
+ data: {
+ id: `label_${chordIdx}`,
+ chordIndex: chordIdx,
+ isLabel: "true",
+ },
+ position: { x: xBase + (chordSpacing * 0.15), y: 30 },
+ locked: true,
+ });
});
if (elements.length === 0) return;
diff --git a/webapp/server.py b/webapp/server.py
index 6226889..bd1a7cc 100644
--- a/webapp/server.py
+++ b/webapp/server.py
@@ -129,20 +129,37 @@ def play_freq():
def play_siren():
"""Play a single frequency for a node on the siren."""
data = request.json
+
+ # Support two modes:
+ # 1. chordIndex + nodeIndex (from clicking a single node)
+ # 2. frequency + voice (from clicking a chord label)
chord_index = data.get("chordIndex")
node_index = data.get("nodeIndex")
+ frequency = data.get("frequency")
+ voice = data.get("voice")
- if chord_index < 0 or chord_index >= len(chords):
- return jsonify({"error": "Invalid chord index"}), 400
+ if frequency is None and voice is None:
+ # Mode 1: use chordIndex/nodeIndex
+ if chord_index is None or node_index is None:
+ return jsonify(
+ {"error": "Missing chordIndex/nodeIndex or frequency/voice"}
+ ), 400
- chord = chords[chord_index]
- if node_index < 0 or node_index >= len(chord):
- return jsonify({"error": "Invalid node index"}), 400
+ if chord_index < 0 or chord_index >= len(chords):
+ return jsonify({"error": "Invalid chord index"}), 400
- pitch = chord[node_index]
- fraction = Fraction(pitch.get("fraction", "1"))
- frequency = fundamental * float(fraction)
- voice = node_index + 1 # 1-indexed
+ chord = chords[chord_index]
+ if node_index < 0 or node_index >= len(chord):
+ return jsonify({"error": "Invalid node index"}), 400
+
+ pitch = chord[node_index]
+ fraction = Fraction(pitch.get("fraction", "1"))
+ frequency = fundamental * float(fraction)
+ voice = node_index + 1 # 1-indexed
+ else:
+ # Mode 2: use provided frequency/voice
+ if frequency is None or voice is None:
+ return jsonify({"error": "Missing frequency or voice"}), 400
# Send to siren (192.168.4.200:54001) using current fundamental
siren_sender = OSCSender(ip="192.168.4.200", port=54001, fundamental=fundamental)
@@ -153,8 +170,6 @@ def play_siren():
{
"frequency": frequency,
"voice": voice,
- "fundamental": fundamental,
- "fraction": pitch.get("fraction", "1"),
"destination": "siren",
}
)