Figure Friday 2025 - week 39

join the Figure Friday session on October 3, at noon Eastern Time, to showcase your creation and receive feedback from the community. Update: Sorry I have to cancel this Friday’s session because I’m out of office.

Can you discover any bicycle traffic patterns in Montreal?

Answer this question and a few others by using Plotly on the Montreal bicycle dataset.

Things to consider:

  • what can you improve in the app or sample figure below (histogram)?
  • would you like to tell a different data story using a Dash app?
  • how can you explore the data with Plotly Studio?

Sample figure (smaller dataset for demo purposes):

Code for sample figure:
import plotly.express as px
import pandas as pd 

# Download csv sheet from google drive: https://drive.google.com/file/d/1piaVJumZT6pSUiG-Xb5fMa0E6KgQG-iJ/view?usp=sharing
df = pd.read_csv("pistes-cyclables-2024.csv")  # this is a much smaller demo dataset
df_unique_location = df[df['id_compteur']==100053209]
hourly_unique_compteurs = df_unique_location.groupby('heure')['nb_passages'].sum()
hourly_unique_compteurs_df = hourly_unique_compteurs.reset_index()

fig = px.histogram(hourly_unique_compteurs_df, x='heure', y='nb_passages', title='Number of bicycles in location: 100053209')
fig.show()

For community members that would like to build the data app with Plotly Studio, simply go to the downloads page to download Plotly Studio.

Below is a screenshot of a dropdown with a scatter map by Plotly Studio on top of this dataset:

Map prompt:

Bike counter locations and traffic volume as a scatter map plot with sliders to adjust point size by passage count and dropdown to filter by date range

Dropdown prompt:

Add a dropdown to change the map layer and include the following options
"basic"
"carto-darkmatter"
"carto-darkmatter-nolabels"
"carto-positron"
"carto-positron-nolabels"
"carto-voyager"
"carto-voyager-nolabels"
"dark"
"light"
"open-street-map"
"outdoors"
"satellite"
"satellite-streets"
"streets"

Thank you @liamc for the gif and prompt.

Participation Instructions:

  • Create - use the weekly data set to build your own Plotly visualization or Dash app. Or, enhance the sample figure provided in this post, using Plotly or Dash.
  • Submit - post your creation to LinkedIn or Twitter with the hashtags #FigureFriday and #plotly by midnight Thursday, your time zone. Please also submit your visualization as a new post in this thread.
  • Celebrate - join the Figure Friday sessions to showcase your creation and receive feedback from the community.

:point_right: If you prefer to collaborate with others on Discord, join the Plotly Discord channel.

Data Source:

Thank you to Montreal Open Data portal for the data.

Hi,

I could use some help. I’ve been working with ChatGPT for many hours now to find out why only the ‘label’ of the hover over pop-up displays but there is no actual marker on the map (I just randomly scroll over the map and they pop-up). But could not get a solution. This is the code I’m using:

import dash
from dash import dcc, html
import pandas as pd
import plotly.express as px


#import
df = pd.read_csv((my file locally), low_memory=False)

#rename
df = df.rename(columns={"number_of_passes2": "total_passes"})

#create map
fig = px.scatter_map(
    df,
    lat="latitude",
    lon="longitude",
    color="total_passes",
    size="total_passes",        # bubble size = count
    size_max=25,                # max bubble size in pixels
    hover_name="counter_id",
    hover_data={"total_passes": True, "latitude": True, "longitude": True},
    color_continuous_scale="RdBu_r",
    zoom=11,
    height=700
)


# make all markers the same size
fig.update_traces(
    marker=dict(
        opacity=0.8,
        )
)

#adjust the map style and margins
fig.update_layout(
    mapbox_style="carto-positron",
    mapbox_center={"lat": 45.5017, "lon": -73.5673},
    margin={"r": 0, "t": 0, "l": 0, "b": 0}
)

#dash app
app = dash.Dash(__name__)

app.layout = html.Div([
    html.H3("Montréal Bicycle Counter Map"),
    dcc.Graph(figure=fig)
])

if __name__ == "__main__":
    app.run(debug=True)

Thanks,

Mike

Hi Mike,
I’m facing a similar issue: usingscatter_map plot, using the clickDataproperty to display information and highlight the selected counter, works perfectly on my local machine. However, the functionality breaks or doesn’t execute when deployed to Plotly Cloud. Here two images:

My local Machine


Plotly Cloud

Mysteries of Science :rofl: :rofl: :person_shrugging:

