import ManagedData from "./manageddata"; import { PLUGIN_PATH, COLOR_PALETTE } from "../../config"; const LINK_NAME_CONNECTOR = " → "; export const NODE_LABEL = "name"; export const NODE_ID = "id"; export const NODE_TYPE = "type"; export const NODE_DESCRIPTION = "description"; export const NODE_IMAGE = "image"; export const NODE_REFERENCES = "references"; export const NODE_VIDEOS = "videos"; export const LINK_SOURCE = "source"; export const LINK_TARGET = "target"; export const LINK_TYPE = "type"; export const LINK_PARTICLE_COUNT = 4; export const GRAPH_NODES = "nodes"; export const GRAPH_LINKS = "links"; export const IMAGE_SIZE = 12; export const IMAGE_SRC = PLUGIN_PATH + "datasets/images/"; export const LINK_PARAMS = [LINK_TYPE]; export const NODE_PARAMS = [ NODE_ID, NODE_LABEL, NODE_IMAGE, NODE_DESCRIPTION, NODE_REFERENCES, NODE_VIDEOS, NODE_TYPE, ]; export const LINK_SIM_PARAMS = ["index"]; export const NODE_SIM_PARAMS = ["index", "x", "y", "vx", "vy", "fx", "fy"]; // Based on https://github.com/d3/d3-force#simulation_nodes export const JSON_CONFIG = PLUGIN_PATH + "datasets/aud1v2.json"; export const STOP_PHYSICS_DELAY = 5000; // ms export class Graph extends ManagedData { constructor(data) { super(Graph.addIdentifiers(data)); this.calculateLinkTypes(); this.onChangeCallbacks = []; } triggerOnChange() { this.onChangeCallbacks.forEach((fn) => fn(this.data)); } onRedo() { this.triggerOnChange(); } onUndo() { this.triggerOnChange(); } storableData(data) { return this.getCleanData(data, true); } /** * Based on the function from the 3d-graph code from @JoschaRode */ calculateLinkTypes() { const linkClasses = []; this.data[GRAPH_LINKS].forEach((link) => linkClasses.push(link[LINK_TYPE]) ); this.linkTypes = [...new Set(linkClasses)]; } getLinkColor(link) { return this.getLinkTypeColor(link[LINK_TYPE]); } getLinkTypeColor(linkClass) { var classIndex = this.linkTypes.indexOf(linkClass); return COLOR_PALETTE[classIndex % COLOR_PALETTE.length]; } deleteNode(nodeId) { // Delete node from nodes this.data[GRAPH_NODES] = this.data[GRAPH_NODES].filter( (n) => n[NODE_ID] !== nodeId ); // Delete links with node this.data[GRAPH_LINKS] = this.data[GRAPH_LINKS].filter( (l) => l[LINK_SOURCE][NODE_ID] !== nodeId && l[LINK_TARGET][NODE_ID] !== nodeId ); this.storeCurrentData("Deleted node with id [" + nodeId + "]"); } stopPhysics() { this.data[GRAPH_NODES].forEach((n) => { n.fx = n.x; n.fy = n.y; }); } static addIdentifiers(data) { data[GRAPH_NODES].forEach((n) => { n.node = true; n.link = false; }); data[GRAPH_LINKS].forEach((l) => { l.node = false; l.link = true; }); return data; } deleteLink(sourceId, targetId) { // Only keep links, of one of the nodes is different this.data[GRAPH_LINKS] = this.data[GRAPH_LINKS].filter( (l) => l[LINK_SOURCE][NODE_ID] !== sourceId || l[LINK_TARGET][NODE_ID] !== targetId ); this.storeCurrentData( "Deleted link connecting [" + sourceId + "] with [" + targetId + "]" ); } isLinkOnNode(link, node) { if (link === undefined || node === undefined) { return false; } if (link.link !== true || node.node !== true) { return false; } return ( link[LINK_SOURCE][NODE_ID] === node[NODE_ID] || link[LINK_TARGET][NODE_ID] === node[NODE_ID] ); } existsLink(sourceId, targetId) { const links = this.data[GRAPH_LINKS]; for (var i = 0; i < links.length; i++) { var link = links[i]; if ( link[LINK_SOURCE][NODE_ID] === sourceId && link[LINK_TARGET][NODE_ID] === targetId ) { return true; } } return false; } changeDetails(selectionDetails) { if (selectionDetails.node === true) { this.changeNodeDetails(selectionDetails[NODE_ID].newDetails); } else if (selectionDetails.link === true) { this.changeLinkDetails( selectionDetails[LINK_SOURCE][NODE_ID], selectionDetails[LINK_TARGET][NODE_ID], selectionDetails ); } } changeNodeDetails(nodeId, newDetails) { var nodes = this.data[GRAPH_NODES]; for (var i = 0; i < nodes.length; i++) { // Is relevant node? if (nodes[i][NODE_ID] !== nodeId) { continue; // No } // Changed details nodes[i] = Object.assign(nodes[i], newDetails); // All done return; } } changeLinkDetails(sourceId, targetId, newDetails) { var links = this.data[GRAPH_LINKS]; for (var i = 0; i < links.length; i++) { // Is relevant link? if ( links[i][LINK_SOURCE][NODE_ID] !== sourceId || links[i][LINK_TARGET][NODE_ID] !== targetId ) { continue; // No } // Changed details links[i] = Object.assign(links[i], newDetails); // All done return; } } connectNodes(sourceId, targetIds) { targetIds.forEach((targetId) => { if ( this.existsLink(sourceId, targetId) || this.existsLink(targetId, sourceId) ) { return; } this.addLink(sourceId, targetId); }); } getCleanData(data = undefined, simulationParameters = false) { if (data === undefined) { data = this.data; } var cleanData = {}; cleanData[GRAPH_LINKS] = []; cleanData[GRAPH_NODES] = []; data[GRAPH_LINKS].forEach((link) => cleanData[GRAPH_LINKS].push( this.getCleanLink(link, simulationParameters) ) ); data[GRAPH_NODES].forEach((node) => cleanData[GRAPH_NODES].push( this.getCleanNode(node, simulationParameters) ) ); return cleanData; } getCleanNode(node, simulationParameters) { var cleanNode = {}; NODE_PARAMS.forEach((param) => { cleanNode[param] = node[param]; }); if (simulationParameters) { NODE_SIM_PARAMS.forEach((param) => { cleanNode[param] = node[param]; }); } return cleanNode; } getCleanLink(link, simulationParameters) { var cleanLink = {}; // Assuming that all nodes are valid, there are two possible formats // 1. source and target are node objects if (link[LINK_SOURCE][NODE_ID] !== undefined) { // Source and target nodes // Node ids will be converted to complete node objects on running graphs, gotta convert back cleanLink[LINK_SOURCE] = link[LINK_SOURCE][NODE_ID]; cleanLink[LINK_TARGET] = link[LINK_TARGET][NODE_ID]; } else { // 2. source and target are just node ids cleanLink[LINK_SOURCE] = link[LINK_SOURCE]; cleanLink[LINK_TARGET] = link[LINK_TARGET]; } // Other parameters LINK_PARAMS.forEach((param) => { cleanLink[param] = link[param]; }); if (simulationParameters) { LINK_SIM_PARAMS.forEach((param) => { cleanLink[param] = link[param]; }); } return cleanLink; } existsNodeId(nodeId) { var nodes = this.data[GRAPH_NODES]; for (var i = 0; i < nodes.length; i++) { if (nodes[i][NODE_ID] === nodeId) { return true; } } return false; } getUnusedNodeId() { var id; do { id = this.getRandomString(); } while (this.existsNodeId(id)); return id; } getRandomString(length = 8) { // Move to global helpers // Based on: https://stackoverflow.com/a/1349426/7376120 var characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; var charactersLength = characters.length; var result = ""; for (var i = 0; i < length; i++) { result += characters.charAt( Math.floor(Math.random() * charactersLength) ); } return result; } addLink(sourceId, targetId, linkDetails = {}) { // Copy params var newLink = linkDetails; // Make sure the IDs exist if ( sourceId === undefined || targetId === undefined || this.existsNodeId(sourceId) === false || this.existsNodeId(targetId) === false ) { return; } // Make sure the link is unique if (this.existsLink(sourceId, targetId)) { return; } newLink[LINK_SOURCE] = sourceId; newLink[LINK_TARGET] = targetId; // Basic node properties newLink.link = true; newLink.node = false; // Add node this.data[GRAPH_LINKS].push(newLink); this.triggerOnChange(); this.storeCurrentData( "Added custom link connecting [" + sourceId + "] with [" + targetId + "]" ); return newLink; } addNode(nodeDetails) { // Copy params var newNode = nodeDetails; // Make sure the ID is set and unique if (newNode[NODE_ID] === undefined) { newNode[NODE_ID] = this.getUnusedNodeId(); } else if (this.existsNodeId(newNode[NODE_ID])) { return; } // Basic node properties newNode.node = true; newNode.link = false; // Add node this.data[GRAPH_NODES].push(newNode); this.triggerOnChange(); this.storeCurrentData( "Added custom node with id [" + newNode[NODE_ID] + "]" ); return newNode; } static toStr(item) { if (item === undefined) { return "UNDEFINED"; } if (item.node) { return item[Graph.NODE_LABEL]; } else if (item.link) { return ( Graph.toStr(item[Graph.LINK_SOURCE]) + LINK_NAME_CONNECTOR + Graph.toStr(item[Graph.LINK_TARGET]) ); } else { return "UNDEFINED"; } } }