block by nitaku 2f4e1d27ce589847a003

Example graph DSL (PEG.js)

Full Screen

-

index.js

// Generated by CoffeeScript 1.10.0
(function() {
  var app;

  app = new AppView({
    el: 'body',
    model: new Graph
  });

}).call(this);

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Example graph DSL in PEG.js</title>
    <link rel="stylesheet" href="index.css">
    <script src="//d3js.org/d3.v3.min.js"></script>
    <script src="/webvis/tmp/peg-0.9.0.min.js"></script>
    <script src="//underscorejs.org/underscore-min.js"></script>
    <script src="//backbonejs.org/backbone-min.js"></script>
    <script src="backbone.d3view.js"></script>
    <script src="//marvl.infotech.monash.edu/webcola/cola.v3.min.js"></script>
    
    <!-- your views go here -->
    <script src="AppView.js"></script>
    <script src="Editor.js"></script>
    <link rel="stylesheet" href="Editor.css">
    <script src="NodeLink.js"></script>
    <link rel="stylesheet" href="NodeLink.css">
    <script src="Matrix.js"></script>
    <link rel="stylesheet" href="Matrix.css">
    
    <!-- your models go here -->
    <script src="Graph.js"></script>
  </head>
  <body>
    <script src="index.js"></script>
  </body>
</html>

AppView.coffee

window.AppView = Backbone.D3View.extend
  initialize: () ->
    # retrieve the grammar from an external file
    d3.text 'minidot.peg.js', (grammar) =>
      left = @d3el.append 'div'
        .attr
          class: 'left'
          
      editor = new Editor
        model: @model
        grammar: grammar

      left.node().appendChild(editor.el)
      editor.render()
      
      
      matrix = new Matrix
        model: @model
        
      left.node().appendChild(matrix.el)
      matrix.render()


      node_link = new NodeLink
        model: @model

      @el.appendChild(node_link.el)
      node_link.render()
      

AppView.js

// Generated by CoffeeScript 1.10.0
(function() {
  window.AppView = Backbone.D3View.extend({
    initialize: function() {
      return d3.text('minidot.peg.js', (function(_this) {
        return function(grammar) {
          var editor, left, matrix, node_link;
          left = _this.d3el.append('div').attr({
            "class": 'left'
          });
          editor = new Editor({
            model: _this.model,
            grammar: grammar
          });
          left.node().appendChild(editor.el);
          editor.render();
          matrix = new Matrix({
            model: _this.model
          });
          left.node().appendChild(matrix.el);
          matrix.render();
          node_link = new NodeLink({
            model: _this.model
          });
          _this.el.appendChild(node_link.el);
          return node_link.render();
        };
      })(this));
    }
  });

}).call(this);

Editor.coffee

window.Editor = Backbone.D3View.extend
  namespace: null
  tagName: 'div'
  
  events:
    input: 'compile'
    
  initialize: (conf) ->
    @d3el.classed 'editor', true
    
    @textarea = @d3el.append 'textarea'
    @status_bar = @d3el.append 'div'
      .attr
        class: 'status_bar'
    
    @parser = PEG.buildParser conf.grammar
    
    # example code
    @textarea.node().value = '''
      0--1
      2--3--4--5--2
      a->b->c->a
      Z
      foo<-bar,baz,bob
      One,Two,Three,Four,Five*
      Four--foo
    '''
    @compile()
  
  compile: () ->
    @status_bar.text 'All ok.'
    @status_bar.classed 'error', false
    
    try
      graph = @parser.parse @textarea.node().value
      @model.update graph
    catch e
      @status_bar.text "Line #{e.location.start.line}: #{e.message}"
      @status_bar.classed 'error', true
    

Editor.css

.editor {
  display: flex;
  flex-direction: column;
}

.editor textarea {
  flex-grow: 1;
  height: 0;
  resize: none;
  border: 0;
  outline: 0;
}

.editor .status_bar {
  height: 22px;
  background: #DDD;
  border-top: 1px solid gray;
  font-family: sans-serif;
  font-size: 12px;
  padding: 4px;
  box-sizing: border-box;
}
.editor .error {
  background: #F77;
}

Editor.js

