view_D3GraphCanvas.js

import { Graph } from '../model/Graph';

import * as d3 from 'd3';
import { getUriLocalname, tokenizeURI } from '@ud-viz/utils_browser';
import * as THREE from 'three';

export class D3GraphCanvas extends THREE.EventDispatcher {
  /**
   * Create a new D3 graph from a JSON object.
   * Adapted from https://observablehq.com/@d3/force-directed-graph#chart and
   * https://www.d3indepth.com/zoom-and-pan/
   *
   * @param {object} config The sparqlModule configuration.
   * @param {number} config.height The SVG canvas height.
   * @param {number} config.width The SVG canvas width.
   * @param {number} config.fontSize The font size to use for node and link labels.
   * @param {object} config.namespaceLabels Prefix declarations which will replace text labels in the Legend. This doesn't (yet) affect the legend font size.
   * @param {Function} handleZoom The function that handles the zoom.
   * @param {Function} formatResponse The function that formats the response from JSON into a list of nodes and links.
   */
  constructor(config, handleZoom, formatResponse) {
    super();
    this.id = THREE.MathUtils.generateUUID();
    this.height = config.height || 800;
    this.width = config.width || 1500;
    this.fontSize = config.fontSize || 4;
    this.fontFamily = config.fontFamily || 'Arial';
    this.strokeWidth = config.strokeWidth || 0.75;
    this.nodeSize = config.nodeSize || 7;
    this.defaultColor = config.defaultColor || 'white';
    this.linkColor = config.linkColor || '#999';
    this.nodeStrokeColor = config.nodeStrokeColor || 'black';
    this.fontSizeLegend = config.fontSizeLegend || 15;

    this.knownNamespaceLabels = config.namespaceLabels;
    this.svg = d3 // the svg in which the graph is displayed
      .create('svg')
      .attr('class', 'd3_graph')
      .attr('id', this.id)
      .attr('viewBox', [0, 0, this.width, this.height])
      .style('display', 'hidden');
    this.data = new Graph();

    this.tooltip = d3
      .create('div')
      .style('visibility', 'hidden')
      .attr('class', 'tooltip')
      .style('background-color', 'white')
      .style('border', 'solid')
      .style('border-width', '2px')
      .style('border-radius', '5px')
      .style('position', 'absolute')
      .style('padding', '5px');

    if (handleZoom == undefined) {
      this.handleZoom = (ev) => {
        d3.selectAll('g.graph')
          .attr('height', '100%')
          .attr('width', '100%')
          .attr(
            'transform',
            'translate(' +
              ev.transform.x +
              ',' +
              ev.transform.y +
              ') scale(' +
              ev.transform.k +
              ')'
          );
      };
    } else {
      this.handleZoom = handleZoom;
    }

    if (formatResponse == undefined) {
      this.formatResponse = (response, graph) => {
        /* If the query is formatted using subject, predicate, object, and optionally
            subjectType and objectType variables the node color based on the type of the
            subject or object's respective type */
        if (
          !response.head.vars.includes('subject') ||
          !response.head.vars.includes('predicate') ||
          !response.head.vars.includes('object')
        ) {
          throw (
            'Missing endpoint response bindings for graph construction. Needs at least "subject", "predicate", "object". Found binding: ' +
            response.head.vars
          );
        }
        const getNodeColorId = (type) => {
          const colorScale = d3
            .scaleOrdinal()
            .domain([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
            .range(d3.schemeCategory10); // d3.schemeCategory10 returns an array of 10 colors and d3.scaleOrdinal is used to create an ordinal scale
          if (!this.data.typeList.includes(type)) {
            this.data.typeList.push(type);
            this.data.legend.push({
              type: type,
              color: colorScale(this.data.typeList.findIndex((d) => d == type)),
            });
          }
          return colorScale(this.data.typeList.findIndex((d) => d == type));
        };
        for (const triple of response.results.bindings) {
          if (
            // if the subject doesn't exist yet
            graph.nodes.find((n) => n.id == triple.subject.value) == undefined
          ) {
            const node = { id: triple.subject.value };
            if (
              // if there is a subjectType assign a type and color id
              triple.subjectType
            ) {
              node.type = getUriLocalname(triple.subjectType.value);
              node.color_id = getNodeColorId(node.type);
            }
            graph.nodes.push(node);
          }
          if (
            // if the object doesn't exist yet
            graph.nodes.find((n) => n.id == triple.object.value) == undefined
          ) {
            const node = { id: triple.object.value };
            if (
              // if there is an objectType assign a color id
              triple.objectType
            ) {
              node.type = getUriLocalname(triple.objectType.value);
              node.color_id = getNodeColorId(node.type);
            }
            graph.nodes.push(node);
          }
          const link = {
            source: triple.subject.value,
            target: triple.object.value,
            label: triple.predicate.value,
          };
          graph.links.push(link);
        }
      };
    } else {
      this.formatResponse = formatResponse;
    }
  }

  // / Data Functions ///

  /**
   * Generate the label of a clustered node
   *
   * @param {object} node a node
   * @param {D3GraphCanvas} graph this
   * @returns {string} the desired label of the node
   */
  generateClusterLabel(node, graph) {
    const map = new Map();
    if (graph.possessCycle(node.id, map))
      return (
        getUriLocalname(node.id) + ' [' + node.child.length.toString() + ']'
      );
    return (
      getUriLocalname(node.id) +
      ' [' +
      graph.generateDescendantList(node.id, []).length.toString() +
      ']'
    );
  }

  /**
   * Retrieve the ID of all the node's descendants
   *
   * @param {string} node_id a node ID
   * @param {Array} list an empty list
   * @returns {Array} the list of all the node's descendants
   */
  generateDescendantList(node_id, list) {
    const allNodes = this.data.nodes.concat(this.data._nodes);
    const node = allNodes.find((element) => {
      return element.id == node_id;
    });
    if (node != undefined && node.child) {
      for (const child_id of node.child) {
        if (
          list.find((element) => {
            return element == child_id;
          }) == undefined
        ) {
          list.push(child_id);
          this.generateDescendantList(child_id, list);
        }
      }
    }
    return list;
  }

  /**
   * Return true if the graph possesses a cycle, false otherwise
   *
   * @param {string} node_id a node ID
   * @param {Map} map an empty map at first
   * @returns {boolean} the result
   */
  possessCycle(node_id, map) {
    const allNodes = this.data.nodes.concat(this.data._nodes);
    if (map.size < allNodes.length) {
      for (const node of allNodes) {
        map.set(node.id, 'white');
      }
    }
    const node = allNodes.find((element) => {
      return element.id == node_id;
    });
    if (node != undefined) {
      map.set(node.id, 'grey');
      if (node.child != undefined) {
        for (const child_id of node.child) {
          if (map.get(child_id) == 'grey') {
            return true;
          } else if (map.get(child_id) == 'white') {
            if (this.possessCycle(child_id, map)) {
              return true;
            }
          }
        }
      }
      map.set(node.id, 'black');
      return false;
    }
    console.log('[haveCycle] node undefined');
    return undefined;
  }

  /**
   * Create and initialize the 'child' and 'parent' properties for all nodes
   *
   */
  addChildParent() {
    for (const link of this.data.links) {
      const source = this.data.nodes.find((element) => {
        return element.id == link.source;
      });
      const target = this.data.nodes.find((element) => {
        return element.id == link.target;
      });

      if (target != undefined && source != undefined) {
        if (!('parent' in target)) {
          target.parent = [];
        }
        if (!('child' in source)) {
          source.child = [];
        }
        if (
          target.parent.find((element) => {
            return element == source.id;
          }) == undefined
        ) {
          target.parent.push(source.id);
        }
        if (
          source.child.find((element) => {
            return element == target.id;
          }) == undefined
        ) {
          source.child.push(target.id);
        }
      }
    }

    let cyclic = false;

    for (const node of this.data.nodes) {
      const map = new Map();
      if (this.possessCycle(node.id, map)) {
        cyclic = true;
      }
    }

    if (!cyclic) {
      for (const node of this.data.nodes) {
        if (!('parent' in node)) {
          node.group = 0;
        }
      }

      let modif = true;
      let i = 0;

      while (modif) {
        modif = false;
        for (const node of this.data.nodes) {
          if (node.group == i) {
            if (node.child != undefined) {
              for (const childNodeId of node.child) {
                const childNode = this.data.nodes.find((element) => {
                  return element.id == childNodeId;
                });
                childNode.group = i + 1;
                modif = true;
              }
            }
          }
        }
        i++;
      }
    } else {
      for (const node of this.data.nodes) {
        node.group = 0;
      }
    }
  }

  /**
   * Return true if any parent of the node is a cluster, false otherwise
   *
   * @param {string} node_id a node ID
   * @returns {boolean} the result
   */
  OneParentCluster(node_id) {
    const allNodes = this.data.nodes.concat(this.data._nodes);
    const node = allNodes.find((element) => {
      return element.id == node_id;
    });
    if (node.parent != undefined) {
      for (const parent_id of node.parent) {
        const parent = allNodes.find((element) => {
          return element.id == parent_id;
        });
        if (parent != undefined) {
          if (parent.cluster) {
            return true;
          }
        }
      }
    }
    return false;
  }

  /**
   * Return true if any parent of the node is visible, false otherwise
   *
   * @param {string} node_id a node ID
   * @returns {boolean} the result
   */
  OneParentVisible(node_id) {
    const allNodes = this.data.nodes.concat(this.data._nodes);
    const node = allNodes.find((element) => {
      return element.id == node_id;
    });
    if (node != undefined) {
      if (node.parent != undefined) {
        for (const parent_id of node.parent) {
          const parent = this.data.nodes.find(
            (element) => element.id == parent_id
          );
          if (parent != undefined) {
            return true;
          }
        }
      }
    } else {
      console.debug('[OneParentVisible] node undefined: ', node_id);
    }
    return false;
  }

  /**
   * Change the state of the node from simple node to cluster, or the opposite
   *
   * @param {string} node_id a node ID
   */
  changeVisibilityDescendants(node_id) {
    const allNodes = this.data.nodes.concat(this.data._nodes);
    const node = allNodes.find((d) => d.id == node_id);
    if (node != undefined) {
      if (node.child != undefined) {
        node.cluster = node.cluster != true;
        const descendants = [];
        this.generateDescendantList(node_id, descendants);
        if (node.cluster) {
          for (const descendant_id of descendants) {
            this.hideNode(descendant_id);
          }
          for (const descendant_id of descendants) {
            const nodeLinks = [];
            this.data.links.forEach((element) => {
              if (
                element.source.id == descendant_id ||
                element.target.id == descendant_id
              ) {
                nodeLinks.push(element);
              }
            });
            for (const link of nodeLinks) {
              this.hideLink(link);
            }
          }
          let link;
          this.data._links.forEach((element) => {
            if (
              node.child.includes(element.source) &&
              element.source != node.id
            ) {
              link = this.createNewLink(
                node_id,
                element.target,
                node_id + '_children_cluster'
              );
              if (link) link.realLink = false;
            }
            if (
              node.child.includes(element.target) &&
              element.target != node.id
            ) {
              link = this.createNewLink(
                element.source,
                node_id,
                node_id + '_children_cluster'
              );
              if (link) link.realLink = false;
            }
          });
        } else {
          for (const descendant_id of descendants) {
            if (
              this.OneParentVisible(descendant_id) &&
              !this.OneParentCluster(descendant_id)
            ) {
              this.showNode(descendant_id);
            }
          }
          for (const descendant_id of descendants) {
            if (
              this.OneParentVisible(descendant_id) &&
              !this.OneParentCluster(descendant_id)
            ) {
              const nodeLinks = [];
              this.data._links.forEach((element) => {
                if (
                  (element.source == descendant_id ||
                    element.target == descendant_id) &&
                  this.data.nodes.find((d) => d.id == element.source) !=
                    undefined &&
                  this.data.nodes.find((d) => d.id == element.target) !=
                    undefined
                ) {
                  nodeLinks.push(element);
                }
              });
              for (const link of nodeLinks) {
                this.showLink(link);
              }
            }
          }
          this.data.links.forEach((element) => {
            if (element.label == node_id + '_children_cluster') {
              this.removeLink(element);
            }
          });
        }
      }
      this.setLinkIndexAndNum();
    } else {
      console.debug('[changeVisibilityDescendant] node undefined: ', node_id);
    }
  }

  /**
   * Change the state of the node from simple node to cluster, or the opposite
   *
   * @param {string} node_id a node ID
   */
  changeVisibilityChildren(node_id) {
    const allNodes = this.data.nodes.concat(this.data._nodes);
    const node = allNodes.find((d) => d.id == node_id);
    if (node != undefined) {
      if (node.child != undefined) {
        node.cluster = node.cluster != true;
        if (node.cluster) {
          for (const child_id of node.child) {
            this.hideNode(child_id);
          }
          for (const child_id of node.child) {
            const nodeLinks = [];
            this.data.links.forEach((element) => {
              if (
                element.source.id == child_id ||
                element.target.id == child_id
              ) {
                nodeLinks.push(element);
              }
            });
            for (const link of nodeLinks) {
              this.hideLink(link);
            }
          }
          let link;
          this.data._links.forEach((element) => {
            if (
              node.child.includes(element.source) &&
              element.source != node.id
            ) {
              link = this.createNewLink(
                node_id,
                element.target,
                node_id + '_children_cluster'
              );
              if (link) link.realLink = false;
            }
            if (
              node.child.includes(element.target) &&
              element.target != node.id
            ) {
              link = this.createNewLink(
                element.source,
                node_id,
                node_id + '_children_cluster'
              );
              if (link) link.realLink = false;
            }
          });
        } else {
          for (const child_id of node.child) {
            if (
              this.OneParentVisible(child_id) &&
              !this.OneParentCluster(child_id)
            ) {
              this.showNode(child_id);
            }
          }
          for (const child_id of node.child) {
            if (
              this.OneParentVisible(child_id) &&
              !this.OneParentCluster(child_id)
            ) {
              const nodeLinks = [];
              this.data._links.forEach((element) => {
                if (
                  (element.source == child_id || element.target == child_id) &&
                  this.data.nodes.find((d) => d.id == element.source) !=
                    undefined &&
                  this.data.nodes.find((d) => d.id == element.target) !=
                    undefined
                ) {
                  nodeLinks.push(element);
                }
              });
              for (const link of nodeLinks) {
                this.showLink(link);
              }
            }
          }
          this.data.links.forEach((element) => {
            if (element.label == node_id + '_children_cluster') {
              this.removeLink(element);
            }
          });
        }
      }
      this.setLinkIndexAndNum();
    } else {
      console.debug('[changeVisibilityChildren] node undefined: ', node_id);
    }
  }

  /**
   * Create a new link and add it to the graph
   *
   * @param {string} source source of the link
   * @param {string} target target of the link
   * @param {string} label label of the link
   * @returns {object} the created link
   */
  createNewLink(source, target, label) {
    if (
      source != target &&
      this.data.nodes.find((element) => element.id == source) != undefined &&
      this.data.nodes.find((element) => element.id == target) != undefined
    ) {
      const link = {};
      link.source = source;
      link.target = target;
      link.label = label;
      this.data.links.push(link);
      this.setLinkIndexAndNum();
      return link;
    }
    return null;
  }

  /**
   * Create a new node and add it to the graph
   *
   * @param {string} node_id a node ID
   * @returns {object} the created node
   */
  createNewNode(node_id) {
    const node = {};
    node.id = node_id;
    node.cluster = false;
    node.display = true;
    this.data.nodes.push(node);
    return node;
  }

  /**
   * Remove the node from the graph
   *
   * @param {string} node_id a node ID
   */
  removeNode(node_id) {
    const allNodes = this.data.nodes.concat(this.data._nodes);
    const node = this.data.nodes.find((element) => {
      return element.id == node_id;
    });
    if (node != undefined) {
      this.data.nodes = this.data.nodes.filter((d) => d.id != node.id);
      for (const child_id of node.child) {
        const child = allNodes.find((element) => {
          return element.id == child_id;
        });
        child.parent = child.parent.filter((d) => d != node.id);
      }
      this.data.links = this.data.links.filter(
        (d) => d.source.id != node_id && d.target.id != node_id
      );
      this.data._links = this.data._links.filter(
        (d) => d.source != node_id && d.target != node_id
      );
      this.update();
    } else {
      console.debug('[removeNode] node undefined: ', node_id);
    }
  }

  /**
   * Remove the link from the graph
   *
   * @param {object} link the link
   */
  removeLink(link) {
    this.data.links = this.data.links.filter((d) => d != link);
    this.data._links = this.data._links.filter((d) => d != link);
    this.setLinkIndexAndNum();
  }

  /**
   * Create a new cluster and add it to the graph
   *
   * @param {string} cluster_id the ID of the created cluster
   * @param {Array} nodes_id an array of node IDs
   * @param {string} source_id the ID of the node to which the created cluster will be linked
   * @returns {object} the created cluster
   */
  createNewCluster(cluster_id, nodes_id, source_id = undefined) {
    const cluster = this.createNewNode(cluster_id);
    cluster.cluster = false;
    cluster.child = nodes_id;
    cluster.realNode = false;
    const allNodes = this.data.nodes.concat(this.data._nodes);
    for (const node_id of nodes_id) {
      const node = allNodes.find((element) => {
        return element.id == node_id;
      });
      if (!('parent' in node)) {
        node.parent = [cluster_id];
      } else {
        node.parent.push(cluster_id);
      }
    }
    if (source_id != undefined) {
      this.createNewLink(source_id, cluster_id, 'isCluster');
      const source = this.data.nodes.find((element) => {
        return element.id == source_id;
      });
      source.child.push(cluster_id);
      cluster.parent = [source_id];
    }
    this.changeVisibilityChildren(cluster_id);
    return cluster;
  }

  /**
   * Hide the node from the graph
   *
   * @param {string} node_id a node ID
   */
  hideNode(node_id) {
    const node = this.data.nodes.find((element) => {
      return element.id == node_id;
    });
    if (node != undefined) {
      const node_copy = { ...node };
      this.data.nodes = this.data.nodes.filter((element) => {
        return element.id != node_id;
      });
      const propertiesToDelete = ['index', 'vx', 'vy', 'x', 'y'];
      propertiesToDelete.forEach((prop) => delete node_copy[prop]);
      this.data._nodes.push(node_copy);
    } else {
      console.debug('[hideNode] node undefined: ', node_id);
    }
  }

  /**
   * Show the hidden node
   *
   * @param {string} node_id a node ID
   */
  showNode(node_id) {
    const node = this.data._nodes.find((element) => {
      return element.id == node_id;
    });
    if (node != undefined) {
      const node_copy = { ...node };
      this.data._nodes = this.data._nodes.filter((element) => {
        return element.id != node_id;
      });
      this.data.nodes.push(node_copy);
    } else {
      console.debug('[showNode] node undefined: ', node_id);
    }
  }

  /**
   * Hide the link from the graph
   *
   * @param {object} link a link
   */
  hideLink(link) {
    if (link != undefined) {
      const link_copy = { ...link };
      this.data.links = this.data.links.filter((element) => {
        return element != link;
      });
      delete link_copy['index'];
      link_copy.source = link_copy.source.id;
      link_copy.target = link_copy.target.id;
      this.data._links.push(link_copy);
    } else {
      console.debug('[hideLink] link undefined: ', link);
    }
  }

  /**
   * Show the hidden link of the graph
   *
   * @param {object} link a link
   */
  showLink(link) {
    if (link != undefined) {
      const link_copy = { ...link };
      this.data._links = this.data._links.filter((element) => {
        return element != link;
      });
      this.data.links.push(link_copy);
    } else {
      console.debug('[showLink] link undefined: ', link);
    }
  }

  /**
   * Return a list of the node's children types
   *
   * @param {string} node_id a node ID
   * @returns {Array} the list
   */
  getChildrenType(node_id) {
    const allNodes = this.data.nodes.concat(this.data._nodes);
    const node = allNodes.find((element) => {
      return element.id == node_id;
    });
    const childrenType = [];
    if (node != undefined) {
      for (const child_id of node.child) {
        const child = this.data.nodes.find((element) => {
          return element.id == child_id;
        });
        if (
          child != undefined &&
          child.type != undefined &&
          childrenType.find((element) => {
            return element == child.type;
          }) == undefined
        ) {
          childrenType.push(child.type);
        }
      }
    }
    return childrenType;
  }

  /**
   * Return a list of the different types of nodes
   *
   * @returns {Array} the list
   */
  getTypeList() {
    const allNodes = this.data.nodes.concat(this.data._nodes);
    const typeList = [];
    for (const node of allNodes) {
      if (
        node != undefined &&
        node.type != undefined &&
        typeList.find((element) => {
          return element == node.type;
        }) == undefined
      ) {
        typeList.push(node.type);
      }
    }
    return typeList;
  }

  /**
   * Returns the list of children of the node of this type
   *
   * @param {string} node_id a node ID
   * @param {string} type a type
   * @returns {Array} the list
   */
  getChildrenByType(node_id, type) {
    const allNodes = this.data.nodes.concat(this.data._nodes);
    const node = allNodes.find((element) => {
      return element.id == node_id;
    });
    const children = [];
    if (node != undefined) {
      for (const child_id of node.child) {
        const child = allNodes.find((element) => {
          return element.id == child_id;
        });
        if (child != undefined && child.type == type) {
          children.push(child_id);
        }
      }
    }
    return children;
  }

  /**
   * Return a list of node IDs whose group is equal to groupIndex
   *
   * @param {number} groupIndex the index of the group
   * @returns {Array} the list
   */
  getNodeByGroup(groupIndex) {
    const allNodes = this.data.nodes.concat(this.data._nodes);
    const nodes = [];
    for (const node of allNodes) {
      if (node.group == groupIndex) {
        nodes.push(node.id);
      }
    }
    return nodes;
  }

  /**
   * Return a list of node IDs whose type is equal to typeName
   *
   * @param {string} typeName the name of the type
   * @returns {Array} the list
   */
  getNodeByType(typeName) {
    const allNodes = this.data.nodes.concat(this.data._nodes);
    const nodes = [];
    for (const node of allNodes) {
      if (node.type == typeName) {
        nodes.push(node.id);
      }
    }
    return nodes;
  }

  /**
   * Set the link index and the number of links between two nodes
   *
   */
  setLinkIndexAndNum() {
    this.linkNum = {};
    for (const link of this.data.links) {
      const source = link.source.id || link.source;
      const target = link.target.id || link.target;
      if (this.linkNum[source + ',' + target] == undefined) {
        this.linkNum[source + ',' + target] = 1;
      } else {
        this.linkNum[source + ',' + target] =
          this.linkNum[source + ',' + target] + 1;
      }
      link.linkindex = this.linkNum[source + ',' + target];
    }
  }

  /**
   * Initialize the d3 SVG canvas based on the data from a graph dataset
   *
   * @param {object} response an RDF JSON object ideally formatted by this.formatResponseData().
   */
  init(response) {
    this.formatResponse(response, this.data);
    this.setLinkIndexAndNum();

    this.addChildParent();
    for (const node of this.data.nodes) {
      node.cluster = false;
      node.realNode = true;
      node.display = true;
    }

    for (const link of this.data.links) {
      link.realLink = true;
    }

    this.g = this.svg.append('g').attr('class', 'graph');
    this.link = this.g.append('g').selectAll('.link');
    this.nodeCircle = this.g.append('g').selectAll('.node');
    this.nodeCluster = this.g.append('g').selectAll('.node');
    this.label = this.svg.append('g').attr('class', 'graph');

    this.distanceLink = 30;
    this.chargeStrength = -40;
    this.forceCenter = 0.1;

    this.simulation = d3
      .forceSimulation(this.data.nodes) // defines simulation nodes
      .force(
        'link',
        d3
          .forceLink(this.data.links)
          .id((d) => d.id) // tells d3 how to identify nodes
          .distance(this.distanceLink)
      )
      .force('charge', d3.forceManyBody().strength(this.chargeStrength)) // adds a repulsive force between the nodes
      .force('x', d3.forceX(this.width / 2).strength(this.forceCenter))
      .force('y', d3.forceY(this.height / 2).strength(this.forceCenter))
      .force('collide', d3.forceCollide(5))
      .alphaTarget(1)
      .on('tick', () => this.ticked(this));

    // adds an event handler for zoom management
    const zoom = d3.zoom().on('zoom', (event) => {
      if (this.handleZoom.length == 1) {
        this.handleZoom(event);
      } else {
        this.handleZoom(event, this);
      }
    });

    this.svg.call(zoom);

    this.node_label_cluster = this.label.selectAll('.node_label_cluster');
    this.node_label = this.label.selectAll('.node_label');
    this.link_label = this.label.selectAll('.link_label');

    // create legend
    if (this.data.legend.length > 0) {
      this.svg
        .append('text')
        .attr('x', 12)
        .attr('y', 24)
        .style('font-size', this.fontSizeLegend)
        .style('text-decoration', 'underline')
        .text('Legend');

      // legend colors
      this.svg
        .append('g')
        .attr('stroke', '#111')
        .attr('stroke-width', 1)
        .selectAll('rect')
        .data(this.data.legend)
        .join('rect')
        .attr('x', 12)
        .attr('y', (d, i) => 32 + i * 16)
        .attr('width', 10)
        .attr('height', 10)
        .style('fill', (d) => d.color)
        .append('title')
        .text((d) => d);

      // legend text
      this.svg
        .append('g')
        .selectAll('text')
        .data(this.data.legend)
        .join('text')
        .attr('x', 26)
        .attr('y', (d, i) => 41 + i * 16)
        .text((d) => d.type)
        .style('font-size', this.fontSizeLegend);
    }

    this.svg
      .append('defs')
      .append('marker')
      .attr('id', 'arrowhead')
      .attr('viewBox', '-0 -5 10 10')
      .attr('orient', 'auto')
      .attr('markerWidth', (this.strokeWidth / 0.75) * 4)
      .attr('markerHeight', 10)
      .attr('refX', 5)
      .attr('xoverflow', 'visible')
      .append('svg:path')
      .attr('d', 'M 0 0 L 0,5 L 10,0 L 0,-5')
      .attr('fill', this.linkColor)
      .style('stroke', 'none');

    this.update();
    this.dispatchEvent({
      type: 'graph_initialized',
      message: 'd3Graph init finished',
      event: null,
      graphId: this.id,
    });
  }

  /**
   * Update the forces of the simulation
   *
   */
  updateForceSimulation() {
    this.simulation.force('link').distance(this.distanceLink);
    this.simulation.force('charge').strength(this.chargeStrength);
    this.simulation.force('x').strength(this.forceCenter);
    this.simulation.force('y').strength(this.forceCenter);
    this.simulation.alpha(1).restart();
  }

  /**
   * Clear and update the d3 SVG canvas based on the data from a graph dataset. Also apply event dispatchers
   *
   *
   */
  update() {
    const hexToDec = function (hex) {
      const code = hex.charCodeAt(0);
      if (code >= 97) return code - 87;
      return parseInt(hex);
    };

    const htmlTooltip = function (data, propertiesOff = []) {
      let str = '';
      for (const property in data) {
        if (!propertiesOff.includes(property))
          if (property != 'source' && property != 'target')
            str = str + `<strong>${property}:</strong> ${data[property]}<br>`;
          else
            str =
              str + `<strong>${property}:</strong> ${data[property]['id']}<br>`;
      }
      return str;
    };

    const modifyColorTint = function (hex, k) {
      hex = hex.toLowerCase();
      let r = (hexToDec(hex[1]) * 16 + hexToDec(hex[2])) * k;
      if (r > 255) r = 255;
      let g = (hexToDec(hex[3]) * 16 + hexToDec(hex[4])) * k;
      if (g > 255) g = 255;
      let b = (hexToDec(hex[5]) * 16 + hexToDec(hex[6])) * k;
      if (b > 255) b = 255;
      const res =
        'rgb(' + r.toString() + ',' + g.toString() + ',' + b.toString() + ')';
      return res;
    };

    const nodeCircleSize = function (d, graph) {
      if (d.group != undefined) return graph.nodeSize - d.group;
      return graph.nodeSize;
    };

    const nodeCircleColor = function (d, graph) {
      if (d.color_id) return d.color_id;
      return graph.defaultColor;
    };

    // attach the data to svg elements
    this.nodeCircle = this.nodeCircle.data(
      this.data.nodes.filter((d) => !d.cluster),
      function (d) {
        return d.id;
      }
    );

    // remove svg elements linked to deleted data
    this.nodeCircle.exit().remove();

    // create a new circle for each new node added to the data
    this.nodeCircle = this.nodeCircle
      .enter()
      .append('circle')
      .attr('r', (d) => nodeCircleSize(d, this))
      .attr('stroke', this.nodeStrokeColor)
      .attr('stroke-opacity', 0.8)
      .attr('stroke-width', this.strokeWidth)
      .attr('fill', (d) => nodeCircleColor(d, this))
      .style('visibility', (d) => {
        const result = d.display ? 'visible' : 'hidden';
        return result;
      })
      .call(
        d3
          .drag()
          .on('start', (e, d) => this.dragstarted(e, d, this))
          .on('drag', this.dragged)
          .on('end', (e, d) => this.dragended(e, d, this))
      )
      .on('click', (event, datum) => {
        this.dispatchEvent({
          type: 'click',
          message: 'node click event',
          event: event,
          datum: datum,
          graphId: this.id,
        });
      })
      .on('mouseover', (event, datum) => {
        this.tooltip
          .style('visibility', 'visible')
          .style('color', 'black')
          .html(
            htmlTooltip(datum, [
              'color_id',
              'cluster',
              'parent',
              'child',
              'realNode',
              'display',
              'index',
              'group',
              'x',
              'y',
              'vx',
              'vy',
              'fx',
              'fy',
            ])
          );
        event.target.style['stroke'] = 'white';
        this.node_label
          .filter((e, j) => {
            return datum.index == j;
          })
          .style('fill', 'white')
          .style('opacity', '1');
        if (datum.child != undefined) {
          const allNodes = this.data.nodes.concat(this.data._nodes);
          for (const child_id of datum.child) {
            const child = allNodes.find((e) => e.id == child_id);
            if (child && child.color_id != undefined)
              this.nodeCircle
                .filter((e) => e.id == child_id)
                .style('fill', modifyColorTint(child.color_id, 1.2));
          }
        }
        this.dispatchEvent({
          type: 'mouseover',
          message: 'node mouseover event',
          event: event,
          datum: datum,
          graphId: this.id,
        });
      })
      .on('mouseout', (event, datum) => {
        this.tooltip.style('visibility', 'hidden');
        event.target.style['stroke'] = this.nodeStrokeColor;
        this.node_label
          .filter((e, j) => {
            return datum.index == j;
          })
          .style('fill', 'grey')
          .style('opacity', '0.5');
        if (datum.child != undefined) {
          const allNodes = this.data.nodes.concat(this.data._nodes);
          for (const child_id of datum.child) {
            const child = allNodes.find((e) => e.id == child_id);
            if (child && child.color_id != undefined)
              this.nodeCircle
                .filter((e) => e.id == child_id)
                .style('fill', child.color_id);
          }
        }
        this.dispatchEvent({
          type: 'mouseout',
          message: 'node mouseout event',
          event: event,
          datum: datum,
          graphId: this.id,
        });
      })
      .on('mousemove', (event, datum) => {
        this.tooltip
          .style('left', event.pageX + 30 + 'px')
          .style('top', event.pageY + 'px');
        this.dispatchEvent({
          type: 'mousemove',
          message: 'node mousemove event',
          event: event,
          datum: datum,
          graphId: this.id,
        });
      })
      .merge(this.nodeCircle);

    this.nodeCluster = this.nodeCluster.data(
      this.data.nodes.filter((d) => d.cluster),
      function (d) {
        return d.id;
      }
    );

    this.nodeCluster.exit().remove();

    // create a new rectangle for each new cluster added to the data
    this.nodeCluster = this.nodeCluster
      .enter()
      .append('rect')
      .attr('width', this.nodeSize * 2)
      .attr('height', this.nodeSize * 2)
      .attr('stroke-opacity', 0.8)
      .attr('stroke-width', this.strokeWidth)
      .attr('stroke', this.nodeStrokeColor)
      .attr('fill', this.defaultColor)
      .style('visibility', (d) => {
        const result = d.display ? 'visible' : 'hidden';
        return result;
      })
      .call(
        d3
          .drag()
          .on('start', (e, d) => this.dragstarted(e, d, this))
          .on('drag', this.dragged)
          .on('end', (e, d) => this.dragended(e, d, this))
      )
      .on('click', (event, datum) => {
        this.dispatchEvent({
          type: 'click',
          message: 'node click event',
          event: event,
          datum: datum,
          graphId: this.id,
        });
      })
      .on('mouseover', (event, datum) => {
        this.tooltip
          .style('visibility', 'visible')
          .html(
            htmlTooltip(datum, [
              'color_id',
              'cluster',
              'realNode',
              'display',
              'index',
              'group',
              'x',
              'y',
              'vx',
              'vy',
              'fx',
              'fy',
            ])
          );
        event.target.style['stroke'] = 'white';
        this.node_label_cluster
          .filter((e, j) => {
            return datum.index == j;
          })
          .style('fill', 'white')
          .style('opacity', '1');
        this.dispatchEvent({
          type: 'mouseover',
          message: 'node mouseover event',
          event: event,
          datum: datum,
          graphId: this.id,
        });
      })
      .on('mouseout', (event, datum) => {
        this.tooltip.style('visibility', 'hidden');
        event.target.style['stroke'] = this.nodeStrokeColor;
        this.node_label_cluster
          .filter((e, j) => {
            return datum.index == j;
          })
          .style('fill', 'grey')
          .style('opacity', '0.5');
        this.dispatchEvent({
          type: 'mouseout',
          message: 'node mouseout event',
          event: event,
          datum: datum,
          graphId: this.id,
        });
      })
      .on('mousemove', (event, datum) => {
        this.tooltip
          .style('left', event.pageX + 30 + 'px')
          .style('top', event.pageY + 'px');
        this.dispatchEvent({
          type: 'mousemove',
          message: 'node mousemove event',
          event: event,
          datum: datum,
          graphId: this.id,
        });
      })
      .merge(this.nodeCluster);

    this.link = this.link.data(this.data.links, function (d) {
      return d.source.id + '-' + d.target.id;
    });

    this.link.exit().remove();

    // create a new line for each new link added to the data
    this.link = this.link
      .enter()
      .append('path')
      .attr('stroke-width', this.strokeWidth)
      .attr('stroke', this.linkColor)
      .attr('stroke-opacity', 0.8)
      .attr('marker-mid', 'url(#arrowhead)')
      .attr('stroke-dasharray', (d) => {
        let result;
        if (d.realLink) result = undefined;
        else result = '1';
        return result;
      })
      .attr('fill', 'none')
      .style('visibility', (d) => {
        let source;
        let target;
        const allNodes = this.data.nodes.concat(this.data._nodes);
        if (d.source.id == undefined) {
          source = this.data.nodes.find((element) => {
            return element.id == d.source;
          });
          target = allNodes.find((element) => {
            return element.id == d.target;
          });
        } else {
          source = d.source;
          target = d.target;
        }
        const result = source.display && target.display ? 'visible' : 'hidden';
        return result;
      })
      .on('mouseover', (event, datum) => {
        this.tooltip
          .style('visibility', 'visible')
          .html(htmlTooltip(datum, ['index', 'realLink']));
        this.dispatchEvent({
          type: 'mouseover',
          message: 'node mouseover event',
          event: event,
          datum: datum,
          graphId: this.id,
        });
      })
      .on('mouseout', (event, datum) => {
        this.tooltip.style('visibility', 'hidden');
        this.dispatchEvent({
          type: 'mouseout',
          message: 'node mouseout event',
          event: event,
          datum: datum,
          graphId: this.id,
        });
      })
      .on('mousemove', (event, datum) => {
        this.tooltip
          .style('left', event.pageX + 30 + 'px')
          .style('top', event.pageY + 'px');
        this.dispatchEvent({
          type: 'mousemove',
          message: 'node mousemove event',
          event: event,
          datum: datum,
          graphId: this.id,
        });
      })
      .merge(this.link);

    this.node_label = this.node_label.data(
      this.data.nodes.filter((d) => !d.cluster),
      function (d) {
        return d.id;
      }
    );
    this.node_label.exit().remove();
    this.node_label = this.node_label
      .enter()
      .append('text')
      .text(function (d) {
        return getUriLocalname(d.id);
      })
      .style('text-anchor', 'middle')
      .style('font-family', this.fontFamily)
      .style('font-size', this.fontSize)
      .style('text-shadow', '1px 1px black')
      .style('fill', 'white')
      .style('opacity', '0.3')
      .style('pointer-events', 'none')
      .style('visibility', (d) => {
        const res = d.display ? 'visible' : 'hidden';
        return res;
      })
      .attr('class', 'node_label')
      .merge(this.node_label);

    this.node_label_cluster = this.node_label_cluster.data(
      this.data.nodes.filter((d) => d.cluster),
      function (d) {
        return d.id;
      }
    );
    this.node_label_cluster.exit().remove();
    this.node_label_cluster = this.node_label_cluster
      .enter()
      .append('text')
      .text((d) => this.generateClusterLabel(d, this))
      .style('text-anchor', 'middle')
      .style('font-family', this.fontFamily)
      .style('font-size', this.fontSize)
      .style('text-shadow', '1px 1px black')
      .style('fill', 'white')
      .style('opacity', '0.3')
      .style('pointer-events', 'none')
      .style('visibility', (d) => {
        const res = d.display ? 'visible' : 'hidden';
        return res;
      })
      .attr('class', 'node_label')
      .merge(this.node_label_cluster);

    this.link_label = this.link_label.data(this.data.links, function (d) {
      return d.source.id + '-' + d.target.id;
    });

    this.link_label.exit().remove();

    this.link_label = this.link_label
      .enter()
      .append('text')
      .text(function (d) {
        return getUriLocalname(d.label);
      })
      .style('text-anchor', 'middle')
      .style('font-family', this.fontFamily)
      .style('font-size', this.fontSize)
      .style('text-shadow', '1px 1px black')
      .style('fill', 'white')
      .style('opacity', '1')
      .style('visibility', 'hidden')
      .style('pointer-events', 'none')
      .attr('class', 'link_label')
      .merge(this.link_label);

    // update and restart the simulation.
    this.simulation.nodes(this.data.nodes);
    this.simulation.force('link').links(this.data.links);
    this.simulation.alpha(1).restart();
    this.dispatchEvent({
      type: 'graph_updated',
      message: 'd3Graph update finished',
      event: null,
      graphId: this.id,
    });
  }

  /**
   * Getter for retrieving the d3 svg.
   *
   * @returns {d3.svg.node} return the D3 svg object that represents the graph's "canvas"
   */
  get canvas() {
    return this.svg.node();
  }

  /**
   * Getter for retrieving the d3 tooltip div.
   *
   * @returns {d3.div.node} return the D3 tooltip div
   */
  get tooltipDiv() {
    return this.tooltip.node();
  }

  /**
   * Hide the graph SVG
   */
  hide() {
    this.svg.style('display', 'hidden');
  }

  /**
   * Show the graph SVG
   */
  show() {
    this.svg.style('display', 'visible');
  }

  /**
   * Remove nodes and lines from the SVG.
   */
  clearCanvas() {
    this.svg.selectAll('g').remove();
    this.svg.selectAll('text').remove();
  }

  // / Interface Functions ///

  ticked(graph) {
    graph.nodeCluster
      .attr('x', (d) => d.x - graph.nodeSize)
      .attr('y', (d) => d.y - graph.nodeSize);

    graph.nodeCircle
      .attr('cx', function (d) {
        return d.x;
      })
      .attr('cy', function (d) {
        return d.y;
      });

    graph.link.attr('d', function (d) {
      /**
       * Calculate the vector normal to vector AB
       *
       * @param {object} A a point
       * @param {object} B a point
       * @returns {object} the normal vector
       */
      const getNormalVec = (A, B) => {
        let n;
        if (B.y - A.y != 0)
          n = {
            x: 1,
            y: (A.x - B.x) / (B.y - A.y),
          };
        else
          n = {
            x: 0,
            y: 1,
          };
        const norm = Math.sqrt(n.x * n.x + n.y * n.y);
        n.x = n.x / norm;
        n.y = n.y / norm;
        return n;
      };
      const n = getNormalVec(d.source, d.target);
      if (n.y * (d.target.y - d.source.y) < 0) {
        n.y = -1 * n.y;
        n.x = -1 * n.x;
      }
      const k = 5 * d.linkindex;
      const Q = {
        x: (d.source.x + d.target.x) / 2 + n.x * k,
        y: (d.source.y + d.target.y) / 2 + n.y * k,
        n: n,
      };
      const path = document.createElementNS(
        'http://www.w3.org/2000/svg',
        'path'
      );
      path.setAttribute(
        'd',
        'M ' +
          d.source.x +
          ' ' +
          d.source.y +
          ' ' +
          'Q ' +
          Q.x +
          ' ' +
          Q.y +
          ' ' +
          d.target.x +
          ' ' +
          d.target.y
      );
      const M = path.getPointAtLength(path.getTotalLength() / 2);
      const n1 = getNormalVec(d.source, M);
      const n2 = getNormalVec(d.target, M);
      if (n1.x * Q.n.x + n1.y * Q.n.y < 0) {
        n1.y = -1 * n1.y;
        n1.x = -1 * n1.x;
      }
      if (n2.x * Q.n.x + n2.y * Q.n.y < 0) {
        n2.y = -1 * n2.y;
        n2.x = -1 * n2.x;
      }
      const k12 = d.linkindex;
      const Q1 = {
        x: (d.source.x + M.x) / 2 + n1.x * k12,
        y: (d.source.y + M.y) / 2 + n1.y * k12,
      };
      const Q2 = {
        x: (d.target.x + M.x) / 2 + n2.x * k12,
        y: (d.target.y + M.y) / 2 + n2.y * k12,
      };
      return (
        'M ' +
        d.source.x +
        ' ' +
        d.source.y +
        ' Q ' +
        Q1.x +
        ' ' +
        Q1.y +
        ' ' +
        M.x +
        ' ' +
        M.y +
        ' Q ' +
        Q2.x +
        ' ' +
        Q2.y +
        ' ' +
        d.target.x +
        ' ' +
        d.target.y
      );
    });

    graph.node_label
      .attr('x', function (d) {
        return d.x;
      })
      .attr('y', function (d) {
        return d.y - graph.nodeSize - 3;
      });

    graph.node_label_cluster
      .attr('x', function (d) {
        return d.x;
      })
      .attr('y', function (d) {
        return d.y - graph.nodeSize - 3;
      });

    graph.link_label
      .attr('x', function (d) {
        return (d.source.x + d.target.x) / 2;
      })
      .attr('y', function (d) {
        return (d.source.y + d.target.y) / 2;
      });
  }

  /**
   *
   * @param {d3.D3DragEvent} event the drag event containing information on which node is being clicked and dragged
   * @param {object} d the dragged node
   * @param {D3GraphCanvas} graph this
   */
  dragstarted(event, d, graph) {
    if (!event.active) graph.simulation.alphaTarget(0.3).restart();
    if (d.fixed == undefined) {
      if (d.fx == undefined) {
        d.fixed = false;
        d.fx = d.x;
        d.fy = d.y;
      } else d.fixed = true;
    }
  }

  /**
   *
   * @param {d3.D3DragEvent} event the drag event containing information on which node is being clicked and dragged
   * @param {object} d the dragged node
   */
  dragged(event, d) {
    if (!d.fixed) {
      d.fx = event.x;
      d.fy = event.y;
    }
  }

  /**
   *
   * @param {d3.D3DragEvent} event the drag event containing information on which node is being clicked and dragged
   * @param {object} d the dragged node
   * @param {D3GraphCanvas} graph this
   */
  dragended(event, d, graph) {
    if (!event.active) graph.simulation.alphaTarget(0);
    if (!d.fixed) {
      d.fx = null;
      d.fy = null;
    }
  }

  /**
   * Check if a list of URIs have namespaces in the known namespace list. If so, replace
   * the namespace of the URI with a prefix. The known namespace list is declared in a
   * configuration file.
   *
   * @param {Array<string>} legendContent the list of uris representing the content of the legend0
   * @returns {Array<string>} returns the legend content with prefixes
   */
  prefixLegend(legendContent) {
    const prefixedLegendContent = [];
    for (const uri in legendContent) {
      const tURI = tokenizeURI(legendContent[uri]);
      if (Object.keys(this.knownNamespaceLabels).includes(tURI.namespace)) {
        prefixedLegendContent.push(
          `${this.knownNamespaceLabels[tURI.namespace]}:${tURI.localname}`
        );
      } else {
        prefixedLegendContent.push(legendContent[uri]);
      }
    }
    return prefixedLegendContent;
  }
}