
One of the tricky parts of my PhD was creating an interactive map visualization showcasing the maritime routes of ships from one port to another. The route of a ship between an origin and a destination port should be a path solely at sea, without crossing any land. Surprisingly, this seemingly straightforward task proved to be quite challenging when attempting to implement it from scratch in Python 🤷♀️. While there are commercial solutions like Marine Traffic available for performing similar tasks, I was looking for an open-source alternative, which I couldn’t find for a long time. Finally, in late 2022, the SeaRoute library was released for Python (previously it was only available for Java), and it made my life a lot easier. In this article, I’ll guide you through the process of creating an interactive map visualization for a Dash app, allowing you to display sea routes using the Dash Leaflet and SeaRoute Python libraries.
What about Dash, Dash Leaflet and SeaRoute?
Dash is a powerful Python framework for building interactive dashboards, created on top of React.js, incorporating all the computing capabilities of Python. For a quick intro on what Dash is and what you can do with it, as well as how to create your very first Dash app and get it up and running, you can take a look at this post I wrote a while ago. If you are new to Dash, I suggest you read this first…
From Data to Dashboard: Visualizing NBA Player Stats With Dash 🏀
In this post, I’ll showcase the use of the Dash Leaflet library, along with the SeaRoute library, using the ancient maritime Silk Road routes as an example.
Dash Leaflet is an extensive map visualization Python library that allows you to integrate interactive Leaflet-style maps into Dash apps – essentially a wrapper for Leaflet.js within the Dash ecosystem. It provides components for creating and customizing maps with various features, such as markers, polygons, popups, and layers.
SeaRoute is a Python library for calculating paths between points at sea – let’s say for instance between Houston and Rotterdam ports. It appears that in order to calculate this in Python from scratch, one needs to do some crazy stuff, like obtain oceanic shapefiles representing the oceanic areas (e.g. Maritime Boundaries) and plot them on a map, then calculate the geodesic path between origin and destination points, and then constraint it to only cross those oceanic areas. This is a rather complex task, mostly because there is not a uniform, standard source of such data yet, as each country may provide their own data on a different format and source, and in some cases data may be incomplete. Seems like a lot of work for just a visualization, if you ask me. So, what a girl needing a beautiful map visualization is supposed to do in this life😠 ? Happily the SeaRoute library release in late 2022 solved this issue, allowing us to easily calculate paths at sea for visualizations, as shown in the map below😇 .

Thus, my goal for the rest of this post is to:
- Calculate the path coordinates between two points at sea using SeaRoute,
- Visualize the path on a map using Dash Leaflet,
- Wrap everything into a Dash app,
all while using the maritime Silk Road routes as an example.
Let’s go! 🤸♀️️
What about the Maritime Silk Road?
The Maritime Silk Road was a crucial maritime trade route that connected various civilizations across the continents of Asia, Africa, and Europe during ancient times. It was an extension of the broader and more well know Silk Road network, which encompassed both land and sea routes and facilitated trade, cultural exchange, and the transmission of ideas and technologies between East and West.
Originating during the Han Dynasty of China (206 BCE – 220 CE), the Maritime Silk Road reached its peak during the Tang (618–907) and Song (960–1279) dynasties. Chinese sailors, venturing across the South China Sea and the Indian Ocean, traded silk, porcelain, tea, and other goods with merchants from Southeast Asia, India, the Arabian Peninsula, East Africa, and beyond. In return, China imported spices, precious metals, gemstones, and exotic goods, enriching its culture and economy. The decline of the Maritime Silk Road began around the 14th century as maritime trade routes shifted and new trade routes emerged. By the 17th century, the traditional Silk Road and Maritime Silk Road routes had largely faded away as new global trade routes emerged with European dominance.
Some indicative routes along the Maritime Silk Road (which we are going to visualize on a map in the rest of this post) are:
- Quanzhou > Malacca > Calicut > Aden > Alexandria
- Guangzhou > Manila > Brunei > Surabaya > Jakarta > Singapore
- Hangzhou > Ningbo > Nagasaki > Busan > Hakata > Osaka
- Guangzhou > Hanoi > Da Nang > Singapore > Colombo > Muscat
- Xiamen > Taiwan > Okinawa > Yokohama > Kobe > Nagasaki > Busan
The SeaRoute library also provides several other interesting calculations. For instance, we can calculate the length of a route or the duration of the trip for a given speed. For this post, let’s consider that a Chinese Junk (a common type of ship used during the Maritime Silk Road era) sailed with an average speed of 5 knots.

