Add alternating layout direction by nesting depth

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2026-01-24 21:24:05 -08:00
parent 4123b49a98
commit 6492456ce0
2 changed files with 176 additions and 125 deletions

View File

@@ -1,97 +1,100 @@
locations: locations:
- name: stage - name: Stage 1
children: children:
- name: upstage - children:
children: - name: Upstage
- name: rack-lighting-2 children:
nodes: - name: rack-lighting-2
- lighting-2 nodes:
- "48:59:00:41:00:29" # Pixie Driver 8k Port 1 - lighting-2
- "48:59:00:28:00:27" # Pixie Driver 8k Port 2 - "48:59:00:41:00:29" # Pixie Driver 8k Port 1
- "48:59:00:3c:00:3e" # Pixie Driver 8k Port 5 - "48:59:00:28:00:27" # Pixie Driver 8k Port 2
- "48:59:00:3c:00:3e" # Pixie Driver 8k Port 5
- name: downstage - name: Under Apron
children: children:
- name: rack-audio - name: rack-audio
nodes: nodes:
- audio - audio
- "MICS-A" - "MICS-A"
- "00:0e:dd:a7:29:93" # MICS-A bridge interface - "00:0e:dd:a7:29:93" # MICS-A bridge interface
- "MICS-B" - "MICS-B"
- "00:0e:dd:a8:3e:b3" # MICS-B bridge interface - "00:0e:dd:a8:3e:b3" # MICS-B bridge interface
- "MICS-C" - "MICS-C"
- "00:0e:dd:a7:6f:55" # MICS-C bridge interface - "00:0e:dd:a7:6f:55" # MICS-C bridge interface
- "MICS-D" - "MICS-D"
- "00:0e:dd:64:3d:51" # MICS-D bridge interface - "00:0e:dd:64:3d:51" # MICS-D bridge interface
- "MICS-E" - "MICS-E"
- "00:0e:dd:ac:fc:7d" # MICS-E bridge interface - "00:0e:dd:ac:fc:7d" # MICS-E bridge interface
- name: rack-lighting-1 - name: rack-lighting-1
nodes: nodes:
- lighting-1 - lighting-1
- "48:59:00:27:00:27" # Pixie Driver 8k Port 1 - "48:59:00:27:00:27" # Pixie Driver 8k Port 1
- "48:59:00:37:00:27" # Pixie Driver 8k Port 2 - "48:59:00:37:00:27" # Pixie Driver 8k Port 2
- "48:59:00:3e:00:27" # Pixie Driver 8k Port 3 - "48:59:00:3e:00:27" # Pixie Driver 8k Port 3
- "48:59:00:3f:00:27" # Pixie Driver 8k Port 4 - "48:59:00:3f:00:27" # Pixie Driver 8k Port 4
- "48:59:00:47:00:1a" # Pixie Driver 8k Port 5 - "48:59:00:47:00:1a" # Pixie Driver 8k Port 5
- "48:59:00:44:00:1a" # Pixie Driver 8k Port 6 - "48:59:00:44:00:1a" # Pixie Driver 8k Port 6
- "48:59:00:42:00:19" # Pixie Driver 8k Port 7 - "48:59:00:42:00:19" # Pixie Driver 8k Port 7
- "48:59:00:44:00:19" # Pixie Driver 8k Port 8 - "48:59:00:44:00:19" # Pixie Driver 8k Port 8
- name: rack-video - name: rack-video
nodes: nodes:
- video - video
- "ATEM 2 M/E Constellation 4K" - "ATEM 2 M/E Constellation 4K"
- "HyperDeck Studio 4K Pro" - "HyperDeck Studio 4K Pro"
- RX-QLAB-1 - RX-QLAB-1
- RX-QLAB-2 - RX-QLAB-2
- TX-PROJ-1 - TX-PROJ-1
- TX-PROJ-2 - TX-PROJ-2
- TX-M4 - TX-M4
- TX-M16 - TX-M16
- TX-MISC - TX-MISC
- TX-PREVIEW - TX-PREVIEW
- name: house - name: Mid House
children: children:
- name: house-left - name: House Left
nodes: nodes:
- satellite-2 - satellite-2
- name: house-right - name: House Right
nodes: nodes:
- satellite-3 - satellite-3
- name: booth - name: Booth
children: children:
- name: shared - children:
nodes: - name: shared
- satellite-1 nodes:
- satellite-1
- name: qlab - children:
nodes: - name: qlab
- qlab nodes:
- TX-QLAB-1 - qlab
- TX-QLAB-2 - TX-QLAB-1
- "SK_PTZEXTREMEV2 [457081]" - TX-QLAB-2
- "SK_RACKPRO2 [452514]" - "SK_PTZEXTREMEV2 [457081]"
- "d0:11:e5:17:03:0b" # pigeon - "SK_RACKPRO2 [452514]"
- "d0:11:e5:17:03:0b" # pigeon
- name: audio - name: audio
nodes: nodes:
- SQ-7 - SQ-7
- name: camera-control - name: camera-control
nodes: nodes:
- RX-CC-PREVIEW - RX-CC-PREVIEW
- RX-CC-M16 - RX-CC-M16
- name: video-control - name: video-control
nodes: nodes:
- RX-VC-M4 - RX-VC-M4
- RX-VC-M16 - RX-VC-M16
- name: control - name: control
nodes: nodes:
- "sunset.local" - "sunset.local"
- RX-CONTROL-1 - RX-CONTROL-1

