Source: biset.js

/**
 * Biset visualization for relational data.
 */
class Biset {

  /**
   * Creates a new Biset visualization.
   * @param root SVG element in which the visualization should be rendered
   */
  constructor(root) {
    this._root = root;
    this.zoomRange = [0.5, 1];
    this.margin = 40;
    this.cornerRadius = 4;
    this.domainWidth = 200;
    this.relationWidth = 200;
    this.entityHeight = 40;
    this.entitySpacing = 1;
    this.indicatorWidth = 40;
    this.bundleHeight = 48;
    this.bundleSpacing = 8;
    this.sortMode = 'frequency';
    this.edgeMode = 'edges';
    this.minBundleSources = 2;
    this.minBundleTargets = 2;
    this.minBundleSize = 0;

    this._space = this._drawSpace(this._root, this._margin, this._zoomRange);
  }

  /**
   * Set the visualization data in the format returned by d3's parse functions.
   * @param value csv
   */
  set data(value) {
    this._data = transform(value);
  }

  /**
   * Set the minimum and maximum scale for zooming as a pair of values.
   * @param value {Array}
   */
  set zoomRange(value) {
    this._zoomRange = value;
  }

  /**
   * Set the initial offset of the visualization from the top left corner.
   * @param value {int}
   */
  set margin(value) {
    this._margin = value;
  }

  /**
   * Set the corner radius for entities and bundles.
   * @param value {int}
   */
  set cornerRadius(value) {
    this._cornerRadius = value;
  }

  /**
   * Set the width of the domain columns.
   * @param value {int}
   */
  set domainWidth(value) {
    this._domainWidth = value;
  }

  /**
   * Set the width of the area between domain columns.
   * @param value {int}
   */
  set relationWidth(value) {
    this._relationWidth = value;
  }

  /**
   * Set the height of entities.
   * @param value {int}
   */
  set entityHeight(value) {
    this._entityHeight = value;
  }

  /**
   * Set the height removed from the bottom of each entity.
   * @param value {int}
   */
  set entitySpacing(value) {
    this._entitySpacing = value;
  }

  /**
   * Set the width taken up by the entity frequency indicator.
   * @param value {int}
   */
  set indicatorWidth(value) {
    this._indicatorWidth = value;
  }

  /**
   * Set the height of bundles.
   * @param value {int}
   */
  set bundleHeight(value) {
    this._bundleHeight = value;
  }

  /**
   * Set the height removed from the bottom of each bundle.
   * @param value {int}
   */
  set bundleSpacing(value) {
    this._bundleSpacing = value;
  }

  /**
   * Set the sort mode which is 'frequency', 'label', or 'priority'.
   * @param value {string}
   */
  set sortMode(value) {
    this._sortMode = value;
  }

  /**
   * Set the edge display mode which is 'edges', 'bundles', or 'hybrid'.
   * @param value {string}
   */
  set edgeMode(value) {
    this._edgeMode = value;
  }

  /**
   * Set the minimum number of entities on the left side of a bundle.
   * @param value {int}
   */
  set minBundleSources(value) {
    this._minBundleSources = value;
  }

  /**
   * Set the minimum number of entities on the right side of a bundle.
   * @param value {int}
   */
  set minBundleTargets(value) {
    this._minBundleTargets = value;
  }

  /**
   * Set the minimum bundle size.
   * @param value {int}
   */
  set minBundleSize(value) {
    this._minBundleSize = value;
  }

  /**
   * Updates the whole visualization.
   */
  draw() {
    let data = this._data;

    // Clear space
    this._space.html("");

    let maxEntityFrequency = d3.max(this._data.entities, e => e.frequency);
    this._indicatorUnit = this._indicatorWidth / maxEntityFrequency;

    // Apply dynamic data transformations.
    this._sortEntities(data.domains, this._sortMode);
    this._layoutBundles(data.relations, this._minBundleSources,
      this._minBundleTargets, this._minBundleSize);
    this._hideLinks(data.relations, this._edgeMode);
    this._updateSelection();

    // Draw the actual visualization.
    let space = this._space;
    this._drawRelations(space, data.relations);
    this._drawDomains(space, data.domains);
  }

