Separating static and dynamic code
Refactor our bar chart to unlock the benefits of idempotent rendering.
Transcript
In this video we're going to refactor our chart to follow D3 best practices and separate out our chart's static code from its dynamic code. Let's go ahead and take a look at our bar chart component.
If we scroll down here, we see this nice renderChart
method that we made in the last video. This method encapsulates all the logic around rendering our bars - but it's also getting a bit large, and it's concerned with both the static parts of our visualization - like creating scale objects - as well as the dynamic parts - like re-rendering the bars when the data changes.
Let's start our refactoring by looking up here at the scales. Our yScale
is created using the scaleLinear()
function. This function returns a new object which we can then call .domain
and .range
on to set some configuration values.
Now, the scale creation itself is static - it never changes even if the data
property changes. And so is this range that we set - it's hard-coded to 0 to 100. But the domain of the scale depends on counts
, which comes from data.
So we want to separate these two parts out. What we want to do is copy this code, come up to didInsertElement
and paste it, and let's get rid of this call to .domain
setter, move this to the same line. And now, instead of just having this be a local property, we'll just set this on the component as the yScale
property.
didInsertElement() {
this.set('yScale', scaleLinear().range([ 0, 100 ]));
Then we can come back down to renderChart
, and instead of creating this, we'll just get it, get rid of that, and then we'll call .domain
on it, and then get rid of the range.
this.get('yScale').domain([ 0, Math.max(...counts) ]);
No D3 lets us call .domain
and .range
on its scales as much as we'd like. So what will happen is the yScale will be created once with a range, and then every time we call renderChart
, we'll update its domain with the new counts
property, which is derived from the new data. So, now we've separated the static part of our code that deals with this scale from the dynamic part.
Let's keep going with the other two scales. We'll copy the color scale, we'll do the same thing, and let's call this colorScale
just the be clear. Get rid of .domain
, move range
to the same line, and then down here we'll just get colorScale
, call domain
on it, and that'll be it.
Same with the xScale
: set it as a property, get rid of domain, set the range and set the padding. Come down here, we'll get it, and then call .domain
on it.
this.set('colorScale', scaleLinear().range(COLORS[this.get('color')]));
this.set('xScale', scaleBand().range([ 0, 100 ]).paddingInner(0.12));
...
this.get('colorScale'.domain([ 0, Math.max(...counts) ]));
this.get('xScale').domain(data.map(data => data.label));
Ok this is looking good!
So we see some red lines down here, and that's because this code needs to reference our new properties instead of these local variables. So we'll just grab these, and replace them with getters, and same with our color scale.
barsEnter
.merge(barsUpdate)
.attr('width', `${this.get('xScale').bandwidth()}%`)
.attr('height', data => `${this.get('yScale')(data.count)}%`)
.attr('x', data => `${this.get('xScale')(data.label)}%`)
.attr('y', data => `${100 - this.get('yScale')(data.count)}%`)
.attr('fill', data => this.get('colorScale')(data.count));
Alright let's save this and try it out. Ok awesome, everything seems to be still be working! And now we have our static scale creation up here in didInsertElement
, it runs once when we first render our component; and then our dynamic code that updates the scales runs whenever we call renderChart
, which happens whenever the data changes.
Ok, let's continue to improve our code.
Right now we have these two methods, renderChart
and updateOpacities
, that both control our bar chart's rendering. Now recall that we first extracted out updateOpacities
because we needed a separate method that we could call whenever our component state changed. And this was when we were doing all of our other rendering in didInsertElement
.
But now that we have this renderChart
method, we have a good place to re-render our bars whenever the data changes, thanks to the fact that we're now working with this update selection. So we can actually refactor this method and move this logic alongside the rest of our bar rendering logic right here.
So let's first come down to updateOpacities
, and we'll comment it out, just to verify that the behavior doesn't work without it. And if we check our app we'll know it's not working because when we select a category, we'll see that the label sticks, but the bar isn't highlighted.
So now let's come back to the code, come up here, and we'll just add a new attribute for opacity. And now we're setting the opacity for each individual bar.
Now if we come down and look at our old logic, it goes something like this: if there's no selected label, all bars should be at 100% opacity; if there is, find all the bars that aren't equal to that selected label and make them 50%, and then find the one bar that is selected, and make it 100%.
So really there's two conditions here. If we have a selected label and a particular bar is not that selected label, make it 50% opacity; otherwise, make it 100%. So let's write the logic like that. First we'll get the current value of the selectedLabel
, if it exists; and then here we just need to return the opacity as a string, kind of like this, since we're setting it using .attr
and it applies to each bar. So the logic is, if we have a selected label and the current label of this bar is not equal to that selected label, then we want to return 50% opacity, otherwise return 100%.
.attr('opacity', data => {
let selected = this.get('selectedLabel');
return (selected && data.label !== selected) ? '0.5' : '1.0';
});
So let's save this and try it out. Ok it looks like this is working again. And if we deselect it, they all have 100% opacity.
Now, we're almost done, but we actually just introduced a small bug. If we come down to our click handler, we'll see that we call updateOpacities
on this line right here. And recall that this was from when our chart managed all of its own state, earlier in this series.
Here let me show you this bug. We'll go back to the posts template, and let's make the second chart here manage its own state just by closing it there and commenting this out.
{{bar-chart data=categoryData color='green'}}
{{!-- selectedLabel=selectedCategory
on-click=(action 'toggleBar' 'selectedCategory')}} --}}
So now it'll manage its own selectedLabel
property internally. So let's save this and go back to the app. And now, if I click on a category, that selects it, but again the highlighting functionality is broken.
So the fix for this is actually pretty easy. Our renderChart
method is safe to call as much as we want, so we can just replace this with renderChart()
. Save this - and now our self-managed chart works again. Nice!
Let's come down here, we'll delete updateOpacities
from here, and from here and here. Let's undo the change in our template to make sure everything still works with filtered data, and everything seems to be functioning correctly! Nice.
And now looking at our bar chart component, we have this clean renderChart
method that's actually idempotent, meaning we can call it as much as we want, and it will always render our chart correctly based on the state of our component. As we've seen, this is a really robust way to write code when you have to deal with rendering that goes outside of Ember's normal templating layer.
In addition to the benefits of having an idempotent renderChart
method, it's generally a good idea to separate the dynamic parts of your D3 code from the static parts. Now in our case, having the static scales recreated every time we called renderChart
, it didn't actually break anything in our visualization - though it might have a created a small performance overhead.
But there are more sophisticated D3 visualizations you may write where it can be important to perform the static transformations only once, and where recreating them on update and exit would actually mess up some transitions or interactions. And that's why it's always a good idea to get in the habit of separating out your chart's static bootstrapping code - like creating scales or setting configuration values - from its dynamic code that will run many times as your chart's data changes.
In the next video, we'll add some motion to our charts by adding animated transitions.