Source: builder/graphBuilder.js

/**
 * 
 * @classdesc
 * 
 * @constructor
 * @param $svg
 */
function GraphBuilder($svg, $addButton, motifSearch) {

    /** @private */
    this.updateCallback = null;

    /** @private */
    this.conversionLocked = false;

    /** @private */
    this.$svg = $svg;

    this.$addButton = $addButton;

    /** @private */
    this.$hover = this.$svg.find(".hover");

    /** @private */
    this.hosts = [];

    this.cleared = true;

    this.motifSearch = motifSearch;

    /** @private */
    this.colors = [ "rgb(122,155,204)", "rgb(122,204,155)", "rgb(187,204,122)", "rgb(204,122,122)", "rgb(187,122,204)", "rgb(122,155,204)" ];

    if (!motifSearch) {
        this.bindNodes();
        this.addHost();
        this.addHost();
    }
}

/**
 * @static
 * @const
 */
GraphBuilder.MAX_HOSTS = 6;

/**
 * @static
 * @const
 */
GraphBuilder.START_OFFSET = 62.5;

/**
 * @static
 * @const
 */
GraphBuilder.Y_SPACING = 50;

/**
 * @static
 * @const
 */
GraphBuilder.BOX = 25;

/**
 * @static
 * @const
 */
GraphBuilder.HALF_BOX = GraphBuilder.BOX / 2;

/**
 * Replaces the contents of this graph builder with the provided BuilderGraph
 * 
 * @param {BuilderGraph} bg
 */
GraphBuilder.prototype.convertFromBG = function(bg) {
    var context = this;

    while (this.hosts.length > 0)
        this.removeHost(this.hosts[this.hosts.length - 1]);

    var hostMap = {};

    bg.getHosts().forEach(function(h) {
        var host = context.addHost();
        hostMap[h] = host;
    });

    var nodeToParents = {};
    var nodeToY = {};
    var nodeToGBNode = {};

    var nodes = bg.getNodes();
    nodes.forEach(function(n) {
        var head = n.getPrev().isHead() ? 0 : 1;
        nodeToParents[n.getId()] = n.getParents().length + head;
        nodeToY[n.getId()] = (context.motifSearch ? 5 : GraphBuilder.START_OFFSET + GraphBuilder.Y_SPACING * 2);
    });

    var next = nodes.filter(function(n) {
        return !nodeToParents[n.getId()];
    });

    while (next.length) {
        var curr = next.pop();

        var node = hostMap[curr.getHost()].addNode(nodeToY[curr.getId()]);
        nodeToGBNode[curr.getId()] = node;

        var cnext = curr.getNext().isTail() ? [] : [ curr.getNext() ];
        var children = curr.getChildren().concat(cnext);

        children.forEach(function(n) {
            var id = n.getId();

            var numParents = --nodeToParents[id];
            nodeToY[id] = (context.motifSearch ? Math.max(nodeToY[id], nodeToY[curr.getId()] + 20) : Math.max(nodeToY[id], nodeToY[curr.getId()] + GraphBuilder.Y_SPACING));

            if (numParents == 0)
                next.push(n);
        });
    }

    nodes.forEach(function(n) {
        var gbn = nodeToGBNode[n.getId()];
        var children = n.getChildren();
        children.forEach(function(c) {
            var gbc = nodeToGBNode[c.getId()];
            var c1 = gbn.getCoords();
            var c2 = gbc.getCoords();

            var $line = Util.svgElement("line").attr({
                "x1": c1[0],
                "x2": c2[0],
                "y1": c1[1],
                "y2": c2[1]
            }).prependTo(context.$svg);

            gbn.addChild(gbc, $line);
        });
    });

    if (this.motifSearch) {
        this.hosts.forEach(function(host) {
            var sortedNodes = host.getNodesSorted();
            var y1 = sortedNodes[0].getCoords()[1];
            var y2 = sortedNodes[sortedNodes.length - 1].getCoords()[1];
            host.setLineYCoordinates(y1, y2);
        });
    }
};

