hyperformula
Version:
HyperFormula is a JavaScript engine for efficient processing of spreadsheet-like data and formulas
339 lines • 10.5 kB
JavaScript
/**
* @license
* Copyright (c) 2025 Handsoncode. All rights reserved.
*/
import { TopSort } from "./TopSort.mjs";
import { ProcessableValue } from "./ProcessableValue.mjs";
/**
* Provides directed graph structure.
*
* Idea for performance improvement:
* - use Set<Node>[] instead of NodeId[][] for edgesSparseArray
*/
export class Graph {
constructor(dependencyQuery) {
this.dependencyQuery = dependencyQuery;
/**
* A sparse array. The value nodesSparseArray[n] exists if and only if node n is in the graph.
* @private
*/
this.nodesSparseArray = [];
/**
* A sparse array. The value edgesSparseArray[n] exists if and only if node n is in the graph.
* The edgesSparseArray[n] is also a sparse array. It may contain removed nodes. To make sure check nodesSparseArray.
* @private
*/
this.edgesSparseArray = [];
/**
* A mapping from node to its id. The value nodesIds.get(node) exists if and only if node is in the graph.
* @private
*/
this.nodesIds = new Map();
/**
* A ProcessableValue object.
* @private
*/
this.dirtyAndVolatileNodeIds = new ProcessableValue({
dirty: [],
volatile: []
}, r => this.processDirtyAndVolatileNodeIds(r));
/**
* A set of node ids. The value infiniteRangeIds.get(nodeId) exists if and only if node is in the graph.
* @private
*/
this.infiniteRangeIds = new Set();
/**
* A dense array. It may contain duplicates and removed nodes.
* @private
*/
this.changingWithStructureNodeIds = [];
this.nextId = 0;
}
/**
* Iterate over all nodes the in graph
*/
getNodes() {
return this.nodesSparseArray.filter(node => node !== undefined);
}
/**
* Checks whether a node is present in graph
*
* @param node - node to check
*/
hasNode(node) {
return this.nodesIds.has(node);
}
/**
* Checks whether exists edge between nodes. If one or both of nodes are not present in graph, returns false.
*
* @param fromNode - node from which edge is outcoming
* @param toNode - node to which edge is incoming
*/
existsEdge(fromNode, toNode) {
const fromId = this.getNodeId(fromNode);
const toId = this.getNodeId(toNode);
if (fromId === undefined || toId === undefined) {
return false;
}
return this.edgesSparseArray[fromId].includes(toId);
}
/**
* Returns nodes adjacent to given node. May contain removed nodes.
*
* @param node - node to which adjacent nodes we want to retrieve
*
* Idea for performance improvement:
* - return an array instead of set
*/
adjacentNodes(node) {
const id = this.getNodeId(node);
if (id === undefined) {
throw this.missingNodeError(node);
}
return new Set(this.edgesSparseArray[id].filter(id => id !== undefined).map(id => this.nodesSparseArray[id]));
}
/**
* Returns number of nodes adjacent to given node. Contrary to adjacentNodes(), this method returns only nodes that are present in graph.
*
* @param node - node to which adjacent nodes we want to retrieve
*/
adjacentNodesCount(node) {
const id = this.getNodeId(node);
if (id === undefined) {
throw this.missingNodeError(node);
}
return this.fixEdgesArrayForNode(id).length;
}
/**
* Adds node to a graph
*
* @param node - a node to be added
*/
addNodeAndReturnId(node) {
const idOfExistingNode = this.nodesIds.get(node);
if (idOfExistingNode !== undefined) {
return idOfExistingNode;
}
const newId = this.nextId;
this.nextId++;
this.nodesSparseArray[newId] = node;
this.edgesSparseArray[newId] = [];
this.nodesIds.set(node, newId);
return newId;
}
/**
* Adds edge between nodes.
*
* The nodes had to be added to the graph before, or the error will be raised
*
* @param fromNode - node from which edge is outcoming
* @param toNode - node to which edge is incoming
*/
addEdge(fromNode, toNode) {
const fromId = this.getNodeIdIfNotNumber(fromNode);
const toId = this.getNodeIdIfNotNumber(toNode);
if (fromId === undefined) {
throw this.missingNodeError(fromNode);
}
if (toId === undefined) {
throw this.missingNodeError(toNode);
}
if (this.edgesSparseArray[fromId].includes(toId)) {
return;
}
this.edgesSparseArray[fromId].push(toId);
}
/**
* Removes node from graph
*/
removeNode(node) {
const id = this.getNodeId(node);
if (id === undefined) {
throw this.missingNodeError(node);
}
if (this.edgesSparseArray[id].length > 0) {
this.edgesSparseArray[id].forEach(adjacentId => this.dirtyAndVolatileNodeIds.rawValue.dirty.push(adjacentId));
this.dirtyAndVolatileNodeIds.markAsModified();
}
const dependencies = this.removeDependencies(node);
delete this.nodesSparseArray[id];
delete this.edgesSparseArray[id];
this.infiniteRangeIds.delete(id);
this.nodesIds.delete(node);
return dependencies;
}
/**
* Removes edge between nodes.
*/
removeEdge(fromNode, toNode) {
const fromId = this.getNodeIdIfNotNumber(fromNode);
const toId = this.getNodeIdIfNotNumber(toNode);
if (fromId === undefined) {
throw this.missingNodeError(fromNode);
}
if (toId === undefined) {
throw this.missingNodeError(toNode);
}
const indexOfToId = this.edgesSparseArray[fromId].indexOf(toId);
if (indexOfToId === -1) {
throw new Error('Edge does not exist');
}
delete this.edgesSparseArray[fromId][indexOfToId];
}
/**
* Removes edge between nodes if it exists.
*/
removeEdgeIfExists(fromNode, toNode) {
const fromId = this.getNodeIdIfNotNumber(fromNode);
const toId = this.getNodeIdIfNotNumber(toNode);
if (fromId === undefined) {
return;
}
if (toId === undefined) {
return;
}
const indexOfToId = this.edgesSparseArray[fromId].indexOf(toId);
if (indexOfToId === -1) {
return;
}
delete this.edgesSparseArray[fromId][indexOfToId];
}
/**
* Sorts the whole graph topologically. Nodes that are on cycles are kept separate.
*/
topSortWithScc() {
return this.getTopSortedWithSccSubgraphFrom(this.getNodes(), () => true, () => {});
}
/**
* Sorts the graph topologically. Nodes that are on cycles are kept separate.
*
* @param modifiedNodes - seed for computation. The algorithm assumes that only these nodes have changed since the last run.
* @param operatingFunction - recomputes value of a node, and returns whether a change occurred
* @param onCycle - action to be performed when node is on cycle
*/
getTopSortedWithSccSubgraphFrom(modifiedNodes, operatingFunction, onCycle) {
const topSortAlgorithm = new TopSort(this.nodesSparseArray, this.edgesSparseArray);
const modifiedNodesIds = modifiedNodes.map(node => this.getNodeId(node)).filter(id => id !== undefined);
return topSortAlgorithm.getTopSortedWithSccSubgraphFrom(modifiedNodesIds, operatingFunction, onCycle);
}
/**
* Marks node as volatile.
*/
markNodeAsVolatile(node) {
const id = this.getNodeId(node);
if (id === undefined) {
return;
}
this.dirtyAndVolatileNodeIds.rawValue.volatile.push(id);
this.dirtyAndVolatileNodeIds.markAsModified();
}
/**
* Marks node as dirty.
*/
markNodeAsDirty(node) {
const id = this.getNodeId(node);
if (id === undefined) {
return;
}
this.dirtyAndVolatileNodeIds.rawValue.dirty.push(id);
this.dirtyAndVolatileNodeIds.markAsModified();
}
/**
* Returns an array of nodes that are marked as dirty and/or volatile.
*/
getDirtyAndVolatileNodes() {
return this.dirtyAndVolatileNodeIds.getProcessedValue();
}
/**
* Clears dirty nodes.
*/
clearDirtyNodes() {
this.dirtyAndVolatileNodeIds.rawValue.dirty = [];
this.dirtyAndVolatileNodeIds.markAsModified();
}
/**
* Marks node as changingWithStructure.
*/
markNodeAsChangingWithStructure(node) {
const id = this.getNodeId(node);
if (id === undefined) {
return;
}
this.changingWithStructureNodeIds.push(id);
}
/**
* Marks all nodes marked as changingWithStructure as dirty.
*/
markChangingWithStructureNodesAsDirty() {
if (this.changingWithStructureNodeIds.length <= 0) {
return;
}
this.dirtyAndVolatileNodeIds.rawValue.dirty = [...this.dirtyAndVolatileNodeIds.rawValue.dirty, ...this.changingWithStructureNodeIds];
this.dirtyAndVolatileNodeIds.markAsModified();
}
/**
* Marks node as infinite range.
*/
markNodeAsInfiniteRange(node) {
const id = this.getNodeIdIfNotNumber(node);
if (id === undefined) {
return;
}
this.infiniteRangeIds.add(id);
}
/**
* Returns an array of nodes marked as infinite ranges
*/
getInfiniteRanges() {
return [...this.infiniteRangeIds].map(id => ({
node: this.nodesSparseArray[id],
id
}));
}
/**
* Returns the internal id of a node.
*/
getNodeId(node) {
return this.nodesIds.get(node);
}
/**
*
*/
getNodeIdIfNotNumber(node) {
return typeof node === 'number' ? node : this.nodesIds.get(node);
}
/**
* Removes invalid neighbors of a given node from the edges array and returns adjacent nodes for the input node.
*/
fixEdgesArrayForNode(id) {
const adjacentNodeIds = this.edgesSparseArray[id];
this.edgesSparseArray[id] = adjacentNodeIds.filter(adjacentId => adjacentId !== undefined && this.nodesSparseArray[adjacentId]);
return this.edgesSparseArray[id];
}
/**
* Removes edges from the given node to its dependencies based on the dependencyQuery function.
*/
removeDependencies(node) {
const dependencies = this.dependencyQuery(node);
dependencies.forEach(([_, dependency]) => {
this.removeEdgeIfExists(dependency, node);
});
return dependencies;
}
/**
* processFn for dirtyAndVolatileNodeIds ProcessableValue instance
* @private
*/
processDirtyAndVolatileNodeIds({
dirty,
volatile
}) {
return [...new Set([...dirty, ...volatile])].map(id => this.nodesSparseArray[id]).filter(node => node !== undefined);
}
/**
* Returns error for missing node.
*/
missingNodeError(node) {
return new Error(`Unknown node ${node}`);
}
}