/** * This program has been developed by students from the bachelor Computer Science at * Utrecht University within the Software Project course. * © Copyright Utrecht University (Department of Information and Computing Sciences) */ import { GraphType, LinkType, NodeType } from '../types'; import { Edge, Node, GraphQueryResult } from '../../../../data-access/store'; import { ML } from '../../../../data-access/store/mlSlice'; import { processML } from './NLMachineLearning'; /** ResultNodeLinkParserUseCase implements methods to parse and translate websocket messages from the backend into a GraphType. */ /** * This program has been developed by students from the bachelor Computer Science at * Utrecht University within the Software Project course. * © Copyright Utrecht University (Department of Information and Computing Sciences) */ /** A node link data-type for a query result object from the backend. */ // export type NodeLinkResultType = { DEPRECATED USE GraphQueryResult // nodes: Node[]; // edges: Link[]; // mlEdges?: Link[]; // }; /** Typing for nodes and links in the node-link result. Nodes and links should always have an id and attributes. */ // export interface AxisType { // id: string; // attributes: Record<string, any>; // mldata?: Record<string, string[]> | number; // This is shortest path data . This name is needs to be changed together with backend TODO: Change this. // } /** Typing for a node in the node-link result */ // export type Node = AxisType; /** Typing for a link in the node-link result */ // export interface Link extends AxisType { // from: string; // to: string; // } export type AxisType = Node | Edge; /** Gets the group to which the node/edge belongs */ export function getGroupName(axisType: AxisType): string { // FIXME: only works in arangodb return axisType.label; } /** Returns true if the given id belongs to the target group. */ export function isNotInGroup(nodeOrEdge: AxisType, targetGroup: string): boolean { return getGroupName(nodeOrEdge) != targetGroup; } /** Checks if a query result form the backend contains valid NodeLinkResultType data. * @param {any} jsonObject The query result object received from the frontend. * @returns True and the jsonObject will be casted, false if the jsonObject did not contain all the data fields. */ export function isNodeLinkResult(jsonObject: any): jsonObject is GraphQueryResult { if (typeof jsonObject === 'object' && jsonObject !== null && 'nodes' in jsonObject && 'edges' in jsonObject) { if (!Array.isArray(jsonObject.nodes) || !Array.isArray(jsonObject.edges)) return false; const validNodes = jsonObject.nodes.every((node: any) => 'id' in node && 'attributes' in node); const validEdges = jsonObject.edges.every((edge: any) => 'from' in edge && 'to' in edge); return validNodes && validEdges; } else return false; } /** Returns a record with a type of the nodes as key and a number that represents how many times this type is present in the nodeLinkResult as value. */ export function getNodeTypes(nodeLinkResult: GraphQueryResult): Record<string, number> { const types: Record<string, number> = {}; nodeLinkResult.nodes.forEach((node) => { const type = getGroupName(node); if (types[type] != undefined) types[type]++; else types[type] = 0; }); return types; } export type UniqueEdge = { from: string; to: string; count: number; attributes: Record<string, any>; }; /** * Parse a message (containing query result) edges to unique edges. * @param {Link[]} queryResultEdges Edges from a query result. * @param {boolean} isLinkPredictionData True if parsing LinkPredictionData, false otherwise. * @returns {UniqueEdge[]} Unique edges with a count property added. */ export function parseToUniqueEdges(queryResultEdges: Edge[], isLinkPredictionData: boolean): UniqueEdge[] { // Edges to be returned const edges: UniqueEdge[] = []; // Collect the edges in map, to only keep unique edges // And count the number of same edges const edgesMap = new Map<string, number>(); const attriMap = new Map<string, Record<string, any>>(); if (queryResultEdges != null) { if (!isLinkPredictionData) { for (let j = 0; j < queryResultEdges.length; j++) { const newLink = queryResultEdges[j].from + ':' + queryResultEdges[j].to; edgesMap.set(newLink, (edgesMap.get(newLink) || 0) + 1); attriMap.set(newLink, queryResultEdges[j].attributes); } edgesMap.forEach((count, key) => { const fromTo = key.split(':'); edges.push({ from: fromTo[0], to: fromTo[1], count: count, attributes: attriMap.get(key) ?? [], }); }); } else { for (let i = 0; i < queryResultEdges.length; i++) { edges.push({ from: queryResultEdges[i].from, to: queryResultEdges[i].to, count: queryResultEdges[i].attributes.jaccard_coefficient as number, attributes: queryResultEdges[i].attributes, }); } } } return edges; } type OptionsI = { defaultX?: number; defaultY?: number; defaultRadius?: number; }; /** * Parse a websocket message containing a query result into a node link GraphType. * @param {any} queryResult An incoming query result from the websocket. * @returns {GraphType} A node-link graph containing the nodes and links for the diagram. */ export function parseQueryResult(queryResult: GraphQueryResult, ml: ML, options: OptionsI = {}): GraphType { let ret: GraphType = { nodes: {}, links: {}, }; const typeDict: { [key: string]: number } = {}; // Counter for the types let counter = 1; // Entry to keep track of the number of machine learning clusters let numberOfMlClusters = 0; // TODO let communityDetectionInResult = false; let shortestPathInResult = false; let linkPredictionInResult = false; for (let i = 0; i < queryResult.nodes.length; i++) { // Assigns a group to every entity type for color coding const nodeId = queryResult.nodes[i]._id; // for datasets without label, label is included in id. eg. "kamerleden/112" //const entityType = queryResult.nodes[i].label; const node = queryResult.nodes[i]; const entityType: string = node.label; // The preferred text to be shown on top of the node let preferredText = nodeId; let typeNumber = 1; // Check if entity is already seen by the dictionary if (entityType in typeDict) typeNumber = typeDict[entityType]; else { typeDict[entityType] = counter; typeNumber = counter; counter++; } // TODO: this should be a setting // Check to see if node has a "naam" attribute and set prefText to it if (queryResult.nodes[i].attributes.name !== undefined) preferredText = queryResult.nodes[i].attributes.name as string; if (queryResult.nodes[i].attributes.label !== undefined) preferredText = queryResult.nodes[i].attributes.label as string; if (queryResult.nodes[i].attributes.naam !== undefined) preferredText = queryResult.nodes[i].attributes.naam as string; //const labelTemplate = queryResult.nodes[i].label == undefined ? queryResult.nodes[i]._id.split('/')[0] : queryResult.nodes[i].label; let radius = options.defaultRadius || 5; let data: NodeType = { _id: queryResult.nodes[i]._id, label: entityType, attributes: queryResult.nodes[i].attributes, type: typeNumber, displayInfo: preferredText, radius: radius, defaultX: (options.defaultX || 0) + Math.random() * radius * 20 - radius * 10, defaultY: (options.defaultY || 0) + Math.random() * radius * 20 - radius * 10, }; // let mlExtra = {}; // if (queryResult.nodes[i].mldata && typeof queryResult.nodes[i].mldata != 'number') { // TODO FIXME: this is somewhere else now // mlExtra = { // shortestPathData: queryResult.nodes[i].mldata as Record<string, string[]>, // }; // shortestPathInResult = true; // } else if (typeof queryResult.nodes[i].mldata == 'number') { // // mldata + 1 so you dont get 0, which is interpreted as 'undefined' // const numberOfCluster = (queryResult.nodes[i].mldata as number) + 1; // mlExtra = { // cluster: numberOfCluster, // clusterAccoringToMLData: numberOfCluster, // }; // communityDetectionInResult = true; // if (numberOfCluster > numberOfMlClusters) { // numberOfMlClusters = numberOfCluster; // } // } // Add mlExtra to the node if necessary // data = { ...data, ...mlExtra }; ret.nodes[data._id] = data; } // Filter unique edges and transform to LinkTypes // List for all links let links: LinkType[] = []; let allNodeIds = new Set(Object.keys(ret.nodes)); // Parse ml edges // if (ml != undefined) { // ml?.linkPrediction?.forEach((link) => { // if (allNodeIds.has(link.from) && allNodeIds.has(link.to)) { // const toAdd: LinkType = { // source: link.from, // target: link.to, // value: link.attributes.jaccard_coefficient as number, // mlEdge: true, // color: 0x000000, // }; // links.push(toAdd); // } // linkPredictionInResult = true; // }); // } // Parse normal edges const uniqueEdges = parseToUniqueEdges(queryResult.edges, false); for (let i = 0; i < uniqueEdges.length; i++) { if (allNodeIds.has(uniqueEdges[i].from) && allNodeIds.has(uniqueEdges[i].to)) { const toAdd: LinkType = { id: uniqueEdges[i].from + ':' + uniqueEdges[i].to, // TODO: this only supports one link between two nodes source: uniqueEdges[i].from, target: uniqueEdges[i].to, value: uniqueEdges[i].count, name: uniqueEdges[i].attributes.Type, mlEdge: false, color: 0x000000, attributes: uniqueEdges[i].attributes, }; ret.links[toAdd.id] = toAdd; } } //TODO: is this in use? const maxCount = links.reduce( (previousValue, currentValue) => (currentValue.value > previousValue ? currentValue.value : previousValue), -1, ); //TODO: is this in use? // Scale the value from 0 to 50 const maxLineWidth = 50; if (maxCount > maxLineWidth) { links.forEach((link) => { link.value = (link.value / maxCount) * maxLineWidth; link.value = link.value < 1 ? 1 : link.value; }); } // Graph to be returned // let toBeReturned: GraphType = { // nodes: nodes, // links: links, // linkPrediction: linkPredictionInResult, // shortestPath: shortestPathInResult, // communityDetection: communityDetectionInResult, // }; // If query with community detection; add number of clusters to the graph // const numberOfClusters = { // numberOfMlClusters: numberOfMlClusters, // }; // if (communityDetectionInResult) { // toBeReturned = { ...toBeReturned, ...numberOfClusters }; // } // return toBeReturned; return processML(ml, ret); }