/**
 * Gets the svg associated with this GraphBuilder as a JQuery object. The svg is
 * what the graph builder is drawn to
 * 
 * @returns {$} The svg as a JQuery object
 */
GraphBuilder.prototype.getSVG = function() {
    return this.$svg;
};

/**
 * Creates and adds a host to this GraphBuilder
 * 
 * @returns {GraphBuilderHost} the newly created and added host
 */
GraphBuilder.prototype.addHost = function() {

    if (this.hosts.length >= GraphBuilder.MAX_HOSTS) {
        throw new Exception("GraphBuilder.prototype.addHost: no new hosts may be added");
    }

    var host = new GraphBuilderHost(this, this.hosts.length, this.motifSearch);
    this.hosts.push(host);

    this.$svg.width(this.hosts.length * 65);

    if (this.$addButton) {
        if (this.hosts.length == GraphBuilder.MAX_HOSTS) {
            this.$addButton.attr("disabled", true);
        }
        else {
            this.$addButton.css("background", this.colors[this.colors.length - 1]);
        }
    }

    return host;
};

/**
 * Removes the provided host from this graph builder
 * 
 * @param {GraphBuilderHost} host the host to remove
 */
GraphBuilder.prototype.removeHost = function(host) {

    Util.removeFromArray(this.hosts, host);
    var hostNum = host.getHostNum();

    // Remove the constraint indicator when removing a host
    host.getConstraintSVG().remove();

    this.hosts.forEach(function(h, i) {
        h.rx = i * 65;
        h.x = h.rx + 12.5;
        h.rect.attr("x", h.rx);
        h.line.attr({
            "x1": h.x,
            "x2": h.x
        });

        h.nodes.forEach(function(n) {
            n.lines.forEach(function(l) {
                if (l.line.attr("x1") == n.x)
                    l.line.attr("x1", h.x);
                else
                    l.line.attr("x2", h.x);
            });
            n.x = h.x;
            n.circle.attr("cx", h.x);
        });

        // update the hostNum
        var currHostNum = h.getHostNum();
        if (currHostNum > hostNum) {
            h.setHostNum(currHostNum - 1);
            h.getConstraintSVG().attr({
                "x": parseFloat(h.getHostSquare().attr("x")) + 8
            });
        }
    });

    this.colors.push(host.color);
    host.removeAllNodes();

    host.rect.remove();
    host.line.remove();

    this.$svg.width(this.hosts.length * 65);

    if (this.$addButton) {
        this.$addButton.css("background", this.colors[this.colors.length - 1]);
        this.$addButton.removeAttr("disabled");
    }
    this.invokeUpdateCallback();
};

/**
 * Retrieves all graph builder hosts associated with this graph builder
 * @returns {Array<GraphBuilderHost>} All hosts
 */
GraphBuilder.prototype.getHosts = function() {
    return this.hosts;
}

/**
 * Retrieves the host whose x coordinate is the one provided
 * 
 * @param {Number} x the x-coord
 * @returns {GraphBuilderHost} the host with the specified x-coordinate
 */
GraphBuilder.prototype.getHostByX = function(x) {
    for (var i = 0; i < this.hosts.length; i++) {
        if (this.hosts[i].x == x) {
            return this.hosts[i];
        }
    }
    return null;
};

/**
 * Retrieves the host that contains the specified node
 * 
 * @param {GraphBuilderNode} node the node whose host you want to find
 * @returns {GraphBuilderHost} the host that contains the specified node
 */
GraphBuilder.prototype.getHostByNode = function(node) {
    for (var i = 0; i < this.hosts.length; i++) {
        if (this.hosts[i].x == node.x) {
            return this.hosts[i];
        }
    }
    return null;
};

/**
 * Gets all of the nodes contained in this graph builder as an array
 * 
 * @returns {Array<GraphBuilderNode>} the nodes in this graph builder
 */