// Generated by CoffeeScript 1.10.0
(function() {
  window.Editor = Backbone.D3View.extend({
    namespace: null,
    tagName: 'div',
    events: {
      input: 'compile'
    },
    initialize: function(conf) {
      this.d3el.classed('editor', true);
      this.textarea = this.d3el.append('textarea');
      this.status_bar = this.d3el.append('div').attr({
        "class": 'status_bar'
      });
      this.parser = PEG.buildParser(conf.grammar);
      this.textarea.node().value = '0--1\n2--3--4--5--2\na->b->c->a\nZ\nfoo<-bar,baz,bob\nOne,Two,Three,Four,Five*\nFour--foo';
      return this.compile();
    },
    compile: function() {
      var e, error, graph;
      this.status_bar.text('All ok.');
      this.status_bar.classed('error', false);
      try {
        graph = this.parser.parse(this.textarea.node().value);
        return this.model.update(graph);
      } catch (error) {
        e = error;
        this.status_bar.text("Line " + e.location.start.line + ": " + e.message);
        return this.status_bar.classed('error', true);
      }
    }
  });

}).call(this);

Graph.coffee

window.Graph = Backbone.Model.extend
  defaults:
    graph: {
      nodes: []
      links: []
    }
    selected: null
    
  update: (graph) ->
    # objectify the graph
    index = {}
    graph.nodes.forEach (n) -> index[n.id] = n
    graph.links.forEach (l) ->
      l.id = l.source + (if l.directed then '->' else '--') + l.target
      l.source = index[l.source]
      l.target = index[l.target]
      
    # FIXME handle direction (e.g. link id should be in lexicographic order for undirected links)
    
    @set 'graph', graph
    
  select: (id) ->
    if id is @get 'selected'
      @set 'selected', null
    else
      @set 'selected', id
      

Graph.js

// Generated by CoffeeScript 1.10.0
(function() {
  window.Graph = Backbone.Model.extend({
    defaults: {
      graph: {
        nodes: [],
        links: []
      },
      selected: null
    },
    update: function(graph) {
      var index;
      index = {};
      graph.nodes.forEach(function(n) {
        return index[n.id] = n;
      });
      graph.links.forEach(function(l) {
        l.id = l.source + (l.directed ? '->' : '--') + l.target;
        l.source = index[l.source];
        return l.target = index[l.target];
      });
      return this.set('graph', graph);
    },
    select: function(id) {
      if (id === this.get('selected')) {
        return this.set('selected', null);
      } else {
        return this.set('selected', id);
      }
    }
  });

}).call(this);

Matrix.coffee

MARGIN = 4

window.Matrix = Backbone.D3View.extend
  tagName: 'svg'
  
  events:
    'click .cell': (evt, d) -> @model.select d.id
  
  initialize: () ->
    @d3el.classed 'matrix', true
    
    @vis = @d3el.append 'g'
      .attr
        transform: "translate(#{MARGIN},#{MARGIN})"
    
    @listenTo @model, 'change:graph', @render
    @listenTo @model, 'change:selected', @focus
    
  render: () ->
    width = @el.getBoundingClientRect().width
    height = @el.getBoundingClientRect().height
    size = Math.min(width, height) - 2*MARGIN

    graph = @model.get 'graph'
      
    # store the node index within its data structure
    graph.nodes.forEach (d, i) -> d.i = i
    
    cell_size = size / graph.nodes.length
    
    cells = @vis.selectAll '.cell'
      .data graph.links, (d) -> d.id
      
    enter_cells = cells.enter().append 'rect'
      .attr
        class: 'cell'
          
    enter_cells.append 'title'
      .text (d) -> d.id
          
    cells
      .attr
        x: (d) -> d.target.i*cell_size
        y: (d) -> d.source.i*cell_size
        width: cell_size
        height: cell_size
        fill: (d) -> if d.directed then 'orange' else 'teal'
        
    cells.exit()
      .remove()
      
    # mirror cells for undirected links
    mirror_cells = @vis.selectAll '.mirror'
      .data graph.links.filter((d) -> not d.directed), (d) -> 'mirror_' + d.id
      
    enter_mirror_cells = mirror_cells.enter().append 'rect'
      .attr
        class: 'mirror cell'
          
    enter_mirror_cells.append 'title'
      .text (d) -> d.id
          
    mirror_cells
      .attr
        x: (d) -> d.source.i*cell_size
        y: (d) -> d.target.i*cell_size
        width: cell_size
        height: cell_size
        fill: (d) -> 'teal'
        
    mirror_cells.exit()
      .remove()
      
  focus: () ->
    id = @model.get 'selected'
    
    @vis.selectAll '.cell'
      .classed 'selected', (d) -> d.id is id
      .classed 'unselected', (d) -> if id is null then null else d.id isnt id

