block by nitaku fdb442d5ad3a04f62b9b8ecd5b893ec0

Path network editor

Full Screen

-

index.js

// Generated by CoffeeScript 1.10.0
(function() {
  new AppView({
    parent: 'body'
  });

}).call(this);

index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>Path Network Editor</title>
  <link type="text/css" href="index.css" rel="stylesheet"/>
  <script src="https://d3js.org/d3.v4.min.js"></script>
  <script src="https://d3js.org/d3-selection-multi.v0.4.min.js"></script>
  <script src="esv.js"></script>
  
  <script src="Graph.js"></script>
  <script src="CurrentTool.js"></script>
  
  <script src="Keyboard.js"></script>
  
  <script src="AppView.js"></script>
  <link type="text/css" href="AppView.css" rel="stylesheet"/>
  
  <script src="Toolbar.js"></script>
  <link type="text/css" href="Toolbar.css" rel="stylesheet"/>
  
  <script src="NetworkView.js"></script>
  <link type="text/css" href="NetworkView.css" rel="stylesheet"/>
  
  <script src="MultilineTool.js"></script>
  <script src="ArrowTool.js"></script>
</head>
<body>
  <script src="index.js"></script>
</body>
</html>

AppView.coffee

global class AppView extends View
  constructor: (conf) ->
    super(conf)
    
    graph = new Graph
    
    keyboard = new Keyboard
    
    keyboard.on 'delete_down', () ->
      graph.delete_selected()
    
    nv = new NetworkView
      graph: graph
      parent: this
      
    tool = new CurrentTool
      graph: graph
      view: nv
    
    new Toolbar
      current_tool: tool
      parent: this
      prepend: true
      
    # default tool is arrow
    tool.set 'Arrow'
      
    a = graph.new_node(100,100)
    b = graph.new_node(100,200)
    graph.new_link(a,b)
    c = graph.new_node(200,200)
    graph.new_link(b,c)
    d = graph.new_node(200,100)
    graph.new_link(c,d)
    graph.new_link(d,a)

AppView.css

.AppView {
  display: flex;
  flex-direction: column;
  width: 100%;
  height: 100%;
}
.AppView .NetworkView {
  flex-grow: 1;
  height: 0;
}

AppView.js

// Generated by CoffeeScript 1.10.0
(function() {
  var AppView,
    extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
    hasProp = {}.hasOwnProperty;

  global(AppView = (function(superClass) {
    extend(AppView, superClass);

    function AppView(conf) {
      var a, b, c, d, graph, keyboard, nv, tool;
      AppView.__super__.constructor.call(this, conf);
      graph = new Graph;
      keyboard = new Keyboard;
      keyboard.on('delete_down', function() {
        return graph.delete_selected();
      });
      nv = new NetworkView({
        graph: graph,
        parent: this
      });
      tool = new CurrentTool({
        graph: graph,
        view: nv
      });
      new Toolbar({
        current_tool: tool,
        parent: this,
        prepend: true
      });
      tool.set('Arrow');
      a = graph.new_node(100, 100);
      b = graph.new_node(100, 200);
      graph.new_link(a, b);
      c = graph.new_node(200, 200);
      graph.new_link(b, c);
      d = graph.new_node(200, 100);
      graph.new_link(c, d);
      graph.new_link(d, a);
    }

    return AppView;

  })(View));

}).call(this);

ArrowTool.coffee

global class ArrowTool
  constructor: (conf) ->
    @graph = conf.graph
    @view = conf.view
    
    @listeners = []
    
    @listeners.push @view.on 'action_on_node', @select
    @listeners.push @view.on 'action_on_link', @select
    
  destroy: () ->
    # unbind all listeners
    @listeners.forEach (l) =>
      @view.on l, null
      
  select: (d) =>
    @graph.select d

ArrowTool.js

// Generated by CoffeeScript 1.10.0
(function() {
  var ArrowTool,
    bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };

  global(ArrowTool = (function() {
    function ArrowTool(conf) {
      this.select = bind(this.select, this);
      this.graph = conf.graph;
      this.view = conf.view;
      this.listeners = [];
      this.listeners.push(this.view.on('action_on_node', this.select));
      this.listeners.push(this.view.on('action_on_link', this.select));
    }

    ArrowTool.prototype.destroy = function() {
      return this.listeners.forEach((function(_this) {
        return function(l) {
          return _this.view.on(l, null);
        };
      })(this));
    };

    ArrowTool.prototype.select = function(d) {
      return this.graph.select(d);
    };

    return ArrowTool;

  })());

}).call(this);

CurrentTool.coffee

global class CurrentTool extends EventSource
  constructor: (conf) ->
    super
      events: ['change']
      
    @graph = conf.graph
    @view = conf.view
    
  set: (name) =>
    if name isnt @tool_name
      @tool_name = name
      
      # destroy old tool, if any
      if @tool?
        @tool.destroy()
        
      @tool = new window[@tool_name+'Tool']
        graph: @graph
        view: @view
      
      @trigger 'change'
      
    return this
  
  get: () =>
    return @tool_name

