components/row.js

class Row {
  /**
   * Creates a new Row for holding Words.
   *
   * @param svg - This Row's SVG group
   * @param {Config~Config} config - The Config object for the parent TAG
   *   instance
   * @param {Number} idx - The Row's index
   * @param {Number} ry - The y-position of the Row's top edge
   * @param {Number} rh - The Row's height
   */
  constructor(svg, config, idx = 0, ry = 0, rh = 100) {
    this.config = config;

    this.idx = idx;
    this.ry = ry; // row position from top
    this.rh = rh; // row height
    this.rw = 0;
    this.words = [];

    // svg elements
    this.svg = null; // group
    this.draggable = null; // row resizer
    this.wordGroup = null; // child group element

    // The last Word we removed, if any.
    // In case we have a Row with no Words left but which still has Links
    // passing through.
    this.lastRemovedWord = null;

    if (svg) {
      this.svgInit(svg);
    }
  }

  /**
   * Initialises the SVG elements related to this Row, and performs an
   * initial draw of the baseline/resize line
   * @param mainSvg - The main SVG document
   */
  svgInit(mainSvg) {
    // All positions will be relative to the baseline for this Row
    this.svg = mainSvg
      .group()
      .transform({ y: this.baseline })
      .addClass("tag-element")
      .addClass("row");

    // Group element to contain word elements
    this.wordGroup = this.svg.group();

    // Row width
    this.rw = mainSvg.width();

    // Add draggable resize line
    this.draggable = this.svg
      .line(0, 0, this.rw, 0)
      .addClass("tag-element")
      .addClass("row-drag")
      .draggable();

    let y = 0;
    this.draggable
      .on("dragstart", function(e) {
        y = e.detail.p.y;
      })
      .on("dragmove", (e) => {
        e.preventDefault();
        let dy = e.detail.p.y - y;
        y = e.detail.p.y;
        mainSvg.fire("row-resize", { object: this, y: dy });
      });
  }

  /**
   * Removes all elements related to this Row from the main SVG document
   * @return {*}
   */
  remove() {
    return this.svg.remove();
  }

  /**
   * Changes the y-position of this Row's upper bound by the given amount
   * @param y
   */
  dy(y) {
    this.ry += y;
    this.svg.transform({ y: this.baseline });
  }

  /**
   * Moves this Row's upper bound vertically to the given y-position
   * @param y
   */
  move(y) {
    this.ry = y;
    this.svg.transform({ y: this.baseline });
  }

  /**
   * Sets the height of this Row
   * @param rh
   */
  height(rh) {
    this.rh = rh;
    this.svg.transform({ y: this.baseline });
  }

  /**
   * Sets the width of this Row
   * @param rw
   */
  width(rw) {
    this.rw = rw;
    this.draggable.attr("x2", this.rw);
  }

  /**
   * Adds the given Word to this Row at the given index, adjusting the
   * x-positions of any Words with higher indices.
   * Optionally, attempts to force an x-position for the Word.
   * If adding the Word to the Row causes any existing Words to overflow its
   * bounds, will return the index of the first Word that no longer fits.
   * @param word
   * @param index
   * @param forceX
   * @return {number} - The index of the first Word that no longer fits, if
   *     the additional Word causes overflow
   */
  addWord(word, index, forceX) {
    if (isNaN(index)) {
      index = this.words.length;
    }

    word.row = this;
    this.words.splice(index, 0, word);
    this.wordGroup.add(word.svg);

    // Determine the new x-position this Word should have.
    word.x = -1;
    let newX;
    if (index === 0) {
      newX = this.config.rowEdgePadding;
    } else {
      const prevWord = this.words[index - 1];
      newX = prevWord.x + prevWord.minWidth;
      if (word.isPunct) {
        newX += this.config.wordPunctPadding;
      } else {
        newX += this.config.wordPadding;
      }
    }

    if (forceX) {
      newX = forceX;
    }

    return this.positionWord(word, newX);
  }