GraphBuilder.prototype.getNodes = function() {
    var nodes = [];
    this.hosts.forEach(function(host) {
        nodes = nodes.concat(host.nodes);
    });
    return nodes;
};

/**
 * Retrieves the node whose coordinates match the specified coordinates
 * 
 * @param {Number} x the x-coordinate
 * @param {Number} y the y-coordinate
 * @returns {GraphBuilderNode} the node with the specified coordinates
 */
GraphBuilder.prototype.getNodeByCoord = function(x, y) {
    var nodes = this.getNodes();
    for (var i = 0; i < nodes.length; i++) {
        var node = nodes[i];
        if (node.x == x && node.y == y) {
            return node;
        }
    }
    return null;
};

/**
 * Resets the GraphBuilder to its original state.
 */
GraphBuilder.prototype.clear = function() {
    // Set the cleared field to true so that the callback in searchBar.js does not update the searchbar input
    this.setCleared(true);
    while (this.hosts.length > 0)
        // removeHost invokes the callback function
        this.removeHost(this.hosts[this.hosts.length - 1]);

    // Set the cleared field to false now that callbacks for resetting the GraphBuilder have completed
    // This allows the searchbar input to be updated the next time a user draws something
    this.setCleared(false);
    this.addHost();
    this.addHost();
};

/**
 * Retrieves the cleared field for this GraphBuilder
 * @returns {Boolean} True if this GraphBuilder was reset, false otherwise
 */
GraphBuilder.prototype.isCleared = function() {
    return this.cleared;
}

/**
 * Sets the cleared field for this GraphBuilder
 * @param {Boolean} cleared The boolean value to set
 */
GraphBuilder.prototype.setCleared = function(cleared) {
    this.cleared = cleared;
}

/**
 * Gets the maximum x-coordinate among the nodes in this graphBuilder
 */
GraphBuilder.prototype.getMaxNodeWidth = function() {
    var nodes = this.getNodes();
    var maxWidth = 0;

    nodes.forEach(function(node) {
        var currWidth = node.getCoords()[0];
        if (currWidth > maxWidth) {
            maxWidth = currWidth;
        }
    });

    return maxWidth;
}

/**
 * Gets the maximum y-coordinate among the nodes in this graphBuilder
 */
GraphBuilder.prototype.getMaxNodeHeight = function() {
    var nodes = this.getNodes();
    var maxHeight = 0;

    nodes.forEach(function(node) {
        var currHeight = node.getCoords()[1];
        if (currHeight > maxHeight) {
            maxHeight = currHeight;
        }
    });

    return maxHeight;
}

/**
 * Handles all user interaction with nodes in the graph builder, including:
 *  
 * <ul>
 * <li>Add node on click</li>
 * <li>Drag to add two connected nodes</li>
 * <li>Drag from existing node to new connected node</li>
 * <li>Drag from existing node to existing node to add connection</li>
 * <li>Drag existing node out of drawing area to remove</li>
 * <li>Double click existing node to remove</li>
 * </ul>
 */
