/** | |
* Parse a relational tree and visualize using arcs. | |
* | |
* @author Nils Diewald | |
*/ | |
"use strict"; | |
define([], function () { | |
const svgNS = "http://www.w3.org/2000/svg"; | |
const _TermRE = new RegExp("^(?:([^\/]+?)\/)?([^:]+?):(.+?)$"); | |
const d = document; | |
return { | |
create : function (snippet) { | |
return Object.create(this)._init(snippet); | |
}, | |
// Initialize the state of the object | |
_init : function (snippet) { | |
const t = this; | |
// Predefine some values | |
t._tokens = []; | |
t._arcs = []; | |
t._tokenElements = []; | |
t._y = 0; | |
t._currentInFocus = undefined; | |
// Some configurations | |
t.maxArc = 200; // maximum height of the bezier control point | |
t.overlapDiff = 40; // Difference on overlaps and minimum height for self-refernces | |
t.arcDiff = 15; | |
t.anchorDiff = 8; | |
t.anchorStart = 15; | |
t.tokenSep = 30; | |
t.xPadding = 10; | |
t.yPadding = 5; | |
// No snippet to process | |
if (snippet == undefined || snippet == null) | |
return t; | |
// Parse the snippet | |
const html = d.createElement("div"); | |
html.innerHTML = snippet; | |
// Establish temporary parsing memory | |
t.temp = { | |
target : {}, // Remember the map id => pos | |
edges : [], // Remember edge definitions | |
pos : 0 // Keep track of the current token position | |
}; | |
// Start parsing from root | |
t._parse(0, html.childNodes, undefined); | |
// Establish edge list | |
const targetMap = t.temp['target']; | |
const edges = t.temp['edges']; | |
let targetID, target, relation; | |
// Iterate over edge lists | |
// TODO: | |
// Support spans for anchors! | |
edges.forEach(function(edge) { | |
// Check the target identifier | |
targetID = edge.targetID; | |
target = targetMap[targetID]; | |
if (target != undefined) { | |
// Check if the source is a span anchor | |
/* | |
var start = edge.srcStart; | |
if (start !== edge.srcEnd) { | |
start = [start, edge.srcEnd]; | |
}; | |
*/ | |
// Add relation | |
relation = { | |
start : [edge.srcStart, edge.srcEnd], | |
end : target, | |
direction : 'uni', | |
label : edge.label | |
}; | |
// console.log(relation); | |
this.addRel(relation); | |
}; | |
}, t); | |
// Reset parsing memory | |
delete t["temp"]; | |
return this; | |
}, | |
// Parse a node of the tree snippet | |
_parse : function (parent, children, mark) { | |
// Iterate over all child nodes | |
Array.from(children).forEach(function(c) { | |
// Element node | |
if (c.nodeType == 1) { | |
let xmlid, target, start, end; | |
// Node is an identifier | |
if (c.hasAttribute('xml:id')) { | |
// Remember that pos has this identifier | |
xmlid = c.getAttribute('xml:id'); | |
// this.temp['target'][xmlid] = | |
start = this.temp['pos']; | |
end = this.temp['pos']; | |
} | |
// Node is a link | |
// Stricter: && c.hasAttribute('xlink:show') | |
else if (c.hasAttribute('xlink:href')) { | |
// Node is a continuation | |
if (c.getAttribute('xlink:show') == "other" && | |
c.hasAttribute('data-action') && | |
c.getAttribute('data-action') == "join" | |
) { | |
xmlid = c.getAttribute('xlink:href').replace(/^#/, ""); | |
start = this.temp['pos']; | |
end = this.temp['pos']; | |
// this.temp['target'][xmlid][1] = this.temp['pos'] -1; | |
// console.log("Here"); | |
} | |
// Node is a relation | |
// Stricter: c.getAttribute('xlink:show') == "none" | |
else { | |
let label; | |
// Get target id | |
target = c.getAttribute('xlink:href').replace(/^#/, ""); | |
if (c.hasAttribute('xlink:title')) { | |
label = this._clean(c.getAttribute('xlink:title')); | |
}; | |
// Remember the defined edge | |
// WRONG! | |
var edge = { | |
label : label, | |
srcStart : this.temp['pos'], | |
targetID : target | |
}; | |
// TEMP: Hot-fix for root relations | |
if (!label.match(/--$/) && !label.match(/ROOT$/)) { | |
this.temp['edges'].push(edge); | |
}; | |
}; | |
}; | |
// Go on with child nodes | |
if (c.hasChildNodes()) { | |
this._parse(0, c.childNodes, mark); | |
}; | |
// The element is a defined anchor | |
if (xmlid !== undefined) { | |
// this.temp['target'][xmlid][1] = this.temp['pos'] -1; | |
// Element already defined | |
if (this.temp['target'][xmlid] !== undefined) { | |
const newtarget = this.temp['target'][xmlid]; | |
end = this.temp['pos'] - 1; | |
newtarget[0] = start < newtarget[0] ? start : newtarget[0]; | |
newtarget[1] = end > newtarget[1] ? end : newtarget[1]; | |
} | |
// Element not yet defined | |
else { | |
end = this.temp['pos'] - 1; | |
this.temp['target'][xmlid] = [start, end]; | |
}; | |
/* | |
console.log('Target ' + xmlid + ' spans from ' + | |
this.temp['target'][xmlid][0] + | |
' to ' + | |
this.temp['target'][xmlid][1] | |
); | |
*/ | |
xmlid = undefined; | |
} | |
// Current element describes an arc | |
else if (target !== undefined) { | |
// TODO: This does not work yet! | |
edge["srcEnd"] = this.temp['pos'] -1; | |
// console.log('Here'); | |
/* | |
console.log('Source spans from ' + | |
edge["srcStart"] + | |
' to ' + | |
edge["srcEnd"] | |
); | |
*/ | |
target = undefined; | |
}; | |
} | |
// Text node | |
else if (c.nodeType == 3) { | |
// Check, if there is a non-whitespace token | |
if (c.nodeValue !== undefined) { | |
const str = c.nodeValue.trim(); | |
if (str !== undefined && str.length > 0) { | |
// Add token to token list | |
this.addToken(str); | |
// Move token position | |
this.temp['pos']++; | |
}; | |
}; | |
}; | |
}, this); | |
// Todo: define edges here! | |
}, | |
// Remove foundry and layer for labels | |
_clean : function (title) { | |
return title.replace(_TermRE, "$3"); | |
}, | |
// Return the number of leaf nodes | |
// (not necessarily part of a relation). | |
// Consecutive nodes that are not part of any | |
// relation are summarized in one node. | |
size : function () { | |
return this._tokens.length; | |
}, | |
// This is a shorthand for SVG element creation | |
_c : function (tag) { | |
return d.createElementNS(svgNS, tag); | |
}, | |
// Get bounding box - with workaround for text nodes | |
_rect : function (node) { | |
if (node.tagName == "tspan" && !navigator.userAgent.match(/Edge/)) { | |
const range = d.createRange(); | |
range.selectNode(node); | |
const rect = range.getBoundingClientRect(); | |
range.detach(); | |
return rect; | |
}; | |
return node.getBoundingClientRect(); | |
}, | |
// Returns the center point of the requesting token | |
_tokenPoint : function (node) { | |
const box = this._rect(node); | |
return box.left + (box.width / 2); | |
}, | |
// Draws an anchor | |
_drawAnchor : function (anchor) { | |
// Calculate the span of the first and last token, the anchor spans | |
const firstBox = this._rect(this._tokenElements[anchor.first]); | |
const lastBox = this._rect(this._tokenElements[anchor.last]); | |
const y = this._y + (anchor.overlaps * this.anchorDiff) - this.anchorStart; | |
const l = this._c('path'); | |
this._arcsElement.appendChild(l); | |
const pathStr = "M " + | |
(firstBox.left - this.offsetLeft) + | |
"," + | |
y + | |
" L " + | |
(lastBox.right - this.offsetLeft) + | |
"," + y; | |
l.setAttribute("d", pathStr); | |
l.setAttribute("class", "anchor"); | |
anchor.element = l; | |
anchor.y = y; | |
return l; | |
}, | |
// Create an arc with an optional label | |
// Potentially needs a height parameter for stacks | |
_drawArc : function (arc) { | |
const t = this; | |
let startPos, endPos; | |
let startY = this._y; | |
let endY = this._y; | |
if (arc.startAnchor !== undefined) { | |
startPos = t._tokenPoint(arc.startAnchor.element); | |
startY = arc.startAnchor.y; | |
} | |
else { | |
startPos = t._tokenPoint(t._tokenElements[arc.first]); | |
}; | |
if (arc.endAnchor !== undefined) { | |
endPos = t._tokenPoint(arc.endAnchor.element) | |
endY = arc.endAnchor.y; | |
} | |
else { | |
endPos = t._tokenPoint(t._tokenElements[arc.last]); | |
}; | |
startPos -= t.offsetLeft; | |
endPos -= t.offsetLeft; | |
// Special treatment for self-references | |
var overlaps = arc.overlaps; | |
if (startPos == endPos) { | |
startPos -= t.overlapDiff / 3; | |
endPos += t.overlapDiff / 3; | |
overlaps += .5; | |
}; | |
const g = t._c("g"); | |
g.setAttribute("class", "arc"); | |
const p = g.appendChild(t._c("path")); | |
p.setAttribute('class', 'edge'); | |
// Attach the new arc before drawing, so computed values are available | |
t._arcsElement.appendChild(g); | |
// Create arc | |
let middle = Math.abs(endPos - startPos) / 2; | |
// TODO: | |
// take the number of tokens into account! | |
let cHeight = t.arcDiff + (overlaps * t.overlapDiff) + (middle / 2); | |
// Respect the maximum height | |
cHeight = cHeight < t.maxArc ? cHeight : t.maxArc; | |
var x = Math.min(startPos, endPos); | |
//var controlY = (startY + endY - cHeight); | |
let controlY = (endY - cHeight); | |
const arcE = "M "+ startPos + "," + startY + | |
" C " + startPos + "," + controlY + | |
" " + endPos + "," + controlY + | |
" " + endPos + "," + endY; | |
p.setAttribute("d", arcE); | |
if (arc.direction !== undefined) { | |
p.setAttribute("marker-end", "url(#arr)"); | |
if (arc.direction === 'bi') { | |
p.setAttribute("marker-start", "url(#arr)"); | |
}; | |
}; | |
if (arc.label === undefined) | |
return g; | |
/* | |
* Calculate the top point of the arc for labeling using | |
* de Casteljau's algorithm, see e.g. | |
* http://blog.sklambert.com/finding-the-control-points-of-a-bezier-curve/ | |
* of course simplified to symmetric arcs ... | |
*/ | |
// Interpolate one side of the control polygon | |
let middleY = (((startY + controlY) / 2) + controlY) / 2; | |
// Create a boxed label | |
const label = this._c("g"); | |
label.setAttribute("class", "label"); | |
t._labelsElement.appendChild(label); | |
// Set arc reference | |
label.arcRef = g; | |
const that = t; | |
label.addEventListener('mouseenter', function () { | |
that.inFocus(this); | |
}); | |
const labelE = label.appendChild(t._c("text")); | |
labelE.setAttribute("x", x + middle); | |
labelE.setAttribute("y", middleY + 3); | |
labelE.setAttribute("text-anchor", "middle"); | |
const textNode = d.createTextNode(arc.label); | |
labelE.appendChild(textNode); | |
const labelBox = labelE.getBBox(); | |
/* | |
if (!labelBox) | |
console.log("----"); | |
*/ | |
const textWidth = labelBox.width; // labelE.getComputedTextLength(); | |
const textHeight = labelBox.height; // labelE.getComputedTextLength(); | |
// Add box with padding to left and right | |
const labelR = label.insertBefore(t._c("rect"), labelE); | |
const boxWidth = textWidth + 2 * t.xPadding; | |
labelR.setAttribute("x", x + middle - (boxWidth / 2)); | |
labelR.setAttribute("ry", 5); | |
labelR.setAttribute("y", labelBox.y - t.yPadding); | |
labelR.setAttribute("width", boxWidth); | |
labelR.setAttribute("height", textHeight + 2 * t.yPadding); | |
}, | |
/** | |
* Get the svg element | |
*/ | |
element : function () { | |
if (this._el !== undefined) | |
return this._el; | |
// Create svg | |
const svg = this._c("svg"); | |
window.addEventListener("resize", function () { | |
// TODO: | |
// Only if text-size changed! | |
// TODO: | |
// This is currently untested | |
this.show(); | |
}.bind(this)); | |
// Define marker arrows | |
const defs = svg.appendChild(this._c("defs")); | |
const marker = defs.appendChild(this._c("marker")); | |
marker.setAttribute("refX", 9); | |
marker.setAttribute("id", "arr"); | |
marker.setAttribute("orient", "auto-start-reverse"); | |
marker.setAttribute("markerUnits","userSpaceOnUse"); | |
const arrow = this._c("path"); | |
arrow.setAttribute("transform", "scale(0.8)"); | |
arrow.setAttribute("d", "M 0,-5 0,5 10,0 Z"); | |
marker.appendChild(arrow); | |
this._el = svg; | |
return this._el; | |
}, | |
// Add a relation with a start, an end, | |
// a direction value and an optional label text | |
addRel : function (rel) { | |
this._arcs.push(rel); | |
return this; | |
}, | |
// Add a token to the list (this will mostly be a word) | |
addToken : function(token) { | |
this._tokens.push(token); | |
return this; | |
}, | |
// Move label and arc in focus | |
inFocus : function (element) { | |
let cif; | |
if (this._currentInFocus) { | |
// Already in focus | |
if (this._currentInFocus === element) | |
return; | |
cif = this._currentInFocus; | |
cif.classList.remove('infocus'); | |
cif.arcRef.classList.remove('infocus'); | |
}; | |
cif = this._currentInFocus = element; | |
this._labelsElement.appendChild(cif); | |
this._arcsElement.appendChild(cif.arcRef); | |
cif.classList.add('infocus'); | |
cif.arcRef.classList.add('infocus'); | |
}, | |
/* | |
* All arcs need to be sorted before shown, | |
* to avoid nesting. | |
*/ | |
_sortArcs : function () { | |
// TODO: | |
// Keep in mind that the arcs may have long anchors! | |
// 1. Iterate over all arcs | |
// 2. Sort all multi | |
let anchors = {}; | |
// 1. Sort by length | |
// 2. Tag all spans with the number of overlaps before | |
// a) Iterate over all spans | |
// b) check the latest preceeding overlapping span (lpos) | |
// -> not found: tag with 0 | |
// -> found: Add +1 to the level of the (lpos) | |
// c) If the new tag is smaller than the previous element, | |
// reorder | |
// Normalize start and end | |
const sortedArcs = this._arcs.map(function (v) { | |
// Check for long anchors | |
if (v.start instanceof Array) { | |
if (v.start[0] == v.start[1]) { | |
v.start = v.start[0]; | |
} | |
else { | |
const middle = Math.ceil(Math.abs(v.start[1] - v.start[0]) / 2) + v.start[0]; | |
// Calculate signature to avoid multiple anchors | |
let anchorSig = "#" + v.start[0] + "_" + v.start[1]; | |
// Reverse signature | |
if (v.start[0] > v.start[1]) { | |
anchorSig = "#" + v.start[1] + "_" + v.start[0]; | |
}; | |
// Check if the anchor already exist | |
let anchor = anchors[anchorSig]; | |
if (anchor === undefined) { | |
anchor = { | |
"first": v.start[0], | |
"last" : v.start[1], | |
"length" : v.start[1] - v.start[0] | |
}; | |
anchors[anchorSig] = anchor; | |
}; | |
v.startAnchor = anchor; | |
// Add to anchors list | |
v.start = middle; | |
}; | |
}; | |
if (v.end instanceof Array) { | |
if (v.end[0] == v.end[1]) { | |
v.end = v.end[0]; | |
} | |
else { | |
const middle = Math.abs(v.end[0] - v.end[1]) + v.end[0]; | |
// Calculate signature to avoid multiple anchors | |
let anchorSig = "#" + v.end[0] + "_" + v.end[1]; | |
// Reverse signature | |
if (v.end[0] > v.end[1]) { | |
anchorSig = "#" + v.end[1] + "_" + v.end[0]; | |
}; | |
// Check if the anchor already exist | |
var anchor = anchors[anchorSig]; | |
if (anchor === undefined) { | |
anchor = { | |
"first": v.end[0], | |
"last" : v.end[1], | |
"length" : v.end[1] - v.end[0] | |
}; | |
anchors[anchorSig] = anchor; | |
}; | |
v.endAnchor = anchor; | |
// Add to anchors list | |
v.end = middle; | |
}; | |
}; | |
v.first = v.start; | |
v.last = v.end; | |
// calculate the arch length | |
if (v.start < v.end) { | |
v.length = v.end - v.start; | |
} | |
else { | |
v.length = v.start - v.end; | |
}; | |
return v; | |
}); | |
// Sort based on length | |
sortedArcs.sort(function (a, b) { | |
if (a.length < b.length) | |
return -1; | |
else | |
return 1; | |
}); | |
// Add sorted arcs and anchors | |
this._sortedArcs = lengthSort(sortedArcs, false); | |
this._sortedAnchors = lengthSort(Object.values(anchors), true); | |
}, | |
/** | |
* Center the viewport of the canvas | |
* TODO: | |
* This is identical to treehierarchy | |
*/ | |
center : function () { | |
if (this._el === undefined) | |
return; | |
const treeDiv = this._el.parentNode; | |
const cWidth = parseFloat(window.getComputedStyle(this._el).width); | |
const treeWidth = parseFloat(window.getComputedStyle(treeDiv).width); | |
// Reposition: | |
if (cWidth > treeWidth) { | |
var scrollValue = (cWidth - treeWidth) / 2; | |
treeDiv.scrollLeft = scrollValue; | |
}; | |
}, | |
// Show the element | |
show : function () { | |
const t = this; | |
const svg = this._el; | |
const height = this.maxArc; | |
// Delete old group | |
if (svg.getElementsByTagName("g")[0] !== undefined) { | |
svg.removeChild( | |
svg.getElementsByTagName("g")[0] | |
); | |
t._tokenElements = []; | |
}; | |
const g = svg.appendChild(t._c("g")); | |
// Draw token list | |
const text = g.appendChild(t._c("text")); | |
text.setAttribute('class', 'leaf'); | |
text.setAttribute("text-anchor", "start"); | |
text.setAttribute("y", height); | |
// Calculate the start position | |
t._y = height - (t.anchorStart); | |
// Introduce some prepending whitespace (yeah - I know ...) | |
const ws = text.appendChild(t._c("tspan")); | |
ws.appendChild(d.createTextNode('\u00A0')); | |
ws.style.textAnchor = "start"; | |
t._tokens.forEach(function(node_i) { | |
// Append svg | |
// var x = text.appendChild(this._c("text")); | |
const tspan = text.appendChild(this._c("tspan")); | |
tspan.appendChild(d.createTextNode(node_i)); | |
tspan.setAttribute("text-anchor", "middle"); | |
this._tokenElements.push(tspan); | |
// Add whitespace! | |
tspan.setAttribute("dx", this.tokenSep); | |
}, t); | |
// Get some global position data that may change on resize | |
t.offsetLeft = t._rect(g).left; | |
// The group of arcs | |
const arcs = g.appendChild(t._c("g")); | |
t._arcsElement = arcs; | |
arcs.classList.add("arcs"); | |
const labels = g.appendChild(t._c("g")); | |
t._labelsElement = labels; | |
labels.classList.add("labels"); | |
// Sort arcs if not sorted yet | |
if (t._sortedArcs === undefined) | |
t._sortArcs(); | |
// 1. Draw all anchors | |
t._sortedAnchors.forEach( | |
i => t._drawAnchor(i) | |
); | |
// 2. Draw all arcs | |
t._sortedArcs.forEach( | |
i => t._drawArc(i) | |
); | |
// Resize the svg with some reasonable margins | |
svg.setAttribute("width", t._rect(text).width + 20); | |
svg.setAttribute("height", height + 20); | |
svg.setAttribute("class", "relTree"); | |
} | |
}; | |
// Sort relations regarding their span | |
function lengthSort (list, inclusive) { | |
/* | |
* The "inclusive" flag allows to | |
* modify the behaviour for inclusivity check, | |
* e.g. if identical start or endpoints mean overlap or not. | |
*/ | |
let stack = []; | |
// Iterate over all definitions | |
list.forEach(function(current) { | |
// Check the stack order | |
let overlaps = 0; | |
let check; | |
for (let j = (stack.length - 1); j >= 0; j--) { | |
check = stack[j]; | |
// (a..(b..b)..a) | |
if (current.first <= check.first && current.last >= check.last) { | |
overlaps = check.overlaps + 1; | |
break; | |
} | |
// (a..(b..a)..b) | |
else if (current.first <= check.first && current.last >= check.first) { | |
if (inclusive || (current.first != check.first && current.last != check.first)) { | |
overlaps = check.overlaps + (current.length == check.length ? 0 : 1); | |
}; | |
} | |
// (b..(a..b)..a) | |
else if (current.first <= check.last && current.last >= check.last) { | |
if (inclusive || (current.first != check.last && current.last != check.last)) { | |
overlaps = check.overlaps + (current.length == check.length ? 0 : 1); | |
}; | |
}; | |
}; | |
// Set overlaps | |
current.overlaps = overlaps; | |
stack.push(current); | |
// Although it is already sorted, | |
// the new item has to be put at the correct place | |
// TODO: | |
// Use something like splice() instead | |
stack.sort(function (a,b) { | |
b.overlaps - a.overlaps | |
}); | |
}); | |
return stack; | |
}; | |
}); |