Tree the whales!

Let's start with the most basic of hierarchical charts -- a tree! Create a new function and fill it with the following:

westerosChart.tree = function Tree(_data) { 
const data = getMajorHouses(_data);
const chart = this.container;
const stratify = d3.stratify()
.parentId(d => d.fatherLabel)
.id(d => d.itemLabel);
const root = stratify(data);
const layout = d3.tree()
.size([
this.innerWidth,
this.innerHeight,
]);
}

We use our next-to-be-written getMajorHouses() function to filter out characters who don't have a fatherLabel property and whose itemLabel isn't set as anybody's fatherLabel property. We then create a new stratify object and set its parentId() accessor function to each item's fatherLabel and the id() accessor to each item's itemLabel. We're able to do the latter with this dataset because we know that each itemLabel is distinct; if this was not the case (for instance, if you had a dataset where there were a few people named John Smith in it), you'd need to use a property other than the person's name as the unique identifier tying everything together.

We then pass our data to our newly defined stratify function to create our root. We also instantiate our tree layout, setting the size of our inner dimensions.

Time to write our data munging functions. First, we will write the addRoot function we imported from common. Go back to common and update addRoot to resemble the following:

export function addRoot(data, itemKey, parentKey, joinValue) { 
data.forEach((d) => { d[parentKey] = d[parentKey] || joinValue; });
data.push({
[parentKey]: '',
[itemKey]: joinValue,
});

return data;
}

The addRoot() function takes four arguments: the data, each item's ID property name, each item's parent ID property name, and a value to set the latter to if one isn't found. It does that, then adds this root element to the data array before returning it.

While we're here, let's fill out fixateColors and create a new function called uniques():

export const uniques = (data, name) => data.reduce( 
(uniqueValues, d) => {
uniqueValues.push(
(uniqueValues.indexOf(name(d)) < 0 ? name(d) : undefined));
return uniqueValues;
}, [])
.filter(i => i); // Filter by identity

export function fixateColors(data, key) {
colorScale.domain(uniques(data, d => d[key]));
}

This sets the domain of our exported color scale to whatever unique values we pass to it. Our uniques() function works by creating an array, adding items as defined by the function passed as the second argument, or skipping the item if another already exists in the results array.

Let's also add a function to get the name of a house by walking backward up the tree and grabbing the old ancestor's last name:

export const getHouseName = (d) => { 
const ancestors = d.ancestors();
let house;
if (ancestors.length > 1) {
ancestors.pop();
house = ancestors.pop().id.split(' ').pop();
} else {
house = 'Westeros';
}

return house;
};

Also, let's add a function to return a list of all the major house names:

export const houseNames = root =>    root.ancestors().shift().children.map(getHouseName);

Next, we'll remove any items that don't have much in terms of lineage data. After our imports section in chapter6/index, add the following:

const getMajorHouses = data =>
addRoot(data, 'itemLabel', 'fatherLabel', 'Westeros')
.map((d, i, a) => {
if (d.fatherLabel === 'Westeros') {
const childrenLen = a.filter(
e => e.fatherLabel === d.itemLabel).length;
return childrenLen > 0 ? d : undefined;
} else {
return d;
}
})
.filter(i => i);

We pass our data to addRoot in order to add Westeros as a root node, and from the results of that, filter out any nodes that have Westeros as a father and haven't set themselves as anyone's father.

Now, we need to draw stuff using the tree layout. Add this to westerosChart.tree:

const links = layout(root) 
.descendants()
.slice(1);

fixateColors(getHouseNames(root), 'id');
const line = d3.line().curve(d3.curveBasis);

chart.selectAll('.link')
.data(links)
.enter()
.append('path')
.attr('fill', 'none')
.attr('stroke', 'lightblue')
.attr('d', d => line([
[d.x, d.y],
[d.x, (d.y + d.parent.y) / 2],
[d.parent.x, (d.y + d.parent.y) / 2],
[d.parent.x, d.parent.y]],
));