  /**
   * Deselects all selected entities and bundles.
   */
  clearSelection() {
    this._data.bundles.forEach(b => b.selected = false);
    this._data.entities.forEach(b => b.selected = false);
    this._updateSelection();
  }

  _selectLinksByEntityId(id) {
    return this._root.selectAll(`.link[data-source="${id}"], .link[data-target="${id}"]`);
  }

  _selectLinksByBundleId(id) {
    return this._root.selectAll(`.link[data-bundle="${id}"]`);
  }

  _handleEntityMouseOver(self) {
    return function () {
      let node = d3.select(this.parentNode);
      node.classed("highlighted", true);
      let id = node.attr("data-id");
      self._selectLinksByEntityId(id)
        .classed("highlighted", true);
    };
  }

  _handleEntityMouseOut(self) {
    return function () {
      let node = d3.select(this.parentNode);
      node.classed("highlighted", false);
      let id = node.attr("data-id");
      self._selectLinksByEntityId(id)
        .classed("highlighted", false);
    };
  }

  _handleBundleMouseOver(self) {
    return function () {
      let node = d3.select(this.parentNode);
      node.classed("highlighted", true);
      let id = node.attr("data-id");
      self._selectLinksByBundleId(id)
        .classed("highlighted", true);
    };
  }

  _handleBundleMouseOut(self) {
    return function () {
      let node = d3.select(this.parentNode);
      node.classed("highlighted", false);
      let id = node.attr("data-id");
      self._selectLinksByBundleId(id)
        .classed("highlighted", false);
    };
  }

  _handleEntityClick(self) {
    return function () {
      let node = d3.select(this.parentNode);
      let id = node.attr("data-id");
      let entity = self._data.entities.filter(e => e.id == id)[0];
      entity.selected = !entity.selected;
      self._updateSelection();
    };
  }

  _handleBundleClick(self) {
    return function () {
      let node = d3.select(this.parentNode);
      let id = node.attr("data-id");
      let bundle = self._data.bundles.filter(b => b.id == id)[0];
      bundle.selected = !bundle.selected;
      self._updateSelection();
    };
  }

  _sortEntities(domains, mode) {
    let labelSort = (a, b) => a.label.localeCompare(b.label);

    let frequencySort = (a, b) => {
      let sort = b.frequency - a.frequency;
      // Fallback to alphabetic sort for entities with equal frequency.
      return sort != 0 ? sort : labelSort(a, b);
    };

    // For priority sort we apply the greedy algorithm from the paper.
    // This algorithm computes a 'rank' attribute for every entity.
    this._data.entities.forEach(e => e.rank = undefined);
    this._data.bundles
      .filter(b => b.visible)
      .sort((a, b) => {
        let s = b.size - a.size;
        if (s !== 0) return s;
        let aFreq = d3.sum(a.sources.concat(a.targets), e => e.frequency);
        let bFreq = d3.sum(b.sources.concat(b.targets), e => e.frequency);
        s = bFreq - aFreq;
        if (s !== 0) return s;
        return labelSort(a.sources[0], b.sources[0]);
      })
      .map((b, i) => {
        b.rank = i;
        return b;
      })
      .forEach(bundle => {
        bundle.sources.concat(bundle.targets).forEach(entity => {
          if (entity.rank == undefined) {
            let bundles = entity.bundles.filter(b => b.visible);
            let totalRank = d3.sum(bundles, b => b.rank);
            entity.rank = totalRank / bundles.length;
          }
        });
      });

    // Ensure all entities have a defined rank.
    this._data.entities.forEach(e => {
      if (e.rank === undefined) e.rank = 0;
    });

    let prioritySort = (a, b) => {
      let s = b.rank - a.rank;
      if (s != 0) return s;
      return frequencySort(a, b);
    };

    let sort = frequencySort;
    switch (mode) {
      case 'label':
        sort = labelSort;
        break;
      case 'priority':
        sort = prioritySort;
        break;
      case 'frequency':
        sort = frequencySort;
        break;
      default:
        throw new Error("Unknown sort mode.");
    }

    domains.forEach(domain => {
      domain.entities.sort(sort).forEach((entity, i) => {
        entity.position = i;
      });
    });
  }

