import Row from "../components/row.js";
class RowManager {
/**
* Instantiate a RowManager for some TAG instance
* @param svg - The svg.js API object for the current TAG instance
* @param config - The Config object for the instance
*/
constructor(svg, config) {
this.config = config;
this._svg = svg;
this._rows = [];
}
/**
* Resizes all the Rows in the visualisation, making sure that they all
* fit the parent container and that none of the Rows/Words overlap
*/
resizeAll() {
this.width(this._svg.width());
this.resizeRow(0);
this.fitWords();
}
/**
* Attempts to adjust the height of the Row with index `i` by the specified
* `dy`. If successful, also adjusts the positions of all the Rows that
* follow it accordingly.
*
* If called without a `dy`, simply ensures that the Row's height is at
* least as large as its minimum height.
*/
resizeRow(i, dy = 0) {
const row = this._rows[i];
if (!row) return;
// Height adjustment
const newHeight = this.config.compactRows
? row.minHeight
: Math.max(row.rh + dy, row.minHeight);
if (row.rh !== newHeight) {
row.height(newHeight);
row.redrawLinksAndClusters();
}
// Adjust position/height of all following Rows
for (i = i + 1; i < this._rows.length; i++) {
const prevRow = this._rows[i - 1];
const thisRow = this._rows[i];
// Height check
let changed = false;
const newHeight = this.config.compactRows
? thisRow.minHeight
: Math.max(thisRow.rh, thisRow.minHeight);
if (thisRow.rh !== newHeight) {
thisRow.height(newHeight);
changed = true;
}
// Position check
if (thisRow.ry !== prevRow.ry2) {
thisRow.move(prevRow.ry2);
changed = true;
}
if (changed) {
thisRow.redrawLinksAndClusters();
}
}
this._svg.height(this.lastRow.ry2 + 20);
}
/**
* Sets the width of all the Rows in the visualisation
* @param {Number} rw - The new Row width
*/
width(rw) {
this._rows.forEach((row) => {
row.width(rw);
// Find any Words that no longer fit on the Row
let i = row.words.findIndex(
(w) => w.x + w.minWidth > rw - this.config.rowEdgePadding
);
if (i > 0) {
while (i < row.words.length) {
this.moveLastWordDown(row.idx);
}
} else {
// Redraw Words/Links that might have changed
row.redrawLinksAndClusters();
}
});
}
/**
* Makes sure that all Words fit nicely on their Rows without overlaps.
* Runs through all the Words on all the Rows in order; the moment one is
* found that overlaps with a neighbour, a recursive move is initiated.
*/
fitWords() {
for (const row of this._rows) {
for (let i = 1; i < row.words.length; i++) {
const prevWord = row.words[i - 1];
const thisWord = row.words[i];
const thisWordPadding = thisWord.isPunct
? this.config.wordPunctPadding
: this.config.wordPadding;
const thisMinX = prevWord.x + prevWord.minWidth + thisWordPadding;
const diff = thisMinX - thisWord.x;
if (diff > 0) {
return this.moveWordRight({
row: row,
wordIndex: i,
dx: diff
});
}
}
}
}
/**
* Adds a new Row to the bottom of the svg and sets the height of the main
* document to match
*/
appendRow() {
const lr = this.lastRow;
const row = !lr
? new Row(this._svg, this.config)
: new Row(this._svg, this.config, lr.idx + 1, lr.ry2);
this._rows.push(row);
this._svg.height(row.ry2 + 20);
return row;
}
/**
* remove last row at the bottom of the svg and resize to match
*/
removeLastRow() {
this._rows.pop().remove();
if (this.lastRow) {
this._svg.height(this.lastRow.ry2 + 20);
}
}
/**
* Adds the given Word to the given Row at the given index.
* Optionally attempts to force an x-position for the Word, which will also
* adjust the x-positions of any Words with higher indices on this Row.
* @param word
* @param row
* @param i
* @param forceX
*/
addWordToRow(word, row, i, forceX) {
if (isNaN(i)) {
i = row.words.length;
}
let overflow = row.addWord(word, i, forceX);
while (overflow < row.words.length) {
this.moveLastWordDown(row.idx);
}
// Now that the Words are settled, make sure that the Row is high enough
// (in case it started too short) and has enough descent space, if there
// are Rows following.
this.resizeRow(row.idx);
}
moveWordOnRow(word, dx) {
let row = word.row;
if (!row) {
return;
}
if (dx >= 0) {
this.moveWordRight({
row,
wordIndex: row.words.indexOf(word),
dx
});
} else if (dx < 0) {
dx = -dx;
this.moveWordLeft({
row,
wordIndex: row.words.indexOf(word),
dx
});
}
}
/**
* Recursively attempts to move the Word at the given index on the given
* Row rightwards. If it runs out of space, moves all other Words right or
* to the next Row as needed.
* @param {Row} params.row
* @param {Number} params.wordIndex
* @param {Number} params.dx - A positive number specifying how far to the
* right we should move the Word
*/
moveWordRight(params) {
const row = params.row;
const wordIndex = params.wordIndex;
const dx = params.dx;
const word = row.words[wordIndex];
const nextWord = row.words[wordIndex + 1];
// First, check if we have space available directly next to this word.
let rightEdge;
if (nextWord) {
rightEdge = nextWord.x;
rightEdge -= nextWord.isPunct
? this.config.wordPunctPadding
: this.config.wordPadding;
} else {
rightEdge = row.rw - this.config.rowEdgePadding;
}
const space = rightEdge - (word.x + word.minWidth);
if (dx <= space) {
word.dx(dx);
return;
}
// No space directly available; recursively move the following Words.
if (!nextWord) {
// Last word on this row
this.moveLastWordDown(row.idx);
} else {
// Move next Word, then move this Word again
this.moveWordRight({
row,
wordIndex: wordIndex + 1,
dx
});
this.moveWordRight(params);
}
}
/**
* Recursively attempts to move the Word at the given index on the given
* Row leftwards. If it runs out of space, tries to move preceding Words
* leftwards or to the previous Row as needed.
* @param {Row} params.row
* @param {Number} params.wordIndex
* @param {Number} params.dx - A positive number specifying how far to the
* left we should try to move the Word
* @return {Boolean} True if the Word was successfully moved
*/
moveWordLeft(params) {
const row = params.row;
const wordIndex = params.wordIndex;
const dx = params.dx;
const word = row.words[wordIndex];
const prevWord = row.words[wordIndex - 1];
const leftPadding = word.isPunct
? this.config.wordPunctPadding
: this.config.wordPadding;
// First, check if we have space available directly next to this word.
let space = word.x;
if (prevWord) {
space -= prevWord.x + prevWord.minWidth + leftPadding;
} else {
space -= this.config.rowEdgePadding;
}
if (dx <= space) {
word.dx(-dx);
return true;
}
// No space directly available; try to recursively move the preceding Words.
// If this is the first Word on this Row, try fitting it on the
// previous Row, or getting the Words on the previous Row to shift.
if (wordIndex === 0) {
const prevRow = this._rows[row.idx - 1];
if (!prevRow) {
return false;
}
// Fits on the previous Row?
if (prevRow.availableSpace >= word.minWidth + leftPadding) {
this.moveFirstWordUp(row.idx);
return true;
}
// Can we shift the Words on the previous Row?
const prevRowShift = word.minWidth + leftPadding - prevRow.availableSpace;
const canMove = this.moveWordLeft({
row: prevRow,
wordIndex: prevRow.words.length - 1,
dx: prevRowShift
});
if (canMove) {
// Pop this word up to the previous row
this.moveFirstWordUp(row.idx);
return true;
} else {
// No can do
return false;
}
}
// Not the first Word; try getting the preceding Words on this Row to shift.
const canMove = this.moveWordLeft({
row,
wordIndex: wordIndex - 1,
dx
});
if (canMove) {
// Retry the move (noting that our index may have changed if earlier
// Words were popped up to the previous Row
return this.moveWordLeft({
row,
wordIndex: row.words.indexOf(word),
dx
});
} else {
// Ah well
return false;
}
}
/**
* Move the first Word on the Row with the given index up to the end
* of the previous Row
* @param index
*/
moveFirstWordUp(index) {
const row = this._rows[index];
const prevRow = this._rows[index - 1];
if (!row || !prevRow) {
return;
}
const word = row.words[0];
const newX = prevRow.rw - this.config.rowEdgePadding - word.minWidth;
row.removeWord(word);
this.addWordToRow(word, prevRow, undefined, newX);
word.redrawClusters();
word.redrawLinks();
if (row === this.lastRow && row.words.length === 0) {
this.removeLastRow();
}
}
/**
* Move the last Word on the Row with the given index down to the start of
* the next Row
* @param index
*/
moveLastWordDown(index) {
let nextRow = this._rows[index + 1] || this.appendRow();
this.addWordToRow(this._rows[index].removeLastWord(), nextRow, 0);
}
/**
* Returns the last Row managed by the RowManager
* @return {*}
*/
get lastRow() {
return this._rows[this._rows.length - 1];
}
/**
* Returns the RowManager's internal Row array
* @return {Array}
*/
get rows() {
return this._rows;
}
}
export default RowManager;