ok Mike in your code you use scatter_map, while in update_layout use mapbox_style, you have to use map_style or update your map to scatter_mapbox, I do not know if this solve your issue.

Hi all,
I almost did this similar dashboard, these maps are so good. :grinning_face:
I’ve found that we have to differentiate between map and mapbox, sometimes I mix them up, and with mapbox only some free map-styles works without APIs. Do I understand correctly?

Hi Ester, you are correct, I always use the scatter_map , so far I have not use API.

For Week 39, my project is a dashboard called the “Montreal Bike Traffic Analyzer.” It’s designed to help users analyze, visualize, and compare bicycle traffic patterns across different counters in Montreal.

How It Works:

Step 1: Set the Date Range

First, navigate to the calendar on the left sidebar. You can select a specific date range you’re interested in, or leave it as the default. Currently, the dataset covers the first eight months of 2024. Selecting a date range immediately filters the data and adjusts all subsequent charts.

Step 2: Select Your Counters

Next, click directly on the map to select up to three specific bike counters. Once selected, they immediately appear on the sidebar with their names and assigned colors.

Step 3: Review Key Performance Indicators (KPIs)

Right above the main charts, summary cards (KPIs) pop up. These allow you to quickly check:

  • The total number of bike passages for each selected counter.
  • The Peak Hour for each counter (e.g., morning vs. evening traffic), which is helpful for understanding usage.

Step 4: Analyze Traffic Routines

Switch between the different data views to understand the usage profile of each selected counter:

  • “Daily Evolution (Time Series)”: This view displays a timeline, showing whether the counter’s traffic was constant or if it spiked sharply, perhaps only on weekends.
  • “Hourly/Daily Pattern”: The bubble chart in this view reveals the counter’s routine. For instance, if you see large bubbles exclusively between 7 to 9 AM and 4 to 6 PM on weekdays, you’ve likely identified a “commuter” counter (“commuter” counter is one where the bike traffic is clearly concentrated during those predictable peak times (e.g., 7-9 AM and 4-6 PM on weekdays), showing that the users are primarily people biking to and from their jobs.)

Step 5: Finding Traffic Pattern Twins (Deep Investigation)

This feature allows for the deepest investigation into traffic similarity:

  1. Navigate to the option “Traffic Pattern Twins (Similarity).”
  2. In the dropdown list, choose one of your three selected counters to use as the model. This will be your Reference counter.
  3. The application then compares the routine of the Reference counter with all other counters in the city.
  4. The result is a list of cards showing the five counters with the most similar hourly routine.
    • The card indicates the similarity percentage (how identical their routines are).
    • It also provides the distance in kilometers between them.
  5. Finally, check the line chart that shows the hourly patterns overlapping. If the lines for your Reference and a “Twin” are nearly identical, you’ve found a traffic point with the exact same function or usage profile, even if they are geographically far apart.

This is what the dashboard/web app primarily does. I’m currently working to resolve an issue that is preventing it from working properly on Plotly Cloud , and I will share the app link once the issue is solved.

Some key technical points of interest here:

For the Twin Patterns Section, I initially used SciPy for the math, but I had to switch to NumPy because Plotly Cloud doesn’t seem to fully support SciPy, or at least it wasn’t functioning correctly during deployment.

Some others images to complement the above share



Any question/suggestion/doubt more than welcome

Hi Alex,

Thanks for the tip, I gave it a try but didn’t seem to work. I updated Dash and Plotly to the latest versions tried again, same result. I suspect this is something to do with my local configurations. Hopefully I have time to test on Py.cafe tomorrow.

Ok Mike I do not know, if you did it but try as @Ester mentioned some mapbox_style needs API, try the free ones.

Hopefully you can solve it

Hi Alex,

Yes, I think you are correct, it may be best to structure geolocations though an API. It probably can be done without it but most likely would require more work. The previous software I used had built-in features for mapping and geolocation, but to get it to work required a higher level of skills than I had and also API keys. Far from impossible, but currently I have have to focus on improving my skills in broader topics, so will put this to the side for now.

I like your “Traffic Pattern by Day and Hour’ chart, instantly readable/understandable, good job!

@mike_finko I’ll try it with the free API, I’ve tried a few times. I’ll show you if it works.

For week 39:

UPDATE: Here a link to this app on plotly cloud: Dash

Here is a dashboard to show bicycle traffic in Montreal:

1. Select map type using the pulldown menu. All scatter_map (map_libre) options are supported.

2. Look at the hover display to see the volume of bicycles, location name and nearby places.

