Making the bars selectable

Learn the right way to build interactions that go outside of Ember's rendering layer.

Transcript

Earlier in this series we added some interaction to our bar charts. We made it so that when the user hovers over these bars, we set some state — the hoveredBar property — and we use that to render a tooltip.

Now, the remaining videos in this series will be focused on adding some more interactions to our bar charts, and having those interactions actually affect the rest of our application, specifically which blog posts are being rendered in this table.

Now, our goal is to end up with a bar chart component that feels like any other Ember component, and uses familiar patterns like data-down-actions-up when integrating with other parts of our application.

In this video we're going to add a second interaction to our bar chart. We'll let users click on bars, and when they do, the bar will become selected, and other bars in the chart will fade out. So let's work on that first.

We'll come to our code and at the bottom where we've wired up our other events, we'll add a new event handler for click. And this function takes in the bound data for this bar, so we'll pass in data, and then when we actually click on the bar, we'll set a new property called selectedLabel, and we'll set it to the label property of the clicked bar.

.on('click', data => {
  this.set('selectedLabel', data.label);
});

Let's come to our template and we'll drop this in here, selectedLabel, and try it out.

{{selectedLabel}}

Alright now when we click a bar, we can see that the selected label property is being set.

Now, whenever we make elements clickable, its good to set their cursor to pointer, so we'll jump over here to our styles file, and we'll add a rule for rect elements and we'll set cursor to pointer.

rect {
  cursor: pointer;
}

Ok, that looks great.

So, what we want to do is fade out the non-selected bars whenever the user selects one. So let's come back to our code, we'll delete the selectedLabel property from our template, and back in our component, after we set selectedLabel, we want to grab all the other bars and set their opacity to 50%. So let's refactor this code a little bit so that we can easily grab the other bars in this method.

We'll come up to line 39, and we'll define a new local variable called bars, and all of these methods starting with append actually return our D3 data-bound selection — which is why we're able to do method chaining like this. So we can actually store the selection in this bars variable, and let's just split out our attribute setting from our events. So now we'll call bars.on again, and let's save this and make sure it still works.