Building our Dash App
Setting up the environment
Before everything, we need to make sure that the necessary libraries are installed. These are Dash, Dash-Leaflet, and SeaRoute, and we can easily install them using pip:
pip install dash dash-leaflet searoute
Then, we can import them by:
import dash
from dash import dcc, html
import dash_leaflet as dl
from dash.dependencies import Input, Output
from searoute import SeaRoute
Next, we can initiate a blank instance of a Dash app simply by:
app = dash.Dash(__name__)
In general, Dash apps include two main components: layout and callbacks. The layout component defines the visual and structural parts of the app, whereas the callbacks component describes the app’s interactivity. But before further diving into the app’s layout, we need to make sure that the required data are available.
Importing the data
Firstly, I created a table containing some indicative ports of the Maritime Silk Road, along with their respective coordinates and brief descriptions. Port coordinates are indicative and were sourced from OpenStreetMap, used under the Open Database License (ODbL).

import pandas as pd
# Constructing the table with ports, coordinates, and descriptions
ports_data = {
'Port': ['Aden', 'Alexandria', 'Brunei', 'Busan', 'Calicut',
'Colombo', 'Da Nang', 'Guangzhou', 'Hakata', 'Hangzhou',
'Hanoi', 'Jakarta', 'Kobe', 'Malacca', 'Manila',
'Muscat', 'Nagasaki', 'Ningbo', 'Okinawa', 'Osaka',
'Quanzhou', 'Singapore', 'Surabaya', 'Taiwan', 'Xiamen', 'Yokohama'],
'Country': ['Yemen', 'Egypt', 'Brunei', 'South Korea', 'India',
'Sri Lanka', 'Vietnam', 'China', 'Japan', 'China',
'Vietnam', 'Indonesia', 'Japan', 'Malaysia', 'Philippines',
'Oman', 'Japan', 'China', 'Japan', 'Japan',
'China', 'Singapore', 'Indonesia', 'Taiwan', 'China', 'Japan'],
'Latitude': [12.799, 31.2001, 4.5353, 35.1796, 11.2588,
6.9271, 16.0544, 23.1291, 33.5904, 30.2741,
21.0285, -6.2088, 34.6901, 2.1896, 14.5995,
23.6102, 32.7467, 29.8683, 26.2041, 34.6937,
24.8798, 1.3521, -7.2575, 23.6978, 24.4798, 35.4437],
'Longitude': [45.0289, 29.9187, 114.7277, 129.0756, 75.7804,
79.8612, 108.2022, 113.2644, 130.4017, 120.1551,
105.8542, 106.8456, 135.1955, 102.2501, 120.9842,
58.5922, 129.8734, 121.544, 127.6476, 135.5023,
118.5876, 103.8198, 112.7521, 120.9605, 118.0894, 139.638],
'Description': ['Important hub on the Red Sea trade route', 'Major Mediterranean port, link to Europe', 'Key stop for spice trade, Islamic influence', 'Korean gateway to the Silk Road', 'Flourishing trade center, spice trade hub',
'Key port for Indian Ocean trade routes', 'Vital port during the Champa Kingdom', 'Ancient Chinese trading port, known as Canton', 'Important Japanese port for Silk Road trade', 'Grand Canal's end point, Silk production hub',
'Capital of Vietnam, ancient trading city', 'Capital of Indonesia, key port in Java', 'Important Japanese port, gateway to Kyoto', 'Strategic strait, crossroads of trade', 'Center of Spanish trade in Asia',
'Important Arabian Sea trading post', 'Japanese gateway to the world, Dutch trading', 'Major seaport, vital to Chinese trade', 'Key stopover for East Asia trade routes', 'Major Japanese port, historic trading center',
'Major Chinese port, key trade hub', 'Strategic strait, major trading post', 'Java's major port, key for Indonesian trade', 'Island stop for maritime trade', 'Historic Chinese port, gateway to Fujian', 'Major Japanese port, opened to foreign trade']
}
ports = pd.DataFrame(ports_data)
On top of this, I define some indicative routes (port sequences) I want to visualize on the map and store them in a DataFrame:
route_1 = ['Quanzhou','Malacca','Calicut','Aden','Alexandria']
route_2 = ['Guangzhou','Manila','Brunei','Surabaya','Jakarta','Singapore']
route_3 = ['Hangzhou','Ningbo','Nagasaki','Busan','Hakata','Osaka']
route_4 = ['Guangzhou','Hanoi','Da Nang','Singapore','Colombo','Muscat']
route_5 = ['Xiamen','Taiwan','Okinawa','Yokohama','Kobe','Nagasaki','Busan']
routes = pd.DataFrame({
'Route': ['route_1', 'route_2', 'route_3', 'route_4', 'route_5'],
'Port_Sequence': [route_1, route_2, route_3, route_4, route_5]
})
Calculating the route
In order to form the needed calculations, I assume a generic route_var
variable representing any route, and structure the respective port names and coordinates included in this route in a DataFrame. More specifically, this code initially iterates through the ports in the given route and retrieves their coordinates and descriptions.
route_rows = []
for port in route_var: # Iterating over ports in route
port_name = port
lat = ports.loc[ports['Port'] == port, 'Latitude'].iloc[0]
lon = ports.loc[ports['Port'] == port, 'Longitude'].iloc[0]
row = {'Port': port_name, 'lat': lat, 'lon': lon}
route_rows.append(row)
route_df = pd.concat([pd.DataFrame(row, index=[0]) for row in route_rows], ignore_index=True)
To begin with the map visualization, we can easily create a map object for visualizing the ports as markers using the Dash-Leaflet library’s dl.Marker()
, dl.Tooltip
, and dl.LayerGroup()
components as follows:
# Create map object from calculated port markers
markers = []
for i in range(len(route_df)):
# Create tooltip for each port marker
tooltip = route_df.loc[i, 'Port'] + ', ' + ports.loc[ports['Port'] == route_df.loc[i, 'Port'], 'Description'].iloc[0]
markers.append( # Calculate markers
dl.Marker(
position=(route_df.loc[i, 'lat'], route_df.loc[i, 'lon']),
children=[dl.Tooltip(tooltip)]
)
)
cluster = dl.LayerGroup(children=markers)

In this way, we initially create the markers using dl.Marker()
with tooltips using dl.Tooltip()
for each port of the selected route. Then, we group these markers into a single layer group using dl.LayerGroup()
. The LayerGroup()
component is used to group multiple map elements, such as markers, into a single layer. This allows us to manage and control these elements collectively. For example, we can show or hide all the markers with one user action, rather than selecting markers one by one.
Moving on to the path at sea calculation, this can be achieved using the SeaRoute library as shown below:
# Calculate path at sea
markers_line = []
length = 0
duration_hours = 0
for i in range(0, len(route_df) - 1):
origin = [route_df.loc[i, 'lon'], route_df.loc[i, 'lat']]
destination = [route_df.loc[i+1, 'lon'], route_df.loc[i+1, 'lat']]
searoutes_coords = sr.searoute(origin, destination, append_orig_dest=True, speed_knot=2)
searoutes_coords_transposed = [[coord[1], coord[0]] for coord in searoutes_coords['geometry']['coordinates']]
markers_line += searoutes_coords_transposed
length += searoutes_coords['properties']['length']
duration_hours += searoutes_coords['properties']['duration_hours']
duration_days = duration_hours / 24
As mentioned previously, the SeaRoute library also allows us to calculate various additional attributes, such as the route distance and the voyage duration for a given speed, as shown here. Notice how the speed is defined with speed_knot = 5
in the sr.searoute()
function.
After calculating the map coordinates with SeaRoute, we can then visualize them on the map using the dl.Polyline()
component. Additionally, we can add arrows to indicate the line direction using the dl.PolylineDecorator()
component. I define a simple arrow from scratch in the patterns
variable.
# Create map object for calculated path at sea
line = dl.Polyline(
positions=markers_line,
smoothFactor=1.0,
color='ForestGreen',
weight=1,
lineCap='round',
lineJoin='round'
)
patterns = [dict(offset='5%', repeat='30px', endOffset='10%', arrowHead=dict(pixelSize=8, polygon=False, pathOptions=dict(stroke=True, color='ForestGreen', weight=1, opacity=10, smoothFactor=1)))]
dline = dl.PolylineDecorator(children=line, patterns=patterns)

In addition, I considered it appropriate to recalculate the center and the bounds of the map for each selected route. This way, the map will refocus for each route selection made by the user, ensuring that the markers and lines are displayed appropriately.
# Calculate bounds
min_lat = min(lat for lat, lon in markers_line) - 2
max_lat = max(lat for lat, lon in markers_line) + 2
min_lon = min(lon for lat, lon in markers_line) - 2
max_lon = max(lon for lat, lon in markers_line) + 2
bounds = [[min_lat, min_lon], [max_lat, max_lon]]
# Calculate centroid
x, y = zip(*markers_line)
centroid = [sum(x) / len(x), sum(y) / len(y)]
Finally, I wrap all these elements into one function.
# Define function for calculating map markers and path at sea of route ports
def get_route_line(route_var):
route_rows = []
for port in route_var: # Iterating over ports in route
port_name = port
lat = ports.loc[ports['Port'] == port, 'Latitude'].iloc[0]
lon = ports.loc[ports['Port'] == port, 'Longitude'].iloc[0]
row = {'Port': port_name, 'lat': lat, 'lon': lon}
route_rows.append(row)
route_df = pd.concat([pd.DataFrame(row, index=[0]) for row in route_rows], ignore_index=True)
# Create map object from calculated port markers
markers = []
for i in range(len(route_df)):
# Create tooltip for each port marker
tooltip = route_df.loc[i, 'Port'] + ', ' + ports.loc[ports['Port'] == route_df.loc[i, 'Port'], 'Description'].iloc[0]
markers.append( # Calculate markers
dl.Marker(
position=(route_df.loc[i, 'lat'], route_df.loc[i, 'lon']),
children=[dl.Tooltip(tooltip)]
)
)
cluster = dl.LayerGroup(children=markers)
# calculate path at sea
markers_line = []
length = 0
duration_hours = 0
for i in range(0, len(route_df) - 1):
origin = [route_df.loc[i, 'lon'], route_df.loc[i, 'lat']]
destination = [route_df.loc[i+1, 'lon'], route_df.loc[i+1, 'lat']]
searoutes_coords = sr.searoute(origin, destination, append_orig_dest=True, speed_knot=2)
searoutes_coords_transposed = [[coord[1], coord[0]] for coord in searoutes_coords['geometry']['coordinates']]
markers_line += searoutes_coords_transposed
length += searoutes_coords['properties']['length']
duration_hours += searoutes_coords['properties']['duration_hours']
duration_days = duration_hours / 24
# create map object for calculated path at sea
line = dl.Polyline(
positions=markers_line,
smoothFactor=1.0,
color='ForestGreen',
weight=1,
lineCap='round',
lineJoin='round'
)
patterns = [dict(offset='5%', repeat='30px', endOffset='10%', arrowHead=dict(pixelSize=8, polygon=False, pathOptions=dict(stroke=True, color='ForestGreen', weight=1, opacity=10, smoothFactor=1)))]
dline = dl.PolylineDecorator(children=line, patterns=patterns)
# Calculate bounds
min_lat = min(lat for lat, lon in markers_line) - 2
max_lat = max(lat for lat, lon in markers_line) + 2
min_lon = min(lon for lat, lon in markers_line) - 2
max_lon = max(lon for lat, lon in markers_line) + 2
bounds = [[min_lat, min_lon], [max_lat, max_lon]]
# Calculate centroid
x, y = zip(*markers_line)
centroid = [sum(x) / len(x), sum(y) / len(y)]
return cluster, dline, centroid, bounds, duration_daysThus
Creating the layout
After defining the get_route_line()
function, we can now structure the layout component of the app. I aim to create a layout resembling the image below:

More specifically, I want to incorporate:
- A route selection container, including a dropdown menu for selecting a route, as well as displaying information about each route, such as the selected route, the assumed vessel speed, and the estimated voyage duration.
- A map visualization, displaying the ports of the selected route and the respective path at sea.
The dropdown pane can be defined as follows:
# Left pane for dropdown and route information
html.Div([
html.H1('Ancient Maritime Silk Road'),
html.Div([
dcc.Dropdown(
id='route_dropdown',
options=[{'label': route, 'value': route} for route in routes['Route']],
placeholder='Select a route'
)
], style={'display': 'block', 'height': '30%', 'justify-content': 'center', 'color': 'gray'}),
html.Div(id='route_info', style={'height': '100%'})
], style={'display': 'inline-block', 'height': '100%', 'width': '15%', 'background-color': '#17408B', 'color': 'white', 'padding': '2%', 'position': 'relative'}),
More specifically, the dropdown menu is populated by the routes DataFrame we defined earlier. In addition, the route information pane will initially be empty and will be populated through a callback once we select a route from the dropdown menu.
Regarding the map visualization, it can be defined as follows:
# Right pane for the map
html.Div([
dl.Map(children=dl.LayersControl(
[
dl.BaseLayer(dl.TileLayer(url='https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'), id='map_base', checked=True, name='Base Map')
] +
[
dl.Overlay(children=[], id='route_lines', checked=True, name='Route Direction'),
dl.Overlay(children=[], id='route_markers', checked=True, name='Ports')
]
), id='routess_map', zoom=3)
], style={'display': 'inline-block', 'height': '100%', 'width': '85%', 'background-color': 'white', 'box-sizing': 'border-box'})
]
Notice how the dl.Map()
component includes a base map of our choice (defined through the dl.BaseLayer()
component) as well as any other map objects defined with dl.Overlay()
. Here again, dl.Overlay()
is initially empty and will be populated once we select a route from the dropdown menu, using the get_route_line()
function we defined earlier.
Finally, both the dropdown menu and the map containers can be incorporated into a parent container, assigned to the layout component of the app:
# Initiate Dash app
app = Dash(__name__)
# Define the layout
app.layout = html.Div([
# Left pane for dropdown and route information
html.Div([
html.H1('Ancient Maritime Silk Road'),
html.Div([
dcc.Dropdown(
id='route_dropdown',
options=[{'label': route, 'value': route} for route in routes['Route']],
placeholder='Select a route'
)
], style={'display': 'block', 'height': '30%', 'justify-content': 'center', 'color': 'gray'}),
html.Div(id='route_info', style={'height': '100%'})
], style={'display': 'inline-block', 'height': '100%', 'width': '15%', 'background-color': '#17408B', 'color': 'white', 'padding': '2%', 'position': 'relative'}),
# Right pane for the map
html.Div([
dl.Map(children=dl.LayersControl(
[
dl.BaseLayer(dl.TileLayer(url='https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'), id='map_base', checked=True, name='Base Map')
] +
[
dl.Overlay(children=[], id='route_lines', checked=True, name='Route Direction'),
dl.Overlay(children=[], id='route_markers', checked=True, name='Ports')
]
), id='events_map', zoom=3)
], style={'display': 'inline-block', 'height': '100%', 'width': '85%', 'background-color': 'white', 'box-sizing': 'border-box'})
], style={'display': 'flex', 'height': '100vh', 'width': '100vw', 'position': 'fixed', 'margin': '-8px', 'justify-content': 'center', 'boxSizing': 'border-box'})

