UNPKG

popoto

Version:

Graph based search interface for Neo4j database.

1,461 lines (1,231 loc) 235 kB
// Copyright (c) 2018 NHOGS Interactive. (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('jquery'), require('d3')) : typeof define === 'function' && define.amd ? define(['exports', 'jquery', 'd3'], factory) : (factory((global.popoto = global.popoto || {}),global.jQuery,global.d3)); }(this, (function (exports,jQuery,d3) { 'use strict'; jQuery = jQuery && jQuery.hasOwnProperty('default') ? jQuery['default'] : jQuery; var version = "2.0.8"; var dataModel = {}; dataModel.idGen = 0; dataModel.generateId = function () { return dataModel.idGen++; }; dataModel.nodes = []; dataModel.links = []; dataModel.getRootNode = function () { return dataModel.nodes[0]; }; /** * logger module. * @module logger */ // LOGGER ----------------------------------------------------------------------------------------------------------- var logger = {}; logger.LogLevels = Object.freeze({DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3, NONE: 4}); logger.LEVEL = logger.LogLevels.NONE; logger.TRACE = false; /** * Log a message on console depending on configured log levels. * Level is define in popoto.logger.LEVEL property. * If popoto.logger.TRACE is set to true, the stack trace is also added in log. * @param logLevel Level of the message from popoto.logger.LogLevels. * @param message Message to log. */ logger.log = function (logLevel, message) { if (console && logLevel >= logger.LEVEL) { if (logger.TRACE) { message = message + "\n" + new Error().stack; } switch (logLevel) { case logger.LogLevels.DEBUG: console.log(message); break; case logger.LogLevels.INFO: console.log(message); break; case logger.LogLevels.WARN: console.warn(message); break; case logger.LogLevels.ERROR: console.error(message); break; } } }; /** * Log a message in DEBUG level. * @param message to log. */ logger.debug = function (message) { logger.log(logger.LogLevels.DEBUG, message); }; /** * Log a message in INFO level. * @param message to log. */ logger.info = function (message) { logger.log(logger.LogLevels.INFO, message); }; /** * Log a message in WARN level. * @param message to log. */ logger.warn = function (message) { logger.log(logger.LogLevels.WARN, message); }; /** * Log a message in ERROR level. * @param message to log. */ logger.error = function (message) { logger.log(logger.LogLevels.ERROR, message); }; var query = {}; /** * Define the number of results displayed in result list. */ query.MAX_RESULTS_COUNT = 100; // query.RESULTS_PAGE_NUMBER = 1; query.VALUE_QUERY_LIMIT = 100; query.USE_PARENT_RELATION = false; query.USE_RELATION_DIRECTION = true; query.COLLECT_RELATIONS_WITH_VALUES = false; query.prefilter = ""; /** * Immutable constant object to identify Neo4j internal ID */ query.NEO4J_INTERNAL_ID = Object.freeze({queryInternalName: "NEO4JID"}); /** * Function used to filter returned relations * return false if the result should be filtered out. * * @param d relation returned object * @returns {boolean} */ query.filterRelation = function (d) { return true; }; /** * Generate the query to count nodes of a label. * If the label is defined as distinct in configuration the query will count only distinct values on constraint attribute. */ query.generateTaxonomyCountQuery = function (label) { var constraintAttr = provider.node.getConstraintAttribute(label); var whereElements = []; var predefinedConstraints = provider.node.getPredefinedConstraints(label); predefinedConstraints.forEach(function (predefinedConstraint) { whereElements.push(predefinedConstraint.replace(new RegExp("\\$identifier", 'g'), "n")); }); if (constraintAttr === query.NEO4J_INTERNAL_ID) { return "MATCH (n:`" + label + "`)" + ((whereElements.length > 0) ? " WHERE " + whereElements.join(" AND ") : "") + " RETURN count(DISTINCT ID(n)) as count" } else { return "MATCH (n:`" + label + "`)" + ((whereElements.length > 0) ? " WHERE " + whereElements.join(" AND ") : "") + " RETURN count(DISTINCT n." + constraintAttr + ") as count" } }; /** * Generate Cypher query match and where elements from root node, selected node and a set of the graph links. * * @param rootNode root node in the graph. * @param selectedNode graph target node. * @param links list of links subset of the graph. * @returns {{matchElements: Array, whereElements: Array}} list of match and where elements. * @param isConstraintNeeded (used only for relation query) * @param useCustomConstraints define whether to use the custom constraints (actually it is used only for results) */ query.generateQueryElements = function (rootNode, selectedNode, links, isConstraintNeeded, useCustomConstraints) { var matchElements = []; var whereElements = []; var relationElements = []; var returnElements = []; var parameters = {}; var rootPredefinedConstraints = provider.node.getPredefinedConstraints(rootNode.label); rootPredefinedConstraints.forEach(function (predefinedConstraint) { whereElements.push(predefinedConstraint.replace(new RegExp("\\$identifier", 'g'), rootNode.internalLabel)); }); matchElements.push("(" + rootNode.internalLabel + ":`" + rootNode.label + "`)"); // Generate root node match element if (isConstraintNeeded || rootNode.immutable) { var rootValueConstraints = query.generateNodeValueConstraints(rootNode, useCustomConstraints); whereElements = whereElements.concat(rootValueConstraints.whereElements); for (var param in rootValueConstraints.parameters) { if (rootValueConstraints.parameters.hasOwnProperty(param)) { parameters[param] = rootValueConstraints.parameters[param]; } } } var relId = 0; // Generate match elements for each links links.forEach(function (l) { var sourceNode = l.source; var targetNode = l.target; var rel = "->"; if (!query.USE_RELATION_DIRECTION || targetNode.isParentRelReverse === true) { rel = "-"; } var relIdentifier = "r" + relId++; relationElements.push(relIdentifier); var predefinedConstraints = provider.node.getPredefinedConstraints(targetNode.label); predefinedConstraints.forEach(function (predefinedConstraint) { whereElements.push(predefinedConstraint.replace(new RegExp("\\$identifier", 'g'), targetNode.internalLabel)); }); if (targetNode !== selectedNode && targetNode.isNegative === true) { if (targetNode.value !== undefined && targetNode.value.length > 0) { var count = 0; targetNode.value.forEach(function (v) { var constraintAttr = provider.node.getConstraintAttribute(v.label); var param; if (count === 0) { param = targetNode.internalLabel + "_" + constraintAttr; count++; } else { param = targetNode.internalLabel + "_" + constraintAttr + count++; } whereElements.push("(NOT (" + sourceNode.internalLabel + ":`" + sourceNode.label + "`)-[:`" + l.label + "`]" + rel + "(:`" + targetNode.label + "`{" + constraintAttr + ":$" + param + "}) )"); }); } else { whereElements.push("(NOT (" + sourceNode.internalLabel + ":`" + sourceNode.label + "`)-[:`" + l.label + "`]" + rel + "(:`" + targetNode.label + "`) )"); } } else { if (query.COLLECT_RELATIONS_WITH_VALUES && targetNode === selectedNode) { returnElements.push("COLLECT(" + relIdentifier + ") AS incomingRels"); } var sourceLabelStatement = ""; if (!useCustomConstraints || provider.node.getGenerateNodeValueConstraints(sourceNode) === undefined) { sourceLabelStatement = ":`" + sourceNode.label + "`"; } var targetLabelStatement = ""; if (!useCustomConstraints || provider.node.getGenerateNodeValueConstraints(targetNode) === undefined) { targetLabelStatement = ":`" + targetNode.label + "`"; } matchElements.push("(" + sourceNode.internalLabel + sourceLabelStatement + ")-[" + relIdentifier + ":`" + l.label + "`]" + rel + "(" + targetNode.internalLabel + targetLabelStatement + ")"); } if (targetNode !== selectedNode && (isConstraintNeeded || targetNode.immutable)) { var nodeValueConstraints = query.generateNodeValueConstraints(targetNode, useCustomConstraints); whereElements = whereElements.concat(nodeValueConstraints.whereElements); for (var param in nodeValueConstraints.parameters) { if (nodeValueConstraints.parameters.hasOwnProperty(param)) { parameters[param] = nodeValueConstraints.parameters[param]; } } } }); return { "matchElements": matchElements, "whereElements": whereElements, "relationElements": relationElements, "returnElements": returnElements, "parameters": parameters }; }; /** * Generate the where and parameter statements for the nodes with value * * @param node the node to generate value constraints * @param useCustomConstraints define whether to use custom generation in popoto config */ query.generateNodeValueConstraints = function (node, useCustomConstraints) { if (useCustomConstraints && provider.node.getGenerateNodeValueConstraints(node) !== undefined) { return provider.node.getGenerateNodeValueConstraints(node)(node); } else { var parameters = {}, whereElements = []; if (node.value !== undefined && node.value.length > 0) { var constraintAttr = provider.node.getConstraintAttribute(node.label); var paramName = node.internalLabel + "_" + constraintAttr; if (node.value.length > 1) { // Generate IN constraint if (node.isNegative === undefined || !node.isNegative) { parameters[paramName] = []; node.value.forEach(function (value) { var constraintValue; if (constraintAttr === query.NEO4J_INTERNAL_ID) { constraintValue = value.internalID; } else { constraintValue = value.attributes[constraintAttr]; } parameters[paramName].push(constraintValue); }); if (constraintAttr === query.NEO4J_INTERNAL_ID) { whereElements.push("ID(" + node.internalLabel + ") IN " + "{`" + paramName + "`}"); } else { whereElements.push(node.internalLabel + "." + constraintAttr + " IN " + "{`" + paramName + "`}"); } } else { var count = 0; node.value.forEach(function (value) { var constraintValue; if (constraintAttr === query.NEO4J_INTERNAL_ID) { constraintValue = value.internalID; } else { constraintValue = value.attributes[constraintAttr]; } if (count === 0) { parameters[paramName] = constraintValue; count++; } else { parameters[paramName + count++] = constraintValue; } }); } } else { // Generate = constraint if (constraintAttr === query.NEO4J_INTERNAL_ID) { parameters[paramName] = node.value[0].internalID; } else { parameters[paramName] = node.value[0].attributes[constraintAttr]; } if (node.isNegative === undefined || !node.isNegative) { var operator = "="; if (constraintAttr === query.NEO4J_INTERNAL_ID) { whereElements.push("ID(" + node.internalLabel + ") " + operator + " " + "{`" + paramName + "`}"); } else { whereElements.push(node.internalLabel + "." + constraintAttr + " " + operator + " " + "{`" + paramName + "`}"); } } } } return { parameters: parameters, whereElements: whereElements } } }; /** * Filter links to get only paths from root to leaf containing a value or being the selectedNode. * All other paths in the graph containing no value are ignored. * * @param rootNode root node of the graph. * @param targetNode node in the graph target of the query. * @param initialLinks list of links repreasenting the graph to filter. * @returns {Array} list of relevant links. */ query.getRelevantLinks = function (rootNode, targetNode, initialLinks) { var links = initialLinks.slice(); var filteredLinks = []; var finalLinks = []; // Filter all links to keep only those containing a value or being the selected node. links.forEach(function (l) { if ((l.target.value !== undefined && l.target.value.length > 0) || l.target === targetNode || l.target.isNegative) { filteredLinks.push(l); } }); // All the filtered links are removed from initial links list. filteredLinks.forEach(function (l) { links.splice(links.indexOf(l), 1); }); // Then all the intermediate links up to the root node are added to get only the relevant links. filteredLinks.forEach(function (fl) { var sourceNode = fl.source; var search = true; while (search) { var intermediateLink = null; links.forEach(function (l) { if (l.target === sourceNode) { intermediateLink = l; } }); if (intermediateLink === null) { // no intermediate links needed search = false; } else { if (intermediateLink.source === rootNode) { finalLinks.push(intermediateLink); links.splice(links.indexOf(intermediateLink), 1); search = false; } else { finalLinks.push(intermediateLink); links.splice(links.indexOf(intermediateLink), 1); sourceNode = intermediateLink.source; } } } }); return filteredLinks.concat(finalLinks); }; /** * Get the list of link defining the complete path from node to root. * All other links are ignored. * * @param node The node where to start in the graph. * @param links */ query.getLinksToRoot = function (node, links) { var pathLinks = []; var targetNode = node; while (targetNode !== dataModel.getRootNode()) { var nodeLink; for (var i = 0; i < links.length; i++) { var link = links[i]; if (link.target === targetNode) { nodeLink = link; break; } } if (nodeLink) { pathLinks.push(nodeLink); targetNode = nodeLink.source; } } return pathLinks; }; /** * Generate a Cypher query to retrieve the results matching the current graph. * * @param isGraph * @returns {{statement: string, parameters: (*|{})}} */ query.generateResultQuery = function (isGraph) { var rootNode = dataModel.getRootNode(); var queryElements = query.generateQueryElements(rootNode, rootNode, query.getRelevantLinks(rootNode, rootNode, dataModel.links), true, true); var queryMatchElements = queryElements.matchElements, queryWhereElements = queryElements.whereElements, queryRelationElements = queryElements.relationElements, queryReturnElements = [], queryEndElements = [], queryParameters = queryElements.parameters; // Sort results by specified attribute var resultOrderByAttribute = provider.node.getResultOrderByAttribute(rootNode.label); if (resultOrderByAttribute) { var order = provider.node.isResultOrderAscending(rootNode.label) ? "ASC" : "DESC"; queryEndElements.push("ORDER BY " + resultOrderByAttribute + " " + order); } queryEndElements.push("LIMIT " + query.MAX_RESULTS_COUNT); var resultAttributes = provider.node.getReturnAttributes(rootNode.label); if (!isGraph) { for (var i = 0; i < resultAttributes.length; i++) { var attribute = resultAttributes[i]; if (attribute === query.NEO4J_INTERNAL_ID) { queryReturnElements.push("ID(" + rootNode.internalLabel + ") AS " + query.NEO4J_INTERNAL_ID.queryInternalName); } else { queryReturnElements.push(rootNode.internalLabel + "." + attribute + " AS " + attribute); } } } else { // Only return relations queryReturnElements.push(rootNode.internalLabel); queryRelationElements.forEach( function (el) { queryReturnElements.push(el); } ); } var queryStatement = "MATCH " + queryMatchElements.join(", ") + ((queryWhereElements.length > 0) ? " WHERE " + queryWhereElements.join(" AND ") : "") + " RETURN DISTINCT " + queryReturnElements.join(", ") + " " + queryEndElements.join(" "); // Filter the query if defined in config var queryStructure = provider.node.filterResultQuery(rootNode.label, { statement: queryStatement, matchElements: queryMatchElements, whereElements: queryWhereElements, withElements: [], returnElements: queryReturnElements, endElements: queryEndElements, parameters: queryParameters }); queryStructure.statement = query.prefilter + queryStructure.statement; return queryStructure; }; /** * Generate a cypher query to the get the node count, set as parameter matching the current graph. * * @param countedNode the counted node * @returns {string} the node count cypher query */ query.generateNodeCountQuery = function (countedNode) { var queryElements = query.generateQueryElements(dataModel.getRootNode(), countedNode, query.getRelevantLinks(dataModel.getRootNode(), countedNode, dataModel.links), true, true); var queryMatchElements = queryElements.matchElements, queryWhereElements = queryElements.whereElements, queryReturnElements = [], queryEndElements = [], queryParameters = queryElements.parameters; var countAttr = provider.node.getConstraintAttribute(countedNode.label); if (countAttr === query.NEO4J_INTERNAL_ID) { queryReturnElements.push("count(DISTINCT ID(" + countedNode.internalLabel + ")) as count"); } else { queryReturnElements.push("count(DISTINCT " + countedNode.internalLabel + "." + countAttr + ") as count"); } var queryStatement = "MATCH " + queryMatchElements.join(", ") + ((queryWhereElements.length > 0) ? " WHERE " + queryWhereElements.join(" AND ") : "") + " RETURN " + queryReturnElements.join(", "); // Filter the query if defined in config var queryStructure = provider.node.filterNodeCountQuery(countedNode, { statement: queryStatement, matchElements: queryMatchElements, whereElements: queryWhereElements, returnElements: queryReturnElements, endElements: queryEndElements, parameters: queryParameters }); return { statement: query.prefilter + queryStructure.statement, parameters: queryStructure.parameters }; }; /** * Generate a Cypher query from the graph model to get all the possible values for the targetNode element. * * @param targetNode node in the graph to get the values. * @returns {string} the query to execute to get all the values of targetNode corresponding to the graph. */ query.generateNodeValueQuery = function (targetNode) { var rootNode = dataModel.getRootNode(); var queryElements = query.generateQueryElements(rootNode, targetNode, query.getRelevantLinks(rootNode, targetNode, dataModel.links), true, false); var queryMatchElements = queryElements.matchElements, queryWhereElements = queryElements.whereElements, queryReturnElements = [], queryEndElements = [], queryParameters = queryElements.parameters; // Sort results by specified attribute var valueOrderByAttribute = provider.node.getValueOrderByAttribute(targetNode.label); if (valueOrderByAttribute) { var order = provider.node.isValueOrderAscending(targetNode.label) ? "ASC" : "DESC"; queryEndElements.push("ORDER BY " + valueOrderByAttribute + " " + order); } queryEndElements.push("LIMIT " + query.VALUE_QUERY_LIMIT); var resultAttributes = provider.node.getReturnAttributes(targetNode.label); var constraintAttribute = provider.node.getConstraintAttribute(targetNode.label); for (var i = 0; i < resultAttributes.length; i++) { if (resultAttributes[i] === query.NEO4J_INTERNAL_ID) { queryReturnElements.push("ID(" + targetNode.internalLabel + ") AS " + query.NEO4J_INTERNAL_ID.queryInternalName); } else { queryReturnElements.push(targetNode.internalLabel + "." + resultAttributes[i] + " AS " + resultAttributes[i]); } } // Add count return attribute on root node var rootConstraintAttr = provider.node.getConstraintAttribute(rootNode.label); if (rootConstraintAttr === query.NEO4J_INTERNAL_ID) { queryReturnElements.push("count(DISTINCT ID(" + rootNode.internalLabel + ")) AS count"); } else { queryReturnElements.push("count(DISTINCT " + rootNode.internalLabel + "." + rootConstraintAttr + ") AS count"); } if (query.COLLECT_RELATIONS_WITH_VALUES) { queryElements.returnElements.forEach(function (re) { queryReturnElements.push(re); }); } var queryStatement = "MATCH " + queryMatchElements.join(", ") + ((queryWhereElements.length > 0) ? " WHERE " + queryWhereElements.join(" AND ") : "") + " RETURN " + queryReturnElements.join(", ") + " " + queryEndElements.join(" "); // Filter the query if defined in config var queryStructure = provider.node.filterNodeValueQuery(targetNode, { statement: queryStatement, matchElements: queryMatchElements, whereElements: queryWhereElements, returnElements: queryReturnElements, endElements: queryEndElements, parameters: queryParameters }); return { statement: query.prefilter + queryStructure.statement, parameters: queryStructure.parameters }; }; /** * Generate a Cypher query to retrieve all the relation available for a given node. * * @param targetNode * @returns {string} */ query.generateNodeRelationQuery = function (targetNode) { var linksToRoot = query.getLinksToRoot(targetNode, dataModel.links); var queryElements = query.generateQueryElements(dataModel.getRootNode(), targetNode, linksToRoot, false, false); var queryMatchElements = queryElements.matchElements, queryWhereElements = queryElements.whereElements, queryReturnElements = [], queryEndElements = [], queryParameters = queryElements.parameters; var rel = query.USE_RELATION_DIRECTION ? "->" : "-"; queryMatchElements.push("(" + targetNode.internalLabel + ":`" + targetNode.label + "`)-[r]" + rel + "(x)"); queryReturnElements.push("type(r) AS label"); if (query.USE_PARENT_RELATION) { queryReturnElements.push("head(labels(x)) AS target"); } else { queryReturnElements.push("last(labels(x)) AS target"); } queryReturnElements.push("count(r) AS count"); queryEndElements.push("ORDER BY count(r) DESC"); var queryStatement = "MATCH " + queryMatchElements.join(", ") + ((queryWhereElements.length > 0) ? " WHERE " + queryWhereElements.join(" AND ") : "") + " RETURN " + queryReturnElements.join(", ") + " " + queryEndElements.join(" "); // Filter the query if defined in config var queryStructure = provider.node.filterNodeRelationQuery(targetNode, { statement: queryStatement, matchElements: queryMatchElements, whereElements: queryWhereElements, returnElements: queryReturnElements, endElements: queryEndElements, parameters: queryParameters }); return { statement: query.prefilter + queryStructure.statement, parameters: queryStructure.parameters }; }; /** * logger module. * @module logger */ var rest = {}; /** * Default REST URL used to call Neo4j server with cypher queries to execute. * This property should be updated to access to your own server. * @type {string} */ rest.CYPHER_URL = "http://localhost:7474/db/data/transaction/commit"; rest.WITH_CREDENTIALS = false; /** * Create JQuery ajax POST request to access Neo4j REST API. * * @param data data object containing Cypher query. * @param url url where to post the data, default to popoto.rest.CYPHER_URL property. * @returns {*} the JQuery ajax request object. */ rest.post = function (data, url) { var strData = JSON.stringify(data); logger.info("REST POST:" + strData); var settings = { type: "POST", beforeSend: function (request) { if (rest.AUTHORIZATION) { request.setRequestHeader("Authorization", rest.AUTHORIZATION); } }, contentType: "application/json" }; if (data !== undefined) { settings.data = strData; } if (rest.WITH_CREDENTIALS === true) { settings.xhrFields = { withCredentials: true }; } var postURL = rest.CYPHER_URL; if (url !== undefined) { postURL = url; } return jQuery.ajax(postURL, settings); }; rest.response = { parse: function (data) { logger.debug(JSON.stringify((data))); var parsedData = []; if (data !== undefined && data.hasOwnProperty("results") && data.results.length > 0 && !(data.hasOwnProperty("errors") && data.errors.length > 0)) { parsedData = data.results.map(function (queryResults) { var results = []; for (var x = 0; x < queryResults.data.length; x++) { var obj = {}; for (var i = 0; i < queryResults.columns.length; i++) { obj[queryResults.columns[i]] = queryResults.data[x].row[i]; } results.push(obj); } return results; }); } logger.info(JSON.stringify((parsedData))); return parsedData } }; var result = {}; result.containerId = "popoto-results"; result.hasChanged = true; result.resultCountListeners = []; result.resultListeners = []; result.graphResultListeners = []; result.RESULTS_PAGE_SIZE = 10; result.TOTAL_COUNT = false; /** * Register a listener to the result count event. * This listener will be called on evry result change with total result count. */ result.onTotalResultCount = function (listener) { result.resultCountListeners.push(listener); }; result.onResultReceived = function (listener) { result.resultListeners.push(listener); }; result.onGraphResultReceived = function (listener) { result.graphResultListeners.push(listener); }; /** * Parse REST returned Graph data and generate a list of nodes and edges. * * @param data * @returns {{nodes: Array, edges: Array}} */ result.parseGraphResultData = function (data) { var nodes = {}, edges = {}; data.results[1].data.forEach(function (row) { row.graph.nodes.forEach(function (n) { if (!nodes.hasOwnProperty(n.id)) { nodes[n.id] = n; } }); row.graph.relationships.forEach(function (r) { if (!edges.hasOwnProperty(r.id)) { edges[r.id] = r; } }); }); var nodesArray = [], edgesArray = []; for (var n in nodes) { if (nodes.hasOwnProperty(n)) { nodesArray.push(nodes[n]); } } for (var e in edges) { if (edges.hasOwnProperty(e)) { edgesArray.push(edges[e]); } } return {nodes: nodesArray, edges: edgesArray}; }; result.updateResults = function () { if (result.hasChanged) { var resultsIndex = {}; var index = 0; // Abort any old running request before starting a new one if (result.resultsXhr !== undefined) { result.resultsXhr.abort(); } var resultQuery = query.generateResultQuery(); result.lastGeneratedQuery = resultQuery; var postData = { "statements": [ { "statement": resultQuery.statement, "parameters": resultQuery.parameters, "resultDataContents": ["row"] } ] }; resultsIndex["results"] = index++; // Add Graph result query if listener found if (result.graphResultListeners.length > 0) { var graphQuery = query.generateResultQuery(true); result.lastGeneratedQuery = graphQuery; postData.statements.push( { "statement": graphQuery.statement, "parameters": graphQuery.parameters, "resultDataContents": ["row", "graph"] }); resultsIndex["graph"] = index++; } if (result.TOTAL_COUNT === true && result.resultCountListeners.length > 0) { var nodeCountQuery = query.generateNodeCountQuery(dataModel.getRootNode()); postData.statements.push( { "statement": nodeCountQuery.statement, "parameters": nodeCountQuery.parameters } ); resultsIndex["total"] = index++; } logger.info("Results ==>"); result.resultsXhr = rest.post(postData) .done(function (response) { logger.info("<== Results"); var parsedData = rest.response.parse(response); var resultObjects = parsedData[resultsIndex["results"]].map(function (d, i) { return { "resultIndex": i, "label": dataModel.getRootNode().label, "attributes": d }; }); result.lastResults = resultObjects; if (resultsIndex.hasOwnProperty("total")) { var count = parsedData[resultsIndex["total"]][0].count; // Notify listeners result.resultCountListeners.forEach(function (listener) { listener(count); }); } // Notify listeners result.resultListeners.forEach(function (listener) { listener(resultObjects); }); if (result.graphResultListeners.length > 0) { var graphResultObjects = result.parseGraphResultData(response); result.graphResultListeners.forEach(function (listener) { listener(graphResultObjects); }); } // Update displayed results only if needed () if (result.isActive) { // Clear all results var results = d3.select("#" + result.containerId).selectAll(".ppt-result").data([]); results.exit().remove(); // Update data results = d3.select("#" + result.containerId).selectAll(".ppt-result").data(resultObjects.slice(0, result.RESULTS_PAGE_SIZE), function (d) { return d.resultIndex; }); // Add new elements var pElmt = results.enter() .append("div") .attr("class", "ppt-result") .attr("id", function (d) { return "popoto-result-" + d.resultIndex; }); // Generate results with providers pElmt.each(function (d) { provider.node.getDisplayResults(d.label)(d3.select(this)); }); } result.hasChanged = false; }) .fail(function (xhr, textStatus, errorThrown) { if (textStatus !== "abort") { logger.error(textStatus + ": error while accessing Neo4j server on URL:\"" + rest.CYPHER_URL + "\" defined in \"rest.CYPHER_URL\" property: " + errorThrown); // Notify listeners result.resultListeners.forEach(function (listener) { listener([]); }); } else { logger.info("<=X= Results - Aborted!"); } }); } }; result.updateResultsCount = function () { // Update result counts with root node count if (result.resultCountListeners.length > 0) { result.resultCountListeners.forEach(function (listener) { listener(dataModel.getRootNode().count); }); } }; result.generatePreQuery = function () { var p = {"ids": []}; result.lastResults.forEach(function (d) { p.ids.push(d.attributes.id); }); return { query: "MATCH (d) WHERE d.id IN $ids WITH d", param: p }; }; /** * Main function to call to use Popoto.js. * This function will create all the HTML content based on available IDs in the page. * * @param startParam Root label or graph schema to use in the graph query builder. */ //TODO add instance creation + config function start(startParam) { logger.info("Popoto " + version + " start on " + startParam); graph.mainLabel = startParam; if (rest.CYPHER_URL === undefined) { logger.error("popoto.rest.CYPHER_URL is not set but is required."); } else { // TODO introduce component generator mechanism instead for future plugin extensions checkHtmlComponents(); if (taxonomy.isActive) { taxonomy.createTaxonomyPanel(); } if (graph.isActive) { graph.createGraphArea(); graph.createForceLayout(); if (typeof startParam === 'string' || startParam instanceof String) { var labelSchema = provider.node.getSchema(startParam); if (labelSchema !== undefined) { graph.addSchema(labelSchema); } else { graph.addRootNode(startParam); } } else { graph.loadSchema(startParam); } } if (queryviewer.isActive) { queryviewer.createQueryArea(); } if (cypherviewer.isActive) { cypherviewer.createQueryArea(); } if (graph.USE_VORONOI_LAYOUT === true) { graph.voronoi.extent([[-popoto.graph.getSVGWidth(), -popoto.graph.getSVGWidth()], [popoto.graph.getSVGWidth() * 2, popoto.graph.getSVGHeight() * 2]]); } update(); } } /** * Check in the HTML page the components to generate. */ function checkHtmlComponents() { var graphHTMLContainer = d3.select("#" + graph.containerId); var taxonomyHTMLContainer = d3.select("#" + taxonomy.containerId); var queryHTMLContainer = d3.select("#" + queryviewer.containerId); var cypherHTMLContainer = d3.select("#" + cypherviewer.containerId); var resultsHTMLContainer = d3.select("#" + result.containerId); if (graphHTMLContainer.empty()) { logger.debug("The page doesn't contain a container with ID = \"" + graph.containerId + "\" no graph area will be generated. This ID is defined in graph.containerId property."); graph.isActive = false; } else { graph.isActive = true; } if (taxonomyHTMLContainer.empty()) { logger.debug("The page doesn't contain a container with ID = \"" + taxonomy.containerId + "\" no taxonomy filter will be generated. This ID is defined in taxonomy.containerId property."); taxonomy.isActive = false; } else { taxonomy.isActive = true; } if (queryHTMLContainer.empty()) { logger.debug("The page doesn't contain a container with ID = \"" + queryviewer.containerId + "\" no query viewer will be generated. This ID is defined in queryviewer.containerId property."); queryviewer.isActive = false; } else { queryviewer.isActive = true; } if (cypherHTMLContainer.empty()) { logger.debug("The page doesn't contain a container with ID = \"" + cypherviewer.containerId + "\" no cypher query viewer will be generated. This ID is defined in cypherviewer.containerId property."); cypherviewer.isActive = false; } else { cypherviewer.isActive = true; } if (resultsHTMLContainer.empty()) { logger.debug("The page doesn't contain a container with ID = \"" + result.containerId + "\" no result area will be generated. This ID is defined in result.containerId property."); result.isActive = false; } else { result.isActive = true; } } /** * Function to call to update all the generated elements including svg graph, query viewer and generated results. */ function update() { updateGraph(); // Do not update if rootNode is not valid. var root = dataModel.getRootNode(); if (!root || root.label === undefined) { return; } if (queryviewer.isActive) { queryviewer.updateQuery(); } if (cypherviewer.isActive) { cypherviewer.updateQuery(); } // Results are updated only if needed. // If id found in html page or if result listeners have been added. // In this case the query must be executed. if (result.isActive || result.resultListeners.length > 0 || result.resultCountListeners.length > 0 || result.graphResultListeners.length > 0) { result.updateResults(); } } /** * Function to call to update the graph only. */ function updateGraph() { if (graph.isActive) { // Starts the D3.js force simulation. // This method must be called when the layout is first created, after assigning the nodes and links. // In addition, it should be called again whenever the nodes or links change. graph.link.updateLinks(); graph.node.updateNodes(); // Force simulation restart graph.force.nodes(dataModel.nodes); graph.force.force("link").links(dataModel.links); graph.force.alpha(1).restart(); } } var taxonomy = {}; taxonomy.containerId = "popoto-taxonomy"; /** * Create the taxonomy panel HTML elements. */ taxonomy.createTaxonomyPanel = function () { var htmlContainer = d3.select("#" + taxonomy.containerId); var taxoUL = htmlContainer.append("ul") .attr("class", "ppt-taxo-ul"); var data = taxonomy.generateTaxonomiesData(); var taxos = taxoUL.selectAll(".taxo").data(data); var taxoli = taxos.enter().append("li") .attr("id", function (d) { return d.id }) .attr("class", "ppt-taxo-li") .attr("value", function (d) { return d.label; }); taxoli.append("span") .attr("class", function (d) { return "ppt-icon " + provider.taxonomy.getCSSClass(d.label, "span-icon"); }) .html("&nbsp;"); taxoli.append("span") .attr("class", "ppt-label") .text(function (d) { return provider.taxonomy.getTextValue(d.label); }); taxoli.append("span") .attr("class", "ppt-count"); // Add an on click event on the taxonomy to clear the graph and set this label as root taxoli.on("click", taxonomy.onClick); taxonomy.addTaxonomyChildren(taxoli); // The count is updated for each labels. var flattenData = []; data.forEach(function (d) { flattenData.push(d); if (d.children) { taxonomy.flattenChildren(d, flattenData); } }); if (!graph.DISABLE_COUNT) { taxonomy.updateCount(flattenData); } }; /** * Recursive function to flatten data content. * */ taxonomy.flattenChildren = function (d, vals) { d.children.forEach(function (c) { vals.push(c); if (c.children) { vals.concat(taxonomy.flattenChildren(c, vals)); } }); }; /** * Updates the count number on a taxonomy. * * @param taxonomyData */ taxonomy.updateCount = function (taxonomyData) { var statements = []; taxonomyData.forEach(function (taxo) { statements.push( { "statement": query.generateTaxonomyCountQuery(taxo.label) } ); }); (function (taxonomies) { logger.info("Count taxonomies ==>"); rest.post( { "statements": statements }) .done(function (response) { logger.info("<== Count taxonomies"); var parsedData = rest.response.parse(response); for (var i = 0; i < taxonomies.length; i++) { var count = parsedData[i][0].count; d3.select("#" + taxonomies[i].id) .select(".ppt-count") .text(" (" + count + ")"); } }) .fail(function (xhr, textStatus, errorThrown) { logger.error(textStatus + ": error while accessing Neo4j server on URL:\"" + rest.CYPHER_URL + "\" defined in \"rest.CYPHER_URL\" property: " + errorThrown); d3.select("#popoto-taxonomy") .selectAll(".ppt-count") .text(" (0)"); }); })(taxonomyData); }; /** * Recursively generate the taxonomy children elements. * * @param selection */ taxonomy.addTaxonomyChildren = function (selection) { selection.each(function (d) { var li = d3.select(this); var children = d.children; if (d.children) { var childLi = li.append("ul") .attr("class", "ppt-taxo-sub-ul") .selectAll("li") .data(children) .enter() .append("li") .attr("id", function (d) { return d.id }) .attr("class", "ppt-taxo-sub-li") .attr("value", function (d) { return d.label; }); childLi.append("span") .attr("class", function (d) { return "ppt-icon " + provider.taxonomy.getCSSClass(d.label, "span-icon"); }) .html("&nbsp;"); childLi.append("span") .attr("class", "ppt-label") .text(function (d) { return provider.taxonomy.getTextValue(d.label); }); childLi.append("span") .attr("class", "ppt-count"); childLi.on("click", taxonomy.onClick); taxonomy.addTaxonomyChildren(childLi); } }); }; taxonomy.onClick = function () { d3.event.stopPropagation(); var label = this.attributes.value.value; dataModel.nodes.length = 0; dataModel.links.length = 0; // Reinitialize internal label generator graph.node.internalLabels = {}; update(); graph.mainLabel = label; if (provider.node.getSchema(label) !== undefined) { graph.addSchema(provider.node.getSchema(label)); } else { graph.addRootNode(label); } graph.hasGraphChanged = true; result.hasChanged = true; graph.ignoreCount = false; update(); tools.center(); }; /** * Parse the list of label providers and return a list of data object containing only searchable labels. * @returns {Array} */ taxonomy.generateTaxonomiesData = function () { var id = 0; var data = []; // Retrieve root providers (searchable and without parent) for (var label in provider.node.Provider) { if (provider.node.Provider.hasOwnProperty(label)) { if (provider.node.getProperty(label, "isSearchable") && !provider.node.Provider[label].parent) { data.push({ "label": label, "id": "popoto-lbl-" + id++ }); } } } // Add children data for each provider with children. data.forEach(function (d) { if (provider.node.getProvider(d.label).hasOwnProperty("children")) { id = taxonomy.addChildrenData(d, id); } }); return data; }; /** * Add children providers data. * @param parentData * @param id */ taxonomy.addChildrenData = function (parentData, id) { parentData.children = []; provider.node.getProvider(parentData.label).children.forEach(function (d) { var childProvider = provider.node.getProvider(d); var childData = { "label": d, "id": "popoto-lbl-" + id++ }; if (childProvider.hasOwnProperty("children")) { id = taxonomy.addChildrenData(childData, id); } if (provider.node.getProperty(d, "isSearchable")) { parentData.children.push(childData); } }); return id; }; // TOOLS ----------------------------------------------------------------------------------------------------------- var tools = {}; // TODO introduce plugin mechanism to add tools tools.CENTER_GRAPH = true; tools.RESET_GRAPH = true; tools.SAVE_GRAPH = false; tools.TOGGLE_TAXONOMY = false; tools.TOGGLE_FULL_SCREEN = true; tools.TOGGLE_VIEW_RELATION = true; tools.TOGGLE_FIT_TEXT = true; /** * Reset the graph to display the root node only. */ tools.reset = function () { dataModel.nodes.length = 0; dataModel.links.length = 0; // Reinitialize internal label generator graph.node.internalLabels = {}; if (typeof graph.mainLabel === 'string' || graph.mainLabel instanceof String) { if (provider.node.getSchema(graph.mainLabel) !== undefined) { graph.addSchema(provider.node.getSchema(graph.mainLabel)); } else { graph.addRootNode(graph.mainLabel); } } else { graph.loadSchema(graph.mainLabel); } graph.hasGraphChanged = true; result.hasChanged = true; update(); tools.center(); }; /** * Reset zoom and center the view on svg center. */ tools.center = function () { graph.svgTag.transition().call(graph.zoom.transform, d3.zoomIdentity); }; /** * Show, hide taxonomy panel. */ tools.toggleTaxonomy = function () { var taxo = d3.select("#" + taxonomy.containerId); if (taxo.filter(".disabled").empty()) { taxo.classed("disabled", true); } else { taxo.classed("disabled", false); } graph.centerRootNode(); }; /** * Enable, disable text fitting on nodes. */ tools.toggleFitText = function () { graph.USE_FIT_TEXT = !graph.USE_FIT_TEXT; graph.node.updateNodes(); }; /** * Show, hide relation donuts. */ tools.toggleViewRelation = function () { graph.DISABLE_RELATION = !graph.DISABLE_RELATION; d3.selectAll(".ppt-g-node-background").classed("hide", graph.DISABLE_RELATION); graph.tick(); }; tools.toggleFullScreen = function () { var elem = document.getElementById(graph.containerId); if (!document.fullscreenElement && // alternative standard method !document.mozFullScreenElement && !document.webkitFullscreenElement && !document.msFullscreenElement) { // current working methods if (elem.requestFullscreen) { elem.requestFullscreen(); } else if (elem.msRequestFullscreen) { elem.msRequestFullscreen(); } else if (elem.mozRequestFullScreen) { elem.mozRequestFullScreen(); } else if (elem.webkitRequestFullscreen) { elem.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT); } } else { if (document.exitFullscreen) { document.exitFullscreen(); } else if (document.msExitFullscreen) { document.msExitFullscreen(); } else if (document.mozCancelFullScreen) { document.mozCancelFullScreen(); } else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); } } }; var toolbar = {}; toolbar.TOOL_TAXONOMY = "Show/hide taxonomy panel"; toolbar.TOOL_RELATION = "Show/hide relation"; toolbar.TOOL_CENTER = "Center view"; toolbar.TOOL_FULL_SCREEN = "Full screen"; toolbar.TOOL_RESET = "Reset graph"; toolbar.TOOL_SAVE = "Save graph"; toolbar.TOOL_FIT_TEXT = "Fit text in nodes"; toolbar.render = function (container) { var toolbar = container .append("div") .attr("class", "ppt-toolbar"); if (tools.TOGGLE_VIEW_RELATION) { toolbar.append("span") .attr("id", "popoto-toggle-relation") .attr("class", "ppt