Matrix.css

.matrix .cell {
  shape-rendering: crispEdges;
  opacity: 0.6;
}
.matrix .selected.cell, .matrix .cell:hover {
  opacity: 1;
}
.matrix .unselected.cell {
  opacity: 0.2;
}

Matrix.js

// Generated by CoffeeScript 1.10.0
(function() {
  var MARGIN;

  MARGIN = 4;

  window.Matrix = Backbone.D3View.extend({
    tagName: 'svg',
    events: {
      'click .cell': function(evt, d) {
        return this.model.select(d.id);
      }
    },
    initialize: function() {
      this.d3el.classed('matrix', true);
      this.vis = this.d3el.append('g').attr({
        transform: "translate(" + MARGIN + "," + MARGIN + ")"
      });
      this.listenTo(this.model, 'change:graph', this.render);
      return this.listenTo(this.model, 'change:selected', this.focus);
    },
    render: function() {
      var cell_size, cells, enter_cells, enter_mirror_cells, graph, height, mirror_cells, size, width;
      width = this.el.getBoundingClientRect().width;
      height = this.el.getBoundingClientRect().height;
      size = Math.min(width, height) - 2 * MARGIN;
      graph = this.model.get('graph');
      graph.nodes.forEach(function(d, i) {
        return d.i = i;
      });
      cell_size = size / graph.nodes.length;
      cells = this.vis.selectAll('.cell').data(graph.links, function(d) {
        return d.id;
      });
      enter_cells = cells.enter().append('rect').attr({
        "class": 'cell'
      });
      enter_cells.append('title').text(function(d) {
        return d.id;
      });
      cells.attr({
        x: function(d) {
          return d.target.i * cell_size;
        },
        y: function(d) {
          return d.source.i * cell_size;
        },
        width: cell_size,
        height: cell_size,
        fill: function(d) {
          if (d.directed) {
            return 'orange';
          } else {
            return 'teal';
          }
        }
      });
      cells.exit().remove();
      mirror_cells = this.vis.selectAll('.mirror').data(graph.links.filter(function(d) {
        return !d.directed;
      }), function(d) {
        return 'mirror_' + d.id;
      });
      enter_mirror_cells = mirror_cells.enter().append('rect').attr({
        "class": 'mirror cell'
      });
      enter_mirror_cells.append('title').text(function(d) {
        return d.id;
      });
      mirror_cells.attr({
        x: function(d) {
          return d.source.i * cell_size;
        },
        y: function(d) {
          return d.target.i * cell_size;
        },
        width: cell_size,
        height: cell_size,
        fill: function(d) {
          return 'teal';
        }
      });
      return mirror_cells.exit().remove();
    },
    focus: function() {
      var id;
      id = this.model.get('selected');
      return this.vis.selectAll('.cell').classed('selected', function(d) {
        return d.id === id;
      }).classed('unselected', function(d) {
        if (id === null) {
          return null;
        } else {
          return d.id !== id;
        }
      });
    }
  });

}).call(this);

NodeLink.coffee

R = 18