Okay, this is where stuff starts to get interesting. We pass our root object created earlier to the layout generator. This gives us our layout data object. This has a method called descendants() that returns all the descendants of the root node, starting with the root node, and continuing down in topological order (we slice off the root node here because it doesn't have links). We then fixate our colors using the functions we wrote a bit earlier. We also create a new line generator with all the default settings and the curveBasis interpolator to make it look nice.

We then create a new selection, join our data to it, and append path elements for each connection. We then use each datum to manually draw paths between the child and parent nodes, putting in two midway points for the curve.

The way these links are generated is sort of less intuitive than it could be, which the D3 maintainers seem to realize needs improvement. Watch issue #27 on d3-shape at github.com/d3/d3-shape/issues/27, as this may change.

Next, let's draw circles for each node:

const nodes = chart.selectAll('.node') 
.data(root.descendants())
.enter()
.append('circle')
.attr('r', 4.5)
.attr('fill', getHouseColor)
.attr('class', 'node')
.attr('cx', d => d.x)
.attr('cy', d => d.y);

We get all the nodes using root.descendants() again and pass that to a new selection, appending circles to each node. We fill them using getHouseColor (which we'll define in a moment) and setting the radius to 4.5. Let's define getHouseColor now, at the top of chapter6/index, right after getMajorHouses():

const getHouseColor = (d) => { 
const ancestors = d.ancestors();
let house;
if (ancestors.length > 1) {
ancestors.pop();
house = ancestors.pop().id.split(' ').pop();
} else {
house = 'Westeros';
}

return color(house);
};

We take the ID, split it into an array using the space character, then take the last item in the array. We're assuming here that the person's last name has a space before it and is at the end of the person's name (Sammy Davis Jr., for instance, would cause issues). If the item only has one ancestor (itself), we set it to Westeros, as that's our root node. We then take whatever value we have and supply it to our color scale.

Next, we'll add our legend. Go back to westerosChart.tree and add the following:

const legendGenerator = legend 
.legendColor()
.scale(color);

this.container
.append('g')
.attr('id', 'legend')
.attr('transform', &grave;translate(0, ${this.innerHeight / 2})&grave;)
.call(legendGenerator);

This creates our legend generator and appends it to our container object, halfway down the height of the chart. We supply it our color scale so that it knows what items to put in the legend.

Go back to lib/main.js and modify it to resemble the following:

import '../styles/index.css'; 
import westerosChart from './chapter6/index';
westerosChart.init('tree', 'data/GoT-lineages-screentimes.json');

Switch to your browser (run npm start beforehand if your local development server isn't running), and you should see something like the following:

We're not done yet though, there aren't any labels!

We could do something like append text labels to each node (which is easy, considering each datum contains the node's itemLabel property), but there are a lot of nodes and the labels will likely collide into each other on most screen sizes. Instead, it's time to trundle to tooltip town! We will use tooltips to show our users what each item in our charts represents when they hover over it.

Go to common/index.js and update the tooltip() function with the following:

export function tooltip(text, chart) { 
return (selection) => {
function mouseover(d) {
const path = d3.select(this);
path.classed('highlighted', true);

const mouse = d3.mouse(chart.node());
const tool = chart.append('g')
.attr('id', 'tooltip')
.attr('transform',
&grave;translate(${mouse[0] + 5},${mouse[1] + 10})&grave;);

const textNode = tool.append('text')
.text(text(d))
.attr('fill', 'black')
.node();

tool.append('rect')
.attr('height', textNode.getBBox().height)
.attr('width', textNode.getBBox().width)
.style('fill', 'rgba(255, 255, 255, 0.6)')
.attr('transform', 'translate(0, -16)');

tool.select('text')
.remove();

tool.append('text').text(text(d));
}

function mousemove() {
const mouse = d3.mouse(chart.node());
d3.select('#tooltip')
.attr('transform',
&grave;translate(${mouse[0] + 15},${mouse[1] + 20})&grave;);
}

function mouseout() {
const path = d3.select(this);
path.classed('highlighted', false);
d3.select('#tooltip').remove();
}

selection.on('mouseover.tooltip', mouseover)
.on('mousemove.tooltip', mousemove)
.on('mouseout.tooltip', mouseout);
};
}

What we do here is create a factory function that returns a new function taking a selection argument. We then attach a bunch of event handlers to each state of the cursor hovering over an element -- when it's over the element, the tooltip element is populated with the specified text and the coordinates of the mouse. When it leaves the targeted element, it hides the tooltip. When it moves around atop of the targeted element, the tooltip's coordinates are updated.

Go back to westerosChart.tree and add the following to the bottom:

nodes.call(tooltip(d => d.data.itemLabel, this.container));

If you remember, our tooltip generator takes two arguments, an accessor function and a target container. We supply this to the generator, then call it on each of our nodes.

There you have it! Tooltips!

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset