trassel
Version:
Graph computing in JavaScript
1,377 lines (1,302 loc) • 1.89 MB
JavaScript
/** @preserve @license @cc_on
* ----------------------------------------------------------
* trassel version 0.1.9
* Graph computing in JavaScript
* https://fukurosan.github.io/Trassel/
* Copyright (c) 2024 Henrik Olofsson
* All Rights Reserved. MIT License
* https://mit-license.org/
* ----------------------------------------------------------
*/
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
const Env = Object.freeze({
DEFAULT_EDGE_STRENGTH: 0.7,
DEFAULT_NODE_MASS: 2000,
DEFAULT_NODE_RADIUS: 50,
DEFAULT_VISIBLE_EDGE_DISTANCE: 350
});
/**
* Takes simple format nodes and edges and converts them into GraphNodes and Edges
* @param {import("../model/ibasicnode").IBasicNode[]} nodes
* @param {import("../model/ibasicedge").IBasicEdge[]} edges
*/
const initializeNodesAndEdges = (nodes = [], edges = []) => {
//Initialize Nodes
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
//Add index property
node.index = i;
//If no radius is set then attempt to set it
node.radius = node.radius ? node.radius : node.width ? Math.max(node.width, node.height) / 2 : Env.DEFAULT_NODE_RADIUS;
//If no mass is set then attempt to set it
node.mass = node.mass ? node.mass : Env.DEFAULT_NODE_MASS;
//If fixed coordinates exist, set regular to the same values
if (node.fx) {
node.x = node.fx;
}
if (node.fy) {
node.y = node.fy;
}
//If no x or y coordinates exist then set a position based on a circle of nodes
if (isNaN(node.x) || isNaN(node.y)) {
const radius = node.radius * Math.sqrt(0.5 + i);
const angle = i * (Math.PI * (3 - Math.sqrt(5)));
node.x = radius * Math.cos(angle);
node.y = radius * Math.sin(angle);
}
//If no velocity is set, initialize it to 0
if (isNaN(node.vx) || isNaN(node.vy)) {
node.vx = 0;
node.vy = 0;
}
}
//Initialize Edges
for (let i = 0; i < edges.length; i++) {
const edge = edges[i];
//Add index property
edge.index = i;
//If no strength has been configured then set it to a default value
if (!edge.strength) {
edge.strength = Env.DEFAULT_EDGE_STRENGTH;
}
//Map the nodes to source & target (and evaluate that they exist!)
//Note that source and target are necessary for D3 adapters to work
edge.source = nodes.find(node => node.id === edge.sourceNode);
edge.target = nodes.find(node => node.id === edge.targetNode);
if (!edge.source || !edge.target) {
throw new Error("Broken Edge " + `${edge}`)
}
//Initialize the edge's length
if (!edge.distance) {
const invisibleDistance = edge.target.radius + edge.source.radius;
edge.distance = invisibleDistance + (edge.visibleDistance ? edge.visibleDistance : Env.DEFAULT_VISIBLE_EDGE_DISTANCE);
}
if (!edge.visibleDistance) {
edge.visibleDistance = edge.distance - edge.target.radius - edge.source.radius;
}
//Initialize the edge's weight
if (isNaN(edge.weight)) {
edge.weight = 1;
}
}
};
/**
* Main layout loop class.
*/
class Loop {
/**
* @param {() => any} fn - Function to be looped
* @param {number=} updateCap - How many FPS to cap the update frequency to.
*/
constructor(fn, updateCap = 60) {
this.fn = fn;
this.timeout = null;
this.running = false;
this.previousTimestamp = null;
this.unprocessedTime = null;
this.UPDATE_CAP = 1000 / updateCap;
}
setUpdateCap(newCap) {
this.UPDATE_CAP = 1000 / newCap;
}
/**
* Start the loop
*/
start() {
if (this.running) {
return
}
this.running = true;
this.previousTimestamp = null;
this.unprocessedTime = null;
this.run();
}
/**
* Stop the loop
*/
stop() {
this.running = false;
this.previousTimestamp = null;
this.unprocessedTime = null;
}
/**
* Execute one loop
*/
run() {
if (this.running) {
if (!this.previousTimestamp) this.previousTimestamp = Date.now();
if (!this.unprocessedTime) this.unprocessedTime = 0;
//If we are lagging behind, stop the unprocessed time from over-accumulating
if (this.unprocessedTime > this.UPDATE_CAP * 10) this.unprocessedTime = this.UPDATE_CAP;
const currentTimestamp = Date.now();
const passedTime = currentTimestamp - this.previousTimestamp;
this.previousTimestamp = currentTimestamp;
this.unprocessedTime += passedTime;
//To make the cap more accurate, change "if" to "while".
//Note that this may lock the thread.
//Because of the nature of settimeout, even though we specify 0 the delay will actually be longer.
//For a cap of 60 updates per second it should not be a problem in most runtimes though.
if (this.unprocessedTime >= this.UPDATE_CAP) {
this.unprocessedTime -= this.UPDATE_CAP;
this.fn();
}
this.timeout = setTimeout(() => {
this.run();
}, 0);
}
}
}
/**
* This is a point region quadtree (i.e. all nodes must have their own quadrant, the only exception being identically positioned nodes).
* This can be used for collision detection as well as n-body approximations such as Barnes and Hut
* To read more about quad trees:
* https://en.wikipedia.org/wiki/
*/
class Quadtree {
/**
* @param {import("../model/igraphnode").IGraphNode[]=} entities - Graph nodes to base the quadtree on
*/
constructor(entities = []) {
this.isMassComputed = false;
this.isLargestRadiusComputed = false;
this.entities = [];
this.quadrants = new Array(4);
this.bounds = { xStart: 0, yStart: 0, xEnd: 1, yEnd: 1 };
this.initialize(entities);
}
/**
* (Re)Computes the quadtree with new graph nodes
* @param {import("../model/igraphnode").IGraphNode[]} entities
*/
initialize(entities = []) {
this.isMassComputed = false;
this.isLargestRadiusComputed = false;
this.entities = entities;
this.bounds = this.getBounds();
this.quadrants = new Array(4);
for (let i = 0; i < entities.length; i++) {
this.addEntity(entities[i]);
}
}
/**
* Recomputes the quadtree with the currently assigned nodes
*/
update() {
this.initialize(this.entities);
}
/**
* Computes the bounds of the quad tree based on the contained entities
* @returns {import("../model/ibounds").IBounds}
*/
getBounds() {
let xStart = 0;
let yStart = 0;
let xEnd = 1;
let yEnd = 1;
for (let i = 0; i < this.entities.length; i++) {
const entity = this.entities[i];
entity.x - 1 < xStart && (xStart = Math.floor(entity.x - 1));
entity.x + 1 > xEnd && (xEnd = Math.ceil(entity.x + 1));
entity.y - 1 < yStart && (yStart = Math.floor(entity.y - 1));
entity.y + 1 > yEnd && (yEnd = Math.ceil(entity.y + 1));
}
//Ensure that the quad tree is square
const width = xEnd - xStart;
const height = yEnd - yStart;
if (width > height) {
const delta = width - height;
yStart -= delta / 2;
yEnd += delta / 2;
} else if (height > width) {
const delta = height - width;
xStart -= delta / 2;
xEnd += delta / 2;
}
return {
xStart,
yStart,
xEnd,
yEnd
}
}
/**
* The quadtree is recomputed by calling this function sequentially for each graph entity
* @param {import("../model/igraphnode").IGraphNode} entity
* @returns
*/
addEntity(entity) {
let parent;
let quadNode = this.quadrants;
const leaf = { entity, next: null };
let xStart = this.bounds.xStart;
let yStart = this.bounds.yStart;
let xEnd = this.bounds.xEnd;
let yEnd = this.bounds.yEnd;
let horizontalCenter;
let verticalCenter;
let right;
let bottom;
let i;
let j;
// Find a suitable leaf position or create one.
// If length is undefined then we have found an entity node
while (quadNode.length) {
//Determine what quadrant of the current parent quadrant we belong in
horizontalCenter = (xStart + xEnd) / 2;
verticalCenter = (yStart + yEnd) / 2;
right = entity.x >= horizontalCenter;
bottom = entity.y >= verticalCenter;
right ? (xStart = horizontalCenter) : (xEnd = horizontalCenter);
bottom ? (yStart = verticalCenter) : (yEnd = verticalCenter);
//The parent is now the old quadrant we just traversed
parent = quadNode;
//The quad node to traverse next is the one we fit into
i = (bottom << 1) | right;
quadNode = quadNode[i];
//If the new quad node is undefined (a set of quadrants have been created, but this specific section is empty)
//Then add the entity here and stop
if (!quadNode) {
parent[i] = leaf;
return
}
}
// If the leaf on the quadrant is exactly the same as this one then create a linked list and return
const xPrevious = quadNode.entity.x;
const yPrevious = quadNode.entity.y;
if (entity.x === xPrevious && entity.y === yPrevious) {
leaf.next = quadNode;
parent[i] = leaf;
return
}
// Otherwise we split the quadrant until the two quad nodes are separated
let rightPrevious;
let bottomPrevious;
let continueLoop = true;
while (continueLoop) {
parent[i] = new Array(4);
parent = parent[i];
//Where is the center?
horizontalCenter = (xStart + xEnd) / 2;
verticalCenter = (yStart + yEnd) / 2;
//In what quadrant does this entity fit?
right = entity.x >= horizontalCenter;
bottom = entity.y >= verticalCenter;
//In what quadrant does the existing leaf fit?
rightPrevious = xPrevious >= horizontalCenter;
bottomPrevious = yPrevious >= verticalCenter;
//This will result in an index between 0-3 corresponding to a newly assigned quadrant
i = (bottom << 1) | right;
j = (bottomPrevious << 1) | rightPrevious;
//If the entity and the existing leaf are still in the same quadrant we need to keep splitting
continueLoop = i === j;
//Preapare for the next iteration by adjusting quadrant measurements
if (continueLoop) {
right ? (xStart = horizontalCenter) : (xEnd = horizontalCenter);
bottom ? (yStart = verticalCenter) : (yEnd = verticalCenter);
}
}
//Assign the nodes to the quadrants we computed in the while loop
parent[j] = quadNode;
parent[i] = leaf;
}
/**
* Traverses the tree from top to bottom. Will execute a callback for each quadrant and leaf.
* If the callback returns a truthy value then the quadrant in question will not be drilled further down into
* @param {(quadNode?: import("../model/quadmember").QuadMember, xStart: number, yStart: number, xEnd: number, yEnd: number) => boolean} callback
* @returns
*/
traverseTopBottom(callback) {
const quadrants = [];
let quadNode;
let child;
let xStart;
let yStart;
let xEnd;
let yEnd;
let horizontalCenter;
let verticalCenter;
let quadrant = { quadNode: this.quadrants, xStart: this.bounds.xStart, xEnd: this.bounds.xEnd, yStart: this.bounds.yStart, yEnd: this.bounds.yEnd };
while (quadrant) {
quadNode = quadrant.quadNode;
xStart = quadrant.xStart;
yStart = quadrant.yStart;
xEnd = quadrant.xEnd;
yEnd = quadrant.yEnd;
if (!callback(quadNode, xStart, yStart, xEnd, yEnd) && quadNode.length) {
horizontalCenter = (xStart + xEnd) / 2;
verticalCenter = (yStart + yEnd) / 2;
if ((child = quadNode[0])) quadrants.push({ quadNode: child, xStart, yStart, xEnd: horizontalCenter, yEnd: verticalCenter });
if ((child = quadNode[1])) quadrants.push({ quadNode: child, xStart: horizontalCenter, yStart, xEnd, yEnd: verticalCenter });
if ((child = quadNode[2])) quadrants.push({ quadNode: child, xStart, yStart: verticalCenter, xEnd: horizontalCenter, yEnd });
if ((child = quadNode[3])) quadrants.push({ quadNode: child, xStart: horizontalCenter, yStart: verticalCenter, xEnd, yEnd });
}
quadrant = quadrants.pop();
}
}
/**
* Executes a callback for each quadrant in the graph from bottom to top.
* @param {(quadNode?: import("../model/quadmember").QuadMember, xStart: number, yStart: number, xEnd: number, yEnd: number) => boolean} callback
* @returns
*/
traverseBottomTop(callback) {
const quadrants = [];
const result = [];
let child;
let xStart;
let yStart;
let xEnd;
let yEnd;
let horizontalCenter;
let verticalCenter;
let quadNode;
let quadrant = { quadNode: this.quadrants, xStart: this.bounds.xStart, xEnd: this.bounds.xEnd, yStart: this.bounds.yStart, yEnd: this.bounds.yEnd };
while (quadrant) {
quadNode = quadrant.quadNode;
if (quadNode.length) {
xStart = quadrant.xStart;
yStart = quadrant.yStart;
xEnd = quadrant.xEnd;
yEnd = quadrant.yEnd;
horizontalCenter = (xStart + xEnd) / 2;
verticalCenter = (yStart + yEnd) / 2;
if ((child = quadNode[0])) quadrants.push({ quadNode: child, xStart, yStart, xEnd: horizontalCenter, yEnd: verticalCenter });
if ((child = quadNode[1])) quadrants.push({ quadNode: child, xStart: horizontalCenter, yStart, xEnd, yEnd: verticalCenter });
if ((child = quadNode[2])) quadrants.push({ quadNode: child, xStart, yStart: verticalCenter, xEnd: horizontalCenter, yEnd });
if ((child = quadNode[3])) quadrants.push({ quadNode: child, xStart: horizontalCenter, yStart: verticalCenter, xEnd, yEnd });
}
result.push(quadrant);
quadrant = quadrants.pop();
}
while ((quadrant = result.pop())) {
callback(quadrant.quadNode, quadrant.xStart, quadrant.yStart, quadrant.xEnd, quadrant.yEnd);
}
}
/**
* Computes the mass of each quadNode and aggregates entity coordinates into an average center.
* Used for example when computing Barnes and Huts n-body approximation
*/
computeMass() {
if (this.isMassComputed) return
this.traverseBottomTop(quadNode => {
//quadNode is a quadrant
if (quadNode.length) {
let totalX = 0;
let totalY = 0;
let totalMass = 0;
for (let i = 0; i < 4; i++) {
const child = quadNode[i];
if (child) {
totalMass += child.mass;
totalX += child.mass * child.x;
totalY += child.mass * child.y;
}
}
quadNode.x = totalX / totalMass;
quadNode.y = totalY / totalMass;
quadNode.mass = totalMass;
}
//quadNode is a leaf node
else {
let totalMass = 0;
let nextQuadNode = quadNode;
do {
totalMass += nextQuadNode.entity.mass;
} while ((nextQuadNode = nextQuadNode.next))
quadNode.x = quadNode.entity.x;
quadNode.y = quadNode.entity.y;
quadNode.mass = totalMass;
}
});
this.isMassComputed = true;
}
/**
* Records the largest radius on each quad node.
* This is useful for example in collision detection.
* We need this information because a point can stretch across multiple quadrants.
* This is a downside of an adaptive tree.
* @param {number} padding - Adds a padding to all radiuses
*/
computeLargestRadius(padding = 0) {
if (this.isLargestRadiusComputed) return
this.traverseBottomTop(quadNode => {
//If it is an entity
if (quadNode.entity) {
quadNode.radius = quadNode.entity.radius + padding;
return
}
//If it is a quadrant
quadNode.radius = 0;
for (let i = 0; i < 4; i++) {
if (quadNode[i] && quadNode[i].radius > quadNode.radius) {
quadNode.radius = quadNode[i].radius;
}
}
});
this.isLargestRadiusComputed = true;
}
}
/**
* Main layout class
*/
class Layout {
/**
* @param {import("./model/ibasicnode").IBasicNode[]=} nodes - Initial nodes
* @param {import("./model/ibasicedge").IBasicEdge[]=} edges - Initial edges
* @param {import("./model/ioptions").ILayoutOptions} options - options
*/
constructor(nodes = [], edges = [], options = {}) {
this.nodes = nodes;
this.edges = edges;
this.alpha = options.alpha || 1;
this.alphaMin = options.alphaMin || 0.001;
this.alphaDecay = options.alphaDecay || 1 - Math.pow(this.alphaMin, 1 / 300);
this.alphaTarget = options.alphaTarget || 0;
this.velocityDecay = options.velocityDecay || 0.6;
/** @type {Map<string, import("./model/ilayoutcomponentobject").ILayoutComponentObject>} */
this.components = new Map();
this.listeners = new Map([
["layoutloopstart", new Set()],
["layoutupdate", new Set()],
["layoutloopend", new Set()]
]);
this.loop = new Loop(this.runLoop.bind(this), options.updateCap ? options.updateCap : 60);
this.initializeNodesAndEdges();
this.quadtree = new Quadtree(this.nodes);
this.isAnimating = false;
}
/**
* Registers an event listener
* @param {string} name - Event name to listen for
* @param {() => any} fn - Callback on event
*/
on(name, fn) {
if (!this.listeners.has(name)) {
console.error(`No such event name: ${name}`);
}
this.listeners.get(name).add(fn);
}
triggerEvent(name) {
this.listeners.get(name).forEach(fn => fn());
}
/**
* This is the main loop function.
* Each time the loop instance triggers an update this will execute.
*/
runLoop() {
this.update();
if (this.alpha < this.alphaMin) {
this.loop.stop();
this.triggerEvent("layoutloopend");
}
}
updateNodesAndEdges(nodes, edges) {
this.nodes = nodes;
this.edges = edges;
this.initializeNodesAndEdges();
this.components.forEach(component => this.initializeComponent(component));
}
initializeNodesAndEdges() {
initializeNodesAndEdges(this.nodes, this.edges);
this.quadtree = new Quadtree(this.nodes);
}
initializeComponent(component) {
let nodes = this.nodes;
let edges = this.edges;
if (component.nodeBindings) {
nodes = this.nodes.filter(node => component.nodeBindings(node));
}
if (component.edgeBindings) {
edges = this.edges.filter(edge => component.edgeBindings(edge));
}
component.instance.initialize(nodes, edges, { quadtree: this.quadtree, remove: () => this.removeComponent(component.id) });
return component
}
/**
* Starts the layout loop
*/
start() {
this.triggerEvent("layoutloopstart");
this.loop.start();
}
/**
* Stops the layout loop
*/
stop() {
this.loop.stop();
this.triggerEvent("layoutloopend");
}
/**
* Sets the update cap (per second) for the layout loop
* @param {number} newCap - new cap
*/
setUpdateCap(newCap) {
this.loop.setUpdateCap(newCap);
}
/**
* Adds a component to the layout
* @param {string} id
* @param {import("./model/ilayoutcomponent").ILayoutComponent} component - A layout component compatible class instance
* @param {(any) => boolean=} nodeBindings - Function that computes if a node should be affected by the component. Blank means true for all.
* @param {(any) => boolean=} edgeBindings - Function that computes if an edge should be affected by the component. Blank means true for all.
* @returns {Layout} - this
*/
addLayoutComponent(id, component, nodeBindings = null, edgeBindings = null) {
if (this.components.has(id)) {
throw new Error("Component already exists: " + id)
}
const componentObject = {
id,
instance: component,
nodeBindings,
edgeBindings
};
this.initializeComponent(componentObject);
this.components.set(id, componentObject);
return this
}
/**
* Removes a compnent with the specified ID
* @param {string} id
*/
removeComponent(id) {
if (this.components.has(id)) {
this.components.get(id).instance.dismount();
this.components.delete(id);
}
}
/**
* Finds the node closest to the provided coordinates
* @param {number} x
* @param {number} y
* @returns {any} - The node
*/
findClosestNodeByCoordinates(x, y) {
let closest;
let radius = Infinity;
for (let i = 0; i < this.nodes.length; ++i) {
const node = this.nodes[i];
const distanceX = x - node.x;
const distanceY = y - node.y;
const distanceSquared = distanceX * distanceX + distanceY * distanceY;
if (distanceSquared < radius) {
closest = node;
radius = distanceSquared;
}
}
return closest
}
/**
* Animates nodes from source positions to target positions within a duration provided.
* This function can be used to transition the graph between states or layouts.
* Once triggered the animation cannot be stopped. All other updates and components will be frozen until the animation completes.
* There should *never* be more than one animation running simultaneously.
* @param {import("./model/itargetnodestate").ITargetNodeState[]} targetNodeStates
* @param {number} duration - Animation duration in milliseconds
* @param {boolean} shouldFixateOnEnd - If true then the graph will fixate the nodes when the animation ends
*/
animateState(targetNodeStates = [], duration = 300, shouldFixateOnEnd = false) {
if (!targetNodeStates.length) return
this.nodeMap = this.nodes.reduce((acc, node) => {
acc[node.id] = node;
return acc
}, {});
targetNodeStates.forEach(state => {
state.sourceX = isNaN(state.sourceX) ? this.nodeMap[state.id].x : state.sourceX;
state.sourceY = isNaN(state.sourceY) ? this.nodeMap[state.id].y : state.sourceY;
});
const startTime = Date.now();
const loop = new Loop(() => {
const deltaTime = Date.now() - startTime;
const percentOfAnimation = Math.min(deltaTime / duration, 100);
targetNodeStates.forEach(nodeState => {
const node = this.nodeMap[nodeState.id];
node.x = nodeState.sourceX + (nodeState.targetX - nodeState.sourceX) * percentOfAnimation;
node.y = nodeState.sourceY + (nodeState.targetY - nodeState.sourceY) * percentOfAnimation;
});
if (deltaTime > duration) {
loop.stop();
this.isAnimating = false;
if (shouldFixateOnEnd) {
targetNodeStates.forEach(nodeState => {
const node = this.nodeMap[nodeState.id];
node.fx = node.x;
node.fy = node.y;
});
}
}
this.triggerEvent("layoutupdate");
}, Infinity);
this.isAnimating = true;
loop.start();
}
/**
* Main update function.
* This executes all components in the layout and computes node positions.
* Note that the update function can be executed without the looper.
* @param {boolean} sendEvent - Should an update event be fired?
*/
update(sendEvent = true) {
if (this.isAnimating) {
return
}
this.alpha += (this.alphaTarget - this.alpha) * this.alphaDecay;
for (const [, component] of this.components.entries()) {
component.instance.execute(this.alpha);
}
for (let i = 0; i < this.nodes.length; i++) {
const node = this.nodes[i];
if (node.fx == null) {
node.vx *= this.velocityDecay;
node.x += node.vx;
} else {
node.x = node.fx;
node.vx = 0;
}
if (node.fy == null) {
node.vy *= this.velocityDecay;
node.y += node.vy;
} else {
node.y = node.fy;
node.vy = 0;
}
}
this.iteration = this.iteration ? this.iteration + 1 : 1;
this.quadtree.update();
sendEvent && this.triggerEvent("layoutupdate");
}
}
/**
* The data store class is responsible to storing and managing all edges and nodes.
* The data store can execute computations such as bringing nodes offline from the graph
* or computing components and paths.
*/
class DataManager {
/**
* Constructor
* @param {import("./model/ibasicnode").IBasicNode[]} nodes
* @param {import("./model/ibasicnode").IBasicEdge[]} edges
*/
constructor(nodes = [], edges = []) {
/**
* All Nodes in the data manager regardless of online/offline status
* @type {import("./model/nodeid").NodeID[]} */
this.allNodes = [];
/**
* All edges in the data manager regardless of online/offline status
* @type {import("./model/ibasicedge").IBasicEdge[]} */
this.allEdges = [];
/**
* A set that contains all currently online nodes. Used for example when we want to process a large dataset
* but only want to expose a small subset of data to an application, renderer or other process.
* @type {Set<import("./model/nodeid").NodeID>} */
this.onlineNodes = new Set();
/**
* A lookup table for node objects. Generally "nodes" in the data manager are just IDs
* @type {Map<import("./model/nodeid").NodeID, import("./model/ibasicnode").IBasicNode>} */
this.nodeLookupMap = new Map();
/**
* A lookup table where the keys are sourceNodes and targets are targetNodes, their full weight and all relevant edge objects.
* @type {Map<import("./model/nodeid").NodeID, { id: import("./model/nodeid").NodeID, edges: import("./model/ibasicedge").IBasicEdge[], weight: number }[]>} */
this.sourceToTargetMap = new Map();
/**
* A lookup table where the keys are targetNodes and targets are sourceNodes, their full weight and all relevant edge objects.
* @type {Map<import("./model/nodeid").NodeID, { id: import("./model/nodeid").NodeID, edges: import("./model/ibasicedge").IBasicEdge[], weight: number }[]>} */
this.targetToSourceMap = new Map();
/**
* A lookup table for undirected edges (basically merging sourceToTarget and targetToSource.
* @type {Map<import("./model/nodeid").NodeID, { id: import("./model/nodeid").NodeID, edges: import("./model/ibasicedge").IBasicEdge[], weight: number }[]>} */
this.nodeToNeighborsMap = new Map();
/**
* Edge indexes mapping an edge to how many other edges share the same sources and targets and what index it has.
* This information is useful for renderers to determine angles and bends of edges to minimize overlap.
* E.g. if there are two edges connecting node X and node Y, then these would overlap visually.
* @type {Map<string, {total: number, index: number}>}
*/
this.edgeIndexes = new Map();
/**
* This is a counter for each node of how many offline edges in the graph connects with it.
* This information is useful to renderers when displaying partial information, and showing meta data about hidden points.
* E.g. a badge on a node in the graph with "42" on it, indicating 42 hidden connections.
* @type {Map<import("./model/nodeid").NodeID, {sourceNode: number, targetNode: number, internal: number}>}
*/
this.offlineEdgeCounter = new Map();
this.updateNodesAndEdges(nodes, edges);
}
/**
* Updates the data in the manager
* @param {import("./model/ibasicnode").IBasicNode[]} nodes - New nodes
* @param {import("./model/ibasicedge").IBasicEdge[]} edges - New Edges
*/
updateNodesAndEdges(nodes, edges) {
//All added nodes will be seen as online
nodes.forEach(node => {
!this.nodeLookupMap.has(node.id) && this.onlineNodes.add(node.id);
});
this.nodeLookupMap = new Map(nodes.map(node => [node.id, node]));
//All removed nodes must be removed from the onlineNodes set
this.allNodes.forEach(nodeID => {
!this.nodeLookupMap.has(nodeID) && this.onlineNodes.delete(nodeID);
});
this.allNodes = nodes.map(node => node.id);
this.allEdges = [...edges];
//In the below step we will aggregate edges between nodes and compute data about the relationships
const sourceToTargetLookup = new Map(this.allNodes.map(node => [node, new Map()]));
const targetToSourceLookup = new Map(this.allNodes.map(node => [node, new Map()]));
const nodeToNeighborsLookup = new Map(this.allNodes.map(node => [node, new Map()]));
const aggregateData = (dataMap, node, neighborNode, edge) => {
if (!dataMap.get(node).has(neighborNode)) {
dataMap.get(node).set(neighborNode, { id: neighborNode, edges: [], weight: 0 });
}
const aggregateObject = dataMap.get(node).get(neighborNode);
aggregateObject.edges.push(edge);
aggregateObject.weight += edge.weight ? edge.weight : 1;
};
this.allEdges.forEach(edge => {
aggregateData(sourceToTargetLookup, edge.sourceNode, edge.targetNode, edge);
aggregateData(targetToSourceLookup, edge.targetNode, edge.sourceNode, edge);
aggregateData(nodeToNeighborsLookup, edge.sourceNode, edge.targetNode, edge);
if (edge.sourceNode !== edge.targetNode) aggregateData(nodeToNeighborsLookup, edge.targetNode, edge.sourceNode, edge);
});
//In the next step we flatten the structure from the aggregate maps we've constructed
this.sourceToTargetMap = new Map(this.allNodes.map(node => [node, []]));
this.targetToSourceMap = new Map(this.allNodes.map(node => [node, []]));
this.nodeToNeighborsMap = new Map(this.allNodes.map(node => [node, []]));
const flatten = (flatMap, structuredMap) => {
for (const [nodeID, neighborMap] of structuredMap) {
flatMap.set(nodeID, Array.from(neighborMap.values()));
}
};
flatten(this.sourceToTargetMap, sourceToTargetLookup);
flatten(this.targetToSourceMap, targetToSourceLookup);
flatten(this.nodeToNeighborsMap, nodeToNeighborsLookup);
this.updateMetaData();
}
/**
* Updates all edge meta data structures.
*/
updateMetaData() {
this.edgeIndexes = new Map();
const edgeMap = new Map();
let edge;
const onlineEdges = this.getOnlineEdges();
for (let i = 0; i < onlineEdges.length; i++) {
edge = onlineEdges[i];
const ID = edge.sourceNode > edge.targetNode ? `${edge.sourceNode}${edge.targetNode}` : `${edge.targetNode}${edge.sourceNode}`;
if (!edgeMap.has(ID)) {
edgeMap.set(ID, [edge]);
continue
}
edgeMap.get(ID).push(edge);
}
for (const [, edgeArray] of edgeMap) {
for (let i = 0; i < edgeArray.length; i++) {
edge = edgeArray[i];
this.edgeIndexes.set(edge, { total: edgeArray.length, index: i });
}
}
this.offlineEdgeCounter = new Map(this.allNodes.map(node => [node, { sourceNode: 0, targetNode: 0, internal: 0 }]));
const offlineEdges = this.getOfflineEdges();
for (let i = 0; i < offlineEdges.length; i++) {
edge = offlineEdges[i];
if (edge.sourceNode === edge.targetNode) {
this.offlineEdgeCounter.get(edge.sourceNode).internal++;
} else {
this.offlineEdgeCounter.get(edge.sourceNode).sourceNode++;
this.offlineEdgeCounter.get(edge.targetNode).targetNode++;
}
}
}
/**
* Computes online nodes
* @returns {import("./model/ibasicnode").IBasicNode[]} node
*/
getOnlineNodes() {
return this.allNodes.filter(nodeID => this.onlineNodes.has(nodeID)).map(nodeID => this.nodeLookupMap.get(nodeID))
}
/**
* Computes offline nodes
* @returns {import("./model/ibasicnode").IBasicNode[]} node
*/
getOfflineNodes() {
return this.allNodes.filter(nodeID => !this.onlineNodes.has(nodeID)).map(nodeID => this.nodeLookupMap.get(nodeID))
}
/**
* Computes online edges
* @returns {import("./model/ibasicedge").IBasicEdge[]} edge
*/
getOnlineEdges() {
return this.allEdges.filter(edge => this.isEdgeOnline(edge))
}
/**
* Computes offline edges
* @returns {import("./model/ibasicedge").IBasicEdge[]} edge
*/
getOfflineEdges() {
return this.allEdges.filter(edge => !this.isEdgeOnline(edge))
}
/**
* Checks if an edge is online
* @param {import("./model/ibasicnode").IBasicEdge} edge
* @returns {boolean}
*/
isEdgeOnline(edge) {
return this.onlineNodes.has(edge.sourceNode) && this.onlineNodes.has(edge.targetNode)
}
/**
* Checks if a node is online
* @param {string} nodeID
* @returns {boolean}
*/
isNodeOnline(nodeID) {
return this.onlineNodes.has(nodeID)
}
/**
* Brings the list of node IDs offline
* @param {import("./model/nodeid").NodeID[]} nodeIDs
*/
bringNodesOffline(nodeIDs) {
nodeIDs.forEach(id => {
this.onlineNodes.delete(id);
});
this.updateMetaData();
}
/**
* Brings the list of node IDs online
* @param {import("./model/nodeid").NodeID[]} nodeIDs
*/
bringNodesOnline(nodeIDs) {
nodeIDs.forEach(id => {
if (!this.nodeLookupMap.has(id)) {
throw new Error(`No such node exists: ${id}`)
}
this.onlineNodes.add(id);
});
this.updateMetaData();
}
bringAllNodesOffline() {
this.onlineNodes = new Set();
this.updateMetaData();
}
bringAllNodesOnline() {
this.onlineNodes = new Set(this.allNodes);
this.updateMetaData();
}
/**
* Retrieves all neighbors for a given nodeID
* @param {import("./model/nodeid").NodeID} nodeID - ID od the node neighbors should be retrieved for
* @param {boolean} isDirected - Only traverse edges where the input node is the sourceNode
* @param {boolean} useOnlyOnline - Only traverse neighbors that are online
* @param {boolean} ignoreInternalEdges - Ignore self-edges
* @returns {import("./model/nodeid").NodeID[]}
*/
getNeighbors(nodeID, isDirected = false, useOnlyOnline = true, ignoreInternalEdges = true) {
let neighbors;
if (isDirected) neighbors = this.sourceToTargetMap.get(nodeID).map(neighbor => neighbor.id);
else neighbors = this.nodeToNeighborsMap.get(nodeID).map(neighbor => neighbor.id);
if (useOnlyOnline) neighbors = neighbors.filter(neighbor => this.onlineNodes.has(neighbor));
if (ignoreInternalEdges) neighbors = neighbors.filter(neighbor => neighbor !== nodeID);
return Array.from(new Set(neighbors))
}
/**
* Computes collateral nodes in implodes or explode operations from a root node (I.e. bringing connected neighbors online/offline)
* This function will not apply any changes, but return an array with affected nodes
* The function exists specifically to help applications that implement implode/explode functionality in graphs
* and need to compute what nodes should be brough online/offline.
* @param {import("./model/nodeid").NodeID} nodeID
* @param {boolean} isBringOnline - If true neighbors will be brought online otherwise offline
* @param {boolean} isDirected - If true then operation will be directed
* @param {"single"|"recursive"|"leafs"} mode - Single means all neighbors are affected, leafs means only neighbors with no other neighbors are affected, recursive means neighbors recursively are affected.
* @returns {import("./model/nodeid").NodeID[]} - Affected nodes
*/
computeImplodeOrExplodeNode(nodeID, isBringOnline = false, isDirected = true, mode = "single") {
if (!this.nodeLookupMap.has(nodeID)) {
throw new Error(`No such node exists: ${nodeID}`)
}
if (!this.onlineNodes.has(nodeID)) {
throw new Error(`Input node is offline: ${nodeID}`)
}
const neighborComputationCache = new Map();
const getValidNeighborsForNode = nodeID => {
if (neighborComputationCache.has(nodeID)) {
return neighborComputationCache.get(nodeID)
}
const neighbors = this.getNeighbors(nodeID, isDirected, isBringOnline ? false : true, true);
neighborComputationCache.set(nodeID, neighbors);
return neighbors
};
const affectedNodes = [];
const processedNodes = new Set();
const nodeLevels = new Map([[nodeID, 0]]);
let nodeQueue = [nodeID];
let currentNode;
while ((currentNode = nodeQueue.pop())) {
if (processedNodes.has(currentNode)) continue
processedNodes.add(currentNode);
if (mode === "single" && nodeLevels.get(currentNode) > 1) continue
if (currentNode !== nodeID) affectedNodes.push(currentNode);
const neighbors = getValidNeighborsForNode(currentNode);
for (let i = 0; i < neighbors.length; i++) {
const neighbor = neighbors[i];
if (!nodeLevels.has(neighbor)) nodeLevels.set(neighbor, nodeLevels.get(currentNode) + 1);
}
if (mode === "leafs") {
for (let i = 0; i < neighbors.length; i++) {
const neighbor = neighbors[i];
const neighborsNeighbors = getValidNeighborsForNode(neighbor).filter(neighborsNeighbor => neighborsNeighbor !== currentNode);
if (neighborsNeighbors.length === 0) nodeQueue.push(neighbor);
}
} else {
nodeQueue = nodeQueue.concat(neighbors);
}
}
return affectedNodes.filter(node => (isBringOnline && !this.onlineNodes.has(node)) || (!isBringOnline && this.onlineNodes.has(node)))
}
/**
* Specifically meant to support renderers in determining optimal target positions for nodes that are being brough online.
* Accepts an array of node IDs and origin coordinates where the nodes should be animated from.
* Returns an array of vertices with optimal positions based on other neighbors present in the graph, or in the case of leafs a circle around the origin.
* Note(!) that this function expects all nodes and edges to have been initialized into GraphNodes and GraphEdges in order to compute this information.
* @param {import("./model/nodeid").NodeID[]} nodeIDs - Array of node IDs
* @param {number} distance - Default distance from origin position to put nodes (for non-average values only!)
* @param {number} originX - Start position for the transition
* @param {number} originY - Start position for the transition
* @returns {{id: import("./model/nodeid").NodeID, x: number, y: numer}[]} - Target coordinates
*/
stageNodePositions(nodeIDs = [], distance = 300, originX = 0, originY = 0) {
if (!nodeIDs.length) return []
const seenOriginNodes = [];
const numberOfLeafNodes = nodeIDs.filter(nodeID => this.getNeighbors(nodeID, false, true, true).length < 2).length;
return nodeIDs.map(nodeID => {
const neighbors = this.getNeighbors(nodeID, false, true, true);
if (neighbors.length < 2) {
seenOriginNodes.push(nodeID);
const multiplier = seenOriginNodes.length;
const divider = numberOfLeafNodes;
const angle = Math.floor((359 / divider) * multiplier);
//+1 is to avoid divisional errors
return {
id: nodeID,
x: originX + 1 + distance * Math.cos((angle * Math.PI) / 180),
y: originY + 1 + distance * Math.sin((angle * Math.PI) / 180)
}
} else {
//We are disregarding the weights of the neighbors. Should maybe be taken into account?
let x = 0;
let y = 0;
for (let i = 0; i < neighbors.length; i++) {
const neighbor = this.nodeLookupMap.get(neighbors[i]);
x += neighbor.x;
y += neighbor.y;
}
x /= neighbors.length;
y /= neighbors.length;
return { id: nodeID, x, y }
}
})
}
/**
* Computes the shortest path from one node to another. Returns an array with the nodeIDs, or null if there is no path.
* @param {import("./model/nodeid").NodeID} startNode - Node ID where the road starts
* @param {import("./model/nodeid").NodeID} endNode - Node ID where the road ends
* @param {boolean} useOnlyOnline - If true the shortest path will only be computed for live nodes
* @param {boolean} isDirected - If true then operation will be directed
* @return {import("./model/nodeid").NodeID[]} - Array of node IDs from startnode to endnode containing the (a) shortest path
*/
findShortestPathUnweighted(startNode, endNode, useOnlyOnline = true, isDirected = true) {
if (useOnlyOnline && (!this.onlineNodes.has(startNode) || !this.onlineNodes.has(endNode))) {
throw new Error("Start node or end node is not live.")
}
if (startNode === endNode) {
return [startNode]
}
const toProcess = [startNode];
const cameFrom = new Map();
let nextNode;
while ((nextNode = toProcess.pop())) {
if (nextNode === endNode) break
const candidates = this.getNeighbors(nextNode, isDirected, useOnlyOnline);
let candidate;
for (let i = 0; i < candidates.length; i++) {
candidate = candidates[i];
if (useOnlyOnline && !this.onlineNodes.has(candidate)) continue
if (cameFrom.has(candidate)) continue
cameFrom.set(candidate, nextNode);
toProcess.push(candidate);
}
}
if (nextNode !== endNode) {
return null
}
let step = nextNode;
const path = [step];
while ((step = cameFrom.get(step))) {
path.push(step);
}
return path.reverse()
}
/**
* Computes the shortest path from one node to another. Returns an array with the nodeIDs, or null if there is no path.
* This is basically Dijkstra's algorithm:
* https://en.wikipedia.org/wiki/Dijkstra's_algorithm
* @param {import("./model/nodeid").NodeID} startNode - Node ID where the road starts
* @param {import("./model/nodeid").NodeID} endNode - Node ID where the road ends
* @param {boolean} useOnlyOnline - If true the shortest path will only be computed for live nodes
* @param {boolean} isDirected - If true then operation will be directed
* @param {boolean} aggregateEdgeWeights - If true then weights for all edges between a set of nodes are aggregated and treated as a single edge
* @return {{id: import("./model/nodeid").NodeID, cost: number}[]} - Array of nodes and costs from startnode to endnode containing the (a) cheapest path
*/
findShortestPathWeighted(startNode, endNode, useOnlyOnline = true, isDirected = true, aggregateEdgeWeights = false) {
if (useOnlyOnline && (!this.onlineNodes.has(startNode) || !this.onlineNodes.has(endNode))) {
throw new Error("Start node or end node is not live.")
}
if (startNode === endNode) {
return [{ id: startNode, cost: 0 }]
}
const getNeighborsWithWeights = nodeID => {
let allNeighbors = isDirected ? this.sourceToTargetMap.get(nodeID) : this.nodeToNeighborsMap.get(nodeID);
if (useOnlyOnline) allNeighbors = allNeighbors.filter(neighbor => this.onlineNodes.has(neighbor.id));
allNeighbors = allNeighbors.filter(neighbor => neighbor.id !== nodeID);
if (!aggregateEdgeWeights) {
allNeighbors = allNeighbors.map(neighbor => {
const cheapestEdge = neighbor.edges.reduce(
(cheapest, edge) => {
const weight = edge.weight ? edge.weight : 1;
return weight < cheapest.weight ? edge : cheapest
},
{ id: null, edges: [], weight: Infinity }
);
const newNeighbor = {
id: neighbor.id,
edges: [cheapestEdge],
weight: cheapestEdge.weight ? cheapestEdge.weight : 1
};
return newNeighbor
});
}
return allNeighbors
};
const cameFrom = new Map();
const weightMap = new Map([[startNode, 0]]);
const nextNodes = [startNode];
const processedNodes = new Set();
let finalCost = null;
let currentNode = null;
while ((currentNode = nextNodes.pop())) {
if (processedNodes.has(currentNode)) continue
processedNodes.add(currentNode);
const currentNodeWeight = weightMap.get(currentNode);
const neighbors = getNeighborsWithWeights(currentNode);
for (let i = 0; i < neighbors.length; i++) {
const neighbor = neighbors[i];
const currentWeight = weightMap.has(neighbor.id) ? weightMap.get(neighbor.id) : Infinity;
const newWeight = currentNodeWeight + neighbor.weight;
if (finalCost && newWeight > finalCost) continue
if (newWeight < currentWeight) {
weightMap.set(neighbor.id, newWeight);
cameFrom.set(neighbor.id, currentNode);
}
if (neighbor.id === endNode) {
finalCost = weightMap.get(endNode);
continue
}
if (!processedNodes.has(neighbor.id)) nextNodes.push(neighbor.id);
}
}
if (finalCost === null) return null
let step = endNode;
const path = [{ id: endNode, weight: finalCost }];
while ((step = cameFrom.get(step))) {
path.push({ id: step, weight: weightMap.get(step) });
}
return path.reverse()
}
/**
* Computes strongly connected components in the graph.
* Basically an implementation of Kosoraju's algorithm.
* https://en.wikipedia.org/wiki/Kosaraju%27s_algorithm
* @param {boolean} useOnlyOnline - If true the shortest path will only be computed for live nodes
* @return {("./model/nodeid").NodeID[][]} - Strongly connected components.
*/
computeStronglyConnectedComponents(useOnlyOnline = true) {
const nodes = useOnlyOnline ? Array.from(this.onlineNodes) : [...this.allNodes];
const stack = [];
const visited = new Set();
//We will need to reverse the sourceToTarget neighbors in step 2 (DFS2)
//It is significantly cheaper to do this during step 1 (DFS1) than computing it separately.
const reversedNeighbors = new Map();
const components = new Map();
let numberOfComponents = 0;
const DFS1 = node => {
if (visited.has(node)) return
visited.add(node);
const neighbors = this.getNeighbors(node, true, useOnlyOnline, true);
for (let j = 0; j < neighbors.length; j++) {
const neighbor = neighbors[j];
if (!reversedNeighbors.has(neighbor)) reversedNeighbors.set(neighbor, []);
reversedNeighbors.get(neighbor).push(node);
DFS1(neighbor);
}
stack.push(node);
};
const DFS2 = node => {
visited.add(node);
if (!components.has(numberOfComponents)) {
components.set(numberOfComponents, []);
}
components.get(numberOfComponents).push(node);
if (reversedNeighbors.has(node)) {
const reversedNeighborsOfNode = reversedNeighbors.get(node);
for (let i = 0; i < reversedNeighborsOfNode.length; i++) {
const reversedNeighbor = reversedNeighborsOfNode[i];
if (!visited.has(reversedNeighbor)) DFS2(reversedNeighbor);
}
}
};
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
DFS1(node);
}
visited.clear();
while (stack.length) {
const node = stack.pop();
if (!visited.has(node)) {
DFS2(node);
numberOfComponents++;
}
}
return Array.from(components.values())
}
/**
* Executes a breadth-first search in the graph given a start node.
* Each node encountered will be handed off to a callback function provided,
* If the callback function returns true then that branch will be terminated.
* @param {import("./model/nodeid").NodeID} startNode
* @param {(import("./model/nodeid").NodeID) => void|true} callback
* @param {boolean} useOnlyOnline - If true only online nodes will be processed
* @param {boolean} isDirected - If true then traversal will be directed
*/
BFS(startNode, callback, useOnlyOnline = true, isDirected = true) {
if (!this.nodeLookupMap.has(startNode)) {
throw new Error(`No such node exists: ${startNode}`)
}
if (useOnlyOnline && !this.onlineNodes.has(startNode)) {
throw new Error(`Input node is offline: ${startNode}`)
}
const seenNodes = new Set();
let nextLevel = [startNode];
let currentLevel = [];
let currentNode = null;
do {
currentLevel = nextLevel.reverse();
nextLevel = [];
while ((currentNode = currentLevel.pop())) {
if (seenNodes.has(currentNode)) continue
seenNodes.add(currentNode);
if (callback(currentNode)) continue
let neighbors;
if (isDirected) neighbors = this.sourceToTargetMap.get(currentNode).map(neighbor => neighbor.id);
else neighbors = this.nodeToNeighborsMap.get(currentNode).map(neighbor => neighbor.id);
nextLevel = nextLevel.concat(useOnlyOnline ? neighbors.filter(neighbor => this.onlineNodes.has(neighbor)) : neighbors);
}
} while (nextLevel.length)
}
/**
* Executes a depth-first search in the graph given a start node.
* Each node encountered will be handed off to a callback function provided,
* If the callback function returns true then that branch will be terminated.
* @param {import("./model/nodeid").NodeID} startNode
* @param {(import("./model/nodeid").NodeID) => void|true} callback
* @param {boolean} useOnlyOnline - If true only online nodes will be processed
* @param {boolean} isDirected - If true then traversal will be directed
*/
DFS(startNode, callback, useOnlyOnline = true, isDirected = true) {
if (!this.nodeLookupMap.has(startNode)) {
throw new Error(`No such node exists: ${startNode}`)
}
if (useOnlyOnline && !this.onlineNodes.has(startNode)) {
throw new Error(`Input node is offline: ${startNode}`)
}
const seenNodes = new Set();
let executionList = [];
let currentNode = startNode;
do {
if (seenNodes.has(currentNode)) continue
seenNodes.add(currentNode);
if (callback(currentNode)) continue
let neighbors;
if (isDirected) neighbors = this.sourceToTargetMap.get(currentNode).map(neighbor => neighbor.id);
else neighbors = this.nodeToNeighborsMap.get(currentNode).map(neighbor => neighbor.id);
executionList = executionList.concat(useOnlyOnline ? neighbors.filter(neighbor => this.onlineNodes.has(neighbor)).reverse() : neighbors.reverse());
} while ((currentNode = executionList.pop()))
}
}
/**
* Community detection using the Louvain algorithm.
* This function takes a list of nodes and edges (data must be valid!) and computes community assignments
* The function returns an array of arrays where each inner array represents a community populated by node IDs.
* To read more about the Louvain community detection algorithm:
* https://arxiv.org/pdf/0803.0476.pdf
* https://medium.com/walmartglobaltech/demystifying-louvains-algorithm-and-its-implementation-in-gpu-9a07cdd3b010
* @param {import("../model/igraphnode").IGraphNode[]} nodes
* @param {import("../model/igraphedge").IGraphEdge[]} edges
* @returns {{communities: import("../model/nodeid").NodeID[][], communityTable: {[key: string]: any}}}
*/
function louvain (nodes, edges) {
function removeDuplicates(array) {
return Array.from(new Set(array))
}
function getEdgeWeight(graph, node1, node2) {
return graph.associationMatrix[node1] ? graph.associationMatrix[node1][node2] : undefined
}
function deepCopy(obj) {
if (obj === null || typeof obj !== "object") {
return obj
}
const temp = obj.constructor();
for (const key in obj) {
temp[key] = deepCopy(obj[key]);
}
return temp
}
function getModularity(graphProperties) {
const communities = removeDuplicates(Object.values(graphProperties.nodesToCommunity));
return communities.reduce((result, community) => {
const internalDegree = graphProperties.totalCommunityWeights[community] || 0;
const degree = graphProperties.degrees[community] || 0;
if (graphProperties.totalWeight > 0) {
result = result + internalDegree / graphProperties.totalWeight - Math.pow(degree / (2 * graphProperties.totalWeight), 2);
}
return result
}, 0.0)
}
function reNumberPartition(nodesToCommunity) {
let count = 0;
const reNumberedPartition = deepCopy(nodesToCommunity);
const newValues = {};
Object.keys(nodesToCommunity).forEach(key => {
const value = nodesToCommunity[key];
let newValue = typeof newValues[value] === "undefined" ? -1 : newValues[value];
if (newValue === -1) {
newValues[value] = count;
newValue = count;
count = count + 1;
}
reNumberedPartition[key] = newValue;
});
return reNumberedPartition
}
function computeNextLevel(graph, graphProperties) {
let currentModularity;
let newModularity = getModularity(graphProperties);
let hasModifiedCommunityMembers = true;
while (hasModifiedCommunityMembers) {
currentModularity = newModularity;
hasModifiedCommunityMembers = false;
//For each node, try to find the optimal community assignment
graph.nodes.forEach(node => {
//Compute neighborhood meta data for the node
const currentNodeCommunity = graphProperties.nodesToCommunity[node];
const communityWeightByTotalWeight = (graphProperties.gdegrees[node] || 0) / (graphProperties.totalWeight * 2.0);
const neighboringCommunities = {};
const neighborhood = typeof graph.associationMatrix[node] === "undefined" ? [] : Object.keys(graph