import $ from "jquery";
import WordTag from "./word-tag.js";
import WordCluster from "./word-cluster.js";
import Util from "../util.js";
class Link {
/**
* Creates a new Link between other entities. Links can have Words or
* other Links as argument anchors.
*
* @param {String} eventId - Unique ID
* @param {Word} trigger - Text-bound entity that indicates the presence of
* this event
* @param {Object[]} args - The arguments to this Link. An Array of
* Objects specifying `anchor` and `type`
* @param {String} relType - For (binary) relational Links, a String
* identifying the relationship type
* @param {Boolean} top - Whether or not this Link should be drawn above
* the text row (if false, it will be drawn below)
* @param {String} category - Links can be shown/hidden by category
*/
constructor(
eventId,
trigger,
args,
relType,
top = true,
category = "default"
) {
// ---------------
// Core properties
this.eventId = eventId;
// Links can be either Event or Relation annotations, to borrow the BRAT
// terminology. Event annotations have a `trigger` entity from the text
// that specifies the event, whereas Relation annotations have a `type`
// that may not be bound to any particular part of the raw text.
// Both types of Links have arguments, which may themselves be nested links.
this.trigger = trigger;
this.relType = relType;
this.arguments = args;
// Contains references to higher-level Links that have this Link as an
// argument
this.links = [];
this.top = top;
this.category = category;
// Is this Link currently visible in the visualisation?
this.visible = false;
// Should this Link be drawn onto the visualisation?
this.enabled = false;
// Slots are the y-intervals at which links may be drawn.
// The main instance will need to provide the `.calculateSlot()` method
// with the full set of Words in the data so that we can check for
// crossing/intervening Links.
this.slot = null;
this.calculatingSlot = false;
// Fill in references in this Link's trigger/argument Words
if (this.trigger) {
this.trigger.links.push(this);
}
this.arguments.forEach((arg) => {
arg.anchor.links.push(this);
});
// ---------------
// Visualisation-related properties
this.initialised = false;
// The main API/config instance this Link is attached to
this.main = null;
this.config = null;
// SVG-related properties
// SVG parents
this.mainSvg = null;
this.svg = null;
// Handle objects
this.handles = [];
// SVG Path and last-drawn path string
this.path = null;
this.lastPathString = "";
// (Horizontal-only) width of the last drawn line for this Link; used
// for calculating Handle positions for parent Links
this.lastDrawnWidth = null;
// Objects for main Link label / argument labels
this.argLabels = [];
this.linkLabel = null;
}
/**
* Initialises this Link against the main API instance
* @param main
*/
init(main) {
this.main = main;
this.config = main.config;
this.mainSvg = main.svg;
this.svg = main.svg
.group()
.addClass("tag-element")
.addClass(this.top ? "link" : "link syntax-link");
// Links are hidden by default; the main function should call `.show()`
// for any Links to be shown
this.svg.hide();
// The main Link line
this.path = this.svg.path().addClass("tag-element");
// Init handles and SVG texts.
// If there is a trigger, it will be the first handle
if (this.trigger) {
this.handles.push(new Handle(this.trigger, this));
}
// Arguments
this.arguments.forEach((arg) => {
this.handles.push(new Handle(arg.anchor, this));
const text = new Label(
this.mainSvg,
this.svg,
arg.type,
"link-arg-label"
);
this.argLabels.push(text);
});
// Main Link label
this.linkLabel = new Label(
this.mainSvg,
this.svg,
this.relType,
"link-main-label"
);
// Closure for identifying dragged handles
let draggedHandle = null;
let dragStartX = 0;
// Drag/Click events
this.path
.draggable()
.on("dragstart", (e) => {
// We use the x and y values (with a little tolerance) to make sure
// that the user is dragging near one of the Link's handles, and not
// just in the middle of the Link's line.
const dragX = e.detail.p.x;
// `dragY` is adjusted for the document's scroll position, but we
// want to compare it against our internal container coordinates
// (ZW: As of svg.draggable.js 2.2.2, `dragY` correctly reflects the
// internal coordinates of the drag)
// const dragY = e.detail.p.y - $(window).scrollTop();
const dragY = e.detail.p.y;
for (let handle of this.handles) {
// Is this handle in the correct vicinity on the y-axis?
if (this.top) {
// The Link line will be above the handle
if (dragY < this.getLineY(handle.row) - 5 || dragY > handle.y + 5) {
continue;
}
} else {
// The Link line will be below the handle
if (dragY < handle.y - 5 || dragY > this.getLineY(handle.row) + 5) {
continue;
}
}
// Is this handle close enough on the x-axis?
// In particular, the handle arrowheads might get fairly long
let distX = Math.abs(handle.x - dragX);
if (distX > this.config.linkArrowWidth) {
continue;
}
// Is it closer than any previous candidate?
if (!draggedHandle || distX < Math.abs(draggedHandle.x - dragX)) {
// Sold!
draggedHandle = handle;
dragStartX = e.detail.p.x;
}
}
})
.on("dragmove", (e) => {
e.preventDefault();
if (!draggedHandle) {
return;
}
// Handle the change in raw x-position for this `dragmove` iteration
let dx = e.detail.p.x - dragStartX;
dragStartX = e.detail.p.x;
draggedHandle.offset += dx;
// Constrain the handle's offset so that it doesn't end up
// overshooting the sides of its anchor
let anchor = draggedHandle.anchor;
if (anchor instanceof Link) {
// The handle is resting on another Link; offset 0 is the left
// edge of the lower Link
draggedHandle.offset = Math.min(draggedHandle.offset, anchor.width);
draggedHandle.offset = Math.max(draggedHandle.offset, 0);
} else {
// The handle is resting on a WordTag/WordCluster; offset 0 is the
// centre of the tag
let halfWidth;
if (this.top && anchor.topTag instanceof WordTag) {
halfWidth = anchor.topTag.textWidth / 2;
} else if (!this.top && anchor.bottomTag instanceof WordTag) {
halfWidth = anchor.bottomTag.textWidth / 2;
} else if (this.top && anchor instanceof WordCluster) {
halfWidth = anchor.textWidth / 2;
} else {
// Shouldn't happen, but maybe this is pointed directly at a Word?
halfWidth = anchor.boxWidth / 2;
}
// Constrain the handle to be within 3px of the bounds of its base
draggedHandle.offset = Math.min(draggedHandle.offset, halfWidth - 3);
draggedHandle.offset = Math.max(draggedHandle.offset, -halfWidth + 3);
}
this.draw(anchor);
})
.on("dragend", () => {
draggedHandle = null;
});
this.path.dblclick((e) =>
this.mainSvg.fire("build-tree", {
object: this,
event: e
})
);
this.path.node.oncontextmenu = (e) => {
e.preventDefault();
this.mainSvg.fire("link-right-click", {
object: this,
type: "link",
event: e
});
};
this.initialised = true;
}
/**
* Toggles the visibility of this Link
*/
toggle() {
if (this.enabled) {
this.hide();
} else {
this.show();
}
}
/**
* Enables this Link and draws it onto the visualisation
*/
show() {
this.enabled = true;
if (this.svg && !this.svg.visible()) {
this.svg.show();
}
this.draw();
this.visible = true;
}
/**
* Disables this Link and removes it from the visualisation
*/
hide() {
this.enabled = false;
if (this.svg && this.svg.visible()) {
this.svg.hide();
}
this.visible = false;
}
/**
* Shows the main label for this Link
*/
showMainLabel() {
this.linkLabel.show();
// Redraw the Link to make sure that the label ends up in the correct spot
this.draw();
}
/**
* Hides the main label for this Link
*/
hideMainLabel() {
this.linkLabel.hide();
}
/**
* Shows the argument labels for this Link
*/
showArgLabels() {
this.argLabels.forEach((label) => label.show());
// Redraw the Link to make sure that the label ends up in the correct spot
this.draw();
}
/**
* Hides the argument labels for this Link
*/
hideArgLabels() {
this.argLabels.forEach((label) => label.hide());
}
/**
* (Re-)draw some Link onto the main visualisation
*
* @param {Word|WordCluster|Link} [modAnchor] - Passed when we know that
* (only) a specific anchor has changed position since the last
* redraw. If not, the positions of all handles will be recalculated.
*/
draw(modAnchor) {
if (!this.initialised || !this.enabled) {
return;
}
// Recalculate handle positions
let calcHandles = this.handles;
if (modAnchor) {
// Only one needs to be calculated
calcHandles = [this.handles.find((h) => h.anchor === modAnchor)];
}
const changedHandles = [];
// One or more of our anchors might be nested Links. We need to make
// sure that all of them are already drawn in, so that our offset
// calculations and the like are accurate.
for (let handle of calcHandles) {
const anchor = handle.anchor;
if (anchor instanceof Link && !anchor.visible) {
anchor.show();
}
}
// Offset calculations
for (let handle of calcHandles) {
const anchor = handle.anchor;
// Two possibilities: The anchor is a Word/WordCluster, or it is a
// Link.
if (!(anchor instanceof Link)) {
// No need to account for multiple rows (the handle will be resting
// on the label for a Word/WordCluster)
// The 0-offset location is the centre of the anchor.
const newX = anchor.cx + handle.offset;
const newY = this.top ? anchor.absoluteY : anchor.absoluteDescent;
if (handle.x !== newX || handle.y !== newY) {
handle.x = newX;
handle.y = newY;
handle.row = anchor.row;
changedHandles.push(handle);
}
} else {
// The anchor is a Link; the handle rests on another Link's line,
// and the offset might extend to the next row and beyond.
const baseLeft = anchor.leftHandle;
// First, make sure the offset doesn't overshoot the base row
handle.offset = Math.min(handle.offset, anchor.width);
handle.offset = Math.max(handle.offset, 0);
// Handle intervening rows without modifying `handle.offset` or
// the anchor Link directly
let calcOffset = handle.offset;
let calcRow = baseLeft.row;
let calcX = baseLeft.x;
while (calcOffset > calcRow.rw - calcX) {
calcOffset -= calcRow.rw - calcX;
calcX = 0;
calcRow = this.main.rowManager.rows[calcRow.idx + 1];
}
// Last row - Deal with remaining offset
const newX = calcX + calcOffset;
const newY = anchor.getLineY(calcRow);
if (handle.x !== newX || handle.y !== newY) {
handle.x = newX;
handle.y = newY;
handle.row = calcRow;
changedHandles.push(handle);
}
}
}
// If our width has changed, we should update the offset of any of our
// parent Links.
// The parent Link will be redrawn after we're done redrawing this
// one, and any adjustments will be made automatically during the redraw.
if (this.lastDrawnWidth === null) {
this.lastDrawnWidth = this.width;
} else {
const growth = this.width - this.lastDrawnWidth;
this.lastDrawnWidth = this.width;
// To get the parent Link's handle position to remain as constant as
// possible, we should adjust its offset only if our left handle changed
if (
changedHandles.length === 1 &&
changedHandles[0] === this.leftHandle
) {
for (let parentLink of this.links) {
const parentHandle = parentLink.handles.find(
(h) => h.anchor === this
);
parentHandle.offset += growth;
parentHandle.offset = Math.max(parentHandle.offset, 0);
parentLink.draw(this);
}
}
}
// draw a polyline between the trigger and each of its arguments
// https://www.w3.org/TR/SVG/paths.html#PathData
if (this.trigger) {
// This Link has a trigger (Event)
this._drawAsEvent();
} else {
// This Link has no trigger (Relation)
this._drawAsRelation();
}
}
/**
* Removes this Link's SVG elements from the visualisation, and removes
* all references to it from the data stores
*/
remove() {
this.svg.remove();
let self = this;
// remove reference to a link
function detachLink(anchor) {
let i = anchor.links.indexOf(self);
if (i > -1) {
anchor.links.splice(i, 1);
}
}
// remove references to link from all anchors
if (this.trigger) {
detachLink(this.trigger);
}
this.arguments.forEach((arg) => detachLink(arg.anchor));
}
/**
* Returns the y-position that this Link's main line will have if it were
* drawn in the given row (based on the Row's position, and this Link's slot)
*
* @param {Row} row
*/
getLineY(row) {
return this.top
? row.ry +
row.rh -
row.wordHeight -
this.config.linkSlotInterval * this.slot
: // Bottom Links have negative slot numbers
row.ry +
row.rh +
row.wordDescent -
this.config.linkSlotInterval * this.slot;
}
/**
* Given the full array of Words in the document, calculates this Link's
* slot based on other crossing/intervening/nested Links, recursively if
* necessary.
*
* Principles:
* 1) Links with no other Links intervening have priority for lowest slot
* 2) Links with fully slotted intervening Links (i.e., no crossings) have
* second priority
* 3) Crossed Links have lowest priority, and are handled in order from
* left to right and descending order of length (in terms of number of
* Words covered)
*
* Sorting of the full Links array is handled by
* {@link module:Util.sortForSlotting Util.sortForSlotting}.
*
* @param {Word[]} words
*/
calculateSlot(words) {
// We may already have calculated this Link's slot in a previous
// iteration, or *be* calculating this Link's slot in a previous
// iteration (i.e., in the case of crossing Links).
if (this.slot) {
// Already calculated
return this.slot;
} else if (this.calculatingSlot) {
// Currently trying to calculate this slot in a previous recursive
// iteration
return 0;
}
this.calculatingSlot = true;
// Pick up all the intervening Links
// We don't include the first and last Word since Links ending on the
// same Word can share the same slot if they don't otherwise overlap
let intervening = [];
const coveredWords = words.slice(
this.endpoints[0].idx + 1,
this.endpoints[1].idx
);
// The above comments notwithstanding, the first and last Word should
// know that we are watching them
words[this.endpoints[0].idx].passingLinks.push(this);
words[this.endpoints[1].idx].passingLinks.push(this);
for (const word of coveredWords) {
// Let this Word know we're watching it
word.passingLinks.push(this);
// Word Links
for (const link of word.links) {
// Only consider Links on the same side of the Row as this one
if (
link !== this &&
link.top === this.top &&
intervening.indexOf(link) < 0
) {
intervening.push(link);
}
}
// WordCluster Links
for (const cluster of word.clusters) {
for (const link of cluster.links) {
if (
link !== this &&
link.top === this.top &&
intervening.indexOf(link) < 0
) {
intervening.push(link);
}
}
}
}
// All of our own nested Links are also intervening Links
for (const arg of this.arguments) {
if (arg.anchor instanceof Link && intervening.indexOf(arg.anchor) < 0) {
intervening.push(arg.anchor);
}
}
intervening = Util.sortForSlotting(intervening);
// Map to slots, reduce to the highest number seen so far (or 0 if there
// are none)
const maxSlot = intervening
.map((link) => link.calculateSlot(words))
.reduce((prev, next) => {
// Absolute numbers -- Slots for bottom Links are negative
next = Math.abs(next);
if (next > prev) {
return next;
} else {
return prev;
}
}, 0);
this.slot = maxSlot + 1;
if (!this.top) {
this.slot = this.slot * -1;
}
this.calculatingSlot = false;
return this.slot;
}
listenForEdit(e) {
this.isEditing = true;
let bbox = e.detail.text.bbox();
e.detail.text.addClass("tag-element").addClass("editing-text");
this.editingText = e.detail.text;
this.editingRect = this.svg
.rect(bbox.width + 8, bbox.height + 4)
.x(bbox.x - 4)
.y(bbox.y - 2)
.rx(2)
.ry(2)
.addClass("tag-element")
.addClass("editing-rect")
.back();
}
text(str) {
if (this.editingText) {
if (str === undefined) {
return this.editingText;
}
this.editingText.text(str);
}
}
stopEditing() {
this.isEditing = false;
this.editingText.removeClass("editing-text");
this.editingRect.remove();
this.editingRect = this.editingText = null;
this.draw();
}
/**
* Gets the left-most and right-most Word anchors that come under this Link.
* (Nested Links are treated as extensions of this Link, so the relevant
* endpoint of the nested Link is recursively found and used)
* @return {Word[]}
*/
get endpoints() {
let minWord = null;
let maxWord = null;
if (this.trigger) {
minWord = maxWord = this.trigger;
}
this.arguments.forEach((arg) => {
if (arg.anchor instanceof Link) {
let endpts = arg.anchor.endpoints;
if (!minWord || minWord.idx > endpts[0].idx) {
minWord = endpts[0];
}
if (!maxWord || maxWord.idx < endpts[1].idx) {
maxWord = endpts[1];
}
} else {
// word or wordcluster
if (!minWord || minWord.idx > arg.anchor.idx) {
minWord = arg.anchor;
}
if (!maxWord || maxWord.idx < arg.anchor.idx) {
maxWord = arg.anchor;
}
}
});
return [minWord, maxWord];
}
/**
* Returns the total horizontal width of the Link, from the leftmost handle
* to the rightmost handle
*/
get width() {
// Handles on the same row?
if (this.leftHandle.row === this.rightHandle.row) {
return this.rightHandle.x - this.leftHandle.x;
}
// If not, calculate the width (including intervening rows)
let width = 0;
width += this.leftHandle.row.rw - this.leftHandle.x;
for (
let i = this.leftHandle.row.idx + 1;
i < this.rightHandle.row.idx;
i++
) {
width += this.main.rowManager.rows[i].rw;
}
width += this.rightHandle.x;
return width;
}
/**
* Returns the leftmost handle (smallest Row index, smallest x-position)
* in this Link
*/
get leftHandle() {
return this.handles.reduce((prev, next) => {
if (prev.precedes(next)) {
return prev;
} else {
return next;
}
}, this.handles[0]);
}
/**
* Returns the rightmost handle (largest Row index, largest x-position)
* in this Link
*/
get rightHandle() {
return this.handles.reduce((prev, next) => {
if (prev.precedes(next)) {
return next;
} else {
return prev;
}
}, this.handles[0]);
}
/**
* Returns the handle corresponding to the trigger for this Link, if one
* is defined
*/
get triggerHandle() {
if (!this.trigger) {
return null;
}
return this.handles.find((handle) => handle.anchor === this.trigger);
}
// =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
// Private helper/setup functions
/**
* Draws this Link as an Event annotation (has a trigger)
* @private
*/
_drawAsEvent() {
let d = "";
const triggerHandle = this.triggerHandle;
const pTrigger = {
x: triggerHandle.x,
y: this.top
? triggerHandle.y - this.config.linkHandlePadding
: triggerHandle.y + this.config.linkHandlePadding
};
// How we draw the lines to each argument's Handle depends on which side
// of the trigger they're on.
// Collect the left and right Handles, sorted by distance from the
// trigger Handle, ascending
const lHandles = [];
const rHandles = [];
for (const handle of this.handles) {
if (handle === triggerHandle) {
continue;
}
if (handle.precedes(triggerHandle)) {
lHandles.push(handle);
} else {
rHandles.push(handle);
}
}
lHandles.sort((a, b) => (a.precedes(b) ? 1 : -1));
rHandles.sort((a, b) => (a.precedes(b) ? -1 : 1));
// Start drawing lines between the Handles/text.
// To prevent drawing lines over the same coordinates repeatedly, we
// simply tack on additional lines as we move to the arguments further
// from the trigger.
// pReference will be the point on the last drawn argument line from
// which the line to the next argument should begin.
let pReference;
// Left handles
// ============
pReference = null;
for (const handle of lHandles) {
// Handle
// ------
const pHandle = {
x: handle.x,
y: this.top
? handle.y - this.config.linkHandlePadding
: handle.y + this.config.linkHandlePadding
};
// Line
// ----
// Draw from argument handle to main Link line
d += "M" + [pHandle.x, pHandle.y];
const handleY = this.getLineY(handle.row);
const curveLeftX = pHandle.x + this.config.linkCurveWidth;
const curveLeftY = this.top
? handleY + this.config.linkCurveWidth
: handleY - this.config.linkCurveWidth;
d +=
"L" +
[pHandle.x, curveLeftY] +
"Q" +
[pHandle.x, handleY, curveLeftX, handleY];
// Horizontal line to pReference (if set)
if (pReference) {
if (handle.row.idx !== pReference.row.idx) {
// Draw in Link line across the end of the first row and all
// intervening rows
d += "L" + [handle.row.rw, handleY];
for (let i = handle.row.idx + 1; i < pReference.row.idx; i++) {
const thisRow = this.main.rowManager.rows[i];
const lineY = this.getLineY(thisRow);
d += "M" + [0, lineY] + "L" + [thisRow.rw, lineY];
}
d += "M" + [0, this.getLineY(pReference.row)];
}
d += "L" + [pReference.x, pReference.y];
}
if (pReference === null) {
// This is the first left handle; draw in the line to the trigger also.
// Label to Trigger handle
// If this handle and the trigger handle are not on the same row,
// draw in the intervening rows first.
let finalY = handleY;
if (handle.row.idx !== triggerHandle.row.idx) {
d += "L" + [handle.row.rw, handleY];
for (let i = handle.row.idx + 1; i < triggerHandle.row.idx; i++) {
const thisRow = this.main.rowManager.rows[i];
const lineY = this.getLineY(thisRow);
d += "M" + [0, lineY] + "L" + [thisRow.rw, lineY];
}
finalY = this.getLineY(triggerHandle.row);
d += "M" + [0, finalY];
}
// Draw down to trigger on last row
const curveRightX = pTrigger.x - this.config.linkCurveWidth;
const curveRightY = this.top
? finalY + this.config.linkCurveWidth
: finalY - this.config.linkCurveWidth;
d +=
"L" +
[curveRightX, finalY] +
"Q" +
[pTrigger.x, finalY, pTrigger.x, curveRightY] +
"L" +
[pTrigger.x, pTrigger.y];
}
// pReference for the next handle will be just past the curved part of
// the left-side vertical line
const refLeft = Math.min(
pHandle.x + this.config.linkCurveWidth,
handle.row.rw
);
pReference = {
x: refLeft,
y: handleY,
row: handle.row
};
// Arrowhead
d += this._arrowhead(pHandle);
// Label
// -----
// The trigger always takes up index 0, so the index for the label is
// one less than the index for this handle in `this.handles`
const label = this.argLabels[this.handles.indexOf(handle) - 1];
let labelCentre = pHandle.x;
if (labelCentre + label.length() / 2 > handle.row.rw) {
labelCentre = handle.row.rw - label.length() / 2;
}
if (labelCentre - label.length() / 2 < 0) {
labelCentre = label.length() / 2;
}
label.move(labelCentre, (pHandle.y + handleY) / 2);
}
// Right handles
// ============
pReference = null;
for (const handle of rHandles) {
// Handle
// ------
const pHandle = {
x: handle.x,
y: this.top
? handle.y - this.config.linkHandlePadding
: handle.y + this.config.linkHandlePadding
};
// pReference for the next handle will be just past the curved part of
// the right-side vertical line. We calculate it here since we use it
// when drawing the line itself.
const refRight = Math.max(pHandle.x - this.config.linkCurveWidth, 0);
// Line
// ----
// Draw from main Link line to argument handle
const handleY = this.getLineY(handle.row);
d += "M" + [refRight, handleY];
const curveRightX = pHandle.x - this.config.linkCurveWidth;
const curveRightY = this.top
? handleY + this.config.linkCurveWidth
: handleY - this.config.linkCurveWidth;
d +=
"L" +
[curveRightX, handleY] +
"Q" +
[pHandle.x, handleY, pHandle.x, curveRightY] +
"L" +
[pHandle.x, pHandle.y];
// Horizontal line from pReference (if set)
if (pReference) {
d += "M" + [pReference.x, pReference.y];
if (pReference.row.idx !== handle.row.idx) {
// Draw in Link line across end of the first row and all
// intervening rows
d += "L" + [pReference.row.rw, pReference.y];
for (let i = pReference.row.idx + 1; i < handle.row.idx; i++) {
const thisRow = this.main.rowManager.rows[i];
const lineY = this.getLineY(thisRow);
d += "M" + [0, lineY] + "L" + [thisRow.rw, lineY];
}
d += "M" + [0, handleY];
}
d += "L" + [refRight, handleY];
}
if (pReference === null) {
// This is the first right handle; draw in the line from the trigger
// also.
d += "M" + [pTrigger.x, pTrigger.y];
// Draw up from trigger handle to main line, then draw across
// intervening rows if trigger handle and this handle are not on the
// same row
const triggerY = this.getLineY(triggerHandle.row);
const curveLeftX = pTrigger.x + this.config.linkCurveWidth;
const curveLeftY = this.top
? triggerY + this.config.linkCurveWidth
: triggerY - this.config.linkCurveWidth;
d +=
"L" +
[pTrigger.x, curveLeftY] +
"Q" +
[pTrigger.x, triggerY, curveLeftX, triggerY];
if (triggerHandle.row.idx !== handle.row.idx) {
d += "L" + [triggerHandle.row.rw, triggerY];
for (let i = triggerHandle.row.idx + 1; i < handle.row.idx; i++) {
const thisRow = this.main.rowManager.rows[i];
const lineY = this.getLineY(thisRow);
d += "M" + [0, lineY] + "L" + [thisRow.rw, lineY];
}
d += "M" + [0, handleY];
}
d += "L" + [refRight, handleY];
}
// pReference for the next handle is just inside the curved part of
// the right-side vertical line
pReference = {
x: refRight,
y: handleY,
row: handle.row
};
// Arrowhead
d += this._arrowhead(pHandle);
// Label
// -----
// The trigger always takes up index 0, so the index for the label is
// one less than the index for this handle in `this.handles`
const label = this.argLabels[this.handles.indexOf(handle) - 1];
let labelCentre = pHandle.x;
if (labelCentre + label.length() / 2 > handle.row.rw) {
labelCentre = handle.row.rw - label.length() / 2;
}
if (labelCentre - label.length() / 2 < 0) {
labelCentre = label.length() / 2;
}
label.move(labelCentre, (pHandle.y + handleY) / 2);
}
// Add flat arrowhead to trigger handle if there are both leftward and
// rightward handles
if (lHandles.length > 0 && rHandles.length > 0) {
d +=
"M" +
[pTrigger.x, pTrigger.y] +
"m" +
[this.config.linkArrowWidth, 0] +
"l" +
[-2 * this.config.linkArrowWidth, 0];
}
// Figure out where to put the main link label
const linkLabelY = this.getLineY(triggerHandle.row);
if (lHandles.length > 0 && rHandles.length > 0) {
// Put it in the middle, right on top of the trigger Word
this.linkLabel.move(triggerHandle.x, linkLabelY);
} else if (lHandles.length === 0) {
// Put it in between the trigger and the first right handle
const rHandle = rHandles[0];
const linkLabelX =
rHandle.row.idx === triggerHandle.row.idx
? (triggerHandle.x + rHandle.x) / 2
: (triggerHandle.x + triggerHandle.row.rw) / 2;
this.linkLabel.move(linkLabelX, linkLabelY);
} else if (rHandles.length === 0) {
// Put it in between the trigger and the first left handle
const lHandle = lHandles[0];
const linkLabelX =
lHandle.row.idx === triggerHandle.row.idx
? (triggerHandle.x + lHandle.x) / 2
: triggerHandle.x / 2;
this.linkLabel.move(linkLabelX, linkLabelY);
}
// Perform draw
if (this.lastPathString !== d) {
this.path.plot(d);
this.lastPathString = d;
}
}
/**
* Draws this Link as a Relation annotation (no trigger/directionality
* implied)
* @private
*/
_drawAsRelation() {
let d = "";
const leftHandle = this.leftHandle;
const rightHandle = this.rightHandle;
// Start/end points
const pStart = {
x: leftHandle.x,
y: this.top
? leftHandle.y - this.config.linkHandlePadding
: leftHandle.y + this.config.linkHandlePadding
};
const pEnd = {
x: rightHandle.x,
y: this.top
? rightHandle.y - this.config.linkHandlePadding
: rightHandle.y + this.config.linkHandlePadding
};
const sameRow = leftHandle.row.idx === rightHandle.row.idx;
// Width/position of the Link's label
// (Always on the first row for multi-line Links)
const textLength = this.linkLabel.length();
const textY = this.getLineY(leftHandle.row);
// Centre on the segment of the Link line on the first row, making sure
// it doesn't overshoot the right row boundary
let textCentre = sameRow
? (pStart.x + pEnd.x) / 2
: (pStart.x + leftHandle.row.rw) / 2;
if (textCentre + textLength / 2 > leftHandle.row.rw) {
textCentre = leftHandle.row.rw - textLength / 2;
}
// Start preparing path string
d += "M" + [pStart.x, pStart.y];
// Left handle/label
// Draw up to the level of the Link line, then position the left arg label
const firstY = this.getLineY(leftHandle.row);
let curveLeftX = pStart.x + this.config.linkCurveWidth;
curveLeftX = Math.min(curveLeftX, leftHandle.row.rw);
const curveLeftY = this.top
? firstY + this.config.linkCurveWidth
: firstY - this.config.linkCurveWidth;
d +=
"L" +
[pStart.x, curveLeftY] +
"Q" +
[pStart.x, firstY, curveLeftX, firstY];
const leftLabel = this.argLabels[this.handles.indexOf(leftHandle)];
let leftLabelCentre = pStart.x;
if (leftLabelCentre + leftLabel.length() / 2 > leftHandle.row.rw) {
leftLabelCentre = leftHandle.row.rw - leftLabel.length() / 2;
}
if (leftLabelCentre - leftLabel.length() / 2 < 0) {
leftLabelCentre = leftLabel.length() / 2;
}
leftLabel.move(leftLabelCentre, (pStart.y + firstY) / 2);
// Right handle/label
// Handling depends on whether or not the right handle is on the same
// row as the left handle
let finalY = firstY;
if (!sameRow) {
// Draw in Link line across the end of the first row, and all
// intervening rows
d += "L" + [leftHandle.row.rw, firstY];
for (let i = leftHandle.row.idx + 1; i < rightHandle.row.idx; i++) {
const thisRow = this.main.rowManager.rows[i];
const lineY = this.getLineY(thisRow);
d += "M" + [0, lineY] + "L" + [thisRow.rw, lineY];
}
finalY = this.getLineY(rightHandle.row);
d += "M" + [0, finalY];
}
// Draw down from the main Link line on last row
const curveRightX = pEnd.x - this.config.linkCurveWidth;
const curveRightY = this.top
? finalY + this.config.linkCurveWidth
: finalY - this.config.linkCurveWidth;
d +=
"L" +
[curveRightX, finalY] +
"Q" +
[pEnd.x, finalY, pEnd.x, curveRightY] +
"L" +
[pEnd.x, pEnd.y];
const rightLabel = this.argLabels[this.handles.indexOf(rightHandle)];
let rightLabelCentre = pEnd.x;
if (rightLabelCentre + rightLabel.length() / 2 > rightHandle.row.rw) {
rightLabelCentre = rightHandle.row.rw - rightLabel.length() / 2;
}
if (rightLabelCentre - rightLabel.length() / 2 < 0) {
rightLabelCentre = rightLabel.length() / 2;
}
rightLabel.move(rightLabelCentre, (pEnd.y + finalY) / 2);
// Arrowheads
d += this._arrowhead(pStart) + this._arrowhead(pEnd);
// Main label
this.linkLabel.move(textCentre, textY);
// Perform draw
if (this.lastPathString !== d) {
this.path.plot(d);
this.lastPathString = d;
}
}
/**
* Returns an SVG path string for an arrowhead pointing towards the given
* point. The arrow points down for top Links, and up for bottom Links.
* @param point
* @return {string}
* @private
*/
_arrowhead(point) {
const s = this.config.linkArrowWidth,
s2 = 5;
return this.top
? "M" + [point.x - s, point.y - s2] + "l" + [s, s2] + "l" + [s, -s2]
: "M" + [point.x - s, point.y + s2] + "l" + [s, -s2] + "l" + [s, s2];
}
// =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
// Debug functions
/**
* Draws the outline of this component's bounding box
*/
drawBbox() {
const bbox = this.svg.bbox();
this.svg
.polyline([
[bbox.x, bbox.y],
[bbox.x2, bbox.y],
[bbox.x2, bbox.y2],
[bbox.x, bbox.y2],
[bbox.x, bbox.y]
])
.fill("none")
.stroke({ width: 1 });
}
/**
* Draws the outline of the text element's bounding box
*/
drawTextBbox() {
const bbox = this.svgTexts[0].bbox();
this.svg
.polyline([
[bbox.x, bbox.y],
[bbox.x2, bbox.y],
[bbox.x2, bbox.y2],
[bbox.x, bbox.y2],
[bbox.x, bbox.y]
])
.fill("none")
.stroke({ width: 1 });
}
}
/**
* Helper class for Link handles (the start/end-points for the Link's line;
* for each Link, there is one handle for each associated Word/nested Link)
* @param {Word|Link} anchor - The Word or Link anchor for this Handle
* @param {Link} parent - The parent Link that this Handle belongs to
*/
class Handle {
constructor(anchor, parent) {
this.anchor = anchor;
this.parent = parent;
this.x = 0;
this.y = 0;
// Offsets
// -------
// For anchor Links, offsets start at 0 on the left bound of the Link
// For anchor Words/WordTags, offsets start at 0 in the centre of the
// Word/WordTag
this.offset = 0;
// If the handle's anchor has multiple Links associated with it,
// stagger them horizontally by setting this handle's offset
// based on its index in the anchor's list of links.
// We want to sort the Links by slot descending (the ones with higher slots
// should be on the left)
let l = anchor.links
.filter((link) => link.top === parent.top)
.sort((a, b) => Math.abs(b.slot) - Math.abs(a.slot));
// Magic number for width to distribute handles across on the same anchor
// TODO: Base on anchor width?
let w = 15;
// Distribute the handles based on their sort position
if (l.length > 1) {
if (anchor instanceof Link) {
this.offset = (l.indexOf(parent) * w) / (l.length - 1);
} else {
// Word/WordCluster offsets are a bit more complex -- We have to
// sort again based on whether the Link extends to the
// left or right of this anchor, then adjust the offset horizontally to
// account for the fact that offset 0 is the centre of the anchor
const leftLinks = [];
const rightLinks = [];
for (const link of l) {
if (anchor.idx > link.endpoints[0].idx) {
leftLinks.push(link);
} else {
rightLinks.push(link);
}
}
// To minimise crossings, we sort the left Links ascending this time,
// so that the ones with smaller slots are on the left.
leftLinks.sort((a, b) => Math.abs(a.slot) - Math.abs(b.slot));
l = leftLinks.concat(rightLinks);
this.offset = (l.indexOf(parent) * w) / (l.length - 1) - w / 2;
}
}
// Row
// ---
// There are two possibilities; the argument might be a Word, or it
// might be a Link. For Words, the Handle is on the same Row. For
// Links, the Handle is in the same Row as the Link's left endpoint.
if (anchor instanceof Link) {
this.row = anchor.endpoints[0].row;
} else {
this.row = anchor.row;
}
}
/**
* Returns true if this handle precedes the given handle
* (i.e., this handle has an earlier Row, or is to its left within the
* same row)
* @param {Handle} handle
*/
precedes(handle) {
if (!this.row || !handle.row) {
return false;
}
return (
this.row.idx < handle.row.idx ||
(this.row.idx === handle.row.idx && this.x < handle.x)
);
}
}
/**
* Helper class for various types of labels to be drawn on/around the Link.
* Consists of two main SVG elements:
* - An SVG Text element with the label text, drawn in some given colour
* - Another SVG Text element with the same text, but with a larger stroke
* width and drawn in white, to serve as the background for the main element
*
* @param mainSvg - The main SVG document (for firing events, etc.)
* @param {svgjs.Doc} svg - The SVG document/group to draw the Text elements in
* @param {String} text - The text of the Label
* @param {String} addClass - Any additional CSS classes to add to the SVG
* elements
*/
class Label {
constructor(mainSvg, svg, text, addClass) {
this.mainSvg = mainSvg;
this.svg = svg.group();
// Main label
/** @type svgjs.Text */
this.svgText = this.svg
.plain(text)
.addClass("tag-element")
.addClass("link-text")
.addClass(addClass);
// Calculate the y-interval between the Text element's top edge and
// baseline, so that we can transform the background / move the Label
// around accordingly.
// Svg.js has actually already done this for us -- the value of `.y()`
// is the top edge, and `.attr("y")` is the baseline
this.svgTextBbox = this.svgText.bbox();
this.ascent = this.svgText.attr("y") - this.svgText.y();
this.baselineYOffset = this.ascent - this.svgTextBbox.h / 2;
// Background (rectangle)
this.svgBackground = this.svg
.rect(this.svgTextBbox.width + 2, this.svgTextBbox.height)
.addClass("tag-element")
.addClass("link-text-bg")
.addClass(addClass)
.radius(2.5)
.back();
// Transform the rectangle to sit nicely behind the label
this.svgBackground.transform({
x: -this.svgTextBbox.width / 2 - 1,
y: -this.ascent
});
// // Background (text)
// this.svgBackground = this.svg.text(text)
// .addClass("tag-element")
// .addClass("link-text-bg")
// .addClass(addClass)
// .back();
// Click events
this.svgText.node.oncontextmenu = (e) => {
this.selectedLabel = text;
e.preventDefault();
this.mainSvg.fire("link-label-right-click", {
object: this.svgText,
type: "text",
event: e
});
};
this.svgText.click((e) =>
this.mainSvg.fire("link-label-edit", {
object: this.svgText,
text,
event: e
})
);
this.svgText.dblclick((e) =>
this.mainSvg.fire("build-tree", {
object: this.svgText,
event: e
})
);
// Start hidden
this.hide();
}
/**
* Shows the Label text elements
*/
show() {
this.svgBackground.show();
this.svgText.show();
}
/**
* Hides the Label text elements
*/
hide() {
this.svgBackground.hide();
this.svgText.hide();
}
/**
* Moves the centre of the baseline of the Label text elements to the given
* coordinates
* (N.B.: SVG Text elements are positioned horizontally by their centres,
* by default. Also, setting the y-attribute directly allows us to move
* the Text element directly by its baseline, rather than its top edge)
* @param x - New horizontal centre of the Label
* @param y - New baseline of the Label
*/
move(x, y) {
this.svgBackground.move(x, y);
this.svgText.attr({ x, y });
}
/**
* Centres the Label elements horizontally and vertically on the given point
* @param x - New horizontal centre of the Label
* @param y - New vertical centre of the Label
*/
centre(x, y) {
return this.move(x, y + this.baselineYOffset);
}
/**
* Returns the length (i.e., width) of the main label
* https://svgjs.com/docs/2.7/elements/#text-length
*/
length() {
return this.svgText.length();
}
// =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
// Debug functions
/**
* Draws the outline of the text element's bounding box
*/
drawTextBbox() {
const bbox = this.svgText.bbox();
this.svg
.polyline([
[bbox.x, bbox.y],
[bbox.x2, bbox.y],
[bbox.x2, bbox.y2],
[bbox.x, bbox.y2],
[bbox.x, bbox.y]
])
.fill("none")
.stroke({ width: 1 });
}
}
export default Link;