Charts/Tooltip.js

"use strict";

import { nest as d3Nest } from "d3-collection";
import {
  select as d3Select,
  event as d3Event,
  clientPoint as d3ClientPoint
} from "d3-selection";
import { transition as d3Transition } from "d3-transition"; // eslint-disable-line no-unused-vars

import defaultValue from "terriajs-cesium/Source/Core/defaultValue";
import defined from "terriajs-cesium/Source/Core/defined";

import dateformat from "dateformat";

const defaultTooltipOffset = {
  // The meaning of these offsets depend on the alignment.
  top: 10,
  right: 10,
  bottom: 10,
  left: 10
};
const defaultClassName = "base-chart-tooltip";
const defaultId = "base-chart-tooltip-id";
const showHideDuration = 250;

/**
 * Handles the drawing of the chart tooltip, which shows the values of the selected data in a legend.
 *
 * @param  {String} [tooltipSettings.id] The id to use for the tooltip DOM element, defaults to 'base-chart-tooltip-id'. Do not change this after creation.
 * @param  {String} [tooltipSettings.className] The className to use for the tooltip DOM element, defaults to 'base-chart-tooltip'. Do not change this after creation.
 * @param  {String} [tooltipSettings.align] One of 'hover' (hover at the mouse position), 'left', 'right', 'prefer-right' (chooses left or right depending on mouse position).
 * @param  {Object} [tooltipSettings.offset] An object with top, left and right properties; these properties' meanings depend on the alignment above.
 *                                           With right/left alignment, the offset is relative to the svg.
 */
