A first implementation of the BreakDown language, a DSL for text annotation made with PEG.js.
| window.AnnotationView = Backbone.D3View.extend | |
| namespace: null | |
| tagName: 'table' | |
| initialize: () -> | |
| @listenTo @model, 'change:annotations', @render | |
| render: () -> | |
| annotations_data = @model.get 'annotations' | |
| annotations = @d3el.selectAll '.annotation' | |
| .data annotations_data | |
| annotations.enter().append 'div' | |
| .attr | |
| class: 'annotation' | |
| annotations | |
| .html (d) -> "<span class='id'>#{d.id}</span> <span class='#{d.type}'>#{d.text.replace(/\n/g,'↵')}</span>" | |
| annotations.exit() | |
| .remove() | |
| // Generated by CoffeeScript 1.10.0 | |
| (function() { | |
| window.AnnotationView = Backbone.D3View.extend({ | |
| namespace: null, | |
| tagName: 'table', | |
| initialize: function() { | |
| return this.listenTo(this.model, 'change:annotations', this.render); | |
| }, | |
| render: function() { | |
| var annotations, annotations_data; | |
| annotations_data = this.model.get('annotations'); | |
| annotations = this.d3el.selectAll('.annotation').data(annotations_data); | |
| annotations.enter().append('div').attr({ | |
| "class": 'annotation' | |
| }); | |
| annotations.html(function(d) { | |
| return "<span class='id'>" + d.id + "</span> <span class='" + d.type + "'>" + (d.text.replace(/\n/g, '↵')) + "</span>"; | |
| }); | |
| return annotations.exit().remove(); | |
| } | |
| }); | |
| }).call(this); |
| window.AppView = Backbone.D3View.extend | |
| initialize: () -> | |
| # retrieve the grammar from an external file | |
| d3.text 'breakdown.peg.js', (grammar) => | |
| editor = new Editor | |
| model: @model | |
| grammar: grammar | |
| @el.appendChild(editor.el) | |
| editor.render() | |
| aw = new AnnotationView | |
| model: @model | |
| @el.appendChild(aw.el) | |
| aw.render() | |
| // Generated by CoffeeScript 1.10.0 | |
| (function() { | |
| window.AppView = Backbone.D3View.extend({ | |
| initialize: function() { | |
| return d3.text('breakdown.peg.js', (function(_this) { | |
| return function(grammar) { | |
| var aw, editor; | |
| editor = new Editor({ | |
| model: _this.model, | |
| grammar: grammar | |
| }); | |
| _this.el.appendChild(editor.el); | |
| editor.render(); | |
| aw = new AnnotationView({ | |
| model: _this.model | |
| }); | |
| _this.el.appendChild(aw.el); | |
| return aw.render(); | |
| }; | |
| })(this)); | |
| } | |
| }); | |
| }).call(this); |
| // 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; | |
| })); |
| { | |
| var plain_text_offset = 0; | |
| var unidentified_span_next_id = 0; | |
| var unidentified_spans_stack = []; | |
| var open_spans = {}; | |
| var result = { | |
| spans: [], | |
| plain_text: "" | |
| }; | |
| } | |
| start = (TripleSection / Body) (Newlines (TripleSection / Body))* { | |
| if(unidentified_spans_stack.length > 0 || Object.keys(open_spans).length > 0 ) { | |
| error('Span not closed at end of input.'); // FIXME better error handling: show the line of the first unclosed span | |
| } | |
| return result; | |
| } | |
| Body 'text body' | |
| = (Text / Operator)* | |
| TripleSection 'triple section' | |
| = '+++' (Newlines Triples)? Newlines '+++' | |
| Triples | |
| = Triple (Newlines Triple)* | |
| Triple | |
| = Subject Spaces Predicate Spaces Object | |
| Subject = Id | |
| Predicate = (!(Spaces) .)+ | |
| Object = (!(Newlines) .)+ | |
| Operator | |
| = SpanOpen / SpanClose | |
| SpanOpen | |
| = id:SpanOpenCode { | |
| if(id === "") { | |
| // store unidentified spans in stack | |
| unidentified_spans_stack.push({ | |
| type: 'span', | |
| start: plain_text_offset, | |
| code_location: location() | |
| }); | |
| } | |
| else { | |
| // store identified spans in an index | |
| open_spans[id] = { | |
| type: 'span', | |
| id: id, | |
| start: plain_text_offset, | |
| code_location: location() | |
| }; | |
| } | |
| } | |
| SpanClose | |
| = id:SpanCloseCode { | |
| var span; | |
| if(id in open_spans) { | |
| // span found in index: move it to results | |
| span = open_spans[id]; | |
| delete open_spans[id]; | |
| } | |
| else { | |
| if(unidentified_spans_stack.length === 0) { | |
| error('Trying to close a span without opening it.'); | |
| } | |
| else { | |
| // span found in stack: move it to results | |
| span = unidentified_spans_stack.pop(); | |
| // give unidentified spans an ID (underscore as first character is not allowed by syntax) | |
| if(id === '') { | |
| id = '_'+unidentified_span_next_id; | |
| unidentified_span_next_id += 1; | |
| } | |
| span.id = id; | |
| } | |
| } | |
| span.end = plain_text_offset; | |
| span.text = result.plain_text.slice(span.start, span.end); | |
| result.spans.push(span); | |
| } | |
| NoText | |
| = SpanOpenCode / SpanCloseCode / '\n+++' / '+++\n' / '+++' | |
| SpanOpenCode = '<' id:NullableId '<' { return id; } | |
| SpanCloseCode = '>' id:NullableId '>' { return id; } | |
| Newlines = [ \t\r\n]+ | |
| Spaces = [ \t]+ | |
| NullableId 'nullable identifier' | |
| = $(Id / '') { return text(); } | |
| Id 'identifier' | |
| = [a-zA-Z0-9][_a-zA-Z0-9]* { return text(); } | |
| Text 'text node' | |
| = (!NoText .)+ { | |
| result.plain_text += text(); | |
| plain_text_offset += text().length; | |
| } |
| window.Data = Backbone.Model.extend | |
| defaults: | |
| annotations: [] | |
| 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 = ''' | |
| This is <<BreakDown>B>, a language that can be used to select portions of text for <<annotation>A>. | |
| <<Nested <<spans>1> are allowed>2>, as well as <<newlines | |
| within spans>N>. Moreover, <<BreakDown>B> <3<supports <4<a syntax>3> for overlapping spans>4>. | |
| Actual annotations are based on RDF triples, and are included in one or more sections like the following one: | |
| +++ | |
| A foaf:page https://en.wikipedia.org/wiki/Annotation | |
| +++ | |
| ''' | |
| @compile() | |
| compile: () -> | |
| @status_bar.text 'All ok.' | |
| @status_bar.classed 'error', false | |
| try | |
| data = @parser.parse @textarea.node().value | |
| @model.set 'annotations', data.spans | |
| catch e | |
| @status_bar.text "Line #{e.location.start.line}: #{e.message}" | |
| @status_bar.classed 'error', true | |
| // 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 = 'This is <<BreakDown>B>, a language that can be used to select portions of text for <<annotation>A>.\n\n<<Nested <<spans>1> are allowed>2>, as well as <<newlines\nwithin spans>N>. Moreover, <<BreakDown>B> <3<supports <4<a syntax>3> for overlapping spans>4>.\n\nActual annotations are based on RDF triples, and are included in one or more sections like the following one:\n+++\nA foaf:page https://en.wikipedia.org/wiki/Annotation\n+++'; | |
| return this.compile(); | |
| }, | |
| compile: function() { | |
| var data, e, error; | |
| this.status_bar.text('All ok.'); | |
| this.status_bar.classed('error', false); | |
| try { | |
| data = this.parser.parse(this.textarea.node().value); | |
| return this.model.set('annotations', data.spans); | |
| } catch (error) { | |
| e = error; | |
| this.status_bar.text("Line " + e.location.start.line + ": " + e.message); | |
| return this.status_bar.classed('error', true); | |
| } | |
| } | |
| }); | |
| }).call(this); |
| app = new AppView | |
| el: 'body' | |
| model: new Data |
| html, body { | |
| margin: 0; | |
| padding: 0; | |
| width: 100%; | |
| height: 100%; | |
| overflow: hidden; | |
| } | |
| body { | |
| display: flex; | |
| flex-direction: row; | |
| } | |
| body > * { | |
| width: 0; | |
| flex-grow: 1; | |
| } | |
| .editor { | |
| display: flex; | |
| flex-direction: column; | |
| border-right: 2px solid gray; | |
| } | |
| textarea { | |
| flex-grow: 1; | |
| height: 0; | |
| resize: none; | |
| border: 0; | |
| outline: 0; | |
| } | |
| .status_bar { | |
| height: 22px; | |
| background: #DDD; | |
| border-top: 1px solid gray; | |
| font-family: sans-serif; | |
| font-size: 12px; | |
| padding: 4px; | |
| box-sizing: border-box; | |
| } | |
| .error { | |
| background: #F77; | |
| } | |
| .annotation { | |
| font-family: sans-serif; | |
| font-size: 12px; | |
| margin: 8px; | |
| margin-bottom: 12px; | |
| } | |
| .annotation .id { | |
| color: #555; | |
| } | |
| .annotation .span { | |
| background: #B9DBF3; | |
| padding: 4px; | |
| } |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>BreakDown language</title> | |
| <link rel="stylesheet" href="index.css"> | |
| <script src="http://d3js.org/d3.v3.min.js"></script> | |
| <script src="/webvis/tmp/peg-0.9.0.min.js"></script> | |
| <script src="http://underscorejs.org/underscore-min.js"></script> | |
| <script src="http://backbonejs.org/backbone-min.js"></script> | |
| <script src="backbone.d3view.js"></script> | |
| <!-- your views go here --> | |
| <script src="AppView.js"></script> | |
| <script src="Editor.js"></script> | |
| <script src="AnnotationView.js"></script> | |
| <!-- your models go here --> | |
| <script src="Data.js"></script> | |
| </head> | |
| <body> | |
| <script src="index.js"></script> | |
| </body> | |
| </html> |
| // Generated by CoffeeScript 1.10.0 | |
| (function() { | |
| window.Table = Backbone.D3View.extend({ | |
| namespace: null, | |
| tagName: 'table', | |
| initialize: function() { | |
| return this.listenTo(this.model, 'change:rows', this.render); | |
| }, | |
| render: function() { | |
| var data_cells, header, header_cells, rows, rows_data; | |
| rows_data = this.model.get('rows'); | |
| header = rows_data.length > 0 ? Object.keys(rows_data[0]) : []; | |
| this.d3el.selectAll('*').remove(); | |
| header_cells = this.d3el.append('tr').selectAll('th').data(header); | |
| header_cells.enter().append('th').text(function(d) { | |
| return d; | |
| }); | |
| rows = this.d3el.append('tbody').selectAll('tr').data(rows_data); | |
| rows.enter().append('tr'); | |
| data_cells = rows.selectAll('td').data(function(d) { | |
| return Object.keys(d).map(function(k) { | |
| return d[k]; | |
| }); | |
| }); | |
| return data_cells.enter().append('td').text(function(d) { | |
| return d; | |
| }); | |
| } | |
| }); | |
| }).call(this); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment