In my previous article, I cover a few different examples of using DeckGL with static data. But what if we want to see how data on a map will change over time?
In this article, I'm going to walk through creating an animated visualization of NYC Taxi trip data, similar to the example shown here.
The data we'll be using for this comes from the NYC Tax Trip data, but we'll be using a pre-formatted version from the VisGL team that can be found here.
Let's take a look at a single record from the dataset, which has three fields: vendor, path, and timestamps.
{
"vendor": 0,
"path": [
[ -74.20986, 40.81773 ],
[ -74.20987, 40.81765 ],
[ -74.20998, 40.81746 ],
[ -74.21062, 40.81682 ],
[ -74.21002, 40.81644 ],
[ -74.21084, 40.81536 ],
[ -74.21142, 40.8146 ],
[ -74.20965, 40.81354 ],
[ -74.21166, 40.81158 ],
...
],
"timestamps": [
1191,
1193.803,
1205.321,
1249.883,
1277.923,
1333.85,
1373.257,
1451.769,
...
]
}
The vendor field in this case is what we'll be using to key off of for color. More interestingly are the path and timestamp fields. In our dataset, there should be a corresponding timestamp for path entry. This is how the TripsLayer knows which point to draw, based on the current time of the map.
Another useful piece of information to know is the range of timestamp values in the dataset. For this example, we can see the range of timestamps in the trips data goes from 6..2486.
const timestamps = trips.reduce(
(ts, trip) => ts.concat(trip.timestamps),
[]
);
console.log('Min:', Math.min(...timestamps)); // 6
console.log('Max:', Math.max(...timestamps)); // 2486.511
Knowing this range will be useful for figuring out how we'll step through the data.
For this visualization, we'll be using the TripsLayer. It's a pretty standard DeckGL layer, with properties for accessing and styling the data. Let's take a look at a simple example.
new TripsLayer({
id: 'trips',
data: '/data/detailed-trips.json',
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: d => (d.vendor === 0 ? [253, 128, 93] : [23, 184, 190]),
opacity: 0.5,
widthMinPixels: 3,
rounded: true,
trailLength: 150,
currentTime: 0,
}),
However, if you were to render this Layer, you'd probably just see an empty map. The key in all of this is the currentTime variable. This variable tells DeckGL which path coordinate to render, based on the the corresponding timestamp.
See what happens in the example below as we adjust the time within the range we previously found from the data.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import React, { useState, useEffect } from 'react';
import DeckGL from 'deck.gl';
import { TripsLayer } from '@deck.gl/geo-layers';
import { StaticMap } from 'react-map-gl';
const { assign } = Object;
import { isMobile } from '../util/mobile';
import config from '../config';
const mapStyle = 'mapbox://styles/mapbox/light-v9';
const mapboxApiAccessToken = config('mapboxApiAccessToken');
const initialStyle = {
position: "relative",
width: "100%",
height: "550px",
border: "1px solid black",
};
const mobileStyle = {
height: "300px",
};
// Viewport settings
const initialViewState = {
latitude: -73.97744746237277,
latitude: 40.715196585765504,
longitude: -73.97840470334431,
zoom: 11.9,
minZoom: 2,
maxZoom: 15,
pitch: 0,
bearing: 0,
};
const mobileViewState = {
latitude: 40.72491632450353,
longitude: -73.98669446445103,
zoom: 11.38228886864759,
};
const BLUE = [23, 184, 190];
const RED = [253, 128, 93];
const DeckGLTripsManualMap = () => {
const [style, setStyle] = useState(assign({}, initialStyle));
const [viewState, setViewState] = useState(initialViewState);
const [time, setTime] = useState(0);
useEffect(() => {
if (isMobile()) {
setStyle(assign({}, initialStyle, mobileStyle))
setViewState(assign({}, initialViewState, mobileViewState))
}
}, []);
const layers = [
new TripsLayer({
id: 'trips',
data: '/data/detailed-trips.json',
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: d => (d.vendor === 0 ? RED : BLUE),
opacity: 0.5,
widthMinPixels: 3,
rounded: true,
trailLength: 180,
currentTime: time,
}),
];
return (
<>
<DeckGL
controller
viewState={viewState}
layers={layers}
style={style}
onViewStateChange={
(nextViewState) => {
setViewState(nextViewState.viewState);
}
}
>
<StaticMap mapboxApiAccessToken={mapboxApiAccessToken} mapStyle={mapStyle}>
<div style={{ margin: "0.5rem", fontFamily: "monospace", fontSize: "18px" }}>
Current Time: {time}
</div>
</StaticMap>
</DeckGL>
<div style={{ width: '100%', marginTop: "1.5rem" }}>
<input
style={{ width: '100%' }}
type="range"
min="0"
max="2486"
step="0.1"
value={time}
onChange={(e) => { setTime(Number(e.target.value)); }}
/>
</div>
</>
);
}
export default DeckGLTripsManualMap;
As we can see, even from the discrete coordinates of our path data, we get these nice smooth lines that run along each trip. The TripsLayer is doing some smoothing and extrapolating of the data along the path as we move through the timestamps, which makes for a more appealing visualization rather than just plotting the points.
If you don't want to your users to move sliders around all day, we can automate the stepping through time. A pretty simple approach would be to just use a setInterval to increment the current time.
// variables
const step = 1;
const intervalMS = 20;
const loopLength = 2500;
const [time, setTime] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setTime(t => (t + step) % loopLength);
}, intervalMS);
return () => clearInterval(interval);
}, []);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import React, { useState, useEffect } from 'react';
import DeckGL from 'deck.gl';
import { TripsLayer } from '@deck.gl/geo-layers';
import { StaticMap } from 'react-map-gl';
const { assign } = Object;
import { isMobile } from '../util/mobile';
import config from '../config';
const mapStyle = 'mapbox://styles/mapbox/light-v9';
const mapboxApiAccessToken = config('mapboxApiAccessToken');
const initialStyle = {
position: "relative",
width: "100%",
height: "550px",
border: "1px solid black",
};
const mobileStyle = {
height: "300px",
}
// Viewport settings
const initialViewState = {
latitude: 40.71460213064598,
longitude: -73.97744746237277,
zoom: 11.5,
minZoom: 2,
maxZoom: 15,
pitch: 0,
bearing: 0,
};
const mobileViewState = {
latitude: 40.72491632450353,
longitude: -73.98669446445103,
zoom: 11.38228886864759,
};
const BLUE = [23, 184, 190];
const RED = [253, 128, 93];
// variables
const step = 1;
const intervalMS = 20;
const loopLength = 2500;
const DeckGLTripsAnimateMap = ({ running }) => {
const [style, setStyle] = useState(assign({}, initialStyle));
const [viewState, setViewState] = useState(initialViewState);
const [time, setTime] = useState(0);
const [interval, setCurrentInterval] = useState(null);
useEffect(() => {
if (isMobile()) {
setStyle(assign({}, initialStyle, mobileStyle))
setViewState(assign({}, initialViewState, mobileViewState))
}
}, []);
const animate = () => {
if (running) {
// increment time by "step" on each loop
setTime(t => (t + step) % loopLength);
}
};
useEffect(() => {
if (!running) {
clearInterval(interval);
return;
}
// start loop
const currentInterval = setInterval(animate, intervalMS);
setCurrentInterval(currentInterval)
return () => clearInterval(currentInterval);
}, [running]);
const layers = [
new TripsLayer({
id: 'trips',
data: '/data/detailed-trips.json',
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: d => (d.vendor === 0 ? RED : BLUE),
opacity: 0.5,
widthMinPixels: 3,
rounded: true,
trailLength: 180,
currentTime: time,
}),
];
return (
<DeckGL
controller
viewState={viewState}
layers={layers}
style={style}
onViewStateChange={
(nextViewState) => {
setViewState(nextViewState.viewState);
}
}
>
<StaticMap mapboxApiAccessToken={mapboxApiAccessToken} mapStyle={mapStyle}>
<div style={{ margin: "0.5rem", fontFamily: "monospace", fontSize: "18px" }}>
Current Time: {time}
</div>
</StaticMap>
</DeckGL>
);
}
export default DeckGLTripsAnimateMap;
Using setInterval works pretty well for most use cases, but if we're performing a lot of work, doing some very heavy visualizations during each interval, the animation will likely start to break down and become "choppy". This generally tends to happen if it takes longer to execute the loop than the interval (intervals will start getting backed up).
A better approach is to use the newer requestAnimationFrame function. I won't go into too much detail here since there's already a ton of great resources available online, but essentially, it allows the browser to request the interval when it's ready (based on how long the previous loop took to render).
It's simple enough to convert our previous interval loop to use requestAnimationFrame, we just need make our animate function to re-call itself on the next animation frame, and kick off the call to animate in useEffect.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const [animation] = useState({});
const animate = () => {
setTime(t => (t + step) % loopLength);
animation.id = window.requestAnimationFrame(animate); // draw next frame
};
useEffect(() => {
if (!running) {
window.cancelAnimationFrame(animation.id);
return;
}
animation.id = window.requestAnimationFrame(animate); // start animation
return () => window.cancelAnimationFrame(animation.id);
}, [running]);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import React, { useState, useEffect } from 'react';
import DeckGL from 'deck.gl';
import { TripsLayer } from '@deck.gl/geo-layers';
import { StaticMap } from 'react-map-gl';
const { assign } = Object;
import { isMobile } from '../util/mobile';
import config from '../config';
const mapStyle = 'mapbox://styles/mapbox/light-v9';
const mapboxApiAccessToken = config('mapboxApiAccessToken');
const initialStyle = {
position: "relative",
width: "100%",
height: "550px",
border: "1px solid black",
};
const mobileStyle = {
height: "300px",
}
// Viewport settings
const initialViewState = {
latitude: 40.71460213064598,
longitude: -73.97744746237277,
zoom: 11.5,
minZoom: 2,
maxZoom: 15,
pitch: 0,
bearing: 0,
};
const mobileViewState = {
latitude: 40.72491632450353,
longitude: -73.98669446445103,
zoom: 11.38228886864759,
};
const BLUE = [23, 184, 190];
const RED = [253, 128, 93];
// variables
const step = 1;
const loopLength = 2500;
let loopRunning = false;
const DeckGLTripsAnimateMap = ({ running }) => {
const [style, setStyle] = useState(assign({}, initialStyle));
const [viewState, setViewState] = useState(initialViewState);
const [time, setTime] = useState(0);
const [animation] = useState({});
useEffect(() => {
if (isMobile()) {
setStyle(assign({}, initialStyle, mobileStyle))
setViewState(assign({}, initialViewState, mobileViewState))
}
}, []);
const animate = () => {
if (loopRunning) { // use variable outside of closure to allow toggle
setTime(t => (t + step) % loopLength);
animation.id = window.requestAnimationFrame(animate); // draw next frame
}
};
useEffect(() => {
if (!running) {
loopRunning = false;
window.cancelAnimationFrame(animation.id);
return;
}
loopRunning = true;
animation.id = window.requestAnimationFrame(animate); // start animation
return () => {
loopRunning = false;
window.cancelAnimationFrame(animation.id);
};
}, [running]);
const layers = [
new TripsLayer({
id: 'trips',
data: '/data/detailed-trips.json',
getPath: d => d.path,
getTimestamps: d => d.timestamps,
getColor: d => (d.vendor === 0 ? RED : BLUE),
opacity: 0.5,
widthMinPixels: 3,
rounded: true,
trailLength: 180,
currentTime: time,
}),
];
return (
<DeckGL
controller
viewState={viewState}
layers={layers}
style={style}
onViewStateChange={
(nextViewState) => {
setViewState(nextViewState.viewState);
}
}
>
<StaticMap mapboxApiAccessToken={mapboxApiAccessToken} mapStyle={mapStyle}>
<div style={{ margin: "0.5rem", fontFamily: "monospace", fontSize: "18px" }}>
Current Time: {time}
</div>
</StaticMap>
</DeckGL>
);
}
export default DeckGLTripsAnimateMap;