3. A favorite restaurant of mine is Ma Poule Mouillée, with very tasty Portuguese rotisserie chicken, poutine and beer. Can you find it, or can you find Jean-Talon market, or Plotly HQ.

I ran out of time to do more; hope you enjoy this.

Here is a screenshot with hover on McGill University:

Here is the code:

import polars as pl
import polars.selectors as cs
import os
import plotly.express as px
import plotly.graph_objects as go
import dash
from dash import Dash, dcc, html, Input, Output
import dash_mantine_components as dmc
import dash_ag_grid as dag
dash._dash_renderer._set_react_version('18.2.0')
#  Dataset has 10 unique customers & locations, 92 unique customer/locatio pairs
#  dropped the HOUR and MINUTE fields, data grouped by ID, DATE, LONG/LAT
#----- LOAD AND CLEAN THE DATASET

#----- GLOBALS -----------------------------------------------------------------
style_horizontal_thick_line = {'border': 'none', 'height': '4px', 
    'background': 'linear-gradient(to right, #007bff, #ff7b00)', 
    'margin': '10px,', 'fontsize': 32}

style_horizontal_thin_line = {'border': 'none', 'height': '2px', 
    'background': 'linear-gradient(to right, #007bff, #ff7b00)', 
    'margin': '10px,', 'fontsize': 12}

style_h2 = {'text-align': 'center', 'font-size': '40px', 
            'fontFamily': 'Arial','font-weight': 'bold', 'color': 'gray'}

map_types = [
    'basic', 'carto-darkmatter', 'carto-darkmatter-nolabels', 'carto-positron', 
    'carto-positron-nolabels', 'carto-voyager', 'carto-voyager-nolabels', 
    'dark', 'light', 'open-street-map', 'outdoors', 'satellite', 
    'satellite-streets', 'streets', 'white-bg']


#----- DASH COMPONENTS------ ---------------------------------------------------
dmc_select_map_style = (
    dmc.Select(
        label='Select map style',
        id='map-style',
        data=map_types,
        value=map_types[0],
        searchable=True,  # Enables search functionality
        clearable=True,    # Allows clearing the selection
        size='sm',
    ),
)


if 'df.parquet' in os.listdir('.'):
    print('reading dataset from parquet file')
    df = pl.read_parquet('df.parquet')
else:
    print('reading location info from excel file')
    df_locations = (
        pl.read_excel('df_locations.xlsx')
    )
    print('reading dataset from csv file')
    df  = (
        pl.scan_csv('pistes-cyclables-2024.csv')
        .select(
            ID = pl.col('id_compteur').cast(pl.UInt32),
            DATE = pl.col('date').str.to_date(format='%m/%d/%Y'),
            LON = pl.col('longitude').mean().over('id_compteur'),  # east-west location,   X
            LAT = pl.col('latitude').mean().over('id_compteur'),   # north-south location, Y
            PASSAGES = pl.col('nb_passages'),
        )
        .filter(pl.col('ID').is_not_null())
        .group_by(['ID', 'DATE','LON', 'LAT']).agg(pl.col('PASSAGES').sum())
        .with_columns(PASSAGES_BY_ID = pl.col('PASSAGES').sum().over('ID'))
        .with_columns(pl.col('PASSAGES').cast(pl.UInt16)) 
        .with_columns(pl.col('PASSAGES_BY_ID').cast(pl.UInt32)) 
        .sort(['ID', 'DATE'])
        .collect()
        .join(
            df_locations.select('ID', 'LOC', 'NEARBY'),
            on='ID',
            how='left'
        )
    )
    df.write_parquet('df.parquet')

def get_scatter_map(map_style):
    # Create the scatter map
    # replaced midpoints of lat, long with median values to suppress outliers
    median_lat = df['LAT'].median()
    median_lon = df['LON'].median()
    fig = px.scatter_map(
        df.unique('ID'),
        lat='LAT', lon='LON',
        size='PASSAGES_BY_ID',
        color='PASSAGES_BY_ID', 
        zoom=11,
        center={'lat':median_lat, 'lon':median_lon},  
        map_style=map_style,
        opacity=0.75,
        custom_data=['LOC', 'NEARBY', 'PASSAGES_BY_ID', 'ID'],
        height=800, width=1200
    )
    fig.update_traces(
        hovertemplate =
            '%{customdata[0]}<br>' +
            'Nearby: %{customdata[1]}<br>' +
            'Passages: %{customdata[2]:,d}<br>' +
            'ID: %{customdata[3]}<br>' +
            '<extra></extra>'
    )
    fig.update_layout(
        title=dict(text='Bicycle traffic by location')
    )
    fig.update(layout_coloraxis_showscale=False)
    return fig

