main.js

/**
 * Main library class
 */

import _ from "lodash";
import $ from "jquery";
import * as SVG from "svg.js";

import RowManager from "./managers/rowmanager";
import LabelManager from "./managers/labelmanager";
import Taxonomy from "./managers/taxonomy";

import Config from "./config";

import Util from "./util";

import Word from "./components/word";
import WordCluster from "./components/word-cluster";
import Link from "./components/link";

/**
 * Take a small performance hit from `autobind` to ensure that the scope of
 * `this` is always correct for all our API methods
 */
import autobind from "autobind-decorator";

@autobind
class Main {
  /**
   * Initialises a TAG instance with the given parameters
   * @param {String|Element|jQuery} container - Either a string containing the
   *     ID of the container element, or the element itself (as a
   *     native/jQuery object)
   * @param {Object} options - Overrides for default library options
   * @param {Object} parsers - Registered parsers for various annotation formats
   */
  constructor(container, options = {}, parsers = {}) {
    // Config options
    this.config = _.defaults(options, new Config());

    // SVG.Doc expects either a string with the element's ID, or the element
    // itself (not a jQuery object).
    if (_.hasIn(container, "jquery")) {
      container = container[0];
    }

    this.svg = new SVG.Doc(container);

    // That said, we need to set the SVG Doc's size using absolute units
    // (since they are used for calculating the widths of rows and other
    // elements).  We use jQuery to get the parent's size.
    this.$container = $(this.svg.node).parent();

    // Managers/Components
    this.rowManager = new RowManager(this.svg, this.config);
    this.labelManager = new LabelManager(this.svg);
    this.taxonomyManager = new Taxonomy(this.config);

    // Registered Parsers
    this.parsers = parsers;

    // Tokens and links that are currently drawn on the visualisation
    this.words = [];
    this.links = [];

    // Initialisation
    this.resize();
    this._setupSVGListeners();
    this._setupUIListeners();
  }

  // =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
  // Loading data into the parser

  /**
   * Loads the given annotation data onto the TAG visualisation
   * @param {Array} dataObjects - The raw annotation data object(s) to load
   * @param {String} format - One of the supported format identifiers for
   *     the data
   */
  loadData(dataObjects, format) {
    // 1) Remove any currently-loaded data
    // 2) Parse the new data
    // 3) Hand-off the parsed data to the SVG initialisation procedure

    if (!_.has(this.parsers, format)) {
      throw `No parser registered for annotation format: ${format}`;
    }

    this.clear();
    const parsedData = this.parsers[format].parse(dataObjects);
    this.init(parsedData);
    this.draw();
  }

  /**
   * Reads the given data file asynchronously and loads it onto the TAG
   * visualisation
   * @param {Object} path - The path pointing to the data
   * @param {String} format - One of the supported format identifiers for
   *     the data
   */
  async loadUrlAsync(path, format) {
    const data = await $.ajax(path);
    this.loadData([data], format);
  }

  /**
   * Reads the given annotation files and loads them onto the TAG
   * visualisation
   * @param {FileList} fileList - We generally expect only one file here, but
   *     some formats (e.g., Brat) involve multiple files per dataset
   * @param {String} format
   */
  async loadFilesAsync(fileList, format) {
    // Instantiate FileReaders for all the given files, and wait until they
    // are read
    const readPromises = _.map(fileList, (file) => {
      const reader = new FileReader();
      reader.readAsText(file);
      return new Promise((resolve) => {
        reader.onload = () => {
          resolve({
            name: file.name,
            type: file.type,
            content: reader.result
          });
        };
      });
    });

    const files = await Promise.all(readPromises);
    this.loadData(files, format);
  }

  // =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
  // Controlling the SVG element

