24 KiB
D3 Build
Lesson Objectives
- Add link to d3 library
- Add an
<svg>tag and size it with D3 - Create some fake data for our app
- Add SVG circles and style them
- Create a linear scale
- Attach data to visual elements
- Use data attached to a visual element to affect its appearance
- Create a time scale
- Parse and format times
- Set dynamic domains
- Dynamically generate svg elements
- Create axes
- Display data in a table
- Create click handler
- Remove data
- Drag an element
- Update data after a drag
- Create a zoom behavior that scales elements
- Update axes when zooming
- Update click points after a transform
- Avoid redrawing entire screen during render
- Hide elements beyond axis
- Use AJAX
Add link to d3 library
First thing we want to do is create basic index.html file:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
</body>
</html>
Now add a link to D3 at the bottom of your <body> tag in index.html:
<body>
<script src="https://d3js.org/d3.v4.min.js"></script>
</body>
Now create app.js, which will store all of our code:
console.log('this works');
and link to it in index.html at the bottom of the <body> tag:
<body>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="app.js" charset="utf-8"></script>
</body>
Add an <svg> tag and size it with D3
At the top of the <body> tag in index.html, add an <svg> tag:
<body>
<svg></svg>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="app.js" charset="utf-8"></script>
</body>
In app.js create variables to hold the width and height of the <svg> tag:
var WIDTH = 800;
var HEIGHT = 600;
Next, we can use d3.select() to select a single element, in this case, the <svg> element:
var WIDTH = 800;
var HEIGHT = 600;
d3.select('svg');
The return value of this is a d3 version of the element (just like jQuery), so we "chain" commands onto this. Let's add some styling to adjust the height/width of the element:
d3.select('svg')
.style('width', WIDTH)
.style('height', HEIGHT);
Create some fake data for our app
In app.js let's create an array of "run" objects (NOTE I'm storing the date as a string on purpose. Also, it's important that this be an array of objects, in order to work with D3):
var WIDTH = 800;
var HEIGHT = 600;
var runs = [
{
id: 1,
date: 'October 1, 2017 at 4:00PM',
distance: 5.2
},
{
id: 2,
date: 'October 2, 2017 at 5:00PM',
distance: 7.0725
},
{
id: 3,
date: 'October 3, 2017 at 6:00PM',
distance: 8.7
}
];
Add SVG circles and style them
Add three circles to your <svg> element (each one will represent a run):
<svg>
<circle/>
<circle/>
<circle/>
</svg>
Create app.css with some styling for the circles and our svg element:
circle {
r:5;
fill: black;
}
svg {
border: 1px solid black;
}
and link to it in index.html
<head>
<meta charset="utf-8">
<title></title>
<link rel="stylesheet" href="app.css">
</head>
Create a linear scale
- Let's position the circles vertically, based on the distance run
- One of the most important things that D3 does is provide the ability to map points in the "domain" of data to points in the visual "range" using what's called a
scale. - There are lots of different kinds of scales, but for now we're just going to use a
linearscale which will map numeric data values to numeric visual values.
In app.js:
d3.select('svg')
.style('width', WIDTH)
.style('height', HEIGHT);
var yScale = d3.scaleLinear(); //create the scale
yScale.range([HEIGHT, 0]); //set the visual range (e.g. 600 to 0)
yScale.domain([0, 10]); //set the data domain (e.g. 0 to 10)
console.log(yScale(5)); //get a visual point from a data value
console.log(yScale.invert(450)); //get a data values from a visual point
- Here we're saying that a data point of 0 to map to a visual height value of 600
- This is because the lower the distance run (data value), the more we want to move the visual point down the Y axis
- remember that the Y axis starts at 0 at the top and increases in value
Attach data to visual elements
We can attach each of our "run" objects to one of our circles, so that each circle can access that data:
yScale.range([HEIGHT, 0]);
yScale.domain([0, 10]);
d3.selectAll('circle').data(runs); //selectAll is like select, but selects all elements that match the query string
Use data attached to a visual element to affect its appearance
When setting a value for an element's style, class, id or any other attribute, we can pass that method a callback instead of a static value.
d3.selectAll('circle').data(runs)
.attr('cy', function(datum, index){
return yScale(datum.distance);
});
- That callback function runs for each visual element selected
- The result of the function is then assigned to whatever aspect of the element is being set (in this case the
cyattribute) - The callback function takes two params
- the individual
datumobject (from the originalrunsarray of objects) attached to that particular visual element - the
indexof thatdatumin the originalrunsarray
- the individual
Create a time scale
- Let's position the circles horizontally, based on the date that they happened
- First create a time scale:
var xScale = d3.scaleTime(); //scaleTime maps date values with numeric visual points
xScale.range([0,WIDTH]);
xScale.domain([new Date('2017-10-1'), new Date('2017-10-31')]);
console.log(xScale.domain()); //you can get the domain whenever you want like this
console.log(xScale.range()); //you can get the range whenever you want like this
Parse and format times
- Note that our
datedata isn't in the format expected by the xScale domain - D3 provides us an easy way to convert strings to dates and vice versa
var parseTime = d3.timeParse("%B%e, %Y at %-I:%M%p");
console.log(parseTime('October 3, 2017 at 6:00PM'));
var formatTime = d3.timeFormat("%B%e, %Y at %-I:%M%p");
console.log(formatTime(new Date()));
Let's use this when calculating cx attributes for our circles:
var parseTime = d3.timeParse("%B%e, %Y at %-I:%M%p");
d3.selectAll('circle')
.attr('cx', function(datum, index){
return xScale(parseTime(datum.date)); //use parseTime to convert the date string property on the datum object to a Date object
});
Set dynamic domains
- At the moment, we're setting up arbitrary min/max values for both distance/date
- D3 can find the min/max of a data set, so that our graph displays just the data ranges we need:
- we pass the min/max methods a callback which gets called for each item of data in the array
- d3 uses the callback to determine which values to compare for min/max
var yMin = d3.min(runs, function(datum, index){
return datum.distance; //compare distance properties of each item in the data array
})
var yMax = d3.max(runs, function(datum, index){
return datum.distance; //compare distance properties of each item in the data array
})
yScale.domain([yMin, yMax]);
We can combine both of these functions into one "extent" function that returns both:
var yDomain = d3.extent(runs, function(datum, index){
return datum.distance; //compare distance properties of each item in the data array
})
yScale.domain(yDomain);
Let's do the same for the xScale's domain:
var parseTime = d3.timeParse("%B%e, %Y at %-I:%M%p");
var xScale = d3.scaleTime();
xScale.range([0,WIDTH]);
xDomain = d3.extent(runs, function(datum, index){
return parseTime(datum.date);
});
xScale.domain(xDomain);
Dynamically generate svg elements
- Currently, we have just enough
<circle>elements to fit our data. What if we don't want to count how many elements are in the array? - D3 Can create elements as needed
- First, remove all
<circle>elements fromindex.html
<svg></svg>
In app.js add the code to create the circles:
d3.select('svg').selectAll('circle') //since no circles exist, we need to select('svg') so that d3 knows where to append the new circles
.data(runs) //attach the data as before
.enter() //find the data objects that have not yet been attached to visual elements
.append('circle'); //for each data object that hasn't been attached, append a <circle> to the <svg>
Create axes
D3 can automatically generate axes for you:
var bottomAxis = d3.axisBottom(xScale); //pass the appropriate scale in as a parameter
d3.select('svg')
.append('g') //put everything inside a group
.call(bottomAxis) //generate the axis within the group
.attr('transform', 'translate(0,'+HEIGHT+')'); //move it to the bottom
Currently, our SVG clips the axis. Let's change some CSS so it doesn't:
svg {
overflow: visible;
}
The left axis is pretty similar:
var leftAxis = d3.axisLeft(yScale);
d3.select('svg')
.append('g')
.call(leftAxis); //no need to transform, since it's placed correctly initially
It's a little tough, so let's adding some margin to the body:
body {
margin: 20px 40px;
}
Display data in a table
Just for debugging purposes, let's create a table which will show all of our data:
<svg></svg>
<table>
<thead>
<tr>
<th>id</th>
<th>date</th>
<th>distance</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
Now populate the <tbody>:
var createTable = function(){
for (var i = 0; i < runs.length; i++) {
var row = d3.select('tbody').append('tr');
row.append('td').html(runs[i].id);
row.append('td').html(runs[i].date);
row.append('td').html(runs[i].distance);
}
}
createTable();
And a little styling:
svg {
overflow: visible;
margin-bottom: 50px;
}
table, th, td {
border: 1px solid black;
}
th, td {
padding:10px;
text-align: center;
}
Create click handler
Let's say that we want it so that when the user clicks on the <svg> element, it creates a new run.
var formatTime = d3.timeFormat("%B%e, %Y at %-I:%M%p"); //will take a date object and return a formatted string
d3.select('svg').on('click', function(){
var x = d3.event.offsetX; //gets the x position of the mouse relative to the svg element
var y = d3.event.offsetY; //gets the y position of the mouse relative to the svg element
var date = xScale.invert(x) //get a date value from a visual point
var distance = yScale.invert(y); //get a numeric distance value from a visual point
var newRun = { //create a new "run" object
id: runs[runs.length-1].id+1, //generate a new id by adding 1 to the last run's id
date: formatTime(date), //format the date object created above
distance: distance //add the distance
}
runs.push(newRun); //push the new run onto the runs array
createTable(); //render the table
});
You might notice that createTable() just adds on all the run rows again. Let's clear out the previous rows:
var createTable = function(){
d3.select('tbody').html(''); //clear out all rows from the table
for (var i = 0; i < runs.length; i++) {
var row = d3.select('tbody').append('tr');
row.append('td').html(runs[i].id);
row.append('td').html(runs[i].date);
row.append('td').html(runs[i].distance);
}
}
Now put the code for creating <circles> inside a render function:
var render = function(){
var yScale = d3.scaleLinear();
yScale.range([HEIGHT, 0]);
yDomain = d3.extent(runs, function(datum, index){
return datum.distance;
})
yScale.domain(yDomain);
d3.select('svg').selectAll('circle')
.data(runs)
.enter()
.append('circle');
d3.selectAll('circle')
.attr('cy', function(datum, index){
return yScale(datum.distance);
});
var parseTime = d3.timeParse("%B%e, %Y at %-I:%M%p");
var xScale = d3.scaleTime();
xScale.range([0,WIDTH]);
xDomain = d3.extent(runs, function(datum, index){
return parseTime(datum.date);
});
xScale.domain(xDomain);
d3.selectAll('circle')
.attr('cx', function(datum, index){
return xScale(parseTime(datum.date));
});
}
render();
For future use, let's move the xScale and yScale out of the render function along with the code for creating the domains/ranges:
var xScale = d3.scaleTime();
var parseTime = d3.timeParse("%B%e, %Y at %-I:%M%p");
xScale.range([0,WIDTH]);
xDomain = d3.extent(runs, function(datum, index){
return parseTime(datum.date);
});
xScale.domain(xDomain);
var yScale = d3.scaleLinear();
yScale.range([HEIGHT, 0]);
yDomain = d3.extent(runs, function(datum, index){
return datum.distance;
})
yScale.domain(yDomain);
var render = function(){
//...rest of render function without xScale and yScale declarations and domain code
}
render();
Let's call render() inside our <svg> click handler:
runs.push(newRun);
createTable();
render();
Remove data
- Let's set up a click handler on a
<circle>to remove that data element from the array - We'll need to do this inside the
render()declaration so that the click handlers are attached after the circles are created
//put this inside the render function, so that click handlers are attached when the circle is created
d3.selectAll('circle').on('click', function(datum, index){
d3.event.stopPropagation(); //stop click event from propagating to the SVG element and creating a run
runs = runs.filter(function(run, index){ //create a new array that has removed the run with the correct id. Set it to the runs var
return run.id != datum.id;
});
render(); //re-render dots
createTable(); //re-render table
});
The <circle> elements aren't be removed though. Let's put them in a <g> so that it's easy to clear out:
<svg>
<g id="points"></g>
</svg>
Now we can clear out the <circle> elements each time render() is called.
var render = function(){
d3.select('#points').html(''); //clear out all circles when rendering
d3.select('#points').selectAll('circle') //add circles to #points group, not svg
.data(runs)
.enter()
.append('circle');
}
Let's put in a little code to handle when the user has deleted all runs and tries to add a new one:
//inside svg click handler
var newRun = {
id: ( runs.length > 0 ) ? runs[runs.length-1].id+1 : 1,
date: formatTime(date),
distance: distance
}
Lastly, let's put in some css, so we know we're clicking on a circle:
circle {
r: 5;
fill: black;
transition: r 0.5s linear, fill 0.5s linear; /* transition */
}
circle:hover { /* hover state */
r:10;
fill: blue;
}
Drag an element
- D3 allows us to create complex interactions called "behaviors" which have multiple callbacks
- Two steps:
- create the behavior
- attach the behavior to one or more elements
- drag behaviors have three callbacks
- when the user starts to drag
- each time the user moves the cursor before releasing the "mouse" button
- when the user releases the "mouse" button
//put this code at the end of the render function
var drag = function(datum){
var x = d3.event.x; //get current x position of the cursor
var y = d3.event.y; //get current y position of the cursor
d3.select(this).attr('cx', x); //change the dragged element's cx attribute to whatever the x position of the cursor is
d3.select(this).attr('cy', y); //change the dragged element's cy attribute to whatever the y position of the cursor is
}
var dragBehavior = d3.drag() //create a drag behavior
// .on('start', dragStart) //dragStart is a reference to a function we haven't created yet
.on('drag', drag); //call the "drag" function (the 2nd param) each time the user moves the cursor before releasing the mouse button. The "drag" function is defined above
// .on('end', dragEnd); //dragEnd is a reference to a function we haven't created yet
d3.selectAll('circle').call(dragBehavior); //attach the dragBehavior behavior to all <circle> elements
Update data after a drag
- Uncomment the
.on('end', dragEnd)code - Create the callback function
var dragEnd = function(datum){
var x = d3.event.x; //get current x position of the cursor
var y = d3.event.y; //get current y position of the cursor
var date = xScale.invert(x); //get the date by using the xScale to invert the x position of the mouse
var distance = yScale.invert(y); //get the distance by using the yScale to invert the y position of the mouse
//since datum is an object, which is are passed by reference, we can update a property on the object, and the original variable will update
datum.date = formatTime(date); //use formatTime() to convert the date variable, which is a Date object, into the appropriate string
datum.distance = distance; //change the distance
createTable(); //redraw the table
}
var dragBehavior = d3.drag()
// .on('start', dragStart)
.on('drag', drag)
.on('end', dragEnd);
Let's change the color of a circle while it's being dragged too:
circle:active {
fill: red;
}
Create a zoom behavior that scales elements
- Another behavior we can create is the zooming ability
- two finger drag
- mouse wheel
- pinch/spread
//put this at the end of app.js
var zoomCallback = function(){ //the callback function
d3.select('#points').attr("transform", d3.event.transform); //transform the #points <g> element based on the zoom transform created
}
var zoom = d3.zoom() //create the zoom behavior
.on('zoom', zoomCallback); //tell it which callback function to use when zooming
d3.select('svg').call(zoom); //attach the behavior to the svg element
Update axes when zooming
To update the axes, let's first add ids to the <g> elements that contain them
d3.select('svg')
.append('g')
.attr('id', 'x-axis') //add an id
.call(bottomAxis)
.attr('transform', 'translate(0,'+HEIGHT+')');
var leftAxis = d3.axisLeft(yScale);
d3.select('svg')
.append('g')
.attr('id', 'y-axis') //add an id
.call(leftAxis);
Now let's use those ids when we zoom:
var zoomCallback = function(){
d3.select('#points').attr("transform", d3.event.transform);
d3.select('#x-axis').call(bottomAxis.scale(d3.event.transform.rescaleX(xScale)));
d3.select('#y-axis').call(leftAxis.scale(d3.event.transform.rescaleY(yScale)));
}
bottomAxis.scale()tells the axis to redraw itselfd3.event.transform.rescaleX(xScale)returns a value indicating how the bottom axis should rescale
Update click points after a transform
- If we click on the svg to create new runs, they circles/data created are incorrect
- When we zoom, we need to save the transformation to a variable
var lastTransform = null; //reference to the last transform that happened
var zoomCallback = function(){
lastTransform = d3.event.transform; //update the transform reference
d3.select('#points').attr("transform", d3.event.transform);
d3.select('#x-axis').call(bottomAxis.scale(d3.event.transform.rescaleX(xScale)));
d3.select('#y-axis').call(leftAxis.scale(d3.event.transform.rescaleY(yScale)));
}
Now use that reference to the last transform when clicking on the svg:
d3.select('svg').on('click', function(){
var x = lastTransform.invertX(d3.event.offsetX); //invert the transformation so we get a proper x value
var y = lastTransform.invertY(d3.event.offsetY);
var date = xScale.invert(x)
var distance = yScale.invert(y);
var newRun = {
id: ( runs.length > 0 ) ? runs[runs.length-1].id+1 : 1,
date: formatTime(date),
distance: distance
}
runs.push(newRun);
createTable();
render();
});
But now clicking before any zoom is broken, since lastTransform will be null:
//set x/y like normal
var x = d3.event.offsetX;
var y = d3.event.offsetY;
//if lastTransform has been updated, overwrite these values
if(lastTransform !== null){
x = lastTransform.invertX(d3.event.offsetX);
y = lastTransform.invertY(d3.event.offsetY);
}
Avoid redrawing entire screen during render
- At the moment, we wipe all
<circle>elements in the<svg>each time we callrender()- This is inefficient. Let's just remove the ones we don't want
- We'll use
.exit()to find the selection of circles that haven't been matched with data- then we'll use
.remove()to remove those circles
- then we'll use
d3.select('#points').selectAll('circle')
.data(runs)
.enter()
.append('circle');
d3.selectAll('circle').exit().remove(); //remove all circles not associated with data
d3.selectAll('circle')
.attr('cy', function(datum, index){
return yScale(datum.distance);
});
d3.selectAll('circle')
.attr('cx', function(datum, index){
return xScale(parseTime(datum.date));
});
- This can cause weird side effects, because some circles are being reassigned to a different set of data
- if we remove a piece of data in the center of the array, the
<circle>in the the DOM that was assigned to it gets reassigned to the piece of data that used to be assigned to the next sibling<circle>in the DOM. Each<circle>gets reassigned over one space from there on out - to avoid these affects, we need to make sure that each circle stays with the data it used to be assigned to
- to do this, we can tell D3 to map
<circles>to datum by id, rather than index in the array
- to do this, we can tell D3 to map
- if we remove a piece of data in the center of the array, the
var circles = d3.select('#points').selectAll('circle').data(runs, function(datum){ //when redrawing circles, make sure pre-existing circles match with their old data
return datum.id
});
circles.enter()
.append('circle')
.attr('cy', function(datum, index){
return yScale(datum.distance);
})
.attr('cx', function(datum, index){
return xScale(parseTime(datum.date));
});
circles.exit().remove();
Hide elements beyond axis
To remove elements once they get beyond an axis, we can just add an outer SVG:
<svg id="container">
<svg>
<g id="points"></g>
</svg>
</svg>
Now replace all d3.select('svg') with d3.select('#container')
d3.select('#container')
.style('width', WIDTH)
.style('height', HEIGHT);
//
// ...
//
d3.select('#container')
.append('g')
.attr('id', 'x-axis')
.call(bottomAxis)
.attr('transform', 'translate(0,'+HEIGHT+')');
var leftAxis = d3.axisLeft(yScale);
d3.select('#container')
.append('g')
.attr('id', 'y-axis')
.call(leftAxis);
//
// ...
//
d3.select('#container').on('click', function(){
//
// ...
//
});
//
// ...
//
d3.select('#container').call(zoom);
And lastly, adjust css:
#container {
overflow: visible;
margin-bottom: 20px;
margin-left: 30px;
}
Use AJAX
We'll have to do some cleanup before we can do AJAX
- Move all functions to the top of the page
- Group all variable declarations together below function declarations
- Move the rest of the initializing code inside an init function an call it
- Move runs data to external
data.jsonfile - Set
var runs = nullat top of page - Set runs and call
init()after ajax call succeeds:
d3.json('data.json', function(error, data){
runs = data;
init();
});