How to build a non-geographical map #2

Or how to turn a scatter plot into an interactive map (with Leaflet and JavaScript)

Fanny Kassapian
Towards Data Science

--

This guy’s body may seem odd and his chair disproportionate, but aren’t maps a distortion of reality, anyway? :)

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.

>> See code & end result (CodePen)

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:

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.
This is what your DataFrame should look like.

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')
This is what the JSON looks like.

For more details, you can find the full notebook here (Github).

Step 2. Create a map with Leaflet

Leaflet.js is an open-source JavaScript library for responsive & interactive maps. It offers wonderful features and a beautiful default design.

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 with addTo(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

Main elements and their ids. >> See CodePen
  • 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

This map is mobile first. I only included a media query for screens that are 500px wide and above, but you may want to refine that.
  • 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 the z-index of the map).
  • By default, the #panel is hidden and is only displayed when .active. Set the left 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 and bottom properties. Note that, if instead of setting bottom, you define a height, the scroll won’t work as you expect. The #panel will continue “outside” of the visible screen (in spite of overflow-y:scroll;).
  • On mobile and small screens, the panel and input group take full width. So, I set width:100vw; but change it to width: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.

Overview of the events that require access to markers’ data. In blue are the functions and in turquoise their respective arguments.

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.

These are the key-value pairs that we need.
  • 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

Overview of the functions associated with panel interactivity.

1/ Click on marker

When a marker is clicked: the search bar is updated with the marker’s title, the “open panel” button changes to “close panel”, and the panel opens and displays some content attached to that 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() within markerOnClik() :
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

When the user types in the search bar, the autocomplete suggests a few marker titles. From there, the user can either select one of those suggestions or confirm her search. Either way, the output is the same as when a user clicks on a marker (see 1/ Click on marker).

autocomplete()

  • Here, we define the options for autocomplete(). Also, when an item is selected from the autocomplete, we store its value in #searchTerm and call search():
$(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.

LayerGroup is a method from the Layer class used to group several layers and handle them as one >> see documentation
  • Create a group of layers var layers = L.layerGroup().addTo(map); and update it within the markers loop layers.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 the markerTitle from the search field, finds its corresponding marker, opens its popup window and calls updatePanel():
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.

To close the panel, the user can either click the close button of the panel or click the “close panel” button on the map.

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 define togglePanel():
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 define closePanel():
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.

🔗 Link to #part 1: How to map similarities with dimensionality reduction

👉 Check out how I use this in practice: www.tailoredpath.com

--

--