window.NodeLink = Backbone.D3View.extend
  tagName: 'svg'
  
  events:
    'click .link': (evt, d) -> @model.select d.id
  
  initialize: () ->
    @d3el.classed 'node_link', true

    defs = @d3el.append 'defs'

    # define arrow markers for graph links
    defs.append 'marker'
      .attr
        id: 'end-arrow'
        viewBox: '0 0 10 10'
        refX: 4+R
        refY: 5
        orient: 'auto'
    .append 'path'
      .attr
        d: 'M0,0 L0,10 L10,5 z'
        
    # append a group for zoomable content
    zoomable_layer = @d3el.append('g')

    zoom = d3.behavior.zoom()
      .scaleExtent([-Infinity,Infinity])
      .on 'zoom', () ->
        zoomable_layer
          .attr
            transform: "translate(#{zoom.translate()})scale(#{zoom.scale()})"

    @d3el.call(zoom)

        
    # create two layers for nodes and links
    @links_layer = zoomable_layer.append 'g'
    @nodes_layer = zoomable_layer.append 'g'
    
    @listenTo @model, 'change:graph', @render
    @listenTo @model, 'change:selected', @focus
  
  render: () ->
    width = @el.getBoundingClientRect().width
    height = @el.getBoundingClientRect().height

    graph = @model.get 'graph'
    
    # draw nodes
    nodes = @nodes_layer.selectAll '.node'
      .data graph.nodes, (d) -> d.id

    enter_nodes = nodes.enter().append 'g'
      .attr
        class: 'node'

    enter_nodes.append 'circle'
      .attr
        r: R

    # draw the label
    enter_nodes.append 'text'
      .text (d) -> d.id
      .attr
        dy: '0.35em'
      
    nodes.exit()
      .remove()
      
    # draw links
    links = @links_layer.selectAll '.link'
      .data graph.links, (d) -> d.id

    links
      .enter().append 'line'
        .attr
          class: 'link'
    
    links
      .classed 'directed', (d) -> d.directed
        
    links.exit()
      .remove()

    ### cola layout ###
    graph.nodes.forEach (v) ->
      v.width = 3*R
      v.height = 3*R

    d3cola = cola.d3adaptor()
      .size([width, height])
      .linkDistance(100)
      .avoidOverlaps(true)
      .nodes(graph.nodes)
      .links(graph.links)
      .on 'tick', () ->
        # update nodes and links
        nodes
          .attr('transform', (d) -> "translate(#{d.x},#{d.y})")

        links
          .attr('x1', (d) -> d.source.x)
          .attr('y1', (d) -> d.source.y)
          .attr('x2', (d) -> d.target.x)
          .attr('y2', (d) -> d.target.y)
          
    drag = d3cola.drag()
    drag.on 'dragstart', () ->
      # silence other listener
      d3.event.sourceEvent.stopPropagation()
      
    nodes
      .call(drag)
      
    d3cola.start(30,30,30)
    
  focus: () ->
    id = @model.get 'selected'
    
    @links_layer.selectAll '.link'
      .classed 'selected', (d) -> d.id is id
      .classed 'unselected', (d) -> if id is null then null else d.id isnt id
      

NodeLink.css

.node_link .node > circle {
  fill: #dddddd;
  stroke: #777777;
  stroke-width: 2px;
}

.node_link .node > text {
  font-family: sans-serif;
  text-anchor: middle;
  pointer-events: none;
  
  -webkit-touch-callout: none; /* iOS Safari */
  -webkit-user-select: none;   /* Chrome/Safari/Opera */
  -khtml-user-select: none;    /* Konqueror */
  -moz-user-select: none;      /* Firefox */
  -ms-user-select: none;       /* IE/Edge */
  user-select: none;           /* non-prefixed version, currently
                                  not supported by any browser */
}

.node_link .link {
  stroke: teal;
  stroke-width: 4px;
  opacity: 0.6;
}
.node_link .directed.link {
  stroke: orange;
  marker-end: url(#end-arrow);
}
.node_link #end-arrow {
  fill: orange;
}
.node_link .selected.link {
  opacity: 1;
}
.node_link .unselected.link {
  opacity: 0.2;
}

NodeLink.js

