Techno Blender
Digitally Yours.

A step-by-step guide to develop a map-based application (Part I) | by Jacky Kaub | Jan, 2023

0 119


Photo by GeoJango Maps on Unsplash

Maps are a powerful tool for visualizing and understanding geographic data but they need specific skills to be designed efficiently.

In this step-by-step guide, we are going to take a deep dive into building a map-based application to show the customers prices of gas stations around them. We will cover the different key steps of a product, from original proof of concept (POC) to the minimum viable product (MVP)

What this article covers

In this article, the first of the series, we are preparing the proof of concept of our application.

We just got an idea that we would like to turn into something valuable but we don’t know yet if this is feasible. We need to look for data, evaluate their relevance and build something basic that will demonstrate if the original idea is feasible.

Also, and more than everything, we need to demonstrate to deciders (that are not necessarily technical people) that the project can be valuable.

From POC to MVP, Author Illustration

In particular, due to the specificity of that particular project, we will see in this article the following topics:

  • How to parse a .xml file into tabular data
  • Filter and combine DataFrames based on distances between points
  • Build a basic map with plotly
  • Add interactions to the map with ipywidgets

By the end of the article, we will have an interactive map that could be presented for POC demos before deciding to go further on product development.

Illustration of the product at the end of the POC phase, Author Illustration

The complete code of this whole POC is available in my github.

A bit of context around the app

In the current economic situation, we observe high volatility of gas prices in the stations. We also see — at least where I come from in France — shortages driven by strikes and some malicious station owners taking the opportunity of this to increase their prices to overly high levels. With this in mind, prices at the stations can become an important criterion for many people who need to refill.

The good news is: in France, real-time gas station prices are accessible through the open data policy implemented in the country. The data, in its raw format, is not of interest to many as not understandable instantaneously. Instead, it would need to be made available and displayed via a proper user interface so the right information is filtered and understandable in an eye blink.

We are going to build this user interface: a web app allowing users to retrieve oil prices for stations around them. I am focusing here on the french market (because the data is open publicly and easy to access), but our work will be easily reproducible for other types of data anywhere in the world.

A quick word about the datasets used

In its politic of open data, France maintains many datasets in free access. In particular, we are going to use the Fuel prices in France — Instant flow Dataset under Open Licence V2 and can be reused for commercial purposes.

We are also going to use the Communal Administrative division API for information relative to cities, also under Open Licence V2.

Our adventure starts here: navigating in the jungle of open data, we found this interesting dataset about gas prices. We don’t know yet what it looks like and what data it contains.

Multiple files are available for download as illustrated below:

A translated version of the page containing the datasets, Author Illustration

The first mistake to avoid here is to overflow our project with too much data: it is not because it is available that we need it.

Here, typically, we only want to demonstrate the possibility of showing the “instant” prices to users, we don’t aim at making a historical prices analysis. Thus, exploring historical data will be, at least for now, a waste of time and we are going to focus only on the instantaneous flow datasets.

Time is a precious resource, and many ideas will never turn into actual products. It is important to be efficient and make sure we focus only on the concept we want to showcase.

According to the documentation, the dataset contains information such as addresses, postal codes, coordinates, and prices, among with other information less useful for the POC (like other services proposed).

Retrieving the data

The instant data feed can be retrieved via a download link which will download a zip file containing a file locally in your computer.

https://donnees.roulez-eco.fr/opendata/instantane

This is nice, but even if we are in the POC phase we need to anticipate some of the constraints we will have later in the project that could be “killer”. In particular here, assuming we will update our data at least once a day, we don’t want the update to be done manually and we need to assess a way to access the data automatically.

Hopefully, we can load the zip directly in python combining urllib and zipfile libraries to perform operations on .zip files directly from an URL.

def load_from_url_in_memory(url):
'''This function load the xml file from the url in memory after unziping it
It assume only one file with a .xml extention is available in the zip
'''

with urllib.request.urlopen(url) as url:
s = io.BytesIO(url.read())
z = zipfile.ZipFile(s)

return z

Explore the .xml file

XML is a markup language commonly used for data storage and data exchanges. Even if newer formats such as JSON tend to gain more and more popularity, XML remains used widely in many applications and industries and it is still relevant to have a sense of how it works.

A .xml file forms tree structures, that start at the “root” and branch to child elements. Each element has its own attributes and its possible own children.

Let’s have a look at the file by opening it in a simple text editor.

The first line of the file gives some metadata, such as the encoding used:

<?xml version="1.0" encoding="ISO-8859-1" standalone="yes"?>

Here it says that to read the file, we need to use an ISO-8859–1 encoding, which will be useful when we will have to parse the file.

The .xml is then composed of different elements with multiple levels of depths:

<pdv_liste>
<pdv id="40140005" latitude="4374653.865" longitude="-132117.12497344" cp="40140" pop="R">
<adresse>9 AVENUE DE CRAMAT</adresse>
<ville>SOUSTONS</ville>
<horaires automate-24-24="1">
<jour id="1" nom="Lundi" ferme=""/>
<jour id="2" nom="Mardi" ferme=""/>
<jour id="3" nom="Mercredi" ferme=""/>
<jour id="4" nom="Jeudi" ferme=""/>
<jour id="5" nom="Vendredi" ferme=""/>
<jour id="6" nom="Samedi" ferme=""/>
<jour id="7" nom="Dimanche" ferme=""/>
</horaires>
<services>
<service>Laverie</service>
<service>Station de gonflage</service>
<service>Automate CB 24/24</service>
</services>
<prix nom="Gazole" id="1" maj="2023-01-18 07:27:47" valeur="1.891"/>
<prix nom="E85" id="3" maj="2023-01-14 08:15:44" valeur="1.059"/>
<prix nom="E10" id="5" maj="2023-01-18 07:27:47" valeur="1.839"/>
</pdv>
<pdv>
...
</pdv>
</pdv_liste>

Here the pdv element represents our oil station. Inside the tag, there is several parameters which are the attributes of the element (id, latitude, longitude, postal code…)

<pdv id="40140005" latitude="4374653.865" longitude="-132117.12497344" cp="40140" pop="R">

Each pdv has also multiple children:

  • adresse: the address of the station
  • ville: the city of the station
  • horaires: the hours of openings of the station
  • services: representing extra services provided such as car wash, ATM, etc…
  • prix: the price of each fuel type (available as attributes)

This data structure can be represented by the tree below:

Data structure of our .xml, Author illustration

From .xml to tabular data