CurrentTool.js

// Generated by CoffeeScript 1.10.0
(function() {
  var CurrentTool,
    bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; },
    extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
    hasProp = {}.hasOwnProperty;

  global(CurrentTool = (function(superClass) {
    extend(CurrentTool, superClass);

    function CurrentTool(conf) {
      this.get = bind(this.get, this);
      this.set = bind(this.set, this);
      CurrentTool.__super__.constructor.call(this, {
        events: ['change']
      });
      this.graph = conf.graph;
      this.view = conf.view;
    }

    CurrentTool.prototype.set = function(name) {
      if (name !== this.tool_name) {
        this.tool_name = name;
        if (this.tool != null) {
          this.tool.destroy();
        }
        this.tool = new window[this.tool_name + 'Tool']({
          graph: this.graph,
          view: this.view
        });
        this.trigger('change');
      }
      return this;
    };

    CurrentTool.prototype.get = function() {
      return this.tool_name;
    };

    return CurrentTool;

  })(EventSource));

}).call(this);

Graph.coffee

global class Graph extends EventSource
  constructor: (conf) ->
    super
      events: ['change']
      
    @nodes = {}
    @links = {}
    
    @next_node_id = 0
    @next_link_id = 0
    
    @selection = null
    
  get_nodes: () ->
    return Object.keys(@nodes).map (k) => @nodes[k]
  
  get_links: () ->
    return Object.keys(@links).map (k) => @links[k]
    
  new_node: (x,y) ->
    n = {
      type: 'node',
      id: @next_node_id,
      x: x,
      y: y,
      out_links: {},
      in_links: {}
    }
    @nodes[n.id] = n
    @next_node_id += 1
    
    @trigger 'change'
    return n
  
  new_link: (source, target) ->
    l = {
      type: 'link',
      id: @next_link_id,
      source: source,
      target: target,
      d: Math.sqrt( Math.pow(target.x-source.x, 2) + Math.pow(target.y-source.y, 2) )
    }
    @links[l.id] = l
    @next_link_id += 1
    
    # reverse pointers from nodes
    l.source.out_links[l.id] = l
    l.target.in_links[l.id] = l
    
    @trigger 'change'
    return l
  
  select: (d) ->
    if @selection?
      delete @selection.selected
      @selection = null
      
    d.selected = true
    @selection = d
    
    @trigger 'change'
    return this
  
  delete_node_id: (id, silent) ->
    d = @nodes[id]
    @delete_node d
    
    if not silent
      @trigger 'change'
    
  delete_node: (d, silent) ->
    Object.keys(d.out_links).forEach (k) => @delete_link_id(k, true)
    Object.keys(d.in_links).forEach (k) => @delete_link_id(k, true)
    delete @nodes[d.id]
    
    if not silent
      @trigger 'change'
    
  delete_link_id: (id, silent) ->
    l = @links[id]
    @delete_link l
    
    if not silent
      @trigger 'change'
    
  delete_link: (l, silent) ->
    delete @links[l.id]
    delete l.source.out_links[l.id]
    delete l.target.in_links[l.id]
    
    if not silent
      @trigger 'change'
  
  delete_selected: () ->
    if not @selection?
      return false
    
    if @selection.type is 'node' and @selection.id of @nodes
      @delete_node_id @selection.id, true
    else if @selection.type is 'link' and @selection.id of @links
      @delete_link_id @selection.id, true
    
    @trigger 'change'
    return true
  

Graph.js

