Path.js

/** @module Path */

let knn = require('./node_modules/rbush-knn'),
    Node = require('./Node'),
    Bounds = require('./Bounds'),
    Defaults = require('./Defaults');

/** Manages a set of Nodes in a continuous, ordered data structure (an Array). */
class Path {
  /**
   * Create a new Path object
   * @param {object} p5 Reference to global p5.js instance
   * @param {array} nodes Array of initial Node objects to start with
   * @param {object} [settings] Object containing local override Settings to be merged with Defaults
   * @param {boolean} [isClosed] Whether this Path is closed (true) or open (false)
   * @param {object} [bounds] Bounds object that this Path must stay within
   * @param {object} [fillColor] Fill color object containing properties h, s, b, and a
   * @param {object} [strokeColor] Stroke color object containing properties h, s, b, and a
   * @param {object} [invertedFillColor] Fill color in "invert mode" containing properties h, s, b, and a
   * @param {object} [invertedStrokeColor] Stroke color in "invert mode" containing properties h, s, b, and a
   */
  constructor(
    p5, 
    nodes, 
    settings = Defaults, 
    isClosed = false, 
    bounds = false,
    fillColor = {h:0, s:0, b:0, a:255}, 
    strokeColor = {h:0, s:0, b:0, a:255}, 
    invertedFillColor = {h:0, s:0, b:255, a:255}, 
    invertedStrokeColor = {h:0, s:0, b:255, a:255}
  ) {
    this.p5 = p5;
    this.nodes = nodes;
    this.isClosed = isClosed;
    this.settings = Object.assign({}, Defaults, settings);
    this.bounds = bounds;

    this.injectionMode = "RANDOM";
    this.lastNodeInjectTime = 0;

    this.nodeHistory = [];

    this.drawNodes = this.settings.DrawNodes;
    this.invertedColors = this.settings.InvertedColors;
    this.traceMode = this.settings.TraceMode;
    this.debugMode = this.settings.DebugMode;
    this.fillMode = this.settings.FillMode;
    this.useBrownianMotion = this.settings.UseBrownianMotion;
    this.drawHistory = this.settings.DrawHistory;
    this.showBounds = this.settings.ShowBounds;

    this.fillColor = fillColor;
    this.strokeColor = strokeColor;
    this.invertedFillColor = invertedFillColor;
    this.invertedStrokeColor = invertedStrokeColor;

    this.currentFillColor = this.fillColor;
    this.currentStrokeColor = this.strokeColor;

    if(this.invertedColors) {
      this.currentFillColor = this.invertedFillColor;
      this.currentStrokeColor = this.invertedStrokeColor;
    }
  }

  /**
   * Run one "tick" of the simulation
   * @param {object} tree Reference to the appropriate R-tree index that this Path belongs to (see World)
   */
  iterate(tree) {
    for (let [index, node] of this.nodes.entries()) {
      // Apply Brownian motion to realistically 'jiggle' nodes
      if(this.useBrownianMotion) {
        this.applyBrownianMotion(index);
      }

      // Move towards neighbors (attraction), if there is space to move
      this.applyAttraction(index);

      // Move away from any nodes that are too close (repulsion)
      this.applyRepulsion(index, tree);

      // Align with neighbors
      this.applyAlignment(index);

      // Apply boundaries
      this.applyBounds(index);

      // Move towards next position
      node.iterate();
    }

    // Split any edges that have become too long
    this.splitEdges();

    // Remove any nodes that are too close to other nodes
    this.pruneNodes();

    // Inject a new node to introduce asymmetry every so often
    if (this.p5.millis() - this.lastNodeInjectTime >= this.settings.NodeInjectionInterval) {
      this.injectNode();
      this.lastNodeInjectTime = this.p5.millis();
    }
  }

  /**
   * For the Node wit the provided index, simulate the small random motions that real microscopic particles experience from collisions with fast-moving molecules
   * @param {number} index Index of Node to apply forces to
   */
  applyBrownianMotion(index) {
    this.nodes[index].x += this.p5.random(-this.settings.BrownianMotionRange/2, this.settings.BrownianMotionRange/2);
    this.nodes[index].y += this.p5.random(-this.settings.BrownianMotionRange/2, this.settings.BrownianMotionRange/2);
  }