# #----- DASH APPLICATION STRUCTURE---------------------------------------------
app = Dash()
server = app.server
app.layout =  dmc.MantineProvider([
    html.Hr(style=style_horizontal_thick_line),
    dmc.Text('Montreal Bicycle Traffic', ta='center', style=style_h2),
    html.Hr(style=style_horizontal_thick_line),
    dmc.Grid(children = [
        dmc.GridCol(dmc_select_map_style, span=2, offset = 1),
    ]),  
    dmc.Grid(children = [
            dmc.GridCol(dcc.Graph(id='scatter-map'), span=10, offset=1),          
        ]),
])
@app.callback(
    Output('scatter-map', 'figure'),
    Input('map-style', 'value'),
)
def callback(map_style):
    scatter_map=get_scatter_map(map_style)
    return scatter_map

if __name__ == '__main__':
    app.run(debug=True)

This is my version. I made it with Mapbox free API.

import dash
import dash_bootstrap_components as dbc
from dash import dcc, html, Input, Output
import plotly.express as px
import pandas as pd
import numpy as np

# --- MAPBOX TOKEN ---
MAPBOX_TOKEN = os.getenv("MAPBOX_TOKEN")
if not MAPBOX_TOKEN or MAPBOX_TOKEN.startswith("{"):
    MAPBOX_TOKEN = "Your API"
px.set_mapbox_access_token(MAPBOX_TOKEN)

# --- DATA LOAD AND PREP ---
df = pd.read_csv("bike.csv")
df.columns = df.columns.str.strip()
df["Datetime"] = pd.to_datetime(
    df["Date"] + " " + df["Time"], dayfirst=True, errors="coerce"
)
# Montreal bbox
MONTREAL_LAT_MIN = 45.4
MONTREAL_LAT_MAX = 45.7
MONTREAL_LON_MIN = -73.7
MONTREAL_LON_MAX = -73.4

df = df[
    (df["latitude"] >= MONTREAL_LAT_MIN)
    & (df["latitude"] <= MONTREAL_LAT_MAX)
    & (df["longitude"] >= MONTREAL_LON_MIN)
    & (df["longitude"] <= MONTREAL_LON_MAX)
].copy()

# Counter dropdown options (no NaN/NaT)
counter_ids = [
    counter for counter in sorted(df['Counter ID'].unique())
    if not pd.isna(counter)
]
counter_options = [
    {
        "label": str(int(counter)) if isinstance(counter, float) and counter.is_integer() else str(counter),
        "value": int(counter) if isinstance(counter, float) and counter.is_integer() else counter,
    }
    for counter in counter_ids
]

# --- ITT egészítsd ki a Standard stílussal! ---
map_styles = {
    "Mapbox Streets": "mapbox://styles/mapbox/streets-v12",
    "Mapbox Light": "mapbox://styles/mapbox/light-v11",
    "Mapbox Dark": "mapbox://styles/mapbox/dark-v11",
    "Mapbox Satellite": "mapbox://styles/mapbox/satellite-v9",
    "Mapbox Satellite Streets": "mapbox://styles/mapbox/satellite-streets-v12",
    "Mapbox Outdoors": "mapbox://styles/mapbox/outdoors-v12",
    "Mapbox Navigation Day": "mapbox://styles/mapbox/navigation-day-v1",
    "Mapbox Navigation Night": "mapbox://styles/mapbox/navigation-night-v1"
}
map_style_options = [
    {"label": name, "value": style} for name, style in map_styles.items()
]

unique_dates = sorted(df["Datetime"].dropna().dt.date.unique()) if not df.empty else []
min_date = min(unique_dates) if unique_dates else None
max_date = max(unique_dates) if unique_dates else None

# --- DASH APP LAYOUT ---

app = dash.Dash(__name__, external_stylesheets=[dbc.themes.BOOTSTRAP])