// Generated by CoffeeScript 1.10.0
(function() {
  var Graph,
    extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
    hasProp = {}.hasOwnProperty;

  global(Graph = (function(superClass) {
    extend(Graph, superClass);

    function Graph(conf) {
      Graph.__super__.constructor.call(this, {
        events: ['change']
      });
      this.nodes = {};
      this.links = {};
      this.next_node_id = 0;
      this.next_link_id = 0;
      this.selection = null;
    }

    Graph.prototype.get_nodes = function() {
      return Object.keys(this.nodes).map((function(_this) {
        return function(k) {
          return _this.nodes[k];
        };
      })(this));
    };

    Graph.prototype.get_links = function() {
      return Object.keys(this.links).map((function(_this) {
        return function(k) {
          return _this.links[k];
        };
      })(this));
    };

    Graph.prototype.new_node = function(x, y) {
      var n;
      n = {
        type: 'node',
        id: this.next_node_id,
        x: x,
        y: y,
        out_links: {},
        in_links: {}
      };
      this.nodes[n.id] = n;
      this.next_node_id += 1;
      this.trigger('change');
      return n;
    };

    Graph.prototype.new_link = function(source, target) {
      var l;
      l = {
        type: 'link',
        id: this.next_link_id,
        source: source,
        target: target,
        d: Math.sqrt(Math.pow(target.x - source.x, 2) + Math.pow(target.y - source.y, 2))
      };
      this.links[l.id] = l;
      this.next_link_id += 1;
      l.source.out_links[l.id] = l;
      l.target.in_links[l.id] = l;
      this.trigger('change');
      return l;
    };

    Graph.prototype.select = function(d) {
      if (this.selection != null) {
        delete this.selection.selected;
        this.selection = null;
      }
      d.selected = true;
      this.selection = d;
      this.trigger('change');
      return this;
    };

    Graph.prototype.delete_node_id = function(id, silent) {
      var d;
      d = this.nodes[id];
      this.delete_node(d);
      if (!silent) {
        return this.trigger('change');
      }
    };

    Graph.prototype.delete_node = function(d, silent) {
      Object.keys(d.out_links).forEach((function(_this) {
        return function(k) {
          return _this.delete_link_id(k, true);
        };
      })(this));
      Object.keys(d.in_links).forEach((function(_this) {
        return function(k) {
          return _this.delete_link_id(k, true);
        };
      })(this));
      delete this.nodes[d.id];
      if (!silent) {
        return this.trigger('change');
      }
    };

    Graph.prototype.delete_link_id = function(id, silent) {
      var l;
      l = this.links[id];
      this.delete_link(l);
      if (!silent) {
        return this.trigger('change');
      }
    };

    Graph.prototype.delete_link = function(l, silent) {
      delete this.links[l.id];
      delete l.source.out_links[l.id];
      delete l.target.in_links[l.id];
      if (!silent) {
        return this.trigger('change');
      }
    };

    Graph.prototype.delete_selected = function() {
      if (this.selection == null) {
        return false;
      }
      if (this.selection.type === 'node' && this.selection.id in this.nodes) {
        this.delete_node_id(this.selection.id, true);
      } else if (this.selection.type === 'link' && this.selection.id in this.links) {
        this.delete_link_id(this.selection.id, true);
      }
      this.trigger('change');
      return true;
    };

    return Graph;

  })(EventSource));

}).call(this);

Keyboard.coffee

# event-emitting keyboard with no repeating keypresses
global class Keyboard extends EventSource
  constructor: () ->
    # each key has a 'down' and an 'up' event
    keys = ['delete','ctrl','shift']
    events = []
    keys.forEach (d) ->
      events.push "#{d}_down"
      events.push "#{d}_up"
    super
      events: events
      
    pressed_keys = {}

    document.onkeydown = (e) =>
      # console.log e.keyCode
      key = @keyname(e.keyCode)
      
      if key? and key not of pressed_keys
        @trigger key + '_down'
        pressed_keys[key] = true
          
    document.onkeyup = (e) =>
      # console.log e.keyCode
      key = @keyname(e.keyCode)
      
      if key? and key of pressed_keys
        @trigger key + '_up'
        delete pressed_keys[key]
        
  keyname: (keyCode) ->
    switch keyCode
      when 46 then 'delete'
      when 17 then 'ctrl'
      when 16 then 'shift'
      else null
      

Keyboard.js

// Generated by CoffeeScript 1.10.0
(function() {
  var Keyboard,
    extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
    hasProp = {}.hasOwnProperty;

  global(Keyboard = (function(superClass) {
    extend(Keyboard, superClass);

    function Keyboard() {
      var events, keys, pressed_keys;
      keys = ['delete', 'ctrl', 'shift'];
      events = [];
      keys.forEach(function(d) {
        events.push(d + "_down");
        return events.push(d + "_up");
      });
      Keyboard.__super__.constructor.call(this, {
        events: events
      });
      pressed_keys = {};
      document.onkeydown = (function(_this) {
        return function(e) {
          var key;
          key = _this.keyname(e.keyCode);
          if ((key != null) && !(key in pressed_keys)) {
            _this.trigger(key + '_down');
            return pressed_keys[key] = true;
          }
        };
      })(this);
      document.onkeyup = (function(_this) {
        return function(e) {
          var key;
          key = _this.keyname(e.keyCode);
          if ((key != null) && key in pressed_keys) {
            _this.trigger(key + '_up');
            return delete pressed_keys[key];
          }
        };
      })(this);
    }

    Keyboard.prototype.keyname = function(keyCode) {
      switch (keyCode) {
        case 46:
          return 'delete';
        case 17:
          return 'ctrl';
        case 16:
          return 'shift';
        default:
          return null;
      }
    };

    return Keyboard;

  })(EventSource));

}).call(this);

MultilineTool.coffee