This tree data structure is not fitted for easy manipulations and we want to parse it to a tabular version, which will be more convenient for later filtering and aggregation operations.

To store the data efficiently, we will create two tables:

  • A table to represent oil stations and their metadata (address, city, etc…)
  • A table to represent fuels, their stations, and their prices

You can use a tool such as https://dbdiagram.io/ to design your data structure.

Sample view of the data structure, Author illustration

In this POC phase, we will not set up a SQL server and simply store the data in memory in pandas DataFrames to go fast. We could also store it locally in a .csv as a starting point, but the file is light, so no need to go there for now.

When we will explore the MVP of the app, we will set up the database in a cleaner way.

Parsing the .xml file

At this point we know the data structure we want to reach, we just have to parse the file properly.

Before going further, I want to raise your attention to the fact that you should be particularly careful when using automated parsers as they could potentially dismiss part of the data available in the file.

For example, using the built-in pandas.read_xml() method, without any specific tuning, would return an incomplete DataFrame.

url = f'https://donnees.roulez-eco.fr/opendata/instantane'
xml_file = load_from_url_in_memory(url)
pd.read_xml(z.open(xml_file), encoding="ISO-8859-1")

The code above would result in the DataFrame below, where most of the information is missing:

Parsing quickly with pd.read_xml and default parameters don’t produce the relevant result

Instead, we are going to code our own data extractor, which will give us the opportunity to review recursive functions and is based on the python xml library.

Note: Even if we are in the POC, it is important to make the effort of keeping a clean code structure. We are going to build a proper set of functions for the data loading module that could be re-used with very few adaptations later in the process. It doesn’t cost extra time, but it will save a lot of work for the MVP.

To parse properly the file we use the xml library. Let’s start by accessing our root element, pdv_list

import xml.etree.ElementTree as ET

#Link of instant data feed
url = f'https://donnees.roulez-eco.fr/opendata/instantane'
#from the function defined earlier
xml_file = load_from_url_in_memory(url)

with z.open(xml_file, "r") as file:
#Create the tree using xml parser
tree = ET.parse(file)
#Access the root node
root = tree.getroot()

The root variable contains the element pdv_list

<Element 'pdv_liste' at 0x0000019EAEF3B400>

And the children elements can be simply accessed by iteration (in our case each child will be an element “pdv”:

for child in root:
#Do something

In order to handle the data whatever the order, we will create a station dictionary that will contain all the metadata that we want to keep for our “stations” table as well as an oil_type dictionary for our “oil_type” dataset.

To keep things clean, depending on the level of complexity of each node we will design custom functions. The code below illustrates how our custom parser work.

def parse_pdv(pdv, stations, oil_types):
'''This function retrieve all the data available in a pdv element'''

station = {}
#Get attributes of the pdv
station.update(pdv.attrib)

#It is precised in the doc that lats and longs have to be divided by 100 000
station['latitude'] = float(station['latitude'])/100000
station['longitude'] = float(station['longitude'])/100000

#Rename the id key
station["station_id"] = station["id"]
del station["id"]

#Explore children, by type of children
for element in pdv:
if element.tag == "adresse":
station["adress"] = element.text
if element.tag == "ville":
station["city"] = element.text
if element.tag == "horaires":
#Some children, more complicated to parse, have their own subfunctions
station = parse_schedules(element, station)
if element.tag == 'prix':
#Some children, more complicated to parse, have their own subfunctions
oil_types = parse_oil(element, station, oil_types)

stations.append(station)
return stations, oil_types

def parse_xml(z):
'''Parse the whole xml'''
xml_file = [file for file in z.namelist() if file.endswith('.xml')][0]
with z.open(xml_file, "r") as file:
#Create the tree using xml parser
tree = ET.parse(file)
#Access the root node
root = tree.getroot()

#Initiate stations and oil_types
stations = []
oil_types = []

for pdv in root:
stations, oil_types = parse_pdv(pdv, stations, oil_types)

stations_df = pd.DataFrame(stations)
oil_types_df = pd.DataFrame(oil_types)

return stations_df, gas_types_df

This set of functions will return those two DataFrames and should be relatively easy to modify in the future if we want to use SQL tables:

Sample DataFrame for the oil_type, Author Illustration
Sample DataFrame for the stations, Author Illustration

At that point, we have latitudes, longitudes, and prices for all the stations. We don’t want to show everything to the users, only the stations proposing relevant gas types around them.

In the coming part, we will develop all the functions to go from the raw datasets to the filtered ones.

Calculate the distance between two points from lat/lon coordinates

Latitudes and Longitudes are expressed in degrees, and as the distance is calculated on the surface of the globe, simple Euclidean geometry cannot be applied.

To calculate the distance between two points, we are going to use the Haversine formula:

def haversine_distance(lat1, lon1, lat2, lon2):
'''Calculate the distance between two points (lat1,lon1) and (lat2, lon2) in km'''
R = 6371

lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2])
dlat = lat2 - lat1
dlon = lon2 - lon1

a = np.sin(dlat / 2)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2)**2
c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))

distance = R * c
return distance

Whether the function comes from you or another developer, don’t forget to test it to verify that the resulting output is correct.

In my case, I am verifying using the Google Earth distance feature and checking the distance between two cities for which I know latitude and longitude, and confirming the function returns the expected output.

Filter the dataset based on a point identified with lat/lon

We can now create a function that filters the station dataset based on the haversine_distance from a source lat/lon.

def filter_dataset_from_source_point(stations_df, lat_point, lon_point, distance = 20):

return stations_df.loc[haversine_distance(stations_df.latitude, stations_df.longitude, lat_point, lon_point)<=distance]

I tested the function around my place to check that I retrieve well the stations available around, which is well the case.

filter_dataset_from_source_point(stations_df, lat_point = 49.131, lon_point = 2.521, distance = 5)
Sample dataset filtered around my village, Author Illustration

Filter by gas type

A user will be only interested in the stations selling the type of gas used in his car. We can now perform a join between station_id and our gas types to retrieve and filter the rows by gas type.

def filter_dataset_by_gas_type(sub_station_df, gas_types_df, gas_type):

joined_df = sub_station_df.set_index("station_id") \
.join(
gas_types_df.set_index("station_id"), how = 'left'
)
return joined_df.loc[joined_df.nom==gas_type]

filter_dataset_by_gas_type(sub_station_df, gas_types_df, "SP98").dropna(axis=1)

Illustration of the results after applying the filter_dataset_by_gas_type, showing only some relevant columns. Author Illustration

Getting coordinates and postal codes of french cities