View File

@@ -75,11 +75,19 @@
return !!(node.poe_budget); return !!(node.poe_budget);
} }
let anonCounter = 0;
function buildLocationIndex(locations, parentId, nodeToLocation, locationMeta, depth, orderBase) { function buildLocationIndex(locations, parentId, nodeToLocation, locationMeta, depth, orderBase) {
if (!locations) return; if (!locations) return;
locations.forEach((loc, idx) => { locations.forEach((loc, idx) => {
const locId = 'loc_' + loc.name.replace(/[^a-zA-Z0-9]/g, '_'); let locId;
locationMeta.set(locId, { name: loc.name, parentId, depth, order: orderBase + idx }); if (loc.name) {
locId = 'loc_' + loc.name.replace(/[^a-zA-Z0-9]/g, '_');
locationMeta.set(locId, { name: loc.name, parentId, depth, order: orderBase + idx, anonymous: false });
} else {
locId = 'loc_anon_' + (anonCounter++);
locationMeta.set(locId, { name: '', parentId, depth, order: orderBase + idx, anonymous: true });
}
if (loc.nodes) { if (loc.nodes) {
loc.nodes.forEach(nodeRef => { loc.nodes.forEach(nodeRef => {
@@ -104,7 +112,8 @@
return chain; return chain;
} }
let topLevelOrder = []; let locationMeta = new Map();
let usedLocations = new Set();
function doLayout() { function doLayout() {
const layout = cy.layout({ const layout = cy.layout({
@@ -123,58 +132,100 @@
'elk.hierarchyHandling': 'INCLUDE_CHILDREN' 'elk.hierarchyHandling': 'INCLUDE_CHILDREN'
}, },
stop: function() { stop: function() {
reorderTopLevel(); reorderLocations();
cy.fit(50); cy.fit(50);
} }
}); });
layout.run(); layout.run();
} }
function reorderTopLevel() { function reorderLocations() {
if (topLevelOrder.length < 2) return; const locationsByParent = new Map();
locationMeta.forEach((meta, locId) => {
if (!usedLocations.has(locId)) return;
const parentKey = meta.parentId || '__root__';
if (!locationsByParent.has(parentKey)) {
locationsByParent.set(parentKey, []);
}
locationsByParent.get(parentKey).push({ id: locId, order: meta.order, depth: meta.depth });
});
const boxes = topLevelOrder.map(locId => { const depths = new Set();
locationMeta.forEach((meta, locId) => {
if (usedLocations.has(locId)) depths.add(meta.depth);
});
const sortedDepths = Array.from(depths).sort((a, b) => b - a);
sortedDepths.forEach(depth => {
const isVertical = depth % 2 === 0;
locationsByParent.forEach((siblings, parentKey) => {
const locationsAtDepth = siblings.filter(s => s.depth === depth);
if (locationsAtDepth.length < 2) return;
locationsAtDepth.sort((a, b) => a.order - b.order);
reorderSiblings(locationsAtDepth.map(l => l.id), isVertical);
});
});
}
function reorderSiblings(locIds, isVertical) {
const boxes = locIds.map(locId => {
const node = cy.getElementById(locId); const node = cy.getElementById(locId);
if (node.empty()) return null; if (node.empty()) return null;
const bb = node.boundingBox(); const bb = node.boundingBox();
return { return {
id: locId, id: locId,
bb: bb, bb: bb,
width: bb.x2 - bb.x1,
height: bb.y2 - bb.y1, height: bb.y2 - bb.y1,
centerX: (bb.x1 + bb.x2) / 2 centerX: (bb.x1 + bb.x2) / 2,
centerY: (bb.y1 + bb.y2) / 2
}; };
}).filter(b => b !== null); }).filter(b => b !== null);
if (boxes.length < 2) return; if (boxes.length < 2) return;
const minY = Math.min(...boxes.map(b => b.bb.y1));
const gap = 50; const gap = 50;
// Find the widest box and use its center as the target if (isVertical) {
const widest = boxes.reduce((a, b) => (b.bb.x2 - b.bb.x1) > (a.bb.x2 - a.bb.x1) ? b : a); const widest = boxes.reduce((a, b) => b.width > a.width ? b : a);
const targetCenterX = widest.centerX; const targetCenterX = widest.centerX;
const minY = Math.min(...boxes.map(b => b.bb.y1));
let targetY = minY; let targetY = minY;
const targets = boxes.map(box => { boxes.forEach(box => {
const result = { const deltaX = targetCenterX - box.centerX;
id: box.id, const deltaY = targetY - box.bb.y1;
deltaX: targetCenterX - box.centerX, moveLocationAndDescendants(box.id, deltaX, deltaY);
deltaY: targetY - box.bb.y1 targetY += box.height + gap;
};
targetY += box.height + gap;
return result;
});
targets.forEach(target => {
const node = cy.getElementById(target.id);
node.descendants().filter(n => !n.isParent()).forEach(n => {
const pos = n.position();
n.position({ x: pos.x + target.deltaX, y: pos.y + target.deltaY });
}); });
} else {
const tallest = boxes.reduce((a, b) => b.height > a.height ? b : a);
const targetCenterY = tallest.centerY;
const minX = Math.min(...boxes.map(b => b.bb.x1));
let targetX = minX;
boxes.forEach(box => {
const deltaX = targetX - box.bb.x1;
const deltaY = targetCenterY - box.centerY;
moveLocationAndDescendants(box.id, deltaX, deltaY);
targetX += box.width + gap;
});
}
}
function moveLocationAndDescendants(locId, deltaX, deltaY) {
if (deltaX === 0 && deltaY === 0) return;
const node = cy.getElementById(locId);
node.descendants().filter(n => !n.isParent()).forEach(n => {
const pos = n.position();
n.position({ x: pos.x + deltaX, y: pos.y + deltaY });
}); });
} }
async function init() { async function init() {
anonCounter = 0;
const [statusResp, configResp] = await Promise.all([ const [statusResp, configResp] = await Promise.all([
fetch('/api/status'), fetch('/api/status'),
fetch('/api/config') fetch('/api/config')
@@ -193,7 +244,7 @@
const switchIds = new Set(); const switchIds = new Set();
const nodeToLocation = new Map(); const nodeToLocation = new Map();
const locationMeta = new Map(); locationMeta = new Map();
buildLocationIndex(config.locations || [], null, nodeToLocation, locationMeta, 0, 0); buildLocationIndex(config.locations || [], null, nodeToLocation, locationMeta, 0, 0);
nodes.forEach((n, i) => { nodes.forEach((n, i) => {
@@ -202,7 +253,7 @@
if (isSwitch(n)) switchIds.add(id); if (isSwitch(n)) switchIds.add(id);
}); });
const usedLocations = new Set(); usedLocations = new Set();
const nodeParents = new Map(); const nodeParents = new Map();
nodes.forEach((n, i) => { nodes.forEach((n, i) => {
@@ -231,7 +282,8 @@
id: locId, id: locId,
label: meta.name, label: meta.name,
parent: meta.parentId && usedLocations.has(meta.parentId) ? meta.parentId : null, parent: meta.parentId && usedLocations.has(meta.parentId) ? meta.parentId : null,
isLocation: true isLocation: true,
isAnonymous: meta.anonymous
} }
}); });
}); });
@@ -267,18 +319,6 @@
}); });
}); });
// Find top-level locations and sort by config order
topLevelOrder = sortedLocations
.filter(locId => {
const meta = locationMeta.get(locId);
return !meta.parentId;
})
.sort((a, b) => {
const metaA = locationMeta.get(a);
const metaB = locationMeta.get(b);
return metaA.order - metaB.order;
});
cy = cytoscape({ cy = cytoscape({
container: document.getElementById('cy'), container: document.getElementById('cy'),
elements: elements, elements: elements,
@@ -343,6 +383,14 @@
'padding': 30 'padding': 30
} }
}, },
{
selector: 'node[?isAnonymous]',
style: {
'background-opacity': 0,
'border-width': 0,
'padding': 10
}
},
{ {
selector: 'edge', selector: 'edge',
style: { style: {