Merge pull request #40 from AngelHely/main/interactive_viewer

KubeDiagrams Interactive Viewer
This commit is contained in:
Philippe Merle
2025-06-14 15:31:16 +02:00
committed by GitHub
7 changed files with 581 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KubeDiagrams Interactive Viewer</title>
<!-- cytoscape -->
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.32.0/dist/cytoscape.min.js"></script>
<!-- layout dagre -->
<script src=https://cdn.jsdelivr.net/npm/dagre@0.8.5/dist/dagre.min.js></script>
<script src="https://cdn.jsdelivr.net/npm/cytoscape-dagre@2.5.0/cytoscape-dagre.min.js"></script>
<!-- layout klay -->
<script src="https://cdn.jsdelivr.net/npm/klayjs@0.4.1/klay.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/cytoscape-klay@3.1.4/cytoscape-klay.min.js"></script>
<!-- cytoscape-context-menus -->
<script src="https://cdn.jsdelivr.net/npm/cytoscape-context-menus@4.2.1/cytoscape-context-menus.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cytoscape-context-menus@4.2.1/cytoscape-context-menus.min.css">
<!-- KubeDiagrams Interactive Viewer -->
<script src="./script/defaultStyle.js"></script>
<script src="./script/layout.js"></script>
<script src="./script/itemAndFunctionMenus.js"></script>
<script src="./script/main.js"></script>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<h1><img style="vertical-align: middle;" src="KubeDiagrams.png" height="80"/> <text style="color: #326CE5;">Interactive Viewer</text></h1>
<div class="buttons">
Select a .dot_json file:
<input type="file" id="fileInput"/>
Choose a layout
<div id="layoutButtons"></div>
Save as
<div id="saveButtons">
<button id="savePNG">PNG</button>
<button id="saveJPG">JPG</button>
</div>
</div>
<div id="paper"></div>
<pre id="tooltip"></pre>
</body>
</html>

View File

@@ -0,0 +1,92 @@
let clusterStyleList = [];
let nodeStyleList = [];
let edgeStyleList = [];
function getDefaultGlobalNodeStyleFromNodeValues(node) {
return {
selector : ".nodeStyle" + nodeStyleList.length,
style : {
'color': node.data.fontcolor,
'font-size': node.data.fontsize,
'font-family': node.data.fontfamily,
}
};
}
function getDefaultClusterStyleFromClusterValues(cluster) {
return {
selector : ".clusterStyle" + clusterStyleList.length,
style : {
'border-style': cluster.data.bs,
'border-color': cluster.data.bc,
'background-color': cluster.data.bgcolor,
}
};
}
function getDefaultEdgeStyleFromEdgeValues(edge) {
return {
selector : ".edgeStyle" + edgeStyleList.length,
style : {
'line-color': edge.data.color,
'target-arrow-color': edge.data.color,
'source-arrow-color': edge.data.color,
'line-style': edge.data.line_style,
'color': edge.data.fontcolor,
'font-size': edge.data.fontsize,
'font-family': edge.data.fontfamily,
}
};
}
const clusterClosedStyle = {
'text-wrap': 'wrap',
'text-max-width': '100px',
'text-valign': 'center',
'text-halign': 'center',
'text-margin-y': 0,
'width': 50,
'height': 20,
}
const clusterOpenStyle = {
'text-valign': 'top',
'text-wrap': 'none',
'text-margin-y': 15,
'padding': '15px',
}
const defaultGroupNodeStyle = {
'background-fit': 'cover',
'background-image': 'data(image)',
'background-opacity': 0,
'background-clip': 'node',
'width': 100,
'height': 100,
'text-valign' : 'bottom',
'text-wrap': 'wrap',
}
const defaultGlobalNodeStyle = {
'content': 'data(label)',
'min-zoomed-font-size': '8'
}
const defaultEdgeStyle = {
'source-endpoint': 'outside-to-node-or-label',
'target-endpoint': 'outside-to-node-or-label',
'width': 1,
'curve-style': 'bezier',
'label': 'data(xlabel)',
'min-zoomed-font-size': '8',
}
const defaultEdgeDirForward = {
'target-arrow-shape': 'triangle',
'target-arrow-fill': 'filled',
}
const defaultEdgeDirBack = {
'source-arrow-shape': 'triangle',
'source-arrow-fill': 'filled',
}