GraphBuilder.prototype.bindNodes = function() {

    var context = this;
    var $svg = this.$svg;
    var $hover = this.$hover;

    if (this.$addButton) {
        this.$addButton.unbind().click(function() {
            context.addHost();
        });
    }

    $svg.unbind().on("mousemove", function(e) {
        var x = e.offsetX || (e.pageX - $svg.offset().left);
        var y = e.offsetY || (e.pageY - $svg.offset().top);

        var arr = [];
        context.hosts.forEach(function(h) {
            var y_int = Math.round((y - GraphBuilder.HALF_BOX) / GraphBuilder.Y_SPACING);
            var snap_y = y_int * GraphBuilder.Y_SPACING + GraphBuilder.HALF_BOX;

            dy = Math.max(GraphBuilder.START_OFFSET, snap_y);
            arr.push([ h.x, dy ]);
            arr.push([ h.x, dy - GraphBuilder.Y_SPACING ]);
            arr.push([ h.x, dy + GraphBuilder.Y_SPACING ]);
        });
        arr = arr.filter(isValid);

        var c = closest(arr, x, y);

        if (!c.x)
            return;

        if (y < GraphBuilder.BOX || c.y < GraphBuilder.BOX) {
            $hover.hide().hidden = true;
        }
        else {
            $hover.show().hidden = false;
        }

        var color = context.getHostByX(c.x).getColor();

        $hover.attr({
            "cx": c.x,
            "cy": c.y,
            "fill": color
        });
    }).on("mousedown", function(e) {
        var hx = $hover.attr("cx"), hy = $hover.attr("cy");
        var existing = context.getNodeByCoord(hx, hy);

        if (!existing && !$hover.hidden) {
            var n = context.getHostByX(hx).addNode(hy, true);
            var $c = $(n.circle);
            context.invokeUpdateCallback();
            $c.mousedown();
        }
    }).on("mouseout", function(e) {
        var $t = $(e.relatedTarget);
        if ($t[0] == $svg[0] || $t.parents("svg").length)
            return;
        $hover.hide();
    });

    $svg.find("circle:not(.hover)").unbind().on("mousedown", function() {
        var parent = this.node;

        var $line = Util.svgElement("line").attr({
            "x1": $(this).attr("cx"),
            "y1": $(this).attr("cy"),
            "x2": $(this).attr("cx"),
            "y2": $(this).attr("cy")
        }).prependTo($svg);

        parent.state = "active";

        $svg.on("mousemove", function(e) {
            if ($hover.hidden) {
                $line.hide();
                return;
            }
            else {
                $line.show();
            }

            var x = e.offsetX || (e.pageX - $svg.offset().left);
            var y = e.offsetY || (e.pageY - $svg.offset().top);

            var a = context.getNodes().map(function(n) {
                return [ n.x, n.y ];
            }).filter(isValid).concat([ [ $hover.attr("cx"), $hover.attr("cy") ] ]);

            var c = closest(a, x, y);

            $line.attr({
                "x2": c.x,
                "y2": c.y
            });

        }).on("mouseup", function() {
            var hx = $hover.attr("cx"), hy = $hover.attr("cy");
            var existing = context.getNodeByCoord(hx, hy);

            if (!existing && !$hover.hidden) {
                var child = context.getHostByX(hx).addNode(hy, false);

                if (parent.y < child.y)
                    parent.addChild(child, $line);
                else
                    child.addChild(parent, $line);
            }
            else {
                if (existing == parent) {
                    $line.remove();
                }
                else {
                    if (parent.y < existing.y)
                        parent.addChild(existing, $line);
                    else
                        existing.addChild(parent, $line);
                }
            }

            parent.state = false;
            context.bindNodes();

        }).on("mouseout", function(e) {
            var $t = $(e.relatedTarget);
            if ($t[0] == $svg[0] || $t.parents("svg").length)
                return;
            $line.remove();
            context.getHostByNode(parent).removeNode(parent);
            context.invokeUpdateCallback();
            context.bindNodes();
        });

    }).on("dblclick", function() {
        context.getHostByNode(this.node).removeNode(this.node);

    }).on("click", function() {
        var $circle = $(this);

        $(".eventConstraintDialog").css({
            "left": $circle.attr("cx") + 200
        }).show();

        $("#eventConstraint").css({
            "border-color": $circle.attr("fill"),
            "margin-top": $circle.attr("cy") + 100
        }).focus();
    });

    $("#eventConstraint").unbind("keydown").on("keydown", function(e) {
        switch (e.which) {
        // Return
        case 13:
            $(".eventConstraintDialog").hide();

            // Update the searchbar with the new constraint
            context.invokeUpdateCallback();
            break;
        }
    });

    function isValid(c) {
        var n = context.getNodes().filter(function(n) {
            return n.state && (!(n.x == c[0]) != !(n.y == c[1]));
        });

        return n.length == 0;
    }

    function closest(array, x, y, d) {
        var r = {};
        for (var i = 0; i < array.length; i++) {
            var ix = parseFloat(array[i][0]);
            var iy = parseFloat(array[i][1]);
            var id = Math.sqrt(Math.pow(x - ix, 2) + Math.pow(y - iy, 2));
            if ((!d || id < d) && (r.dist === undefined || id < r.dist)) {
                r.index = i;
                r.dist = id;
                r.x = ix;
                r.y = iy;
            }
        }

        return r;
    }
};