# FIXME if the source node of a preview line is deleted, this goes into an inconsistent state
# maybe its better to have a boolean for the drawing state and methods to activate/deactivate it
# we could then register a listener on a change event of the graph to run a check for the active node and deactivate the drawing mode if it is not found
global class MultilineTool
  constructor: (conf) ->
    @graph = conf.graph
    @view = conf.view
    
    @last_point = null
    
    @listeners = []
    
    @listeners.push @view.on 'hover_on_point', @hover_on_point
    @listeners.push @view.on 'action_on_point', @new_point
    @listeners.push @view.on 'action_on_node', @touch_node
    @listeners.push @view.on 'action_on_link', @touch_link
    
  destroy: () ->
    # unbind all listeners
    @listeners.forEach (l) =>
      @view.on l, null
      
    # clean the preview line
    if @preview_line?
      @preview_line.remove()
    
  new_point: (x, y) =>
    nn = @graph.new_node x, y
    
    # continue the path, if any
    if @last_point?
      @graph.new_link @last_point, nn
      
    @last_point = nn
    
    return nn
    
  touch_node: (n, x, y) =>
    # touching an already existing node closes the path onto it
    if @last_point?
      if @last_point isnt n
        # avoid linking a node to itself
        @graph.new_link @last_point, n
      @last_point = null
    else
      @last_point = n
      
  touch_link: (l, x, y) =>
    # create a new point on the link using the closest point to the click
    A = {x: l.source.x, y: l.source.y}
    B = {x: l.target.x, y: l.target.y}
    P = {x: x, y: y}
    CP = get_closest_point(A, B, P)
    
    must_close = @last_point?
    
    nn = @new_point CP.x, CP.y
    
    # close the path, if any
    if must_close
      @last_point = null
    
    # split the link
    @graph.delete_link l
    @graph.new_link l.source, nn
    @graph.new_link nn, l.target
    
  hover_on_point: (x, y) =>
    # FIXME this can be implemented in a better way
    if @last_point?
      if not @preview_line?
        @preview_line = @view.tools_overlay.append 'line'
      @preview_line
        .attrs
          class: 'preview_line'
          x1: (d) => @last_point.x
          y1: (d) => @last_point.y
          x2: x
          y2: y
    else
      if @preview_line?
        @preview_line.remove()
        @preview_line = null
        
# private function to find the closest point on a segment
get_closest_point = (A, B, P) ->
  a_to_p = [P.x - A.x, P.y - A.y]     # vector A->P
  a_to_b = [B.x - A.x, B.y - A.y]     # vector A->B

  atb2 = a_to_b[0]*a_to_b[0] + a_to_b[1]*a_to_b[1] # squared magnitude of a_to_b

  atp_dot_atb = a_to_p[0]*a_to_b[0] + a_to_p[1]*a_to_b[1] # The dot product of a_to_p and a_to_b

  t = atp_dot_atb / atb2              # The normalized "distance" from a to your closest point
  
  C = {x: A.x + a_to_b[0]*t, y: A.y + a_to_b[1]*t} # Add the distance to A, moving towards B
  
  return C

MultilineTool.js

// Generated by CoffeeScript 1.10.0
(function() {
  var MultilineTool, get_closest_point,
    bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };

  global(MultilineTool = (function() {
    function MultilineTool(conf) {
      this.hover_on_point = bind(this.hover_on_point, this);
      this.touch_link = bind(this.touch_link, this);
      this.touch_node = bind(this.touch_node, this);
      this.new_point = bind(this.new_point, this);
      this.graph = conf.graph;
      this.view = conf.view;
      this.last_point = null;
      this.listeners = [];
      this.listeners.push(this.view.on('hover_on_point', this.hover_on_point));
      this.listeners.push(this.view.on('action_on_point', this.new_point));
      this.listeners.push(this.view.on('action_on_node', this.touch_node));
      this.listeners.push(this.view.on('action_on_link', this.touch_link));
    }

    MultilineTool.prototype.destroy = function() {
      this.listeners.forEach((function(_this) {
        return function(l) {
          return _this.view.on(l, null);
        };
      })(this));
      if (this.preview_line != null) {
        return this.preview_line.remove();
      }
    };

    MultilineTool.prototype.new_point = function(x, y) {
      var nn;
      nn = this.graph.new_node(x, y);
      if (this.last_point != null) {
        this.graph.new_link(this.last_point, nn);
      }
      this.last_point = nn;
      return nn;
    };

    MultilineTool.prototype.touch_node = function(n, x, y) {
      if (this.last_point != null) {
        if (this.last_point !== n) {
          this.graph.new_link(this.last_point, n);
        }
        return this.last_point = null;
      } else {
        return this.last_point = n;
      }
    };

    MultilineTool.prototype.touch_link = function(l, x, y) {
      var A, B, CP, P, must_close, nn;
      A = {
        x: l.source.x,
        y: l.source.y
      };
      B = {
        x: l.target.x,
        y: l.target.y
      };
      P = {
        x: x,
        y: y
      };
      CP = get_closest_point(A, B, P);
      must_close = this.last_point != null;
      nn = this.new_point(CP.x, CP.y);
      if (must_close) {
        this.last_point = null;
      }
      this.graph.delete_link(l);
      this.graph.new_link(l.source, nn);
      return this.graph.new_link(nn, l.target);
    };

    MultilineTool.prototype.hover_on_point = function(x, y) {
      if (this.last_point != null) {
        if (this.preview_line == null) {
          this.preview_line = this.view.tools_overlay.append('line');
        }
        return this.preview_line.attrs({
          "class": 'preview_line',
          x1: (function(_this) {
            return function(d) {
              return _this.last_point.x;
            };
          })(this),
          y1: (function(_this) {
            return function(d) {
              return _this.last_point.y;
            };
          })(this),
          x2: x,
          y2: y
        });
      } else {
        if (this.preview_line != null) {
          this.preview_line.remove();
          return this.preview_line = null;
        }
      }
    };

    return MultilineTool;

  })());

  get_closest_point = function(A, B, P) {
    var C, a_to_b, a_to_p, atb2, atp_dot_atb, t;
    a_to_p = [P.x - A.x, P.y - A.y];
    a_to_b = [B.x - A.x, B.y - A.y];
    atb2 = a_to_b[0] * a_to_b[0] + a_to_b[1] * a_to_b[1];
    atp_dot_atb = a_to_p[0] * a_to_b[0] + a_to_p[1] * a_to_b[1];
    t = atp_dot_atb / atb2;
    C = {
      x: A.x + a_to_b[0] * t,
      y: A.y + a_to_b[1] * t
    };
    return C;
  };

}).call(this);

