lib/network/modules/NodesHandler.js

Summary

Maintainability
F
3 days
Test Coverage
let util = require("../../util");
let DataSet = require('../../DataSet');
let DataView = require('../../DataView');
var Node = require("./components/Node").default;


/**
 * Handler for Nodes
 */
class NodesHandler {
  /**
   * @param {Object} body
   * @param {Images} images
   * @param {Array.<Group>} groups
   * @param {LayoutEngine} layoutEngine
   */
  constructor(body, images, groups, layoutEngine) {
    this.body = body;
    this.images = images;
    this.groups = groups;
    this.layoutEngine = layoutEngine;

    // create the node API in the body container
    this.body.functions.createNode = this.create.bind(this);

    this.nodesListeners = {
      add: (event, params) => { this.add(params.items); },
      update: (event, params) => { this.update(params.items, params.data, params.oldData); },
      remove: (event, params) => { this.remove(params.items); }
    };

    this.defaultOptions = {
      borderWidth: 1,
      borderWidthSelected: 2,
      brokenImage: undefined,
      color: {
        border: '#2B7CE9',
        background: '#97C2FC',
        highlight: {
          border: '#2B7CE9',
          background: '#D2E5FF'
        },
        hover: {
          border: '#2B7CE9',
          background: '#D2E5FF'
        }
      },
      fixed: {
        x: false,
        y: false
      },
      font: {
        color: '#343434',
        size: 14, // px
        face: 'arial',
        background: 'none',
        strokeWidth: 0, // px
        strokeColor: '#ffffff',
        align: 'center',
        vadjust: 0,
        multi: false,
        bold: {
          mod: 'bold'
        },
        boldital: {
          mod: 'bold italic'
        },
        ital: {
          mod: 'italic'
        },
        mono: {
          mod: '',
          size: 15, // px
          face: 'monospace',
          vadjust: 2
        }
      },
      group: undefined,
      hidden: false,
      icon: {
        face: 'FontAwesome',  //'FontAwesome',
        code: undefined,  //'\uf007',
        size: 50,  //50,
        color: '#2B7CE9'   //'#aa00ff'
      },
      image: undefined, // --> URL
      label: undefined,
      labelHighlightBold: true,
      level: undefined,
      margin: {
        top: 5,
        right: 5,
        bottom: 5,
        left: 5
      },
      mass: 1,
      physics: true,
      scaling: {
        min: 10,
        max: 30,
        label: {
          enabled: false,
          min: 14,
          max: 30,
          maxVisible: 30,
          drawThreshold: 5
        },
        customScalingFunction: function (min, max, total, value) {
          if (max === min) {
            return 0.5;
          }
          else {
            let scale = 1 / (max - min);
            return Math.max(0, (value - min) * scale);
          }
        }
      },
      shadow: {
        enabled: false,
        color: 'rgba(0,0,0,0.5)',
        size: 10,
        x: 5,
        y: 5
      },
      shape: 'ellipse',
      shapeProperties: {
        borderDashes: false, // only for borders
        borderRadius: 6,     // only for box shape
        interpolation: true,  // only for image and circularImage shapes
        useImageSize: false,  // only for image and circularImage shapes
        useBorderWithImage: false  // only for image shape
      },
      size: 25,
      title: undefined,
      value: undefined,
      x: undefined,
      y: undefined
    };

    // Protect from idiocy
    if (this.defaultOptions.mass <= 0) {
      throw 'Internal error: mass in defaultOptions of NodesHandler may not be zero or negative';
    }

    this.options = util.bridgeObject(this.defaultOptions);

    this.bindEventListeners();
  }

  /**
   * Binds event listeners
   */
  bindEventListeners() {
    // refresh the nodes. Used when reverting from hierarchical layout
    this.body.emitter.on('refreshNodes', this.refresh.bind(this));
    this.body.emitter.on('refresh', this.refresh.bind(this));
    this.body.emitter.on('destroy', () => {
      util.forEach(this.nodesListeners, (callback, event) => {
        if (this.body.data.nodes)
          this.body.data.nodes.off(event, callback);
      });
      delete this.body.functions.createNode;
      delete this.nodesListeners.add;
      delete this.nodesListeners.update;
      delete this.nodesListeners.remove;
      delete this.nodesListeners;
    });
  }

