Making a tooltip

Use the awesome Ember Modal Dialog library to create a tooltip alongside our D3 chart.

Transcript

Our charts are starting to take shape. Right now they give us some sense of our data - but we can't tell what each bar represents! So let's add some tooltips to our charts.

Let's come to our terminal we'll run

ember install ember-modal-dialog

This is one of the things I love about using D3 with Ember. You can google examples for how to build tooltips with D3, but I already know and love this addon, so we're just going to use this.

I also like using the tether version of ember-modal-dialog, so we'll run

ember install ember-tether

and this will let us use the tether version. Okay, let's restart the server, and come to our code.

Now that we have modal dialog set up, we want to start out just by getting a simple tooltip working in our bar chart component. So let's open our component's template, and below our SVG here, let's just render a modal-dialog with just a simple body here:

{{#modal-dialog}}
  <p>Hello, tooltip!</p>
{{/modal-dialog}}

If we check out our app, here we can see our tooltips being rendered, and if we open up the inspector, we can actually see that there are three of them right here, stacked on top of each other.

Let's come back to the tooltip, and we'll pass in a tetherTarget of svg. Now you can kind of see our tooltips back here, behind our first SVG. So let's move it up to the top. We can do this by setting our dialog's targetAttachment to "top middle". And now we can see all our tooltips above our first chart.

The reason they're all here is because, we just passed in svg as our target, so all three charts' tooltips are just rendering to the first svg on the page. What we can do to fix this is to make tetherTarget dynamic.

So let's open our component, and at the bottom of our didInsertElement hook, after our chart's been rendered, let's call this.set and we'll define a new property called tooltipTarget, and we'll just set it equal to the svg node for this particular component.

this.set('tooltipTarget', this.$('rect')[0]);

And then we'll pass in tooltipTarget here for our tetherTarget.

Now if we come to the app, we'll see that our tooltips are being rendered up here, and we can't see our charts anymore. If we look at the console, we'll see some modal dialog errors, and this is because our modal dialog is trying to render immediately, but our charts haven't had a chance to finish rendering yet in didInsertElement.

Since we only want to render our modal dialog once tooltipTarget is set, we can actually just wrap this in an {{#if tooltipTarget}}

{{#if tooltipTarget}}
  {{#modal-dialog
    tetherTarget=tooltipTarget
    targetAttachment='top middle'}}
    <p>Hello, tooltip!</p>
  {{/modal-dialog}}
{{/if}}

which will make this line valid. Alright let's save this, and come to our app. And now each tooltip is rendering to its own chart!

We can easily come back to our component and change this tooltipTarget too. Let's make it the first rect instead of the root svg. And now we see the tooltip is tethering to the first rectangle in each chart.

Ok, let's add some styling so these actually look more like tooltips. We'll add a div with class background black; we'll make the text white; we'll make the font size 7; and we'll add padding to all sides of scale 2; and let's give it an opacity of 80%.

<div class="bg-black white f7 pa2 o-80">
  <p>Hello, tooltip!</p>
</div>

Great. Now, we also want our tooltip floating above each bar, so we want the tooltip's bottom middle to tether to the bar's top middle. So we'll come back to our template, and we'll set the attachment to bottom middle. Now they're right up against each other, and we want a bit of an offset, so we can come set an offset property, and this property takes a vertical offset then horizontal, so we'll give it a vertical offset of 14 pixels, and a horizontal of 0.

{{#modal-dialog
  tetherTarget=tooltipTarget
  targetAttachment='top middle'
  attachment='bottom middle'
  offset='14px 0'}}

And now there's a little more breathing room.

Next I have some CSS here for an arrow, so let's come and we'll create a styles.scss file right here in our bar chart component - and I'm using ember-component-css which lets me write these local style files - and I'll just paste in this CSS.

.tooltip-arrow {
  width: 0;
  height: 0;
  border-left: 5px solid transparent;
  border-right: 5px solid transparent;
  border-top: 9px solid black;
  position: absolute;
  opacity: 0.8;
  bottom: -9px;
  left: calc(50% - 5px);
}

Then I'll come back to our template, and I'll add a div below our main one, with a class of tooltip-arrow. We'll save that, come back, and now our tooltips are looking great.

Ok, now it's time to add the interaction.

We only want the tooltips to show if we're hovering over a bar. So when we hover a bar, we'll set the tooltipTarget to that bar, and then when we mouse off of the bar, we'll clear it.

So what we want to do is come back to our bar chart component, and right here where we're adding the bars, we want to attach new event handlers to them.

So after we've finished drawing each bar, we can come here and call .on('mouseover'), and we get a function here whose parameter is data, just like all these other functions. And similar to each of these, this event handler will attach to each individual bar, and the data that's passed in will be the object that's bound to that bar.

What we're really doing here is setting state on our component - so, we're saying that the user has hovered the first author, or the second category or so on. So in our event handler, let's set some new state called hoveredLabel, and we'll set that new property to the label of the bar that we hovered. And likewise, whenever the user will mouse out of a bar, we'll update the hoveredLabel state to just be null.

svg.selectAll('rect').data(this.get('data'))
  .enter()
  .append('rect')
  .attr('width', `${xScale.bandwidth()}%`)
  .attr('height', data => `${yScale(data.count)}%`)
  .attr('x', data => `${xScale(data.label)}%`)
  .attr('y', data => `${100 - yScale(data.count)}%`)
  .on('mouseover', data => {
    this.set('hoveredLabel', data.label);
  })
  .on('mouseout', () => {
    this.set('hoveredLabel', null);
  });

Ok let's test out this new piece of state of our component. We can just come to our template and drop it down here, {{hoveredLabel}}. We'll save this, come to our app, and now whenever we hover over our bars, we can actually see the hovered label. Pretty cool!

Ok, so let's delete that, and what we really want to do is, use the hoveredLabel to find the corresponding rect, set that to the tooltipTarget, so that our tooltip knows which bar to tether to.

So let's come back to our component, and instead of setting tooltipTarget down here after our initial render of the chart, we'll write it as a computed property that depends on the state of hoveredLabel.

So let's come up here, we'll write tooltipTarget, and this is a computed property that depends on hoveredLabel. And again we want to find the rectangle that corresponds with the hovered label. So what we can do is reselect all of our rectangles like we did down on line 24, and we'll grab all the rects, and this will actually give us the same data-bound D3 selection that we have down here in our didInsertElement hook. And this is because D3 actually binds data to the DOM, so you can re-select these nodes at any time.

Now our selection represents all of bars, but we want to filter it down to just the one that is representing the current hoveredLabel. So we'll call .filter on this selection, and this takes a function with each data object passed in - but we only want the bar whose data label is equal to the current value of hoveredLabel. And this will return a selection with a single bar in it, and then we can call .node() to return the actual rect element, which is what our tooltip will be able to tether to.

tooltipTarget: Ember.computed('hoveredLabel', function() {
  return select(this.$('svg')[0]).selectAll('rect')
    .filter(data => data.label === this.get('hoveredLabel'))
    .node();
})

Let's come down and delete line 42, we'll save this, and we'll check out the app. Nice! Now our tooltip's binding to the hovered bar. One last thing - let's come back to our template, and we'll replace this dummy label with the actual hoveredLabel, since we already have that in this hoveredLabel property. And now our tooltip is working.

OK let's recap what we just did. First, in our component, we added two event handlers to all of our bars. The first one fires when the user mouses over a bar, and then with the data that was bound to that hovered bar, we come here and set a new hoveredLabel property on our component equal to the label of the data that was hovered. And then the second fires whenever the user mouses out of a bar, and it just clears that hoveredLabel property.

Then up here we wrote tooltipTarget as a computed property. It selects all the rect elements from this component, and then filters them down to the one rect whose data matches the hoveredLabel, and then it returns the matching node. And in our template, if we have a tooltipTarget (which will only be true if the user has hovered over a bar), we render a modal dialog, and we tell it to tether to that tooltip target. And then when the user mouses out of the bar, tooltipTarget is cleared and the modal dialog goes away.


Being able to use addons like ember-modal-dialog right alongside our D3 code is one of the things that makes this integration story so powerful. We can let D3 do what it does best, and fallback to standard Ember techniques whenever they're most appropriate.

Now it might seem strange at first to use event methods from D3 like calling .on on our bars - and that's because in general, we're used to using Ember's event system to wire up user interactions with our application. But within our bar chart component, we're writing D3 code and we want to stick with D3's patterns. This makes our bar chart code familiar to other developers that know D3, and ensures that we have full access to all of D3's capabilities when we're actually coding our visualizations.

When interacting with libraries like D3 that seem to have some overlap with Ember's responsibilities, I like to draw myself boundaries to contain the library code, and then identify the simplest way for that boundary to interact with the rest of my application. Now in our case, we wrote some imperative code in the style of idiomatic D3. But then we identified the minimum state that we need — which was the hoveredLabel property — to keep the component's interface purely declarative within the rest of our application.

Drawing these declarative boundaries around the less Ember-y parts of our codebase does wonders to contain complexity, and gives us a great interop story between libraries like D3 and the rest of our Ember application code.

In the next video, we're going to polish up our charts a bit with some color.

Questions?

Send us a tweet:

Or ask us in Inside EmberMap, our private Slack workspace for subscribers.