  /**
   * Move the Node with the provided index closer to it's connected neighbor Nodes
   * @param {number} index Index of Node to apply forces to
   */
  applyAttraction(index) {
    let distance, leastMinDistance;
    let connectedNodes = this.getConnectedNodes(index);

    // Move towards next node, if there is one
    if (
      connectedNodes.nextNode != undefined && connectedNodes.nextNode instanceof Node && 
      !this.nodes[index].isFixed
    ) {
      distance = this.nodes[index].distance(connectedNodes.nextNode);
      leastMinDistance = Math.min(this.nodes[index].minDistance, connectedNodes.nextNode.minDistance);

      if (distance > leastMinDistance) {
        this.nodes[index].nextPosition.x = this.p5.lerp(this.nodes[index].nextPosition.x, connectedNodes.nextNode.x, this.settings.AttractionForce);
        this.nodes[index].nextPosition.y = this.p5.lerp(this.nodes[index].nextPosition.y, connectedNodes.nextNode.y, this.settings.AttractionForce);
      }
    }

    // Move towards previous node, if there is one
    if (
      connectedNodes.previousNode != undefined && connectedNodes.previousNode instanceof Node && 
      !this.nodes[index].isFixed
    ) {
      distance = this.nodes[index].distance(connectedNodes.previousNode);
      leastMinDistance = Math.min(this.nodes[index].minDistance, connectedNodes.previousNode.minDistance);

      if (distance > leastMinDistance) {
        this.nodes[index].nextPosition.x = this.p5.lerp(this.nodes[index].nextPosition.x, connectedNodes.previousNode.x, this.settings.AttractionForce);
        this.nodes[index].nextPosition.y = this.p5.lerp(this.nodes[index].nextPosition.y, connectedNodes.previousNode.y, this.settings.AttractionForce);
      }
    }
  }

  /**
   * Move the referenced Node (by index) away from all other nearby Nodes within the appropriate R-tree index (tree), within a pre-defined radius
   * @param {number} index Index of Node to apply forces to
   * @param {object} tree Reference to the appropriate R-tree index that this Path belongs to (see World)
   */
  applyRepulsion(index, tree) {
    // Perform knn search to find all neighbors within certain radius
    var neighbors = knn(tree, 
                        this.nodes[index].x, 
                        this.nodes[index].y,
                        undefined,
                        undefined,
                        this.nodes[index].repulsionRadius * this.nodes[index].repulsionRadius); // radius must be squared as per https://github.com/mourner/rbush-knn/issues/13

    // Move this node away from all nearby neighbors
    // TODO: Make this proportional to distance?
    for(let node of neighbors) {
      this.nodes[index].nextPosition.x = this.p5.lerp(this.nodes[index].x, node.x, -this.settings.RepulsionForce);
      this.nodes[index].nextPosition.y = this.p5.lerp(this.nodes[index].y, node.y, -this.settings.RepulsionForce);
    }
  }

  /**
   * Move the referenced Node (by index) towards the midpoint of it's connected neighbor Nodes in an effort to minimize curvature
   * @param {number} index Index of Node to apply forces to
   */
  applyAlignment(index) {
    let connectedNodes = this.getConnectedNodes(index);

    if (
      connectedNodes.previousNode != undefined && connectedNodes.previousNode instanceof Node &&
      connectedNodes.nextNode != undefined && connectedNodes.nextNode instanceof Node &&
      !this.nodes[index].isFixed
    ) {
      // Find the midpoint between the neighbors of this node
      let midpoint = this.getMidpointNode(connectedNodes.previousNode, connectedNodes.nextNode);

      // Move this point towards this midpoint
      this.nodes[index].nextPosition.x = this.p5.lerp(this.nodes[index].nextPosition.x, midpoint.x, this.settings.AlignmentForce);
      this.nodes[index].nextPosition.y = this.p5.lerp(this.nodes[index].nextPosition.y, midpoint.y, this.settings.AlignmentForce);
    }
  }

  /** Search for edges that are too long and inject a new Node to split them up */
  splitEdges() {
    for (let [index, node] of this.nodes.entries()) {
      let connectedNodes = this.getConnectedNodes(index);

      if (
        connectedNodes.previousNode != undefined && connectedNodes.previousNode instanceof Node &&
        node.distance(connectedNodes.previousNode) >= this.settings.MaxDistance) 
      {
        let midpointNode = this.getMidpointNode(node, connectedNodes.previousNode);
        
        // Inject the new midpoint node into the global list
        if(index == 0) {
          this.nodes.splice(this.nodes.length, 0, midpointNode);
        } else {
          this.nodes.splice(index, 0, midpointNode);
        }
      }
    }
  }