NetworkView.coffee

global class NetworkView extends View
  constructor: (conf) ->
    conf = {} if not conf?
    conf.events = [
      'action_on_point',
      'action_on_node',
      'action_on_link',
      'hover_on_point'
    ]
    super(conf)
    
    @graph = conf.graph
    
    svg = @d3el.append 'svg'
    
    # append a group for zoomable content
    @zoomable_layer = svg.append 'g'

    zoom = d3.zoom()
      .scaleExtent([-Infinity,Infinity])
      .on 'zoom', () =>
        @current_zoom_t = d3.event.transform
        
        @zoomable_layer
          .attrs
            transform: @current_zoom_t
            
        @nodes_layer.selectAll '.node > *'
          .attrs
            transform: "scale(#{1/@current_zoom_t.k})"
            
        @links_layer.selectAll '.link .label > *'
          .attrs
            transform: "scale(#{1/@current_zoom_t.k})"

    svg.call zoom
    @current_zoom_t = d3.zoomTransform svg
    
    @links_layer = @zoomable_layer.append 'g'
    @nodes_layer = @zoomable_layer.append 'g'
    
    @tools_overlay = @zoomable_layer.append 'g'
    
    @graph.on 'change', @redraw
    
    svg.on 'click', () =>
      [x, y] = d3.mouse(@zoomable_layer.node())
      @trigger 'action_on_point', x, y
    svg.on 'mousemove', () =>
      [x, y] = d3.mouse(@zoomable_layer.node())
      @trigger 'hover_on_point', x, y
    
  redraw: () =>
    # nodes
    nodes = @nodes_layer.selectAll '.node'
      .data @graph.get_nodes(), (d) -> d.id
      
    enter_nodes = nodes.enter().append 'g'
      .attrs
        class: 'node'
      .on 'click', (d) =>
        [x, y] = d3.mouse(@zoomable_layer.node())
        @trigger 'action_on_node', d, x, y
        d3.event.stopPropagation()
        
    enter_nodes.append 'circle'
    enter_nodes.append 'text'
    
    all_nodes = enter_nodes.merge(nodes)
    
    all_nodes.select 'circle'
      .attrs
        r: (d) -> 4
        transform: "scale(#{1/@current_zoom_t.k})"
        
    all_nodes.select 'text'
      .text (d) -> "(#{d3.format('.2f')(d.x)}, #{d3.format('.2f')(d.y)})"
      .attrs
        y: 16
        transform: "scale(#{1/@current_zoom_t.k})"
        
    all_nodes
      .classed 'selected', (d) -> d.selected
      .attrs
        transform: (d) -> "translate(#{d.x},#{d.y})"
        
    nodes.exit()
      .remove()
      
    # links
    links = @links_layer.selectAll '.link'
      .data @graph.get_links(), (d) -> d.id
      
    enter_links = links.enter().append 'g'
      .attrs
        class: 'link'
      .on 'click', (d) =>
        [x, y] = d3.mouse(@zoomable_layer.node())
        @trigger 'action_on_link', d, x, y
        d3.event.stopPropagation()
          
    enter_links.append 'line'
      .attrs
        class: 'background'
          
    enter_links.append 'line'
      .attrs
        class: 'foreground'
          
    enter_labels = enter_links.append 'g'
      .attrs
        class: 'label'
        
    enter_labels.append 'text'
    
    all_links = enter_links.merge(links)
    
    all_links.selectAll 'line'
      .attrs
        x1: (d) -> d.source.x
        y1: (d) -> d.source.y
        x2: (d) -> d.target.x
        y2: (d) -> d.target.y
        
    all_links.selectAll '.label'
      .attrs
        transform: (d) -> "translate(#{(d.source.x+d.target.x)/2},#{(d.source.y+d.target.y)/2})"
        
    all_links.selectAll '.label text'
      .text (d) -> d3.format('.2f')(d.d)
      .attrs
        dy: '0.35em'
        transform: "scale(#{1/@current_zoom_t.k})"
        
    all_links
      .classed 'selected', (d) -> d.selected
        
    links.exit()
      .remove()
      