  /**
   * Assumes that the given Word is already on this Row.
   * Tries to move the Word to the given x-position, adjusting the
   * x-positions of all the following Words on the Row as well.
   * If this ends up pushing some Words off the Row, returns the index of
   * the first Word that no longer fits.
   * @param word
   * @param newX
   * @return {number} - The index of the first Word that no longer fits, if
   *     the additional Word causes overflow
   */
  positionWord(word, newX) {
    const wordIndex = this.words.indexOf(word);
    const prevWord = this.words[wordIndex - 1];
    const nextWord = this.words[wordIndex + 1];

    // By default, assume that no Words have overflowed the Row
    let overflowIndex = this.words.length;

    // Make sure we aren't stomping over a previous Word
    if (prevWord) {
      const wordPadding = word.isPunct
        ? this.config.wordPunctPadding
        : this.config.wordPadding;

      if (newX < prevWord.x + prevWord.minWidth + wordPadding) {
        throw `Trying to position new Word over existing one!
        (Row: ${this.idx}, wordIndex: ${wordIndex})`;
      }
    }

    // Change the position of the next Word if we have to;
    if (nextWord) {
      const nextWordPadding = nextWord.isPunct
        ? this.config.wordPunctPadding
        : this.config.wordPadding;

      if (nextWord.x - nextWordPadding < newX + word.minWidth) {
        overflowIndex = this.positionWord(
          nextWord,
          newX + word.minWidth + nextWordPadding
        );
      }
    }

    // We have moved the next Word on the Row, or marked it as part of the
    // overflow; at this point, we either have space to move this Word, or
    // this Word itself is about to overflow the Row.
    if (newX + word.minWidth > this.rw - this.config.rowEdgePadding) {
      // Alas.  The overflowIndex is ours.
      return wordIndex;
    } else {
      // We can move.  If any of the Words that follow us overflowed, return
      // their index.
      word.move(newX);
      return overflowIndex;
    }
  }

  /**
   * Removes the specified Word from this Row, returning it for potential
   * further operations.
   * @param word
   * @return {Word}
   */
  removeWord(word) {
    if (this.lastRemovedWord !== word) {
      this.lastRemovedWord = word;
    }
    this.words.splice(this.words.indexOf(word), 1);
    this.wordGroup.removeElement(word.svg);
    return word;
  }

  /**
   * Removes the last Word from this Row, returning it for potential
   * further operations.
   * @return {Word}
   */
  removeLastWord() {
    return this.removeWord(this.words[this.words.length - 1]);
  }

  /**
   * Redraws all the unique Links and WordClusters associated with all the
   * Words in the row
   */
  redrawLinksAndClusters() {
    const elements = [];
    for (const word of this.words) {
      for (const link of word.passingLinks) {
        if (elements.indexOf(link) < 0) {
          elements.push(link);
        }
      }
      for (const cluster of word.clusters) {
        if (elements.indexOf(cluster) < 0) {
          elements.push(cluster);
        }
      }
    }
    elements.forEach((element) => element.draw());
  }

  /**
   * Gets the y-position of the Row's baseline (where the draggable resize
   * line is, and the baseline for all the Row's words)
   */
  get baseline() {
    return this.ry + this.rh;
  }

  /**
   * Returns the lower bound of the Row on the y-axis
   * @return {number}
   */
  get ry2() {
    return this.ry + this.rh + this.minDescent;
  }

  /**
   * Returns the maximum slot occupied by Links related to Words on this Row.
   * Considers positive slots, so only accounts for top Links.
   */
  get maxSlot() {
    let checkWords = this.words;
    if (checkWords.length === 0 && this.lastRemovedWord !== null) {
      // We let all our Words go; what was the last one that mattered?
      checkWords = [this.lastRemovedWord];
    }

    let maxSlot = 0;
    for (const word of checkWords) {
      for (const link of word.passingLinks) {
        maxSlot = Math.max(maxSlot, link.slot);
      }
    }
    return maxSlot;
  }

  /**
   * Returns the minimum slot occupied by Links related to Words on this Row.
   * Considers negative slots, so only accounts for bottom Links.
   */
  get minSlot() {
    let checkWords = this.words;
    if (checkWords.length === 0 && this.lastRemovedWord !== null) {
      // We let all our Words go; what was the last one that mattered?
      checkWords = [this.lastRemovedWord];
    }

    let minSlot = 0;
    for (const word of checkWords) {
      for (const link of word.passingLinks) {
        minSlot = Math.min(minSlot, link.slot);
      }
    }
    return minSlot;
  }

