/**
* Objects representing raw entity/token strings.
*
* The SVG elements for the Word and any attendant WordTags are positioned
* within an SVG group such that the bounding box of the Word always has an
* x-value of 0. In addition, a y-value of 0 within the bounding box
* corresponds to the bottom of the Word's text element (between the Word
* and a bottom WordTag, if one is present).
*
* Actual positioning of this Word's SVG elements is then achieved by
* applying an x-transformation to the SVG group as a whole.
*
* WordTag / WordCluster -> [Word] -> Row
*/
import WordTag from "./word-tag.js";
class Word {
/**
* Creates a new Word instance
* @param {String} text - The raw text for this Word
* @param {Number} idx - The index of this Word within the
* currently-parsed document
*/
constructor(text, idx) {
this.text = text;
this.idx = idx;
// Optional properties that may be set later
// -----------------------------------------
this.eventIds = [];
this.registeredTags = {};
this.topTagCategory = "";
this.bottomTagCategory = "";
// Back-references that will be set when this Word is used in
// other structures
// ---------------------------------------------------------
// WordTag
this.topTag = null;
this.bottomTag = null;
// WordCluster
this.clusters = [];
// Link
this.links = [];
// Row
this.row = null;
// Links that pass over this Word (even if this Word is not an endpoint
// for the Link) -- Used for Link/Row slot calculations
this.passingLinks = [];
// SVG-related properties
// ----------------------
this.initialised = false;
// Main API instance
this.main = null;
// Main Config object for the parent instance
this.config = null;
// SVG group containing this Word and its attendant WordTags
this.svg = null;
// The x-position of the left bound of the Word's box
this.x = 0;
// Calculate the SVG BBox only once per transformation (it's expensive)
this._bbox = null;
this._textBbox = null;
}
/**
* Any event IDs (essentially arbitrary labels) that this Word is
* associated with
* @param id
*/
addEventId(id) {
if (this.eventIds.indexOf(id) < 0) {
this.eventIds.push(id);
}
}
/**
* Register a tag for this word under the given category.
* At run-time, one category of tags can be shown above this Word and
* another can be shown below it.
* @param {String} category
* @param {String} tag
*/
registerTag(category = "default", tag) {
this.registeredTags[category] = tag;
}
/**
* Returns all the unique tag categories currently registered for this Word
*/
getTagCategories() {
return Object.keys(this.registeredTags);
}
/**
* Sets the top tag category for this Word, redrawing it if it is initialised
* @param {String} category
*/
setTopTagCategory(category) {
if (this.topTag) {
this.topTag.remove();
this.topTag = null;
}
// Not all categories of tags will be available for all Words
if (!this.registeredTags[category]) {
return;
}
this.topTagCategory = category;
if (this.initialised) {
this.topTag = new WordTag(
this.registeredTags[category],
this,
this.config
);
// Since one of the Word's tags has changed, recalculate/realign its
// bounding box
this.alignBox();
}
}
/**
* Sets the bottom tag category for this Word, redrawing it if it is
* initialised
* @param {String} category
*/
setBottomTagCategory(category) {
if (this.bottomTag) {
this.bottomTag.remove();
this.bottomTag = null;
}
// Not all categories of tags will be available for all Words
if (!this.registeredTags[category]) {
return;
}
this.bottomTagCategory = category;
if (this.initialised) {
this.bottomTag = new WordTag(
this.registeredTags[category],
this,
this.config,
false
);
// Since one of the Word's tags has changed, recalculate/realign its
// bounding box
this.alignBox();
}
}
/**
* Initialises the SVG elements related to this Word, and performs an
* initial draw of it and its WordTags.
* The Word will be drawn in the top left corner of the canvas, but will
* be properly positioned when added to a Row.
* @param main - The main API instance
*/
init(main) {
this.main = main;
this.config = main.config;
const mainSvg = main.svg;
this.svg = mainSvg
.group()
.addClass("tag-element")
.addClass("word");
// Draw main word text. We remove the default additional leading
// (basically vertical line-height padding) so that we can position it
// more precisely.
this.svgText = this.svg
.text(this.text)
.addClass("tag-element")
.addClass("word-text")
.leading(1);
// The positioning anchor for the text element is its centre, so we need
// to translate the entire Word rightward by half its width.
// In addition, the x/y-position points at the upper-left corner of the
// Word's bounding box, but since we are working relative to the Row's
// main line, we need to move the Word upwards so that the lower-left
// corner meets the Row.
// The desired final outcome is for the Text element's bbox to have an
// x-value of 0 and a y2-value of 0.
const currentBox = this.svgText.bbox();
this.svgText.move(-currentBox.x, -currentBox.height);
this._textBbox = this.svgText.bbox();
// ------------------------
// Draw in this Word's tags
if (this.topTagCategory) {
this.topTag = new WordTag(
this.registeredTags[this.topTagCategory],
this,
this.config
);
}
if (this.bottomTagCategory) {
this.bottomTag = new WordTag(
this.registeredTags[this.bottomTagCategory],
this,
this.config,
false
);
}
// Draw cluster info
this.clusters.forEach((cluster) => {
cluster.init(this, main);
});
// Ensure that all the SVG elements for this Word and any WordTags are
// well-positioned within the Word's bounding box, and set the cached
// values this._textBbox and this._bbox
this.alignBox();
// ---------------------
// Attach drag listeners
let x = 0;
let mousemove = false;
this.svgText
.draggable()
.on("dragstart", function(e) {
mousemove = false;
x = e.detail.p.x;
mainSvg.fire("word-move-start");
})
.on("dragmove", (e) => {
e.preventDefault();
let dx = e.detail.p.x - x;
x = e.detail.p.x;
mainSvg.fire("word-move", { object: this, x: dx });
if (dx !== 0) {
mousemove = true;
}
})
.on("dragend", () => {
mainSvg.fire("word-move-end", {
object: this,
clicked: mousemove === false
});
});
// attach right click listener
this.svgText.dblclick((e) =>
mainSvg.fire("build-tree", {
object: this,
event: e
})
);
this.svgText.node.oncontextmenu = (e) => {
e.preventDefault();
mainSvg.fire("word-right-click", { object: this, event: e });
};
this.initialised = true;
}
/**
* Redraw Links
*/
redrawLinks() {
this.links.forEach((l) => l.draw(this));
this.redrawClusters();
}
/**
* Redraw all clusters (they should always be visible)
*/
redrawClusters() {
this.clusters.forEach((cluster) => {
if (cluster.endpoints.indexOf(this) > -1) {
cluster.draw();
}
});
}
/**
* Sets the base x-position of this Word and its attendant SVG elements
* (including its WordTags)
* @param x
*/
move(x) {
this.x = x;
this.svg.transform({ x: this.x });
this.redrawLinks();
}
/**
* Moves the base x-position of this Word and its attendant SVG elements
* by the given amount
* @param x
*/
dx(x) {
this.move(this.x + x);
}
/**
* Aligns the elements of this Word and any attendant WordTags such that
* the entire Word's bounding box has an x-value of 0, and an x2-value
* equal to its width
*/
alignBox() {
// We begin by resetting the position of the Text elements of this Word
// and any WordTags, so that consecutive calls to `.alignBox()` don't
// push them further and further away from their starting point
this.svgText.attr({ x: 0, y: 0 });
const currentBox = this.svgText.bbox();
this.svgText.move(-currentBox.x, -currentBox.height);
this._textBbox = this.svgText.bbox();
if (this.topTag) {
this.topTag.centre();
}
if (this.bottomTag) {
this.bottomTag.centre();
}
// Generally, we will only need to move things around if the WordTags
// are wider than the Word, which gives the Word's bounding box a
// negative x-value.
this._bbox = this.svg.bbox();
const diff = -this._bbox.x;
if (diff <= 0) {
return;
}
// We can't apply the `.x()` translation directly to this Word's SVG
// group, or it will simply set a transformation on the group (leaving
// the bounding box unchanged). We need to move all its children
// (recursively) instead.
function childrenDx(parent, diff) {
for (const child of parent.children()) {
if (child.children && child.children()) {
childrenDx(child, diff);
} else {
child.dx(diff);
}
}
}
childrenDx(this.svg, diff);
// And update the cached values
this._bbox = this.svg.bbox();
}
/**
* Returns the width of the bounding box for this Word and its WordTags.
* @return {Number}
*/
get boxWidth() {
return this._bbox.width;
}
/**
* Returns the minimum width needed to hold this Word and its WordTags.
* Differs from boxWidth in that it will also reserve space for the Word's
* WordClusters if necessary (even though the WordClusters are not
* technically part of the Word's box)
*/
get minWidth() {
// The Word's Bbox covers the Word and its WordTags
let minWidth = this.boxWidth;
for (const cluster of this.clusters) {
const [clusterLeft, clusterRight] = cluster.endpoints;
if (clusterLeft.row !== clusterRight.row) {
// Let's presume that if the Rows are different, the Cluster has
// enough space (this probably isn't true, but can be revisited later)
continue;
}
const wordWidth =
cluster.endpoints[1].x +
cluster.endpoints[1].boxWidth -
cluster.endpoints[0].x;
const labelWidth = cluster.svgText.bbox().width;
if (labelWidth > wordWidth) {
// The WordCluster's label is wider than the Words it comprises; add
// a bit of extra width to this Word
minWidth = Math.max(minWidth, labelWidth / cluster.words.length);
}
}
return minWidth;
}
/**
* Returns the extent of the bounding box for this Word above the Row's line
* @return {Number}
*/
get boxHeight() {
// Since the Word's box is relative to the Row's line to begin with,
// this is simply the negative of the y-value of the box
return -this._bbox.y;
}
/**
* Returns the extent of the bounding box for this Word below the Row's line
* @return {Number}
*/
get descendHeight() {
// Since the Word's box is relative to the Row's line to begin with,
// this is simply the y2-value of the box
return this._bbox.y2;
}
/**
* Returns the absolute y-position of the top of this Word's bounding box
* @return {Number}
*/
get absoluteY() {
return this.row ? this.row.baseline - this.boxHeight : this.boxHeight;
}
/**
* Returns the absolute y-position of the bottom of this Word's bounding box
* @return {Number}
*/
get absoluteDescent() {
return this.row
? this.row.ry + this.row.rh + this.descendHeight
: this.descendHeight;
}
/**
* Returns the absolute x-position of the centre of this Word's box
* @return {Number}
*/
get cx() {
return this.x + this.boxWidth / 2;
}
/**
* Returns the width of the bounding box of the Word's SVG text element
* @return {Number}
*/
get textWidth() {
return this._textBbox.width;
}
/**
* Returns the height of the bounding box of the Word's SVG text element
* @return {Number}
*/
get textHeight() {
return this._textBbox.height;
}
/**
* Returns the *relative* x-position of the centre of the bounding
* box of the Word's SVG text element
*/
get textRcx() {
return this._textBbox.cx;
}
/**
* Returns true if this Word contains a single punctuation character
*
* FIXME: doesn't handle fancier unicode punctuation | should exclude
* left-punctuation e.g. left-paren or left-quote
* @return {Boolean}
*/
get isPunct() {
return this.text.length === 1 && this.text.charCodeAt(0) < 65;
}
// =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
// 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.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 Word;