So far we’ve only looked at static lifeless graphics with a few rollovers for additional information. Let’s make an animated visualization that shows the active flights over time between Melbourne and Sydney in Australia.
See the Pen D3 – scales by Haig Armen (@haigarmen) on CodePen.
The SVG document for this type of graphic is made up of text, lines and circles.
<svg id="chart" width="600" height="500">
<text class="time" x="300" y="50" text-anchor="middle">6:00</text>
<text class="origin-text" x="90" y="75" text-anchor="end">MEL</text>
<text class="dest-text" x="510" y="75" text-anchor="start">SYD</text>
<circle class="origin-dot" r="5" cx="100" cy="75" />
<circle class="dest-dot" r="5" cx="500" cy="75" />
<line class="origin-dest-line" x1="110" y1="75" x2="490" y2="75" />
<!-- for each flight in the current time -->
<g class="flight">
<text class="flight-id" x="160" y="100">JQ 500</text>
<line class="flight-line" x1="100" y1="100" x2="150" y2="100" />
<circle class="flight-dot" cx="150" cy="100" r="5" />
</g>
</svg>
The dynamic parts are the time and the elements within the flight group and the data might look something like this:
let data = [
{ departs: '06:00 am', arrives: '07:25 am', id: 'Jetstar 500' },
{ departs: '06:00 am', arrives: '07:25 am', id: 'Qantas 400' },
{ departs: '06:00 am', arrives: '07:25 am', id: 'Virgin 803' }
]
To get an x position for a dynamic time we’ll need to create a time scale for each flight that maps its departure and arrival times to an x position on our chart. We can loop through our data at the start adding Date
objects and scales so they’re easier to work with. Moment.js helps a lot here with date parsing and manipulation.
data.forEach((d)=> {
d.departureDate = moment(d.departs, "hh-mm a").toDate();
d.arrivalDate = moment(d.arrives, "hh-mm a").toDate();
d.xScale = d3.time.scale()
.domain([departureDate, arrivalDate])
.range([100, 500])
});
We can now pass our changing Date to xScale()
to get an x coordinate for each flight.
Render Loop
Departure and arrival times are rounded to 5 minutes so we can step through our data in 5m increments from the first departure to the last arrival.
let now = moment(data[0].departs, "hh:mm a");
const end = moment(data[data.length - 1].arrives, "hh:mm a");
const loop = function() {
const time = now.toDate();
// Filter data set to active flights in the current time
const currentData = data.filter((d)=> {
return d.departureDate <= time && time <= d.arrivalDate
});
render(currentData, time);
if (now <= end) {
// Increment 5m and call loop again in 500ms
now = now.add(5, 'minutes');
setTimeout(loop, 500);
}
}
Enter, Update and Exit
D3 allows you to specify transformations and transitions of elements when:
- New data points come in (Enter)
- Existing data points change (Update)
- Existing data points are removed (Exit)
const render = function(data, time) {
// render the time
d3.select('.time')
.text(moment(time).format("hh:mm a"))
// Make a d3 selection and apply our data set
const flight = d3.select('#chart')
.selectAll('g.flight')
.data(data, (d)=> d.id)
// Enter new nodes for any data point with an id not in the DOM
const newFlight = flight.enter()
.append("g")
.attr('class', 'flight')
const xPoint = (d)=> d.xScale(time);
const yPoint = (d, i)=> 100 + i * 25;
newFlight.append("circle")
.attr('class',"flight-dot")
.attr('cx', xPoint)
.attr('cy', yPoint)
.attr('r', "5")
// Update existing nodes in selection with id's that are in the data
flight.select('.flight-dot')
.attr('cx', xPoint)
.attr('cy', yPoint)
// Exit old nodes in selection with id's that are not in the data
const oldFlight = flight.exit()
.remove()
}
Transitions
The code above renders a frame every 500ms with a 5 minute time increment:
- It updates the time
- Creates a new flight group with a circle for every flight
- Updates the x/y coordinates of current flights
- Removes the flight groups when they’ve arrived
This works but what we really want is a smooth transition between each of these frames. We can achieve this by creating a transition on any D3 selection and providing a duration and easing function before setting attributes or style properties.
For example, let’s fade in the opacity of entering flight groups.
const newFlight = flight.enter()
.append("g")
.attr('class', 'flight')
.attr('opacity', 0)
newFlight.transition()
.duration(500)
.attr('opacity', 1)
Let’s fade out exiting flight groups.
flight.exit()
.transition()
.duration(500)
.attr('opacity', 0)
.remove()
Add a smooth transition between the x and y points.
flight.select('.flight-dot')
.transition()
.duration(500)
.ease('linear')
.attr('cx', xPoint)
.attr('cy', yPoint)
We can also transition the time between the 5 minute increments so that it displays every minute rather than every five minutes using the tween
function.
const inFiveMinutes = moment(time).add(5, 'minutes').toDate();
const i = d3.interpolate(time, inFiveMinutes);
d3.select('.time')
.transition()
.duration(500)
.ease('linear')
.tween("text", ()=> {
return function(t) {
this.textContent = moment(i(t)).format("hh:mm a");
};
});
t
is a progress value between 0 and 1 for the transition.
Leave a Reply