NetworkView.css

.NetworkView {
  display: flex;
  flex-direction: column;
}
.NetworkView svg {
  background: white;
  flex-grow: 1;
  height: 0;
}

.NetworkView .node {
  /* help with node selection */
  stroke-width: 6;
  stroke: transparent;
}
.NetworkView .node text, .NetworkView .link text {
  font-family: sans-serif;
  font-size: 10px;
  text-anchor: middle;
  stroke: none;
  display: none;
  text-shadow: -1px -1px white, -1px 1px white, 1px 1px white, 1px -1px white, -1px 0 white, 0 1px white, 1px 0 white, 0 -1px white;
  pointer-events: none;
}
.NetworkView .selected.node text, .NetworkView .selected.link text {
  display: inline;
}

.NetworkView .link *, .NetworkView .preview_line {
  vector-effect: non-scaling-stroke;
}

.NetworkView .link .foreground {
  stroke: black;
  opacity: 0.3;
  stroke-width: 2;
  fill: none;
}

.NetworkView .link .background {
  /* help with node selection */
  stroke: transparent;
  stroke-width: 6;
  fill: none;
}

.NetworkView .preview_line {
  stroke: red;
  opacity: 0.4;
  stroke-width: 2;
  fill: none;
  pointer-events: none;
}

.NetworkView .selected.node {
  fill: blue;
  stroke: rgba(0,0,255,0.1);
}
.NetworkView .selected.link .foreground {
  stroke: blue;
}
.NetworkView .selected.link .background {
  stroke: rgba(0,0,255,0.1);
}
.NetworkView .selected.link text {
  fill: blue;
}

NetworkView.js

