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.