Figure Friday 2025-w15

The topic of this Figure Friday is the range of different brands/types of electric cars.
The original dataset didn’t really spark my imagination. I used it to extract the car brands/types and then linked that to data I gathered from the web regarding major cities in the Netherlands & worldwide, as well as the range of the cars as stated by marketers.

The underlying question: Suppose I wanted to buy a second-hand electric car coming off a lease — which cars would be eligible in terms of range?

Demo

Py.cafe : demo en code (this demo does not work anymore due to CORS errors, see version 2 below)

Community link: link

Example includes:

  • Various dropdowns that, depending on the selections, show what the car’s range could be.
  • A visualization in the form of a map, where the selected location is the center and the range is shown as a circle.
  • It’s possible to select multiple cars at once — this allows you to see the difference between them.

Version 2

Version two uses an open map key. The app works in the same way as the first version but you need a mapbox.com access token to make it work. I created a short video to demo.

Code for version 2
import dash
from dash import html, dcc, Input, Output, State, callback_context
import pandas as pd
import geopandas as gpd
from shapely.geometry import Point
import plotly.express as px
import numpy as np
import plotly.graph_objects as go
from dash.exceptions import PreventUpdate
import math
import re
import dash_bootstrap_components as dbc
from dash_bootstrap_templates import load_figure_template

# loads the "sketchy" template and sets it as the default
load_figure_template("sketchy")

# IMPORTANT: Add your Mapbox access token here
mapbox_access_token = "YOUR_MAPBOX_ACCESS_TOKEN_HERE"  # Replace with your token

# Load city data
df_cities = pd.read_csv("nl_cities_extended.csv").sort_values(by='city')

# Load datasets
df_nl = pd.read_csv("nl_cities_extended.csv")
df_world = pd.read_csv("worldcities_cleaned.csv")  # This must contain 'city', 'lat', 'lon' columns

# Load car data
df_cars = pd.read_csv('electric_car_data_with_scraped_ranges-v1.csv')
df_cars = df_cars.dropna(subset=["Scraped_Range"])
df_cars['Make - Model'] = df_cars['Make'] + ' - ' + df_cars['Model']

# Helper function to convert hex color to rgba
def hex_to_rgba(hex_color, alpha=0.3):
    """Convert hex color to rgba with specified alpha."""
    # Remove the # symbol if present
    hex_color = hex_color.lstrip('#')
    
    # Convert hex to RGB
    r = int(hex_color[0:2], 16)
    g = int(hex_color[2:4], 16)
    b = int(hex_color[4:6], 16)
    
    return f'rgba({r},{g},{b},{alpha})'

app = dash.Dash(__name__, suppress_callback_exceptions=True, external_stylesheets=[dbc.themes.CYBORG])
app.title = "EV Range Map"

# Default car selection
default_car = "TESLA - MODEL 3"

app.layout = dbc.Container([
    html.Div([
        html.H2("Electric Vehicle Range", style={"marginTop":"2rem"}),
        html.P("Easily compare ranges of new electric vehicles as advertised by manufacturers."),
        html.Hr(),
        html.Div([
            html.Div([
                html.H5("Choose between dutch or worldwide cities"),
                dcc.RadioItems(
                    id="dataset-selector",
                    options=[
                        {"label": "Netherlands", "value": "nl"},
                        {"label": "World", "value": "world"}
                    ],
                    value="nl",  # Default to Netherlands
                    labelStyle={"display": "inline-block", "margin-right": "15px"},
                    style={"margin-bottom": "10px"}
                )
            ], style={"width": "48%", "display": "inline-block"}),
            
            html.Div([
                html.H5("Select your vehicle:"),
                dcc.Dropdown(
                    id="car-dropdown",
                    options=[{"label": make_model, "value": make_model} for make_model in sorted(df_cars['Make - Model'].unique())],
                    value=[default_car],  # Default to Tesla Model 3
                    placeholder="Select car models (multi-select)",
                    multi=True,
                    style={"width": "100%"}
                )
            ], style={"width": "48%", "display": "inline-block", "float": "right"})
        ]),
        
        html.Div([
            html.Div([
                html.H5("Select a city:"),
                dcc.Dropdown(
                    id="city-dropdown",
                    placeholder="Select a city",
                    value="Leeuwarden",  # Default to Leeuwarden
                    style={"width": "100%"}
                ),
            ], style={"width": "48%", "display": "inline-block"}),
            
            html.Div([
                html.H4("Manual Radius (Optional)"),
                dcc.Input(
                    id="radius-input",
                    type="number",
                    placeholder="Radius in km (optional)",
                    style={"width": "50%", "margin-right": "10px"}
                ),
                html.Button("Show Manual Radius", id="manual-radius-button", n_clicks=0),
            ], style={"width": "48%", "display": "none", "float": "right"})
        ]),
        
        html.Div([
            dcc.Graph(id="map", style={"height": "70vh"})
        ], style={"margin-top": "20px"}),
        
        html.Div(id="selected-vehicles-info", style={"margin-top": "20px"})
    ])
])