// Generated by CoffeeScript 1.10.0
(function() {
  var NetworkView,
    bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; },
    extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
    hasProp = {}.hasOwnProperty;

  global(NetworkView = (function(superClass) {
    extend(NetworkView, superClass);

    function NetworkView(conf) {
      this.redraw = bind(this.redraw, this);
      var svg, zoom;
      if (conf == null) {
        conf = {};
      }
      conf.events = ['action_on_point', 'action_on_node', 'action_on_link', 'hover_on_point'];
      NetworkView.__super__.constructor.call(this, conf);
      this.graph = conf.graph;
      svg = this.d3el.append('svg');
      this.zoomable_layer = svg.append('g');
      zoom = d3.zoom().scaleExtent([-Infinity, Infinity]).on('zoom', (function(_this) {
        return function() {
          _this.current_zoom_t = d3.event.transform;
          _this.zoomable_layer.attrs({
            transform: _this.current_zoom_t
          });
          _this.nodes_layer.selectAll('.node > *').attrs({
            transform: "scale(" + (1 / _this.current_zoom_t.k) + ")"
          });
          return _this.links_layer.selectAll('.link .label > *').attrs({
            transform: "scale(" + (1 / _this.current_zoom_t.k) + ")"
          });
        };
      })(this));
      svg.call(zoom);
      this.current_zoom_t = d3.zoomTransform(svg);
      this.links_layer = this.zoomable_layer.append('g');
      this.nodes_layer = this.zoomable_layer.append('g');
      this.tools_overlay = this.zoomable_layer.append('g');
      this.graph.on('change', this.redraw);
      svg.on('click', (function(_this) {
        return function() {
          var ref, x, y;
          ref = d3.mouse(_this.zoomable_layer.node()), x = ref[0], y = ref[1];
          return _this.trigger('action_on_point', x, y);
        };
      })(this));
      svg.on('mousemove', (function(_this) {
        return function() {
          var ref, x, y;
          ref = d3.mouse(_this.zoomable_layer.node()), x = ref[0], y = ref[1];
          return _this.trigger('hover_on_point', x, y);
        };
      })(this));
    }

    NetworkView.prototype.redraw = function() {
      var all_links, all_nodes, enter_labels, enter_links, enter_nodes, links, nodes;
      nodes = this.nodes_layer.selectAll('.node').data(this.graph.get_nodes(), function(d) {
        return d.id;
      });
      enter_nodes = nodes.enter().append('g').attrs({
        "class": 'node'
      }).on('click', (function(_this) {
        return function(d) {
          var ref, x, y;
          ref = d3.mouse(_this.zoomable_layer.node()), x = ref[0], y = ref[1];
          _this.trigger('action_on_node', d, x, y);
          return d3.event.stopPropagation();
        };
      })(this));
      enter_nodes.append('circle');
      enter_nodes.append('text');
      all_nodes = enter_nodes.merge(nodes);
      all_nodes.select('circle').attrs({
        r: function(d) {
          return 4;
        },
        transform: "scale(" + (1 / this.current_zoom_t.k) + ")"
      });
      all_nodes.select('text').text(function(d) {
        return "(" + (d3.format('.2f')(d.x)) + ", " + (d3.format('.2f')(d.y)) + ")";
      }).attrs({
        y: 16,
        transform: "scale(" + (1 / this.current_zoom_t.k) + ")"
      });
      all_nodes.classed('selected', function(d) {
        return d.selected;
      }).attrs({
        transform: function(d) {
          return "translate(" + d.x + "," + d.y + ")";
        }
      });
      nodes.exit().remove();
      links = this.links_layer.selectAll('.link').data(this.graph.get_links(), function(d) {
        return d.id;
      });
      enter_links = links.enter().append('g').attrs({
        "class": 'link'
      }).on('click', (function(_this) {
        return function(d) {
          var ref, x, y;
          ref = d3.mouse(_this.zoomable_layer.node()), x = ref[0], y = ref[1];
          _this.trigger('action_on_link', d, x, y);
          return d3.event.stopPropagation();
        };
      })(this));
      enter_links.append('line').attrs({
        "class": 'background'
      });
      enter_links.append('line').attrs({
        "class": 'foreground'
      });
      enter_labels = enter_links.append('g').attrs({
        "class": 'label'
      });
      enter_labels.append('text');
      all_links = enter_links.merge(links);
      all_links.selectAll('line').attrs({
        x1: function(d) {
          return d.source.x;
        },
        y1: function(d) {
          return d.source.y;
        },
        x2: function(d) {
          return d.target.x;
        },
        y2: function(d) {
          return d.target.y;
        }
      });
      all_links.selectAll('.label').attrs({
        transform: function(d) {
          return "translate(" + ((d.source.x + d.target.x) / 2) + "," + ((d.source.y + d.target.y) / 2) + ")";
        }
      });
      all_links.selectAll('.label text').text(function(d) {
        return d3.format('.2f')(d.d);
      }).attrs({
        dy: '0.35em',
        transform: "scale(" + (1 / this.current_zoom_t.k) + ")"
      });
      all_links.classed('selected', function(d) {
        return d.selected;
      });
      return links.exit().remove();
    };

    return NetworkView;

  })(View));

}).call(this);

Toolbar.coffee

global class Toolbar extends View
  constructor: (conf) ->
    super(conf)
    
    @current_tool = conf.current_tool
    
    @tool_btns = {}
    
    @tool_btns['Arrow'] = @d3el.append 'button'
      .text 'Arrow tool'
      .on 'click', () =>
        @current_tool.set 'Arrow'
      
    @tool_btns['Multiline'] = @d3el.append 'button'
      .text 'Multiline tool'
      .on 'click', () =>
        @current_tool.set 'Multiline'
        
    @current_tool.on 'change', @redraw
    
  redraw: () =>
    Object.keys(@tool_btns).forEach (tool_name) =>
      @tool_btns[tool_name].classed 'selected', tool_name is @current_tool.get()
      

Toolbar.css

.Toolbar {
  padding: 4px;
  background: #EEE;
  border-bottom: 1px solid gray;
}
.Toolbar > *:not(:first-child) {
  margin-left: 4px;
}
.Toolbar button {
  border: 1px solid gray;
  padding: 4px;
  background: #CCC;
}
.Toolbar button.selected {
  background: #ffb16f;
}

Toolbar.js

// Generated by CoffeeScript 1.10.0
(function() {
  var Toolbar,
    bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; },
    extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
    hasProp = {}.hasOwnProperty;

  global(Toolbar = (function(superClass) {
    extend(Toolbar, superClass);

    function Toolbar(conf) {
      this.redraw = bind(this.redraw, this);
      Toolbar.__super__.constructor.call(this, conf);
      this.current_tool = conf.current_tool;
      this.tool_btns = {};
      this.tool_btns['Arrow'] = this.d3el.append('button').text('Arrow tool').on('click', (function(_this) {
        return function() {
          return _this.current_tool.set('Arrow');
        };
      })(this));
      this.tool_btns['Multiline'] = this.d3el.append('button').text('Multiline tool').on('click', (function(_this) {
        return function() {
          return _this.current_tool.set('Multiline');
        };
      })(this));
      this.current_tool.on('change', this.redraw);
    }

    Toolbar.prototype.redraw = function() {
      return Object.keys(this.tool_btns).forEach((function(_this) {
        return function(tool_name) {
          return _this.tool_btns[tool_name].classed('selected', tool_name === _this.current_tool.get());
        };
      })(this));
    };

    return Toolbar;

  })(View));

}).call(this);