What we have now is not bad but not enough: we don’t want our users to write themselves the latitude and longitude of their location which is not something accessible easily.

Instead, we want a feature to get the lat/lon as easily as possible. There is many ways of doing this and many API ( like Google Map API ) provide already information accessible at different degree of complexity (and different degree of prices…). For a POC we want to stay simple, so we are just going to look for a dataset with coordinates of city centers based on their postal codes.

Note: We have a dataset of stations that includes information about coordinates and postal codes, but it is limited to only cities that have gas stations. We aim to make the dataset as comprehensive as possible.

After a bit of research, I figured out another open dataset to use, accessible via an API that allows us to quickly retrieve exactly what we need. As mentioned in the introduction, those data are also under an open license and can be used freely.

url = f"https://geo.api.gouv.fr/communes?fields=codesPostaux,nom,centre"
req = requests.get(url).json()

#We need to unpack the json via a loop as we want duplicated lines for each postal code
city_data = []
for elmt in req:
for code_postal in elmt['codesPostaux']:
city = {}
city["postal_code"] = code_postal
city["name"] = elmt['nom']
city["lat"] = elmt['centre']['coordinates'][1]
city["lon"] = elmt['centre']['coordinates'][0]
city_data.append(city)

cities_df = pd.DataFrame(city_data)

Sample DataFrame for our cities_df, Author Illustration

We can now develop our final brick: a function that will take as input 3 parameters (a postal code, a distance, and a type of gas) and that will return the prices of gas stations around (I’ll assume for now stations_df, cities_df, and gas_type_df as a global variable, but I’ll package everything properly in a class at the end of the article).

from builtins import ValueError

def get_close_gas_prices(postal_code, distance_km, gas_type):

global cities_df
global stations_df
global gas_types_df

sub_cities = cities_df.loc[cities_df.postal_code==postal_code]

#If the postal code is not value, raise a value error
if len(sub_cities)==0:
raise BadInputError("Postal code is not a valid code")

lat_point, lon_point = sub_cities.iloc[0][["lat","lon"]]
sub_station_df = filter_dataset_from_source_point(stations_df, lat_point, lon_point, distance_km)
return filter_dataset_by_gas_type(sub_station_df, gas_types_df, gas_type)[["latitude" ,"longitude" ,"cp" ,"city","nom", "valeur"]]

Tested again on my city (note there is one more station here, probably due to a slight difference in the lat/lon from the postal_code -> lat/lon):

get_close_gas_prices(postal_code='60560', distance_km=5, gas_type='SP98')
CP dataset sample, Author Illustration

There is something also important to note here: while gas types will be accessible via a finite dropdown list, it is the user’s responsibility to write a correct postal code. If the postal code is not correct, the sub_cities DataFrame will be empty, and accessing its first element will raise an error.

We prefer to handle this case by catching the error with our own custom Exception, which will be easier to integrate later in the MVP.

from builtins import Exception

class BadInputError(Exception):
def __init__(self, message):
self.message = message

Using a postal code with the wrong format or with a value not in our database will generate this error message:

Illustration of our custom exception, Author Illustration

So far, so good, we demonstrated the possibility to access quickly the relevant information in the database by simply using a postal code, a distance, and a fuel type.

We are still using some python functions here which might be ugly to show to the management or to investors. To prove to them that it is worth pushing the project forward in the next stage of development, we want to integrate this in a small demonstrator.

We could create a small dashboard that runs locally using plotly and dash but the fastest way would be to simply create a small widget in a notebook. A more advanced dashboard will be then demonstrated during the prototyping phase.

Displaying a map in a notebook

There are multiple ways to display a map in a Jupyter Notebook, like the excellent folium library (check this post if you want more info about the lib).

In my case, I prefer to use plotly in anticipation ofthe next stages of development where we will embed the plotly map directly in our User Interface.

This part is very important in our development: it has to give a “wow” feeling to people to who you will demonstrate if you want a chance to reach later stages of development. You might have the best idea in the world, but if the package is ugly, people won’t buy it.

To make something clean, this is the information we want to display in our map:

  • The coordinate of the center of the city used
  • A transparent circle showing the perimeter of the research
  • The coordinates of the stations providing the type of fuel requested
  • For more clarity, we are also going to map the color of the station to the deviation of the price from the average

A simple map with Scattermapbox

Plotly has an integration of Mapbox GL JS, allowing us to build a tiled-base map that will handle operations such as navigation in the map and zooms without using excessive CPU resources.

To build a map we need two ingredients:

  • A trace containing the data to visualize and compatible with maps (here go.Scattermapbox)
  • A layout, including a map layer. This is done by specifying a Mapbox dictionary. Note: you will need a free Mapbox token to access the Mapbox tiles used for the map background.

The example below shows a simple map (we use FigureWidget rather than Figure to integrate later this map directly in our widget)

import plotly.graph_objects as go

#I hardcode my lat/lon for now, we integrate this in a proper class later
lat,lon = cities_df.loc[cities_df.postal_code=='60560'][["lat","lon"]].iloc[0]

stations = go.Scattermapbox(
lat= station_df.latitude,
lon= station_df.longitude,
mode='markers',
marker = dict(
size = 14,
symbol = 'fuel',
),
showlegend = False
)

#create the layout
layout = go.Layout(
height = 700,
width = 700,
mapbox = dict(
accesstoken = token,
style ='streets',
center = dict(
lat = lat,
lon = lon
),
zoom = 10,
),
)

fig = go.FigureWidget(data=[stations], layout=layout)
fig.show()

This code, when applied to our pre-filtered dataset, will result in :

Output from the plotly map, Author Illustration

Not very fancy yet, but it’s a good start.

Adding price indicators

We can simply add other traces to our map to add more information to our base map.

As it is not possible to change directly the color of the icons, we are going to add an extra ScatterMapBox with circle points for which we can modify the color.

prices = go.Scattermapbox(
lat = station_df.latitude,
lon = station_df.longitude,
mode ='markers',
marker = dict(
size = 30,
color = station_df.valeur.astype(float),
colorscale = 'PiYG_r'
),
opacity = 0.5,
text = station_df.valeur+' €/L',
showlegend = False
)

#This is a bit hacky, but it allows us to add a solid border around the circle
solid_price_border = go.Scattermapbox(
lat= station_df.latitude,
lon= station_df.longitude,
mode='markers',
marker=dict(
size=45,
color='black',
),
opacity = 0.8,
text=station_df.valeur+' €/L',
showlegend = False,
)

Output from the plotly map with price mapped, Author Illustration