@app.callback(
    Output("city-dropdown", "options"),
    Input("dataset-selector", "value")
)
def update_city_options(dataset):
    df = df_world if dataset == "world" else df_nl

    return [
        {"label": str(row["city"]), "value": str(row["city"])}
        for _, row in df.iterrows()
        if pd.notnull(row["city"])  # filter out missing city names
    ]

# Helper function to calculate the optimal zoom level
def calculate_zoom_level(radius_km):
    # Constants for zoom level calculation
    EARTH_CIRCUMFERENCE = 40075  # Earth's circumference at the equator in km
    
    # Calculate the visible distance at zoom level 0 (roughly half the earth's circumference)
    visible_distance_at_zoom_0 = EARTH_CIRCUMFERENCE / 2
    
    # We want to show a circle with diameter of 2*radius_km
    safety_factor = 1.5  # Make sure we can see the entire circle with some margin
    required_visible_distance = 2 * radius_km * safety_factor
    
    # Calculate the zoom level
    zoom_level = math.log2(visible_distance_at_zoom_0 / required_visible_distance)
    
    # Limit zoom level to reasonable values
    return min(max(zoom_level, 0), 20)

@app.callback(
    [Output("map", "figure"),
     Output("selected-vehicles-info", "children")],
    [Input("city-dropdown", "value"),
     Input("car-dropdown", "value"),
     Input("manual-radius-button", "n_clicks")],
    [State("radius-input", "value"),
     State("dataset-selector", "value")]
)
def update_map(selected_city, selected_cars, manual_radius_clicks, manual_radius_km, dataset):
    ctx = callback_context
    trigger_id = ctx.triggered[0]['prop_id'].split('.')[0] if ctx.triggered else None
    
    if not selected_city:
        return {}, html.Div("Please select a city to view the map")
    
    df = df_world if dataset == "world" else df_nl
    city_rows = df[df["city"] == selected_city]
    
    if city_rows.empty:
        return {}, html.Div(f"City {selected_city} not found in the dataset")
    
    row = city_rows.iloc[0]
    lat, lon = row["lat"], row["lon"]
    
    # Initialize figure
    fig = go.Figure()
    
    # Add city point
    fig.add_trace(
        go.Scattermapbox(
            lat=[lat],
            lon=[lon],
            mode='markers+text',
            marker=dict(size=10, color="red"),
            text=[selected_city],
            textposition="top right",
            name="City"
        )
    )
    
    # Prepare vehicle info table
    car_info_rows = []
    max_radius = 0  # Track the maximum radius for zoom level
    
    # Define a set of colors for the circles
    colors = px.colors.qualitative.Plotly
    
    # Add vehicle ranges if cars are selected
    if selected_cars:
        for idx, make_model in enumerate(selected_cars):
            car_row = df_cars[df_cars['Make - Model'] == make_model]
            if not car_row.empty:
                # First try to use scraped range, if not available use electric range
                range_km = car_row['Scraped_Range'].values[0]
                if pd.isna(range_km) or range_km == 0:
                    range_km = car_row['Electric Range'].values[0]
                    source = "Official Range"
                else:
                    source = "Scraped Range"
                
                if pd.notna(range_km) and range_km > 0:
                    # Create Point and buffer for this car's range
                    gdf = gpd.GeoSeries([Point(lon, lat)], crs="EPSG:4326").to_crs(epsg=28992)
                    circle = gdf.buffer(range_km * 1000).to_crs(epsg=4326)
                    
                    # GeoJSON from buffer
                    geojson = circle.__geo_interface__
                    
                    # Get color for this car (cycling through the color palette)
                    hex_color = colors[idx % len(colors)]
                    
                    # For Plotly hex colors, convert to proper format for rgba
                    if hex_color.startswith('#'):
                        fill_color = hex_to_rgba(hex_color, 0.3)
                    else:  # Handle RGB format like 'rgb(99,110,250)'
                        rgb_match = re.match(r'rgb\((\d+),(\d+),(\d+)\)', hex_color)
                        if rgb_match:
                            r, g, b = map(int, rgb_match.groups())
                            fill_color = f'rgba({r},{g},{b},0.3)'
                        else:
                            # Fallback color if parsing fails
                            fill_color = 'rgba(100,100,255,0.3)'
                    
                    # Add polygon to map
                    fig.add_trace(
                        go.Choroplethmapbox(
                            geojson=geojson,
                            locations=[0],
                            z=[1],
                            colorscale=[[0, fill_color], [1, fill_color]],
                            marker_opacity=0.5,
                            marker_line_width=1,
                            marker_line_color=hex_color,
                            name=f"{make_model} ({range_km:.0f} km)",
                            showscale=False,
                            hoverinfo="name"
                        )
                    )
                    
                    # Update max radius if this is larger
                    max_radius = max(max_radius, range_km)
                    
                    # Add to info table
                    car_info_rows.append(
                        html.Tr([
                            html.Td(make_model),
                            html.Td(f"{range_km:.0f} km"),
                            #html.Td(source)
                        ])
                    )
                else:
                    car_info_rows.append(
                        html.Tr([
                            html.Td(make_model),
                            html.Td("No range data available", colSpan=2, style={"color": "red"})
                        ])
                    )
    
    # Add manual radius if requested
    if trigger_id == "manual-radius-button" and manual_radius_km:
        # Create Point and buffer
        gdf = gpd.GeoSeries([Point(lon, lat)], crs="EPSG:4326").to_crs(epsg=28992)
        circle = gdf.buffer(manual_radius_km * 1000).to_crs(epsg=4326)

        # GeoJSON from buffer
        geojson = circle.__geo_interface__
        
        # Add to map
        fig.add_trace(
            go.Choroplethmapbox(
                geojson=geojson,
                locations=[0],
                z=[1],
                colorscale=[[0, 'rgba(255,0,0,0.3)'], [1, 'rgba(255,0,0,0.3)']],
                marker_opacity=0.4,
                marker_line_width=1,
                marker_line_color='red',
                name=f"Manual Radius: {manual_radius_km} km",
                showscale=False,
                hoverinfo="name"
            )
        )
        
        # Update max radius if manual radius is larger
        max_radius = max(max_radius, manual_radius_km)
        
        # Add to info table
        car_info_rows.append(
            html.Tr([
                html.Td("Manual Radius", style={"fontWeight": "bold"}),
                html.Td(f"{manual_radius_km} km", style={"fontWeight": "bold"}),
                html.Td("User Defined", style={"fontWeight": "bold"})
            ])
        )
    
    # Calculate zoom level to show the entire circle(s)
    if max_radius > 0:
        zoom = calculate_zoom_level(max_radius)
    else:
        # Default zoom if no circles are shown
        zoom = 10
    
    # Update the map layout to use Mapbox with token
    fig.update_layout(
        mapbox=dict(
            accesstoken=mapbox_access_token,
            style='light',  # You can choose: light, dark, streets, outdoors, satellite, satellite-streets
            center=dict(lat=lat, lon=lon),
            zoom=zoom
        ),
        margin={"r": 0, "t": 0, "l": 0, "b": 0},
        legend=dict(
            orientation="h",
            yanchor="bottom",
            y=1.02,
            xanchor="right",
            x=1
        )
    )
    
    # Create the info table
    if car_info_rows:
        vehicle_info = html.Div([
            html.H4("Selected Vehicles and Ranges"),
            html.Table([
                html.Thead(
                    html.Tr([
                        html.Th("Make - Model", style={"padding": "8px", "borderBottom": "1px solid #ddd"}),
                        html.Th("Marketed range", style={"padding": "8px", "borderBottom": "1px solid #ddd"}),
                       # html.Th("Source", style={"padding": "8px", "borderBottom": "1px solid #ddd"})
                    ])
                ),
                html.Tbody(car_info_rows)
            ], style={"width": "100%", "border": "1px solid #ddd", "borderCollapse": "collapse", 
                    "textAlign": "left"})
        ])
    else:
        vehicle_info = html.Div("Select vehicles from the dropdown to visualize their ranges")
    
    return fig, vehicle_info

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

Figure Friday is an initiative from the Dash/Plotly community. Every friday a new dataset is made available with some basic code to show a visual and an explanation of the subject. People are invited to adjust the code and improve the visual or create a small app. The next friday a zoom session takes place where some explain why they made what they made and others can give feedback, all in a constructive and respectful atmosphere. In the thread on the community site people share their visual, code and a demo if possible.