View File

@@ -0,0 +1,35 @@
const itemOpenClose = {
id: 'co',
content: 'close/open',
tooltipText: 'close/open',
selector: 'node[group = "cluster"]',
onClickFunction: function (event) {
const cluster = event.target;
if (cluster.data('isClose')) {
openCluster(cluster);
}
else {
closeCluster(cluster);
}
},
}
/**
* Open a cluster
* @param {*} cluster
*/
function openCluster(cluster) {
cluster.children().style('display', 'element');
cluster.data('isClose', false);
cluster.style(clusterOpenStyle);
}
/**
* Close a cluster
* @param {*} cluster
*/
function closeCluster(cluster) {
cluster.children().style('display', 'none');
cluster.data('isClose', true);
cluster.style(clusterClosedStyle);
}

View File

@@ -0,0 +1,22 @@
const layoutDagre = {name: 'dagre',
rankDir: 'TB',
ranker: 'network-simplex',
nodeSep: 25,
avoidOverlap: true,
fit: true,
}
const layoutKlay = {
name: 'klay',
klay: {
direction: 'DOWN',
layoutHierarchy: true,
spacing: 50,
edgeSpacingFactor: 0,
fixedAlignment: 'RIGHTUP',
nodeLayering:'INTERACTIVE',
}
}
const layoutList = [layoutDagre, layoutKlay];

View File