  _layoutBundles(relations, minSources, minTargets, minSize) {
    relations.forEach(relation => {
      relation.bundles.forEach(bundle => {
        // Bundle position is average of connected entity positions.
        let entities = bundle.sources.concat(bundle.targets);
        bundle.position = d3.mean(entities, e => e.position);

        // Filter bundles.
        let hasMinSources = bundle.sources.length >= minSources;
        let hasMinTargets = bundle.targets.length >= minTargets;
        let hasMinSize = entities.length >= minSize;
        let bundlesEnabled = this._edgeMode == 'bundles' || this._edgeMode == 'hybrid';
        bundle.visible = bundlesEnabled && hasMinSources && hasMinTargets && hasMinSize;
      });

      let visibleBundles = relation.bundles
        .filter(b => b.visible)
        .sort((a, b) => a.position - b.position);

      let oldCenter = d3.mean(visibleBundles, b => b.position);

      // Move bundles downwards if they overlap with the one before them.
      let spacing = this._bundleHeight / this._entityHeight;
      for (let i = 1; i < visibleBundles.length; i++) {
        let prev = visibleBundles[i - 1];
        let curr = visibleBundles[i];
        curr.position = Math.max(prev.position + spacing, curr.position);
      }

      // Move all bundles so that the center of gravity is the same as before.
      let newCenter = d3.mean(visibleBundles, b => b.position);
      let correction = oldCenter - newCenter;
      visibleBundles.forEach(b => b.position += correction);
    });
  }

  _hideLinks(relations, edgeMode) {
    relations.forEach(relation => {
      let visibleBundles = relation.bundles.filter(b => b.visible);
      relation.links.forEach(link => {
        link.visible = edgeMode == 'edges' || edgeMode == 'hybrid';

        // In hybrid mode we hide all links that are expressed by a bundle.
        if (edgeMode == 'hybrid') {
          visibleBundles.forEach(bundle => {
            let containsSource = bundle.sources.indexOf(link.source) != -1;
            let containsTarget = bundle.targets.indexOf(link.target) != -1;
            if (containsSource && containsTarget) {
              link.visible = false;
            }
          });
        }
      });
    });
  }

  _updateSelection() {
    let data = this._data;
    let applyFocus = (element, array) => array
      .filter((x, i, self) => self.indexOf(x) === i)
      .filter(x => x.selected)
      .filter(x => x !== element)
      .forEach(x => element.focus++);

    data.entities.forEach(entity => {
      entity.focus = entity.selected ? 1 : 0;
      applyFocus(entity, entity.sources);
      applyFocus(entity, entity.targets);
      applyFocus(entity, entity.bundles);
    });

    data.bundles.forEach(bundle => {
      bundle.focus = bundle.selected ? 1 : 0;
      applyFocus(bundle, bundle.sources);
      applyFocus(bundle, bundle.targets);
    });

    let linkFocus = (a, b) => Math.min(a.focus, b.focus);
    data.relations.forEach(relation => {
      relation.links.forEach(link => {
        link.focus = linkFocus(link.source, link.target);
      });
      relation.sourceLinks.forEach(link => {
        link.focus = linkFocus(link.entity, link.bundle);
      });
      relation.targetLinks.forEach(link => {
        link.focus = linkFocus(link.entity, link.bundle);
      });
    });

    d3.selectAll(".entity")
      .classed("selected", d => d.selected)
      .attr("data-focus", d => d.focus);

    d3.selectAll(".bundle")
      .classed("selected", d => d.selected)
      .attr("data-focus", d => d.focus);

    d3.selectAll(".link")
      .attr("data-focus", d => d.focus);
  }