  /** Remove Nodes that are too close to their neighbors to minimize "pinching" */
  pruneNodes() {
    for(let [index, node] of this.nodes.entries()) {
      let connectedNodes = this.getConnectedNodes(index);

      if(
        connectedNodes.previousNode != undefined && connectedNodes.previousNode instanceof Node &&
        node.distance(connectedNodes.previousNode) < this.settings.MinDistance) 
      {
        if(index == 0) {
          if(!this.nodes[this.nodes.length - 1].isFixed) {
            this.nodes.splice(this.nodes.length - 1, 1);
          }
        } else {
          if(!this.nodes[index - 1].isFixed) {
            this.nodes.splice(index - 1, 1);
          }
        }
      }
    }
  }

  /** Insert a new Node using the current injection method */
  injectNode() {
    switch(this.injectionMode) {
      case "RANDOM":
        this.injectRandomNode();
        break;
      case "CURVATURE":
        this.injectNodeByCurvature();
        break;
    }
  }

    /** Insert a new Node in a random location along the Path, if there is space for it */
    injectRandomNode() {
      // Choose two connected nodes at random
      let index = parseInt(this.p5.random(1, this.nodes.length));
      let connectedNodes = this.getConnectedNodes(index);

      if (
        connectedNodes.previousNode != undefined && connectedNodes.previousNode instanceof Node &&
        connectedNodes.nextNode != undefined && connectedNodes.nextNode instanceof Node &&
        this.nodes[index].distance(connectedNodes.previousNode) > this.settings.MinDistance
      ) {
        // Create a new node in the middle
        let midpointNode = this.getMidpointNode(this.nodes[index], connectedNodes.previousNode);
        
        // Splice new node into array
        this.nodes.splice(index, 0, midpointNode);
      }
    }

    /** Insert a new Node in an area where curvature is high */
    injectNodeByCurvature() {
      for(let [index, node] of this.nodes.entries()) {
        let connectedNodes = this.getConnectedNodes(index);

        if( connectedNodes.previousNode == undefined || connectedNodes.nextNode == undefined ) {
          continue;
        }

        // Find angle between adjacent nodes
        let n = connectedNodes.nextNode.y - connectedNodes.previousNode.y;
        let d = connectedNodes.nextNode.x - connectedNodes.previousNode.x;
        let angle = Math.round(Math.abs(Math.atan(n/d)));
        
        // // If angle is below a certain angle (high curvature), replace the current node with two nodes
        if(angle > 20) {
          let previousMidpointNode = this.getMidpointNode(node, connectedNodes.previousNode);
          let nextMidpointNode = this.getMidpointNode(node, connectedNodes.nextNode);
          
          // // Replace this node with the two new nodes
          if(index == 0) {
            this.nodes.splice(this.nodes.length-1, 0, previousMidpointNode);
            this.nodes.splice(0, 0, nextMidpointNode);
          } else {
            this.nodes.splice(index, 1, previousMidpointNode, nextMidpointNode);
          }
        }
      }
    }

  /**
   * Do not allow the referenced Node (by index) to leave the interior of the assigned Bounds polygon
   * @param {number} index Index of Node to apply force to
   */
  applyBounds(index) {
    if(
      this.bounds != undefined && this.bounds instanceof Bounds &&
      !this.bounds.contains([this.nodes[index].x, this.nodes[index].y])
    ) {
      this.nodes[index].isFixed = true;
    }
  }

  /**
   * For a given Node, find a return it's immediate connected neighbor Nodes
   * @param {number} index Index of Node to retrieve neighbors of
   * @returns {object} References to previous and next nodes, if they exist. Will always return a value for at least one.
   */
  getConnectedNodes(index) {
    let previousNode, nextNode;

    // Find previous node, if there is one
    if(index == 0 && this.isClosed) {
      previousNode = this.nodes[this.nodes.length - 1];
    } else if(index >= 1) {
      previousNode = this.nodes[index - 1];
    }

    // Find next node, if there is one
    if(index == this.nodes.length - 1 && this.isClosed) {
      nextNode = this.nodes[0];
    } else if(index <= this.nodes.length - 1) {
      nextNode = this.nodes[index + 1];
    }

    return {
      previousNode,
      nextNode
    };
  }

