Replace Cytoscape with DOM-based grid layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ian Gulliver
2026-01-25 17:16:21 -08:00
parent 19fabc71e4
commit 0e6db94b83
3 changed files with 315 additions and 417 deletions

View File

@@ -12,9 +12,10 @@ type Config struct {
} }
type Location struct { type Location struct {
Name string `yaml:"name" json:"name"` Name string `yaml:"name" json:"name"`
Nodes []string `yaml:"nodes,omitempty" json:"nodes,omitempty"` Direction string `yaml:"direction,omitempty" json:"direction,omitempty"`
Children []*Location `yaml:"children,omitempty" json:"children,omitempty"` Nodes []string `yaml:"nodes,omitempty" json:"nodes,omitempty"`
Children []*Location `yaml:"children,omitempty" json:"children,omitempty"`
} }
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {

View File

@@ -1,40 +1,41 @@
locations: locations:
- name: Stage 1 - name: Stage 1
direction: vertical
children: children:
- children: - name: LIGHTING-2 Rack
- children: nodes:
- name: LIGHTING-2 Rack - 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:43:00:29" # Pixie Driver 8k Port 3
- "48:59:00:28:00:27" # Pixie Driver 8k Port 2 - "48:59:00:42:00:29" # Pixie Driver 8k Port 4
- "48:59:00:43:00:29" # Pixie Driver 8k Port 3 - "48:59:00:3c:00:3e" # Pixie Driver 8k Port 5
- "48:59:00:42:00:29" # Pixie Driver 8k Port 4 - "48:59:00:3f:00:3e" # Pixie Driver 8k Port 6
- "48:59:00:3c:00:3e" # Pixie Driver 8k Port 5 - "48:59:00:25:00:3e" # Pixie Driver 8k Port 7
- "48:59:00:3f:00:3e" # Pixie Driver 8k Port 6 - "48:59:00:41:00:3e" # Pixie Driver 8k Port 8
- "48:59:00:25:00:3e" # Pixie Driver 8k Port 7 - ART9 # Cyc
- "48:59:00:41:00:3e" # Pixie Driver 8k Port 8 - ART10 # Cyc
- ART9 # Cyc - ART11 # Cyc
- ART10 # Cyc - ART12 # Cyc
- ART11 # Cyc - ART13 # Cyc
- ART12 # Cyc - ART14 # Cyc
- ART13 # Cyc
- ART14 # Cyc
- children: - direction: horizontal
- children: children:
- nodes: - nodes:
- ART16 # R2X1 - ART16 # R2X1
- nodes: - nodes:
- ART20 # R2X2 - ART20 # R2X2
- children: - direction: horizontal
- nodes: children:
- "MON1-A" - nodes:
- "MON1-B" - "MON1-A"
- "MON1-B"
- name: Under Apron - name: Under Apron
direction: horizontal
children: children:
- name: AUDIO Rack - name: AUDIO Rack
nodes: nodes:
@@ -76,7 +77,8 @@ locations:
- TX-MISC - TX-MISC
- TX-PREVIEW - TX-PREVIEW
- children: - direction: horizontal
children:
- nodes: - nodes:
- "Y001-MAIN1-L-d1e155" - "Y001-MAIN1-L-d1e155"
- "ac:44:f2:4e:84:d6" # MAIN1-L bridge interface - "ac:44:f2:4e:84:d6" # MAIN1-L bridge interface
@@ -93,14 +95,16 @@ locations:
- "Y001-MAIN1-R-d1e194" - "Y001-MAIN1-R-d1e194"
- "ac:44:f2:4e:84:d4" # MAIN1-R bridge interface - "ac:44:f2:4e:84:d4" # MAIN1-R bridge interface
- children: - direction: horizontal
children:
- nodes: - nodes:
- "RX-PROJ-1" - "RX-PROJ-1"
- nodes: - nodes:
- "RX-PROJ-2" - "RX-PROJ-2"
- children: - direction: horizontal
children:
- nodes: - nodes:
- satellite-2 - satellite-2
- "Y001-MAIN2-L-d1e298" - "Y001-MAIN2-L-d1e298"
@@ -124,44 +128,45 @@ locations:
- ART19 # Focus - ART19 # Focus
- name: Booth - name: Booth
direction: vertical
children: children:
- children: - name: SATELLITE-1 Rack
- name: SATELLITE-1 Rack nodes:
- satellite-1
- direction: horizontal
children:
- name: Lighting Control
nodes: nodes:
- satellite-1 - qlab
- TX-QLAB-1
- TX-QLAB-2
- "SK_PTZEXTREMEV2 [457081]"
- "SK_RACKPRO2 [452514]"
- pigeon
- showpi1
- showpi2
- children: - name: Sound Control
- name: Lighting Control nodes:
nodes: - SQ-7
- qlab - "00:04:c4:15:07:a4" # SQ-7 bridge port
- TX-QLAB-1 - BT
- TX-QLAB-2
- "SK_PTZEXTREMEV2 [457081]"
- "SK_RACKPRO2 [452514]"
- pigeon
- showpi1
- showpi2
- name: Sound Control - name: Camera Control
nodes: nodes:
- SQ-7 - RX-CC-PREVIEW
- "00:04:c4:15:07:a4" # SQ-7 bridge port - RX-CC-M16
- BT - "AtemPanel-7c2e0da86d22"
- "AtemPanel-7c2e0da86d4c"
- name: Camera Control - name: Video Control
nodes: nodes:
- RX-CC-PREVIEW - RX-VC-M4
- RX-CC-M16 - RX-VC-M16
- "AtemPanel-7c2e0da86d22" - "ATEM-2-ME-Advanced-Panel-20"
- "AtemPanel-7c2e0da86d4c"
- name: Video Control - name: Control
nodes: nodes:
- RX-VC-M4 - sunset
- RX-VC-M16 - RX-CONTROL-1
- "ATEM-2-ME-Advanced-Panel-20"
- name: Control
nodes:
- sunset
- RX-CONTROL-1

View File

@@ -12,47 +12,120 @@
padding: 10px; padding: 10px;
background: #111; background: #111;
color: #eee; color: #eee;
height: 100vh;
} }
#controls { #controls {
margin-bottom: 10px; margin-bottom: 10px;
} }
#controls button { #stats { margin-left: 10px; }
background: #333; #error { color: #f66; padding: 20px; }
color: #fff;
border: 1px solid #555; #container {
padding: 6px 12px; display: flex;
margin-right: 5px; flex-direction: column;
border-radius: 4px; gap: 20px;
cursor: pointer;
} }
#controls button:hover { background: #444; }
#cy { .location {
background: #1a1a1a; background: #222;
border: 1px solid #333; border: 1px solid #444;
height: calc(100vh - 50px); border-radius: 8px;
padding: 10px;
}
.location.top-level {
width: 100%; width: 100%;
} }
#error { color: #f66; padding: 20px; }
.location-name {
font-weight: bold;
font-size: 14px;
margin-bottom: 10px;
text-align: center;
}
.location.anonymous {
background: transparent;
border: none;
padding: 0;
}
.location.anonymous > .location-name {
display: none;
}
.node-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.node-row + .node-row {
margin-top: 8px;
}
.node {
width: 120px;
height: 50px;
background: #a6d;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
font-size: 11px;
padding: 4px;
cursor: pointer;
overflow: hidden;
word-break: normal;
overflow-wrap: break-word;
white-space: pre-line;
}
.node:hover {
filter: brightness(1.2);
}
.node.switch {
background: #2a2;
border: 2px solid #4f4;
font-weight: bold;
}
.node.copied {
outline: 2px solid #fff;
}
.children {
display: flex;
gap: 15px;
margin-top: 10px;
}
.children.horizontal {
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-evenly;
width: 100%;
}
.children.vertical {
flex-direction: column;
align-items: center;
}
</style> </style>
</head> </head>
<body> <body>
<div id="controls"> <div id="controls">
<strong>Tendrils</strong> <strong>Tendrils</strong>
<button onclick="doLayout()">Layout</button>
<button onclick="cy.fit(50)">Fit</button>
<span id="stats"></span> <span id="stats"></span>
</div> </div>
<div id="error"></div> <div id="error"></div>
<div id="cy"></div> <div id="container"></div>
<script src="cytoscape.min.js"></script>
<script src="elk.bundled.js"></script>
<script src="cytoscape-elk.min.js"></script>
<script> <script>
cytoscape.use(cytoscapeElk);
let cy;
function getLabel(node) { function getLabel(node) {
if (node.names && node.names.length > 0) return node.names.join('\n'); if (node.names && node.names.length > 0) return node.names.join('\n');
if (node.interfaces && node.interfaces.length > 0) { if (node.interfaces && node.interfaces.length > 0) {
@@ -89,153 +162,117 @@
let anonCounter = 0; let anonCounter = 0;
function buildLocationIndex(locations, parentId, nodeToLocation, locationMeta, depth, orderBase) { function buildLocationTree(locations, parentId) {
if (!locations) return; if (!locations) return [];
locations.forEach((loc, idx) => { return locations.map((loc, idx) => {
let locId; let locId;
let anonymous = false;
if (loc.name) { if (loc.name) {
locId = 'loc_' + loc.name.replace(/[^a-zA-Z0-9]/g, '_'); locId = 'loc_' + loc.name.replace(/[^a-zA-Z0-9]/g, '_');
locationMeta.set(locId, { name: loc.name, parentId, depth, order: orderBase + idx, anonymous: false });
} else { } else {
locId = 'loc_anon_' + (anonCounter++); locId = 'loc_anon_' + (anonCounter++);
locationMeta.set(locId, { name: '', parentId, depth, order: orderBase + idx, anonymous: true }); anonymous = true;
} }
if (loc.nodes) {
loc.nodes.forEach(nodeRef => {
nodeToLocation.set(nodeRef.toLowerCase(), locId);
});
}
if (loc.children) {
buildLocationIndex(loc.children, locId, nodeToLocation, locationMeta, depth + 1, 0);
}
});
}
function getLocationChain(locId, locationMeta) {
const chain = [];
let current = locId;
while (current) {
chain.push(current);
const meta = locationMeta.get(current);
current = meta ? meta.parentId : null;
}
return chain;
}
let locationMeta = new Map();
let usedLocations = new Set();
function doLayout() {
const layout = cy.layout({
name: 'elk',
fit: false,
padding: 50,
nodeDimensionsIncludeLabels: true,
elk: {
algorithm: 'layered',
'elk.direction': 'DOWN',
'elk.spacing.nodeNode': 80,
'elk.spacing.edgeNode': 40,
'elk.spacing.edgeEdge': 30,
'elk.layered.spacing.nodeNodeBetweenLayers': 100,
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
'elk.hierarchyHandling': 'INCLUDE_CHILDREN'
},
stop: function() {
reorderLocations();
cy.fit(50);
}
});
layout.run();
}
function reorderLocations() {
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 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);
if (node.empty()) return null;
const bb = node.boundingBox();
return { return {
id: locId, id: locId,
bb: bb, name: loc.name || '',
width: bb.x2 - bb.x1, anonymous: anonymous,
height: bb.y2 - bb.y1, direction: loc.direction || 'horizontal',
centerX: (bb.x1 + bb.x2) / 2, nodeRefs: (loc.nodes || []).map(n => n.toLowerCase()),
centerY: (bb.y1 + bb.y2) / 2 children: buildLocationTree(loc.children, locId)
}; };
}).filter(b => b !== null); });
if (boxes.length < 2) return;
const gap = 50;
if (isVertical) {
const widest = boxes.reduce((a, b) => b.width > a.width ? b : a);
const targetCenterX = widest.centerX;
const minY = Math.min(...boxes.map(b => b.bb.y1));
let targetY = minY;
boxes.forEach(box => {
const deltaX = targetCenterX - box.centerX;
const deltaY = targetY - box.bb.y1;
moveLocationAndDescendants(box.id, deltaX, deltaY);
targetY += box.height + gap;
});
} 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) { function buildNodeIndex(locations, index) {
if (deltaX === 0 && deltaY === 0) return; locations.forEach(loc => {
const node = cy.getElementById(locId); loc.nodeRefs.forEach(ref => {
node.descendants().filter(n => !n.isParent()).forEach(n => { index.set(ref, loc);
const pos = n.position(); });
n.position({ x: pos.x + deltaX, y: pos.y + deltaY }); buildNodeIndex(loc.children, index);
}); });
} }
function findLocationForNode(node, nodeIndex) {
const identifiers = getNodeIdentifiers(node);
for (const ident of identifiers) {
if (nodeIndex.has(ident)) {
return nodeIndex.get(ident);
}
}
return null;
}
function createNodeElement(node) {
const div = document.createElement('div');
div.className = 'node' + (isSwitch(node) ? ' switch' : '');
div.textContent = getLabel(node);
div.addEventListener('click', () => {
const json = JSON.stringify(node, null, 2);
navigator.clipboard.writeText(json).then(() => {
div.classList.add('copied');
setTimeout(() => div.classList.remove('copied'), 300);
});
});
return div;
}
function renderLocation(loc, assignedNodes, isTopLevel) {
const nodes = assignedNodes.get(loc) || [];
const hasNodes = nodes.length > 0;
const childElements = loc.children
.map(child => renderLocation(child, assignedNodes, false))
.filter(el => el !== null);
if (!hasNodes && childElements.length === 0) {
return null;
}
const container = document.createElement('div');
let classes = 'location';
if (loc.anonymous) classes += ' anonymous';
if (isTopLevel) classes += ' top-level';
container.className = classes;
const nameEl = document.createElement('div');
nameEl.className = 'location-name';
nameEl.textContent = loc.name;
container.appendChild(nameEl);
if (hasNodes) {
const switches = nodes.filter(n => isSwitch(n));
const nonSwitches = nodes.filter(n => !isSwitch(n));
if (switches.length > 0) {
const switchRow = document.createElement('div');
switchRow.className = 'node-row';
switches.forEach(node => {
switchRow.appendChild(createNodeElement(node));
});
container.appendChild(switchRow);
}
if (nonSwitches.length > 0) {
const nodeRow = document.createElement('div');
nodeRow.className = 'node-row';
nonSwitches.forEach(node => {
nodeRow.appendChild(createNodeElement(node));
});
container.appendChild(nodeRow);
}
}
if (childElements.length > 0) {
const childrenContainer = document.createElement('div');
childrenContainer.className = 'children ' + loc.direction;
childElements.forEach(el => childrenContainer.appendChild(el));
container.appendChild(childrenContainer);
}
return container;
}
async function init() { async function init() {
anonCounter = 0; anonCounter = 0;
const [statusResp, configResp] = await Promise.all([ const [statusResp, configResp] = await Promise.all([
@@ -251,210 +288,65 @@
document.getElementById('stats').textContent = document.getElementById('stats').textContent =
`${nodes.length} nodes, ${links.length} links`; `${nodes.length} nodes, ${links.length} links`;
const elements = []; const locationTree = buildLocationTree(config.locations || [], null);
const idMap = new Map(); const nodeIndex = new Map();
const switchIds = new Set(); buildNodeIndex(locationTree, nodeIndex);
const nodeToLocation = new Map(); const assignedNodes = new Map();
locationMeta = new Map(); const unassignedNodes = [];
buildLocationIndex(config.locations || [], null, nodeToLocation, locationMeta, 0, 0);
nodes.forEach((n, i) => { nodes.forEach(node => {
const id = 'n' + i; const loc = findLocationForNode(node, nodeIndex);
idMap.set(n.typeid, id); if (loc) {
if (isSwitch(n)) switchIds.add(id); if (!assignedNodes.has(loc)) {
}); assignedNodes.set(loc, []);
usedLocations = new Set();
const nodeParents = new Map();
nodes.forEach((n, i) => {
const id = 'n' + i;
const identifiers = getNodeIdentifiers(n);
for (const ident of identifiers) {
if (nodeToLocation.has(ident)) {
const locId = nodeToLocation.get(ident);
nodeParents.set(id, locId);
getLocationChain(locId, locationMeta).forEach(l => usedLocations.add(l));
break;
} }
assignedNodes.get(loc).push(node);
} else {
unassignedNodes.push(node);
} }
}); });
const sortedLocations = Array.from(usedLocations).sort((a, b) => { const container = document.getElementById('container');
const chainA = getLocationChain(a, locationMeta).length; container.innerHTML = '';
const chainB = getLocationChain(b, locationMeta).length;
return chainA - chainB; locationTree.forEach(loc => {
const el = renderLocation(loc, assignedNodes, true);
if (el) container.appendChild(el);
}); });
sortedLocations.forEach(locId => { if (unassignedNodes.length > 0) {
const meta = locationMeta.get(locId); const unassignedLoc = document.createElement('div');
elements.push({ unassignedLoc.className = 'location top-level';
data: {
id: locId,
label: meta.name,
parent: meta.parentId && usedLocations.has(meta.parentId) ? meta.parentId : null,
isLocation: true,
isAnonymous: meta.anonymous
}
});
});
nodes.forEach((n, i) => { const nameEl = document.createElement('div');
const id = 'n' + i; nameEl.className = 'location-name';
const sw = switchIds.has(id); nameEl.textContent = 'Unassigned';
const parent = nodeParents.get(id) || null; unassignedLoc.appendChild(nameEl);
elements.push({ const switches = unassignedNodes.filter(n => isSwitch(n));
data: { const nonSwitches = unassignedNodes.filter(n => !isSwitch(n));
id: id,
label: getLabel(n),
isSwitch: sw,
parent: parent,
rawData: n
}
});
});
links.forEach((link, i) => { if (switches.length > 0) {
const idA = idMap.get(link.node_a?.typeid); const switchRow = document.createElement('div');
const idB = idMap.get(link.node_b?.typeid); switchRow.className = 'node-row';
if (!idA || !idB) return; switches.forEach(node => {
switchRow.appendChild(createNodeElement(node));
elements.push({
data: {
id: 'e' + i,
source: idA,
target: idB,
sourceLabel: link.interface_a || '',
targetLabel: link.interface_b || '',
rawData: link
}
});
});
cy = cytoscape({
container: document.getElementById('cy'),
elements: elements,
autoungrabify: true,
autounselectify: true,
style: [
{
selector: 'node',
style: {
'label': 'data(label)',
'text-valign': 'center',
'text-halign': 'center',
'background-color': '#a6d',
'color': '#fff',
'font-size': 12,
'width': 120,
'height': 40,
'padding': 8,
'shape': 'round-rectangle',
'text-wrap': 'wrap',
'text-max-width': 110
}
},
{
selector: 'node[?isSwitch]',
style: {
'background-color': '#2a2',
'border-width': 3,
'border-color': '#4f4',
'font-size': 14,
'font-weight': 'bold',
'width': 100,
'height': 50
}
},
{
selector: 'node[?isLocation]',
style: {
'background-color': '#333',
'background-opacity': 0.8,
'border-width': 2,
'border-color': '#666',
'text-valign': 'top',
'text-halign': 'center',
'text-margin-y': 10,
'font-size': 16,
'font-weight': 'bold',
'color': '#fff',
'padding': 30,
'shape': 'round-rectangle'
}
},
{
selector: ':parent',
style: {
'background-opacity': 0.5,
'border-width': 2,
'border-color': '#666',
'text-valign': 'top',
'text-halign': 'center',
'text-margin-y': 10,
'padding': 30
}
},
{
selector: 'node[?isAnonymous]',
style: {
'background-opacity': 0,
'border-width': 0,
'padding': 10
}
},
{
selector: 'edge',
style: {
'width': 2,
'line-color': '#666',
'curve-style': 'bezier',
'source-label': 'data(sourceLabel)',
'target-label': 'data(targetLabel)',
'source-text-offset': 40,
'target-text-offset': 40,
'source-text-rotation': 'autorotate',
'target-text-rotation': 'autorotate',
'font-size': 9,
'color': '#aaa',
'text-background-color': '#1a1a1a',
'text-background-opacity': 1,
'text-background-padding': 2
}
}
],
layout: { name: 'preset' }
});
cy.on('click', 'node', function(evt) {
const node = evt.target;
const rawData = node.data('rawData');
if (rawData && !node.data('isLocation')) {
const json = JSON.stringify(rawData, null, 2);
navigator.clipboard.writeText(json).then(() => {
console.log('Copied node data');
}).catch(err => {
console.error('Copy failed:', err);
}); });
unassignedLoc.appendChild(switchRow);
} }
});
cy.on('click', 'edge', function(evt) { if (nonSwitches.length > 0) {
const edge = evt.target; const nodeRow = document.createElement('div');
const rawData = edge.data('rawData'); nodeRow.className = 'node-row';
if (rawData) { nonSwitches.forEach(node => {
const json = JSON.stringify(rawData, null, 2); nodeRow.appendChild(createNodeElement(node));
navigator.clipboard.writeText(json).then(() => {
console.log('Copied link data');
}).catch(err => {
console.error('Copy failed:', err);
}); });
unassignedLoc.appendChild(nodeRow);
} }
});
doLayout(); container.appendChild(unassignedLoc);
}
} }
init().catch(e => { init().catch(e => {