// Generated by CoffeeScript 1.10.0
(function() {
  var R;

  R = 18;

  window.NodeLink = Backbone.D3View.extend({
    tagName: 'svg',
    events: {
      'click .link': function(evt, d) {
        return this.model.select(d.id);
      }
    },
    initialize: function() {
      var defs, zoom, zoomable_layer;
      this.d3el.classed('node_link', true);
      defs = this.d3el.append('defs');
      defs.append('marker').attr({
        id: 'end-arrow',
        viewBox: '0 0 10 10',
        refX: 4 + R,
        refY: 5,
        orient: 'auto'
      }).append('path').attr({
        d: 'M0,0 L0,10 L10,5 z'
      });
      zoomable_layer = this.d3el.append('g');
      zoom = d3.behavior.zoom().scaleExtent([-Infinity, Infinity]).on('zoom', function() {
        return zoomable_layer.attr({
          transform: "translate(" + (zoom.translate()) + ")scale(" + (zoom.scale()) + ")"
        });
      });
      this.d3el.call(zoom);
      this.links_layer = zoomable_layer.append('g');
      this.nodes_layer = zoomable_layer.append('g');
      this.listenTo(this.model, 'change:graph', this.render);
      return this.listenTo(this.model, 'change:selected', this.focus);
    },
    render: function() {
      var d3cola, drag, enter_nodes, graph, height, links, nodes, width;
      width = this.el.getBoundingClientRect().width;
      height = this.el.getBoundingClientRect().height;
      graph = this.model.get('graph');
      nodes = this.nodes_layer.selectAll('.node').data(graph.nodes, function(d) {
        return d.id;
      });
      enter_nodes = nodes.enter().append('g').attr({
        "class": 'node'
      });
      enter_nodes.append('circle').attr({
        r: R
      });
      enter_nodes.append('text').text(function(d) {
        return d.id;
      }).attr({
        dy: '0.35em'
      });
      nodes.exit().remove();
      links = this.links_layer.selectAll('.link').data(graph.links, function(d) {
        return d.id;
      });
      links.enter().append('line').attr({
        "class": 'link'
      });
      links.classed('directed', function(d) {
        return d.directed;
      });
      links.exit().remove();

      /* cola layout */
      graph.nodes.forEach(function(v) {
        v.width = 3 * R;
        return v.height = 3 * R;
      });
      d3cola = cola.d3adaptor().size([width, height]).linkDistance(100).avoidOverlaps(true).nodes(graph.nodes).links(graph.links).on('tick', function() {
        nodes.attr('transform', function(d) {
          return "translate(" + d.x + "," + d.y + ")";
        });
        return links.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;
        });
      });
      drag = d3cola.drag();
      drag.on('dragstart', function() {
        return d3.event.sourceEvent.stopPropagation();
      });
      nodes.call(drag);
      return d3cola.start(30, 30, 30);
    },
    focus: function() {
      var id;
      id = this.model.get('selected');
      return this.links_layer.selectAll('.link').classed('selected', function(d) {
        return d.id === id;
      }).classed('unselected', function(d) {
        if (id === null) {
          return null;
        } else {
          return d.id !== id;
        }
      });
    }
  });

}).call(this);

backbone.d3view.js

// Backbone.D3View.js 0.3.1
// ---------------

//     (c) 2015 Adam Krebs
//     Backbone.D3View may be freely distributed under the MIT license.
//     For all details and documentation:
//     https://github.com/akre54/Backbone.D3View

(function (factory) {
  if (typeof define === 'function' && define.amd) { define(['backbone', 'd3'], factory);
  } else if (typeof exports === 'object') { module.exports = factory(require('backbone'), require('d3'));
  } else { factory(Backbone, d3); }
}(function (Backbone, d3) {

  // Cached regex to match an opening '<' of an HTML tag, possibly left-padded
  // with whitespace.
  var paddedLt = /^\s*</;

  var ElementProto = (typeof Element !== 'undefined' && Element.prototype) || {};
  var matchesSelector = ElementProto.matches ||
    ElementProto.webkitMatchesSelector ||
    ElementProto.mozMatchesSelector ||
    ElementProto.msMatchesSelector ||
    ElementProto.oMatchesSelector;

  Backbone.D3ViewMixin = {

    // A reference to the d3 selection backing the view.
    d3el: null,

    namespace: d3.ns.prefix.svg,

    $: function(selector) {
      return this.el.querySelectorAll(selector);
    },

    $$: function(selector) {
      return this.d3el.selectAll(selector);
    },

    _removeElement: function() {
      this.undelegateEvents();
      this.d3el.remove();
    },

    _createElement: function(tagName) {
      var ns = typeof this.namespace === 'function' ? this.namespace() : this.namespace;
      return ns ?
         document.createElementNS(ns, tagName) :
         document.createElement(tagName);
    },

    _setElement: function(element) {
      if (typeof element == 'string') {
        if (paddedLt.test(element)) {
          var el = document.createElement('div');
          el.innerHTML = element;
          this.el = el.firstChild;
        } else {
          this.el = document.querySelector(element);
        }
      } else {
        this.el = element;
      }

      this.d3el = d3.select(this.el);
    },

    _setAttributes: function(attributes) {
      this.d3el.attr(attributes);
    },

    // `delegate` supports two- and three-arg forms. The `selector` is optional.
    delegate: function(eventName, selector, listener) {
      if (listener === undefined) {
        listener = selector;
        selector = null;
      }

      var view = this;
      var wrapped = function(event) {
        var node = event.target,
            idx = 0,
            o = d3.event;

        d3.event = event;

        // The `event` object is stored in `d3.event` but Backbone expects it as
        // the first argument to the listener.
        if (!selector) {
          listener.call(view, d3.event, node.__data__, idx++);
          d3.event = o;
          return;
        }

        while (node && node !== view.el) {
          if (matchesSelector.call(node, selector)) {
            listener.call(view, d3.event, node.__data__, idx++);
          }
          node = node.parentNode;
        }
        d3.event = o;
      };

      var map = this._domEvents || (this._domEvents = {});
      var handlers = map[eventName] || (map[eventName] = []);
      handlers.push({selector: selector, listener: listener, wrapped: wrapped});

      this.el.addEventListener(eventName, wrapped, false);
      return this;
    },

    undelegate: function(eventName, selector, listener) {
      if (!this._domEvents || !this._domEvents[eventName]) return;

      if (typeof selector !== 'string') {
        listener = selector;
        selector = null;
      }

      var handlers = this._domEvents[eventName].slice();
      var i = handlers.length;
      while (i--) {
        var handler = handlers[i];

        var match = (listener ? handler.listener === listener : true) &&
            (selector ? handler.selector === selector : true);

        if (!match) continue;

        this.el.removeEventListener(eventName, handler.wrapped, false);
        this._domEvents[eventName].splice(i, 1);
      }
    },

    undelegateEvents: function() {
      var map = this._domEvents, el = this.el;
      if (!el || !map) return;

      Object.keys(map).forEach(function(eventName) {
        map[eventName].forEach(function(handler) {
          el.removeEventListener(eventName, handler.wrapped, false);
        });
      });

      this._domEvents = {};
      return this;
    }
  };

  Backbone.D3View = Backbone.View.extend(Backbone.D3ViewMixin);

  return Backbone.D3View;
}));

