From 75f4e130d3dcbb55dcec86b26df6414a95fc27ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Vy=C4=8D=C3=ADtal?= Date: Thu, 28 Nov 2019 19:15:30 +0100 Subject: [PATCH] fix(hierarchical): ignore invisible nodes (#270) * fix(hierarchical): ignore invisible nodes The layout originally made space for invisible nodes (for example after clustering). Now it takes into account only the nodes that are actually rendered solving the problems with mysterios empty spaces in hierarchies with clusters. * chore(examples): add clustering to hierarchy methods The example is mostly rewritten. It has brand new design. A lot of weird stuff was rewritten (like a comment talking about randomly generating nodes when in fact it was just generating ordered numbers 0 to 18 in simple for; edges being pushed one by one into an array; the String constructor etc.) The example now allows layouting method and shaking direction to be chosen using radio buttons (ordinary buttons were used before) and checkboxes to choose which nodes should be clustered. --- .../layout/hierarchicalLayoutMethods.html | 394 +++++++++++++----- lib/network/modules/LayoutEngine.js | 15 +- lib/network/modules/layout-engine/index.ts | 66 ++- 3 files changed, 336 insertions(+), 139 deletions(-) diff --git a/examples/network/layout/hierarchicalLayoutMethods.html b/examples/network/layout/hierarchicalLayoutMethods.html index 7c22d0db7e..f43ae197dd 100644 --- a/examples/network/layout/hierarchicalLayoutMethods.html +++ b/examples/network/layout/hierarchicalLayoutMethods.html @@ -1,126 +1,298 @@ - + - - Vis Network | Layouts | Hierarchical Layout Difference - - - - - - - - - - -

Hierarchical layout difference

-
- This example shows a the effect of the different hierarchical layout - methods. Hubsize is based on the amount of edges connected to a node. The - node with the most connections (the largest hub) is drawn at the top of - the tree. The direction method is based on the direction of the edges. Try - switching between the methods by clicking on the buttons bellow. -
-

- Layout method: - -

-

- Shake towards: - - (Applies to directed only.) -

+ conf.addEventListener("change", handleConfChange); + handleConfChange(); + }); + + + + + + +
+
+

Vis Network

+

Layouts

+

Hierarchical Layout Methods

+
+ +

+ This example shows the effect of the different hierarchical layouting + methods, node shaking and how hierarchical layouts work with clustering. + Also note that it's impossible to properly position a "hierarchy" with a + cycle (If node 1 is above node 2 and node 2 is above node 1 which one is + actually on top?) +

+ +

Hub Size

+

+ The hub size layouting method is based on the amount of edges connected + to a node. The node with the most connections (the largest hub) is drawn + at the top of the tree. +

+ +

Direction

+

+ The direction layouting method is based on the direction of the edges. + The from nodes are placed above the to nodes in the hierarchy. Nodes + that can be placed on multiple levels are by default shaken towards + towards the leaves. All the leaves are then in a single line at the very + bottom of the hierarchy. Optionally they can be shaken towards the roots + which results in the roots being in a single line at the very top of the + hierarchy. +

+ +

Interactive Configuration

+
+

+ Layout method: +
+ + + +
+ + + +

+ +

+ Shake towards (Applies to directed only.): +
+ + + +
+ + + +

+ +

+ Cluster: +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +
+ + + +