  /**
   * Prepares all the Rows/Words/Links.
   * Adds all Words/WordClusters to Rows in the visualisation, but does not draw
   * Links or colour the various Words/WordTags
   * @param {Object} parsedData
   */
  init(parsedData) {
    // Convert the parsed data into visualisation objects (by adding
    // SVG/visualisation-related data and methods)
    // TODO: Refactor the Word/WordTag/WordCluster/Link system instead of
    //  patching it here.

    // Tokens -> Words
    // Labels -> WordTags
    // Records LongLabels to convert later.
    this.words = [];
    const longLabels = [];
    parsedData.tokens.forEach((token) => {
      // Basic
      const word = new Word(token.text, token.idx);
      this.words.push(word);

      _.forOwn(token.registeredLabels, (label, category) => {
        if (_.has(label, "token")) {
          // Label
          word.registerTag(category, label.val);
        } else if (_.has(label, "tokens")) {
          // LongLabel
          if (longLabels.indexOf(label) < 0) {
            longLabels.push(label);
          }
        }
      });
    });

    // LongLabels -> WordClusters
    // (via back-references)
    // N.B.: Assumes that the `.idx` property of each Word is equal to its
    // index in `this.words`.
    longLabels.forEach((longLabel) => {
      const labelWords = [];
      for (let x = 0; x < longLabel.tokens.length; x++) {
        const wordIdx = longLabel.tokens[x].idx;
        labelWords.push(this.words[wordIdx]);
      }
      new WordCluster(labelWords, longLabel.val);
    });

    // Links
    // Arguments might be Tokens or (Parser) Links; convert them to Words
    // and Links.
    // N.B.: Assumes that nested Links are parsed earlier in the array.
    this.links = [];
    const linksById = {};
    parsedData.links.forEach((link) => {
      let newTrigger = null;
      const newArgs = [];

      if (link.trigger) {
        // Assume the trigger is a Token
        newTrigger = this.words[link.trigger.idx];
      }

      // noinspection JSAnnotator
      link.arguments.forEach((arg) => {
        if (arg.anchor.type === "Token") {
          newArgs.push({
            anchor: this.words[arg.anchor.idx],
            type: arg.type
          });
        } else if (arg.anchor.type === "Link") {
          newArgs.push({
            anchor: linksById[arg.anchor.eventId],
            type: arg.type
          });
        }
      });

      const newLink = new Link(
        link.eventId,
        newTrigger,
        newArgs,
        link.relType,
        link.category === "default",
        link.category
      );

      this.links.push(newLink);
      linksById[newLink.eventId] = newLink;
    });

    // Calculate the Link slots (vertical intervals to separate
    // crossing/intervening Links).
    // Because the order of the Links array affects the slot calculations,
    // we sort it here first in case they aren't sorted in the original
    // annotation data.
    this.links = Util.sortForSlotting(this.links);
    this.links.forEach((link) => link.calculateSlot(this.words));

    // Initialise the first Row; new ones will be added automatically as
    // Words are drawn onto the visualisation
    if (this.words.length > 0 && !this.rowManager.lastRow) {
      this.rowManager.appendRow();
    }

    // Draw the Words onto the visualisation
    this.words.forEach((word) => {
      // If the tag categories to show for the Word are already set (via the
      // default config or user options), set them here so that the Word can
      // draw them directly on init
      word.setTopTagCategory(this.config.topTagCategory);
      word.setBottomTagCategory(this.config.bottomTagCategory);
      word.init(this);
      this.rowManager.addWordToRow(word, this.rowManager.lastRow);
    });

    // We have to initialise all the Links before we draw any of them, to
    // account for nested Links etc.
    this.links.forEach((link) => {
      link.init(this);
    });
  }