index.coffee

app = new AppView
  el: 'body'
  model: new Graph

index.css

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

body {
  display: flex;
  flex-direction: row;
}

.left {
  width: 300px;
  border-right: 2px solid gray;
  
  display: flex;
  flex-direction: column;
}

.editor {
  height: 0;
  flex-grow: 1;
  
}

.node_link {
  width: 0;
  flex-grow: 2;
}

.matrix {
  height: 300px;
  border-top: 2px solid gray;
}

minidot.peg.js

// DOT-like syntax translated to d3.js data structure
/*
1--2--3--4--1
1--3
2--4
*/
{
  var node_index = {};
  var graph = {
    nodes: [],
    links: []
  };
}

start
  = Def ('\n' Def)* { return graph; }

Def 'definition'
  = CliqueDef
  / NodesLinksDef

CliqueDef 'clique definition'
  = list:NodeList '*' {
    list.forEach(function(a){
      list.forEach(function(b){
        if(a !== b) {
          graph.links.push({
            source: d3.min([a,b]),
            target: d3.max([a,b])
          });
        }
      });
    });
  }

NodesLinksDef 'nodes-links definition'
  = first:NodeList rest:(_ Link _ NodeList)* {
    var node_lists = [first].concat(rest.map(function(d){ return d[3]; }));
    node_lists.forEach(function(list, i){
      // add links between contiguous node lists
      if(i < node_lists.length-1) {
        var list_a = list;
        var list_b = node_lists[i+1];
        var l = rest[i][1];
        list_a.forEach(function(a){
          list_b.forEach(function(b){
            var link = {};
            if(l == 'undirected') {
              link.source = d3.min([a,b]);
              link.target = d3.max([a,b]);
            }
            else if (l == 'ltr') {
              link.source = a;
              link.target = b;
              link.directed = true;
            }
            else if (l == 'rtl') {
              link.source = b;
              link.target = a;
              link.directed = true;
            };
            graph.links.push(link);
          });
        });
      }
    });
  }
  
Link 'link specifier'
  = '--' { return 'undirected'; }
  / '->' { return 'ltr'; }
  / '<-' { return 'rtl'; }
  
NodeList 'node list'
  = first:Node rest:(',' Node)* {
    var nodes = [first].concat(rest.map(function(d){ return d[1]; }));
    
    nodes.forEach(function(id) {
      // add a node if its ID was not previously found
      if(!(id in node_index)) {
        var node = {id: id};
        node_index[id] = node;
        graph.nodes.push(node);
      }
    });
    
    return nodes;
  }

Node 'identifier'
  = [_a-zA-Z0-9]+ { return text(); }

_ 'whitespace'
  = [ \t]*