+
+
+ diff --git a/lib/network/modules/LayoutEngine.js b/lib/network/modules/LayoutEngine.js index a2728dd3b7..7c424f6cdf 100644 --- a/lib/network/modules/LayoutEngine.js +++ b/lib/network/modules/LayoutEngine.js @@ -1503,19 +1503,16 @@ class LayoutEngine { * @private */ _determineLevelsDirected() { - const nodes = this.body.nodeIndices.map(id => this.body.nodes[id]); + const nodes = this.body.nodeIndices.reduce((acc, id) => { + acc.set(id, this.body.nodes[id]); + return acc; + }, new Map()); const levels = this.hierarchical.levels; if (this.options.hierarchical.shakeTowards === "roots") { - this.hierarchical.levels = fillLevelsByDirectionRoots( - nodes, - this.hierarchical.levels - ); + this.hierarchical.levels = fillLevelsByDirectionRoots(nodes, levels); } else { - this.hierarchical.levels = fillLevelsByDirectionLeaves( - nodes, - this.hierarchical.levels - ); + this.hierarchical.levels = fillLevelsByDirectionLeaves(nodes, levels); } this.hierarchical.setMinLevelToZero(this.body.nodes); diff --git a/lib/network/modules/layout-engine/index.ts b/lib/network/modules/layout-engine/index.ts index 50dff0e974..7484c53a9d 100644 --- a/lib/network/modules/layout-engine/index.ts +++ b/lib/network/modules/layout-engine/index.ts @@ -1,25 +1,29 @@ type Levels = Record; +type Id = string | number; interface Edge { connected: boolean; from: Node; - fromId: string | number; + fromId: Id; to: Node; - toId: string | number; + toId: Id; } interface Node { - id: string | number; + id: Id; edges: Edge[]; } /** * Try to assign levels to nodes according to their positions in the cyclic “hierarchy”. * - * @param nodes - Nodes of the graph. + * @param nodes - Visible nodes of the graph. * @param levels - If present levels will be added to it, if not a new object will be created. * * @returns Populated node levels. */ -function fillLevelsByDirectionCyclic(nodes: Node[], levels: Levels): Levels { +function fillLevelsByDirectionCyclic( + nodes: Map, + levels: Levels +): Levels { const edges = new Set(); nodes.forEach((node): void => { node.edges.forEach((edge): void => { @@ -48,18 +52,23 @@ function fillLevelsByDirectionCyclic(nodes: Node[], levels: Levels): Levels { /** * Assign levels to nodes according to their positions in the hierarchy. Leaves will be lined up at the bottom and all other nodes as close to their children as possible. * - * @param nodes - Nodes of the graph. + * @param nodes - Visible nodes of the graph. * @param levels - If present levels will be added to it, if not a new object will be created. * * @returns Populated node levels. */ export function fillLevelsByDirectionLeaves( - nodes: Node[], + nodes: Map, levels: Levels = Object.create(null) ): Levels { return fillLevelsByDirection( // Pick only leaves (nodes without children). - (node): boolean => !node.edges.every((edge): boolean => edge.to === node), + (node): boolean => + node.edges + // Take only visible nodes into account. + .filter((edge): boolean => nodes.has(edge.toId)) + // Check that all edges lead to this node (leaf). + .every((edge): boolean => edge.to === node), // Use the lowest level. (newLevel, oldLevel): boolean => oldLevel > newLevel, // Go against the direction of the edges. @@ -72,18 +81,23 @@ export function fillLevelsByDirectionLeaves( /** * Assign levels to nodes according to their positions in the hierarchy. Roots will be lined up at the top and all nodes as close to their parents as possible. * - * @param nodes - Nodes of the graph. + * @param nodes - Visible nodes of the graph. * @param levels - If present levels will be added to it, if not a new object will be created. * * @returns Populated node levels. */ export function fillLevelsByDirectionRoots( - nodes: Node[], + nodes: Map, levels: Levels = Object.create(null) ): Levels { return fillLevelsByDirection( // Pick only roots (nodes without parents). - (node): boolean => !node.edges.every((edge): boolean => edge.from === node), + (node): boolean => + node.edges + // Take only visible nodes into account. + .filter((edge): boolean => nodes.has(edge.toId)) + // Check that all edges lead from this node (root). + .every((edge): boolean => edge.from === node), // Use the highest level. (newLevel, oldLevel): boolean => oldLevel < newLevel, // Go in the direction of the edges. @@ -99,7 +113,7 @@ export function fillLevelsByDirectionRoots( * @param isEntryNode - Checks and return true if the graph should be traversed from this node. * @param shouldLevelBeReplaced - Checks and returns true if the level of given node should be updated to the new value. * @param direction - Wheter the graph should be traversed in the direction of the edges `"to"` or in the other way `"from"`. - * @param nodes - Nodes of the graph. + * @param nodes - Visible nodes of the graph. * @param levels - If present levels will be added to it, if not a new object will be created. * * @returns Populated node levels. @@ -108,25 +122,35 @@ function fillLevelsByDirection( isEntryNode: (node: Node) => boolean, shouldLevelBeReplaced: (newLevel: number, oldLevel: number) => boolean, direction: "to" | "from", - nodes: Node[], + nodes: Map, levels: Levels ): Levels { - const limit = nodes.length; - const edgeIdProp = direction + "Id"; + const limit = nodes.size; + const edgeIdProp: "fromId" | "toId" = (direction + "Id") as "fromId" | "toId"; const newLevelDiff = direction === "to" ? 1 : -1; - for (const entryNode of nodes) { - if (isEntryNode(entryNode)) { + for (const [entryNodeId, entryNode] of nodes) { + if ( + // Skip if the node is not visible. + !nodes.has(entryNodeId) || + // Skip if the node is not an entry node. + !isEntryNode(entryNode) + ) { continue; } // Line up all the entry nodes on level 0. - levels[entryNode.id] = 0; + levels[entryNodeId] = 0; const stack: Node[] = [entryNode]; let done = 0; let node: Node | undefined; while ((node = stack.pop())) { + if (!nodes.has(entryNodeId)) { + // Skip if the node is not visible. + continue; + } + const newLevel = levels[node.id] + newLevelDiff; node.edges @@ -137,7 +161,11 @@ function fillLevelsByDirection( // Ignore circular edges. edge.to !== edge.from && // Ignore edges leading to the node that's currently being processed. - edge[direction] !== node + edge[direction] !== node && + // Ignore edges connecting to an invisible node. + nodes.has(edge.toId) && + // Ignore edges connecting from an invisible node. + nodes.has(edge.fromId) ) .forEach((edge): void => { const targetNodeId = edge[edgeIdProp];