  /**
   * Resizes Rows and (re-)draws Links and WordClusters, without changing
   * the positions of Words/Link handles
   */
  draw() {
    // Draw in the currently toggled Links
    this.links.forEach((link) => {
      if (
        (link.top && link.category === this.config.topLinkCategory) ||
        (!link.top && link.category === this.config.bottomLinkCategory)
      ) {
        link.show();
      }

      if (
        (link.top && this.config.showTopMainLabel) ||
        (!link.top && this.config.showBottomMainLabel)
      ) {
        link.showMainLabel();
      } else {
        link.hideMainLabel();
      }

      if (
        (link.top && this.config.showTopArgLabels) ||
        (!link.top && this.config.showBottomArgLabels)
      ) {
        link.showArgLabels();
      } else {
        link.hideArgLabels();
      }
    });

    // Now that Links are visible, make sure that all Rows have enough space
    this.rowManager.resizeAll();

    // And change the Row resize cursor if compact mode is on
    this.rowManager.rows.forEach((row) => {
      this.config.compactRows
        ? row.draggable.addClass("row-drag-compact")
        : row.draggable.removeClass("row-drag-compact");
    });

    // Change token colours based on the current taxonomy, if loaded
    this.taxonomyManager.colour(this.words);
  }

  /**
   * Removes all elements from the visualisation
   */
  clear() {
    // Removing Rows takes care of Words and WordTags
    while (this.rowManager.rows.length > 0) {
      this.rowManager.removeLastRow();
    }
    // Links and Clusters are drawn directly on the main SVG document
    this.links.forEach((link) => link.svg && link.svg.remove());
    this.words.forEach((word) => {
      word.clusters.forEach((cluster) => cluster.remove());
    });
    // Reset colours
    this.taxonomyManager.resetDefaultColours();
  }

  /**
   * Fits the SVG element and its children to the size of its container
   */
  resize() {
    this.svg.size(this.$container.innerWidth(), this.$container.innerHeight());
    this.rowManager.resizeAll();
  }

  // =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
  // Controlling taxonomic information and associated colours

  /**
   * Loads a new taxonomy specification (in YAML form) into the module
   * @param {String} taxonomy - A YAML string representing the taxonomy object
   */
  loadTaxonomyYaml(taxonomy) {
    return this.taxonomyManager.loadTaxonomyYaml(taxonomy);
  }

  /**
   * Returns a YAML representation of the currently loaded taxonomy
   */
  getTaxonomyYaml() {
    return this.taxonomyManager.getTaxonomyYaml();
  }

  /**
   * Returns the currently loaded taxonomy as an Array.
   * Simple labels are stored as Strings in Arrays, and category labels are
   * stored as single-key objects.
   *
   * E.g., a YAML document like the following:
   *
   *  - Label A
   *  - Category 1:
   *    - Label B
   *    - Label C
   *  - Label D
   *
   * Parses to the following taxonomy object:
   *
   *  [
   *    "Label A",
   *    {
   *      "Category 1": [
   *        "Label B",
   *        "Label C"
   *      ]
   *    },
   *    "Label D"
   *  ]
   *
   * @return {Array}
   */
  getTaxonomyTree() {
    return this.taxonomyManager.getTaxonomyTree();
  }

  /**
   * Given some label (either for a WordTag or WordCluster), return the
   * colour that the taxonomy manager has assigned to it
   * @param label
   */
  getColour(label) {
    return this.taxonomyManager.getColour(label);
  }

  /**
   * Sets the colour for some label (either for a WordTag or WordCluster)
   * and redraws the visualisation
   * @param label
   * @param colour
   */
  setColour(label, colour) {
    this.taxonomyManager.assignColour(label, colour);
    this.taxonomyManager.colour(this.words);
  }

  // =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
  // Higher-level API functions

  /**
   * Exports the current visualisation as an SVG file
   */
  exportSvg() {
    // Get the raw SVG definition
    let exportedSVG = this.svg.svg();

    // We also need to inline a copy of the relevant SVG styles, which might
    // have been modified/overwritten by the user
    const svgRules = Util.getCssRules(
      this.$container.find(".tag-element").toArray()
    );

    const i = exportedSVG.indexOf("</defs>");
    exportedSVG =
      exportedSVG.slice(0, i) +
      "<style>" +
      svgRules.join("\n") +
      "</style>" +
      exportedSVG.slice(i);

    // Create a virtual download link and simulate a click on it (using the
    // native `.click()` method, since jQuery cannot `.trigger()` it
    $(`<a 
      href="data:image/svg+xml;charset=utf-8,${encodeURIComponent(exportedSVG)}"
      download="tag.svg"></a>`)
      .appendTo($("body"))[0]
      .click();
  }