esv.coffee

# make a function global
window.global = (f) -> window[f.name] = f


# object able to trigger events
global class EventSource
  # instantiate a new EventSource with the given event types
  constructor: (config) ->
    @_dispatcher = d3.dispatch(config.events...)
    
    # auto incrementing ids are used to avoid overwriting listeners
    @next_id = 0

  # register a callback on the given event type, with an optional namespace
  # syntax: event_type.namespace
  # null as callback unbinds the listener
  on: (event_type_ns, callback) ->
    splitted_event_type_ns = event_type_ns.split('.')
    event_type = splitted_event_type_ns[0]
    if splitted_event_type_ns.length > 1
      namespace = splitted_event_type_ns[1]
    else
      # use an automatic ID
      namespace = @next_id
      @next_id += 1
      
    event_type_full = event_type + '.' + namespace
    @_dispatcher.on event_type_full, callback
    
    # callers who want to unbind or overwrite the created listener have to store the full event type
    return event_type_full

  # trigger an event of the given type and pass the given args
  trigger: (event_type, args...) ->
    @_dispatcher.apply(event_type, this, args)
    return this

# GUI component
global class View extends EventSource
  constructor: (conf) ->
    super(conf)
    
    # div is default
    if not conf.tag?
      conf.tag = 'div'
      
    # both DOM and d3 element references
    @el = document.createElement(conf.tag)
    @d3el = d3.select(@el)
    
    # set a CSS class equal to the class name of the view
    # WARNING this is not supported on IE
    # if needed, this polyfill can be used:
    # https://github.com/JamesMGreene/Function.name
    @d3el.classed this.constructor.name, true
    
    # automatically append this view to the given parent, if any
    if conf.parent?
      @append_to(conf.parent, conf.prepend)
      
  # append this view to a parent view or node
  append_to: (parent, prepend) ->
    if parent.el? # Backbone-style view
      p_el = parent.el
    else # selector or d3 selection or dom node
      p_el = d3.select(parent).node()
      
    if prepend
      p_el.insertBefore @el, p_el.firstChild
    else
      p_el.appendChild @el

  # recompute width and height
  recompute_size: () ->
    @width = @el.getBoundingClientRect().width
    @height = @el.getBoundingClientRect().height
    

esv.js

// Generated by CoffeeScript 1.10.0
(function() {
  var EventSource, View,
    slice = [].slice,
    extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
    hasProp = {}.hasOwnProperty;

  window.global = function(f) {
    return window[f.name] = f;
  };

  global(EventSource = (function() {
    function EventSource(config) {
      this._dispatcher = d3.dispatch.apply(d3, config.events);
      this.next_id = 0;
    }

    EventSource.prototype.on = function(event_type_ns, callback) {
      var event_type, event_type_full, namespace, splitted_event_type_ns;
      splitted_event_type_ns = event_type_ns.split('.');
      event_type = splitted_event_type_ns[0];
      if (splitted_event_type_ns.length > 1) {
        namespace = splitted_event_type_ns[1];
      } else {
        namespace = this.next_id;
        this.next_id += 1;
      }
      event_type_full = event_type + '.' + namespace;
      this._dispatcher.on(event_type_full, callback);
      return event_type_full;
    };

    EventSource.prototype.trigger = function() {
      var args, event_type;
      event_type = arguments[0], args = 2 <= arguments.length ? slice.call(arguments, 1) : [];
      this._dispatcher.apply(event_type, this, args);
      return this;
    };

    return EventSource;

  })());

  global(View = (function(superClass) {
    extend(View, superClass);

    function View(conf) {
      View.__super__.constructor.call(this, conf);
      if (conf.tag == null) {
        conf.tag = 'div';
      }
      this.el = document.createElement(conf.tag);
      this.d3el = d3.select(this.el);
      this.d3el.classed(this.constructor.name, true);
      if (conf.parent != null) {
        this.append_to(conf.parent, conf.prepend);
      }
    }

    View.prototype.append_to = function(parent, prepend) {
      var p_el;
      if (parent.el != null) {
        p_el = parent.el;
      } else {
        p_el = d3.select(parent).node();
      }
      if (prepend) {
        return p_el.insertBefore(this.el, p_el.firstChild);
      } else {
        return p_el.appendChild(this.el);
      }
    };

    View.prototype.recompute_size = function() {
      this.width = this.el.getBoundingClientRect().width;
      return this.height = this.el.getBoundingClientRect().height;
    };

    return View;

  })(EventSource));

}).call(this);

index.coffee

new AppView
  parent: 'body'

index.css

body, html {
  padding: 0;
  margin: 0;
  height: 100%;
}