Mercurial > hg > NetworkVis
diff popoto_dev/src/js/popoto.js @ 12:d67c5ad47709
implementation with dropdown popup, unfinished
author | alistair |
---|---|
date | Fri, 02 Oct 2015 01:08:46 -0400 |
parents | |
children |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/popoto_dev/src/js/popoto.js Fri Oct 02 01:08:46 2015 -0400 @@ -0,0 +1,3766 @@ +/** + * Popoto.js is a JavaScript library built with D3.js providing a graph based search interface generated in HTML and SVG usable on any modern browser. + * This library generates an interactive graph query builder into any website or web based application to create dynamic queries on Neo4j databases and display the results. + * + * Copyright (C) 2014-2015 Frederic Ciminera + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * contact@popotojs.com + */ +popoto = function () { + var popoto = { + version: "0.0-a6" + }; + + /** + * Main function to call to use Popoto.js. + * This function will create all the HTML content based on available IDs in the page. + * popoto.graph.containerId for the graph query builder. + * popoto.queryviewer.containerId for the query viewer. + * + * @param label Root label to use in the graph query builder. + */ + popoto.start = function (label) { + popoto.logger.info("Popoto " + popoto.version + " start."); + + if (typeof popoto.rest.CYPHER_URL == 'undefined') { + popoto.logger.error("popoto.rest.CYPHER_URL is not set but this property is required."); + } else { + // TODO introduce component generator mechanism instead for future plugin extensions + popoto.checkHtmlComponents(); + + if (popoto.taxonomy.isActive) { + popoto.taxonomy.createTaxonomyPanel(); + } + + if (popoto.graph.isActive) { + popoto.graph.createGraphArea(); + popoto.graph.createForceLayout(); + popoto.graph.addRootNode(label); + } + + if (popoto.queryviewer.isActive) { + popoto.queryviewer.createQueryArea(); + } + + popoto.update(); + } + }; + + /** + * Check in the HTML page the components to generate. + */ + popoto.checkHtmlComponents = function () { + var graphHTMLContainer = d3.select("#" + popoto.graph.containerId); + var taxonomyHTMLContainer = d3.select("#" + popoto.taxonomy.containerId); + var queryHTMLContainer = d3.select("#" + popoto.queryviewer.containerId); + var cypherHTMLContainer = d3.select("#" + popoto.cypherviewer.containerId); + var resultsHTMLContainer = d3.select("#" + popoto.result.containerId); + + if (graphHTMLContainer.empty()) { + popoto.logger.debug("The page doesn't contain a container with ID = \"" + popoto.graph.containerId + "\" no graph area will be generated. This ID is defined in popoto.graph.containerId property."); + popoto.graph.isActive = false; + } else { + popoto.graph.isActive = true; + } + + if (taxonomyHTMLContainer.empty()) { + popoto.logger.debug("The page doesn't contain a container with ID = \"" + popoto.taxonomy.containerId + "\" no taxonomy filter will be generated. This ID is defined in popoto.taxonomy.containerId property."); + popoto.taxonomy.isActive = false; + } else { + popoto.taxonomy.isActive = true; + } + + if (queryHTMLContainer.empty()) { + popoto.logger.debug("The page doesn't contain a container with ID = \"" + popoto.queryviewer.containerId + "\" no query viewer will be generated. This ID is defined in popoto.queryviewer.containerId property."); + popoto.queryviewer.isActive = false; + } else { + popoto.queryviewer.isActive = true; + } + + if (cypherHTMLContainer.empty()) { + popoto.logger.debug("The page doesn't contain a container with ID = \"" + popoto.cypherviewer.containerId + "\" no cypher query viewer will be generated. This ID is defined in popoto.cypherviewer.containerId property."); + popoto.cypherviewer.isActive = false; + } else { + popoto.cypherviewer.isActive = true; + } + + if (resultsHTMLContainer.empty()) { + popoto.logger.debug("The page doesn't contain a container with ID = \"" + popoto.result.containerId + "\" no result area will be generated. This ID is defined in popoto.result.containerId property."); + popoto.result.isActive = false; + } else { + popoto.result.isActive = true; + } + }; + + /** + * Function to call to update all the generated elements including svg graph, query viewer and generated results. + */ + popoto.update = function () { + popoto.updateGraph(); + + if (popoto.queryviewer.isActive) { + popoto.queryviewer.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 (popoto.result.isActive || popoto.result.resultListeners.length > 0 || popoto.result.resultCountListeners.length > 0) { + popoto.result.updateResults(); + } + }; + + /** + * Function to call to update the graph only. + */ + popoto.updateGraph = function () { + if (popoto.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. + popoto.graph.force.start(); + popoto.graph.link.updateLinks(); + popoto.graph.node.updateNodes(); + } + }; + + // REST ------------------------------------------------------------------------------------------------------------ + popoto.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} + */ + popoto.rest.CYPHER_URL = "http://localhost:7474/db/data/transaction/commit"; + + /** + * Create JQuery ajax POST request to access Neo4j REST API. + * + * @param data data object containing Cypher query + * @returns {*} the JQuery ajax request object. + */ + popoto.rest.post = function (data) { + var strData = JSON.stringify(data); + popoto.logger.info("REST POST:" + strData); + + return $.ajax({ + type: "POST", + beforeSend: function (request) { + if (popoto.rest.AUTHORIZATION) { + request.setRequestHeader("Authorization", popoto.rest.AUTHORIZATION); + } + }, + url: popoto.rest.CYPHER_URL, + contentType: "application/json", + data: strData + }); + }; + + // LOGGER ----------------------------------------------------------------------------------------------------------- + popoto.logger = {}; + popoto.logger.LogLevels = Object.freeze({DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3, NONE: 4}); + popoto.logger.LEVEL = popoto.logger.LogLevels.NONE; + popoto.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. + */ + popoto.logger.log = function (logLevel, message) { + if (console && logLevel >= popoto.logger.LEVEL) { + if (popoto.logger.TRACE) { + message = message + "\n" + new Error().stack + } + switch (logLevel) { + case popoto.logger.LogLevels.DEBUG: + console.log(message); + break; + case popoto.logger.LogLevels.INFO: + console.log(message); + break; + case popoto.logger.LogLevels.WARN: + console.warn(message); + break; + case popoto.logger.LogLevels.ERROR: + console.error(message); + break; + } + } + }; + + /** + * Log a message in DEBUG level. + * @param message to log. + */ + popoto.logger.debug = function (message) { + popoto.logger.log(popoto.logger.LogLevels.DEBUG, message); + }; + + /** + * Log a message in INFO level. + * @param message to log. + */ + popoto.logger.info = function (message) { + popoto.logger.log(popoto.logger.LogLevels.INFO, message); + }; + + /** + * Log a message in WARN level. + * @param message to log. + */ + popoto.logger.warn = function (message) { + popoto.logger.log(popoto.logger.LogLevels.WARN, message); + }; + + /** + * Log a message in ERROR level. + * @param message to log. + */ + popoto.logger.error = function (message) { + popoto.logger.log(popoto.logger.LogLevels.ERROR, message); + }; + + // TAXONOMIES ----------------------------------------------------------------------------------------------------- + + popoto.taxonomy = {}; + popoto.taxonomy.containerId = "popoto-taxonomy"; + + /** + * Create the taxonomy panel HTML elements. + */ + popoto.taxonomy.createTaxonomyPanel = function () { + var htmlContainer = d3.select("#" + popoto.taxonomy.containerId); + + var taxoUL = htmlContainer.append("ul"); + + var data = popoto.taxonomy.generateTaxonomiesData(); + + var taxos = taxoUL.selectAll(".taxo").data(data); + + var taxoli = taxos.enter().append("li") + .attr("id", function (d) { + return d.id + }) + .attr("value", function (d) { + return d.label; + }); + + taxoli.append("img") + .attr("src", "css/image/category.png") + .attr("width", "24") + .attr("height", "24"); + + taxoli.append("span") + .attr("class", "ppt-label") + .text(function (d) { + return popoto.provider.getTaxonomyTextValue(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", popoto.taxonomy.onClick); + + popoto.taxonomy.addTaxonomyChildren(taxoli); + + // The count is updated for each labels. + var flattenData = []; + data.forEach(function (d) { + flattenData.push(d); + if (d.children) { + popoto.taxonomy.flattenChildren(d, flattenData); + } + }); + + popoto.taxonomy.updateCount(flattenData); + }; + + /** + * Recursive function to flatten data content. + * + */ + popoto.taxonomy.flattenChildren = function (d, vals) { + d.children.forEach(function (c) { + vals.push(c); + if (c.children) { + vals.concat(popoto.taxonomy.flattenChildren(c, vals)); + } + }); + }; + + /** + * Updates the count number on a taxonomy. + * + * @param taxonomyData + */ + popoto.taxonomy.updateCount = function (taxonomyData) { + var statements = []; + + taxonomyData.forEach(function (taxo) { + statements.push( + { + "statement": popoto.query.generateTaxonomyCountQuery(taxo.label) + } + ); + }); + + (function (taxonomies) { + popoto.logger.info("Count taxonomies ==> "); + popoto.rest.post( + { + "statements": statements + }) + .done(function (returnedData) { + for (var i = 0; i < taxonomies.length; i++) { + var count = returnedData.results[i].data[0].row[0]; + d3.select("#" + taxonomies[i].id) + .select(".ppt-count") + .text(" (" + count + ")"); + } + }) + .fail(function (xhr, textStatus, errorThrown) { + popoto.logger.error(textStatus + ": error while accessing Neo4j server on URL:\"" + popoto.rest.CYPHER_URL + "\" defined in \"popoto.rest.CYPHER_URL\" property: " + errorThrown); + d3.select("#popoto-taxonomy") + .selectAll(".ppt-count") + .text(" (0)"); + }); + })(taxonomyData); + }; + + /** + * Recursively generate the taxonomy children elements. + * + * @param selection + */ + popoto.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") + .selectAll("li") + .data(children) + .enter() + .append("li") + .attr("id", function (d) { + return d.id + }) + .attr("value", function (d) { + return d.label; + }); + + childLi.append("img") + .attr("src", "css/image/category.png") + .attr("width", "24") + .attr("height", "24"); + + childLi.append("span") + .attr("class", "ppt-label") + .text(function (d) { + return popoto.provider.getTaxonomyTextValue(d.label); + }); + + childLi.append("span") + .attr("class", "ppt-count"); + + childLi.on("click", popoto.taxonomy.onClick); + + popoto.taxonomy.addTaxonomyChildren(childLi); + } + + }); + }; + + popoto.taxonomy.onClick = function () { + d3.event.stopPropagation(); + + // Workaround to avoid click on taxonomies if root node has not yet been initialized + // If it contains a count it mean all the initialization has been done + var root = popoto.graph.getRootNode(); + if (root.count === undefined) { + return; + } + + var label = this.attributes.value.value; + + while (popoto.graph.force.nodes().length > 0) { + popoto.graph.force.nodes().pop(); + } + + while (popoto.graph.force.links().length > 0) { + popoto.graph.force.links().pop(); + } + + // Reinitialize internal label generator + popoto.graph.node.internalLabels = {}; + + popoto.update(); + popoto.graph.addRootNode(label); + popoto.graph.hasGraphChanged = true; + popoto.result.hasChanged = true; + popoto.update(); + popoto.tools.center(); + }; + + /** + * Parse the list of label providers and return a list of data object containing only searchable labels. + * @returns {Array} + */ + popoto.taxonomy.generateTaxonomiesData = function () { + var id = 0; + var data = []; + + // Retrieve root providers (searchable and without parent) + for (var label in popoto.provider.nodeProviders) { + if (popoto.provider.nodeProviders.hasOwnProperty(label)) { + if (popoto.provider.getProperty(label, "isSearchable") && !popoto.provider.nodeProviders[label].parent) { + data.push({ + "label": label, + "id": "popoto-lbl-" + id++ + }); + } + } + } + + // Add children data for each provider with children. + data.forEach(function (d) { + if (popoto.provider.getProvider(d.label).hasOwnProperty("children")) { + id = popoto.taxonomy.addChildrenData(d, id); + } + }); + + return data; + }; + + /** + * Add children providers data. + * @param parentData + * @param id + */ + popoto.taxonomy.addChildrenData = function (parentData, id) { + parentData.children = []; + + popoto.provider.getProvider(parentData.label).children.forEach(function (d) { + var childProvider = popoto.provider.getProvider(d); + var childData = { + "label": d, + "id": "popoto-lbl-" + id++ + }; + if (childProvider.hasOwnProperty("children")) { + id = popoto.taxonomy.addChildrenData(childData, id); + } + if (popoto.provider.getProperty(d, "isSearchable")) { + parentData.children.push(childData); + } + }); + + return id; + }; + + // TOOLS ----------------------------------------------------------------------------------------------------------- + + popoto.tools = {}; + // TODO introduce plugin mechanism to add tools + popoto.tools.CENTER_GRAPH = true; + popoto.tools.RESET_GRAPH = true; + popoto.tools.TOGGLE_TAXONOMY = true; + popoto.tools.TOGGLE_FULL_SCREEN = true; + + /** + * Reset all the graph to display the root node only. + */ + popoto.tools.reset = function () { + var label = popoto.graph.getRootNode().label; + + while (popoto.graph.force.nodes().length > 0) { + popoto.graph.force.nodes().pop(); + } + + while (popoto.graph.force.links().length > 0) { + popoto.graph.force.links().pop(); + } + + // Reinitialize internal label generator + popoto.graph.node.internalLabels = {}; + + popoto.update(); + popoto.graph.addRootNode(label); + popoto.graph.hasGraphChanged = true; + popoto.result.hasChanged = true; + popoto.update(); + popoto.tools.center(); + }; + + /** + * Reset zoom and center the view on svg center. + */ + popoto.tools.center = function () { + popoto.graph.zoom.translate([0, 0]).scale(1); + popoto.graph.svg.transition().attr("transform", "translate(" + popoto.graph.zoom.translate() + ")" + " scale(" + popoto.graph.zoom.scale() + ")"); + }; + + /** + * Show, hide taxonomy panel. + */ + popoto.tools.toggleTaxonomy = function () { + var taxo = d3.select("#" + popoto.taxonomy.containerId); + if (taxo.filter(".disabled").empty()) { + taxo.classed("disabled", true); + } else { + taxo.classed("disabled", false); + } + }; + + popoto.tools.toggleFullScreen = function () { + + var elem = document.getElementById(popoto.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(); + } + } + }; + // GRAPH ----------------------------------------------------------------------------------------------------------- + + popoto.graph = {}; + + /** + * ID of the HTML component where the graph query builder elements will be generated in. + * @type {string} + */ + popoto.graph.containerId = "popoto-graph"; + popoto.graph.hasGraphChanged = true; + // Defines the min and max level of zoom available in graph query builder. + popoto.graph.zoom = d3.behavior.zoom().scaleExtent([0.1, 10]); + popoto.graph.WHEEL_ZOOM_ENABLED = true; + popoto.graph.TOOL_TAXONOMY = "Show/hide taxonomy panel"; + popoto.graph.TOOL_CENTER = "Center view"; + popoto.graph.TOOL_FULL_SCREEN = "Full screen"; + popoto.graph.TOOL_RESET = "Reset graph"; + + /** + * Define the list of listenable events on graph. + */ + popoto.graph.Events = Object.freeze({NODE_ROOT_ADD: "root.node.add", NODE_EXPAND_RELATIONSHIP: "node.expandRelationship"}); + + /** + * Generates all the HTML and SVG element needed to display the graph query builder. + * Everything will be generated in the container with id defined by popoto.graph.containerId. + */ + popoto.graph.createGraphArea = function () { + + var htmlContainer = d3.select("#" + popoto.graph.containerId); + + var toolbar = htmlContainer + .append("div") + .attr("class", "ppt-toolbar"); + + if (popoto.tools.RESET_GRAPH) { + toolbar.append("span") + .attr("id", "popoto-reset-menu") + .attr("class", "ppt-menu reset") + .attr("title", popoto.graph.TOOL_RESET) + .on("click", popoto.tools.reset); + } + + if (popoto.taxonomy.isActive && popoto.tools.TOGGLE_TAXONOMY) { + toolbar.append("span") + .attr("id", "popoto-taxonomy-menu") + .attr("class", "ppt-menu taxonomy") + .attr("title", popoto.graph.TOOL_TAXONOMY) + .on("click", popoto.tools.toggleTaxonomy); + } + + if (popoto.tools.CENTER_GRAPH) { + toolbar.append("span") + .attr("id", "popoto-center-menu") + .attr("class", "ppt-menu center") + .attr("title", popoto.graph.TOOL_CENTER) + .on("click", popoto.tools.center); + } + + if (popoto.tools.TOGGLE_FULL_SCREEN) { + toolbar.append("span") + .attr("id", "popoto-fullscreen-menu") + .attr("class", "ppt-menu fullscreen") + .attr("title", popoto.graph.TOOL_FULL_SCREEN) + .on("click", popoto.tools.toggleFullScreen); + } + + var svgTag = htmlContainer.append("svg").call(popoto.graph.zoom.on("zoom", popoto.graph.rescale)); + + svgTag.on("dblclick.zoom", null) + .attr("class", "ppt-svg-graph"); + + if (!popoto.graph.WHEEL_ZOOM_ENABLED) { + // Disable mouse wheel events. + svgTag.on("wheel.zoom", null) + .on("mousewheel.zoom", null); + } + + popoto.graph.svg = svgTag.append('svg:g'); + + // Create two separated area for links and nodes + // Links and nodes are separated in a dedicated "g" element + // and nodes are generated after links to ensure that nodes are always on foreground. + popoto.graph.svg.append("g").attr("id", popoto.graph.link.gID); + popoto.graph.svg.append("g").attr("id", popoto.graph.node.gID); + + // This listener is used to center the root node in graph during a window resize. + // TODO can the listener be limited on the parent container only? + window.addEventListener('resize', popoto.graph.centerRootNode); + }; + + popoto.graph.centerRootNode = function () { + popoto.graph.getRootNode().px = popoto.graph.getSVGWidth() / 2; + popoto.graph.getRootNode().py = popoto.graph.getSVGHeight() / 2; + popoto.update(); + }; + + /** + * Get the actual width of the SVG element containing the graph query builder. + * @returns {number} + */ + popoto.graph.getSVGWidth = function () { + if (typeof popoto.graph.svg == 'undefined' || popoto.graph.svg.empty()) { + popoto.logger.debug("popoto.graph.svg is undefined or empty."); + return 0; + } else { + return document.getElementById(popoto.graph.containerId).clientWidth; + } + }; + + /** + * Get the actual height of the SVG element containing the graph query builder. + * @returns {number} + */ + popoto.graph.getSVGHeight = function () { + if (typeof popoto.graph.svg == 'undefined' || popoto.graph.svg.empty()) { + popoto.logger.debug("popoto.graph.svg is undefined or empty."); + return 0; + } else { + return document.getElementById(popoto.graph.containerId).clientHeight; + } + }; + + /** + * Function to call on SVG zoom event to update the svg transform attribute. + */ + popoto.graph.rescale = function () { + var trans = d3.event.translate, + scale = d3.event.scale; + + popoto.graph.svg.attr("transform", + "translate(" + trans + ")" + + " scale(" + scale + ")"); + }; + + /****************************** + * Default parameters used to configure D3.js force layout. + * These parameter can be modified to change graph behavior. + ******************************/ + popoto.graph.LINK_DISTANCE = 150; + popoto.graph.LINK_STRENGTH = 1; + popoto.graph.FRICTION = 0.8; + popoto.graph.CHARGE = -1400; + popoto.graph.THETA = 0.8; + popoto.graph.GRAVITY = 0.0; + + /** + * Contains the list off root node add listeners. + */ + popoto.graph.rootNodeAddListeners = []; + popoto.graph.nodeExpandRelationsipListeners = []; + + /** + * Create the D3.js force layout for the graph query builder. + */ + popoto.graph.createForceLayout = function () { + + popoto.graph.force = d3.layout.force() + .size([popoto.graph.getSVGWidth(), popoto.graph.getSVGHeight()]) + .linkDistance(function (d) { + if (d.type === popoto.graph.link.LinkTypes.RELATION) { + return ((3 * popoto.graph.LINK_DISTANCE) / 2); + } else { + return popoto.graph.LINK_DISTANCE; + } + }) + .linkStrength(function (d) { + if (d.linkStrength) { + return d.linkStrength; + } else { + return popoto.graph.LINK_STRENGTH; + } + }) + .friction(popoto.graph.FRICTION) + .charge(function (d) { + if (d.charge) { + return d.charge; + } else { + return popoto.graph.CHARGE; + } + }) + .theta(popoto.graph.THETA) + .gravity(popoto.graph.GRAVITY) + .on("tick", popoto.graph.tick); // Function called on every position update done by D3.js + + // Disable event propagation on drag to avoid zoom and pan issues + popoto.graph.force.drag() + .on("dragstart", function (d) { + d3.event.sourceEvent.stopPropagation(); + }) + .on("dragend", function (d) { + d3.event.sourceEvent.stopPropagation(); + }); + }; + + /** + * Add a listener to the specified event. + * + * @param event name of the event to add the listener. + * @param listener the listener to add. + */ + popoto.graph.on = function (event, listener) { + if (event === popoto.graph.Events.NODE_ROOT_ADD) { + popoto.graph.rootNodeAddListeners.push(listener); + } + if (event === popoto.graph.Events.NODE_EXPAND_RELATIONSHIP) { + popoto.graph.nodeExpandRelationsipListeners.push(listener); + } + }; + + /** + * Adds graph root nodes using the label set as parameter. + * All the other nodes should have been removed first to avoid inconsistent data. + * + * @param label label of the node to add as root. + */ + popoto.graph.addRootNode = function (label) { + if (popoto.graph.force.nodes().length > 0) { + popoto.logger.debug("popoto.graph.addRootNode is called but the graph is not empty."); + } + + popoto.graph.force.nodes().push({ + "id": "0", + "type": popoto.graph.node.NodeTypes.ROOT, + // x and y coordinates are set to the center of the SVG area. + // These coordinate will never change at runtime except if the window is resized. + "x": popoto.graph.getSVGWidth() / 2, + "y": popoto.graph.getSVGHeight() / 2, + "label": label, + // The node is fixed to always remain in the center of the svg area. + // This property should not be changed at runtime to avoid issues with the zoom and pan. + "fixed": true, + // Label used internally to identify the node. + // This label is used for example as cypher query identifier. + "internalLabel": popoto.graph.node.generateInternalLabel(label) + }); + + // Notify listeners + popoto.graph.rootNodeAddListeners.forEach(function (listener) { + listener(popoto.graph.getRootNode()); + }); + }; + + /** + * Get the graph root node. + * @returns {*} + */ + popoto.graph.getRootNode = function () { + return popoto.graph.force.nodes()[0]; + }; + + /** + * Function to call on D3.js force layout tick event. + * This function will update the position of all links and nodes elements in the graph with the force layout computed coordinate. + */ + popoto.graph.tick = function () { + popoto.graph.svg.selectAll("#" + popoto.graph.link.gID + " > g") + .selectAll("path") + .attr("d", function (d) { + var parentAngle = popoto.graph.computeParentAngle(d.target); + var targetX = d.target.x + (popoto.graph.link.RADIUS * Math.cos(parentAngle)), + targetY = d.target.y - (popoto.graph.link.RADIUS * Math.sin(parentAngle)); + + var sourceX = d.source.x - (popoto.graph.link.RADIUS * Math.cos(parentAngle)), + sourceY = d.source.y + (popoto.graph.link.RADIUS * Math.sin(parentAngle)); + + if (d.source.x <= d.target.x) { + return "M" + sourceX + " " + sourceY + "L" + targetX + " " + targetY; + } else { + return "M" + targetX + " " + targetY + "L" + sourceX + " " + sourceY; + } + }); + + popoto.graph.svg.selectAll("#" + popoto.graph.node.gID + " > g") + .attr("transform", function (d) { + return "translate(" + (d.x) + "," + (d.y) + ")"; + }); + }; + + // LINKS ----------------------------------------------------------------------------------------------------------- + popoto.graph.link = {}; + + /** + * Defines the radius around the node to start link drawing. + * If set to 0 links will start from the middle of the node. + */ + popoto.graph.link.RADIUS = 25; + + // ID of the g element in SVG graph containing all the link elements. + popoto.graph.link.gID = "popoto-glinks"; + + /** + * Defines the different type of link. + * RELATION is a relation link between two nodes. + * VALUE is a link between a generic node and a value. + */ + popoto.graph.link.LinkTypes = Object.freeze({RELATION: 0, VALUE: 1}); + + /** + * Function to call to update the links after modification in the model. + * This function will update the graph with all removed, modified or added links using d3.js mechanisms. + */ + popoto.graph.link.updateLinks = function () { + popoto.graph.link.svgLinkElements = popoto.graph.svg.select("#" + popoto.graph.link.gID).selectAll("g"); + popoto.graph.link.updateData(); + popoto.graph.link.removeElements(); + popoto.graph.link.addNewElements(); + popoto.graph.link.updateElements(); + }; + + /** + * Update the links element with data coming from popoto.graph.force.links(). + */ + popoto.graph.link.updateData = function () { + popoto.graph.link.svgLinkElements = popoto.graph.link.svgLinkElements.data(popoto.graph.force.links(), function (d) { + return d.id; + }); + }; + + /** + * Clean links elements removed from the list. + */ + popoto.graph.link.removeElements = function () { + popoto.graph.link.svgLinkElements.exit().remove(); + }; + + /** + * Create new elements. + */ + popoto.graph.link.addNewElements = function () { + + var newLinkElements = popoto.graph.link.svgLinkElements.enter().append("g") + .attr("class", "ppt-glink") + .on("mouseover", popoto.graph.link.mouseOverLink) + .on("mouseout", popoto.graph.link.mouseOutLink); + + newLinkElements.append("path"); + + newLinkElements.append("text") + .attr("text-anchor", "middle") + .attr("dy", "-4") + .append("textPath") + .attr("class", "ppt-textPath") + .attr("startOffset", "50%"); + + }; + + /** + * Update all the elements (new + modified) + */ + popoto.graph.link.updateElements = function () { + popoto.graph.link.svgLinkElements + .attr("id", function (d) { + return "ppt-glink_" + d.id; + }); + + popoto.graph.link.svgLinkElements.selectAll("path") + .attr("id", function (d) { + return "ppt-path_" + d.id + }) + .attr("class", function (d) { + if (d.type === popoto.graph.link.LinkTypes.VALUE) { + return "ppt-link-value"; + } else { + if (d.target.count == 0) { + return "ppt-link-relation disabled"; + } else { + if (d.target.value !== undefined) { + return "ppt-link-relation value"; + } else { + return "ppt-link-relation"; + } + } + } + }); + + // Due to a bug on webkit browsers (as of 30/01/2014) textPath cannot be selected + // To workaround this issue the selection is done with its associated css class + popoto.graph.link.svgLinkElements.selectAll("text") + .attr("id", function (d) { + return "ppt-text_" + d.id + }) + .attr("class", function (d) { + if (d.type === popoto.graph.link.LinkTypes.VALUE) { + return "ppt-link-text-value"; + } else { + if (d.target.count == 0) { + return "ppt-link-text-relation disabled"; + } else { + if (d.target.value !== undefined) { + return "ppt-link-text-relation value"; + } else { + return "ppt-link-text-relation"; + } + } + } + }) + .selectAll(".ppt-textPath") + .attr("id", function (d) { + return "ppt-textpath_" + d.id + }) + .attr("xlink:href", function (d) { + return "#ppt-path_" + d.id + }) + .text(function (d) { + return popoto.provider.getLinkTextValue(d); + }); + }; + + /** + * Function called when mouse is over the link. + * This function is used to change the CSS class on hover of the link and query viewer element. + * + * TODO try to introduce event instead of directly access query spans here. This could be used in future extensions. + */ + popoto.graph.link.mouseOverLink = function () { + d3.select(this).select("path").classed("ppt-link-hover", true); + d3.select(this).select("text").classed("ppt-link-hover", true); + + if (popoto.queryviewer.isActive) { + var hoveredLink = d3.select(this).data()[0]; + + popoto.queryviewer.queryConstraintSpanElements.filter(function (d) { + return d.ref === hoveredLink; + }).classed("hover", true); + popoto.queryviewer.querySpanElements.filter(function (d) { + return d.ref === hoveredLink; + }).classed("hover", true); + } + }; + + /** + * Function called when mouse goes out of the link. + * This function is used to reinitialize the CSS class of the link and query viewer element. + */ + popoto.graph.link.mouseOutLink = function () { + d3.select(this).select("path").classed("ppt-link-hover", false); + d3.select(this).select("text").classed("ppt-link-hover", false); + + if (popoto.queryviewer.isActive) { + var hoveredLink = d3.select(this).data()[0]; + + popoto.queryviewer.queryConstraintSpanElements.filter(function (d) { + return d.ref === hoveredLink; + }).classed("hover", false); + popoto.queryviewer.querySpanElements.filter(function (d) { + return d.ref === hoveredLink; + }).classed("hover", false); + } + }; + + //////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // NODES ----------------------------------------------------------------------------------------------------------- + + popoto.graph.node = {}; + + // ID of the g element in SVG graph containing all the link elements. + popoto.graph.node.gID = "popoto-gnodes"; + + // Node ellipse size used by default for text nodes. + popoto.graph.node.ELLIPSE_RX = 50; + popoto.graph.node.ELLIPSE_RY = 25; + popoto.graph.node.TEXT_Y = 8; + popoto.graph.node.BACK_CIRCLE_R = 70; + // Define the max number of character displayed in ellipses. + popoto.graph.node.NODE_MAX_CHARS = 11; + + // Number of nodes displayed per page during value selection. + popoto.graph.node.PAGE_SIZE = 10; + + // Count box default size + popoto.graph.node.CountBox = {x: 16, y: 33, w: 52, h: 19}; + + // Store choose node state to avoid multiple node expand at the same time + popoto.graph.node.chooseWaiting = false; + + /** + * Defines the list of possible nodes. + * ROOT: Node used as graph root. It is the target of the query. Only one node of this type should be available in graph. + * CHOOSE: Nodes defining a generic node label. From these node is is possible to select a value or explore relations. + * VALUE: Unique node containing a value constraint. Usually replace CHOOSE nodes once a value as been selected. + * GROUP: Empty node used to group relations. No value can be selected but relations can be explored. These nodes doesn't have count. + */ + popoto.graph.node.NodeTypes = Object.freeze({ROOT: 0, CHOOSE: 1, VALUE: 2, GROUP: 3}); + + // Variable used to generate unique id for each new nodes. + popoto.graph.node.idgen = 0; + + // Used to generate unique internal labels used for example as identifier in Cypher query. + popoto.graph.node.internalLabels = {}; + + /** + * Create a normalized identifier from a node label. + * Multiple calls with the same node label will generate different unique identifier. + * + * @param nodeLabel + * @returns {string} + */ + popoto.graph.node.generateInternalLabel = function (nodeLabel) { + var label = nodeLabel.toLowerCase().replace(/ /g, ''); + + if (label in popoto.graph.node.internalLabels) { + popoto.graph.node.internalLabels[label] = popoto.graph.node.internalLabels[label] + 1; + } else { + popoto.graph.node.internalLabels[label] = 0; + return label; + } + + return label + popoto.graph.node.internalLabels[label]; + }; + + /** + * Update Nodes SVG elements using D3.js update mechanisms. + */ + popoto.graph.node.updateNodes = function () { + if (!popoto.graph.node.svgNodeElements) { + popoto.graph.node.svgNodeElements = popoto.graph.svg.select("#" + popoto.graph.node.gID).selectAll("g"); + } + popoto.graph.node.updateData(); + popoto.graph.node.removeElements(); + popoto.graph.node.addNewElements(); + popoto.graph.node.updateElements(); + }; + + /** + * Update node data with changes done in popoto.graph.force.nodes() model. + */ + popoto.graph.node.updateData = function () { + popoto.graph.node.svgNodeElements = popoto.graph.node.svgNodeElements.data(popoto.graph.force.nodes(), function (d) { + return d.id; + }); + + if (popoto.graph.hasGraphChanged) { + popoto.graph.node.updateCount(); + popoto.graph.hasGraphChanged = false; + } + }; + + /** + * Update nodes count by executing a query for every nodes with the new graph structure. + */ + popoto.graph.node.updateCount = function () { + + var statements = []; + + var counterNodes = popoto.graph.force.nodes() + .filter(function (d) { + return d.type !== popoto.graph.node.NodeTypes.VALUE && d.type !== popoto.graph.node.NodeTypes.GROUP; + }); + + counterNodes.forEach(function (node) { + var query = popoto.query.generateNodeCountCypherQuery(node); + statements.push( + { + "statement": query + } + ); + }); + + popoto.logger.info("Count nodes ==> "); + popoto.rest.post( + { + "statements": statements + }) + .done(function (returnedData) { + + if (returnedData.errors && returnedData.errors.length > 0) { + popoto.logger.error("Cypher query error:" + JSON.stringify(returnedData.errors)); + } + + if (returnedData.results && returnedData.results.length > 0) { + for (var i = 0; i < counterNodes.length; i++) { + counterNodes[i].count = returnedData.results[i].data[0].row[0]; + } + } else { + counterNodes.forEach(function (node) { + node.count = 0; + }); + } + popoto.graph.node.updateElements(); + popoto.graph.link.updateElements(); + }) + .fail(function (xhr, textStatus, errorThrown) { + popoto.logger.error(textStatus + ": error while accessing Neo4j server on URL:\"" + popoto.rest.CYPHER_URL + "\" defined in \"popoto.rest.CYPHER_URL\" property: " + errorThrown); + counterNodes.forEach(function (node) { + node.count = 0; + }); + popoto.graph.node.updateElements(); + popoto.graph.link.updateElements(); + }); + }; + + /** + * Remove old elements. + * Should be called after updateData. + */ + popoto.graph.node.removeElements = function () { + var toRemove = popoto.graph.node.svgNodeElements.exit(); + + // Nodes without parent are simply removed. + toRemove.filter(function (d) { + return !d.parent; + }).remove(); + + // Nodes with a parent are removed with an animation (nodes are collapsed to their parents before being removed) + toRemove.filter(function (d) { + return d.parent; + }).transition().duration(300).attr("transform", function (d) { + return "translate(" + d.parent.x + "," + d.parent.y + ")"; + }).remove(); + }; + + /** + * Add all new elements. + * Only the skeleton of new nodes are added custom data will be added during the element update phase. + * Should be called after updateData and before updateElements. + */ + popoto.graph.node.addNewElements = function () { + var gNewNodeElements = popoto.graph.node.svgNodeElements.enter() + .append("g") + .on("click", popoto.graph.node.nodeClick) + .on("mouseover", popoto.graph.node.mouseOverNode) + .on("mouseout", popoto.graph.node.mouseOutNode); + + // Add right click on all nodes except value + gNewNodeElements.filter(function (d) { + return d.type !== popoto.graph.node.NodeTypes.VALUE; + }).on("contextmenu", popoto.graph.node.clearSelection); + + // Disable right click context menu on value nodes + gNewNodeElements.filter(function (d) { + return d.type === popoto.graph.node.NodeTypes.VALUE; + }).on("contextmenu", function () { + // Disable context menu on + d3.event.preventDefault(); + }); + + // Most browser will generate a tooltip if a title is specified for the SVG element + // TODO Introduce an SVG tooltip instead? + gNewNodeElements.append("title").attr("class", "ppt-svg-title"); + + // Nodes are composed of 3 layouts and skeleton are created here. + popoto.graph.node.addBackgroundElements(gNewNodeElements); + popoto.graph.node.addMiddlegroundElements(gNewNodeElements); + popoto.graph.node.addForegroundElements(gNewNodeElements); + }; + + /** + * Create the background for a new node element. + * The background of a node is defined by a circle not visible by default (fill-opacity set to 0) but can be used to highlight a node with animation on this attribute. + * This circle also define the node zone that can receive events like mouse clicks. + * + * @param gNewNodeElements + */ + popoto.graph.node.addBackgroundElements = function (gNewNodeElements) { + var background = gNewNodeElements + .append("g") + .attr("class", "ppt-g-node-background"); + + background.append("circle") + .attr("class", function (d) { + var cssClass = "ppt-node-background-circle"; + if (d.value !== undefined) { + cssClass = cssClass + " selected-value"; + } else if (d.type === popoto.graph.node.NodeTypes.ROOT) { + cssClass = cssClass + " root"; + } else if (d.type === popoto.graph.node.NodeTypes.CHOOSE) { + cssClass = cssClass + " choose"; + } else if (d.type === popoto.graph.node.NodeTypes.VALUE) { + cssClass = cssClass + " value"; + } else if (d.type === popoto.graph.node.NodeTypes.GROUP) { + cssClass = cssClass + " group"; + } + + return cssClass; + }) + .style("fill-opacity", 0) + .attr("r", popoto.graph.node.BACK_CIRCLE_R); + }; + + /** + * Create the node main elements. + * + * @param gNewNodeElements + */ + popoto.graph.node.addMiddlegroundElements = function (gNewNodeElements) { + var middle = gNewNodeElements + .append("g") + .attr("class", "ppt-g-node-middleground"); + }; + + /** + * Create the node foreground elements. + * It contains node additional elements, count or tools like navigation arrows. + * + * @param gNewNodeElements + */ + popoto.graph.node.addForegroundElements = function (gNewNodeElements) { + var foreground = gNewNodeElements + .append("g") + .attr("class", "ppt-g-node-foreground"); + + // plus sign + var gRelationship = foreground.filter(function (d) { + return d.type !== popoto.graph.node.NodeTypes.VALUE; + }).append("g").attr("class", "ppt-rel-plus-icon"); + + gRelationship.append("title") + .text("Add relationship"); + + gRelationship + .append("circle") + .attr("class", "ppt-rel-plus-background") + .attr("cx", "32") + .attr("cy", "-43") + .attr("r", "16"); + + gRelationship + .append("path") + .attr("class", "ppt-rel-plus-path") + .attr("d", "M 40,-45 35,-45 35,-50 30,-50 30,-45 25,-45 25,-40 30,-40 30,-35 35,-35 35,-40 40,-40 z"); + + gRelationship + .on("mouseover", function () { + d3.select(this).select(".ppt-rel-plus-background").transition().style("fill-opacity", 0.5); + }) + .on("mouseout", function () { + d3.select(this).select(".ppt-rel-plus-background").transition().style("fill-opacity", 0); + }) + .on("click", function () { + d3.event.stopPropagation(); // To avoid click event on svg element in background + popoto.graph.node.expandRelationship.call(this); + }); + + // Minus sign + var gMinusRelationship = foreground.filter(function (d) { + return d.type !== popoto.graph.node.NodeTypes.VALUE; + }).append("g").attr("class", "ppt-rel-minus-icon"); + + gMinusRelationship.append("title") + .text("Remove relationship"); + + gMinusRelationship + .append("circle") + .attr("class", "ppt-rel-minus-background") + .attr("cx", "32") + .attr("cy", "-43") + .attr("r", "16"); + + gMinusRelationship + .append("path") + .attr("class", "ppt-rel-minus-path") + .attr("d", "M 40,-45 25,-45 25,-40 40,-40 z"); + + gMinusRelationship + .on("mouseover", function () { + d3.select(this).select(".ppt-rel-minus-background").transition().style("fill-opacity", 0.5); + }) + .on("mouseout", function () { + d3.select(this).select(".ppt-rel-minus-background").transition().style("fill-opacity", 0); + }) + .on("click", function () { + d3.event.stopPropagation(); // To avoid click event on svg element in background + popoto.graph.node.collapseRelationship.call(this); + }); + + // Arrows icons added only for root and choose nodes + var gArrow = foreground.filter(function (d) { + return d.type === popoto.graph.node.NodeTypes.ROOT || d.type === popoto.graph.node.NodeTypes.CHOOSE; + }) + .append("g") + .attr("class", "ppt-node-foreground-g-arrows"); + + var glArrow = gArrow.append("g"); + //glArrow.append("polygon") + //.attr("points", "-53,-23 -33,-33 -33,-13"); + glArrow.append("circle") + .attr("class", "ppt-larrow") + .attr("cx", "-43") + .attr("cy", "-23") + .attr("r", "17"); + + glArrow.append("path") + .attr("class", "ppt-arrow") + .attr("d", "m -44.905361,-23 6.742,-6.742 c 0.81,-0.809 0.81,-2.135 0,-2.944 l -0.737,-0.737 c -0.81,-0.811 -2.135,-0.811 -2.945,0 l -8.835,8.835 c -0.435,0.434 -0.628,1.017 -0.597,1.589 -0.031,0.571 0.162,1.154 0.597,1.588 l 8.835,8.834 c 0.81,0.811 2.135,0.811 2.945,0 l 0.737,-0.737 c 0.81,-0.808 0.81,-2.134 0,-2.943 l -6.742,-6.743 z"); + + glArrow.on("click", function (clickedNode) { + d3.event.stopPropagation(); // To avoid click event on svg element in background + + // On left arrow click page number is decreased and node expanded to display the new page + if (clickedNode.page > 1) { + clickedNode.page--; + popoto.graph.node.collapseNode(clickedNode); + popoto.graph.node.expandNode(clickedNode); + } + }); + + var grArrow = gArrow.append("g"); + //grArrow.append("polygon") + //.attr("points", "53,-23 33,-33 33,-13"); + + grArrow.append("circle") + .attr("class", "ppt-rarrow") + .attr("cx", "43") + .attr("cy", "-23") + .attr("r", "17"); + + grArrow.append("path") + .attr("class", "ppt-arrow") + .attr("d", "m 51.027875,-24.5875 -8.835,-8.835 c -0.811,-0.811 -2.137,-0.811 -2.945,0 l -0.738,0.737 c -0.81,0.81 -0.81,2.136 0,2.944 l 6.742,6.742 -6.742,6.742 c -0.81,0.81 -0.81,2.136 0,2.943 l 0.737,0.737 c 0.81,0.811 2.136,0.811 2.945,0 l 8.835,-8.836 c 0.435,-0.434 0.628,-1.017 0.597,-1.588 0.032,-0.569 -0.161,-1.152 -0.596,-1.586 z"); + + grArrow.on("click", function (clickedNode) { + d3.event.stopPropagation(); // To avoid click event on svg element in background + + if (clickedNode.page * popoto.graph.node.PAGE_SIZE < clickedNode.count) { + clickedNode.page++; + popoto.graph.node.collapseNode(clickedNode); + popoto.graph.node.expandNode(clickedNode); + } + }); + + // Count box + var countForeground = foreground.filter(function (d) { + return d.type !== popoto.graph.node.NodeTypes.GROUP; + }); + + countForeground + .append("rect") + .attr("x", popoto.graph.node.CountBox.x) + .attr("y", popoto.graph.node.CountBox.y) + .attr("width", popoto.graph.node.CountBox.w) + .attr("height", popoto.graph.node.CountBox.h) + .attr("class", "ppt-count-box"); + + countForeground + .append("text") + .attr("x", 42) + .attr("y", 48) + .attr("text-anchor", "middle") + .attr("class", "ppt-count-text"); + }; + + /** + * Updates all elements. + */ + popoto.graph.node.updateElements = function () { + popoto.graph.node.svgNodeElements.attr("id", function (d) { + return "popoto-gnode_" + d.id; + }); + + popoto.graph.node.svgNodeElements + .selectAll(".ppt-svg-title") + .text(function (d) { + return popoto.provider.getTextValue(d); + }); + + popoto.graph.node.svgNodeElements.filter(function (n) { + return n.type !== popoto.graph.node.NodeTypes.ROOT + }).call(popoto.graph.force.drag); + + popoto.graph.node.updateBackgroundElements(); + popoto.graph.node.updateMiddlegroundElements(); + popoto.graph.node.updateForegroundElements(); + }; + + popoto.graph.node.updateBackgroundElements = function () { + popoto.graph.node.svgNodeElements.selectAll(".ppt-g-node-background") + .selectAll(".ppt-node-background-circle") + .attr("class", function (d) { + var cssClass = "ppt-node-background-circle"; + + if (d.type === popoto.graph.node.NodeTypes.VALUE) { + cssClass = cssClass + " value"; + } else if (d.type === popoto.graph.node.NodeTypes.GROUP) { + cssClass = cssClass + " group"; + } else { + if (d.value !== undefined) { + if (d.type === popoto.graph.node.NodeTypes.ROOT) { + cssClass = cssClass + " selected-root-value"; + } else if (d.type === popoto.graph.node.NodeTypes.CHOOSE) { + cssClass = cssClass + " selected-value"; + } + } else { + if (d.count == 0) { + cssClass = cssClass + " disabled"; + } else { + if (d.type === popoto.graph.node.NodeTypes.ROOT) { + cssClass = cssClass + " root"; + } else if (d.type === popoto.graph.node.NodeTypes.CHOOSE) { + cssClass = cssClass + " choose"; + } + } + } + } + + return cssClass; + }) + .attr("r", popoto.graph.node.BACK_CIRCLE_R); + }; + + /** + * Update the middle layer of nodes. + * TODO refactor node generation to allow future extensions (for example add plugin with new node types...) + */ + popoto.graph.node.updateMiddlegroundElements = function () { + + var middleG = popoto.graph.node.svgNodeElements.selectAll(".ppt-g-node-middleground"); + + // Clear all content in case node type has changed + middleG.selectAll("*").remove(); + + //------------------------------- + // Update IMAGE nodes + var imageMiddle = middleG.filter(function (d) { + return popoto.provider.getNodeDisplayType(d) === popoto.provider.NodeDisplayTypes.IMAGE; + }).append("image").attr("class", "ppt-node-image"); + + imageMiddle + .attr("width", function (d) { + return popoto.provider.getImageWidth(d); + }) + .attr("height", function (d) { + return popoto.provider.getImageHeight(d); + }) + // Center the image on node + .attr("transform", function (d) { + return "translate(" + (-popoto.provider.getImageWidth(d) / 2) + "," + (-popoto.provider.getImageHeight(d) / 2) + ")"; + }) + .attr("xlink:href", function (d) { + return popoto.provider.getImagePath(d); + }); + + //------------------------- + // Update TEXT nodes + var ellipseMiddle = middleG.filter(function (d) { + return popoto.provider.getNodeDisplayType(d) === popoto.provider.NodeDisplayTypes.TEXT; + }).append("ellipse").attr("rx", popoto.graph.node.ELLIPSE_RX).attr("ry", popoto.graph.node.ELLIPSE_RY); + + // Set class according to node type + ellipseMiddle + .attr("rx", popoto.graph.node.ELLIPSE_RX) + .attr("ry", popoto.graph.node.ELLIPSE_RY) + .attr("class", function (d) { + if (d.type === popoto.graph.node.NodeTypes.ROOT) { + if (d.value) { + return "ppt-node-ellipse selected-root-value" + } else { + if (d.count == 0) { + return "ppt-node-ellipse root disabled"; + } else { + return "ppt-node-ellipse root"; + } + } + } else if (d.type === popoto.graph.node.NodeTypes.CHOOSE) { + if (d.value) { + return "ppt-node-ellipse selected-value" + } else { + if (d.count == 0) { + return "ppt-node-ellipse choose disabled"; + } else { + return "ppt-node-ellipse choose"; + } + } + } else if (d.type === popoto.graph.node.NodeTypes.VALUE) { + return "ppt-node-ellipse value"; + } else if (d.type === popoto.graph.node.NodeTypes.GROUP) { + return "ppt-node-ellipse group"; + } + }); + + //------------------------- + // Update SVG nodes + var svgMiddle = middleG.filter(function (d) { + return popoto.provider.getNodeDisplayType(d) === popoto.provider.NodeDisplayTypes.SVG; + }).append("g") + // Add D3.js nested data with all paths required to render the svg element. + .selectAll("path").data(function (d) { + return popoto.provider.getSVGPaths(d); + }); + + // Update nested data elements + svgMiddle.exit().remove(); + + svgMiddle.enter().append("path"); + + middleG + .selectAll("path") + .attr("d", function (d) { + return d.d; + }) + .attr("class", function (d) { + return d["class"]; + }); + + // Update text + var textMiddle = middleG.filter(function (d) { + return popoto.provider.isTextDisplayed(d); + }).append('text') + .attr('x', 0) + .attr('y', popoto.graph.node.TEXT_Y) + .attr('text-anchor', 'middle'); + textMiddle + .attr('y', popoto.graph.node.TEXT_Y) + .attr("class", function (d) { + switch (d.type) { + case popoto.graph.node.NodeTypes.CHOOSE: + if (d.value === undefined) { + if (d.count == 0) { + return "ppt-node-text-choose disabled"; + } else { + return "ppt-node-text-choose"; + } + } else { + return "ppt-node-text-choose selected-value"; + } + case popoto.graph.node.NodeTypes.GROUP: + return "ppt-node-text-group"; + case popoto.graph.node.NodeTypes.ROOT: + if (d.value === undefined) { + if (d.count == 0) { + return "ppt-node-text-root disabled"; + } else { + return "ppt-node-text-root"; + } + } else { + return "ppt-node-text-root selected-value"; + } + case popoto.graph.node.NodeTypes.VALUE: + return "ppt-node-text-value"; + } + }) + .text(function (d) { + if (popoto.provider.isTextDisplayed(d)) { + return popoto.provider.getTextValue(d); + } else { + return ""; + } + }); + }; + + /** + * Updates the foreground elements + */ + popoto.graph.node.updateForegroundElements = function () { + + // Updates browse arrows status + var gArrows = popoto.graph.node.svgNodeElements.selectAll(".ppt-g-node-foreground") + .selectAll(".ppt-node-foreground-g-arrows"); + gArrows.classed("active", function (d) { + return d.valueExpanded && d.data && d.data.length > popoto.graph.node.PAGE_SIZE; + }); + + gArrows.selectAll(".ppt-larrow").classed("enabled", function (d) { + return d.page > 1; + }); + + gArrows.selectAll(".ppt-rarrow").classed("enabled", function (d) { + if (d.data) { + var count = d.data.length; + return d.page * popoto.graph.node.PAGE_SIZE < count; + } else { + return false; + } + }); + + // Update count box class depending on node type + var gForegrounds = popoto.graph.node.svgNodeElements.selectAll(".ppt-g-node-foreground"); + + gForegrounds.selectAll(".ppt-count-box").filter(function (d) { + return d.type !== popoto.graph.node.NodeTypes.CHOOSE; + }).classed("root", true); + + gForegrounds.selectAll(".ppt-count-box").filter(function (d) { + return d.type === popoto.graph.node.NodeTypes.CHOOSE; + }).classed("value", true); + + gForegrounds.selectAll(".ppt-count-box").classed("disabled", function (d) { + return d.count == 0; + }); + + gForegrounds.selectAll(".ppt-count-text") + .text(function (d) { + if (d.count != null) { + return d.count; + } else { + return "..."; + } + }) + .classed("disabled", function (d) { + return d.count == 0; + }); + + // Hide/Show plus icon (set disabled CSS class) if node already has been expanded. + gForegrounds.selectAll(".ppt-rel-plus-icon") + .classed("disabled", function (d) { + return d.linkExpanded || d.count == 0 || d.linkCount == 0; + }); + + gForegrounds.selectAll(".ppt-rel-minus-icon") + .classed("disabled", function (d) { + return (!d.linkExpanded) || d.count == 0 || d.linkCount == 0; + }); + + }; + + /** + * Handle the mouse over event on nodes. + */ + popoto.graph.node.mouseOverNode = function () { + d3.event.preventDefault(); + + // TODO don't work on IE (nodes unstable) find another way to move node in foreground on mouse over? + // d3.select(this).moveToFront(); + + d3.select(this).select(".ppt-g-node-background").selectAll("circle").transition().style("fill-opacity", 0.5); + + if (popoto.queryviewer.isActive) { + // Get the hovered node data + var hoveredNode = d3.select(this).data()[0]; + + // Hover the node in query + popoto.queryviewer.queryConstraintSpanElements.filter(function (d) { + return d.ref === hoveredNode; + }).classed("hover", true); + popoto.queryviewer.querySpanElements.filter(function (d) { + return d.ref === hoveredNode; + }).classed("hover", true); + } + }; + + /** + * Handle mouse out event on nodes. + */ + popoto.graph.node.mouseOutNode = function () { + d3.event.preventDefault(); + + d3.select(this).select(".ppt-g-node-background").selectAll("circle").transition().style("fill-opacity", 0); + + if (popoto.queryviewer.isActive) { + // Get the hovered node data + var hoveredNode = d3.select(this).data()[0]; + + // Remove hover class on node. + popoto.queryviewer.queryConstraintSpanElements.filter(function (d) { + return d.ref === hoveredNode; + }).classed("hover", false); + popoto.queryviewer.querySpanElements.filter(function (d) { + return d.ref === hoveredNode; + }).classed("hover", false); + } + }; + + /** + * Handle the click event on nodes. + */ + popoto.graph.node.nodeClick = function () { + var clickedNode = d3.select(this).data()[0]; // Clicked node data + popoto.logger.debug("nodeClick (" + clickedNode.label + ")"); + + if (clickedNode.type === popoto.graph.node.NodeTypes.VALUE) { + popoto.graph.node.valueNodeClick(clickedNode); + } else if (clickedNode.type === popoto.graph.node.NodeTypes.CHOOSE || clickedNode.type === popoto.graph.node.NodeTypes.ROOT) { + if (clickedNode.valueExpanded) { + popoto.graph.node.collapseNode(clickedNode); + } else { + popoto.graph.node.chooseNodeClick(clickedNode); + } + } + }; + + /** + * Remove all the value node directly linked to clicked node. + * + * @param clickedNode + */ + popoto.graph.node.collapseNode = function (clickedNode) { + if (clickedNode.valueExpanded) { // node is collapsed only if it has been expanded first + popoto.logger.debug("collapseNode (" + clickedNode.label + ")"); + + var linksToRemove = popoto.graph.force.links().filter(function (l) { + return l.source === clickedNode && l.type === popoto.graph.link.LinkTypes.VALUE; + }); + + // Remove children nodes from model + linksToRemove.forEach(function (l) { + popoto.graph.force.nodes().splice(popoto.graph.force.nodes().indexOf(l.target), 1); + }); + + // Remove links from model + for (var i = popoto.graph.force.links().length - 1; i >= 0; i--) { + if (linksToRemove.indexOf(popoto.graph.force.links()[i]) >= 0) { + popoto.graph.force.links().splice(i, 1); + } + } + + // Node has been fixed when expanded so we unfix it back here. + if (clickedNode.type !== popoto.graph.node.NodeTypes.ROOT) { + clickedNode.fixed = false; + } + + // Parent node too if not root + if (clickedNode.parent && clickedNode.parent.type !== popoto.graph.node.NodeTypes.ROOT) { + clickedNode.parent.fixed = false; + } + + clickedNode.valueExpanded = false; + popoto.update(); + + } else { + popoto.logger.debug("collapseNode called on an unexpanded node"); + } + }; + + /** + * Function called on a value node click. + * In this case the value is added in the parent node and all the value nodes are collapsed. + * + * @param clickedNode + */ + popoto.graph.node.valueNodeClick = function (clickedNode) { + popoto.logger.debug("valueNodeClick (" + clickedNode.label + ")"); + clickedNode.parent.value = clickedNode; + popoto.result.hasChanged = true; + popoto.graph.hasGraphChanged = true; + + popoto.graph.node.collapseNode(clickedNode.parent); + }; + + /** + * Function called on choose node click. + * In this case a query is executed to get all the possible value + * @param clickedNode + * TODO optimize with cached data? + */ + popoto.graph.node.chooseNodeClick = function (clickedNode) { + popoto.logger.debug("chooseNodeClick (" + clickedNode.label + ") with waiting state set to " + popoto.graph.node.chooseWaiting); + if (!popoto.graph.node.chooseWaiting && !clickedNode.immutable) { + + // Collapse all expanded nodes first + popoto.graph.force.nodes().forEach(function (n) { + if ((n.type == popoto.graph.node.NodeTypes.ROOT || n.type == popoto.graph.node.NodeTypes.CHOOSE) && n.valueExpanded) { + popoto.graph.node.collapseNode(n); + } + }); + + // Set waiting state to true to avoid multiple call on slow query execution + popoto.graph.node.chooseWaiting = true; + + popoto.logger.info("Values (" + clickedNode.label + ") ==> "); + popoto.rest.post( + { + "statements": [ + { + "statement": popoto.query.generateValueQuery(clickedNode) + }] + }) + .done(function (data) { + clickedNode.id = (++popoto.graph.node.idgen); + clickedNode.data = popoto.graph.node.parseResultData(data); + clickedNode.page = 1; + popoto.graph.node.expandNode(clickedNode); + popoto.graph.node.chooseWaiting = false; + }) + .fail(function (xhr, textStatus, errorThrown) { + popoto.graph.node.chooseWaiting = false; + popoto.logger.error(textStatus + ": error while accessing Neo4j server on URL:\"" + popoto.rest.CYPHER_URL + "\" defined in \"popoto.rest.CYPHER_URL\" property: " + errorThrown); + }); + } + }; + + /** + * Parse query execution result and generate an array of object. + * These objects contains of a list of properties made of result attributes with their value. + * + * @param data query execution raw data + * @returns {Array} array of structured object with result attributes. + */ + popoto.graph.node.parseResultData = function (data) { + var results = []; + + for (var x = 0; x < data.results[0].data.length; x++) { + var obj = {}; + + for (var i = 0; i < data.results[0].columns.length; i++) { + obj[data.results[0].columns[i]] = data.results[0].data[x].row[i]; + } + + results.push(obj); + } + + return results; + }; + + /** + * Compute the angle in radian between the node and its parent. + * TODO: clean or add comments to explain the code... + * + * @param node node to compute angle. + * @returns {number} angle in radian. + */ + popoto.graph.computeParentAngle = function (node) { + var angleRadian = 0; + var r = 100; + if (node.parent) { + var xp = node.parent.x; + var yp = node.parent.y; + var x0 = node.x; + var y0 = node.y; + var dist = Math.sqrt(Math.pow(xp - x0, 2) + Math.pow(yp - y0, 2)); + + var k = r / (dist - r); + var xc = (x0 + (k * xp)) / (1 + k); + + var val = (xc - x0) / r; + if (val < -1) { + val = -1; + } + if (val > 1) { + val = 1; + } + + angleRadian = Math.acos(val); + + if (yp > y0) { + angleRadian = 2 * Math.PI - angleRadian; + } + } + return angleRadian; + }; + + /** + * Function called to expand a node containing values. + * This function will create the value nodes with the clicked node internal data. + * Only nodes corresponding to the current page index will be generated. + * + * @param clickedNode + */ + popoto.graph.node.expandNode = function (clickedNode) { + + // Get subset of node corresponding to the current node page and page size + var lIndex = clickedNode.page * popoto.graph.node.PAGE_SIZE; + var sIndex = lIndex - popoto.graph.node.PAGE_SIZE; + + var dataToAdd = clickedNode.data.slice(sIndex, lIndex); + var parentAngle = popoto.graph.computeParentAngle(clickedNode); + + // Then each node are created and dispatched around the clicked node using computed coordinates. + var i = 1; + dataToAdd.forEach(function (d) { + var angleDeg; + if (clickedNode.parent) { + angleDeg = (((360 / (dataToAdd.length + 1)) * i)); + } else { + angleDeg = (((360 / (dataToAdd.length)) * i)); + } + + var nx = clickedNode.x + (100 * Math.cos((angleDeg * (Math.PI / 180)) - parentAngle)), + ny = clickedNode.y + (100 * Math.sin((angleDeg * (Math.PI / 180)) - parentAngle)); + + var node = { + "id": (++popoto.graph.node.idgen), + "parent": clickedNode, + "attributes": d, + "type": popoto.graph.node.NodeTypes.VALUE, + "label": clickedNode.label, + "count": d.count, + "x": nx, + "y": ny, + "internalID": d[popoto.query.NEO4J_INTERNAL_ID.queryInternalName] + }; + + popoto.graph.force.nodes().push(node); + + popoto.graph.force.links().push( + { + id: "l" + (++popoto.graph.node.idgen), + source: clickedNode, + target: node, + type: popoto.graph.link.LinkTypes.VALUE + } + ); + + i++; + }); + + // Pin clicked node and its parent to avoid the graph to move for selection, only new value nodes will blossom around the clicked node. + clickedNode.fixed = true; + if (clickedNode.parent && clickedNode.parent.type !== popoto.graph.node.NodeTypes.ROOT) { + clickedNode.parent.fixed = true; + } + // Change node state + clickedNode.valueExpanded = true; + popoto.update(); + }; + + /** + * Function called on a right click on a node. + * + * In this case all expanded nodes in the graph will first be closed then if no relation have been added yet a Cypher query is executed to get all the related nodes. + * A first coordinate pre-computation is done to dispatch the new node correctly around the parent node and the nodes are added with a link in the model. + * + * If no relation are found or relation were already added the right click event is used to remove the node current selection. + * + */ + popoto.graph.node.expandRelationship = function () { + // Prevent default right click event opening menu. + d3.event.preventDefault(); + + // Notify listeners + popoto.graph.nodeExpandRelationsipListeners.forEach(function (listener) { + listener(this); + }); + + // Get clicked node. + var clickedNode = d3.select(this).data()[0]; + + if (!clickedNode.linkExpanded && !popoto.graph.node.linkWaiting && !clickedNode.valueExpanded) { + popoto.graph.node.linkWaiting = true; + + popoto.logger.info("Relations (" + clickedNode.label + ") ==> "); + popoto.rest.post( + { + "statements": [ + { + "statement": popoto.query.generateLinkQuery(clickedNode) + }] + }) + .done(function (data) { + var parsedData = popoto.graph.node.parseResultData(data); + + parsedData = parsedData.filter(function (d) { + return popoto.query.filterRelation(d); + }); + + if (parsedData.length <= 0) { + // Set linkExpanded to true to avoid a new query call on next right click + clickedNode.linkExpanded = true; + clickedNode.linkCount = 0; + popoto.graph.hasGraphChanged = true; + popoto.update(); + } else { + var parentAngle = popoto.graph.computeParentAngle(clickedNode); + + var i = 1; + parsedData.forEach(function (d) { + var angleDeg; + if (parentAngle) { + angleDeg = (((360 / (parsedData.length + 1)) * i)); + } else { + angleDeg = (((360 / (parsedData.length)) * i)); + } + + var nx = clickedNode.x + (100 * Math.cos((angleDeg * (Math.PI / 180)) - parentAngle)), + ny = clickedNode.y + (100 * Math.sin((angleDeg * (Math.PI / 180)) - parentAngle)); + + var isGroupNode = popoto.provider.getIsGroup(d); + // filter multiple labels + var nodeLabel = popoto.provider.getLabelFilter(d.label); + + var node = { + "id": "" + (++popoto.graph.node.idgen), + "parent": clickedNode, + "type": (isGroupNode) ? popoto.graph.node.NodeTypes.GROUP : popoto.graph.node.NodeTypes.CHOOSE, + "label": nodeLabel, + "fixed": false, + "internalLabel": popoto.graph.node.generateInternalLabel(nodeLabel), + "x": nx, + "y": ny + }; + + popoto.graph.force.nodes().push(node); + + popoto.graph.force.links().push( + { + id: "l" + (++popoto.graph.node.idgen), + source: clickedNode, + target: node, + type: popoto.graph.link.LinkTypes.RELATION, + label: d.relationship + } + ); + + i++; + }); + + popoto.graph.hasGraphChanged = true; + clickedNode.linkExpanded = true; + clickedNode.linkCount = parsedData.length; + popoto.update(); + } + popoto.graph.node.linkWaiting = false; + }) + .fail(function (xhr, textStatus, errorThrown) { + popoto.logger.error(textStatus + ": error while accessing Neo4j server on URL:\"" + popoto.rest.CYPHER_URL + "\" defined in \"popoto.rest.CYPHER_URL\" property: " + errorThrown); + popoto.graph.node.linkWaiting = false; + }); + } + }; + + /** + * Remove all relationships from context node (including children). + */ + popoto.graph.node.collapseRelationship = function () { + d3.event.preventDefault(); + + // Get clicked node. + var clickedNode = d3.select(this).data()[0]; + + if (clickedNode.linkExpanded && clickedNode.linkCount > 0 && !popoto.graph.node.linkWaiting && !clickedNode.valueExpanded) { + + // Collapse all expanded choose nodes first to avoid having invalid displayed value node if collapsed relation contains a value. + popoto.graph.force.nodes().forEach(function (n) { + if ((n.type === popoto.graph.node.NodeTypes.CHOOSE || n.type === popoto.graph.node.NodeTypes.ROOT) && n.valueExpanded) { + popoto.graph.node.collapseNode(n); + } + }); + + var linksToRemove = popoto.graph.force.links().filter(function (l) { + return l.source === clickedNode && l.type === popoto.graph.link.LinkTypes.RELATION; + }); + + // Remove children nodes from model + linksToRemove.forEach(function (l) { + popoto.graph.node.removeNode(l.target); + }); + + // Remove links from model + for (var i = popoto.graph.force.links().length - 1; i >= 0; i--) { + if (linksToRemove.indexOf(popoto.graph.force.links()[i]) >= 0) { + popoto.graph.force.links().splice(i, 1); + } + } + + clickedNode.linkExpanded = false; + popoto.result.hasChanged = true; + popoto.graph.hasGraphChanged = true; + popoto.update(); + } + }; + + /** + * Remove a node and its relationships (recursively) from the graph. + * + * @param node the node to remove. + */ + popoto.graph.node.removeNode = function (node) { + + var linksToRemove = popoto.graph.force.links().filter(function (l) { + return l.source === node; + }); + + // Remove children nodes from model + linksToRemove.forEach(function (l) { + popoto.graph.node.removeNode(l.target); + }); + + // Remove links from model + for (var i = popoto.graph.force.links().length - 1; i >= 0; i--) { + if (linksToRemove.indexOf(popoto.graph.force.links()[i]) >= 0) { + popoto.graph.force.links().splice(i, 1); + } + } + + popoto.graph.force.nodes().splice(popoto.graph.force.nodes().indexOf(node), 1); + + }; + + /** + * Function to add on node event to clear the selection. + * Call to this function on a node will remove the selected value and triger a graph update. + */ + popoto.graph.node.clearSelection = function () { + // Prevent default event like right click opening menu. + d3.event.preventDefault(); + + // Get clicked node. + var clickedNode = d3.select(this).data()[0]; + + // Collapse all expanded choose nodes first + popoto.graph.force.nodes().forEach(function (n) { + if ((n.type === popoto.graph.node.NodeTypes.CHOOSE || n.type === popoto.graph.node.NodeTypes.ROOT) && n.valueExpanded) { + popoto.graph.node.collapseNode(n); + } + }); + + if (clickedNode.value != null && !clickedNode.immutable) { + // Remove selected value of choose node + delete clickedNode.value; + + popoto.result.hasChanged = true; + popoto.graph.hasGraphChanged = true; + popoto.update(); + } + }; + +// QUERY VIEWER ----------------------------------------------------------------------------------------------------- + popoto.queryviewer = {}; + popoto.queryviewer.containerId = "popoto-query"; + popoto.queryviewer.QUERY_STARTER = "I'm looking for"; + popoto.queryviewer.CHOOSE_LABEL = "choose"; + + /** + * Create the query viewer area. + * + */ + popoto.queryviewer.createQueryArea = function () { + var id = "#" + popoto.queryviewer.containerId; + + popoto.queryviewer.queryConstraintSpanElements = d3.select(id).append("p").attr("class", "ppt-query-constraint-elements").selectAll(".queryConstraintSpan"); + popoto.queryviewer.querySpanElements = d3.select(id).append("p").attr("class", "ppt-query-elements").selectAll(".querySpan"); + }; + + /** + * Update all the elements displayed on the query viewer based on current graph. + */ + popoto.queryviewer.updateQuery = function () { + + // Remove all query span elements + popoto.queryviewer.queryConstraintSpanElements = popoto.queryviewer.queryConstraintSpanElements.data([]); + popoto.queryviewer.querySpanElements = popoto.queryviewer.querySpanElements.data([]); + + popoto.queryviewer.queryConstraintSpanElements.exit().remove(); + popoto.queryviewer.querySpanElements.exit().remove(); + + // Update data + popoto.queryviewer.queryConstraintSpanElements = popoto.queryviewer.queryConstraintSpanElements.data(popoto.queryviewer.generateConstraintData(popoto.graph.force.links(), popoto.graph.force.nodes())); + popoto.queryviewer.querySpanElements = popoto.queryviewer.querySpanElements.data(popoto.queryviewer.generateData(popoto.graph.force.links(), popoto.graph.force.nodes())); + + // Remove old span (not needed as all have been cleaned before) + // popoto.queryviewer.querySpanElements.exit().remove(); + + // Add new span + popoto.queryviewer.queryConstraintSpanElements.enter().append("span") + .on("contextmenu", popoto.queryviewer.rightClickSpan) + .on("click", popoto.queryviewer.clickSpan) + .on("mouseover", popoto.queryviewer.mouseOverSpan) + .on("mouseout", popoto.queryviewer.mouseOutSpan); + + popoto.queryviewer.querySpanElements.enter().append("span") + .on("contextmenu", popoto.queryviewer.rightClickSpan) + .on("click", popoto.queryviewer.clickSpan) + .on("mouseover", popoto.queryviewer.mouseOverSpan) + .on("mouseout", popoto.queryviewer.mouseOutSpan); + + // Update all span + popoto.queryviewer.queryConstraintSpanElements + .attr("id", function (d) { + return d.id + }) + .attr("class", function (d) { + if (d.isLink) { + return "ppt-span-link"; + } else { + if (d.type === popoto.graph.node.NodeTypes.ROOT) { + return "ppt-span-root"; + } else if (d.type === popoto.graph.node.NodeTypes.CHOOSE) { + if (d.ref.value) { + return "ppt-span-value"; + } else { + return "ppt-span-choose"; + } + } else if (d.type === popoto.graph.node.NodeTypes.VALUE) { + return "ppt-span-value"; + } else if (d.type === popoto.graph.node.NodeTypes.GROUP) { + return "ppt-span-group"; + } else { + return "ppt-span"; + } + } + }) + .text(function (d) { + return d.term + " "; + }); + + popoto.queryviewer.querySpanElements + .attr("id", function (d) { + return d.id + }) + .attr("class", function (d) { + if (d.isLink) { + return "ppt-span-link"; + } else { + if (d.type === popoto.graph.node.NodeTypes.ROOT) { + return "ppt-span-root"; + } else if (d.type === popoto.graph.node.NodeTypes.CHOOSE) { + if (d.ref.value) { + return "ppt-span-value"; + } else { + return "ppt-span-choose"; + } + } else if (d.type === popoto.graph.node.NodeTypes.VALUE) { + return "ppt-span-value"; + } else if (d.type === popoto.graph.node.NodeTypes.GROUP) { + return "ppt-span-group"; + } else { + return "ppt-span"; + } + } + }) + .text(function (d) { + return d.term + " "; + }); + }; + + popoto.queryviewer.generateConstraintData = function (links, nodes) { + var elmts = [], id = 0; + + // Add + elmts.push( + {id: id++, term: popoto.queryviewer.QUERY_STARTER} + ); + + // Add the root node as query term + if (nodes.length > 0) { + elmts.push( + {id: id++, type: nodes[0].type, term: popoto.provider.getSemanticValue(nodes[0]), ref: nodes[0]} + ); + } + + // Add a span for each link and its target node + links.forEach(function (l) { + + var sourceNode = l.source; + var targetNode = l.target; + if (l.type === popoto.graph.link.LinkTypes.RELATION && targetNode.type !== popoto.graph.node.NodeTypes.GROUP && targetNode.value) { + if (sourceNode.type === popoto.graph.node.NodeTypes.GROUP) { + elmts.push( + {id: id++, type: sourceNode.type, term: popoto.provider.getSemanticValue(sourceNode), ref: sourceNode} + ); + } + + elmts.push({id: id++, isLink: true, term: popoto.provider.getLinkSemanticValue(l), ref: l}); + + if (targetNode.type !== popoto.graph.node.NodeTypes.GROUP) { + if (targetNode.value) { + elmts.push( + {id: id++, type: targetNode.type, term: popoto.provider.getSemanticValue(targetNode), ref: targetNode} + ); + } else { + elmts.push( + {id: id++, type: targetNode.type, term: "<" + popoto.queryviewer.CHOOSE_LABEL + " " + popoto.provider.getSemanticValue(targetNode) + ">", ref: targetNode} + ); + } + } + } + }); + + return elmts; + }; + + // TODO add option nodes in generated query when no value is available + popoto.queryviewer.generateData = function (links, nodes) { + var elmts = [], options = [], id = 0; + + // Add a span for each link and its target node + links.forEach(function (l) { + + var sourceNode = l.source; + var targetNode = l.target; + + if (targetNode.type === popoto.graph.node.NodeTypes.GROUP) { + options.push( + {id: id++, type: targetNode.type, term: popoto.provider.getSemanticValue(targetNode), ref: targetNode} + ); + } + + if (l.type === popoto.graph.link.LinkTypes.RELATION && targetNode.type !== popoto.graph.node.NodeTypes.GROUP && !targetNode.value) { + if (sourceNode.type === popoto.graph.node.NodeTypes.GROUP) { + elmts.push( + {id: id++, type: sourceNode.type, term: popoto.provider.getSemanticValue(sourceNode), ref: sourceNode} + ); + } + + elmts.push({id: id++, isLink: true, term: popoto.provider.getLinkSemanticValue(l), ref: l}); + + if (targetNode.type !== popoto.graph.node.NodeTypes.GROUP) { + if (targetNode.value) { + elmts.push( + {id: id++, type: targetNode.type, term: popoto.provider.getSemanticValue(targetNode), ref: targetNode} + ); + } else { + elmts.push( + {id: id++, type: targetNode.type, term: "<" + popoto.queryviewer.CHOOSE_LABEL + " " + popoto.provider.getSemanticValue(targetNode) + ">", ref: targetNode} + ); + } + } + } + }); + + return elmts.concat(options); + }; + + /** + * + */ + popoto.queryviewer.mouseOverSpan = function () { + d3.select(this).classed("hover", function (d) { + return d.ref; + }); + + var hoveredSpan = d3.select(this).data()[0]; + + if (hoveredSpan.ref) { + var linkElmt = popoto.graph.svg.selectAll("#" + popoto.graph.link.gID + " > g").filter(function (d) { + return d === hoveredSpan.ref; + }); + linkElmt.select("path").classed("ppt-link-hover", true); + linkElmt.select("text").classed("ppt-link-hover", true); + + var nodeElmt = popoto.graph.svg.selectAll("#" + popoto.graph.node.gID + " > g").filter(function (d) { + return d === hoveredSpan.ref; + }); + + nodeElmt.select(".ppt-g-node-background").selectAll("circle").transition().style("fill-opacity", 0.5); + } + }; + + popoto.queryviewer.rightClickSpan = function () { + var hoveredSpan = d3.select(this).data()[0]; + + if (!hoveredSpan.isLink && hoveredSpan.ref) { + var nodeElmt = popoto.graph.svg.selectAll("#" + popoto.graph.node.gID + " > g").filter(function (d) { + return d === hoveredSpan.ref; + }); + + nodeElmt.on("contextmenu").call(nodeElmt.node(), hoveredSpan.ref); + } + }; + + popoto.queryviewer.clickSpan = function () { + var hoveredSpan = d3.select(this).data()[0]; + + if (!hoveredSpan.isLink && hoveredSpan.ref) { + var nodeElmt = popoto.graph.svg.selectAll("#" + popoto.graph.node.gID + " > g").filter(function (d) { + return d === hoveredSpan.ref; + }); + + nodeElmt.on("click").call(nodeElmt.node(), hoveredSpan.ref); + } + }; + + /** + * + */ + popoto.queryviewer.mouseOutSpan = function () { + d3.select(this).classed("hover", false); + + var hoveredSpan = d3.select(this).data()[0]; + + if (hoveredSpan.ref) { + var linkElmt = popoto.graph.svg.selectAll("#" + popoto.graph.link.gID + " > g").filter(function (d) { + return d === hoveredSpan.ref; + }); + linkElmt.select("path").classed("ppt-link-hover", false); + linkElmt.select("text").classed("ppt-link-hover", false); + + var nodeElmt = popoto.graph.svg.selectAll("#" + popoto.graph.node.gID + " > g").filter(function (d) { + return d === hoveredSpan.ref; + }); + nodeElmt.select(".ppt-g-node-background").selectAll("circle").transition().style("fill-opacity", 0); + } + }; + +// CYPHER VIEWER ----------------------------------------------------------------------------------------------------- + + // TODO not available yet + popoto.cypherviewer = {}; + popoto.cypherviewer.containerId = "popoto-cypher"; + +// QUERY ------------------------------------------------------------------------------------------------------------ + popoto.query = {}; + /** + * Define the number of results displayed in result list. + */ + popoto.query.RESULTS_PAGE_SIZE = 100; + popoto.query.VALUE_QUERY_LIMIT = 1000; + popoto.query.USE_PARENT_RELATION = false; + popoto.query.USE_RELATION_DIRECTION = true; + + /** + * Immutable constant object to identify Neo4j internal ID + */ + popoto.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} + */ + popoto.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. + */ + popoto.query.generateTaxonomyCountQuery = function (label) { + var constraintAttr = popoto.provider.getConstraintAttribute(label); + + var whereElements = []; + + var predefinedConstraints = popoto.provider.getPredefinedConstraints(label); + predefinedConstraints.forEach(function (predefinedConstraint) { + whereElements.push(predefinedConstraint.replace(new RegExp("\\$identifier", 'g'), "n")); + }); + + if (constraintAttr === popoto.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 + */ + popoto.query.generateQueryElements = function (rootNode, selectedNode, links, isConstraintNeeded) { + var matchElements = []; + var whereElements = []; + var rel = popoto.query.USE_RELATION_DIRECTION ? "->" : "-"; + + var rootPredefinedConstraints = popoto.provider.getPredefinedConstraints(rootNode.label); + + rootPredefinedConstraints.forEach(function (predefinedConstraint) { + whereElements.push(predefinedConstraint.replace(new RegExp("\\$identifier", 'g'), rootNode.internalLabel)); + }); + + // Generate root node match element + if (rootNode.value && (isConstraintNeeded || rootNode.immutable)) { + var rootConstraintAttr = popoto.provider.getConstraintAttribute(rootNode.label); + if (rootConstraintAttr === popoto.query.NEO4J_INTERNAL_ID) { + matchElements.push("(" + rootNode.internalLabel + ":`" + rootNode.label + "`)"); + whereElements.push("ID(" + rootNode.internalLabel + ") = " + rootNode.value.internalID); + } else { + var constraintValue = rootNode.value.attributes[rootConstraintAttr]; + + if (typeof constraintValue === "boolean" || typeof constraintValue === "number") { + matchElements.push("(" + rootNode.internalLabel + ":`" + rootNode.label + "`{`" + rootConstraintAttr + "`:" + constraintValue + "})"); + } else { + matchElements.push("(" + rootNode.internalLabel + ":`" + rootNode.label + "`{`" + rootConstraintAttr + "`:\"" + constraintValue + "\"})"); + } + } + } else { + matchElements.push("(" + rootNode.internalLabel + ":`" + rootNode.label + "`)"); + } + + // Generate match elements for each links + links.forEach(function (l) { + var sourceNode = l.source; + var targetNode = l.target; + + var predefinedConstraints = popoto.provider.getPredefinedConstraints(targetNode.label); + + predefinedConstraints.forEach(function (predefinedConstraint) { + whereElements.push(predefinedConstraint.replace(new RegExp("\\$identifier", 'g'), targetNode.internalLabel)); + }); + + if (targetNode.value && targetNode !== selectedNode && (isConstraintNeeded || rootNode.immutable)) { + var constraintAttr = popoto.provider.getConstraintAttribute(targetNode.label); + var constraintValue = targetNode.value.attributes[constraintAttr]; + if (constraintAttr === popoto.query.NEO4J_INTERNAL_ID) { + matchElements.push("(" + sourceNode.internalLabel + ":`" + sourceNode.label + "`)-[:`" + l.label + "`]" + rel + "(" + targetNode.internalLabel + ":`" + targetNode.label + "`)"); + whereElements.push("ID(" + targetNode.internalLabel + ") = " + targetNode.value.internalID); + } else { + if (typeof constraintValue === "boolean" || typeof constraintValue === "number") { + matchElements.push("(" + sourceNode.internalLabel + ":`" + sourceNode.label + "`)-[:`" + l.label + "`]" + rel + "(" + targetNode.internalLabel + ":`" + targetNode.label + "`{`" + constraintAttr + "`:" + constraintValue + "})"); + } else { + matchElements.push("(" + sourceNode.internalLabel + ":`" + sourceNode.label + "`)-[:`" + l.label + "`]" + rel + "(" + targetNode.internalLabel + ":`" + targetNode.label + "`{`" + constraintAttr + "`:\"" + constraintValue + "\"})"); + } + } + } else { + matchElements.push("(" + sourceNode.internalLabel + ":`" + sourceNode.label + "`)-[:`" + l.label + "`]" + rel + "(" + targetNode.internalLabel + ":`" + targetNode.label + "`)"); + } + }); + + return {"matchElements": matchElements, "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. + */ + popoto.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 || l.target === targetNode) { + 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 + */ + popoto.query.getLinksToRoot = function (node, links) { + var pathLinks = []; + var targetNode = node; + + while (targetNode !== popoto.graph.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 all the relation available for a given node. + * + * @param targetNode + * @returns {string} + */ + popoto.query.generateLinkQuery = function (targetNode) { + + var linksToRoot = popoto.query.getLinksToRoot(targetNode, popoto.graph.force.links()); + var queryElements = popoto.query.generateQueryElements(popoto.graph.getRootNode(), targetNode, linksToRoot, false); + var matchElements = queryElements.matchElements, + returnElements = [], + whereElements = queryElements.whereElements, + endElements = []; + var rel = popoto.query.USE_RELATION_DIRECTION ? "->" : "-"; + + matchElements.push("(" + targetNode.internalLabel + ":`" + targetNode.label + "`)-[r]" + rel + "(x)"); + returnElements.push("type(r) AS relationship"); + if (popoto.query.USE_PARENT_RELATION) { + returnElements.push("head(labels(x)) AS label"); + } else { + //returnElements.push("last(labels(x)) AS label"); + returnElements.push("labels(x) AS label"); + } + returnElements.push("count(r) AS count"); + endElements.push("ORDER BY count(r) DESC"); + + return "MATCH " + matchElements.join(", ") + ((whereElements.length > 0) ? " WHERE " + whereElements.join(" AND ") : "") + " RETURN " + returnElements.join(", ") + " " + endElements.join(" "); + }; + + /** + * Generate a Cypher query + * @returns {string} + */ + popoto.query.generateResultCypherQuery = function () { + + var rootNode = popoto.graph.getRootNode(); + var queryElements = popoto.query.generateQueryElements(rootNode, rootNode, popoto.query.getRelevantLinks(rootNode, rootNode, popoto.graph.force.links()), true); + var matchElements = queryElements.matchElements, + returnElements = [], + whereElements = queryElements.whereElements, + endElements = []; + + // Sort results by specified attribute + var resultOrderByAttribute = popoto.provider.getResultOrderByAttribute(rootNode.label); + if (resultOrderByAttribute) { + var order = popoto.provider.isResultOrderAscending(rootNode.label) ? "ASC" : "DESC"; + endElements.push("ORDER BY " + resultOrderByAttribute + " " + order); + } + + endElements.push("LIMIT " + popoto.query.RESULTS_PAGE_SIZE); + + var resultAttributes = popoto.provider.getReturnAttributes(rootNode.label); + var constraintAttribute = popoto.provider.getConstraintAttribute(rootNode.label); + + for (var i = 0; i < resultAttributes.length; i++) { + var attribute = resultAttributes[i]; + if (attribute === popoto.query.NEO4J_INTERNAL_ID) { + if (attribute == constraintAttribute) { + returnElements.push("ID(" + rootNode.internalLabel + ") AS " + popoto.query.NEO4J_INTERNAL_ID.queryInternalName); + } else { + returnElements.push("COLLECT(DISTINCT ID(" + rootNode.internalLabel + ")) AS " + popoto.query.NEO4J_INTERNAL_ID.queryInternalName); + } + } else { + if (attribute == constraintAttribute) { + returnElements.push(rootNode.internalLabel + "." + attribute + " AS " + attribute); + } else { + returnElements.push("COLLECT(DISTINCT " + rootNode.internalLabel + "." + attribute + ") AS " + attribute); + } + } + } + + return "MATCH " + matchElements.join(", ") + ((whereElements.length > 0) ? " WHERE " + whereElements.join(" AND ") : "") + " RETURN DISTINCT " + returnElements.join(", ") + " " + endElements.join(" "); + }; + + popoto.query.generateResultCypherQueryCount = function () { + + var rootNode = popoto.graph.getRootNode(); + var queryElements = popoto.query.generateQueryElements(rootNode, rootNode, popoto.query.getRelevantLinks(rootNode, rootNode, popoto.graph.force.links()), true); + var constraintAttribute = popoto.provider.getConstraintAttribute(rootNode.label); + var matchElements = queryElements.matchElements, + returnElements = [], + whereElements = queryElements.whereElements, + endElements = []; + + if (constraintAttribute === popoto.query.NEO4J_INTERNAL_ID) { + returnElements.push("count(DISTINCT ID(" + rootNode.internalLabel + ")) AS count"); + } else { + returnElements.push("count(DISTINCT " + rootNode.internalLabel + "." + constraintAttribute + ") AS count"); + } + + return "MATCH " + matchElements.join(", ") + ((whereElements.length > 0) ? " WHERE " + whereElements.join(" AND ") : "") + " RETURN " + returnElements.join(", ") + (endElements.length > 0 ? " " + endElements.join(" ") : ""); + }; + + /** + * Generate the query to update node counts. + * + * @param countedNode the counted node + * @returns {string} the node count cypher query; + */ + popoto.query.generateNodeCountCypherQuery = function (countedNode) { + + var queryElements = popoto.query.generateQueryElements(popoto.graph.getRootNode(), countedNode, popoto.query.getRelevantLinks(popoto.graph.getRootNode(), countedNode, popoto.graph.force.links()), true); + var matchElements = queryElements.matchElements, + whereElements = queryElements.whereElements, + returnElements = []; + + var countAttr = popoto.provider.getConstraintAttribute(countedNode.label); + + if (countAttr === popoto.query.NEO4J_INTERNAL_ID) { + returnElements.push("count(DISTINCT ID(" + countedNode.internalLabel + ")) as count"); + } else { + returnElements.push("count(DISTINCT " + countedNode.internalLabel + "." + countAttr + ") as count"); + } + + return "MATCH " + matchElements.join(", ") + ((whereElements.length > 0) ? " WHERE " + whereElements.join(" AND ") : "") + " RETURN " + returnElements.join(", "); + }; + + /** + * 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. + */ + popoto.query.generateValueQuery = function (targetNode) { + + var rootNode = popoto.graph.getRootNode(); + var queryElements = popoto.query.generateQueryElements(rootNode, targetNode, popoto.query.getRelevantLinks(rootNode, targetNode, popoto.graph.force.links()), true); + var matchElements = queryElements.matchElements, + endElements = [], + whereElements = queryElements.whereElements, + returnElements = []; + + // Sort results by specified attribute + var valueOrderByAttribute = popoto.provider.getValueOrderByAttribute(targetNode.label); + if (valueOrderByAttribute) { + var order = popoto.provider.isValueOrderAscending(targetNode.label) ? "ASC" : "DESC"; + endElements.push("ORDER BY " + valueOrderByAttribute + " " + order); + } + + endElements.push("LIMIT " + popoto.query.VALUE_QUERY_LIMIT); + + var resultAttributes = popoto.provider.getReturnAttributes(targetNode.label); + var constraintAttribute = popoto.provider.getConstraintAttribute(targetNode.label); + + for (var i = 0; i < resultAttributes.length; i++) { + if (resultAttributes[i] === popoto.query.NEO4J_INTERNAL_ID) { + if (resultAttributes[i] == constraintAttribute) { + returnElements.push("ID(" + targetNode.internalLabel + ") AS " + popoto.query.NEO4J_INTERNAL_ID.queryInternalName); + } else { + returnElements.push("COLLECT (DISTINCT ID(" + targetNode.internalLabel + ")) AS " + popoto.query.NEO4J_INTERNAL_ID.queryInternalName); + } + } else { + if (resultAttributes[i] == constraintAttribute) { + returnElements.push(targetNode.internalLabel + "." + resultAttributes[i] + " AS " + resultAttributes[i]); + } else { + returnElements.push("COLLECT(DISTINCT " + targetNode.internalLabel + "." + resultAttributes[i] + ") AS " + resultAttributes[i]); + } + } + } + + // Add count return attribute on root node + var rootConstraintAttr = popoto.provider.getConstraintAttribute(rootNode.label); + + if (rootConstraintAttr === popoto.query.NEO4J_INTERNAL_ID) { + returnElements.push("count(DISTINCT ID(" + rootNode.internalLabel + ")) AS count"); + } else { + returnElements.push("count(DISTINCT " + rootNode.internalLabel + "." + rootConstraintAttr + ") AS count"); + } + + return "MATCH " + matchElements.join(", ") + ((whereElements.length > 0) ? " WHERE " + whereElements.join(" AND ") : "") + " RETURN DISTINCT " + returnElements.join(", ") + " " + endElements.join(" "); + }; + + /////////////////////////////////////////////////////////////////// + // Results + + popoto.result = {}; + popoto.result.containerId = "popoto-results"; + popoto.result.hasChanged = true; + popoto.result.resultCountListeners = []; + popoto.result.resultListeners = []; + + /** + * Register a listener to the result count event. + * This listener will be called on evry result change with total result count. + */ + popoto.result.onTotalResultCount = function (listener) { + popoto.result.resultCountListeners.push(listener); + }; + + popoto.result.onResultReceived = function (listener) { + popoto.result.resultListeners.push(listener); + }; + + /** + * Parse REST returned data and generate a list of result objects. + * + * @param data + * @returns {Array} + */ + popoto.result.parseResultData = function (data) { + + var results = []; + if (data.results && data.results.length > 0) { + for (var x = 0; x < data.results[0].data.length; x++) { + + var obj = { + "resultIndex": x, + "label": popoto.graph.getRootNode().label, + "attributes": {} + }; + + for (var i = 0; i < data.results[0].columns.length; i++) { + // Some results can be an array as collect is used in query + // So all values are converted to string + obj.attributes[data.results[0].columns[i]] = "" + data.results[0].data[x].row[i]; + } + + results.push(obj); + } + } + + return results; + }; + + popoto.result.updateResults = function () { + if (popoto.result.hasChanged) { + var query = popoto.query.generateResultCypherQuery(); + + // FIXME temporary cypher query update here. To be replaced by real interactive cypher viewer. + if (popoto.cypherviewer.isActive) { + d3.select("#" + popoto.cypherviewer.containerId) + // In this temporary version only the match part of the query is displayed to avoid huge query with lot of return attributes. + .text(query.split("RETURN")[0] + " RETURN " + popoto.graph.getRootNode().internalLabel); + } + + popoto.logger.info("Results ==> "); + popoto.rest.post( + { + "statements": [ + { + "statement": query + }] + }) + .done(function (data) { + + if (data.errors && data.errors.length > 0) { + popoto.logger.error("Cypher query error:" + JSON.stringify(data.errors)); + } + + // Parse data + var resultObjects = popoto.result.parseResultData(data); + + // Notify listeners + popoto.result.resultListeners.forEach(function (listener) { + listener(resultObjects); + }); + + // Update displayed results only if needed () + if (popoto.result.isActive) { + // Clear all results + var results = d3.select("#" + popoto.result.containerId).selectAll(".ppt-result").data([]); + results.exit().remove(); + + // Update data + results = d3.select("#" + popoto.result.containerId).selectAll(".ppt-result").data(resultObjects, function (d) { + return d.resultIndex; + }); + + // Add new elements + var pElmt = results.enter() + .append("p") + .attr("class", "ppt-result") + .attr("id", function (d) { + return "popoto-result-" + d.resultIndex; + }); + + // Generate results with providers + pElmt.each(function (d) { + popoto.provider.getDisplayResultFunction(d.label)(d3.select(this)); + }); + } + + popoto.result.hasChanged = false; + }) + .fail(function (xhr, textStatus, errorThrown) { + popoto.logger.error(textStatus + ": error while accessing Neo4j server on URL:\"" + popoto.rest.CYPHER_URL + "\" defined in \"popoto.rest.CYPHER_URL\" property: " + errorThrown); + + // Notify listeners + popoto.result.resultListeners.forEach(function (listener) { + listener([]); + }); + + }); + + // Execute query to get total result count + // But only if needed, if listeners have been added + if (popoto.result.resultCountListeners.length > 0) { + popoto.logger.info("Results count ==> "); + popoto.rest.post( + { + "statements": [ + { + "statement": popoto.query.generateResultCypherQueryCount() + }] + }) + .done(function (data) { + + if (data.errors && data.errors.length > 0) { + popoto.logger.error("Cypher query error:" + JSON.stringify(data.errors)); + } + + var count = 0; + + if (data.results && data.results.length > 0) { + count = data.results[0].data[0].row[0]; + } + + popoto.result.resultCountListeners.forEach(function (listener) { + listener(count); + }); + + }) + .fail(function (xhr, textStatus, errorThrown) { + popoto.logger.error(textStatus + ": error while accessing Neo4j server on URL:\"" + popoto.rest.CYPHER_URL + "\" defined in \"popoto.rest.CYPHER_URL\" property: " + errorThrown); + + popoto.result.resultCountListeners.forEach(function (listener) { + listener(0); + }); + }); + } + } + }; + +// NODE LABEL PROVIDERS ----------------------------------------------------------------------------------------------------- + + popoto.provider = {}; + popoto.provider.linkProvider = {}; + popoto.provider.taxonomyProvider = {}; + popoto.provider.nodeProviders = {}; + + /** + * Get the text representation of a link. + * + * @param link the link to get the text representation. + * @returns {string} the text representation of the link. + */ + popoto.provider.getLinkTextValue = function (link) { + if (popoto.provider.linkProvider.hasOwnProperty("getLinkTextValue")) { + return popoto.provider.linkProvider.getLinkTextValue(link); + } else { + if (popoto.provider.DEFAULT_LINK_PROVIDER.hasOwnProperty("getLinkTextValue")) { + return popoto.provider.DEFAULT_LINK_PROVIDER.getLinkTextValue(link); + } else { + popoto.logger.error("No provider defined for getLinkTextValue"); + } + } + }; + + /** + * Get the semantic text representation of a link. + * + * @param link the link to get the semantic text representation. + * @returns {string} the semantic text representation of the link. + */ + popoto.provider.getLinkSemanticValue = function (link) { + if (popoto.provider.linkProvider.hasOwnProperty("getLinkSemanticValue")) { + return popoto.provider.linkProvider.getLinkSemanticValue(link); + } else { + if (popoto.provider.DEFAULT_LINK_PROVIDER.hasOwnProperty("getLinkSemanticValue")) { + return popoto.provider.DEFAULT_LINK_PROVIDER.getLinkSemanticValue(link); + } else { + popoto.logger.error("No provider defined for getLinkSemanticValue"); + } + } + }; + + /** + * Label provider used by default if none have been defined for a label. + * This provider can be changed if needed to customize default behavior. + * If some properties are not found in user customized providers, default values will be extracted from this provider. + */ + popoto.provider.DEFAULT_LINK_PROVIDER = Object.freeze( + { + /** + * Function used to return the text representation of a link. + * + * The default behavior is to return the internal relation name as text for relation links. + * And return the target node text value for links between a node and its expanded values but only if text is not displayed on value node. + * + * @param link the link to represent as text. + * @returns {string} the text representation of the link. + */ + "getLinkTextValue": function (link) { + if (link.type === popoto.graph.link.LinkTypes.VALUE) { + // Links between node and list of values. + + if (popoto.provider.isTextDisplayed(link.target)) { + // Don't display text on link if text is displayed on target node. + return ""; + } else { + // No text is displayed on target node then the text is displayed on link. + return popoto.provider.getTextValue(link.target); + } + + } else { + + // Link + return link.label + } + }, + + /** + * Function used to return a descriptive text representation of a link. + * This representation should be more complete than getLinkTextValue and can contain semantic data. + * This function is used for example to generate the label in the query viewer. + * + * The default behavior is to return the getLinkTextValue. + * + * @param link the link to represent as text. + * @returns {string} the text semantic representation of the link. + */ + "getLinkSemanticValue": function (link) { + return popoto.provider.getLinkTextValue(link); + } + }); + popoto.provider.linkProvider = popoto.provider.DEFAULT_LINK_PROVIDER; + + /** + * Get the text representation of a taxonomy. + * + * @param label the label used for the taxonomy. + * @returns {string} the text representation of the taxonomy. + */ + popoto.provider.getTaxonomyTextValue = function (label) { + if (popoto.provider.taxonomyProvider.hasOwnProperty("getTextValue")) { + return popoto.provider.taxonomyProvider.getTextValue(label); + } else { + if (popoto.provider.DEFAULT_TAXONOMY_PROVIDER.hasOwnProperty("getTextValue")) { + return popoto.provider.DEFAULT_TAXONOMY_PROVIDER.getTextValue(label); + } else { + popoto.logger.error("No provider defined for taxonomy getTextValue"); + } + } + }; + + /** + * Label provider used by default if none have been defined for a label. + * This provider can be changed if needed to customize default behavior. + * If some properties are not found in user customized providers, default values will be extracted from this provider. + */ + popoto.provider.DEFAULT_TAXONOMY_PROVIDER = Object.freeze( + { + /** + * Function used to return the text representation of a taxonomy. + * + * The default behavior is to return the label without changes. + * + * @param label the label used to represent the taxonomy. + * @returns {string} the text representation of the taxonomy. + */ + "getTextValue": function (label) { + return label; + } + }); + popoto.provider.taxonomyProvider = popoto.provider.DEFAULT_TAXONOMY_PROVIDER; + + /** + * Define the different type of rendering of a node for a given label. + * TEXT: default rendering type, the node will be displayed with an ellipse and a text in it. + * IMAGE: the node is displayed as an image using the image tag in the svg graph. + * In this case an image path is required. + * SVG: the node is displayed using a list of svg path, each path can contain its own color. + */ + popoto.provider.NodeDisplayTypes = Object.freeze({TEXT: 0, IMAGE: 1, SVG: 2}); + + /** + * Get the label provider for the given label. + * If no provider is defined for the label: + * First search in parent provider. + * Then if not found will create one from default provider. + * + * @param label to retrieve the corresponding label provider. + * @returns {object} corresponding label provider. + */ + popoto.provider.getProvider = function (label) { + if (label === undefined) { + popoto.logger.error("Node label is undefined, no label provider can be found."); + } else { + if (popoto.provider.nodeProviders.hasOwnProperty(label)) { + return popoto.provider.nodeProviders[label]; + } else { + popoto.logger.debug("No direct provider found for label " + label); + + // Search in all children list definitions to find the parent provider. + for (var p in popoto.provider.nodeProviders) { + if (popoto.provider.nodeProviders.hasOwnProperty(p)) { + var provider = popoto.provider.nodeProviders[p]; + if (provider.hasOwnProperty("children")) { + if (provider["children"].indexOf(label) > -1) { + popoto.logger.debug("No provider is defined for label (" + label + "), parent (" + p + ") will be used"); + // A provider containing the required label in its children definition has been found it will be cloned. + + var newProvider = {"parent": p}; + for (var pr in provider) { + if (provider.hasOwnProperty(pr) && pr != "children" && pr != "parent") { + newProvider[pr] = provider[pr]; + } + } + + popoto.provider.nodeProviders[label] = newProvider; + return popoto.provider.nodeProviders[label]; + } + } + } + } + + popoto.logger.debug("No label provider defined for label (" + label + ") default one will be created from popoto.provider.DEFAULT_PROVIDER"); + + popoto.provider.nodeProviders[label] = {}; + // Clone default provider properties in new provider. + for (var prop in popoto.provider.DEFAULT_PROVIDER) { + if (popoto.provider.DEFAULT_PROVIDER.hasOwnProperty(prop)) { + popoto.provider.nodeProviders[label][prop] = popoto.provider.DEFAULT_PROVIDER[prop]; + } + } + return popoto.provider.nodeProviders[label]; + } + } + }; + + /** + * Get the property or function defined in node label provider. + * If the property is not found search is done in parents. + * If not found in parent, property defined in popoto.provider.DEFAULT_PROVIDER is returned. + * If not found in default provider, defaultValue is set and returned. + * + * @param label node label to get the property in its provider. + * @param name name of the property to retrieve. + * @returns {*} node property defined in its label provider. + */ + popoto.provider.getProperty = function (label, name) { + var provider = popoto.provider.getProvider(label); + + if (!provider.hasOwnProperty(name)) { + var providerIterator = provider; + + // Check parents + var isPropertyFound = false; + while (providerIterator.hasOwnProperty("parent") && !isPropertyFound) { + providerIterator = popoto.provider.getProvider(providerIterator.parent); + if (providerIterator.hasOwnProperty(name)) { + + // Set attribute in child to optimize next call. + provider[name] = providerIterator[name]; + isPropertyFound = true; + } + } + + if (!isPropertyFound) { + popoto.logger.debug("No \"" + name + "\" property found for node label provider (" + label + "), default value will be used"); + if (popoto.provider.DEFAULT_PROVIDER.hasOwnProperty(name)) { + provider[name] = popoto.provider.DEFAULT_PROVIDER[name]; + } else { + popoto.logger.error("No default value for \"" + name + "\" property found for label provider (" + label + ")"); + } + } + } + return provider[name]; + }; + + /** + * Return the "isSearchable" property for the node label provider. + * Is Searchable defined whether the label can be used as graph query builder root. + * If true the label can be displayed in the taxonomy filter. + * + * @param label + * @returns {*} + */ + popoto.provider.getIsSearchable = function (label) { + return popoto.provider.getProperty(label, "isSearchable"); + }; + + /** + * Return the list of attributes defined in node label provider. + * Parents return attributes are also returned. + * + * @param label used to retrieve parent attributes. + * @returns {Array} list of return attributes for a node. + */ + popoto.provider.getReturnAttributes = function (label) { + var provider = popoto.provider.getProvider(label); + var attributes = {}; // Object is used as a Set to merge possible duplicate in parents + + if (provider.hasOwnProperty("returnAttributes")) { + for (var i = 0; i < provider.returnAttributes.length; i++) { + if (provider.returnAttributes[i] === popoto.query.NEO4J_INTERNAL_ID) { + attributes[popoto.query.NEO4J_INTERNAL_ID.queryInternalName] = true; + } else { + attributes[provider.returnAttributes[i]] = true; + } + } + } + + // Add parent attributes + while (provider.hasOwnProperty("parent")) { + provider = popoto.provider.getProvider(provider.parent); + if (provider.hasOwnProperty("returnAttributes")) { + for (var j = 0; j < provider.returnAttributes.length; j++) { + if (provider.returnAttributes[j] === popoto.query.NEO4J_INTERNAL_ID) { + attributes[popoto.query.NEO4J_INTERNAL_ID.queryInternalName] = true; + } else { + attributes[provider.returnAttributes[j]] = true; + } + } + } + } + + // Add default provider attributes if any but not internal id as this id is added only if none has been found. + if (popoto.provider.DEFAULT_PROVIDER.hasOwnProperty("returnAttributes")) { + for (var k = 0; k < popoto.provider.DEFAULT_PROVIDER.returnAttributes.length; k++) { + if (popoto.provider.DEFAULT_PROVIDER.returnAttributes[k] !== popoto.query.NEO4J_INTERNAL_ID) { + attributes[popoto.provider.DEFAULT_PROVIDER.returnAttributes[k]] = true; + } + } + } + + // Add constraint attribute in the list + var constraintAttribute = popoto.provider.getConstraintAttribute(label); + if (constraintAttribute === popoto.query.NEO4J_INTERNAL_ID) { + attributes[popoto.query.NEO4J_INTERNAL_ID.queryInternalName] = true; + } else { + attributes[constraintAttribute] = true; + } + + + // Add all in array + var attrList = []; + for (var attr in attributes) { + if (attributes.hasOwnProperty(attr)) { + if (attr == popoto.query.NEO4J_INTERNAL_ID.queryInternalName) { + attrList.push(popoto.query.NEO4J_INTERNAL_ID); + } else { + attrList.push(attr); + } + } + } + + // If no attributes have been found internal ID is used + if (attrList.length <= 0) { + attrList.push(popoto.query.NEO4J_INTERNAL_ID); + } + return attrList; + }; + + /** + * Return the attribute to use as constraint attribute for a node defined in its label provider. + * + * @param label + * @returns {*} + */ + popoto.provider.getConstraintAttribute = function (label) { + return popoto.provider.getProperty(label, "constraintAttribute"); + }; + + /** + * Return a list of predefined constraint defined in the node label configuration. + * + * @param label + * @returns {*} + */ + popoto.provider.getPredefinedConstraints = function (label) { + return popoto.provider.getProperty(label, "getPredefinedConstraints")(); + }; + + + popoto.provider.getValueOrderByAttribute = function (label) { + return popoto.provider.getProperty(label, "valueOrderByAttribute"); + }; + + popoto.provider.isValueOrderAscending = function (label) { + return popoto.provider.getProperty(label, "isValueOrderAscending"); + }; + + popoto.provider.getResultOrderByAttribute = function (label) { + return popoto.provider.getProperty(label, "resultOrderByAttribute"); + }; + + popoto.provider.isResultOrderAscending = function (label) { + return popoto.provider.getProperty(label, "isResultOrderAscending"); + }; + + /** + * Return the value of the getTextValue function defined in the label provider corresponding to the parameter node. + * If no "getTextValue" function is defined in the provider, search is done in parents. + * If none is found in parent default provider method is used. + * + * @param node + */ + popoto.provider.getTextValue = function (node) { + return popoto.provider.getProperty(node.label, "getTextValue")(node); + }; + + + /** + * Return the value of the getTextValue function defined in the label provider corresponding to the parameter node. + * If no "getTextValue" function is defined in the provider, search is done in parents. + * If none is found in parent default provider method is used. + * + * @param node + */ + popoto.provider.getTextValue = function (node) { + return popoto.provider.getProperty(node.label, "getTextValue")(node); + }; + + /** + * Return the value of the getSemanticValue function defined in the label provider corresponding to the parameter node. + * The semantic value is a more detailed description of the node used for example in the query viewer. + * If no "getTextValue" function is defined in the provider, search is done in parents. + * If none is found in parent default provider method is used. + * + * @param node + * @returns {*} + */ + popoto.provider.getSemanticValue = function (node) { + return popoto.provider.getProperty(node.label, "getSemanticValue")(node); + }; + + /** + * Return a list of SVG paths objects, each defined by a "d" property containing the path and "f" property for the color. + * + * @param node + * @returns {*} + */ + popoto.provider.getSVGPaths = function (node) { + return popoto.provider.getProperty(node.label, "getSVGPaths")(node); + }; + + /** + * Check in label provider if text must be displayed with images nodes. + * @param node + * @returns {*} + */ + popoto.provider.isTextDisplayed = function (node) { + return popoto.provider.getProperty(node.label, "getIsTextDisplayed")(node); + }; + + /** + * Return the getIsGroup property. + * + * @param node + * @returns {*} + */ + popoto.provider.getIsGroup = function (node) { + return popoto.provider.getProperty(node.label, "getIsGroup")(node); + }; + + /** + * Return the node display type. + * can be TEXT, IMAGE, SVG or GROUP. + * + * @param node + * @returns {*} + */ + popoto.provider.getNodeDisplayType = function (node) { + return popoto.provider.getProperty(node.label, "getDisplayType")(node); + }; + + /** + * Return the file path of the image defined in the provider. + * + * @param node the node to get the image path. + * @returns {string} the path of the node image. + */ + popoto.provider.getImagePath = function (node) { + return popoto.provider.getProperty(node.label, "getImagePath")(node); + }; + + /** + * Return the width size of the node image. + * + * @param node the node to get the image width. + * @returns {int} the image width. + */ + popoto.provider.getImageWidth = function (node) { + return popoto.provider.getProperty(node.label, "getImageWidth")(node); + }; + + /** + * Return the height size of the node image. + * + * @param node the node to get the image height. + * @returns {int} the image height. + */ + popoto.provider.getImageHeight = function (node) { + return popoto.provider.getProperty(node.label, "getImageHeight")(node); + }; + + /** + * Return the displayResults function defined in label parameter's provider. + * + * @param label + * @returns {*} + */ + popoto.provider.getDisplayResultFunction = function (label) { + return popoto.provider.getProperty(label, "displayResults"); + }; + + /** + * Select the label if there is more than one. + * + * Discards the label with an underscore. + */ + popoto.provider.getLabelFilter = function (nodeLabel) { + if (Array.isArray(nodeLabel)) { + // use last label + var label = nodeLabel[nodeLabel.length - 1]; + if (label.indexOf('_') != -1 && nodeLabel.length > 1) { + // skip if wrong label + label = nodeLabel[nodeLabel.length - 2]; + } + // replace array with string + nodeLabel = label; + } + return nodeLabel; + } + + /** + * Label provider used by default if none have been defined for a label. + * This provider can be changed if needed to customize default behavior. + * If some properties are not found in user customized providers, default values will be extracted from this provider. + */ + popoto.provider.DEFAULT_PROVIDER = Object.freeze( + { + /********************************************************************** + * Label specific parameters: + * + * These attributes are specific to a node label and will be used for every node having this label. + **********************************************************************/ + + /** + * Defines whether this label can be used as root element of the graph query builder. + * This property is also used to determine whether the label can be displayed in the taxonomy filter. + * + * The default value is true. + */ + "isSearchable": true, + + /** + * Defines the list of attribute to return for node of this label. + * All the attributes listed here will be added in generated cypher queries and available in result list and in node provider's functions. + * + * The default value contains only the Neo4j internal id. + * This id is used by default because it is a convenient way to identify a node when nothing is known about its attributes. + * But you should really consider using your application attributes instead, it is a bad practice to rely on this attribute. + */ + "returnAttributes": [popoto.query.NEO4J_INTERNAL_ID], + + /** + * Defines the attribute used to order the value displayed on node. + * + * Default value is "count" attribute. + */ + "valueOrderByAttribute": "count", + + /** + * Defines whether the value query order by is ascending, if false order by will be descending. + * + * Default value is false (descending) + */ + "isValueOrderAscending": false, + + /** + * Defines the attribute used to order the results. + * + * Default value is "null" to disable order by. + */ + "resultOrderByAttribute": null, + + /** + * Defines whether the result query order by is ascending, if false order by will be descending. + * + * Default value is true (ascending) + */ + "isResultOrderAscending": true, + + /** + * Defines the attribute of the node to use in query constraint for nodes of this label. + * This attribute is used in the generated cypher query to build the constraints with selected values. + * + * The default value is the Neo4j internal id. + * This id is used by default because it is a convenient way to identify a node when nothing is known about its attributes. + * But you should really consider using your application attributes instead, it is a bad practice to rely on this attribute. + */ + "constraintAttribute": popoto.query.NEO4J_INTERNAL_ID, + + /** + * Defines the attribute of the node to display as a text identifying the node. + * + * The default value is the Neo4j internal id. + */ + "displayAttribute": popoto.query.NEO4J_INTERNAL_ID, + + /** + * Return the list of predefined constraints to add for the given label. + * These constraints will be added in every generated Cypher query. + * + * For example if the returned list contain ["$identifier.born > 1976"] for "Person" nodes everywhere in popoto.js the generated Cypher query will add the constraint + * "WHERE person.born > 1976" + * + * @returns {Array} + */ + "getPredefinedConstraints": function () { + return []; + }, + + /********************************************************************** + * Node specific parameters: + * + * These attributes are specific to nodes (in graph or query viewer) for a given label. + * But they can be customized for nodes of the same label. + * The parameters are defined by a function that will be called with the node as parameter. + * In this function the node internal attributes can be used to customize the value to return. + **********************************************************************/ + + /** + * Function returning the display type of a node. + * This type defines how the node will be drawn in the graph. + * + * The result must be one of the following values: + * + * popoto.provider.NodeDisplayTypes.IMAGE + * In this case the node will be drawn as an image and "getImagePath" function is required to return the node image path. + * + * popoto.provider.NodeDisplayTypes.SVG + * In this case the node will be drawn as SVG paths and "getSVGPaths" + * + * popoto.provider.NodeDisplayTypes.TEXT + * In this case the node will be drawn as a simple ellipse. + * + * Default value is TEXT. + * + * @param node the node to extract its type. + * @returns {number} one value from popoto.provider.NodeDisplayTypes + */ + "getDisplayType": function (node) { + return popoto.provider.NodeDisplayTypes.TEXT; + }, + + /** + * Function defining whether the node is a group node. + * In this case no count are displayed and no value can be selected on the node. + * + * Default value is false. + */ + "getIsGroup": function (node) { + return false; + }, + + /** + * Function defining whether the node text representation must be displayed on graph. + * If true the value returned for getTextValue on node will be displayed on graph. + * + * This text will be added in addition to the getDisplayType representation. + * It can be displayed on all type of node display, images, SVG or text. + * + * Default value is true + * + * @param node the node to display on graph. + * @returns {boolean} true if text must be displayed on graph for the node. + */ + "getIsTextDisplayed": function (node) { + return true; + }, + + /** + * Function used to return the text representation of a node. + * + * The default behavior is to return the label of the node + * or the value of constraint attribute of the node if it contains value. + * + * The returned value is truncated using popoto.graph.node.NODE_MAX_CHARS property. + * + * @param node the node to represent as text. + * @returns {string} the text representation of the node. + */ + "getTextValue": function (node) { + var text; + var textAttr = popoto.provider.getProperty(node.label, "displayAttribute"); + if (node.type === popoto.graph.node.NodeTypes.VALUE) { + if (textAttr === popoto.query.NEO4J_INTERNAL_ID) { + text = "" + node.internalID; + } else { + text = "" + node.attributes[textAttr]; + } + } else { + if (node.value === undefined) { + text = node.label; + } else { + if (textAttr === popoto.query.NEO4J_INTERNAL_ID) { + text = "" + node.value.internalID; + } else { + text = "" + node.value.attributes[textAttr]; + } + } + } + // Text is truncated to fill the ellipse + return text.substring(0, popoto.graph.node.NODE_MAX_CHARS); + }, + + /** + * Function used to return a descriptive text representation of a link. + * This representation should be more complete than getTextValue and can contain semantic data. + * This function is used for example to generate the label in the query viewer. + * + * The default behavior is to return the getTextValue not truncated. + * + * @param node the node to represent as text. + * @returns {string} the text semantic representation of the node. + */ + "getSemanticValue": function (node) { + var text; + var textAttr = popoto.provider.getProperty(node.label, "displayAttribute"); + if (node.type === popoto.graph.node.NodeTypes.VALUE) { + if (textAttr === popoto.query.NEO4J_INTERNAL_ID) { + text = "" + node.internalID; + } else { + text = "" + node.attributes[textAttr]; + } + } else { + if (node.value === undefined) { + text = node.label; + } else { + if (textAttr === popoto.query.NEO4J_INTERNAL_ID) { + text = "" + node.value.internalID; + } else { + text = "" + node.value.attributes[textAttr]; + } + } + } + return text; + }, + + /** + * Function returning the image file path to use for a node. + * This function is only used for popoto.provider.NodeDisplayTypes.IMAGE type nodes. + * + * @param node + * @returns {string} + */ + "getImagePath": function (node) { + if (node.type === popoto.graph.node.NodeTypes.VALUE) { + return "css/image/node-yellow.png"; + } else { + if (node.value === undefined) { + if (node.type === popoto.graph.node.NodeTypes.ROOT) { + return "css/image/node-blue.png"; + } + if (node.type === popoto.graph.node.NodeTypes.CHOOSE) { + return "css/image/node-green.png"; + } + if (node.type === popoto.graph.node.NodeTypes.GROUP) { + return "css/image/node-black.png"; + } + } else { + return "css/image/node-orange.png"; + } + } + }, + + /** + * Function returning the image width of the node. + * This function is only used for popoto.provider.NodeDisplayTypes.IMAGE type nodes. + * + * @param node + * @returns {number} + */ + "getImageWidth": function (node) { + return 125; + }, + + /** + * Function returning the image height of the node. + * This function is only used for popoto.provider.NodeDisplayTypes.IMAGE type nodes. + * + * @param node + * @returns {number} + */ + "getImageHeight": function (node) { + return 125; + }, + + /********************************************************************** + * Results specific parameters: + * + * These attributes are specific to result display. + **********************************************************************/ + + /** + * Generate the result entry content using d3.js mechanisms. + * + * The parameter of the function is the <p> selected with d3.js + * + * The default behavior is to generate a <table> containing all the return attributes in a <th> and their value in a <td>. + * + * @param pElmt the <p> element generated in the result list. + */ + "displayResults": function (pElmt) { + var result = pElmt.data()[0]; + + var returnAttributes = popoto.provider.getReturnAttributes(result.label); + + var table = pElmt.append("table").attr("class", "ppt-result-table"); + + returnAttributes.forEach(function (attribute) { + var tr = table.append("tr"); + tr.append("th").text(function () { + return attribute + ":"; + }); + if (result.attributes[attribute] !== undefined) { + tr.append("td").text(function (result) { + return result.attributes[attribute]; + }); + } + }); + } + + }); + + return popoto; +}(); \ No newline at end of file