Don’t underestimate the importance of the color map you chose. There are multiple ways in which you can represent information and the message will not be necessarily the same. We are using here a divergence colormap, which is good for showing the relative difference of price from the average of the sample. I also leverage a classic human bias that associate red tones with something bad (here high prices) and green with something good (low prices).

As an example this is the result obtained with the default colormap:

An example NOT to follow about mapping color to prices, , Author Illustration

Add the position of the user and the research perimeter

Adding the position of the user is the simplest part, we can just add a red dot that will symbolize the center position of the research.

The research perimeter is a bit more complicated. We will represent the circle using another Scattermapbox with latitudes and longitudes at the edge of the circle. To do so, we need first a function to calculate, from a coordinate and a radius, the coordinates of the perimeter of the circle.

def points_on_circle(lat, lon, radius, num_points):
'''This function return a list of coordinates as lat/lon of size num_points forming a circle of a certain radius centered on a
a point of coordinate lat,lon. Thanks internet :)
'''
points = []
R = 6371
for i in range(num_points):
bearing = 360/num_points*i
lat2 = np.arcsin(np.sin(np.radians(lat))*np.cos(radius/R) +
np.cos(np.radians(lat))*np.sin(radius/R)*np.cos(np.radians(bearing)))
lon2 = np.radians(lon) + np.arctan2(np.sin(np.radians(bearing))*np.sin(radius/R)*np.cos(np.radians(lat)),
np.cos(radius/R)-np.sin(np.radians(lat))*np.sin(lat2))
points.append((np.degrees(lat2), np.degrees(lon2)))
points = np.array(points)
points = np.vstack([points, points[0]])

return points[:,0], points[:,1]

We can now generate a list of points, and add them as a new trace to our graph.

circle_lats, circle_longs = points_on_circle(lat,lon, 5, 50)

research_zone = go.Scattermapbox(
lat= circle_lats,
lon= circle_longs,
mode='lines',
fill='toself',
fillcolor = "rgba(1,1,1,0.2)",
marker=dict(
size=45,
color='black',
),
opacity = 0.8,
showlegend = False,
)

Adding the research parameter area and the position of the user, Author Illustration

Final packaging in a widget

We are almost done with our demonstrator, we just need to add a bit of interactivity. To do so, I am going to package everything we did so far and include 3 components to control the type of fuel, the distance of research, and the postal code.

I will skip the full packaging, I am only putting everything we saw so far in a general class to handle the different states. If you are not familiar with interactive notebook using ipywidgets, you can check this list of articles.

URL = f'https://donnees.roulez-eco.fr/opendata/instantane'
CITY_PATH = 'cities_coordinates.csv'
INIT_POST_CODE = '75001'
INIT_FUEL = 'SP98'
INIT_DIST = 3

import plotly.graph_objects as go

class FuelWidget:

def __init__(self):
'''We initiate the instance of our class with the functions we saw so far
1. We download the latest data from the stations price and gen our 2 dataframe
2. We load the postal code -> lat/lon .csv
3. We filter the dataset based on initial values
4. We initiate the layout with the figure we saw before and some UI components
'''

self._distance = INIT_DIST
self._fuel = INIT_FUEL
self._post_code = INIT_POST_CODE

#Load the stations data
z = load_from_url_in_memory(URL)
self._stations_df , self._gas_types_df = parse_xml(z)

#Load the cities data
self._cities_df = pd.read_csv(CITY_PATH, index_col=0, dtype={"postal_code":str})

#Filter the dataset with the initial constants
self._filter_by_postal_code(self._post_code, self._distance, self._fuel)

#Initialise the layout
self._init_layout()

def _init_layout(self):
'''This function initiate the widget layout and the different callbacks'''
#Select fuel dropdown
fuel_type_dropdown = Dropdown(options = self._gas_types_df.nom.unique(),
value = INIT_FUEL,
description = "Fuel type")
fuel_type_dropdown.observe(self._on_fuel_change, names='value')

#Select max distance dropdown
distance_dropdrown = Dropdown(options = [*range(1,30)],
value = INIT_DIST,
description = "distance (km)")
distance_dropdrown.observe(self._on_distance, names='value')

#Select postal code from text
postal_code_text= Text(placeholder="Postal Code")
postal_code_text.observe(self._on_change_text, names='value')

#Create the figure based on initial value
self._fig = init_chart(self._sub_station_with_prices,
self._lat_pos,
self._lon_pos,
self._distance)

#Create the widget
self._widget = VBox([postal_code_text,
HBox([fuel_type_dropdown,
distance_dropdrown]),
self._fig])

The callbacks will all do the same thing: observe a change, update the filtered dataset, and update the figure. For example, here is the _on_distance callback:

    def _on_distance(self, change):
'''This callback is called when the distance dropdown change, refilter the dataset,
and update the figure
'''
self._distance = change["new"]
done = self._filter_by_postal_code(self._post_code, self._distance, self._fuel)
if done:
self._update_fig()

Finally, let’s have a look at the method to update the figure. Once the dataset is filtered, we are generating a new figure and update the key elements of our current figure with the new traces generated.

def _update_fig(self):
#Initialize a new figure
new_fig = init_chart(self._sub_station_with_prices, self._lat_pos, self._lon_pos, self._distance)
with self._fig.batch_update():
#Update all the lat longs for our 5 traces
for k in range(5):
self._fig.data[k].lat = new_fig.data[k].lat
self._fig.data[k].lon = new_fig.data[k].lon

#Update the color markers of the prices
self._fig.data[2].marker = new_fig.data[2].marker

#Update the overlayed text
for k in [1,2,3]:
self._fig.data[k].text = new_fig.data[k].text

#Update the new center of the map
center = {"lat":self._lat_pos, "lon":self._lon_pos}
self._fig.layout.mapbox.center = center

And that’s it! We can just add a “public” method display that will be called in the notebook to display the widget, and our demo is now ready to be shown.

def display(self):
display(self._widget)
Our POC demo in action, Author Illustration

This last chapter concludes the first part of our product development, aka the Proof of Concept.

We built step by step the first demo that we can use to demonstrate the potential of our idea. This demo, done in a small amount of time, is still minimalist and we will have to develop the concept further in the next development phase, the Prototyping.

In that next phase of development, we will modify this minimalist widget to make it a real web application with more features and usable locally on your computer.


Photo by GeoJango Maps on Unsplash

Maps are a powerful tool for visualizing and understanding geographic data but they need specific skills to be designed efficiently.

