How to build a non-geographical map #2
Or how to turn a scatter plot into an interactive map (with Leaflet and JavaScript)
Maps are powerful design objects for data visualization. They emphasize the spatial relationship between elements and the comparison of variables. In the context of non-geographical data, web maps can enhance traditional scatter plots.
🔗 Link to #part 1: How to map similarities with dimensionality reduction
#Part 2: From scatter plot to interactive map
At the center of data visualization, is communication. The ability to transform a frightening table into a compelling story. Often, the ultimate goal is to drive insights for problem-solving or decision making. In most cases, users don’t get to play with data and take a passive stance. But, what if you want to empower your audience, spark their curiosity and allow them to explore data?
We use maps to navigate the unknown. They give us the confidence to get off the beaten path. We deviate from the initial route and make a stop at a café or a detour by a museum. This is also how we use the web. We make a search, one link leads to another, and we end up developing an interest in something we did not even know existed a few clicks ago.
Could we convey data in the same serendipitous way?
In the first part of this series, we used dimensionality reduction to map similarities in a 2D space. We ended up with a new coordinate system that allowed us to visualize our data in a scatter plot (see graph (Plotly)).
In this article, we will transform it into an interactive map, including a search bar and a side panel. We’ll be using Leaflet and some additional JavaScript.
This article might be useful to anyone who wants to build a web map, no matter the type of data. If that’s your case, jump to step 2.
Step 1. Build a JSON of coordinates
If you followed along the mapping of non-geographical data, you now have an embedding — a (very, very long) Numpy array of coordinates representing your data in 2D.
To plot all these points of data on a Leaflet map, we need them stored into a JavaScript object.
To do so, we can transform our DataFrame into a JSON string and handle it with JavaScript.
If you only have a few points of data to plot, then you may want to skip this step and store them manually in your JavaScript.
Also, bear in mind that each user will “load” the entire JSON every time they open the page. This will affect your website’s performance. Depending on the amount of data and traffic you have, you may prefer to load the content dynamically from a data base.
1/ Create a DataFrame
In this example, I will be using:
- the embedding resulting from the mapping of occupations based on skills and knowledge similarity and
- the ONET open database.
Our JSON must contain each marker’s coordinates and some data, such as its title and description, that we’ll use to update the panel’s content.
Below is a quick description of the steps that I followed to create the DataFrame. You can find the full notebook here (Github):
- After loading the Occupation Data.xlsx file from O*NET, I created two series. I used the O*NET SOC Code as their common index and occupations titles and descriptions as their respective values. Then, I combined them into a single DataFrame.
- Finally, I created a dictionary of all occupations titles and their respective array of coordinates (based on the UMAP embedding) and mapped it with the DataFrame’s title column.
2/ Convert into JSON
- Pandas has a built-in function that enables to convert an object to a JSON string:
yourdataframe.to_json('newfile.json', orient='index')
For more details, you can find the full notebook here (Github).
Step 2. Create a map with Leaflet
To create this map, you need Leaflet, Bootstrap, jQuery and jQuery-ui. Make sure to include the links to their CSS and JS libraries in your code.
1/ Create map
In this article, I will only use the very basic settings of Leaflet and focus on panel interactivity with the map. However, there are plenty of amazing things you can do with Leaflet, it’s worth a look.
Below is a quick set up. Alternatively, you can follow their quick start guide.
- In your HTML, create an empty container:
<div id="mapDemo"> </div>
- The map container must have a defined height. Here, I set it to 100% of the viewer’s screen:
#mapDemo {width: 100vw; height: 100vh;}
- In your JS, create the map:
map = L.map('mapDemo');
. Because we’ll have a panel on the left, let’s place the zoom controls at the bottom right of the screen:map.zoomControl.setPosition('bottomright');
2/ Plot markers
The data is stored under a JSON file with the same structure as a JavaScript dictionary. Note that I based my codePen example on a JavaScript dictionary with only 3 elements and 3 features (“title”, “coords” (coordinates) and “description”).
- First, store your JSON file in a JavaScript dictionary
var markers = {}
:
var markers = $.getJSON("map_data.json");
- Then, loop through the dictionary to store each marker’s features under
markerOptions
. Plot them withaddTo(map)
:
$.each(markers, function(key, val) {
var markerOptions = {markerId: key,
markerTitle: val['title'],
markerText: val['description']}; var marker = L.marker(val['coords'], markerOptions).addTo(map);
});
- To see the markers, set a map view within your data coordinates bounds:
var bounds = [[-10,-10], [10,10]];
map.fitBounds(bounds);
Step 3. Lay the groundwork
Before tackling interactivity, let’s get the HTML and CSS out of the way.
1/ HTML
- In the
<body>
of your HTML, create an input group with a form control (#searchTerm
) and a button (#searchBtn
). - Below, add a button (
#panelBtn
) . It will allow the user to open/close the panel at all times. - For the panel, add a
<div>
with a close button (#closeBtn
) and the content that will be updated with markers’ data (like a title and a paragraph).
2/ CSS
- In your CSS, make sure that all the elements that should sit on top of the map have a
z-index
higher than 400 (400 being thez-index
of the map). - By default, the
#panel
is hidden and is only displayed when.active
. Set theleft
property to the same size as the width of the#panel
element to hide it, and switch it to 0 when#panel.active
(to show it). - For the panel height, define where the the element should start and end with
top
andbottom
properties. Note that, if instead of settingbottom
, you define aheight
, the scroll won’t work as you expect. The#panel
will continue “outside” of the visible screen (in spite ofoverflow-y:scroll;
). - On mobile and small screens, the panel and input group take full width. So, I set
width:100vw;
but change it towidth:500px;
for screens that are more than 500px wide.
You can find the complete code here (codePen).
3/ Data structures
We have to jump ahead a little and think about the data we will need to access to display the right output.
Dictionaries
Let’s call markerOnClick()
the function that is called when a marker is clicked, and search()
the function that is called after a search is entered (either by clicking on a suggested marker title or by clicking the search button).
These functions have a bulk of steps in common. To avoid writing the same code twice, we encompass these steps under the updatePanel()
function.
To update the panel with the title and text attached to each markerId
, updatePanel()
must have access to some other marker features. Likewise, search()
is a function of markerTitle
but needs access to markerId
to call updatePanel()
.
For functions to be able to access those features, we need dictionaries.
- Create 3 dictionaries:
var titleToId = {}; var idToTitle = {}; var idToText = {};
- Then, append them as you loop through the
markers{}
dictionary:
$.each(markers, function(key, val) {
...
titleToId[val['title']] = key;
idToTitle[key] = val['title'];
idToText[key] = val['description'];
...
});
List
When the user types a search, it calls autocomplete()
, which suggests all the possible marker titles based on user input. Thus, the data source for this function must be a list of marker titles:
- Create a list of marker titles
var titlesList =[];
that you append within the markers loop:
$.each(markers, function(key, val) {
...
titlesList.push(val['title']);
...
});
Step 4. Orchestrate interactivity
1/ Click on marker
updatePanel()
- Define
updatePanel()
:
var updatePanel = function(mId){
$('#panel').addClass('active');
$("#panelBtn").text('< Close Panel');
var markerTitle = idToTitle[mId];
var markerText = idToText[mId];
$('#panelTitle').text(markerTitle);
$('#panelText').text(markerText);
};
markerOnClick()
- And call
updatePanel()
withinmarkerOnClik()
:
var markerOnClick = function(){
var mId = this.options.markerId;
var markerTitle = idToTitle[mId];
$('#searchTerm').val(markerTitle);
updatePanel(mId);
};
- In the loop, add the onClick event to all markers. Set the popup options (popupContent) and bind the popup to marker click:
$.each(markers, function(key, val) {
...
marker.on('click', markerOnClick);
var popupContent = val['title'];
marker.bindPopup(popupContent);
...
});
Now, when the user clicks on a marker, the corresponding popup window opens.
2/ Search input
autocomplete()
- Here, we define the options for autocomplete(). Also, when an item is selected from the autocomplete, we store its value in
#searchTerm
and callsearch()
:
$(function() {
$("#searchTerm").autocomplete({
source: titlesList,
minLength: 1,
select: function(event, ui) {
$("#searchTerm").val(ui.item.label);
console.log(ui.item.label);
search();
}
});
});
search()
Now, if the user clicks on a suggestedmarkerTitle
, search()
and updatePanel()
are called. However, without an actual click on the marker, the popup won’t show.
A solution is to attach a different layer to each marker. We can then apply Leaflet’s openPopup() method to the layers within the search()
function.
- Create a group of layers
var layers = L.layerGroup().addTo(map);
and update it within the markers looplayers.addLayer(marker)
- A layer is attached to each marker, but we need a dictionary that will allow us to retrieve a marker’s layer based on its
markerId
:var idToLayer = {};
:
$.each(fgLayers._layers, function(key,val){
idToLayer[val.options.markerId] = key;
});
- We can finally define the
search()
function. It gets themarkerTitle
from the search field, finds its corresponding marker, opens its popup window and callsupdatePanel()
:
var search = function(){
$("#searchTerm").blur();
var markerTitle = $("#searchTerm").val();
var markerId = titleToId[markerTitle];
var layerId = idToLayer[markerId];
var layer = layers._layers[layerId];
layer.openPopup(); updatePanel(markerId);
}
3/ Open-close panel
After users have made a search or clicked a marker, we want them to be able to close the panel and go back to the map at all times. We also want to leave them the option to reopen the panel, from the map directly.
togglePanel()
The #panelBtn
button allows the user to successively open or close the panel. Its content should change depending on which action (open or close) it allows the user to do next. We can achieve this by using jQuery’s toggleClass method.
- Add an onClick event to the button:
<button onClick = "togglePanel()";>
and definetogglePanel()
:
var togglePanel = function() {
$('#panel').toggleClass('active');
$("#panelBtn").text(($("#panelBtn").text() == 'Open Panel >') ? '< Close Panel' : 'Open Panel >');
}
closePanel()
The #closeBtn
button allows the user to close the panel when it is open, and updates the #panelBtn
inner HTML.
- Add an onClick event to the button:
<button onClick = "closePanel()";>
and defineclosePanel()
:
var closePanel = function() {
$('#panel').removeClass('active');
$('#panelBtn').text('Open Panel >');
}
Wrap up
Thank you for reading 😃
In this series, we’ve covered how to use dimensionality reduction to build a scatter plot that uses distance as a proxy for similarity and turned it into an interactive map.
I had a lot of fun writing those articles and illustrating them, and learnt a lot along the way. I hope you’ve found it useful, and I look forward to seeing more amazing maps out there.