  _relationScaleX() {
    let halfRelationWidth = this._relationWidth / 2;
    return d3.scaleLinear()
      .domain([-1, 1])
      .range([-halfRelationWidth, halfRelationWidth]);
  }

  _relationScaleY() {
    return d3.scaleLinear()
      .domain([0, 1])
      .range([0, this._entityHeight]);
  }

  _drawSpace(root, offset, zoomRange) {
    // Build hierarchy.
    let zoomContainer = root.append("g")
      .classed("zoom-container", true);
    let offsetContainer = zoomContainer.append("g")
      .classed("offset-container", true);

    // Static transforms.
    offsetContainer.attr("transform", Biset.transform(offset, offset, 1));

    // Detect zoom/pan gestures.
    root.call(d3.zoom()
      .scaleExtent(zoomRange)
      .on("zoom", () => {
        // Zoom transforms.
        zoomContainer.attr("transform", Biset.transform(
          d3.event.transform.x,
          d3.event.transform.y,
          d3.event.transform.k)
        );
      })
    );

    return offsetContainer;
  }

  _drawDomains(root, domains) {
    let self = this;
    let selection = root
      .selectAll(".domain")
      .data(domains);

    // Container
    selection.enter()
      .append("g")
      .classed("domain", true)
      .attr("transform", (d, i) => {
        let x = i * (this._domainWidth + this._relationWidth);
        return Biset.translate(x, 0);
      })
      .each(function (domain) {
        self._drawEntities(d3.select(this), domain.entities);
      });
  }

  _drawEntities(root, entities) {
    let selection = root
      .selectAll(".entity")
      .data(entities);

    // Container
    let contents = selection.enter()
      .append("g")
      .classed("entity", true)
      .classed("selected", d => d.selected)
      .attr("data-id", d => d.id)
      .attr("data-focus", d => d.focus)
      .attr("transform", (d, i) => Biset.translate(0, i * this._entityHeight));

    // Background
    contents.append("rect")
      .classed("background", true)
      .attr("width", this._domainWidth)
      .attr("height", this._entityHeight - this._entitySpacing)
      .attr("rx", this._cornerRadius)
      .attr("ry", this._cornerRadius)
      .on("mouseover", this._handleEntityMouseOver(this))
      .on("mouseout", this._handleEntityMouseOut(this))
      .on("mousedown", this._handleEntityClick(this));

    // Frequency indicator
    let indicators = contents.append("rect")
      .classed("indicator", true)
      .attr("width", d => this._indicatorUnit * d.frequency)
      .attr("height", this._entityHeight - this._entitySpacing)
      .attr("rx", this._cornerRadius)
      .attr("ry", this._cornerRadius);

    // Frequency indicator tooltip
    contents.append("title")
      .text(d => `${d.frequency} occurrence${d.frequency != 1 ? "s" : ""}`);

    // Label
    contents.append("text")
      .classed("text", true)
      .attr("x", 8 + this._indicatorWidth)
      .attr("y", this._entityHeight / 2 + 5)
      .attr("font-size", "16px")
      .attr("fill", "rgba(0, 0, 0, 0.87")
      .text(d => d.label);
  }

  _drawRelations(root, relations) {
    let self = this;
    let selection = root
      .selectAll(".relation")
      .data(relations);

    selection.enter()
      .append("g")
      .classed("relation", true)
      .attr("transform", (d, i) => {
        let x = (i + 1) * this._domainWidth + (i + 0.5) * this._relationWidth;
        let y = this._entityHeight / 2;
        return Biset.translate(x, y);
      })
      .each(function (relation) {
        let node = d3.select(this);
        self._drawSoloLinks(node, relation.links);
        self._drawSourceLinks(node, relation.sourceLinks);
        self._drawTargetLinks(node, relation.targetLinks);
        self._drawBundles(node, relation.bundles);
      });
  }

