block by nitaku 7493693

Graph editing

Full Screen

Use the mouse (or touch) to pan & zoom or drag nodes around. Clicking a node or a link selects it. You can press del to remove the current selection. Press n to add new nodes (you have to click once on the SVG first).

Based on this example.

The code mixes a pan & zoom implementation based on this example by Mike Bostock with a node drag behavior (see the d3.js wiki). For unknown reasons, this implementation behaves correctly only with the provided version of d3, while it acts strangely with the latest one. It also does not work with touch input.

To implement keyboard input, suitable keyCodes values have been produced by using this jsFiddle.

The code is somewhat disorganized, and it reacts very badly when trying to change something or add new features…

index.js

(function() {
  var global, graph, height, update, width,
    __indexOf = Array.prototype.indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; };

  width = 960;

  height = 500;

  /* SELECTION - store the selected node
  */

  global = {
    selection: null
  };

  /* create some fake data
  */

  graph = {
    nodes: [
      {
        id: 'A',
        x: 469,
        y: 410,
        type: 'X'
      }, {
        id: 'B',
        x: 493,
        y: 364,
        type: 'X'
      }, {
        id: 'C',
        x: 442,
        y: 365,
        type: 'X'
      }, {
        id: 'D',
        x: 467,
        y: 314,
        type: 'X'
      }, {
        id: 'E',
        x: 477,
        y: 248,
        type: 'Y'
      }, {
        id: 'F',
        x: 425,
        y: 207,
        type: 'Y'
      }, {
        id: 'G',
        x: 402,
        y: 155,
        type: 'Y'
      }, {
        id: 'H',
        x: 369,
        y: 196,
        type: 'Y'
      }, {
        id: 'I',
        x: 350,
        y: 148,
        type: 'Z'
      }, {
        id: 'J',
        x: 539,
        y: 222,
        type: 'Z'
      }, {
        id: 'K',
        x: 594,
        y: 235,
        type: 'Z'
      }, {
        id: 'L',
        x: 582,
        y: 185,
        type: 'Z'
      }, {
        id: 'M',
        x: 633,
        y: 200,
        type: 'Z'
      }
    ],
    links: [
      {
        source: 'A',
        target: 'B'
      }, {
        source: 'B',
        target: 'C'
      }, {
        source: 'C',
        target: 'A'
      }, {
        source: 'B',
        target: 'D'
      }, {
        source: 'D',
        target: 'C'
      }, {
        source: 'D',
        target: 'E'
      }, {
        source: 'E',
        target: 'F'
      }, {
        source: 'F',
        target: 'G'
      }, {
        source: 'F',
        target: 'H'
      }, {
        source: 'G',
        target: 'H'
      }, {
        source: 'G',
        target: 'I'
      }, {
        source: 'H',
        target: 'I'
      }, {
        source: 'J',
        target: 'E'
      }, {
        source: 'J',
        target: 'L'
      }, {
        source: 'J',
        target: 'K'
      }, {
        source: 'K',
        target: 'L'
      }, {
        source: 'L',
        target: 'M'
      }, {
        source: 'M',
        target: 'K'
      }
    ],
    objectify: (function() {
      /* resolve node IDs (not optimized at all!)
      */
      var l, n, _i, _len, _ref, _results;
      _ref = graph.links;
      _results = [];
      for (_i = 0, _len = _ref.length; _i < _len; _i++) {
        l = _ref[_i];
        _results.push((function() {
          var _j, _len2, _ref2, _results2;
          _ref2 = graph.nodes;
          _results2 = [];
          for (_j = 0, _len2 = _ref2.length; _j < _len2; _j++) {
            n = _ref2[_j];
            if (l.source === n.id) {
              l.source = n;
              continue;
            }
            if (l.target === n.id) {
              l.target = n;
              continue;
            } else {
              _results2.push(void 0);
            }
          }
          return _results2;
        })());
      }
      return _results;
    }),
    remove: (function(condemned) {
      /* remove the given node or link from the graph, also deleting dangling links if a node is removed
      */      if (__indexOf.call(graph.nodes, condemned) >= 0) {
        graph.nodes = graph.nodes.filter(function(n) {
          return n !== condemned;
        });
        return graph.links = graph.links.filter(function(l) {
          return l.source.id !== condemned.id && l.target.id !== condemned.id;
        });
      } else if (__indexOf.call(graph.links, condemned) >= 0) {
        return graph.links = graph.links.filter(function(l) {
          return l !== condemned;
        });
      }
    }),
    last_index: 0,
    add_node: (function() {
      var n;
      n = {
        id: graph.last_index++,
        x: width / 2,
        y: height / 2,
        type: 'X'
      };
      return graph.nodes.push(n);
    })
  };

  graph.objectify();

  window.main = (function() {
    /* create the SVG
    */
    var container, svg;
    svg = d3.select('body').append('svg').attr('width', width).attr('height', height);
    /* ZOOM and PAN
    */
    /* create container elements
    */
    container = svg.append('g');
    container.call(d3.behavior.zoom().scaleExtent([0.5, 8]).on('zoom', (function() {
      return global.vis.attr('transform', "translate(" + d3.event.translate + ")scale(" + d3.event.scale + ")");
    })));
    global.vis = container.append('g');
    /* create a rectangular overlay to catch events
    */
    /* WARNING rect size is huge but not infinite. this is a dirty hack
    */
    global.vis.append('rect').attr('class', 'overlay').attr('x', -500000).attr('y', -500000).attr('width', 1000000).attr('height', 1000000).on('click', (function(d) {
      /* SELECTION
      */      global.selection = null;
      d3.selectAll('.node').classed('selected', false);
      return d3.selectAll('.link').classed('selected', false);
    }));
    /* END ZOOM and PAN
    */
    global.colorify = d3.scale.category10();
    /* initialize the force layout
    */
    global.force = d3.layout.force().size([width, height]).charge(-400).linkDistance(60).on('tick', (function() {
      /* update nodes and links
      */      global.vis.selectAll('.node').attr('transform', function(d) {
        return "translate(" + d.x + "," + d.y + ")";
      });
      return global.vis.selectAll('.link').attr('x1', function(d) {
        return d.source.x;
      }).attr('y1', function(d) {
        return d.source.y;
      }).attr('x2', function(d) {
        return d.target.x;
      }).attr('y2', function(d) {
        return d.target.y;
      });
    }));
    /* DELETION - pressing DEL deletes the selection
    */
    /* CREATION - pressing N creates a new node
    */
    d3.select(window).on('keydown', function() {
      if (d3.event.keyCode === 46) {
        if (global.selection != null) {
          graph.remove(global.selection);
          global.selection = null;
          return update();
        }
      } else if (d3.event.keyCode === 78) {
        graph.add_node();
        return update();
      }
    });
    return update();
  });

  update = function() {
    /* update the layout
    */
    var links, new_nodes, nodes;
    global.force.nodes(graph.nodes).links(graph.links).start();
    /* create nodes and links
    */
    /* (links are drawn first to make them appear under the nodes)
    */
    /* also, overwrite the selections with their databound version
    */
    links = global.vis.selectAll('.link').data(graph.links, function(d) {
      return "" + d.source.id + "->" + d.target.id;
    });
    links.enter().append('line').attr('class', 'link').on('click', (function(d) {
      /* SELECTION
      */      global.selection = d;
      d3.selectAll('.link').classed('selected', function(d2) {
        return d2 === d;
      });
      return d3.selectAll('.node').classed('selected', false);
    }));
    links.exit().remove();
    /* also define a drag behavior to drag nodes
    */
    /* dragged nodes become fixed
    */
    nodes = global.vis.selectAll('.node').data(graph.nodes, function(d) {
      return d.id;
    });
    new_nodes = nodes.enter().append('g').attr('class', 'node').call(global.force.drag().on('dragstart', function(d) {
      return d.fixed = true;
    })).on('click', (function(d) {
      /* SELECTION
      */      global.selection = d;
      d3.selectAll('.node').classed('selected', function(d2) {
        return d2 === d;
      });
      return d3.selectAll('.link').classed('selected', false);
    }));
    new_nodes.append('circle').attr('r', 18).attr('stroke', function(d) {
      return global.colorify(d.type);
    }).attr('fill', function(d) {
      return d3.hcl(global.colorify(d.type)).brighter(3);
    });
    /* draw the label
    */
    new_nodes.append('text').text(function(d) {
      return d.id;
    }).attr('dy', '0.35em').attr('fill', function(d) {
      return global.colorify(d.type);
    });
    return nodes.exit().remove();
  };

}).call(this);