const Tooltip = {
  defaultClassName: defaultClassName,
  defaultId: defaultId,

  id(tooltipSettings) {
    return defaultValue(tooltipSettings.id, defaultId);
  },

  select(tooltipSettings) {
    return d3Select("#" + Tooltip.id(tooltipSettings));
  },

  create(container, tooltipSettings) {
    // Make the tooltip DOM element, invisible to start.
    if (defined(tooltipSettings)) {
      container
        .append("div")
        .attr("id", Tooltip.id(tooltipSettings))
        .attr(
          "class",
          defaultValue(tooltipSettings.className, defaultClassName)
        )
        .style("opacity", 1e-6)
        .style("position", "absolute")
        .style("display", "none");
    }
  },

  destroy(tooltipSettings) {
    // Remove the tooltip DOM element.
    if (defined(tooltipSettings)) {
      const id = Tooltip.id(tooltipSettings);
      const tooltipElement = d3Select("#" + id).nodes();
      if (tooltipElement) {
        d3Select("#" + id).remove();
        //NOTE:  why not remove it directly like above?
        // tooltipElement.parentElement.removeChild(tooltipElement);
      }
    }
  },

  singleRowHtml(color, name, value, units) {
    if (value === null) return;
    const styleAttribute = defined(color)
      ? 'style="background-color: ' + color + '" '
      : "";

    const formattedVal = isNaN(value) ? value : value.toFixed(2);

    return `<tr class="dataRow">
      <td class="dataIcon">
        <span class="color" ${styleAttribute}></span>
      </td>
      <td class="dataVal">
        <span class="name">${name}</span>
      </td>
      <td class="value">
        ${formattedVal}
        <span class="units">${units || ""}</span>
      </td>
    </tr>`;
  },

  html(selectedData, xLocation) {
    let html;
    const readableX =
      typeof xLocation.getMonth === "function"
        ? dateformat(xLocation, "dd/mm/yyyy, HH:MMTT")
        : xLocation;
    html = '<p class="x-value">' + readableX + "</p>";

    // If there is only one line showing, then label it with the category name, not the column name.
    // Else, if there is only one column name (shared by all the categories), show the category names
    //     and don't show the column name.
    // Else, if there is only one category name, then there is no need to show it.
    // In general, show both, grouped by category name.

    // If there is only a moment dataset it's x values (a date will be shown)
    if (
      selectedData.length === 1 &&
      (selectedData[0].type === "moment" ||
        selectedData[0].type === "momentPoints")
    ) {
      return html;
    } else if (selectedData.length === 1) {
      const onlyLine = selectedData[0];
      html += '<p class="category-name">' + onlyLine.categoryName + "</p>";
      html += "<tbody><table>";
      html += this.singleRowHtml(
        onlyLine.color,
        `${onlyLine.name}`,
        onlyLine.type === "moment" || onlyLine.type === "momentPoints"
          ? readableX
          : onlyLine.point.y,
        onlyLine.units
      );
      html += "</tbody></table>";
      return html;
    }

    // The next line turns [chartData1A, chartData2, chartData1B] into
    // [{key: 'categoryName1', values: [chartData1A, chartData1B]}, {key: 'categoryName2', values: [chartData2]}].
    const dataGroupedByCategory = d3Nest()
      .key(d => d.categoryName)
      .entries(selectedData);
    // And similarly for the column names.
    // const dataGroupedByName = d3Nest()
    //   .key(d => d.name)
    //   .entries(selectedData);

    // if (dataGroupedByName.length === 1) {
    //   // All lines have the same name.
    //   html += '<table class="mouseover"><tbody>';
    //   dataGroupedByName[0].values.forEach(line => {
    //     html += this.singleRowHtml(
    //       line.color,
    //       line.categoryName,
    //       line.point.y,
    //       line.units
    //     );
    //   });
    //   html += "</tbody></table>";
    //   return html;
    // }

    dataGroupedByCategory.forEach(group => {
      if (
        group.values[0].type === "moment" ||
        group.values[0].type === "momentPoints"
      ) {
        return;
      }
      // if (dataGroupedByCategory.length > 1) {
      //   html += '<p class="category-name">' + group.key + "</p>";
      // }
      html += '<p class="category-name">' + group.key + "</p>";
      html += '<table class="mouseover categoryTable"><tbody>';
      group.values.forEach(line => {
        html += this.singleRowHtml(
          line.color,
          line.name,
          line.point.y,
          line.units
        );
      });
      html += "</tbody></table>";
    });
    return html;
  },

  show(html, tooltipElement, tooltipSettings, boundingRect) {
    tooltipElement
      .html(html)
      .style("display", "block")
      .transition()
      .duration(showHideDuration)
      .style("opacity", 1)
      .style("max-width", "300px")
      .style("visibility", "visible");

    const tooltipWidth = +tooltipElement.nodes()[0].offsetWidth;
    const tooltipOffset = defaultValue(
      tooltipSettings.offset,
      defaultTooltipOffset
    );
    let top, left, right;

    const clientPos = d3ClientPoint(tooltipElement.node().parentNode, d3Event);
    const clientX = clientPos[0];
    const clientY = clientPos[1];

    switch (tooltipSettings.align) {
      case "left":
        top = tooltipOffset.top;
        left = tooltipOffset.left;
        break;
      case "right":
        top = tooltipOffset.top;
        right = tooltipOffset.right;
        break;
      case "prefer-right": {
        // Only show on the left if we would be under the tooltip on the right, but not on the left.
        top = tooltipOffset.top;
        const leftEdgeWhenPositionedRight =
          boundingRect.width - tooltipOffset.right - tooltipWidth;
        const rightEdgeWhenPositionedLeft = tooltipOffset.left + tooltipWidth;
        if (
          clientX >= leftEdgeWhenPositionedRight &&
          clientX > rightEdgeWhenPositionedLeft
        ) {
          left = tooltipOffset.left;
        } else {
          right = tooltipOffset.right;
        }
        break;
      }
      case "hover":
      default:
        top = d3Event.clientY - tooltipOffset.top;
        left = d3Event.clientX + (-tooltipWidth - tooltipOffset.left);
        break;
    }

    const tooltipHeight = tooltipElement.node().offsetHeight;

    const possibleYClash = clientY < tooltipHeight + tooltipOffset.top;
    if (possibleYClash) {
      tooltipElement.style("bottom", "60px");
      tooltipElement.style("top", null);
    } else {
      tooltipElement.style("top", top + "px");
      tooltipElement.style("bottom", null);
    }

    if (left !== undefined) {
      tooltipElement.style("left", left + "px");
    } else {
      tooltipElement.style("left", "auto");
    }
    if (right !== undefined) {
      tooltipElement.style("right", right + "px");
    } else {
      tooltipElement.style("right", "auto");
    }
  },

  hide(tooltipElement) {
    tooltipElement
      .transition()
      .duration(showHideDuration)
      .style("opacity", 1e-6);
    // visibility hidden cannot transition, and it is too flashy if you use it without.
    // We need it because opacity=0 along can get in front of other elements and prevent the hover from working at all.
    // So delay it until (and only if) the opacity has already done its job.
    setTimeout(function() {
      if (+tooltipElement.style("opacity") < 0.002) {
        tooltipElement.style("visibility", "hidden");
      }
    }, showHideDuration * 1.2);
  }
};

module.exports = Tooltip;