From 2f4a5e2811b43d0f5df27cc79c6f89201de5c3b2 Mon Sep 17 00:00:00 2001 From: Tiago Lascasas Santos Date: Tue, 2 Apr 2024 03:25:08 +0100 Subject: [PATCH] [LaraApi] Graph dot formatter now supports layout options --- LaraApi/src-lara/lara/graphs/DotFormatter.js | 133 +++++----- LaraApi/src-lara/lara/graphs/Graphs.mjs | 241 ++++++++++--------- 2 files changed, 198 insertions(+), 176 deletions(-) diff --git a/LaraApi/src-lara/lara/graphs/DotFormatter.js b/LaraApi/src-lara/lara/graphs/DotFormatter.js index 611aac7cc..0d5d68711 100644 --- a/LaraApi/src-lara/lara/graphs/DotFormatter.js +++ b/LaraApi/src-lara/lara/graphs/DotFormatter.js @@ -1,71 +1,90 @@ class DotFormatter { - // Array of objects that contains the properties 'attr' (string) and 'predicate' (function) - #nodeAttrs; + // Array of objects that contains the properties 'attr' (string) and 'predicate' (function) + #nodeAttrs; - // Array of objects that contains the properties 'attr' (string) and 'predicate' (function) - #edgeAttrs; + // Array of objects that contains the properties 'attr' (string) and 'predicate' (function) + #edgeAttrs; - // Function that receives a node and returns the corresponding label. By default, call .toString() over the data - #nodeLabelFormatter; + // Map with selected layout options + #layoutOptions; - // Function that receives an edge and returns the corresponding label. By default, call .toString() over the data - #edgeLabelFormatter; + // Function that receives a node and returns the corresponding label. By default, call .toString() over the data + #nodeLabelFormatter; - constructor() { - this.#nodeAttrs = []; - this.#edgeAttrs = []; + // Function that receives an edge and returns the corresponding label. By default, call .toString() over the data + #edgeLabelFormatter; - this.#nodeLabelFormatter = (node) => node.data().toString(); - this.#edgeLabelFormatter = (edge) => edge.data().toString(); - } - static #sanitizeDotLabel(label) { - return label.replaceAll("\n", "\\l").replaceAll("\r", ""); - } + constructor() { + this.#nodeAttrs = []; + this.#edgeAttrs = []; + this.#layoutOptions = new Map(); - addNodeAttribute(attrString, predicate) { - if (predicate === undefined) { - predicate = (node) => true; + this.#nodeLabelFormatter = (node) => node.data().toString(); + this.#edgeLabelFormatter = (edge) => edge.data().toString(); } - this.#nodeAttrs.push({ attr: attrString, predicate: predicate }); - } + static #sanitizeDotLabel(label) { + return label.replaceAll("\n", "\\l").replaceAll("\r", ""); + } + + addNodeAttribute(attrString, predicate) { + if (predicate === undefined) { + predicate = (node) => true; + } + + this.#nodeAttrs.push({ attr: attrString, predicate: predicate }); + } + + addEdgeAttribute(attrString, predicate) { + if (predicate === undefined) { + predicate = (edge) => true; + } + + this.#edgeAttrs.push({ attr: attrString, predicate: predicate }); + } + + setLayoutOption(option, value) { + this.#layoutOptions.set(option, value); + } + + setNodeLabelFormatter(nodeLabelFormatter) { + this.#nodeLabelFormatter = nodeLabelFormatter; + } - addEdgeAttribute(attrString, predicate) { - if (predicate === undefined) { - predicate = (edge) => true; + setEdgeLabelFormatter(edgeLabelFormatter) { + this.#edgeLabelFormatter = edgeLabelFormatter; } - this.#edgeAttrs.push({ attr: attrString, predicate: predicate }); - } - - setNodeLabelFormatter(nodeLabelFormatter) { - this.#nodeLabelFormatter = nodeLabelFormatter; - } - - setEdgeLabelFormatter(edgeLabelFormatter) { - this.#edgeLabelFormatter = edgeLabelFormatter; - } - - getNodeAttributes(node) { - return this.#nodeAttrs - .filter((obj) => obj.predicate(node)) - .map((obj) => obj.attr) - .join(" "); - } - - getEdgeAttributes(edge) { - return this.#edgeAttrs - .filter((obj) => obj.predicate(edge)) - .map((obj) => obj.attr) - .join(" "); - } - - getNodeLabel(node) { - return DotFormatter.#sanitizeDotLabel(this.#nodeLabelFormatter(node)); - } - - getEdgeLabel(edge) { - return DotFormatter.#sanitizeDotLabel(this.#edgeLabelFormatter(edge)); - } + getNodeAttributes(node) { + return this.#nodeAttrs + .filter((obj) => obj.predicate(node)) + .map((obj) => obj.attr) + .join(" "); + } + + getEdgeAttributes(edge) { + return this.#edgeAttrs + .filter((obj) => obj.predicate(edge)) + .map((obj) => obj.attr) + .join(" "); + } + + getLayoutOptions() { + let options = []; + + this.#layoutOptions.forEach((option, val) => { + options.push(`${val}="${option}";`); + }); + + return options.join("\n"); + } + + getNodeLabel(node) { + return DotFormatter.#sanitizeDotLabel(this.#nodeLabelFormatter(node)); + } + + getEdgeLabel(edge) { + return DotFormatter.#sanitizeDotLabel(this.#edgeLabelFormatter(edge)); + } } diff --git a/LaraApi/src-lara/lara/graphs/Graphs.mjs b/LaraApi/src-lara/lara/graphs/Graphs.mjs index de46a9c3f..7b8bae23e 100644 --- a/LaraApi/src-lara/lara/graphs/Graphs.mjs +++ b/LaraApi/src-lara/lara/graphs/Graphs.mjs @@ -10,142 +10,145 @@ import cytoscape from "lara-js/api/libs/cytoscape-3.21.1.min.cjs"; * Current implementation uses Cytoscape.js (https://js.cytoscape.org/) */ class Graphs { - static #isLibLoaded = false; + static #isLibLoaded = false; - /** - * @param {Object} [config = {}] configuration for the graph, according to what Cytoscape accepts as configuration object - */ - static newGraph(config) { - // Ensure library is loaded - Graphs.loadLibrary(); + /** + * @param {Object} [config = {}] configuration for the graph, according to what Cytoscape accepts as configuration object + */ + static newGraph(config) { + // Ensure library is loaded + Graphs.loadLibrary(); - const _config = config ?? {}; + const _config = config ?? {}; - return cytoscape(_config); - } - - static loadLibrary() { - if (Graphs.#isLibLoaded) { - return; + return cytoscape(_config); } - globalThis.cytoscape = cytoscape; - - Graphs.#isLibLoaded = true; - } + static loadLibrary() { + if (Graphs.#isLibLoaded) { + return; + } - static addNode(graph, nodeData) { - let _nodeData = nodeData ?? {}; + globalThis.cytoscape = cytoscape; - // Check if NodeData - if (!(_nodeData instanceof NodeData)) { - _nodeData = Object.assign(new NodeData(), _nodeData); + Graphs.#isLibLoaded = true; } - return graph.add({ group: "nodes", data: _nodeData }); - } + static addNode(graph, nodeData) { + let _nodeData = nodeData ?? {}; - static addEdge(graph, sourceNode, targetNode, edgeData) { - let _edgeData = edgeData ?? {}; + // Check if NodeData + if (!(_nodeData instanceof NodeData)) { + _nodeData = Object.assign(new NodeData(), _nodeData); + } - // Check if EdgeData - if (!(_edgeData instanceof EdgeData)) { - _edgeData = Object.assign(new EdgeData(), _edgeData); + return graph.add({ group: "nodes", data: _nodeData }); } - _edgeData.source = sourceNode.id(); - _edgeData.target = targetNode.id(); - - return graph.add({ group: "edges", data: _edgeData }); - } - - /** - * - * @param {graph} graph - * @param {lara.graphs.DotFormatter} dotFormatter - * @returns - */ - static toDot(graph, dotFormatter) { - dotFormatter ??= new DotFormatter(); - - var dot = "digraph test {\n"; - - // Declare nodes - for (const node of graph.nodes()) { - dot += - '"' + - node.id() + - '" [label="' + - dotFormatter.getNodeLabel(node) + - '" shape=box'; - - // Add node attributes - const nodeAttrs = dotFormatter.getNodeAttributes(node); - dot += nodeAttrs.length === 0 ? "" : " " + nodeAttrs; - - dot += "];\n"; + static addEdge(graph, sourceNode, targetNode, edgeData) { + let _edgeData = edgeData ?? {}; + + // Check if EdgeData + if (!(_edgeData instanceof EdgeData)) { + _edgeData = Object.assign(new EdgeData(), _edgeData); + } + + _edgeData.source = sourceNode.id(); + _edgeData.target = targetNode.id(); + + return graph.add({ group: "edges", data: _edgeData }); } - for (const edge of graph.edges()) { - dot += - '"' + - edge.data().source + - '" -> "' + - edge.data().target + - '" [label="' + - dotFormatter.getEdgeLabel(edge) + - '"'; - - // Get edge attributes - const edgeAttrs = dotFormatter.getEdgeAttributes(edge); - dot += edgeAttrs.length === 0 ? "" : " " + edgeAttrs; - - dot += "];\n"; + /** + * + * @param {graph} graph + * @param {lara.graphs.DotFormatter} dotFormatter + * @returns + */ + static toDot(graph, dotFormatter) { + dotFormatter ??= new DotFormatter(); + + var dot = "digraph test {\n"; + + const layout = dotFormatter.getLayoutOptions(); + dot += layout + "\n"; + + // Declare nodes + for (const node of graph.nodes()) { + dot += + '"' + + node.id() + + '" [label="' + + dotFormatter.getNodeLabel(node) + + '" shape=box'; + + // Add node attributes + const nodeAttrs = dotFormatter.getNodeAttributes(node); + dot += nodeAttrs.length === 0 ? "" : " " + nodeAttrs; + + dot += "];\n"; + } + + for (const edge of graph.edges()) { + dot += + '"' + + edge.data().source + + '" -> "' + + edge.data().target + + '" [label="' + + dotFormatter.getEdgeLabel(edge) + + '"'; + + // Get edge attributes + const edgeAttrs = dotFormatter.getEdgeAttributes(edge); + dot += edgeAttrs.length === 0 ? "" : " " + edgeAttrs; + + dot += "];\n"; + } + + dot += "}\n"; + + return dot; } - dot += "}\n"; - - return dot; - } - - /** - * - * @param {node} node - * @param {boolean} [loopsAreLeafs = false] - * @returns true if the outdegree (number of edges with this node as source) is zero, false otherwise. By default, if a node has a connection to itself (loop) it is not considered a leaf - */ - static isLeaf(node, loopsAreLeafs = false) { - const includeLoops = !loopsAreLeafs; - return node.outdegree(includeLoops) === 0; - } - - /** - * Removes a node from the graph. Before removing the node, creates connections between all connecting sources and targets. - * - * @param {graph} graph - * @param {node} node - * @param {(edge, edge) -> EdgeData)} edgeMap function that receives the incoming edge and the outgoing edge, and returns a new EdgeData that replaces both edges - */ - static removeNode(graph, node, edgeMapper) { - // Get edges of node - const edges = node.connectedEdges(); - - const incomingEdges = edges.filter((edge) => edge.target().equals(node)); - const outgoingEdges = edges.filter((edge) => edge.source().equals(node)); - - for (const incoming of incomingEdges) { - for (const outgoing of outgoingEdges) { - const newEdgeData = edgeMapper(incoming, outgoing); - Graphs.addEdge( - graph, - incoming.source(), - outgoing.target(), - newEdgeData - ); - } + /** + * + * @param {node} node + * @param {boolean} [loopsAreLeafs = false] + * @returns true if the outdegree (number of edges with this node as source) is zero, false otherwise. By default, if a node has a connection to itself (loop) it is not considered a leaf + */ + static isLeaf(node, loopsAreLeafs = false) { + const includeLoops = !loopsAreLeafs; + return node.outdegree(includeLoops) === 0; } - // Remove node - node.remove(); - } + /** + * Removes a node from the graph. Before removing the node, creates connections between all connecting sources and targets. + * + * @param {graph} graph + * @param {node} node + * @param {(edge, edge) -> EdgeData)} edgeMap function that receives the incoming edge and the outgoing edge, and returns a new EdgeData that replaces both edges + */ + static removeNode(graph, node, edgeMapper) { + // Get edges of node + const edges = node.connectedEdges(); + + const incomingEdges = edges.filter((edge) => edge.target().equals(node)); + const outgoingEdges = edges.filter((edge) => edge.source().equals(node)); + + for (const incoming of incomingEdges) { + for (const outgoing of outgoingEdges) { + const newEdgeData = edgeMapper(incoming, outgoing); + Graphs.addEdge( + graph, + incoming.source(), + outgoing.target(), + newEdgeData + ); + } + } + + // Remove node + node.remove(); + } }