  /**
   *
   * @param {Object} options
   */
  setOptions(options) {
    if (options !== undefined) {
      Node.parseOptions(this.options, options);

      // update the shape in all nodes
      if (options.shape !== undefined) {
        for (let nodeId in this.body.nodes) {
          if (this.body.nodes.hasOwnProperty(nodeId)) {
            this.body.nodes[nodeId].updateShape();
          }
        }
      }

      // update the font in all nodes
      if (options.font !== undefined) {
        for (let nodeId in this.body.nodes) {
          if (this.body.nodes.hasOwnProperty(nodeId)) {
            this.body.nodes[nodeId].updateLabelModule();
            this.body.nodes[nodeId].needsRefresh();
          }
        }
      }

      // update the shape size in all nodes
      if (options.size !== undefined) {
        for (let nodeId in this.body.nodes) {
          if (this.body.nodes.hasOwnProperty(nodeId)) {
            this.body.nodes[nodeId].needsRefresh();
          }
        }
      }

      // update the state of the variables if needed
      if (options.hidden !== undefined || options.physics !== undefined) {
        this.body.emitter.emit('_dataChanged');
      }
    }
  }

  /**
   * Set a data set with nodes for the network
   * @param {Array | DataSet | DataView} nodes         The data containing the nodes.
   * @param {boolean} [doNotEmit=false]
   * @private
   */
  setData(nodes, doNotEmit = false) {
    let oldNodesData = this.body.data.nodes;

    if (nodes instanceof DataSet || nodes instanceof DataView) {
      this.body.data.nodes = nodes;
    }
    else if (Array.isArray(nodes)) {
      this.body.data.nodes = new DataSet();
      this.body.data.nodes.add(nodes);
    }
    else if (!nodes) {
      this.body.data.nodes = new DataSet();
    }
    else {
      throw new TypeError('Array or DataSet expected');
    }

    if (oldNodesData) {
      // unsubscribe from old dataset
      util.forEach(this.nodesListeners, function (callback, event) {
        oldNodesData.off(event, callback);
      });
    }

    // remove drawn nodes
    this.body.nodes = {};

    if (this.body.data.nodes) {
      // subscribe to new dataset
      let me = this;
      util.forEach(this.nodesListeners, function (callback, event) {
        me.body.data.nodes.on(event, callback);
      });

      // draw all new nodes
      let ids = this.body.data.nodes.getIds();
      this.add(ids, true);
    }

    if (doNotEmit === false) {
      this.body.emitter.emit("_dataChanged");
    }
  }


  /**
   * Add nodes
   * @param {number[] | string[]} ids
   * @param {boolean} [doNotEmit=false]
   * @private
   */
  add(ids, doNotEmit = false) {
    let id;
    let newNodes = [];
    for (let i = 0; i < ids.length; i++) {
      id = ids[i];
      let properties = this.body.data.nodes.get(id);
      let node = this.create(properties);
      newNodes.push(node);
      this.body.nodes[id] = node; // note: this may replace an existing node
    }

    this.layoutEngine.positionInitially(newNodes);

    if (doNotEmit === false) {
      this.body.emitter.emit("_dataChanged");
    }
  }

  /**
   * Update existing nodes, or create them when not yet existing
   * @param {number[] | string[]} ids id's of changed nodes
   * @param {Array} changedData array with changed data
   * @param {Array|undefined} oldData optional; array with previous data
   * @private
   */
  update(ids, changedData, oldData) {
    let nodes = this.body.nodes;
    let dataChanged = false;
    for (let i = 0; i < ids.length; i++) {
      let id = ids[i];
      let node = nodes[id];
      let data = changedData[i];
      if (node !== undefined) {
        // update node
        if (node.setOptions(data)) {
          dataChanged = true;
        }
      }
      else {
        dataChanged = true;
        // create node
        node = this.create(data);
        nodes[id] = node;
      }
    }

    if (!dataChanged && oldData !== undefined) {
      // Check for any changes which should trigger a layout recalculation
      // For now, this is just 'level' for hierarchical layout
      // Assumption: old and new data arranged in same order; at time of writing, this holds.
      dataChanged = changedData.some(function(newValue, index) {
        let oldValue = oldData[index];
        return (oldValue && oldValue.level !== newValue.level);
      });
    }

    if (dataChanged === true) {
      this.body.emitter.emit("_dataChanged");
    }
    else {
      this.body.emitter.emit("_dataUpdated");
    }
  }

  /**
   * Remove existing nodes. If nodes do not exist, the method will just ignore it.
   * @param {number[] | string[]} ids
   * @private
   */
  remove(ids) {
    let nodes = this.body.nodes;

    for (let i = 0; i < ids.length; i++) {
      let id = ids[i];
      delete nodes[id];
    }

    this.body.emitter.emit("_dataChanged");
  }


  /**
   * create a node
   * @param {Object} properties
   * @param {class} [constructorClass=Node.default]
   * @returns {*}
   */
  create(properties, constructorClass = Node) {
    return new constructorClass(properties, this.body, this.images, this.groups, this.options, this.defaultOptions)
  }