In this step-by-step guide, we are going to take a deep dive into building a map-based application to show the customers prices of gas stations around them. We will cover the different key steps of a product, from original proof of concept (POC) to the minimum viable product (MVP)

What this article covers

In this article, the first of the series, we are preparing the proof of concept of our application.

We just got an idea that we would like to turn into something valuable but we don’t know yet if this is feasible. We need to look for data, evaluate their relevance and build something basic that will demonstrate if the original idea is feasible.

Also, and more than everything, we need to demonstrate to deciders (that are not necessarily technical people) that the project can be valuable.

From POC to MVP, Author Illustration

In particular, due to the specificity of that particular project, we will see in this article the following topics:

  • How to parse a .xml file into tabular data
  • Filter and combine DataFrames based on distances between points
  • Build a basic map with plotly
  • Add interactions to the map with ipywidgets

By the end of the article, we will have an interactive map that could be presented for POC demos before deciding to go further on product development.

Illustration of the product at the end of the POC phase, Author Illustration

The complete code of this whole POC is available in my github.

A bit of context around the app

In the current economic situation, we observe high volatility of gas prices in the stations. We also see — at least where I come from in France — shortages driven by strikes and some malicious station owners taking the opportunity of this to increase their prices to overly high levels. With this in mind, prices at the stations can become an important criterion for many people who need to refill.

The good news is: in France, real-time gas station prices are accessible through the open data policy implemented in the country. The data, in its raw format, is not of interest to many as not understandable instantaneously. Instead, it would need to be made available and displayed via a proper user interface so the right information is filtered and understandable in an eye blink.

We are going to build this user interface: a web app allowing users to retrieve oil prices for stations around them. I am focusing here on the french market (because the data is open publicly and easy to access), but our work will be easily reproducible for other types of data anywhere in the world.

A quick word about the datasets used

In its politic of open data, France maintains many datasets in free access. In particular, we are going to use the Fuel prices in France — Instant flow Dataset under Open Licence V2 and can be reused for commercial purposes.

We are also going to use the Communal Administrative division API for information relative to cities, also under Open Licence V2.

Our adventure starts here: navigating in the jungle of open data, we found this interesting dataset about gas prices. We don’t know yet what it looks like and what data it contains.

Multiple files are available for download as illustrated below:

A translated version of the page containing the datasets, Author Illustration

The first mistake to avoid here is to overflow our project with too much data: it is not because it is available that we need it.

Here, typically, we only want to demonstrate the possibility of showing the “instant” prices to users, we don’t aim at making a historical prices analysis. Thus, exploring historical data will be, at least for now, a waste of time and we are going to focus only on the instantaneous flow datasets.

Time is a precious resource, and many ideas will never turn into actual products. It is important to be efficient and make sure we focus only on the concept we want to showcase.

According to the documentation, the dataset contains information such as addresses, postal codes, coordinates, and prices, among with other information less useful for the POC (like other services proposed).

Retrieving the data

The instant data feed can be retrieved via a download link which will download a zip file containing a file locally in your computer.

https://donnees.roulez-eco.fr/opendata/instantane

This is nice, but even if we are in the POC phase we need to anticipate some of the constraints we will have later in the project that could be “killer”. In particular here, assuming we will update our data at least once a day, we don’t want the update to be done manually and we need to assess a way to access the data automatically.

Hopefully, we can load the zip directly in python combining urllib and zipfile libraries to perform operations on .zip files directly from an URL.

def load_from_url_in_memory(url):
'''This function load the xml file from the url in memory after unziping it
It assume only one file with a .xml extention is available in the zip
'''

with urllib.request.urlopen(url) as url:
s = io.BytesIO(url.read())
z = zipfile.ZipFile(s)

return z

Explore the .xml file

XML is a markup language commonly used for data storage and data exchanges. Even if newer formats such as JSON tend to gain more and more popularity, XML remains used widely in many applications and industries and it is still relevant to have a sense of how it works.

A .xml file forms tree structures, that start at the “root” and branch to child elements. Each element has its own attributes and its possible own children.

Let’s have a look at the file by opening it in a simple text editor.

The first line of the file gives some metadata, such as the encoding used:

<?xml version="1.0" encoding="ISO-8859-1" standalone="yes"?>

Here it says that to read the file, we need to use an ISO-8859–1 encoding, which will be useful when we will have to parse the file.

The .xml is then composed of different elements with multiple levels of depths:

<pdv_liste>
<pdv id="40140005" latitude="4374653.865" longitude="-132117.12497344" cp="40140" pop="R">
<adresse>9 AVENUE DE CRAMAT</adresse>
<ville>SOUSTONS</ville>
<horaires automate-24-24="1">
<jour id="1" nom="Lundi" ferme=""/>
<jour id="2" nom="Mardi" ferme=""/>
<jour id="3" nom="Mercredi" ferme=""/>
<jour id="4" nom="Jeudi" ferme=""/>
<jour id="5" nom="Vendredi" ferme=""/>
<jour id="6" nom="Samedi" ferme=""/>
<jour id="7" nom="Dimanche" ferme=""/>
</horaires>
<services>
<service>Laverie</service>
<service>Station de gonflage</service>
<service>Automate CB 24/24</service>
</services>
<prix nom="Gazole" id="1" maj="2023-01-18 07:27:47" valeur="1.891"/>
<prix nom="E85" id="3" maj="2023-01-14 08:15:44" valeur="1.059"/>
<prix nom="E10" id="5" maj="2023-01-18 07:27:47" valeur="1.839"/>
</pdv>
<pdv>
...
</pdv>
</pdv_liste>

Here the pdv element represents our oil station. Inside the tag, there is several parameters which are the attributes of the element (id, latitude, longitude, postal code…)

<pdv id="40140005" latitude="4374653.865" longitude="-132117.12497344" cp="40140" pop="R">

Each pdv has also multiple children:

  • adresse: the address of the station
  • ville: the city of the station
  • horaires: the hours of openings of the station
  • services: representing extra services provided such as car wash, ATM, etc…
  • prix: the price of each fuel type (available as attributes)

This data structure can be represented by the tree below:

Data structure of our .xml, Author illustration

From .xml to tabular data

This tree data structure is not fitted for easy manipulations and we want to parse it to a tabular version, which will be more convenient for later filtering and aggregation operations.

To store the data efficiently, we will create two tables:

  • A table to represent oil stations and their metadata (address, city, etc…)
  • A table to represent fuels, their stations, and their prices

You can use a tool such as https://dbdiagram.io/ to design your data structure.

Sample view of the data structure, Author illustration