  /**
   * Returns the maximum height above the baseline of the Word
   * elements on the Row (accounting for their top WordTags and attached
   * WordClusters, if present)
   */
  get wordHeight() {
    let wordHeight = 0;
    for (const word of this.words) {
      wordHeight = Math.max(wordHeight, word.boxHeight);

      if (word.clusters.length > 0) {
        for (const cluster of word.clusters) {
          wordHeight = Math.max(wordHeight, cluster.fullHeight);
        }
      }
    }
    if (wordHeight === 0 && this.lastRemovedWord) {
      // If we have no Words left on this Row, base our calculations on the
      // last Word that was on this Row, for positioning any Links that are
      // still passing through
      wordHeight = this.lastRemovedWord.boxHeight;
      if (this.lastRemovedWord.clusters.length > 0) {
        for (const cluster of this.lastRemovedWord.clusters) {
          wordHeight = Math.max(wordHeight, cluster.fullHeight);
        }
      }
    }
    return wordHeight;
  }

  /**
   * Returns the maximum descent below the baseline of the Word
   * elements on the Row (accounting for their bottom WordTags, if present)
   */
  get wordDescent() {
    let wordDescent = 0;
    for (const word of this.words) {
      wordDescent = Math.max(wordDescent, word.descendHeight);
    }
    return wordDescent;
  }

  /**
   * Returns the minimum amount of height above the baseline needed to fit
   * all this Row's Words, top WordTags and currently-visible top Links.
   * Includes vertical Row padding.
   * @return {number}
   */
  get minHeight() {
    // Minimum height needed for Words + padding only
    let height = this.wordHeight + this.config.rowVerticalPadding;

    // Highest visible top Link
    let maxVisibleSlot = 0;

    let checkWords = this.words;
    if (checkWords.length === 0 && this.lastRemovedWord !== null) {
      // We let all our Words go; what was the last one that mattered?
      checkWords = [this.lastRemovedWord];
    }

    for (const word of checkWords) {
      for (const link of word.links.concat(word.passingLinks)) {
        if (link.top && link.visible) {
          maxVisibleSlot = Math.max(maxVisibleSlot, link.slot);
        }
      }
    }

    // Because top Link labels are above the Link lines, we need to add
    // their height if any of the Words on this Row is an endpoint for a Link
    if (maxVisibleSlot > 0) {
      return (
        height +
        maxVisibleSlot * this.config.linkSlotInterval +
        this.config.rowExtraTopPadding
      );
    }

    // Still here?  No visible top Links on this row.
    return height;
  }

  /**
   * Returns the minimum amount of descent below the baseline needed to fit
   * all this Row's bottom WordTags and currently-visible bottom Links.
   * Includes vertical Row padding.
   * @return {number}
   */
  get minDescent() {
    // Minimum height needed for WordTags + padding only
    let descent = this.wordDescent + this.config.rowVerticalPadding;

    // Lowest visible bottom Link
    let minVisibleSlot = 0;

    let checkWords = this.words;
    if (checkWords.length === 0 && this.lastRemovedWord !== null) {
      // We let all our Words go; what was the last one that mattered?
      checkWords = [this.lastRemovedWord];
    }

    for (const word of checkWords) {
      for (const link of word.links.concat(word.passingLinks)) {
        if (!link.top && link.visible) {
          minVisibleSlot = Math.min(minVisibleSlot, link.slot);
        }
      }
    }

    // Unlike in the `minHeight()` function, bottom Link labels do not
    // extend below the Link lines, so we don't need to add extra padding
    // for them.
    if (minVisibleSlot < 0) {
      return descent + Math.abs(minVisibleSlot) * this.config.linkSlotInterval;
    }

    // Still here?  No visible bottom Links on this row.
    return descent;
  }

  /**
   * Returns the amount of space available at the end of this Row for adding
   * new Words
   */
  get availableSpace() {
    if (this.words.length === 0) {
      return this.rw - this.config.rowEdgePadding * 2;
    }

    const lastWord = this.words[this.words.length - 1];
    return (
      this.rw - this.config.rowEdgePadding - lastWord.x - lastWord.minWidth
    );
  }

  // =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
  // 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 });
  }
}

export default Row;