index.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>Graph editing</title>
        <link type="text/css" href="index.css" rel="stylesheet"/>
        <script src="d3.v3.min.js"></script>
        <script src="index.js"></script>
    </head>
    <body onload="main()">
    </body>
</html>

index.coffee

width = 960
height = 500

### SELECTION - store the selected node ###
global = {
    selection: null
}

### create some fake data ###
graph = {
    nodes: [
        {id: 'A', x: 469, y: 410, type: 'X'},
        {id: 'B', x: 493, y: 364, type: 'X'},
        {id: 'C', x: 442, y: 365, type: 'X'},
        {id: 'D', x: 467, y: 314, type: 'X'},
        {id: 'E', x: 477, y: 248, type: 'Y'},
        {id: 'F', x: 425, y: 207, type: 'Y'},
        {id: 'G', x: 402, y: 155, type: 'Y'},
        {id: 'H', x: 369, y: 196, type: 'Y'},
        {id: 'I', x: 350, y: 148, type: 'Z'},
        {id: 'J', x: 539, y: 222, type: 'Z'},
        {id: 'K', x: 594, y: 235, type: 'Z'},
        {id: 'L', x: 582, y: 185, type: 'Z'},
        {id: 'M', x: 633, y: 200, type: 'Z'}
    ],
    links: [
        {source: 'A', target: 'B'},
        {source: 'B', target: 'C'},
        {source: 'C', target: 'A'},
        {source: 'B', target: 'D'},
        {source: 'D', target: 'C'},
        {source: 'D', target: 'E'},
        {source: 'E', target: 'F'},
        {source: 'F', target: 'G'},
        {source: 'F', target: 'H'},
        {source: 'G', target: 'H'},
        {source: 'G', target: 'I'},
        {source: 'H', target: 'I'},
        {source: 'J', target: 'E'},
        {source: 'J', target: 'L'},
        {source: 'J', target: 'K'},
        {source: 'K', target: 'L'},
        {source: 'L', target: 'M'},
        {source: 'M', target: 'K'}
    ],
    objectify: (() ->
        ### resolve node IDs (not optimized at all!) ###
        for l in graph.links
            for n in graph.nodes
                if l.source is n.id
                    l.source = n
                    continue
                    
                if l.target is n.id
                    l.target = n
                    continue
    ),
    remove: ((condemned) ->
        ### remove the given node or link from the graph, also deleting dangling links if a node is removed ###
        if condemned in graph.nodes
            graph.nodes = graph.nodes.filter (n) -> n isnt condemned
            graph.links = graph.links.filter (l) -> l.source.id isnt condemned.id and l.target.id isnt condemned.id
        else if condemned in graph.links
            graph.links = graph.links.filter (l) -> l isnt condemned
    ),
    last_index: 0,
    add_node: (() ->
        n = {
            id: graph.last_index++,
            x: width/2,
            y: height/2,
            type: 'X'
        }
        
        graph.nodes.push(n)
    )
}