app.layout = dbc.Container(
    [
        html.H3("Montreal Bicycle Counter Map", className="text-center my-3"),
        dbc.Row(
            [
                dbc.Col(
                    dcc.DatePickerSingle(
                        id="date_picker",
                        min_date_allowed=min_date,
                        max_date_allowed=max_date,
                        date=min_date,
                        display_format="YYYY-MM-DD"
                    ),
                    width="auto"
                ),
                dbc.Col(
                    dcc.Dropdown(
                        id="counter_dropdown",
                        options=counter_options,
                        value=[],
                        placeholder="Select Counter(s)",
                        multi=True,
                        style={"minWidth": "220px"},
                    ),
                    width="auto"
                ),
                dbc.Col(
                    dcc.Dropdown(
                        id="map_style_dropdown",
                        options=map_style_options,
                        value="mapbox://styles/mapbox/standard",  # <--- Legyen ez az alap!
                        clearable=False,
                        style={"minWidth": "220px"},
                    ),
                    width="auto"
                ),
            ],
            justify="center",
            class_name="mb-3",
        ),
        dbc.Row(
            [
                dbc.Col(
                    dcc.Graph(
                        id="map_graph",
                        config={"scrollZoom": True},
                        style={"height": "700px"},
                    ),
                    width=12,
                )
            ]
        )
    ],
    fluid=True,
)

@app.callback(
    Output("map_graph", "figure"),
    [
        Input("date_picker", "date"),
        Input("counter_dropdown", "value"),
        Input("map_style_dropdown", "value"),
    ]
)
def update_map(selected_date, selected_counters, selected_map_style):
    if not selected_date:
        filtered_df = df.copy()
    else:
        filtered_df = df[df["Datetime"].dt.date == pd.to_datetime(selected_date).date()].copy()
    if selected_counters:
        filtered_df = filtered_df[filtered_df["Counter ID"].isin(selected_counters)]

    if filtered_df.empty:
        fig = px.scatter_mapbox(
            lat=[],
            lon=[],
            zoom=11,
            height=800,
            center={"lat": 45.53, "lon": -73.57},
            mapbox_style=selected_map_style,
        )
    else:
        fig = px.scatter_mapbox(
            filtered_df,
            lat="latitude",
            lon="longitude",
            size="Number of Passages",
            color="Number of Passages",
            color_continuous_scale="Sunsetdark",
            size_max=30,
            hover_data=["Counter ID", "Number of Passages"],
            zoom=11,
            height=800,
            center={"lat": 45.53, "lon": -73.57},
            mapbox_style=selected_map_style,
        )
    fig.update_layout(
        margin={"r": 0, "t": 30, "l": 0, "b": 0},
        coloraxis_colorbar=dict(title="Passages"),
        uirevision="stay-zoomed",
    )
    return fig

if __name__ == "__main__":
    app.run(debug=True)

Hi @Mike_Purtell

Looking at your code, I see you’re using .parquet files. I tried it as well and noticed the following two points:

  1. The file size reduction wasn’t significant, only going from 50.8 to 47.5.
  2. The Map only shows two counters; the others are missing.

The only difference is that I used Pandas and you used Polars. Perhaps Polars’ speed optimization features are a better match for the .parquet format.

@Mike_Purtell I found the restaurant with OSM Overpass API and search. This is good practice. :slight_smile:

HI @Avacsiglo21,

Here is pandas code where the 50.4M csv is read into a pandas dataframe and saved as a parquet file without removal of any columns or recasting any of the column types. The parquet file size is under 1M, less than 2% of the source file size.

If you are adding columns or other data that would increase the size of the dataframe, it may be best to do those things after reading in the data set from parquet file, or csv. Anything added by coding steps doesn’t have to be saved on disk.

Parquet files save the datatypes for each column, while csv has no such thing. So if you cast numeric columns to fewer bytes (Int64 to Int16, Float64 to Float32, etc.) the saved parquet file will be even smaller. Categorical data types can also save file space when there are string columns with low number of unique values.

Parquet files have a huge advantage in the time it takes to read them compared with csv. One reason is because parquet files save the data type of each column with the data. The read parquet step does not have to figure out a type for each column by looking at its data, as it does when reading csv..

Good question Alex, thank you, I hope this helps, and wondering why your parquet file was only about 10% smaller than the csv source.

import pandas as pd
df = pd.read_csv('pistes-cyclables-2024.csv')
df.to_parquet('pistes-cyclables-2024.parquet')

Hi @Mike_Purtell ,

May be I did something wrong, I did it again and I got the exact same results like you showed.

df = pd.read_csv(“pistes-cyclables-2024.csv”, low_memory=False, parse_dates=[‘date’], skiprows=1)
df.to_parquet(‘pistes-cyclables-2024.parquet’)

The 2nd question I´ll test later kkkk.

Thanks Mike,

nice map, @Ester .Do you usually prefer working with Mapbox?