svg-graph.js

732 lines | 21.402 kB Blame History Raw Download
/*
 * Copyright 2012 LinkedIn Corp.
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not
 * use this file except in compliance with the License. You may obtain a copy of
 * the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations under
 * the License.
 */

/*
 * SVG graph view.
 */

$.namespace('azkaban');

azkaban.SvgGraphView = Backbone.View.extend({
  events: {},

  initialize: function (settings) {
    this.model.bind('change:selected', this.changeSelected, this);
    this.model.bind('centerNode', this.centerNode, this);
    this.model.bind('change:graph', this.render, this);
    this.model.bind('resetPanZoom', this.resetPanZoom, this);
    this.model.bind('change:update', this.handleStatusUpdate, this);
    this.model.bind('change:disabled', this.handleDisabledChange, this);
    this.model.bind('change:updateAll', this.handleUpdateAllStatus, this);
    this.model.bind('expandFlow', this.expandFlow, this);
    this.model.bind('collapseFlow', this.collapseFlow, this);

    this.graphMargin = settings.graphMargin ? settings.graphMargin : 25;
    this.svgns = "http://www.w3.org/2000/svg";
    this.xlinksn = "http://www.w3.org/1999/xlink";

    var graphDiv = this.el[0];
    var svg = $(this.el).find('svg')[0];
    if (!svg) {
      svg = this.el;
    }

    this.svgGraph = svg;
    $(this.svgGraph).svg();
    this.svg = $(svg).svg('get');

    $(this.svgGraph).empty();

    // Create mainG node
    var gNode = document.createElementNS(this.svgns, 'g');
    gNode.setAttribute("class", "main graph");
    svg.appendChild(gNode);
    this.mainG = gNode;

    if (settings.rightClick) {
      this.rightClick = settings.rightClick;
    }

    $(svg).svgNavigate();

    var self = this;
    if (self.rightClick && self.rightClick.graph) {
      $(svg).on("contextmenu", function (evt) {
        console.log("graph click");
        var currentTarget = evt.currentTarget;

        self.rightClick.graph(evt, self.model, currentTarget.data);
        return false;
      });
    }

    this.tooltipcontainer = settings.tooltipcontainer
        ? settings.tooltipcontainer : "body";
    if (settings.render) {
      this.render();
    }
  },

  render: function () {
    console.log("graph render");
    $(this.mainG).empty();

    this.graphBounds = this.renderGraph(this.model.get("data"), this.mainG);
    this.resetPanZoom(0);
  },

  renderGraph: function (data, g) {
    g.data = data;
    var nodes = data.nodes;
    var edges = data.edges;
    var nodeMap = data.nodeMap;

    // Create a g node for edges, so that they're forced in the back.
    var edgeG = this.svg.group(g);
    if (nodes.length == 0) {
      console.log("No results");
      return;
    }
    ;

    // Assign labels
    for (var i = 0; i < nodes.length; ++i) {
      nodes[i].label = nodes[i].id;
    }

    var self = this;
    for (var i = 0; i < nodes.length; ++i) {
      this.drawNode(this, nodes[i], g);
      $(nodes[i].gNode).click(function (evt) {
        var selected = self.model.get("selected");
        if (selected == evt.currentTarget.data) {
          self.model.unset("selected");
        }
        else {
          self.model.set({"selected": evt.currentTarget.data});
        }

        evt.stopPropagation();
        evt.cancelBubble = true;
      });
    }

    // layout
    layoutGraph(nodes, edges, 10);
    var bounds = this.calculateBounds(nodes);
    this.moveNodes(nodes);

    for (var i = 0; i < edges.length; ++i) {
      edges[i].toNode = nodeMap[edges[i].to];
      edges[i].fromNode = nodeMap[edges[i].from];
      this.drawEdge(this, edges[i], edgeG);
    }

    this.model.set({"flowId": data.flowId, "edges": edges});

    var margin = this.graphMargin;
    bounds.minX = bounds.minX ? bounds.minX - margin : -margin;
    bounds.minY = bounds.minY ? bounds.minY - margin : -margin;
    bounds.maxX = bounds.maxX ? bounds.maxX + margin : margin;
    bounds.maxY = bounds.maxY ? bounds.maxY + margin : margin;

    this.assignInitialStatus(this, data);

    if (self.rightClick) {
      if (self.rightClick.node) {
        // Proper children selectors don't work properly on svg
        for (var i = 0; i < nodes.length; ++i) {
          $(nodes[i].gNode).on("contextmenu", function (evt) {
            console.log("node click");
            var currentTarget = evt.currentTarget;
            self.rightClick.node(evt, self.model, currentTarget.data);
            return false;
          });
        }
      }
      if (this.rightClick.graph) {
        $(g).on("contextmenu", function (evt) {
          console.log("graph click");
          var currentTarget = evt.currentTarget;

          self.rightClick.graph(evt, self.model, currentTarget.data);
          return false;
        });
      }
    }
    ;

    $(".node").each(function (d, i) {
      $(this).tooltip({
        container: self.tooltipcontainer,
        delay: {
          show: 500,
          hide: 100
        }
      });
    });

    return bounds;
  },

  handleDisabledChange: function (evt) {
    this.changeDisabled(this.model.get('data'));
  },

  changeDisabled: function (data) {
    for (var i = 0; i < data.nodes.length; ++i) {
      var node = data.nodes[i];
      if (node.disabled) {
        if (node.gNode) {
          addClass(node.gNode, "nodeDisabled");
          $(node.gNode).attr("title", "DISABLED (" + node.type + ")").tooltip(
              'fixTitle');
        }
      }
      else {
        if (node.gNode) {
          removeClass(node.gNode, "nodeDisabled");
          $(node.gNode).attr("title", node.status + " (" + node.type
              + ")").tooltip('fixTitle');
        }
        if (node.type == 'flow') {
          this.changeDisabled(node);
        }
      }
    }
  },

  assignInitialStatus: function (evt, data) {
    for (var i = 0; i < data.nodes.length; ++i) {
      var updateNode = data.nodes[i];
      var g = updateNode.gNode;
      var initialStatus = updateNode.status ? updateNode.status : "READY";

      addClass(g, initialStatus);
      var title = initialStatus + " (" + updateNode.type + ")";

      if (updateNode.disabled) {
        addClass(g, "nodeDisabled");
        title = "DISABLED (" + updateNode.type + ")";
      }
      $(g).attr("title", title);
    }
  },

  changeSelected: function (self) {
    console.log("change selected");
    var selected = this.model.get("selected");
    var previous = this.model.previous("selected");

    if (previous) {
      // Unset previous
      removeClass(previous.gNode, "selected");
    }

    if (selected) {
      this.propagateExpansion(selected);
      var g = selected.gNode;
      addClass(g, "selected");

      console.log(this.model.get("autoPanZoom"));
      if (this.model.get("autoPanZoom")) {
        this.centerNode(selected);
      }
    }
  },

  propagateExpansion: function (node) {
    if (node.parent.type) {
      this.propagateExpansion(node.parent);
      this.expandFlow(node.parent);
    }
  },

  handleStatusUpdate: function (evt) {
    var updateData = this.model.get("update");
    var data = this.model.get("data");
    this.updateStatusChanges(updateData, data);
  },

  updateStatusChanges: function (updateData, data) {
    // Assumes all changes have been applied.
    if (updateData.nodes) {
      var nodeMap = data.nodeMap;
      for (var i = 0; i < updateData.nodes.length; ++i) {
        var node = updateData.nodes[i];
        var nodeToUpdate = nodeMap[node.id];

        var g = nodeToUpdate.gNode;
        if (g) {
          this.handleRemoveAllStatus(g);
          addClass(g, nodeToUpdate.status);

          var title = nodeToUpdate.status + " (" + nodeToUpdate.type + ")";
          if (nodeToUpdate.disabled) {
            addClass(g, "nodeDisabled");
            title = "DISABLED (" + nodeToUpdate.type + ")";
          }
          $(g).attr("title", title).tooltip('fixTitle');

          if (node.nodes) {
            this.updateStatusChanges(node, nodeToUpdate);
          }
        }
      }
    }
  },

  handleRemoveAllStatus: function (gNode) {
    for (var j = 0; j < statusList.length; ++j) {
      var status = statusList[j];
      removeClass(gNode, status);
    }
  },

  handleRightClick: function (self) {
    if (this.rightClick) {
      var callbacks = this.rightClick;
      var currentTarget = self.currentTarget;
      if (callbacks.node && currentTarget.jobid) {
        callbacks.node(self, this.model, currentTarget.nodeobj);
      }
      else if (callbacks.edge &&
          (currentTarget.nodeName == "polyline" ||
              currentTarget.nodeName == "line")) {
        callbacks.edge(self, this.model);
      }
      else if (callbacks.graph) {
        callbacks.graph(self, this.model);
      }
      return false;
    }
    return true;
  },

  drawEdge: function (self, edge, g) {
    var svg = this.svg;
    var svgns = self.svgns;

    var startNode = edge.fromNode;
    var endNode = edge.toNode;

    var startPointY = startNode.y + startNode.height / 2;
    var endPointY = endNode.y - endNode.height / 2;

    if (edge.guides) {
      // Create guide array
      var pointArray = new Array();
      pointArray.push([startNode.x, startPointY]);
      for (var i = 0; i < edge.guides.length; ++i) {
        var edgeGuidePoint = edge.guides[i];
        pointArray.push([edgeGuidePoint.x, edgeGuidePoint.y]);
      }
      pointArray.push([endNode.x, endPointY]);

      edge.line = svg.polyline(g, pointArray, {class: "edge", fill: "none"});
      edge.line.data = edge;
      edge.oldpoints = pointArray;
    }
    else {
      edge.line = svg.line(g, startNode.x, startPointY, endNode.x, endPointY,
          {class: "edge"});
      edge.line.data = edge;
    }
  },

  drawNode: function (self, node, g) {
    if (node.type == 'flow') {
      this.drawFlowNode(self, node, g);
    }
    else if (node.condition != null) {
      this.drawConditionNode(self, node, g);
    } else {
      this.drawBoxNode(self, node, g);
    }
  },

  moveNodes: function (nodes) {
    var svg = this.svg;
    for (var i = 0; i < nodes.length; ++i) {
      var node = nodes[i];
      var gNode = node.gNode;

      svg.change(gNode, {"transform": translateStr(node.x, node.y)});
    }
  },

  expandFlow: function (node) {
    var svg = this.svg;
    var gnode = node.gNode;
    node.expanded = true;

    var innerG = gnode.innerG;
    var borderRect = innerG.borderRect;
    var labelG = innerG.labelG;

    var bbox;
    if (!innerG.expandedFlow) {
      var topmargin = 30, bottommargin = 5;
      var hmargin = 10;

      var expandedFlow = svg.group(innerG, "", {class: "expandedGraph"});
      this.renderGraph(node, expandedFlow);
      innerG.expandedFlow = expandedFlow;
      removeClass(innerG, "collapsed");
      addClass(innerG, "expanded");
      node.expandedWidth = node.width;
      node.expandedHeight = node.height;
    }
    else {
      $(innerG.expandedFlow).show();
      removeClass(innerG, "collapsed");
      addClass(innerG, "expanded");
      node.width = node.expandedWidth;
      node.height = node.expandedHeight;
    }

    this.relayoutFlow(node);

    var bounds = this.calculateBounds(this.model.get("data").nodes);

    var margin = this.graphMargin;
    bounds.minX = bounds.minX ? bounds.minX - margin : -margin;
    bounds.minY = bounds.minY ? bounds.minY - margin : -margin;
    bounds.maxX = bounds.maxX ? bounds.maxX + margin : margin;
    bounds.maxY = bounds.maxY ? bounds.maxY + margin : margin;
    this.graphBounds = bounds;
  },

  collapseFlow: function (node) {
    console.log("Collapsing flow");
    var svg = this.svg;
    var gnode = node.gNode;
    node.expanded = false;

    var innerG = gnode.innerG;
    var borderRect = innerG.borderRect;
    var labelG = innerG.labelG;

    removeClass(innerG, "expanded");
    addClass(innerG, "collapsed");

    node.height = node.collapsedHeight;
    node.width = node.collapsedWidth;

    $(innerG.expandedFlow).hide();
    this.relayoutFlow(node);

    var bounds = this.calculateBounds(this.model.get("data").nodes);

    var margin = this.graphMargin;
    bounds.minX = bounds.minX ? bounds.minX - margin : -margin;
    bounds.minY = bounds.minY ? bounds.minY - margin : -margin;
    bounds.maxX = bounds.maxX ? bounds.maxX + margin : margin;
    bounds.maxY = bounds.maxY ? bounds.maxY + margin : margin;
    this.graphBounds = bounds;
  },

  relayoutFlow: function (node) {
    if (node.expanded) {
      this.layoutExpandedFlowNode(node);
    }

    var parent = node.parent;
    if (parent) {
      layoutGraph(parent.nodes, parent.edges, 10);
      this.relayoutFlow(parent);
      // Move all points again.
      this.moveNodeEdges(parent.nodes, parent.edges);
      this.animateExpandedFlowNode(node, 250);
    }
  },

  moveNodeEdges: function (nodes, edges) {
    var svg = this.svg;
    for (var i = 0; i < nodes.length; ++i) {
      var node = nodes[i];
      var gNode = node.gNode;

      $(gNode).animate({"svgTransform": translateStr(node.x, node.y)}, 250);
    }

    for (var j = 0; j < edges.length; ++j) {
      var edge = edges[j];
      var startNode = edge.fromNode;
      var endNode = edge.toNode;
      var line = edge.line;

      var startPointY = startNode.y + startNode.height / 2;
      var endPointY = endNode.y - endNode.height / 2;

      if (edge.guides) {
        // Create guide array
        var pointArray = new Array();
        pointArray.push([startNode.x, startPointY]);
        for (var i = 0; i < edge.guides.length; ++i) {
          var edgeGuidePoint = edge.guides[i];
          pointArray.push([edgeGuidePoint.x, edgeGuidePoint.y]);
        }
        pointArray.push([endNode.x, endPointY]);

        animatePolylineEdge(svg, edge, pointArray, 250);
        edge.oldpoints = pointArray;
      }
      else {
        $(line).animate({
          svgX1: startNode.x,
          svgY1: startPointY,
          svgX2: endNode.x,
          svgY2: endPointY
        });
      }
    }
  },

  calculateBounds: function (nodes) {
    var bounds = {};
    var node = nodes[0];
    bounds.minX = node.x - 10;
    bounds.minY = node.y - 10;
    bounds.maxX = node.x + 10;
    bounds.maxY = node.y + 10;

    for (var i = 0; i < nodes.length; ++i) {
      node = nodes[i];
      var centerX = node.width / 2;
      var centerY = node.height / 2;

      var minX = node.x - centerX;
      var minY = node.y - centerY;
      var maxX = node.x + centerX;
      var maxY = node.y + centerY;

      bounds.minX = Math.min(bounds.minX, minX);
      bounds.minY = Math.min(bounds.minY, minY);
      bounds.maxX = Math.max(bounds.maxX, maxX);
      bounds.maxY = Math.max(bounds.maxY, maxY);
    }
    bounds.width = bounds.maxX - bounds.minX;
    bounds.height = bounds.maxY - bounds.minY;

    return bounds;
  },

  drawBoxNode: function (self, node, g) {
    var svg = this.svg;
    var horizontalMargin = 8;
    var verticalMargin = 2;

    var nodeG = svg.group(g, "", {class: "node jobnode"});

    var innerG = svg.group(nodeG, "", {class: "nodebox"});
    var borderRect = svg.rect(innerG, 0, 0, 10, 10, 3, 3, {class: "border"});
    var jobNameText = svg.text(innerG, horizontalMargin, 16, node.label);
    nodeG.innerG = innerG;
    innerG.borderRect = borderRect;

    var labelBBox = jobNameText.getBBox();
    var totalWidth = labelBBox.width + 2 * horizontalMargin;
    var totalHeight = labelBBox.height + 2 * verticalMargin;
    svg.change(borderRect, {width: totalWidth, height: totalHeight});
    svg.change(jobNameText, {y: (totalHeight + labelBBox.height) / 2 - 3});
    svg.change(innerG,
        {transform: translateStr(-totalWidth / 2, -totalHeight / 2)});

    node.width = totalWidth;
    node.height = totalHeight;

    node.gNode = nodeG;
    nodeG.data = node;
  },

  drawConditionNode: function (self, node, g) {
    var svg = this.svg;
    var horizontalMargin = 8;
    var verticalMargin = 2;

    var nodeG = svg.group(g, "", {class: "node jobnode"});

    var innerG = svg.group(nodeG, "", {class: "nodebox"});
    var borderRect = svg.rect(innerG, 0, 0, 10, 10, 3, 3, {class: "border"});
    var conditionRect = svg.rect(innerG, 0, 0, 10, 10, 0, 0, {class: "border"});
    var jobNameText = svg.text(innerG, horizontalMargin, 16, node.label);
    var conditionText = svg.text(innerG, horizontalMargin, 12, "condition",
        {"font-size": 12});
    nodeG.innerG = innerG;
    innerG.borderRect = borderRect;

    var labelBBox = jobNameText.getBBox();
    var conditionlabelBBox = conditionText.getBBox();
    var totalWidth = labelBBox.width + 2 * horizontalMargin;
    var totalHeight = 2 * labelBBox.height + 2 * verticalMargin;
    svg.change(borderRect, {width: totalWidth, height: totalHeight});
    svg.change(conditionRect, {width: totalWidth, height: labelBBox.height});
    svg.change(conditionText, {x: (totalWidth - conditionlabelBBox.width) / 2});
    svg.change(jobNameText, {y: (totalHeight + labelBBox.height) / 2 + 6});
    svg.change(innerG,
        {transform: translateStr(-totalWidth / 2, -totalHeight / 2)});

    node.width = totalWidth;
    node.height = totalHeight;

    node.gNode = nodeG;
    nodeG.data = node;
  },

  drawFlowNode: function (self, node, g) {
    var svg = this.svg;

    // Base flow node
    var nodeG = svg.group(g, "", {"class": "node flownode"});

    // Create all the elements
    var innerG = svg.group(nodeG, "", {class: "nodebox collapsed"});
    var borderRect = svg.rect(innerG, 0, 0, 10, 10, 3, 3,
        {class: "flowborder"});

    // Create label
    var labelG = svg.group(innerG);
    var iconHeight = 20;
    var iconWidth = 21;
    var textOffset = iconWidth + 4;
    var jobNameText = svg.text(labelG, textOffset, 1, node.label);
    var flowIdText = svg.text(labelG, textOffset, 11, node.flowId,
        {"font-size": 8})
    var tempLabelG = labelG.getBBox();
    var iconImage = svg.image(
        labelG, 0, -iconHeight / 2, iconWidth, iconHeight,
        contextURL + "/images/graph-icon.png", {});

    // Assign key values to make searching quicker
    node.gNode = nodeG;
    nodeG.data = node;

    // Do this because jquery svg selectors don't work
    nodeG.innerG = innerG;
    innerG.borderRect = borderRect;
    innerG.labelG = labelG;

    // Layout everything in the node
    this.layoutFlowNode(self, node);
  },

  layoutFlowNode: function (self, node) {
    var svg = this.svg;
    var horizontalMargin = 8;
    var verticalMargin = 2;

    var gNode = node.gNode;
    var innerG = gNode.innerG;
    var borderRect = innerG.borderRect;
    var labelG = innerG.labelG;

    var labelBBox = labelG.getBBox();
    var totalWidth = labelBBox.width + 2 * horizontalMargin;
    var totalHeight = labelBBox.height + 2 * verticalMargin;

    svg.change(labelG, {
      transform: translateStr(horizontalMargin, labelBBox.height / 2
          + verticalMargin)
    });
    svg.change(innerG,
        {transform: translateStr(-totalWidth / 2, -totalHeight / 2)});
    svg.change(borderRect, {width: totalWidth, height: totalHeight});

    node.height = totalHeight;
    node.width = totalWidth;
    node.collapsedHeight = totalHeight;
    node.collapsedWidth = totalWidth;
  },

  layoutExpandedFlowNode: function (node) {
    var svg = this.svg;
    var topmargin = 30, bottommargin = 5;
    var hmargin = 10;

    var gNode = node.gNode;
    var innerG = gNode.innerG;
    var borderRect = innerG.borderRect;
    var labelG = innerG.labelG;
    var expandedFlow = innerG.expandedFlow;

    var bound = this.calculateBounds(node.nodes);

    node.height = bound.height + topmargin + bottommargin;
    node.width = bound.width + hmargin * 2;
    svg.change(expandedFlow, {
      transform: translateStr(-bound.minX + hmargin, -bound.minY + topmargin)
    });
    //$(innerG).animate({svgTransform: translateStr(-node.width/2, -node.height/2)}, 50);
    //$(borderRect).animate({svgWidth: node.width, svgHeight: node.height}, 50);
  },

  animateExpandedFlowNode: function (node, time) {
    var gNode = node.gNode;
    var innerG = gNode.innerG;
    var borderRect = innerG.borderRect;

    $(innerG).animate(
        {svgTransform: translateStr(-node.width / 2, -node.height / 2)}, time);
    $(borderRect).animate({svgWidth: node.width, svgHeight: node.height}, time);
    $(borderRect).animate({svgFill: 'white'}, time);
  },

  resetPanZoom: function (duration) {
    var bounds = this.graphBounds;
    var param = {
      x: bounds.minX,
      y: bounds.minY,
      width: (bounds.maxX - bounds.minX),
      height: (bounds.maxY - bounds.minY), duration: duration
    };

    this.panZoom(param);
  },

  centerNode: function (node) {
    // The magic of affine transformation.
    // Multiply the inverse root matrix with the current matrix to get the node
    // position.
    // Rather do this than to traverse backwards through the scene graph.
    var ctm = node.gNode.getCTM();
    var globalCTM = this.mainG.getCTM().inverse();
    var otherTransform = globalCTM.multiply(ctm);
    // Also a beauty of affine transformation. The translate is always the
    // left most column of the matrix.
    var x = otherTransform.e - node.width / 2;
    var y = otherTransform.f - node.height / 2;

    this.panZoom({x: x, y: y, width: node.width, height: node.height});
  },

  globalNodePosition: function (gNode) {
    if (node.parent) {

      var parentPos = this.globalNodePosition(node.parent);
      return {x: parentPos.x + node.x, y: parentPos.y + node.y};
    }
    else {
      return {x: node.x, y: node.y};
    }
  },

  panZoom: function (params) {
    params.maxScale = 2;
    $(this.svgGraph).svgNavigate("transformToBox", params);
  }
});