In this POC phase, we will not set up a SQL server and simply store the data in memory in pandas DataFrames to go fast. We could also store it locally in a .csv as a starting point, but the file is light, so no need to go there for now.

When we will explore the MVP of the app, we will set up the database in a cleaner way.

Parsing the .xml file

At this point we know the data structure we want to reach, we just have to parse the file properly.

Before going further, I want to raise your attention to the fact that you should be particularly careful when using automated parsers as they could potentially dismiss part of the data available in the file.

For example, using the built-in pandas.read_xml() method, without any specific tuning, would return an incomplete DataFrame.

url = f'https://donnees.roulez-eco.fr/opendata/instantane'
xml_file = load_from_url_in_memory(url)
pd.read_xml(z.open(xml_file), encoding="ISO-8859-1")

The code above would result in the DataFrame below, where most of the information is missing:

Parsing quickly with pd.read_xml and default parameters don’t produce the relevant result

Instead, we are going to code our own data extractor, which will give us the opportunity to review recursive functions and is based on the python xml library.

Note: Even if we are in the POC, it is important to make the effort of keeping a clean code structure. We are going to build a proper set of functions for the data loading module that could be re-used with very few adaptations later in the process. It doesn’t cost extra time, but it will save a lot of work for the MVP.

To parse properly the file we use the xml library. Let’s start by accessing our root element, pdv_list

import xml.etree.ElementTree as ET

#Link of instant data feed
url = f'https://donnees.roulez-eco.fr/opendata/instantane'
#from the function defined earlier
xml_file = load_from_url_in_memory(url)

with z.open(xml_file, "r") as file:
#Create the tree using xml parser
tree = ET.parse(file)
#Access the root node
root = tree.getroot()

The root variable contains the element pdv_list

<Element 'pdv_liste' at 0x0000019EAEF3B400>

And the children elements can be simply accessed by iteration (in our case each child will be an element “pdv”:

for child in root:
#Do something

In order to handle the data whatever the order, we will create a station dictionary that will contain all the metadata that we want to keep for our “stations” table as well as an oil_type dictionary for our “oil_type” dataset.

To keep things clean, depending on the level of complexity of each node we will design custom functions. The code below illustrates how our custom parser work.

def parse_pdv(pdv, stations, oil_types):
'''This function retrieve all the data available in a pdv element'''

station = {}
#Get attributes of the pdv
station.update(pdv.attrib)

#It is precised in the doc that lats and longs have to be divided by 100 000
station['latitude'] = float(station['latitude'])/100000
station['longitude'] = float(station['longitude'])/100000

#Rename the id key
station["station_id"] = station["id"]
del station["id"]

#Explore children, by type of children
for element in pdv:
if element.tag == "adresse":
station["adress"] = element.text
if element.tag == "ville":
station["city"] = element.text
if element.tag == "horaires":
#Some children, more complicated to parse, have their own subfunctions
station = parse_schedules(element, station)
if element.tag == 'prix':
#Some children, more complicated to parse, have their own subfunctions
oil_types = parse_oil(element, station, oil_types)

stations.append(station)
return stations, oil_types

def parse_xml(z):
'''Parse the whole xml'''
xml_file = [file for file in z.namelist() if file.endswith('.xml')][0]
with z.open(xml_file, "r") as file:
#Create the tree using xml parser
tree = ET.parse(file)
#Access the root node
root = tree.getroot()

#Initiate stations and oil_types
stations = []
oil_types = []

for pdv in root:
stations, oil_types = parse_pdv(pdv, stations, oil_types)

stations_df = pd.DataFrame(stations)
oil_types_df = pd.DataFrame(oil_types)

return stations_df, gas_types_df

This set of functions will return those two DataFrames and should be relatively easy to modify in the future if we want to use SQL tables:

Sample DataFrame for the oil_type, Author Illustration
Sample DataFrame for the stations, Author Illustration

At that point, we have latitudes, longitudes, and prices for all the stations. We don’t want to show everything to the users, only the stations proposing relevant gas types around them.

In the coming part, we will develop all the functions to go from the raw datasets to the filtered ones.

Calculate the distance between two points from lat/lon coordinates

Latitudes and Longitudes are expressed in degrees, and as the distance is calculated on the surface of the globe, simple Euclidean geometry cannot be applied.

To calculate the distance between two points, we are going to use the Haversine formula:

def haversine_distance(lat1, lon1, lat2, lon2):
'''Calculate the distance between two points (lat1,lon1) and (lat2, lon2) in km'''
R = 6371

lat1, lon1, lat2, lon2 = map(np.radians, [lat1, lon1, lat2, lon2])
dlat = lat2 - lat1
dlon = lon2 - lon1

a = np.sin(dlat / 2)**2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2)**2
c = 2 * np.arctan2(np.sqrt(a), np.sqrt(1 - a))

distance = R * c
return distance

Whether the function comes from you or another developer, don’t forget to test it to verify that the resulting output is correct.

In my case, I am verifying using the Google Earth distance feature and checking the distance between two cities for which I know latitude and longitude, and confirming the function returns the expected output.

Filter the dataset based on a point identified with lat/lon

We can now create a function that filters the station dataset based on the haversine_distance from a source lat/lon.

def filter_dataset_from_source_point(stations_df, lat_point, lon_point, distance = 20):

return stations_df.loc[haversine_distance(stations_df.latitude, stations_df.longitude, lat_point, lon_point)<=distance]

I tested the function around my place to check that I retrieve well the stations available around, which is well the case.

filter_dataset_from_source_point(stations_df, lat_point = 49.131, lon_point = 2.521, distance = 5)
Sample dataset filtered around my village, Author Illustration

Filter by gas type

A user will be only interested in the stations selling the type of gas used in his car. We can now perform a join between station_id and our gas types to retrieve and filter the rows by gas type.

def filter_dataset_by_gas_type(sub_station_df, gas_types_df, gas_type):

joined_df = sub_station_df.set_index("station_id") \
.join(
gas_types_df.set_index("station_id"), how = 'left'
)
return joined_df.loc[joined_df.nom==gas_type]

filter_dataset_by_gas_type(sub_station_df, gas_types_df, "SP98").dropna(axis=1)

Illustration of the results after applying the filter_dataset_by_gas_type, showing only some relevant columns. Author Illustration

Getting coordinates and postal codes of french cities

What we have now is not bad but not enough: we don’t want our users to write themselves the latitude and longitude of their location which is not something accessible easily.