  /**
   * Create and return a Node exactly halfway between the two provided Nodes
   * @param {object} node1 First node
   * @param {object} node2 Second node
   * @param {boolean} [fixed] Whether this new Node should be fixed or not
   * @returns {object} New Node object
   */
  getMidpointNode(node1, node2, fixed = false) {
    return new Node(
      this.p5,
      (node1.x + node2.x) / 2,
      (node1.y + node2.y) / 2,
      this.settings,
      fixed
    );
  }

  /** Draw this Path to the canvas using current object visibility settings */
  draw() {
    // Draw all the previous paths saved to the history array
    if(this.drawHistory) {
      this.drawPreviousEdges();
    }

    // Draw bounds
    if(this.showBounds && this.bounds != undefined && this.bounds instanceof Bounds) {
      this.drawBounds();
    }

    // Set shape fill 
    if(this.fillMode && this.isClosed) {
      this.p5.fill(this.currentFillColor.h, this.currentFillColor.s, this.currentFillColor.b, this.currentFillColor.a);
    } else {
      this.p5.noFill();
    }

    // Set stroke color
    this.p5.stroke(this.currentStrokeColor.h, this.currentStrokeColor.s, this.currentStrokeColor.b, this.currentStrokeColor.a);

    // Draw current edges
    this.drawCurrentEdges();

    // Draw all nodes
    if(this.drawNodes) {
      this.drawCurrentNodes();
    }
  }

  /** Draw the current edges (leading edge) of the path */
  drawCurrentEdges() {
    this.drawEdges(this.nodes);
  }

  /** Draw all previous edges of the path saved to history array */
  drawPreviousEdges() {
    for(let [index, nodes] of this.nodeHistory.entries()) {
      this.p5.stroke(
        this.currentStrokeColor.h, 
        this.currentStrokeColor.s, 
        this.currentStrokeColor.b,
        index * 30
      );

      this.drawEdges(nodes);
    }
  }

  /**
   * Draw edges for a given set of nodes - can be either the current or previous nodes
   * @param {array} nodes Array of Node objects
   */
  drawEdges(nodes) {
    // Begin capturing vertices
    if(!this.debugMode) {
      this.p5.beginShape();
    }

    // Create vertices or lines (if debug mode)
    for (let i = 0; i < nodes.length; i++) {
      if(!this.debugMode) {
        this.p5.vertex(nodes[i].x, nodes[i].y);
      } else {

        // In debug mode each line has a unique stroke color, which isn't possible with begin/endShape(). Instead we'll use line()
        if(i > 0) {
          if(!this.traceMode) {
            this.p5.stroke( this.p5.map(i, 0, nodes.length-1, 0, 255, true), 255, 255, 255 );
          } else {
            this.p5.stroke( this.p5.map(i, 0, nodes.length-1, 0, 255, true), 255, 255, 2 );
          }

          this.p5.line(nodes[i-1].x, nodes[i-1].y, nodes[i].x, nodes[i].y);
        }
      }
    }

    // For closed paths, connect the last and first nodes
    if(this.isClosed) {
      if(!this.debugMode) {
        this.p5.vertex(nodes[0].x, nodes[0].y);
      } else {
        this.p5.line(nodes[nodes.length - 1].x, nodes[nodes.length - 1].y, nodes[0].x, nodes[0].y);
      }
    }

    // Stop capturing vertices
    if(!this.debugMode) {
      this.p5.endShape();
    }
  }

  /** Draw circles for every node */
  drawCurrentNodes() {
    this.p5.noStroke();

    if(!this.invertedColors) {
      this.p5.fill(0);
    } else {
      this.p5.fill(255);
    }

    for (let [index, node] of this.nodes.entries()) {
      if(this.debugMode) {
        this.p5.fill( this.p5.map(index, 0, this.nodes.length-1, 0, 255, true), 255, 255, 255 );
      }

      node.draw();
    }
  }

  /** Draw boundary shape(s) */
  drawBounds() {
    if(!this.invertedColors) {
      this.p5.stroke(200);
    } else {
      this.p5.stroke(100);
    }

    this.p5.noFill();

    this.bounds.draw();
  }