graph.objectify()

window.main = (() ->
    ### create the SVG ###
    svg = d3.select('body').append('svg')
        .attr('width', width)
        .attr('height', height)
        
    ### ZOOM and PAN ###
    ### create container elements ###
    container = svg.append('g')
    
    container.call(d3.behavior.zoom().scaleExtent([0.5, 8]).on('zoom', (() -> global.vis.attr('transform', "translate(#{d3.event.translate})scale(#{d3.event.scale})"))))
    
    global.vis = container.append('g')
    
    ### create a rectangular overlay to catch events ###
    ### WARNING rect size is huge but not infinite. this is a dirty hack ###
    global.vis.append('rect')
        .attr('class', 'overlay')
        .attr('x', -500000)
        .attr('y', -500000)
        .attr('width', 1000000)
        .attr('height', 1000000)
        .on('click', ((d) ->
            ### SELECTION ###
            global.selection = null
            d3.selectAll('.node').classed('selected', false)
            d3.selectAll('.link').classed('selected', false)
        ))
        
    ### END ZOOM and PAN ###
    
    global.colorify = d3.scale.category10()
    
    ### initialize the force layout ###
    global.force = d3.layout.force()
        .size([width, height])
        .charge(-400)
        .linkDistance(60)
        .on('tick', (() ->
            ### update nodes and links  ###
            global.vis.selectAll('.node')
                .attr('transform', (d) -> "translate(#{d.x},#{d.y})")
                
            global.vis.selectAll('.link')
                .attr('x1', (d) -> d.source.x)
                .attr('y1', (d) -> d.source.y)
                .attr('x2', (d) -> d.target.x)
                .attr('y2', (d) -> d.target.y)
                
        ))
        
    ### DELETION - pressing DEL deletes the selection ###
    ### CREATION - pressing N creates a new node ###
    d3.select(window)
        .on('keydown', () ->
            if d3.event.keyCode is 46 # DEL
                if global.selection?
                    graph.remove global.selection
                    global.selection = null
                    update()
            else if d3.event.keyCode is 78 # N
                graph.add_node()
                update()
        )
        
    update()
)