  /**
   * Changes the value of the given option setting
   * (Redraw to see changes)
   * @param {String} option
   * @param value
   */
  setOption(option, value) {
    this.config[option] = value;
  }

  /**
   * Gets the current value for the given option setting
   * @param {String} option
   */
  getOption(option) {
    return this.config[option];
  }

  /**
   * Returns an Array of all the categories available for the top Links
   * (Generally, event/relation annotations)
   */
  getTopLinkCategories() {
    const categories = this.links
      .filter((link) => link.top)
      .map((link) => link.category);

    return _.uniq(categories);
  }

  /**
   * Shows the specified category of top Links, hiding the others
   * @param category
   */
  setTopLinkCategory(category) {
    this.setOption("topLinkCategory", category);
    this.links
      .filter((link) => link.top)
      .forEach((link) => {
        if (link.category === category) {
          link.show();
        } else {
          link.hide();
        }
      });

    // Always resize when the set of visible Links may have changed
    this.rowManager.resizeAll();
  }

  /**
   * Returns an Array of all the categories available for the bottom Links
   * (Generally, syntactic/dependency parses)
   */
  getBottomLinkCategories() {
    const categories = this.links
      .filter((link) => !link.top)
      .map((link) => link.category);

    return _.uniq(categories);
  }

  /**
   * Shows the specified category of bottom Links, hiding the others
   * @param category
   */
  setBottomLinkCategory(category) {
    this.setOption("bottomLinkCategory", category);
    this.links
      .filter((link) => !link.top)
      .forEach((link) => {
        if (link.category === category) {
          link.show();
        } else {
          link.hide();
        }
      });

    // Always resize when the set of visible Links may have changed
    this.rowManager.resizeAll();
  }

  /**
   * Returns an Array of all the categories available for top Word tags
   * (Generally, text-bound mentions)
   */
  getTagCategories() {
    const categories = this.words.flatMap((word) => word.getTagCategories());
    return _.uniq(categories);
  }

  /**
   * Shows the specified category of top Word tags
   * @param category
   */
  setTopTagCategory(category) {
    this.setOption("topTagCategory", category);
    this.words.forEach((word) => {
      word.setTopTagCategory(category);
      word.passingLinks.forEach((link) => link.draw());
    });

    // (Re-)colour the labels
    this.taxonomyManager.colour(this.words);

    // Always resize when the set of visible Links may have changed
    this.rowManager.resizeAll();
  }

  /**
   * Shows the specified category of bottom Word tags
   * @param category
   */
  setBottomTagCategory(category) {
    this.setOption("bottomTagCategory", category);
    this.words.forEach((word) => {
      word.setBottomTagCategory(category);
      word.passingLinks.forEach((link) => link.draw());
    });

    // Always resize when the set of visible Links may have changed
    this.rowManager.resizeAll();
  }

  /**
   * Shows/hides the main label on top Links
   * @param {Boolean} visible - Show if true, hide if false
   */
  setTopMainLabelVisibility(visible) {
    this.setOption("showTopMainLabel", visible);
    if (visible) {
      this.links
        .filter((link) => link.top)
        .forEach((link) => link.showMainLabel());
    } else {
      this.links
        .filter((link) => link.top)
        .forEach((link) => link.hideMainLabel());
    }
  }

  /**
   * Shows/hides the argument labels on top Links
   * @param {Boolean} visible - Show if true, hide if false
   */
  setTopArgLabelVisibility(visible) {
    this.setOption("showTopArgLabels", visible);
    if (visible) {
      this.links
        .filter((link) => link.top)
        .forEach((link) => link.showArgLabels());
    } else {
      this.links
        .filter((link) => link.top)
        .forEach((link) => link.hideArgLabels());
    }
  }

