import ManagedData from "../manageddata"; import { Link } from "./link"; import { NodeType } from "./nodetype"; import { Node } from "./node"; import { GLOBAL_PARAMS } from "../helper/serializableitem"; import { GraphElement } from "./graphelement"; const GRAPH_PARAMS = [...GLOBAL_PARAMS]; const GRAPH_DATA_PARAMS = ["nodes", "links", "types"]; export type GraphData = { nodes: Node[]; links: Link[]; types: NodeType[] }; export class Graph extends ManagedData { public data: GraphData; private nextNodeId = 0; private nextLinkId = 0; private nextTypeId = 0; // Callbacks public onChangeCallbacks: { (data: GraphData): void }[]; constructor(data: GraphData) { super(data); this.onChangeCallbacks = []; this.connectElementsToGraph(this.data); this.prepareIds(data); } /** * Sets the correct graph object for all the graph elements in data. * @param data Datastructure to connect. */ connectElementsToGraph(data: GraphData) { data.nodes.forEach((n) => (n.graph = this)); data.links.forEach((l) => { l.graph = this; l.source = data.nodes.find((node) => node.id === l.sourceId); l.target = data.nodes.find((node) => node.id === l.targetId); }); data.types.forEach((t) => (t.graph = this)); } /** * Intuitive getter for links. * @returns All links associated with the graph. */ public get links(): Link[] { return this.data.links; } /** * Intuitive getter for nodes. * @returns All nodes associated with the graph.number */ public get nodes(): Node[] { return this.data.nodes; } /** * Intuitive getter for node types. * @returns All node types associated with the graph. */ public get types(): NodeType[] { return this.data.types; } /** * Determines the highest, used ids for GraphElements in data for later use. * @param data Data to analyse. */ private prepareIds(data: GraphData) { if (data.links.length > 0) { this.nextLinkId = this.getHighestId(data.links) + 1; } if (data.nodes.length > 0) { this.nextNodeId = this.getHighestId(data.nodes) + 1; } if (data.types.length > 0) { this.nextTypeId = this.getHighestId(data.types) + 1; } } /** * Finds the highest id from a list of graph elements. * @param elements List of elements containing element with highest id. * @returns Highest id in list. */ private getHighestId(elements: GraphElement[]): number { let highest = 0; elements.forEach((element) => { if (highest < element.id) { highest = element.id; } }); return highest; } /** * Calls all registered callbacks for the onChange event. * @private */ private triggerOnChange() { this.onChangeCallbacks.forEach((fn) => fn(this.data)); } /** * Triggers change event on data-redo. */ protected onRedo() { this.triggerOnChange(); } /** * Triggers change event on data-undo. */ protected onUndo() { this.triggerOnChange(); } protected storableData(data: GraphData): any { const clean: GraphData = { nodes: [], links: [], types: [], }; clean.links = data.links.map((link) => link.getCleanInstance()); clean.nodes = data.nodes.map((node) => node.getCleanInstance()); clean.types = data.types.map((type) => type.getCleanInstance()); return clean; } protected restoreData(data: GraphData): any { const parsedData = Graph.parseData(data); this.connectElementsToGraph(parsedData); return parsedData; } serialize(): any { return this.serializeData(this.data); } /** * Takes a data object and serializes it. * @param data GraphData object to serialize. * @returns Serialized data. */ private serializeData(data: GraphData): any { return { ...this.serializeProperties(GRAPH_PARAMS), ...this.serializeProperties(GRAPH_DATA_PARAMS, data), }; } /** * Adds a pre-created node to the graph. * @param node New node object. * @returns True, if successful. */ public addNode(node: Node) { if (this.data.nodes.includes(node)) { return true; // Already exists in graph. } // Update id node.id = this.nextNodeId; this.nextNodeId += 1; // Is valid node? if (node.label == undefined) { node.label = "Unnamed"; } if (node.type == undefined) { if (this.types.length > 0) { // Just give first type in list node.type = this.types[0]; } else { // Give empty type // TODO: Properly add new type, with proper ID. Implemented this.addType(..); node.type = new NodeType(this); } } this.data.nodes.push(node); this.triggerOnChange(); // TODO: Use toString implementation of node this.storeCurrentData("Added node [" + node + "]"); return true; } /** * Deletes a node from the graph. * @param node Node object to remove. * @returns True, if successful. */ public deleteNode(node: Node): boolean { if (!this.data.nodes.includes(node)) { return true; // Doesn't even exist in graph to begin with. } this.data.nodes = this.data.nodes.filter((n: Node) => !n.equals(node)); try { // No save points should be created when deleting the links this.disableStoring(); // Delete all the links that contain this node node.links.forEach((l) => { l.delete(); }); } finally { this.enableStoring(); } this.triggerOnChange(); // TODO: Use toString implementation of node this.storeCurrentData( "Deleted node [" + node + "] and all connected links" ); return true; } getNode(id: number): Node { return this.getElementWithId(this.nodes, id); } getType(id: number): NodeType { return this.getElementWithId(this.types, id); } getElementWithId(elements: GraphElement[], id: number): any { const numberId = Number(id); if (isNaN(numberId)) { return undefined; } return elements.find((e) => e.id === numberId); } /** * Adds a pre-created link to the graph. * @param link New link object. * @returns True, if successful. */ public addLink(link: Link): boolean { if (this.data.links.includes(link)) { return true; // Already exists in graph. } // Update id link.id = this.nextLinkId; this.nextLinkId += 1; this.data.links.push(link); this.triggerOnChange(); // TODO: Use toString implementation of link this.storeCurrentData("Added link [" + link + "]"); return true; } /** * Deletes a link from the graph. * @param link Link object to remove. * @returns True, if successful. */ public deleteLink(link: Link): boolean { if (!this.data.links.includes(link)) { return true; // Doesn't even exist in graph to begin with. } this.data.links = this.data.links.filter((l: Link) => !l.equals(link)); this.triggerOnChange(); // TODO: Use toString implementation of link this.storeCurrentData("Deleted link [" + link + "]"); return true; } /** * Calculates the pythagoras distance. * @param nodeA One node. * @param nodeB The other node. * @returns Distance between both nodes. */ private nodeDistance(nodeA: Node, nodeB: Node): number { const a = nodeA as any; const b = nodeB as any; const xDistance = Math.abs(a.x - b.x); const yDistance = Math.abs(a.y - b.y); return Math.sqrt(Math.pow(xDistance, 2) + Math.pow(yDistance, 2)); } /** * Goes over all nodes and finds the closest node based on distance, that is not the given reference node. * @param referenceNode Reference node to get closest other node to. * @returns Closest node and distance. Undefined, if no closest node can be found. */ public getClosestOtherNode(referenceNode: Node): { node: Node; distance: number; } { if (referenceNode == undefined || this.nodes.length < 2) { return undefined; } // Iterate over all nodes, keep the one with the shortest distance let closestDistance: number = undefined; let closestNode: Node = undefined; this.nodes.forEach((node) => { if (node.equals(referenceNode)) { return; // Don't compare to itself } const currentDistance = this.nodeDistance(node, referenceNode); if ( closestDistance == undefined || closestDistance > currentDistance ) { closestDistance = currentDistance; closestNode = node; } }); return { node: closestNode, distance: closestDistance }; } public static parse(raw: any): Graph { return new Graph(this.parseData(raw)); } public static parseData(raw: any): GraphData { const data: GraphData = { nodes: [], links: [], types: [], }; // Parse nodes if (raw.nodes === undefined) { throw new Error( "Invalid graph data format. Could not find any nodes." ); } raw.nodes.forEach((rawNode: any) => { data.nodes.push(Node.parse(rawNode)); }); // Parse links if (raw.links === undefined) { throw new Error( "Invalid graph data format. Could not find any links." ); } raw.links.forEach((rawLink: any) => { data.links.push(Link.parse(rawLink)); // No need to replace node ids with proper node objects, since that should be done in the graph itself. Only have to prepare valid GraphData }); // Collect all node types and give id if none given yet let typeId: number = undefined; if (data.nodes.length > 0 && data.nodes[0].type.id === undefined) { typeId = 0; } // TODO: Remove, when types are directly parsed and not just implicit data.nodes.forEach((node) => { const sharedType: NodeType = data.types.find((type) => type.equals(node.type) ); if (sharedType !== undefined) { node.type = sharedType; // Assign it the stored type, to make sure that it has the same reference as every other node to this type return; } if (typeId !== undefined) { node.type.id = typeId; typeId += 1; } // Doesn't exist in list yet, so add data.types.push(node.type); }); return data; } }