Skip to content

Conversation

@ghost
Copy link

@ghost ghost commented Sep 8, 2016

Add tooltip #6

tien.nguyenv added 2 commits September 8, 2016 20:54
@nmqhoan
Copy link
Collaborator

nmqhoan commented Sep 8, 2016

This PR is made as @jccq request

@stormpython
Copy link
Owner

stormpython commented Sep 11, 2016

Firstly, @ScorpiOVN and @jccq thanks for this pr. Its something that really needed to get done.

While I understand the approach here, I am not completely on board with having events attached directly to the svg:rects for 3 reasons:

  1. In a complex application like Kibana that could potentially have many other visualizations in a dashboard, memory leaks can be a serious issue. If there is a high density heatmap with many 100s or 1000s of svg:rects, each with its own listeners attached, there is an increased chance for memory leaks if the visualization is not cleaned up properly.
  2. By hardcoding the tooltip functionality to svg:rects, it makes it hard to have customizable tooltips in the future. For example, if a user wanted to add values that weren't attached to the data that the heatmap cells receive. Ideally, we would use Kibana tooltips since that would be the best case scenario. However, since they don't have an API for re-use, I opened an issue to create temporary tooltips with the hope that one day this would make it in as a core Kibana visualization. Then the developers wouldn't need to rip out the hard coded tooltip functionality. They could just simply use an API to get the data they need back to populate their tooltip.
  3. I don't like mixing tooltip functionality within svg:rect generation. I think its best handled separately.

The heatmap directive is already setup to take an eventListeners object. Since my code isn't well documented, its not obvious. So I will do my best to explain its functionality.

The eventListeners object is expected to consist of keys that represent valid event types and values that are arrays of listener functions. For example:

// Example eventListeners object
// This is added within the heatmap_controller.js file.
{
  mouseover: [ myCustomMouseoverFunction, ... ],
  mouseout: [ myCustomMouseoutFunction, ...],
  ...
}

For a look at the code to see how events are attached to the svg, take a look here.

The event listeners are attached in one place, on the parent svg, so that they don't have to be placed on any svg object individually. However, doing it in this way means that the listener functions have to appropriately filter for the element of interest. By default, the function takes a d3.event object. For example:

// Added within the heatmap_controller.js file.
function myCustomMouseoverFunction(event) {
  var target = d3.select(event.target);
  var isHeatmapCell = (target.attr("class") === "cell");

  if (isHeatmapCell) {
    // get data bound to heatmap cell
    var d = _.first(target.data());

    // Custom code for tooltip functionality goes here
  }
}

Now that we can separate the rect generation code from the tooltip code, we need to create the tooltip object. Since Kibana uses angular, the best way is probably to create a tooltip directive. In this way, we can populate the tooltip directive from our HeatmapController. A very simple angular tooltip directive might be:

// heatmap_tooltip.js
var d3 = require("d3");
var _ = require("lodash");

angular.directive('heatmapTooltip', function () {
  function controller($scope) {
    $scope.isShown = false;

     /*
      * Make sure that the items array is populated before tooltip is shown.
      * The items variable is an array of objects, e.g.
      *  [ 
      *    { key: "Column", value: "Tuesday" },
      *    { key: "Row", value: "12pm" }, 
      *    { key: "Count", value: 12 } 
      *  ]
      */
    this.showOnHover = function () {
      $scope.isShown = !!($scope.items && _.isArray($scope.items) && $scope.items.length);
    };
  }

  function link(scope, element, attrs, ctrl) {
    function render($scope) {
      d3.select(_.first(element))
        .style("top", $scope.top + "px")
        .style("left", $scope.left + "px");

      ctrl.showOnHover();
    }

    scope.$watchGroup(["top", "left", "items"], function (newVal, oldVal, scope) {
      render(scope);
    }, 250);
  }

  return {
    restrict: "E",
    scope: {
      top: "=",
      left: "=",
      items: "="
    },
    replace: true,
    controller: controller,
    link: link,
    template: require("plugins/heatmap/heatmap_tooltip.html")
  };
});

Here is what the html for this directive might look like:

<!-- heatmap_tooltip.html -->
<div class="heatmap-tooltip" ng-show="isShown">
  <ul class="heatmap-tooltip-list">
    <li ng-repeat="item in items">
      <span>{{ item.key }}: </span>
      <span>{{ item.value }}</span>
    </li>
  </ul>
</div>

Here is what the css for this directive might look like:

/* heatmap_tooltip.css */
.heatmap-tooltip {
  position: absolute;
  width: auto;
  height: auto;
  padding: 5px;
  z-index: 150;
  background-color: #d3d3d3;
  -webkit-border-radius: 10px;
  -moz-border-radius: 10px;
  border-radius: 10px;
  -webkit-box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.4);
  -mox-box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.4);
  box-shadow: 4px 4px 10px rgba(0, 0, 0, 0.4);
  pointer-events: none;
  white-space: nowrap;
}

.heatmap-tooltip-list {
  padding: 0;
  list-style-type: none;
}

Finally, we can add the tooltip directive to our heatmap.html file.

<!-- heatmap.html -->
<div ng-controller="HeatmapController" class="heatmap-vis">
  <heatmap data="data" options="vis.params" eventListeners="eventListeners"></heatmap>
  <heatmap-tooltip top="top" left="left" items="tooltipItems"></heatmap-tooltip>
</div>

Now, we would need to complete the tooltip functionality. This would happen in the heatmap_controller.js file.

// heatmap_controller.js
    ...
    $scope.data = [{
      cells: processTableGroups(tabifyAggResponse($scope.vis, resp), $scope)
    }];

    $scope.eventListeners = {
      mouseover: [ mouseover ]
    };

    // Default tooltip settings
    $scope.tooltipItems = [];
    $scope.top = 0;
    $scope.left = 0;

    function mouseover(event) {
      var target = d3.select(event.target);
      var isHeatmapCell = (target.attr("class") === "cell");
      var OFFSET = 50;

      if (isHeatmapCell) {
        // get data bound to heatmap cell
        var d = _.first(target.data());

        // Custom code for tooltip functionality goes here
        $scope.$apply(function () {
          $scope.tooltipItems = Object.keys(d)
            .filter(function (key) { return key !== "data"; })
            .map(function (key) {
              return {
                key: key.toUpperCase(),
                value: d[key]
              };
            });

          $scope.top = d.data.row + OFFSET;
          $scope.left = d.data.col + OFFSET;
        }
    }
  });
});

While this solution is more involved, it satisfies the criteria above. That is:

  1. It takes advantage of the API for attaching event listeners in one place, therefore making it easier to remove event listeners from objects on chart destruction and reducing the chances of memory leaks.
  2. It allows users/developers to customize their tooltips both in the presentation and data presented.
  3. It separates the tooltip functionality from the chart generation code.

@stormpython
Copy link
Owner

I've found a bug here.

svg should be element

@stormpython
Copy link
Owner

There should also be a mouseout function that resets the $scope values.

function mouseout() {
  $scope.$apply(function () {
    $scope.tooltipItems = [];
    $scope.top = 0;
    $scope.left = 0;
  });
}

@tuhp
Copy link

tuhp commented Sep 12, 2016

Hi Stormpython,
I also found a bug at here

The: eventListeners="eventListeners"
must change to: event-listeners="eventListeners"

@stormpython
Copy link
Owner

@tuhp thank you.

@nmqhoan nmqhoan mentioned this pull request Sep 15, 2016
@stormpython
Copy link
Owner

Closing in favor of #41.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants