Add alternating layout direction by nesting depth
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
159
config.yaml
159
config.yaml
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user