  _drawBundles(root, bundles) {
    let scaleX = this._relationScaleX();
    let scaleY = this._relationScaleY();
    let width = bundle => this._indicatorUnit * bundle.size;

    let selection = root
      .selectAll(".bundle")
      .data(bundles.filter(b => b.visible));

    let containers = selection.enter()
      .append("g")
      .classed("bundle", true)
      .classed("selected", d => d.selected)
      .attr("data-id", d => d.id)
      .attr("data-focus", d => d.focus)
      .attr("transform", d => {
        let x = scaleX(0) - width(d) / 2;
        let y = scaleY(d.position) - this._bundleHeight / 2;
        return Biset.translate(x, y);
      });

    containers.append("rect")
      .classed("background", true)
      .attr("width", width)
      .attr("height", this._bundleHeight - this._bundleSpacing)
      .attr("rx", this._cornerRadius)
      .attr("ry", this._cornerRadius)
      .on("mouseover", this._handleBundleMouseOver(this))
      .on("mouseout", this._handleBundleMouseOut(this))
      .on("mousedown", this._handleBundleClick(this));

    containers.append("rect")
      .classed("indicator", true)
      .attr("width", d => d.sources.length / d.size * width(d))
      .attr("height", this._bundleHeight - this._bundleSpacing)
      .attr("rx", this._cornerRadius)
      .attr("ry", this._cornerRadius);

    containers.append("title")
      .text(d => `${d.sources.length}/${d.targets.length}`);
  }

  _drawSoloLinks(root, links) {
    let scaleX = this._relationScaleX();
    let scaleY = this._relationScaleY();

    let selection = root
      .selectAll(".solo-link")
      .data(links.filter(l => l.visible));
    selection.enter()
      .append("path")
      .classed("link", true)
      .classed("solo-link", true)
      .attr("data-id", d => d.id)
      .attr("data-source", d => d.source.id)
      .attr("data-target", d => d.target.id)
      .attr("data-focus", d => d.focus)
      .attr("d", d => Biset.link(
        scaleX(-1),
        scaleY(d.source.position),
        scaleX(1),
        scaleY(d.target.position)
      ));
  }

  _drawSourceLinks(root, links) {
    let scaleX = this._relationScaleX();
    let scaleY = this._relationScaleY();

    let selection = root
      .selectAll(".source-link")
      .data(links.filter(l => l.bundle.visible));
    selection.enter()
      .append("path")
      .classed("link", true)
      .classed("source-link", true)
      .attr("data-id", d => d.id)
      .attr("data-source", d => d.entity.id)
      .attr("data-bundle", d => d.bundle.id)
      .attr("data-focus", d => d.focus)
      .attr("d", d => Biset.link(
        -d.bundle.size * this._indicatorUnit / 2,
        scaleY(d.bundle.position),
        scaleX(-1),
        scaleY(d.entity.position)
      ));
  }

  _drawTargetLinks(root, links) {
    let scaleX = this._relationScaleX();
    let scaleY = this._relationScaleY();

    let selection = root
      .selectAll(".target-link")
      .data(links.filter(l => l.bundle.visible));
    selection.enter()
      .append("path")
      .classed("link", true)
      .classed("target-link", true)
      .attr("data-id", d => d.id)
      .attr("data-target", d => d.entity.id)
      .attr("data-bundle", d => d.bundle.id)
      .attr("data-focus", d => d.focus)
      .attr("d", d => Biset.link(
        d.bundle.size * this._indicatorUnit / 2,
        scaleY(d.bundle.position),
        scaleX(1),
        scaleY(d.entity.position)
      ));
  }

  static transform(x, y, scale) {
    return `translate(${x}, ${y}) scale(${scale})`;
  }

  static translate(x, y) {
    return Biset.transform(x, y, 1);
  }

  static link(x0, y0, x1, y1) {
    let xm = (x0 + x1) / 2;
    return `M ${x0} ${y0} C ${xm} ${y0} ${xm} ${y1} ${x1} ${y1}`;
  }
}