  /**
   *
   * @param {boolean} [clearPositions=false]
   */
  refresh(clearPositions = false) {
    util.forEach(this.body.nodes, (node, nodeId) => {
      let data = this.body.data.nodes.get(nodeId);
      if (data !== undefined) {
        if (clearPositions === true) {
          node.setOptions({x:null, y:null});
        }
        node.setOptions({ fixed: false });
        node.setOptions(data);
      }
    });
  }


  /**
   * Returns the positions of the nodes.
   * @param {Array.<Node.id>|String} [ids]  --> optional, can be array of nodeIds, can be string
   * @returns {{}}
   */
  getPositions(ids) {
    let dataArray = {};
    if (ids !== undefined) {
      if (Array.isArray(ids) === true) {
        for (let i = 0; i < ids.length; i++) {
          if (this.body.nodes[ids[i]] !== undefined) {
            let node = this.body.nodes[ids[i]];
            dataArray[ids[i]] = { x: Math.round(node.x), y: Math.round(node.y) };
          }
        }
      }
      else {
        if (this.body.nodes[ids] !== undefined) {
          let node = this.body.nodes[ids];
          dataArray[ids] = { x: Math.round(node.x), y: Math.round(node.y) };
        }
      }
    }
    else {
      for (let i = 0; i < this.body.nodeIndices.length; i++) {
        let node = this.body.nodes[this.body.nodeIndices[i]];
        dataArray[this.body.nodeIndices[i]] = { x: Math.round(node.x), y: Math.round(node.y) };
      }
    }
    return dataArray;
  }


  /**
   * Load the XY positions of the nodes into the dataset.
   */
  storePositions() {
    // todo: add support for clusters and hierarchical.
    let dataArray = [];
    var dataset = this.body.data.nodes.getDataSet();

    for (let nodeId in dataset._data) {
      if (dataset._data.hasOwnProperty(nodeId)) {
        let node = this.body.nodes[nodeId];
        if (dataset._data[nodeId].x != Math.round(node.x) || dataset._data[nodeId].y != Math.round(node.y)) {
          dataArray.push({ id: node.id, x: Math.round(node.x), y: Math.round(node.y) });
        }
      }
    }
    dataset.update(dataArray);
  }

  /**
   * get the bounding box of a node.
   * @param {Node.id} nodeId
   * @returns {j|*}
   */
  getBoundingBox(nodeId) {
    if (this.body.nodes[nodeId] !== undefined) {
      return this.body.nodes[nodeId].shape.boundingBox;
    }
  }


  /**
   * Get the Ids of nodes connected to this node.
   * @param {Node.id} nodeId
   * @param {'to'|'from'|undefined} direction values 'from' and 'to' select respectively parent and child nodes only.
   *                                          Any other value returns both parent and child nodes.
   * @returns {Array}
   */
  getConnectedNodes(nodeId, direction) {
    let nodeList = [];
    if (this.body.nodes[nodeId] !== undefined) {
      let node = this.body.nodes[nodeId];
      let nodeObj = {}; // used to quickly check if node already exists
      for (let i = 0; i < node.edges.length; i++) {
        let edge = node.edges[i];
        if (direction !== 'to' && edge.toId == node.id) { // these are double equals since ids can be numeric or string
          if (nodeObj[edge.fromId] === undefined) {
            nodeList.push(edge.fromId);
            nodeObj[edge.fromId] = true;
          }
        }
        else if (direction !== 'from' && edge.fromId == node.id) { // these are double equals since ids can be numeric or string
          if (nodeObj[edge.toId] === undefined) {
            nodeList.push(edge.toId);
            nodeObj[edge.toId] = true;
          }
        }
      }
    }
    return nodeList;
  }

  /**
   * Get the ids of the edges connected to this node.
   * @param {Node.id} nodeId
   * @returns {*}
   */
  getConnectedEdges(nodeId) {
    let edgeList = [];
    if (this.body.nodes[nodeId] !== undefined) {
      let node = this.body.nodes[nodeId];
      for (let i = 0; i < node.edges.length; i++) {
        edgeList.push(node.edges[i].id)
      }
    }
    else {
      console.log("NodeId provided for getConnectedEdges does not exist. Provided: ", nodeId);
    }
    return edgeList;
  }


  /**
   * Move a node.
   *
   * @param {Node.id} nodeId
   * @param {number} x
   * @param {number} y
   */
  moveNode(nodeId, x, y) {
    if (this.body.nodes[nodeId] !== undefined) {
      this.body.nodes[nodeId].x = Number(x);
      this.body.nodes[nodeId].y = Number(y);
      setTimeout(() => {this.body.emitter.emit("startSimulation")},0);
    }
    else {
      console.log("Node id supplied to moveNode does not exist. Provided: ", nodeId);
    }
  }
}

export default NodesHandler;