  /**
   * Shows/hides the main label on bottom Links
   * @param {Boolean} visible - Show if true, hide if false
   */
  setBottomMainLabelVisibility(visible) {
    this.setOption("showBottomMainLabel", visible);
    if (visible) {
      this.links
        .filter((link) => !link.top)
        .forEach((link) => link.showMainLabel());
    } else {
      this.links
        .filter((link) => !link.top)
        .forEach((link) => link.hideMainLabel());
    }
  }

  /**
   * Shows/hides the argument labels on bottom Links
   * @param {Boolean} visible - Show if true, hide if false
   */
  setBottomArgLabelVisibility(visible) {
    this.setOption("showBottomArgLabels", visible);
    if (visible) {
      this.links
        .filter((link) => !link.top)
        .forEach((link) => link.showArgLabels());
    } else {
      this.links
        .filter((link) => !link.top)
        .forEach((link) => link.hideArgLabels());
    }
  }

  // =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
  // Private helper/setup functions

  /**
   * Sets up listeners for custom SVG.js events
   * N.B.: Event listeners will change the execution context by default, so
   * either provide a closure to the main library instance or use arrow
   * functions to preserve the original context
   * cf. http://es6-features.org/#Lexicalthis
   * @private
   */
  _setupSVGListeners() {
    this.svg.on("row-resize", (event) => {
      this.labelManager.stopEditing();
      this.rowManager.resizeRow(event.detail.object.idx, event.detail.y);
    });

    // svg.on('label-updated', function(e) {
    //   // TODO: so so incomplete
    //   let color = tm.getColor(e.detail.label, e.detail.object);
    //   e.detail.object.node.style.fill = color;
    // });

    this.svg.on("word-move-start", () => {
      this.links.forEach((link) => {
        if (
          (link.top && !this.config.showTopLinksOnMove) ||
          (!link.top && !this.config.showBottomLinksOnMove)
        ) {
          link.hide();
        }
      });
    });

    this.svg.on("word-move", (event) => {
      // tooltip.clear();
      this.labelManager.stopEditing();
      this.rowManager.moveWordOnRow(event.detail.object, event.detail.x);
    });

    this.svg.on("word-move-end", () => {
      this.links.forEach((link) => {
        if (
          (link.top && link.category === this.config.topLinkCategory) ||
          (!link.top && link.category === this.config.bottomLinkCategory)
        ) {
          link.show();
        }
      });
    });

    // this.svg.on("tag-remove", (event) => {
    //   event.detail.object.remove();
    //   this.taxonomyManager.remove(event.detail.object);
    // });

    // this.svg.on("row-recalculate-slots", () => {
    //   this.links.forEach(link => {
    //     link.slot = null;
    //   });
    //   this.links = Util.sortForSlotting(this.links);
    //   this.links.forEach(link => link.calculateSlot(this.words));
    //   this.links.forEach(link => link.draw());
    // });

    // ZW: Hardcoded dependencies on full UI
    // this.svg.on("build-tree", (event) => {
    //   document.body.classList.remove("tree-closed");
    //   if (tree.isInModal) {
    //     setActiveTab("tree");
    //   }
    //   else {
    //     setActiveTab(null);
    //   }
    //   if (e.detail) {
    //     tree.graph(e.detail.object);
    //   }
    //   else {
    //     tree.resize();
    //   }
    // });
  }

  /**
   * Sets up listeners for general browser events
   * @private
   */
  _setupUIListeners() {
    // Browser window resize
    $(window).on(
      "resize",
      _.throttle(() => {
        this.resize();
      }, 50)
    );
  }

  // =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=
  // Debug functions
  xLine(x) {
    this.svg.line(x, 0, x, 1000).stroke({ width: 1 });
  }

  yLine(y) {
    this.svg.line(0, y, 1000, y).stroke({ width: 1 });
  }
}

export default Main;