Instead, we want a feature to get the lat/lon as easily as possible. There is many ways of doing this and many API ( like Google Map API ) provide already information accessible at different degree of complexity (and different degree of prices…). For a POC we want to stay simple, so we are just going to look for a dataset with coordinates of city centers based on their postal codes.

Note: We have a dataset of stations that includes information about coordinates and postal codes, but it is limited to only cities that have gas stations. We aim to make the dataset as comprehensive as possible.

After a bit of research, I figured out another open dataset to use, accessible via an API that allows us to quickly retrieve exactly what we need. As mentioned in the introduction, those data are also under an open license and can be used freely.

url = f"https://geo.api.gouv.fr/communes?fields=codesPostaux,nom,centre"
req = requests.get(url).json()

#We need to unpack the json via a loop as we want duplicated lines for each postal code
city_data = []
for elmt in req:
for code_postal in elmt['codesPostaux']:
city = {}
city["postal_code"] = code_postal
city["name"] = elmt['nom']
city["lat"] = elmt['centre']['coordinates'][1]
city["lon"] = elmt['centre']['coordinates'][0]
city_data.append(city)

cities_df = pd.DataFrame(city_data)

Sample DataFrame for our cities_df, Author Illustration

We can now develop our final brick: a function that will take as input 3 parameters (a postal code, a distance, and a type of gas) and that will return the prices of gas stations around (I’ll assume for now stations_df, cities_df, and gas_type_df as a global variable, but I’ll package everything properly in a class at the end of the article).

from builtins import ValueError

def get_close_gas_prices(postal_code, distance_km, gas_type):

global cities_df
global stations_df
global gas_types_df

sub_cities = cities_df.loc[cities_df.postal_code==postal_code]

#If the postal code is not value, raise a value error
if len(sub_cities)==0:
raise BadInputError("Postal code is not a valid code")

lat_point, lon_point = sub_cities.iloc[0][["lat","lon"]]
sub_station_df = filter_dataset_from_source_point(stations_df, lat_point, lon_point, distance_km)
return filter_dataset_by_gas_type(sub_station_df, gas_types_df, gas_type)[["latitude" ,"longitude" ,"cp" ,"city","nom", "valeur"]]

Tested again on my city (note there is one more station here, probably due to a slight difference in the lat/lon from the postal_code -> lat/lon):

get_close_gas_prices(postal_code='60560', distance_km=5, gas_type='SP98')
CP dataset sample, Author Illustration

There is something also important to note here: while gas types will be accessible via a finite dropdown list, it is the user’s responsibility to write a correct postal code. If the postal code is not correct, the sub_cities DataFrame will be empty, and accessing its first element will raise an error.

We prefer to handle this case by catching the error with our own custom Exception, which will be easier to integrate later in the MVP.

from builtins import Exception

class BadInputError(Exception):
def __init__(self, message):
self.message = message

Using a postal code with the wrong format or with a value not in our database will generate this error message:

Illustration of our custom exception, Author Illustration

So far, so good, we demonstrated the possibility to access quickly the relevant information in the database by simply using a postal code, a distance, and a fuel type.

We are still using some python functions here which might be ugly to show to the management or to investors. To prove to them that it is worth pushing the project forward in the next stage of development, we want to integrate this in a small demonstrator.

We could create a small dashboard that runs locally using plotly and dash but the fastest way would be to simply create a small widget in a notebook. A more advanced dashboard will be then demonstrated during the prototyping phase.

Displaying a map in a notebook

There are multiple ways to display a map in a Jupyter Notebook, like the excellent folium library (check this post if you want more info about the lib).

In my case, I prefer to use plotly in anticipation ofthe next stages of development where we will embed the plotly map directly in our User Interface.

This part is very important in our development: it has to give a “wow” feeling to people to who you will demonstrate if you want a chance to reach later stages of development. You might have the best idea in the world, but if the package is ugly, people won’t buy it.

To make something clean, this is the information we want to display in our map:

  • The coordinate of the center of the city used
  • A transparent circle showing the perimeter of the research
  • The coordinates of the stations providing the type of fuel requested
  • For more clarity, we are also going to map the color of the station to the deviation of the price from the average

A simple map with Scattermapbox

Plotly has an integration of Mapbox GL JS, allowing us to build a tiled-base map that will handle operations such as navigation in the map and zooms without using excessive CPU resources.

To build a map we need two ingredients:

  • A trace containing the data to visualize and compatible with maps (here go.Scattermapbox)
  • A layout, including a map layer. This is done by specifying a Mapbox dictionary. Note: you will need a free Mapbox token to access the Mapbox tiles used for the map background.

The example below shows a simple map (we use FigureWidget rather than Figure to integrate later this map directly in our widget)

import plotly.graph_objects as go

#I hardcode my lat/lon for now, we integrate this in a proper class later
lat,lon = cities_df.loc[cities_df.postal_code=='60560'][["lat","lon"]].iloc[0]

stations = go.Scattermapbox(
lat= station_df.latitude,
lon= station_df.longitude,
mode='markers',
marker = dict(
size = 14,
symbol = 'fuel',
),
showlegend = False
)

#create the layout
layout = go.Layout(
height = 700,
width = 700,
mapbox = dict(
accesstoken = token,
style ='streets',
center = dict(
lat = lat,
lon = lon
),
zoom = 10,
),
)

fig = go.FigureWidget(data=[stations], layout=layout)
fig.show()

This code, when applied to our pre-filtered dataset, will result in :

Output from the plotly map, Author Illustration

Not very fancy yet, but it’s a good start.

Adding price indicators

We can simply add other traces to our map to add more information to our base map.

As it is not possible to change directly the color of the icons, we are going to add an extra ScatterMapBox with circle points for which we can modify the color.

prices = go.Scattermapbox(
lat = station_df.latitude,
lon = station_df.longitude,
mode ='markers',
marker = dict(
size = 30,
color = station_df.valeur.astype(float),
colorscale = 'PiYG_r'
),
opacity = 0.5,
text = station_df.valeur+' €/L',
showlegend = False
)

#This is a bit hacky, but it allows us to add a solid border around the circle
solid_price_border = go.Scattermapbox(
lat= station_df.latitude,
lon= station_df.longitude,
mode='markers',
marker=dict(
size=45,
color='black',
),
opacity = 0.8,
text=station_df.valeur+' €/L',
showlegend = False,
)

Output from the plotly map with price mapped, Author Illustration

Don’t underestimate the importance of the color map you chose. There are multiple ways in which you can represent information and the message will not be necessarily the same. We are using here a divergence colormap, which is good for showing the relative difference of price from the average of the sample. I also leverage a classic human bias that associate red tones with something bad (here high prices) and green with something good (low prices).

