UNPKG

trassel

Version:
1,377 lines (1,302 loc) 1.89 MB
/** @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