Setting up callbacks
Now that we have set up the layout of the Dash app, our next step is to define its interactivity. When we choose a route from the dropdown menu, the respective markers and lines should appear on the map, along with the respective route information. This can be achieved with a single callback function, as follows:
@app.callback(
Output('route_markers', 'children'),
Output('route_lines', 'children'),
Output('routes_map', 'center'),
Output('routes_map', 'bounds'),
Output('route_info', 'children'),
Input('route_dropdown', 'value')
)
def update_map_lines(selected_route):
if selected_route is None:
bounds = [[-50, -80], [50, 80]]
centroid = [0, 0]
return [], [], centroid, bounds, []
else:
route_var = routes.loc[routes['Route'] == selected_route, 'Port_Sequence'].iloc[0]
cluster, dline, centroid, bounds, duration_days, length = get_route_line(route_var)
route_name = selected_route.replace('_', ' ').title()
route_info = [
html.P([html.B("Route: "), route_name]),
html.P([html.B("Distance: "), f"{length:.0f} km"]),
html.P([html.B("Speed: "), "2 knots"]),
html.P([html.B("Duration: "), f"{duration_days:.0f} days"]),
]
return cluster, [dline], centroid, bounds, route_info
This callback uses the get_route_line()
function we created earlier to create the markers and line map objects, recalculate the center and bounds of the map, as well as calculate the route information to be displayed.
Testing the app
After defining the layout and callback components, our app is ready, and we can launch it by writing the following code:
if __name__ == '__main__':
app.run_server(debug=True)
Then, we can run the entire app file. If everything is done correctly, we’ll get something like this:

✨And voilà✨
The Dash app runs on a localhost server and is accessible via a web browser at the displayed URL. This way, we can see and debug a fully functional instance of our app.

On my mind
In data analytics and visualization, custom reporting tools like Dash are gaining popularity due to their flexibility and ease of use. Unlike self-service tools like Power BI or Tableau, which offer many pre-built visualization options, Dash provides full control over report design and functionality. This enables the creation of fully customized reports and visualizations to meet specific user needs.
For instance, the map visualization shown in this post would be significantly more difficult, or even impossible, to create using off-the-shelf data visualization tools. We would be required to separately calculate and store the route coordinates, and then visualize them on a map, if we used, let’s say, Tableau. Even then, creating a directed line would be quite cumbersome. This level of customization highlights why Dash is an increasingly preferred choice for data professionals.
✨Thank you for reading!✨
Loved this post?
💌 Join me on Substack or LinkedIn ☕, or Buy me a coffee!
or, take a look at my other Data Science posts: