Replace Cytoscape with DOM-based grid layout
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
139
config.yaml
139
config.yaml
@@ -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
|
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
Reference in New Issue
Block a user