  /** Take a snapshot of the current nodes by saving a dereferenced clone of them to the history array */
  addToHistory() {
    if(this.nodeHistory.length == this.settings.MaxHistorySize) {
      this.nodeHistory.shift();
    }

    this.nodeHistory.push(Object.assign([], JSON.parse(JSON.stringify(this.nodes))));
  }

  /**
   * Move this entire Path by a certain amount by moving all of it's Nodes
   * @param {number} xOffset Distance on X axis to move Path
   * @param {number} yOffset Distance on Y axis to move Path
   */
  moveTo(xOffset, yOffset) {
    for(let node of this.nodes) {
      node.x += xOffset;
      node.y += yOffset;
    }
  }

  /**
   * Scale (multiply) all Nodes by the provided factor
   * @param {number} factor Factor to multiple all Nodes' coordinates by
   */
  scale(factor) {
    for(let node of this.nodes) {
      node.x *= factor;
      node.y *= factor;
    }
  }

  /**
   * Insert a new Node object from outside of this class
   * @param {object} node Node object to insert
   */
  addNode(node) {
    this.nodes.push(node);
  }

  /**
   * Return a raw 2D array of all Node coordinates. Useful for creating Bounds objects.
   * @returns {array} Array of all Node coordinates in the format of [polygon_n][x1][y1], ...
   */
  toArray() {
    let polygon = [];

    for(let node of this.nodes) {
      polygon.push([node.x, node.y]);
    }

    return polygon;
  }

  /**
   * Get the current state of "trace mode" flag
   * @returns {boolean} Current state of "trace mode" flag
   */
  getTraceMode() {
    return this.traceMode;
  }

  /**
   * Get the current state of the "invert mode" flag
   * @returns {boolean} Current state of the "invert mode" flag
   */
  getInvertedColors() {
    return this.invertedColors;
  }

  /**
   * Sets the minimum distance that each Node wants to be from it's neighboring Nodes
   * @param {number} minDistance 
   */
  setMinDistance(minDistance) {
    this.settings.MinDistance = minDistance;

    for(let node of this.nodes) {
      node.minDistance = minDistance;
    }
  }

  /**
   * Sets the maximum distance an edge can be before it is split
   * @param {number} maxDistance 
   */
  setMaxDistance(maxDistance) {
    this.settings.MaxDistance = maxDistance;

    for(let node of this.nodes) {
      node.maxDistance = maxDistance;
    }
  }

  /**
   * Sets the radius around each Node that it can affect other Nodes
   * @param {number} repulsionRadius 
   */
  setRepulsionRadius(repulsionRadius) {
    this.settings.RepulsionRadius = repulsionRadius;

    for(let node of this.nodes) {
      node.repulsionRadius = repulsionRadius;
    }
  }

  /**
   * Sets the state of the "trace mode" flag
   * @param {boolean} state New state for "trace mode" flag
   */
  setTraceMode(state) {
    this.traceMode = state;

    if(!this.traceMode) {
      this.currentFillColor.a = 255;
      this.currentStrokeColor.a = 255;
    } else {
      this.currentFillColor.a = 255;
      this.currentStrokeColor.a = 255;
    }
  }

  /**
   * Sets the state of the "invert mode" flag
   * @param {boolean} state New state for "invert mode" flag
   */
  setInvertedColors(state) {
    this.invertedColors = state;

    if(!this.invertedColors) {
      this.currentFillColor = this.fillColor;
      this.currentStrokeColor = this.strokeColor;
    } else {
      this.currentFillColor = this.invertedFillColor;
      this.currentStrokeColor = this.invertedStrokeColor;
    }

    // Reapply the current trace mode state to make sure opacity is adjusted when colors are inverted
    this.setTraceMode(this.traceMode);
  }

  /**
   * Set the Bounds object that this Path must stay within
   * @param {object} bounds Bounds object that this Path must stay within
   */
  setBounds(bounds) {
    this.bounds = bounds;
  }

  /** Toggle the current state of the "trace mode" flag */
  toggleTraceMode() {
    this.setTraceMode(!this.getTraceMode());
  }

  /** Toggle the current state of the "invert mode" flag */
  toggleInvertedColors() {
    this.setInvertedColors(!this.getInvertedColors());
  }
}

module.exports = Path;