@@ -0,0 +1,362 @@
cytoscape.use(cytoscapeDagre);
cytoscape.use(cytoscapeKlay);
let cy;
let currentLayout;
/**
* initialise a cytoscape instance, create layout selector buttons, add event listeners on save buttons and add
* an event listener on the file input button to load the cytoscape graph.
*/
function setUp() {
cy = getCyGraph();
currentLayout = layoutList[0];
createLayoutSelectorButton();
document.getElementById("savePNG").addEventListener("click", () => { saveFile("png")});
document.getElementById("saveJPG").addEventListener("click", () => { saveFile("jpg")});
document.getElementById('fileInput').addEventListener('change', readFileAndloadCytoscapeGraph);
}
/**
* return a cytoscape graph.
* @returns
*/
function getCyGraph() {
return cytoscape({
container: document.getElementById('paper'),
elements: undefined,
hideEdgesOnViewport: true,
pixelRatio: window.devicePixelRatio,
style: [
{
selector: 'node',
style: defaultGlobalNodeStyle
},
{
selector: 'node[group = "cluster"]',
style: clusterOpenStyle
},
{
selector: 'node[group = "node"]',
style: defaultGroupNodeStyle
},
{
selector: 'edge',
style: defaultEdgeStyle
},
{
selector: 'edge[dir = "forward"]',
style: defaultEdgeDirForward
},
{
selector: 'edge[dir = "back"]',
style: defaultEdgeDirBack
}
]
});
}
/**
* Read a dot_json file to add nodes and edges in a elements list used to load a cytoscape instance.
* @param {*} event
*/
function readFileAndloadCytoscapeGraph(event) {
const file = event.target.files[0];
const reader = new FileReader();
const elements = [];
reader.onload = function(e) {
let content = e.target.result;
const json = JSON.parse(content);
const nodes = json.objects;
const edges = json.edges;
addNodesElementsParsedFromNodesJson(elements, nodes);
addNodesEdgesParsedFromEdgesJson(elements, edges);
load_cytoscape(elements);
};
reader.readAsText(file);
}
/**
* Remove precedent elements before to add the new elements then create tool tip and context menus for the
* cytoscape instance.
* @param {*} elements
*/
function load_cytoscape(elements) {
cy.nodes().remove();
cy.add(elements);
createTooltip("node");
createTooltip("edge");
createAndGetContextMenu(cy);
cy.layout(currentLayout).run();
}
function load_layout(layout) {
currentLayout = layout;
cy.layout(currentLayout).run();
}
/**
* Add nodes elements created from nodesJson data list in the elements list.
* @param {*} elements
* @param {*} nodesJson - nodes data list
* @returns
*/
function addNodesElementsParsedFromNodesJson(elements, nodesJson) {
let parent = {};
for (let i in nodesJson) {
let node = {
data: {
id: nodesJson[i]._gvid,
group: (nodesJson[i].nodes) ? 'cluster' : 'node',
isClose : false,
label: (nodesJson[i].label.includes('<') && nodesJson[i].label.includes('>')) ? nodesJson[i].tooltip : nodesJson[i].label,
bs: getCorrespondingBorderStyle(nodesJson[i].style),
bgcolor: nodesJson[i].bgcolor ?? 'blue',
bc: nodesJson[i].pencolor ?? 'gray',
parent: parent[nodesJson[i]._gvid] ?? '',
fontsize: nodesJson[i].fontsize ?? '',
fontfamily: nodesJson[i].fontname ?? '',
fontcolor: nodesJson[i].fontcolor ?? '',
image: (nodesJson[i].image) ? getURLImage(nodesJson[i].image) : '',
tooltip: nodesJson[i].tooltip ?? ''
}
}
if (nodesJson[i].nodes) {
for (let child of nodesJson[i].nodes) {
parent[child] = nodesJson[i]._gvid;
}
}
if (nodesJson[i].subgraphs) {
for (let sub of nodesJson[i].subgraphs) {
parent[sub] = nodesJson[i]._gvid;
}
}
addClassToElement(node);
elements.push(node);
}
return elements;
}
/**
* Add nodes elements created from edgesJson data list in the elements list.
* @param {*} elements
* @param {*} edgesJson
*/
function addNodesEdgesParsedFromEdgesJson(elements, edgesJson) {
for (let e in edgesJson) {
let edge = {
data: {
id: 'e' + edgesJson[e]._gvid,
group: 'edge',
dir: edgesJson[e].dir,
source: edgesJson[e].tail,
target: edgesJson[e].head,
color: edgesJson[e].color,
line_style: edgesJson[e].style ?? 'solid',
xlabel: edgesJson[e].xlabel ?? '',
fontsize: edgesJson[e].fontsize ?? '',
fontfamily: edgesJson[e].fontname ?? '',
fontcolor: edgesJson[e].fontcolor ?? '',
tooltip: edgesJson[e].tooltip ?? '',
}
}
addClassToElement(edge);
elements.push(edge);
}
}
/**
* Get border style based on the what's in the style parameter.
* @param {*} style
* @returns
*/
function getCorrespondingBorderStyle(style) {
if (style.includes('dashed')) {
return 'dashed';
}
else {
return 'solid';
}
}
/**
* Get the corresponding url image based on the local url of the image parameter get from a dot_jon file.
* @param {*} image
* @returns
*/
function getURLImage(image) {
let idx = image.indexOf("resources\/")
if(idx != -1) {
return "https://raw.githubusercontent.com/mingrammer/diagrams/refs/heads/master/" + image.slice(image.indexOf("resources"))
} else {
idx = image.indexOf("bin\/icons\/")
if(idx != -1) {
return "https://raw.githubusercontent.com/philippemerle/KubeDiagrams/refs/heads/main/" + image.slice(image.indexOf("bin\/icons\/"))
} else {
return image
}
}
}
/**
* Create a HTML tooltip with the events of the cytoscape instance
* @param {*} elementType
*/
function createTooltip(elementType) {
const tooltip = document.getElementById('tooltip');
let to;
cy.on('mouseover', elementType, function(event) {
const element = event.target;
to = setTimeout(() => {
tooltip.style.display = 'block';
tooltip.innerText = element.data('tooltip');
}, 1000);
});
cy.on('mouseout', elementType, function(event) {
clearTimeout(to);
tooltip.style.display = 'none';
});
cy.on('mousemove', function(event) {
tooltip.style.left = (event.originalEvent.pageX + 10) + 'px';
tooltip.style.top = (event.originalEvent.pageY + 10) + 'px';
});
}
/**
* Create layout selector buttons after the fileInput
*/
function createLayoutSelectorButton() {
const layoutButtons = document.getElementById("layoutButtons");
for (let layout of layoutList) {
let button = document.createElement("button")
button.id = layout.name;
button.textContent = layout.name;
button.addEventListener("click", () => {load_layout(layout)} );
layoutButtons.appendChild(button);
}
}
/**
* Save file in png or jpg format.
* @param {*} format
*/
function saveFile(format) {
const img = getFileAs(format);
const ele = document.createElement('a');
ele.href = img;
ele.download = 'graph.' + format;
ele.click();
}
/**
* Get a representation of the graph as an image of the requested format
* @param {*} format
* @returns
*/
function getFileAs(format) {
switch (format) {
case "png": return cy.png({full : true});
case "jpg": return cy.jpg({full : true});
}
}
/**
* Create and return a context menus for the cytoscape instance
* @param {*} cy
* @returns
*/
function createAndGetContextMenu(cy) {
return cy.contextMenus({
evtType: 'cxttap',
menuItems: [itemOpenClose]
})
}
/**
* Check the element's group and then add a style class based on the group and his values.
* @param {*} element
*/
function addClassToElement(element) {
let classesName = [];
let style;
if (element.data.group == "edge") {
style = getDefaultEdgeStyleFromEdgeValues(element);
classesName.push(findAndGetClassStyle(style, edgeStyleList).selector.replace(/^\./, ''));
}
else {
style = getDefaultGlobalNodeStyleFromNodeValues(element);
classesName.push(findAndGetClassStyle(style, nodeStyleList).selector.replace(/^\./, ''));
if (element.data.group == "cluster") {
style = getDefaultClusterStyleFromClusterValues(element);
classesName.push(findAndGetClassStyle(style, clusterStyleList).selector.replace(/^\./, ''))
}
}
element.classes = classesName.join(' ');
}
/**
* Look for a style class similar to the styleTarget in the styleList. if a style class is founded,
* so the method return the style class founded otherwise add the styleTarget in the cytoscape instance and
* in the styleList then return the styleTarget
* @param {*} styleTarget
* @param {*} styleList
* @returns
*/
function findAndGetClassStyle(styleTarget, styleList) {
for (let style of styleList) {
if (equals(styleTarget.style, style.style)) {
return style;
}
}
addStyleToCytoscapeGraph(styleTarget);
styleList.push(styleTarget);
return styleTarget;
}
/**
* Add a new style in the style attribute of the cytoscape instance.
* @param {*} style
*/
function addStyleToCytoscapeGraph(style) {
const existingStyle = cy.style().json();
cy.style([...existingStyle, style]);
}
/**
* Check if two object are similar.
* @param {*} o1
* @param {*} o2
* @returns
*/
function equals(o1, o2) {
if (o1 === o2) return true;
if (typeof o1 !== "object" || typeof o2 !== "object" || o1 == null || o2 == null) {
return false;
}
const k1 = Object.keys(o1);
const k2 = Object.keys(o2);
if (k1.length !== k2.length) return false;
for (let k of k1) {
if (!k2.includes(k) || !equals(o1[k], o2[k])) {
return false;
}
}
return true;
}
window.addEventListener("load", setUp);

View File

@@ -0,0 +1,24 @@
#paper {
width: 100%;
height: 80vh;
display: block;
margin: auto;
border: solid;
}
.buttons {
display: flex;
gap: 10px;
}
#layoutButtons {
display: flex;
gap: 4px;
}
#tooltip {
position: absolute;
display: none;
background: white;
border: solid;
padding: 5px 10px;
border-radius: 4px;
font-size: 15px;
}