As an example this is the result obtained with the default colormap:

An example NOT to follow about mapping color to prices, , Author Illustration

Add the position of the user and the research perimeter

Adding the position of the user is the simplest part, we can just add a red dot that will symbolize the center position of the research.

The research perimeter is a bit more complicated. We will represent the circle using another Scattermapbox with latitudes and longitudes at the edge of the circle. To do so, we need first a function to calculate, from a coordinate and a radius, the coordinates of the perimeter of the circle.

def points_on_circle(lat, lon, radius, num_points):
'''This function return a list of coordinates as lat/lon of size num_points forming a circle of a certain radius centered on a
a point of coordinate lat,lon. Thanks internet :)
'''
points = []
R = 6371
for i in range(num_points):
bearing = 360/num_points*i
lat2 = np.arcsin(np.sin(np.radians(lat))*np.cos(radius/R) +
np.cos(np.radians(lat))*np.sin(radius/R)*np.cos(np.radians(bearing)))
lon2 = np.radians(lon) + np.arctan2(np.sin(np.radians(bearing))*np.sin(radius/R)*np.cos(np.radians(lat)),
np.cos(radius/R)-np.sin(np.radians(lat))*np.sin(lat2))
points.append((np.degrees(lat2), np.degrees(lon2)))
points = np.array(points)
points = np.vstack([points, points[0]])

return points[:,0], points[:,1]

We can now generate a list of points, and add them as a new trace to our graph.

circle_lats, circle_longs = points_on_circle(lat,lon, 5, 50)

research_zone = go.Scattermapbox(
lat= circle_lats,
lon= circle_longs,
mode='lines',
fill='toself',
fillcolor = "rgba(1,1,1,0.2)",
marker=dict(
size=45,
color='black',
),
opacity = 0.8,
showlegend = False,
)

Adding the research parameter area and the position of the user, Author Illustration

Final packaging in a widget

We are almost done with our demonstrator, we just need to add a bit of interactivity. To do so, I am going to package everything we did so far and include 3 components to control the type of fuel, the distance of research, and the postal code.

I will skip the full packaging, I am only putting everything we saw so far in a general class to handle the different states. If you are not familiar with interactive notebook using ipywidgets, you can check this list of articles.

URL = f'https://donnees.roulez-eco.fr/opendata/instantane'
CITY_PATH = 'cities_coordinates.csv'
INIT_POST_CODE = '75001'
INIT_FUEL = 'SP98'
INIT_DIST = 3

import plotly.graph_objects as go

class FuelWidget:

def __init__(self):
'''We initiate the instance of our class with the functions we saw so far
1. We download the latest data from the stations price and gen our 2 dataframe
2. We load the postal code -> lat/lon .csv
3. We filter the dataset based on initial values
4. We initiate the layout with the figure we saw before and some UI components
'''

self._distance = INIT_DIST
self._fuel = INIT_FUEL
self._post_code = INIT_POST_CODE

#Load the stations data
z = load_from_url_in_memory(URL)
self._stations_df , self._gas_types_df = parse_xml(z)

#Load the cities data
self._cities_df = pd.read_csv(CITY_PATH, index_col=0, dtype={"postal_code":str})

#Filter the dataset with the initial constants
self._filter_by_postal_code(self._post_code, self._distance, self._fuel)

#Initialise the layout
self._init_layout()

def _init_layout(self):
'''This function initiate the widget layout and the different callbacks'''
#Select fuel dropdown
fuel_type_dropdown = Dropdown(options = self._gas_types_df.nom.unique(),
value = INIT_FUEL,
description = "Fuel type")
fuel_type_dropdown.observe(self._on_fuel_change, names='value')

#Select max distance dropdown
distance_dropdrown = Dropdown(options = [*range(1,30)],
value = INIT_DIST,
description = "distance (km)")
distance_dropdrown.observe(self._on_distance, names='value')

#Select postal code from text
postal_code_text= Text(placeholder="Postal Code")
postal_code_text.observe(self._on_change_text, names='value')

#Create the figure based on initial value
self._fig = init_chart(self._sub_station_with_prices,
self._lat_pos,
self._lon_pos,
self._distance)

#Create the widget
self._widget = VBox([postal_code_text,
HBox([fuel_type_dropdown,
distance_dropdrown]),
self._fig])

The callbacks will all do the same thing: observe a change, update the filtered dataset, and update the figure. For example, here is the _on_distance callback:

    def _on_distance(self, change):
'''This callback is called when the distance dropdown change, refilter the dataset,
and update the figure
'''
self._distance = change["new"]
done = self._filter_by_postal_code(self._post_code, self._distance, self._fuel)
if done:
self._update_fig()

Finally, let’s have a look at the method to update the figure. Once the dataset is filtered, we are generating a new figure and update the key elements of our current figure with the new traces generated.

def _update_fig(self):
#Initialize a new figure
new_fig = init_chart(self._sub_station_with_prices, self._lat_pos, self._lon_pos, self._distance)
with self._fig.batch_update():
#Update all the lat longs for our 5 traces
for k in range(5):
self._fig.data[k].lat = new_fig.data[k].lat
self._fig.data[k].lon = new_fig.data[k].lon

#Update the color markers of the prices
self._fig.data[2].marker = new_fig.data[2].marker

#Update the overlayed text
for k in [1,2,3]:
self._fig.data[k].text = new_fig.data[k].text

#Update the new center of the map
center = {"lat":self._lat_pos, "lon":self._lon_pos}
self._fig.layout.mapbox.center = center

And that’s it! We can just add a “public” method display that will be called in the notebook to display the widget, and our demo is now ready to be shown.

def display(self):
display(self._widget)
Our POC demo in action, Author Illustration

This last chapter concludes the first part of our product development, aka the Proof of Concept.

We built step by step the first demo that we can use to demonstrate the potential of our idea. This demo, done in a small amount of time, is still minimalist and we will have to develop the concept further in the next development phase, the Prototyping.

In that next phase of development, we will modify this minimalist widget to make it a real web application with more features and usable locally on your computer.

FOLLOW US ON GOOGLE NEWS

Read original article here

Denial of responsibility! Techno Blender is an automatic aggregator of the all world’s media. In each content, the hyperlink to the primary source is specified. All trademarks belong to their rightful owners, all materials to their authors. If you are the owner of the content and do not want us to publish your materials, please contact us by email – [email protected]. The content will be deleted within 24 hours.

Leave a comment