In this guide you will learn how to deal with user interactions within your visualization. After going through it, you will be able to manage some interesting events (e.g. waiting for a layer to load or clicking on a feature) to give your users more dynamic and useful visualizations. You will also learn how to build very common add-ons such as pop-ups and legends.
At the end of the guide you will have built a visualization like this one:
In order to start, grab the source from a working template like this basemap. Copy its source code into a new file called interactivity.html
and test it is working fine before going on.
Add a navigation control to the map, with:
1
2
3
// Add zoom controls
const nav = new mapboxgl.NavigationControl();
map.addControl(nav, 'top-left');
It all begins with the map and sometimes you are interested in listening to some relevant events from to the map itself. For example you want to wait for it to load or maybe display the current map’s center coordinates. In those cases, you can use a set of events already provided by the Mapbox GL JS Map, such as load and move respectively, and attach callback functions to react on them.
Add this pair of listeners to your code to test map events, just after the map initialization:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Wait for the map to render for the first time
map.on('load', () => {
console.log('Map has loaded!');
});
// Listen to every move event caused by the user
const displayCenter = () => {
const center = map.getCenter();
const longitude = center.lng.toFixed(6);
const latitude = center.lat.toFixed(6);
const bearing = map.getBearing().toFixed(0);
const zoom = map.getZoom().toFixed(2);
console.log(`Center: [${longitude}, ${latitude}] - Zoom: ${zoom} - Bearing: ${bearing}º`);
};
map.on('move', displayCenter);
Check the console after loading your file in the browser, to see the first message: ‘Map has loaded’. Then, interact with the map control and see the updated values for center & zoom. You can check Mapbox Map reference for more information on map events.
The result should look like this:
Once you have your basemap, you can start to work with your layers. And all carto.Layer objects have two events to listen to: loaded
and updated
.
The use of the loaded
event is pretty common, due to the fact that in most cases you need to load the data from an external server and that can take some time.
Add this to your code to create a layer as usual, and add it to your map:
1
2
3
4
5
carto.setDefaultAuth({ username: 'cartovl', apiKey: 'default_public' });
const source = new carto.source.Dataset('populated_places');
const viz = new carto.Viz();
const layer = new carto.Layer('Cities', source, viz);
layer.addTo(map);
Now add a loaded
listener to the previous layer:
1
2
3
layer.on('loaded', () => {
console.log('Cities layer has been loaded!');
});
This event could be useful for example to display some kind of loading animation over your map and then hiding it after the layer has loaded (see for example this visualization). Notice how the name of the event is
loaded
, notload
.
If you were using several layers, you could also have a single function to handle them all. For this case, on and off are available at carto
namespace, expecting a list of layers like in this code: carto.on('loaded', [layer1, layer2], () => { console.log('All layers have loaded'); })
.
Regarding to the updated
event, it can be useful for the cases where the layer’s viz changes, for example when you are building an animation. See the Playing with animations guide.
If you check your work now, it should look like this:
Variables are a way to store and reuse expressions, and that can definitively help you when adding interactions to your visualization, so let’s practice a bit with them.
First, you are going to add a variable whose value depends solely on the current map extent. Replace your current const viz = new carto.Viz();
, with this code that grabs the current displayed features using the String API:
1
2
3
const viz = new carto.Viz(`
@currentFeatures: viewportFeatures()
`);
And finally, you should add this to handle the updates after you change the map’s extent:
1
2
3
4
5
const displayNumberOfCities = () => {
const numberOfFeatures = viz.variables.currentFeatures.value.length;
console.log(`Now you can see ${numberOfFeatures} cities`);
};
layer.on('updated', displayNumberOfCities);
Notice how the variable can be accessed directly from the
carto.Viz
object, inside itsvariables
array, without the@
symbol. Its content is accessible using.value
, and this is possible because the expression has noproperties
related to the features themselves.
You can imagine layer:updated
event as a “kind of” layer:viz-updated
event, notifying you whenever something relevant has changed in the viz attached to the layer.
If you want to reduce the number of current console.log
entries, to better see the new one, you can remove the previous handler on map:move
with:
1
map.off('move', displayCenter);
You have already advanced a lot in this guide. Now take a small rest and check your work with this:
If the data you are interested in for your interaction is a feature property
, such as the name of the city or its population, then you can also use some variables to store them. Those are called data-driven variables, because their values change as you interact with each of the features (in our example, with each city).
To test them you should edit again your viz as follows:
1
2
3
4
5
const viz = new carto.Viz(`
@currentFeatures: viewportFeatures()
@name: $name
@popK: $pop_max / 1000.0
`);
Both properties, $name and $pop_max are columns in the original dataset.
As the variables depend on properties, you can’t just access them by using something like viz.variables.name.value
on layer:updated
. That will throw an error saying: property needs to be evaluated in a ‘feature’. You need to use carto.Interactivity.
All feature interactions are ruled by the carto.Interactivity
, so let’s create an object of this type, associating it with the current layer.
Add this line:
1
const interactivity = new carto.Interactivity(layer);
Then use its featureClick
event to react when you click on a city:
1
2
3
4
5
6
7
interactivity.on('featureClick', featureEvent => {
featureEvent.features.forEach((feature) => {
const name = feature.variables.name.value;
const popK = feature.variables.popK.value.toFixed(0);
console.log(`You have clicked on ${name} with a population of ${popK}K`);
});
});
Notice how
carto.Interactivity
provides you with a dynamic change on the mouse pointer when you hover on a feature, and how it handles a collection, because you can click on several features at the same time if they are near enough.
The carto.Interactivity
can handle different events:
featureClick
: Fired when the user clicks on features.featureClickOut
: Fired when the user clicks outside a feature that was clicked in the last featureClick event.featureHover
: Fired when the user moves the cursor over a feature.featureEnter
: Fired the first time the user moves the cursor inside a feature.featureLeave
: Fired the first time the user moves the cursor outside a feature.In every callback a single parameter of type featureEvent will be received. This object will have the position
and coordinates
where the
event happened (we didn’t use that so far) and the list of Features that have been interacted.
A very common case is to display pop-ups, little emerging windows with information on the features.
You can build the pop-up yourself if you want to, but using Mapbox GL
allows you to easily reuse mapboxgl.Popup.
So let’s adapt a bit the previous ‘featureClick’ handler. You’re going to add some code inside the current handler:
1
2
3
4
interactivity.on('featureClick', featureEvent => {
// ...existing code...
// Add more code HERE
});
First just grab the first feature in the interaction, if exists, with this:
1
2
3
4
const feature = featureEvent.features[0];
if (!feature) {
return;
}
And then you can create the pop-up with this code:
1
2
3
4
5
6
7
8
9
const coords = featureEvent.coordinates;
const html = `
<h1>${feature.variables.name.value}</h1>
<p>Population: ${feature.variables.popK.value.toFixed(0)}K</p>
`;
new mapboxgl.Popup()
.setLngLat([coords.lng, coords.lat])
.setHTML(html)
.addTo(map);
For simplicity, we have created a pop-up linked to the first feature, but you’re free to choose the contents (maybe even a paginated pop-up with several cities and some photos?).
At this point, your map looks like:
Interactivity also can help you to define your styles dynamically.
For example, with the next code you’ll learn something very useful and common: how to style your features when you interact with them, to give more emphasis to the selected ones.
First you have to set up a listener for the featureEnter
in the current Interactivity
object. This listener will change the color and size of the features included in the features
array.
1
2
3
4
5
6
interactivity.on('featureEnter', featureEvent => {
featureEvent.features.forEach((feature) => {
feature.color.blendTo('rgba(0, 255, 0, 0.8)', 100);
feature.width.blendTo(20, 100);
});
});
blendTo is an expression that allows a smooth transition between two values. In this case, the transition makes the original color turn to red and also increases the size of the symbols.
When the featureLeave
event is fired you can tell your callback to reset
the color and size for each feature:
1
2
3
4
5
6
interactivity.on('featureLeave', featureEvent => {
featureEvent.features.forEach((feature) => {
feature.color.reset();
feature.width.reset();
});
});
Congrats! You’ve finished this guide. The final map should look like this:
Here it is the full example:
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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
<!DOCTYPE html>
<html>
<head>
<!-- Include CARTO VL JS -->
<script src="../../../../dist/carto-vl.js"></script>
<!-- Include Mapbox GL JS -->
<script src="https://libs.cartocdn.com/mapbox-gl/v0.48.0-carto1/mapbox-gl.js"></script>
<!-- Include Mapbox GL CSS -->
<link href="https://libs.cartocdn.com/mapbox-gl/v0.48.0-carto1/mapbox-gl.css" rel="stylesheet" />
<!-- Make the map visible -->
<style>
#map {
position: absolute;
height: 100%;
width: 100%;
}
</style>
</head>
<body>
<!-- Add map container -->
<div id="map"></div>
<script>
// Add basemap and set properties
const map = new mapboxgl.Map({
container: 'map',
style: carto.basemaps.voyager,
center: [0, 30],
zoom: 2
});
// Add zoom controls
const nav = new mapboxgl.NavigationControl();
map.addControl(nav, 'top-left');
// MAP EVENTS
// Wait for the map to render for the first time
map.on('load', () => {
console.log('Map has loaded!');
});
// Listen to every move event caused by the user
const displayCenter = () => {
const center = map.getCenter();
const longitude = center.lng.toFixed(6);
const latitude = center.lat.toFixed(6);
const bearing = map.getBearing().toFixed(0);
const zoom = map.getZoom().toFixed(2);
console.log(`Center: [${longitude}, ${latitude}] - Zoom: ${zoom} - Bearing: ${bearing}º`);
};
map.on('move', displayCenter);
//** CARTO VL functionality begins here **//
// LAYER EVENTS & VARIABLES
// Add layer as usual
carto.setDefaultAuth({ username: 'cartovl', apiKey: 'default_public' });
const source = new carto.source.Dataset('populated_places');
// Viz using a dynamic variable
const viz = new carto.Viz(`
@currentFeatures: viewportFeatures()
@name: $name
@popK: $pop_max / 1000.0
`);
const layer = new carto.Layer('Cities', source, viz);
layer.addTo(map);
// Add on 'loaded' event handler to layer
layer.on('loaded', () => {
console.log('Cities layer has been loaded!');
});
// Disable previous listener on map:move just for clarity
map.off('move', displayCenter);
// Add on 'updated' event handler to layer
const displayNumberOfCities = () => {
const numberOfFeatures = viz.variables.currentFeatures.value.length;
console.log(`Now you can see ${numberOfFeatures} cities`);
};
layer.on('updated', displayNumberOfCities);
// DATA-DRIVEN VARIABLES & carto.Interactivity
const interactivity = new carto.Interactivity(layer);
// Handle 'featureClick' to display city name and population
interactivity.on('featureClick', featureEvent => {
featureEvent.features.forEach((feature) => {
const name = feature.variables.name.value;
const popK = feature.variables.popK.value.toFixed(0);
console.log(`You have clicked on ${name} with a population of ${popK}K`);
});
// Get the first feature
const feature = featureEvent.features[0];
if (!feature) {
return;
}
// Add pop-up using mapboxgl
const coords = featureEvent.coordinates;
const html = `
<h1>${feature.variables.name.value}</h1>
<p>Population: ${feature.variables.popK.value.toFixed(0)}K</p>
`;
new mapboxgl.Popup()
.setLngLat([coords.lng, coords.lat])
.setHTML(html)
.addTo(map);
});
// Disable previous listener on layer:updated just for clarity
layer.off('updated', displayNumberOfCities);
// Change style on 'featureEnter'
interactivity.on('featureEnter', featureEvent => {
featureEvent.features.forEach((feature) => {
feature.color.blendTo('rgba(0, 255, 0, 0.8)', 100);
feature.width.blendTo(20, 100);
});
});
// Reset to previous style on 'featureLeave'
interactivity.on('featureLeave', featureEvent => {
featureEvent.features.forEach((feature) => {
feature.color.reset();
feature.width.reset();
});
});
</script>
</body>
</html>