update = () ->
    ### update the layout ###
    global.force
        .nodes(graph.nodes)
        .links(graph.links)
        .start()
        
    ### create nodes and links ###
    ### (links are drawn first to make them appear under the nodes) ###
    ### also, overwrite the selections with their databound version ###
    links = global.vis.selectAll('.link')
        .data(graph.links, (d) -> "#{d.source.id}->#{d.target.id}")
        
    links
      .enter().append('line')
        .attr('class', 'link')
        .on('click', ((d) ->
            ### SELECTION ###
            global.selection = d
            d3.selectAll('.link').classed('selected', (d2) -> d2 is d)
            d3.selectAll('.node').classed('selected', false)
        ))
        
    links
      .exit().remove()
      
    ### also define a drag behavior to drag nodes ###
    ### dragged nodes become fixed ###
    nodes = global.vis.selectAll('.node')
        .data(graph.nodes, (d) -> d.id)
        
    new_nodes = nodes
      .enter().append('g')
        .attr('class', 'node')
        .call(global.force.drag().on('dragstart', (d) -> d.fixed = true)) # DRAG
        .on('click', ((d) ->
            ### SELECTION ###
            global.selection = d
            d3.selectAll('.node').classed('selected', (d2) -> d2 is d)
            d3.selectAll('.link').classed('selected', false)
        ))
        
    new_nodes.append('circle')
        .attr('r', 18)
        .attr('stroke', (d) -> global.colorify(d.type))
        .attr('fill', (d) -> d3.hcl(global.colorify(d.type)).brighter(3))
        
    ### draw the label ###
    new_nodes.append('text')
        .text((d) -> d.id)
        .attr('dy', '0.35em')
        .attr('fill', (d) -> global.colorify(d.type))
        
    nodes
      .exit().remove()
      

index.css

.node > circle {
  stroke-width: 4px;
}

.node > text {
  pointer-events: none;
  font-family: sans-serif;
  font-weight: bold;
  text-anchor: middle;
}

.link {
  stroke-width: 6px;
  stroke: gray;
  opacity: 0.6;
}

.selected > circle {
  stroke-width: 8px;
}

.selected.link {
  stroke-width: 14px;
}

.overlay {
  fill: transparent;
}

index.sass

.node > circle
    stroke-width: 4px
        
.node > text
    pointer-events: none
    font-family: sans-serif
    font-weight: bold
    text-anchor: middle
    
.link
    stroke-width: 6px
    stroke: gray
    opacity: 0.6
    
// selection
.selected > circle
    stroke-width: 8px
    
.selected.link
    stroke-width: 14px
    
// zoom and pan
.overlay
    fill: transparent