/**
 * Handles user interaction with host squares in the graph builder
 *
 * @param {GraphBuilderHost} host The host to bind events to 
 */
GraphBuilder.prototype.bindHost = function(host) {
    var graphBuilder = this;
    var square = host.getHostSquare();

    square.on("dblclick", function() {
        // Have to pass in host here or the value of "this" in handleHostClick will be for square
        // and we won't be able to access graphBuilder
        graphBuilder.handleHostDblClick(host);
    }).on("click", function() {
        graphBuilder.handleHostClick(host);
    });

    $("#hostConstraint").unbind("keydown").on("keydown", function(e) {
        switch (e.which) {
        // Return
        case 13:
            var currHost = graphBuilder.getHostByX($(this).attr("name"));
            currHost.setConstraint($(this).val().trim());
            $(".hostConstraintDialog").hide();

            // Update the searchbar with the new constraint
            graphBuilder.invokeUpdateCallback();
            break;
        }
    });
}

/**
 * Handles the click event on a host box in a custom structured search by showing the constraint dialog box
 * @param {GraphBuilderHost} host The host whose host box was clicked on
 */
GraphBuilder.prototype.handleHostClick = function(host) {
    $(".hostConstraintDialog").css({
        "left": host.graphBuilder.getSVG().offset().left + host.getX() + 15
    }).show();

    $("#hostConstraint").css({
        "border-color": host.getColor()
    }).val(host.getConstraint()).attr("name", host.getX()).focus();
}

/**
 * Handles the double click event on a host box in a custom structured search by removing the host
 * @param {GraphBuilderHost} host The host whose host box was double clicked on
 */
GraphBuilder.prototype.handleHostDblClick = function(host) {
    host.graphBuilder.removeHost(host);
    $(".hostConstraintDialog").hide();
}

/**
 * Sets the update callback function. The update callback function will be
 * called whenever a change is made to this graph builder
 * 
 * @param {Function} fn a function of zero parameters
 */
GraphBuilder.prototype.setUpdateCallback = function(fn) {
    this.updateCallback = fn;
};

/**
 * Invokes the update callback function
 */
GraphBuilder.prototype.invokeUpdateCallback = function() {
    if (this.updateCallback) {
        this.updateCallback();
    }
};

/**
 * Converts the drawn graph in this graph builder to a BuilderGraph
 * 
 * @returns {BuilderGraph} the resulting BuilderGraph
 */
GraphBuilder.prototype.convertToBG = function() {

    var hosts = this.hosts.map(function(gbHost) {
        return gbHost.getName();
    });

    var hostConstraints = this.hosts.map(function(gbHost) {
        return gbHost.getConstraint() != "";
    });

    var builderGraph = new BuilderGraph(hosts, hostConstraints);
    var gbNodeToBuilderNode = {};

    this.hosts.forEach(function(gbHost) {
        var tail = builderGraph.getTail(gbHost.getName());

        gbHost.getNodesSorted().forEach(function(gbNode) {
            var builderNode = new BuilderNode();
            gbNodeToBuilderNode[gbNode.getId()] = builderNode;
            tail.insertPrev(builderNode);

            var index = hosts.indexOf(builderNode.getHost());
            builderNode.setHasHostConstraint(hostConstraints[index]);
        });
    });

    this.getNodes().forEach(function(gbNode) {
        var builderNode = gbNodeToBuilderNode[gbNode.getId()];
        gbNode.getChildren().sort(function(a, b) {
            return a.y - b.y;
        }).forEach(function(child) {
            builderNode.addChild(gbNodeToBuilderNode[child.getId()]);
        });
    });

    return builderGraph;
};