UNPKG

brat-client

Version:

Client from brat rapid annotation tool

1,316 lines (1,208 loc) 140 kB
// -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; -*- // vim:set ft=javascript ts=2 sw=2 sts=2 cindent: var Visualizer = (function($, window, undefined) { var DocumentData = function(text) { this.text = text; this.chunks = []; this.spans = {}; this.eventDescs = {}; this.sentComment = {}; this.arcs = []; this.arcById = {}; this.markedSent = {}; this.spanAnnTexts = {}; this.towers = {}; // this.sizes = {}; }; var Fragment = function(id, span, from, to) { this.id = id; this.span = span; this.from = from; this.to = to; // this.towerId = undefined; // this.drawOrder = undefined; }; var Span = function(id, type, offsets, generalType) { this.id = id; this.type = type; this.totalDist = 0; this.numArcs = 0; this.generalType = generalType; this.headFragment = null; this.unsegmentedOffsets = offsets; this.offsets = []; this.segmentedOffsetsMap = {}; // this.unsegmentedOffsets = undefined; // this.from = undefined; // this.to = undefined; // this.wholeFrom = undefined; // this.wholeTo = undefined; // this.headFragment = undefined; // this.chunk = undefined; // this.marked = undefined; // this.avgDist = undefined; // this.curly = undefined; // this.comment = undefined; // { type: undefined, text: undefined }; // this.annotatorNotes = undefined; // this.drawCurly = undefined; // this.glyphedLabelText = undefined; // this.group = undefined; // this.height = undefined; // this.highlightPos = undefined; // this.indexNumber = undefined; // this.labelText = undefined; // this.nestingDepth = undefined; // this.nestingDepthLR = undefined; // this.nestingDepthRL = undefined; // this.nestingHeight = undefined; // this.nestingHeightLR = undefined; // this.nestingHeightRL = undefined; // this.rect = undefined; // this.rectBox = undefined; // this.refedIndexSum = undefined; // this.right = undefined; // this.totaldist = undefined; // this.width = undefined; this.initContainers(); }; Span.prototype.initContainers = function(offsets) { this.incoming = []; this.outgoing = []; this.attributes = {}; this.attributeText = []; this.attributeCues = {}; this.attributeCueFor = {}; this.attributeMerge = {}; // for box, cross, etc. that are span-global this.fragments = []; this.normalizations = []; }; Span.prototype.splitMultilineOffsets = function(text) { this.segmentedOffsetsMap = {}; for (var fi = 0, nfi = 0; fi < this.unsegmentedOffsets.length; fi++) { var begin = this.unsegmentedOffsets[fi][0]; var end = this.unsegmentedOffsets[fi][1]; for (var ti = begin; ti < end; ti++) { var c = text.charAt(ti); if (c == '\n' || c == '\r') { if (begin !== null) { this.offsets.push([begin, ti]) this.segmentedOffsetsMap[nfi++] = fi; begin = null; } } else if (begin === null) { begin = ti; } } if (begin !== null) { this.offsets.push([begin, end]); this.segmentedOffsetsMap[nfi++] = fi; } } }; Span.prototype.copy = function(id) { var span = $.extend(new Span(), this); // clone span.id = id; // protect from shallow copy span.initContainers(); span.unsegmentedOffsets = this.unsegmentedOffsets.slice(); // read-only; shallow copy is fine span.offsets = this.offsets; span.segmentedOffsetsMap = this.segmentedOffsetsMap; return span; }; var EventDesc = function(id, triggerId, roles, klass) { this.id = id; this.triggerId = triggerId; var roleList = this.roles = []; $.each(roles, function(roleNo, role) { roleList.push({ type: role[0], targetId: role[1] }); }); if (klass == "equiv") { this.equiv = true; } else if (klass == "relation") { this.relation = true; } // this.leftSpans = undefined; // this.rightSpans = undefined; // this.annotatorNotes = undefined; }; var Chunk = function(index, text, from, to, space, spans) { this.index = index; this.text = text; this.from = from; this.to = to; this.space = space; this.fragments = []; // this.sentence = undefined; // this.group = undefined; // this.highlightGroup = undefined; // this.markedTextStart = undefined; // this.markedTextEnd = undefined; // this.nextSpace = undefined; // this.right = undefined; // this.row = undefined; // this.textX = undefined; // this.translation = undefined; } var Arc = function(eventDesc, role, dist, eventNo) { this.origin = eventDesc.id; this.target = role.targetId; this.dist = dist; this.type = role.type; this.shadowClass = eventDesc.shadowClass; this.jumpHeight = 0; if (eventDesc.equiv) { this.equiv = true; this.eventDescId = eventNo; eventDesc.equivArc = this; } else if (eventDesc.relation) { this.relation = true; this.eventDescId = eventNo; } // this.marked = undefined; }; var Row = function(svg) { this.group = svg.group(); this.background = svg.group(this.group); this.chunks = []; this.hasAnnotations = false; this.maxArcHeight = 0; this.maxSpanHeight = 0; }; var Measurements = function(widths, height, y) { this.widths = widths; this.height = height; this.y = y; }; // A naive whitespace tokeniser var tokenise = function(text) { var tokenOffsets = []; var tokenStart = null; var lastCharPos = null; for (var i = 0; i < text.length; i++) { var c = text[i]; // Have we found the start of a token? if (tokenStart == null && !/\s/.test(c)) { tokenStart = i; lastCharPos = i; // Have we found the end of a token? } else if (/\s/.test(c) && tokenStart != null) { tokenOffsets.push([tokenStart, i]); tokenStart = null; // Is it a non-whitespace character? } else if (!/\s/.test(c)) { lastCharPos = i; } } // Do we have a trailing token? if (tokenStart != null) { tokenOffsets.push([tokenStart, lastCharPos + 1]); } return tokenOffsets; }; // A naive newline sentence splitter var sentenceSplit = function(text) { var sentenceOffsets = []; var sentStart = null; var lastCharPos = null; for (var i = 0; i < text.length; i++) { var c = text[i]; // Have we found the start of a sentence? if (sentStart == null && !/\s/.test(c)) { sentStart = i; lastCharPos = i; // Have we found the end of a sentence? } else if (c == '\n' && sentStart != null) { sentenceOffsets.push([sentStart, i]); sentStart = null; // Is it a non-whitespace character? } else if (!/\s/.test(c)) { lastCharPos = i; } } // Do we have a trailing sentence without a closing newline? if (sentStart != null) { sentenceOffsets.push([sentStart, lastCharPos + 1]); } return sentenceOffsets; }; // Sets default values for a wide range of optional attributes var setSourceDataDefaults = function(sourceData) { // The following are empty lists if not set $.each([ 'attributes', 'comments', 'entities', 'equivs', 'events', 'modifications', 'normalizations', 'relations', 'triggers', ], function(attrNo, attr) { if (sourceData[attr] === undefined) { sourceData[attr] = []; } }); // If we lack sentence offsets we fall back on naive sentence splitting if (sourceData.sentence_offsets === undefined) { sourceData.sentence_offsets = sentenceSplit(sourceData.text); } // Similarily we fall back on whitespace tokenisation if (sourceData.token_offsets === undefined) { sourceData.token_offsets = tokenise(sourceData.text); } }; // Set default values for a variety of collection attributes var setCollectionDefaults = function(collectionData) { // The following are empty lists if not set $.each([ 'entity_attribute_types', 'entity_types', 'event_attribute_types', 'event_types', 'relation_attribute_types', 'relation_types', 'unconfigured_types', ], function(attrNo, attr) { if (collectionData[attr] === undefined) { collectionData[attr] = []; } }); }; var Visualizer = function(dispatcher, svgId, webFontURLs) { var $svgDiv = $('#' + svgId); if (!$svgDiv.length) { throw Error('Could not find container with id="' + svgId + '"'); } var that = this; // OPTIONS var roundCoordinates = true; // try to have exact pixel offsets var boxTextMargin = { x: 0, y: 1.5 }; // effect is inverse of "margin" for some reason var highlightRounding = { x: 3, y:3 }; // rx, ry for highlight boxes var spaceWidths = { ' ': 4, '\u00a0': 4, '\u200b': 0, '\u3000': 8, '\t': 12, '\n': 4 }; var coloredCurlies = true; // color curlies by box BG var arcSlant = 15; //10; var minArcSlant = 8; var arcHorizontalSpacing = 10; // min space boxes with connecting arc var rowSpacing = -5; // for some funny reason approx. -10 gives "tight" packing. var sentNumMargin = 31; var smoothArcCurves = true; // whether to use curves (vs lines) in arcs var smoothArcSteepness = 0.5; // steepness of smooth curves (control point) var reverseArcControlx = 5; // control point distance for "UFO catchers" // "shadow" effect settings (note, error, incompelete) var rectShadowSize = 3; var rectShadowRounding = 2.5; var arcLabelShadowSize = 1; var arcLabelShadowRounding = 5; var shadowStroke = 2.5; // TODO XXX: this doesn't affect anything..? // "marked" effect settings (edited, focus, match) var markedSpanSize = 6; var markedArcSize = 2; var markedArcStroke = 7; // TODO XXX: this doesn't seem to do anything..? var rowPadding = 2; var nestingAdjustYStepSize = 2; // size of height adjust for nested/nesting spans var nestingAdjustXStepSize = 1; // size of height adjust for nested/nesting spans var highlightSequence = '#FF9632;#FFCC00;#FF9632'; // yellow - deep orange //var highlightSequence = '#FFFC69;#FFCC00;#FFFC69'; // a bit toned town var highlightSpanSequence = highlightSequence; var highlightArcSequence = highlightSequence; var highlightTextSequence = highlightSequence; var highlightDuration = '2s'; // different sequence for "mere" matches (as opposed to "focus" and // "edited" highlights) var highlightMatchSequence = '#FFFF00'; // plain yellow var fragmentConnectorDashArray = '1,3,3,3'; var fragmentConnectorColor = '#000000'; // END OPTIONS var svg; var $svg; var data = null; var sourceData = null; var requestedData = null; var coll, doc, args; var relationTypesHash; var isRenderRequested; var isCollectionLoaded = false; var entityAttributeTypes = null; var eventAttributeTypes = null; var spanTypes = null; var highlightGroup; var collapseArcs = false; var rtlmode = false; var collapseArcSpace = false; // var commentPrioLevels = ['Unconfirmed', 'Incomplete', 'Warning', 'Error', 'AnnotatorNotes']; // XXX Might need to be tweaked - inserted diff levels var commentPrioLevels = [ 'Unconfirmed', 'Incomplete', 'Warning', 'Error', 'AnnotatorNotes', 'AddedAnnotation', 'MissingAnnotation', 'ChangedAnnotation']; this.arcDragOrigin = null; // TODO var rowBBox = function(span) { var box = $.extend({}, span.rectBox); // clone var chunkTranslation = span.chunk.translation; box.x += chunkTranslation.x; box.y += chunkTranslation.y; return box; }; var commentPriority = function(commentClass) { if (commentClass === undefined) return -1; var len = commentPrioLevels.length; for (var i = 0; i < len; i++) { if (commentClass.indexOf(commentPrioLevels[i]) != -1) return i; } return 0; }; var clearSVG = function() { data = null; sourceData = null; svg.clear(); $svgDiv.hide(); }; var setMarked = function(markedType) { $.each(args[markedType] || [], function(markedNo, marked) { if (marked[0] == 'sent') { data.markedSent[marked[1]] = true; } else if (marked[0] == 'equiv') { // [equiv, Equiv, T1] $.each(sourceData.equivs, function(equivNo, equiv) { if (equiv[1] == marked[1]) { var len = equiv.length; for (var i = 2; i < len; i++) { if (equiv[i] == marked[2]) { // found it len -= 3; for (var i = 1; i <= len; i++) { var arc = data.eventDescs[equiv[0] + "*" + i].equivArc; arc.marked = markedType; } return; // next equiv } } } }); } else if (marked.length == 2) { markedText.push([parseInt(marked[0], 10), parseInt(marked[1], 10), markedType]); } else { var span = data.spans[marked[0]]; if (span) { if (marked.length == 3) { // arc $.each(span.outgoing, function(arcNo, arc) { if (arc.target == marked[2] && arc.type == marked[1]) { arc.marked = markedType; } }); } else { // span span.marked = markedType; } } else { var eventDesc = data.eventDescs[marked[0]]; if (eventDesc) { // relation var relArc = eventDesc.roles[0]; $.each(data.spans[eventDesc.triggerId].outgoing, function(arcNo, arc) { if (arc.target == relArc.targetId && arc.type == relArc.type) { arc.marked = markedType; } }); } else { // try for trigger $.each(data.eventDescs, function(eventDescNo, eventDesc) { if (eventDesc.triggerId == marked[0]) { data.spans[eventDesc.id].marked = markedType; } }); } } } }); }; var findArcHeight = function(fromIndex, toIndex, fragmentHeights) { var height = 0; for (var i = fromIndex; i <= toIndex; i++) { if (fragmentHeights[i] > height) height = fragmentHeights[i]; } height += Configuration.visual.arcSpacing; return height; } var adjustFragmentHeights = function(fromIndex, toIndex, fragmentHeights, height) { for (var i = fromIndex; i <= toIndex; i++) { if (fragmentHeights[i] < height) fragmentHeights[i] = height; } } var fragmentComparator = function(a, b) { var tmp; var aSpan = a.span; var bSpan = b.span; // spans with more fragments go first tmp = aSpan.fragments.length - bSpan.fragments.length; if (tmp) { return tmp < 0 ? 1 : -1; } // longer arc distances go last tmp = aSpan.avgDist - bSpan.avgDist; if (tmp) { return tmp < 0 ? -1 : 1; } // spans with more arcs go last tmp = aSpan.numArcs - bSpan.numArcs; if (tmp) { return tmp < 0 ? -1 : 1; } // compare the span widths, // put wider on bottom so they don't mess with arcs, or shorter // on bottom if there are no arcs. var ad = a.to - a.from; var bd = b.to - b.from; tmp = ad - bd; if (aSpan.numArcs == 0 && bSpan.numArcs == 0) { tmp = -tmp; } if (tmp) { return tmp < 0 ? 1 : -1; } tmp = aSpan.refedIndexSum - bSpan.refedIndexSum; if (tmp) { return tmp < 0 ? -1 : 1; } // if no other criterion is found, sort by type to maintain // consistency // TODO: isn't there a cmp() in JS? if (aSpan.type < bSpan.type) { return -1; } else if (aSpan.type > bSpan.type) { return 1; } return 0; }; var setData = function(_sourceData) { if (!args) args = {}; sourceData = _sourceData; dispatcher.post('newSourceData', [sourceData]); data = new DocumentData(sourceData.text); // collect annotation data $.each(sourceData.entities, function(entityNo, entity) { // offsets given as array of (start, end) pairs var span = // (id, type, offsets, generalType) new Span(entity[0], entity[1], entity[2], 'entity'); span.splitMultilineOffsets(data.text); data.spans[entity[0]] = span; }); var triggerHash = {}; $.each(sourceData.triggers, function(triggerNo, trigger) { // (id, type, offsets, generalType) var triggerSpan = new Span(trigger[0], trigger[1], trigger[2], 'trigger'); triggerSpan.splitMultilineOffsets(data.text); triggerHash[trigger[0]] = [triggerSpan, []]; // triggerSpan, eventlist }); $.each(sourceData.events, function(eventNo, eventRow) { var eventDesc = data.eventDescs[eventRow[0]] = // (id, triggerId, roles, klass) new EventDesc(eventRow[0], eventRow[1], eventRow[2]); var trigger = triggerHash[eventDesc.triggerId]; var span = trigger[0].copy(eventDesc.id); trigger[1].push(span); data.spans[eventDesc.id] = span; }); // XXX modifications: delete later $.each(sourceData.modifications, function(modNo, mod) { // mod: [id, spanId, modification] if (!data.spans[mod[2]]) { dispatcher.post('messages', [[['<strong>ERROR</strong><br/>Event ' + mod[2] + ' (referenced from modification ' + mod[0] + ') does not occur in document ' + data.document + '<br/>(please correct the source data)', 'error', 5]]]); return; } data.spans[mod[2]][mod[1]] = true; }); var midpointComparator = function(a, b) { var tmp = a.from + a.to - b.from - b.to; if (!tmp) return 0; return tmp < 0 ? -1 : 1; }; // split spans into span fragments (for discontinuous spans) $.each(data.spans, function(spanNo, span) { $.each(span.offsets, function(offsetsNo, offsets) { var from = parseInt(offsets[0], 10); var to = parseInt(offsets[1], 10); var fragment = new Fragment(offsetsNo, span, from, to); span.fragments.push(fragment); }); // ensure ascending order span.fragments.sort(midpointComparator); span.wholeFrom = span.fragments[0].from; span.wholeTo = span.fragments[span.fragments.length - 1].to; span.headFragment = span.fragments[(true) ? span.fragments.length - 1 : 0]; // TODO configurable! }); var spanComparator = function(a, b) { var aSpan = data.spans[a]; var bSpan = data.spans[b]; var tmp = aSpan.headFragment.from + aSpan.headFragment.to - bSpan.headFragment.from - bSpan.headFragment.to; if (tmp) { return tmp < 0 ? -1 : 1; } return 0; }; $.each(sourceData.equivs, function(equivNo, equiv) { // equiv: ['*', 'Equiv', spanId...] equiv[0] = "*" + equivNo; var equivSpans = equiv.slice(2); var okEquivSpans = []; // collect the equiv spans in an array $.each(equivSpans, function(equivSpanNo, equivSpan) { if (data.spans[equivSpan]) okEquivSpans.push(equivSpan); // TODO: #404, inform the user with a message? }); // sort spans in the equiv by their midpoint okEquivSpans.sort(spanComparator); // generate the arcs var len = okEquivSpans.length; for (var i = 1; i < len; i++) { var eventDesc = data.eventDescs[equiv[0] + '*' + i] = // (id, triggerId, roles, klass) new EventDesc(okEquivSpans[i - 1], okEquivSpans[i - 1], [[equiv[1], okEquivSpans[i]]], 'equiv'); eventDesc.leftSpans = okEquivSpans.slice(0, i); eventDesc.rightSpans = okEquivSpans.slice(i); } }); $.each(sourceData.relations, function(relNo, rel) { // rel[2] is args, rel[2][a][0] is role and rel[2][a][1] is value for a in (0,1) var argsDesc = relationTypesHash[rel[1]]; argsDesc = argsDesc && argsDesc.args; var t1, t2; if (argsDesc) { // sort the arguments according to the config var args = {} args[rel[2][0][0]] = rel[2][0][1]; args[rel[2][1][0]] = rel[2][1][1]; t1 = args[argsDesc[0].role]; t2 = args[argsDesc[1].role]; } else { // (or leave as-is in its absence) t1 = rel[2][0][1]; t2 = rel[2][1][1]; } data.eventDescs[rel[0]] = // (id, triggerId, roles, klass) new EventDesc(t1, t1, [[rel[1], t2]], 'relation'); }); // attributes $.each(sourceData.attributes, function(attrNo, attr) { // attr: [id, name, spanId, value, cueSpanId // TODO: might wish to check what's appropriate for the type // instead of using the first attribute def found var attrType = (eventAttributeTypes[attr[1]] || entityAttributeTypes[attr[1]]); var attrValue = attrType && attrType.values.byName[attrType.bool || attr[3]]; var span = data.spans[attr[2]]; if (!span) { dispatcher.post('messages', [[['Annotation ' + attr[2] + ', referenced from attribute ' + attr[0] + ', does not exist.', 'error']]]); return; } var valText = (attrValue && attrValue.name) || attr[3]; var attrText = attrType ? (attrType.bool ? attrType.name : (attrType.name + ': ' + valText)) : (attr[3] == true ? attr[1] : attr[1] + ': ' + attr[3]); span.attributeText.push(attrText); span.attributes[attr[1]] = attr[3]; if (attr[4]) { // cue span.attributeCues[attr[1]] = attr[4]; var cueSpan = data.spans[attr[4]]; cueSpan.attributeCueFor[data.spans[1]] = attr[2]; cueSpan.cue = 'CUE'; // special css type } $.extend(span.attributeMerge, attrValue); }); // comments $.each(sourceData.comments, function(commentNo, comment) { // comment: [entityId, type, text] // TODO error handling // sentence id: ['sent', sentId] if (comment[0] instanceof Array && comment[0][0] == 'sent') { // sentence comment var sent = comment[0][1]; var text = comment[2]; if (data.sentComment[sent]) { text = data.sentComment[sent].text + '<br/>' + text; } data.sentComment[sent] = { type: comment[1], text: text }; } else { var id = comment[0]; var trigger = triggerHash[id]; var eventDesc = data.eventDescs[id]; var commentEntities = trigger ? trigger[1] // trigger: [span, ...] : id in data.spans ? [data.spans[id]] // span: [span] : id in data.eventDescs ? [data.eventDescs[id]] // arc: [eventDesc] : []; $.each(commentEntities, function(entityId, entity) { // if duplicate comment for entity: // overwrite type, concatenate comment with a newline if (!entity.comment) { entity.comment = { type: comment[1], text: comment[2] }; } else { entity.comment.type = comment[1]; entity.comment.text += "\n" + comment[2]; } // partially duplicate marking of annotator note comments if (comment[1] == "AnnotatorNotes") { entity.annotatorNotes = comment[2]; } // prioritize type setting when multiple comments are present if (commentPriority(comment[1]) > commentPriority(entity.shadowClass)) { entity.shadowClass = comment[1]; } }); } }); // normalizations $.each(sourceData.normalizations, function(normNo, norm) { var id = norm[0]; var normType = norm[1]; var target = norm[2]; var refdb = norm[3]; var refid = norm[4]; var reftext = norm[5]; // grab entity / event the normalization applies to var span = data.spans[target]; if (!span) { dispatcher.post('messages', [[['Annotation ' + target + ', referenced from normalization ' + id + ', does not exist.', 'error']]]); return; } // TODO: do we have any possible use for the normType? span.normalizations.push([refdb, refid, reftext]); // quick hack for span box visual style span.normalized = 'Normalized'; }); // prepare span boundaries for token containment testing var sortedFragments = []; $.each(data.spans, function(spanNo, span) { $.each(span.fragments, function(fragmentNo, fragment) { sortedFragments.push(fragment); }); }); // sort fragments by beginning, then by end sortedFragments.sort(function(a, b) { var x = a.from; var y = b.from; if (x == y) { x = a.to; y = b.to; } return ((x < y) ? -1 : ((x > y) ? 1 : 0)); }); var currentFragmentId = 0; var startFragmentId = 0; var numFragments = sortedFragments.length; var lastTo = 0; var firstFrom = null; var chunkNo = 0; var space; var chunk = null; // token containment testing (chunk recognition) $.each(sourceData.token_offsets, function() { var from = this[0]; var to = this[1]; if (firstFrom === null) firstFrom = from; // Replaced for speedup; TODO check correctness // inSpan = false; // $.each(data.spans, function(spanNo, span) { // if (span.from < to && to < span.to) { // // it does; no word break // inSpan = true; // return false; // } // }); // Is the token end inside a span? if (startFragmentId && to > sortedFragments[startFragmentId - 1].to) { while (startFragmentId < numFragments && to > sortedFragments[startFragmentId].from) { startFragmentId++; } } currentFragmentId = startFragmentId; while (currentFragmentId < numFragments && to >= sortedFragments[currentFragmentId].to) { currentFragmentId++; } // if yes, the next token is in the same chunk if (currentFragmentId < numFragments && to > sortedFragments[currentFragmentId].from) { return; } // otherwise, create the chunk found so far space = data.text.substring(lastTo, firstFrom); var text = data.text.substring(firstFrom, to); if (chunk) chunk.nextSpace = space; // (index, text, from, to, space) { chunk = new Chunk(chunkNo++, text, firstFrom, to, space); chunk.lastSpace = space; data.chunks.push(chunk); lastTo = to; firstFrom = null; }); var numChunks = chunkNo; // find sentence boundaries in relation to chunks chunkNo = 0; var sentenceNo = 0; var pastFirst = false; $.each(sourceData.sentence_offsets, function() { var from = this[0]; if (chunkNo >= numChunks) return false; if (data.chunks[chunkNo].from > from) return; var chunk; while (chunkNo < numChunks && (chunk = data.chunks[chunkNo]).from < from) { chunkNo++; } chunkNo++; if (pastFirst && from <= chunk.from) { var numNL = chunk.space.split("\n").length - 1; if (!numNL) numNL = 1; sentenceNo += numNL; chunk.sentence = sentenceNo; } else { pastFirst = true; } }); // assign fragments to appropriate chunks var currentChunkId = 0; var chunk; $.each(sortedFragments, function(fragmentId, fragment) { while (fragment.to > (chunk = data.chunks[currentChunkId]).to) currentChunkId++; chunk.fragments.push(fragment); fragment.text = chunk.text.substring(fragment.from - chunk.from, fragment.to - chunk.from); fragment.chunk = chunk; }); // assign arcs to spans; calculate arc distances $.each(data.eventDescs, function(eventNo, eventDesc) { var dist = 0; var origin = data.spans[eventDesc.id]; if (!origin) { // TODO: include missing trigger ID in error message dispatcher.post('messages', [[['<strong>ERROR</strong><br/>Trigger for event "' + eventDesc.id + '" not found in ' + data.document + '<br/>(please correct the source data)', 'error', 5]]]); return; } var here = origin.headFragment.from + origin.headFragment.to; $.each(eventDesc.roles, function(roleNo, role) { var target = data.spans[role.targetId]; if (!target) { dispatcher.post('messages', [[['<strong>ERROR</strong><br/>"' + role.targetId + '" (referenced from "' + eventDesc.id + '") not found in ' + data.document + '<br/>(please correct the source data)', 'error', 5]]]); return; } var there = target.headFragment.from + target.headFragment.to; var dist = Math.abs(here - there); var arc = new Arc(eventDesc, role, dist, eventNo); origin.totalDist += dist; origin.numArcs++; target.totalDist += dist; target.numArcs++; data.arcs.push(arc); target.incoming.push(arc); origin.outgoing.push(arc); // ID dict for easy access. TODO: have a function defining the // (origin,type,target)->id mapping (see also annotator_ui.js) var arcId = origin.id + '--' + role.type + '--' + target.id; data.arcById[arcId] = arc; }); // roles }); // eventDescs // highlighting markedText = []; setMarked('edited'); // set by editing process setMarked('focus'); // set by URL setMarked('matchfocus'); // set by search process, focused match setMarked('match'); // set by search process, other (non-focused) match $.each(data.spans, function(spanId, span) { // calculate average arc distances // average distance of arcs (0 for no arcs) span.avgDist = span.numArcs ? span.totalDist / span.numArcs : 0; lastSpan = span; // collect fragment texts into span texts var fragmentTexts = []; $.each(span.fragments, function(fragmentNo, fragment) { // TODO heuristics fragmentTexts.push(fragment.text); }); span.text = fragmentTexts.join(''); }); // data.spans for (var i = 0; i < 2; i++) { // preliminary sort to assign heights for basic cases // (first round) and cases resolved in the previous // round(s). $.each(data.chunks, function(chunkNo, chunk) { // sort chunk.fragments.sort(fragmentComparator); // renumber $.each(chunk.fragments, function(fragmentNo, fragment) { fragment.indexNumber = fragmentNo; }); }); // nix the sums, so we can sum again $.each(data.spans, function(spanNo, span) { span.refedIndexSum = 0; }); // resolved cases will now have indexNumber set // to indicate their relative order. Sum those for referencing cases // for use in iterative resorting $.each(data.arcs, function(arcNo, arc) { data.spans[arc.origin].refedIndexSum += data.spans[arc.target].headFragment.indexNumber; }); } // Final sort of fragments in chunks for drawing purposes // Also identify the marked text boundaries regarding chunks $.each(data.chunks, function(chunkNo, chunk) { // and make the next sort take this into account. Note that this will // now resolve first-order dependencies between sort orders but not // second-order or higher. chunk.fragments.sort(fragmentComparator); $.each(chunk.fragments, function(fragmentNo, fragment) { fragment.drawOrder = fragmentNo; }); }); data.spanDrawOrderPermutation = Object.keys(data.spans); data.spanDrawOrderPermutation.sort(function(a, b) { var spanA = data.spans[a]; var spanB = data.spans[b]; // We're jumping all over the chunks, but it's enough that // we're doing everything inside each chunk in the right // order. should it become necessary to actually do these in // linear order, put in a similar condition for // spanX.headFragment.chunk.index; but it should not be // needed. var tmp = spanA.headFragment.drawOrder - spanB.headFragment.drawOrder; if (tmp) return tmp < 0 ? -1 : 1; return 0; }); // resort the spans for linear order by center sortedFragments.sort(midpointComparator); // sort fragments into towers, calculate average arc distances var lastFragment = null; var towerId = -1; $.each(sortedFragments, function(i, fragment) { if (!lastFragment || (lastFragment.from != fragment.from || lastFragment.to != fragment.to)) { towerId++; } fragment.towerId = towerId; lastFragment = fragment; }); // sortedFragments // find curlies (only the first fragment drawn in a tower) $.each(data.spanDrawOrderPermutation, function(spanIdNo, spanId) { var span = data.spans[spanId]; $.each(span.fragments, function(fragmentNo, fragment) { if (!data.towers[fragment.towerId]) { data.towers[fragment.towerId] = []; fragment.drawCurly = true; fragment.span.drawCurly = true; } data.towers[fragment.towerId].push(fragment); }); }); var spanAnnTexts = {}; $.each(data.chunks, function(chunkNo, chunk) { chunk.markedTextStart = []; chunk.markedTextEnd = []; $.each(chunk.fragments, function(fragmentNo, fragment) { if (chunk.firstFragmentIndex == undefined) { chunk.firstFragmentIndex = fragment.towerId; } chunk.lastFragmentIndex = fragment.towerId; var spanLabels = Util.getSpanLabels(spanTypes, fragment.span.type); fragment.labelText = Util.spanDisplayForm(spanTypes, fragment.span.type); // Find the most appropriate label according to text width if (Configuration.abbrevsOn && spanLabels) { var labelIdx = 1; // first abbrev var maxLength = (fragment.to - fragment.from) / 0.8; while (fragment.labelText.length > maxLength && spanLabels[labelIdx]) { fragment.labelText = spanLabels[labelIdx]; labelIdx++; } } var svgtext = svg.createText(); // one "text" element per row var postfixArray = []; var prefix = ''; var postfix = ''; var warning = false; $.each(fragment.span.attributes, function(attrType, valType) { // TODO: might wish to check what's appropriate for the type // instead of using the first attribute def found var attr = (eventAttributeTypes[attrType] || entityAttributeTypes[attrType]); if (!attr) { // non-existent type warning = true; return; } var val = attr.values.byName[attr.bool || valType]; if (!val) { // non-existent value warning = true; return; } if ($.isEmptyObject(val)) { // defined, but lacks any visual presentation warning = true; return; } if (val.glyph) { if (val.position == "left") { prefix = val.glyph + prefix; var tspan_attrs = { 'class' : 'glyph' }; if (val.glyphColor) { tspan_attrs.fill = val.glyphColor; } svgtext.span(val.glyph, tspan_attrs); } else { // XXX right is implied - maybe change postfixArray.push([attr, val]); postfix += val.glyph; } } }); var text = fragment.labelText; if (prefix !== '') { text = prefix + ' ' + text; svgtext.string(' '); } svgtext.string(fragment.labelText); if (postfixArray.length) { text += ' ' + postfix; svgtext.string(' '); $.each(postfixArray, function(elNo, el) { var tspan_attrs = { 'class' : 'glyph' }; if (el[1].glyphColor) { tspan_attrs.fill = el[1].glyphColor; } svgtext.span(el[1].glyph, tspan_attrs); }); } if (warning) { svgtext.span("#", { 'class': 'glyph attribute_warning' }); text += ' #'; } fragment.glyphedLabelText = text; if (!spanAnnTexts[text]) { spanAnnTexts[text] = true; data.spanAnnTexts[text] = svgtext; } }); // chunk.fragments }); // chunks var numChunks = data.chunks.length; // note the location of marked text with respect to chunks var startChunk = 0; var currentChunk; // sort by "from"; we don't need to sort by "to" as well, // because unlike spans, chunks are disjunct markedText.sort(function(a, b) { return Util.cmp(a[0], b[0]); }); $.each(markedText, function(textNo, textPos) { var from = textPos[0]; var to = textPos[1]; var markedType = textPos[2]; if (from < 0) from = 0; if (to < 0) to = 0; if (to >= data.text.length) to = data.text.length - 1; if (from > to) from = to; while (startChunk < numChunks) { var chunk = data.chunks[startChunk]; if (from <= chunk.to) { chunk.markedTextStart.push([textNo, true, from - chunk.from, null, markedType]); break; } startChunk++; } if (startChunk == numChunks) { dispatcher.post('messages', [[['Wrong text offset', 'error']]]); return; } currentChunk = startChunk; while (currentChunk < numChunks) { var chunk = data.chunks[currentChunk]; if (to <= chunk.to) { chunk.markedTextEnd.push([textNo, false, to - chunk.from]); break } currentChunk++; } if (currentChunk == numChunks) { dispatcher.post('messages', [[['Wrong text offset', 'error']]]); var chunk = data.chunks[data.chunks.length - 1]; chunk.markedTextEnd.push([textNo, false, chunk.text.length]); return; } }); // markedText dispatcher.post('dataReady', [data]); }; var resetData = function() { setData(sourceData); renderData(); } var translate = function(element, x, y) { $(element.group).attr('transform', 'translate(' + x + ', ' + y + ')'); element.translation = { x: x, y: y }; }; var showMtime = function() { if (data.mtime) { // we're getting seconds and need milliseconds //$('#document_ctime').text("Created: " + Annotator.formatTime(1000 * data.ctime)).css("display", "inline"); $('#document_mtime').text("Last modified: " + Util.formatTimeAgo(1000 * data.mtime)).css("display", "inline"); } else { //$('#document_ctime').css("display", "none"); $('#document_mtime').css("display", "none"); } }; var addHeaderAndDefs = function() { var commentName = (coll + '/' + doc).replace('--', '-\\-'); $svg.append('<!-- document: ' + commentName + ' -->'); var defs = svg.defs(); var $blurFilter = $('<filter id="Gaussian_Blur"><feGaussianBlur in="SourceGraphic" stdDeviation="2" /></filter>'); svg.add(defs, $blurFilter); return defs; } var getTextMeasurements = function(textsHash, options, callback) { // make some text elements, find out the dimensions var textMeasureGroup = svg.group(options); // changed from $.each because of #264 ('length' can appear) for (var text in textsHash) { if (textsHash.hasOwnProperty(text)) { svg.text(textMeasureGroup, 0, 0, text); } } // measuring goes on here var widths = {}; $(textMeasureGroup).find('text').each(function(svgTextNo, svgText) { var text = $(svgText).text(); widths[text] = this.getComputedTextLength(); if (callback) { $.each(textsHash[text], function(text, object) { callback(object, svgText); }); } }); var bbox = textMeasureGroup.getBBox(); svg.remove(textMeasureGroup); return new Measurements(widths, bbox.height, bbox.y); }; var getTextAndSpanTextMeasurements = function() { // get the span text sizes var chunkTexts = {}; // set of span texts $.each(data.chunks, function(chunkNo, chunk) { chunk.row = undefined; // reset if (!chunkTexts.hasOwnProperty(chunk.text)) chunkTexts[chunk.text] = [] var chunkText = chunkTexts[chunk.text]; // here we also need all the spans that are contained in // chunks with this text, because we need to know the position // of the span text within the respective chunk text chunkText.push.apply(chunkText, chunk.fragments); // and also the markedText boundaries chunkText.push.apply(chunkText, chunk.markedTextStart); chunkText.push.apply(chunkText, chunk.markedTextEnd); }); var textSizes = getTextMeasurements( chunkTexts, undefined, function(fragment, text) { if (fragment instanceof Fragment) { // it's a fragment! // measure the fragment text position in pixels var firstChar = fragment.from - fragment.chunk.from; if (firstChar < 0) { firstChar = 0; dispatcher.post('messages', [[['<strong>WARNING</strong>' + '<br/> ' + 'The fragment [' + fragment.from + ', ' + fragment.to + '] (' + fragment.text + ') is not ' + 'contained in its designated chunk [' + fragment.chunk.from + ', ' + fragment.chunk.to + '] most likely ' + 'due to the fragment starting or ending with a space, please ' + 'verify the sanity of your data since we are unable to ' + 'visualise this fragment correctly and will drop leading ' + 'space characters' , 'warning', 15]]]); } var lastChar = fragment.to - fragment.chunk.from - 1; // Adjust for XML whitespace (#832, #1009) var textUpToFirstChar = fragment.chunk.text.substring(0, firstChar); var textUpToLastChar = fragment.chunk.text.substring(0, lastChar); var textUpToFirstCharUnspaced = textUpToFirstChar.replace(/\s\s+/g, ' '); var textUpToLastCharUnspaced = textUpToLastChar.replace(/\s\s+/g, ' '); firstChar -= textUpToFirstChar.length - textUpToFirstCharUnspaced.length; lastChar -= textUpToLastChar.length - textUpToLastCharUnspaced.length; var startPos, endPos; if (firstChar < fragment.chunk.text.length) { startPos = text.getStartPositionOfChar(firstChar).x; } else { startPos = text.getComputedTextLength(); } endPos = (lastChar < firstChar) ? startPos : text.getEndPositionOfChar(lastChar).x; // In RTL mode, positions are negative (left to right) if (rtlmode) { startPos = -startPos; endPos = -endPos; } fragment.curly = { from: Math.min(startPos, endPos), to: Math.max(startPos, endPos) }; } else { // it's markedText [id, start?, char#, offset] if (fragment[2] < 0) fragment[2] = 0; if (!fragment[2]) { // start fragment[3] = text.getStartPositionOfChar(fragment[2]).x; } else { fragment[3] = text.getEndPositionOfChar(fragment[2] - 1).x + 1; } } }); // get the fragment annotation text sizes var fragmentTexts = {}; var noSpans = true; $.each(data.spans, function(spanNo, span) { $.each(span.fragments, function(fragmentNo, fragment) { fragmentTexts[fragment.glyphedLabelText] = true; noSpans = false; }); }); if (noSpans) fragmentTexts.$ = true; // dummy so we can at least get the height var fragmentSizes = getTextMeasurements(fragmentTexts, {'class': 'span'}); return { texts: textSizes, fragments: fragmentSizes }; }; var addArcTextMeasurements = function(sizes) { // get the arc annotation text sizes (for all labels) var arcTexts = {}; $.each(data.arcs, function(arcNo, arc) { var labels = Util.getArcLabels(spanTypes, data.spans[arc.origin].type, arc.type, relationTypesHash); if (!labels.length) labels = [arc.type]; $.each(labels, function(labelNo, label) { arcTexts[label] = true; }); }); var arcSizes = getTextMeasurements(arcTexts, {'class': 'arcs'}); sizes.arcs = arcSizes; }; var adjustTowerAnnotationSizes = function() { // find biggest annotation in each tower $.each(data.towers, function(towerNo, tower) { var maxWidth = 0; $.each(tower, function(fragmentNo, fragment) { var width = data.sizes.fragments.widths[fragment.glyphedLabelText]; if (width > maxWidth) maxWidth = width; }); // tower $.each(tower, function(fragmentNo, fragment) { fragment.width = maxWidth; }); // tower }); // data.towers }; var makeArrow = function(defs, spec) { var parsedSpec = spec.split(','); var type = parsedSpec[0]; if (type == 'none') return; var width = 5; var height = 5; var color = "black"; if ($.isNumeric(parsedSpec[1]) && parsedSpec[2]) { if ($.