let bars = 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)}%`)
  .attr('fill', data => color(data.count));

bars
  .on('mouseover', data => {
    this.set('hoveredLabel', data.label);
  })
  .on('mouseout', () => {
    this.set('hoveredLabel', null);
  })
  .on('click', data => {
    this.set('selectedLabel', data.label);
  });

Ok, now that bars is in a variable, we have easy access to it in our click handler. So, first let's say clickedLabel is data.label, so this is the label that we actually clicked. And then we'll set that to the selectedLabel property, and now we want to use bars to find all the bars that were not clicked, and update their opacities to 50%. So we can call .filter here, and we'll filter based on the data that is bound to each bar, and we want to find all the bars whose data.label is not equal to the clicked label. And once we have those, we just want to set their opacity to .5.

.on('click', data => {
  let clickedLabel = data.label;
  this.set('selectedLabel', clickedLabel);

  bars.filter(data => data.label !== clickedLabel)
    .attr('opacity', '0.5');
});

Let's save this and try it out. Nice! Now whenever we click a bar, all the other bars fade out.

But we actually have a problem. If we click on another bar, our chart breaks. And this is because we just set all the bars that weren't clicked to 50%; but we don't update the bar that was clicked to 100%. So we can fix this by copying this line, and then we'll find the bar that was clicked, and set its opacity to 100%.

bars.filter(data => data.label !== clickedLabel)
  .attr('opacity', '0.5');
bars.filter(data => data.label === clickedLabel)
  .attr('opacity', '1.0');

Save this, and now we can toggle different bars.


Alright next, let's make it so we can deselect bars. So here if we've already selected a bar and we click it again, we want it to go away and for the chart to go back to its original state.

So we'll come back to our click handler, and we'll work on this logic. So we want to say, if the user has clicked on the label that's already equal to the selected label, then we want to reset everything; otherwise, we'll do our normal logic, which is right here. So to reset, we just want to set selectedLabel to the empty string, and then make sure all the bars have an opacity of their original value of 1.0.

.on('click', data => {
  let clickedLabel = data.label;

  if (clickedLabel === this.get('selectedLabel')) {
    this.set('selectedLabel', '');
    bars.attr('opacity', '1.0');

  } else {
    this.set('selectedLabel', clickedLabel);

    bars.filter(data => data.label !== clickedLabel)
      .attr('opacity', '0.5');
    bars.filter(data => data.label === this.get('selectedLabel'))
      .attr('opacity', '1.0');
  }
});

Alright let's save this and try it out. I can toggle every bar just like before, but now when I click again, it resets. Cool.4


Alright, now our bar charts seem to be working fine - but what about initial render? If we come here and select Economics, the third bar in our categories chart, let's see what would happen if we were to pass this in from our blog post template.

So we'll come to our blog post template, and in our categories chart we'll pass in an initial value for selectedLabel of Economics.

{{bar-chart data=categoryData color='green' selectedLabel='Economics'}}

If we come back to the page, we see its not working; and actually if we click this bar it doesn't work either, because the selectedLabel was set but the opacities aren't accurate. And if we start clicking around again, it'll work, but that initial render is broken.

If we come back to the code of our bar chart component, well its easy to see why: we're only updating the opacities in our click handler. But now we've introduced a new way to change the selectedLabel state - on initial render - so we need to refactor this a bit.

Let's come down and write a new method called updateOpacities, and here's where we'll do our rendering logic for the bars' opacities.

Now if we look at our click handler, we can see that we're actually doing two things: we're updating some state - so in this case we're setting the selectedLabel to the empty string, in this case we're setting it to the clicked lable - and we're re-rendering our bars' opacities - up here we set them all to 1.0, and here we set them based on whether they're the selected bar or not.

What we want to do is just pull out the rendering portion and move it to updateOpacities.

So let's grab all this, come down here, and now what we're saying is, if we don't have a selected label, make sure everything's 100%; otherwise - and we'll get rid of this set call as well - otherwise, render the bars based on the selected label.

Now we'll see here that we're re-rendering based on the clicked label, but now we want to do it based on the actual state of selectedLabel. We also don't have access to the bars in this method, but we can actually just reselect the bars whenever we need them, just like we did up here in our tooltipTarget computed property. So we'll come down here, define a local bars property, and now we have this rendering method that's encapsulated in its own function.

updateOpacities() {
  let bars = select(this.$('svg')[0]).selectAll('rect');

  if (this.get('selectedLabel')) {
    bars.filter(data => data.label !== this.get('selectedLabel'))
      .attr('opacity', '0.5');
    bars.filter(data => data.label === this.get('selectedLabel'))
      .attr('opacity', '1.0');

  } else {
    bars.attr('opacity', '1.0');
  }
}

Just to summarize what we did, we moved the rendering part of our click handler to a new method, and we based the rendering logic not off of the clicked label anymore, but off of the current state of selectedLabel.

Now we can come up to the click handler, and get rid of all this rendering logic - so our click handler is really just focused on updating state. And after it updates state, we'll invoke our new updateOpacities method.

.on('click', data => {
  if (data.label === this.get('selectedLabel')) {
    this.set('selectedLabel', '');
  } else {
    this.set('selectedLabel', data.label);
  }

  this.updateOpacities();
});

Let's save this and check out our app. Ok, our original behavior is working but, if we refresh, initial render is still broken. We just need to come down to the bottom of our chart rendering logic right here, and call this.updateOpacities() once to cover the initial render case. So this is being called after our chart renders for the first time in didInsertElement; and then its being called anytime we change the selectedLabel in our click handler.

Let's save this, and try it out. And now we see our third bar here is highlighted on initial render.


Ok, before we leave, let's make one more change. Looking at our charts right now, when we click on one of these bars, we get the tooltip when we're hovering over it with our mouse. But if we select a bar and move off, we lose the tooltip. It'd be nice if the tooltip would stay showing over a bar if it happened to be selected, even if we weren't hovering it.

So let's come back to our component, and come up to our tooltipTarget property. Right now it depends on the hoveredLabel only, but we want it to attach to either the selectedLabel or the hoveredLabel.

Let's make a new property called highlightedLabel, and we'll use Ember.computed.or, and this will be either the selected label or the hovered label.

highlightedLabel: Ember.computed.or('selectedLabel', 'hoveredLabel')

And let's try using this instead of our hoveredLabel for our tooltipTarget. So we'll just change it to highlightedLabel.

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

Let's save this and come back to our app. Ok, now we don't even see the tooltip on hover, so let's debug this. We'll come here and, let's put a debugger in our tooltipTarget. If we open our inspector and reload the app, we see that we actually hit this debugger before our first chart has even rendered. So Ember is running this tooltipTarget computed property before our chart has had a chance to render; and so when its filtering down these bars, well they haven't been rendered yet and so it can't find anything.

Since we're rendering our chart using D3, let's come down to after our chart is rendered for the first time, and set a new property on our component called didRenderChart to true.

this.set('didRenderChart');

And this way, if we ever need to write a computed property that needs to know that our D3 chart has rendered, we can use this variable.

If we come back up and add this to our tooltipTarget property, save it, and come back, now we see our tooltip is working again. And we actually see it right here, over our third bar because its selected with that initial value; but the label is not right unless we hover. And that's because, if we come to our template, we see that we were still using the hovered label, but now we want to use our new highlightedLabel property, which will be either the selectedLabel or the hoveredLabel.

<p>{{highlightedLabel}}</p>

Let's save this, and check it out. Now the selected bar has the right label; when we hover it works; and when we select new bars, it works as well. Pretty cool!


In this video we added some new behavior to our bar chart. Now when a user clicks a bar, we set a property on our component and update the visual display of our chart by changing the bars' opacities.

Now, we had to write this part of our chart's rendering logic in a separate method, and manually call it both on initial render, and whenever the selectedLabel property changed as a result of a click event. And this felt like a lot of work.

Now normally in Ember, we're able to just render properties and components in our templates, and Ember is responsible for making sure that the rendered output matches the current state of our application, whether its initial rendering or re-rendering. But when we go outside of Ember's rendering layer and use something like D3 to affect the visual output of our charts or our application, it becomes our responsibility to ensure that those charts are re-rendered whenever a relevant piece of state changes.

Now, this is an important point to understand and it goes beyond D3. There's going to be times where you need to interact with a library or API that affects the visual output of your application, but that Ember is unaware of. For example if you were rendering a video player, there's a lot of state and visual output there that Ember doesn't know about. Or if you were wrapping a jQuery plugin, or some library that rendered to a canvas tag or rendered some 3D content. In all of these situations you're forced to deal with rendering yourself, which as we saw can get a little tricky.

When you find yourself in a situation where you do have to control some rendering logic on your own, try to write your code in the style that we did in this video: separate state changes from re-rendering logic. So certain events will update state on your component, and then you'll re-render parts of the output, but only using the new state. And in this way your rendering will still flow from the state of your component to the output, and that makes it easier as you change things and add new ways of changing the state, either through new events or from other parts of your application interacting with your components.

In the next video, we'll learn about state hoisting, and make our bar chart component follow the data-down-actions-up pattern.

Questions?

Send us a tweet:

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