Figure Friday 2025-w15

Een app die het op basis van marketingcijfers mogelijk maakt om de “range” van verschillende elektrische auto’s met elkaar te vergelijken. Je kunt kiezen voor een verzameling Nederlandse of wereldwijde steden als uitvalsbasis.


Onderwerp van deze Figure Friday zijn de ranges voor verschillende merken/typen elektrische auto’s. De originele dataset sprak niet zo tot mijn verbeelding. Ik heb de originele dataset gebruikt om merken/typen auto’s te extraheren dit vervolgens gekoppeld aan data die ik van het web heb gehaald m.b.t. grote steden in Nederland & de wereld, en het bereik van de auto zoals door de marketeers opgegeven.

Zoals al een gewoonte is geworden, heb ik voor de webscraping, vinden van goede resources, en deze keer ook voor een deel van de app zelf AI gebruikt, volgens de methode “kijken of het logisch is, geen condities vergeten zijn en goed testen”.

De achterliggende vraag: stel dat ik een tweedehands elektrische auto zou willen kopen uit de lease, welke auto’s komen dan qua bereik in aanmerking.

Demo

Py.cafe : demo en code (deze demo werkt helaas niet meer vanwege een CORS error, zie voor een werkend voorbeeld versie 2).

Community link: link

Voorbeeld bevat

  • Diverse dropboxen die afhankelijk van de keuze’s laten zien wat het bereik van de auto zou kunnen zijn.
  • Visualisatie is een kaart waarop de gekozen plaats het middelpunt is en het bereik als cirkel getoond wordt.
  • Het is mogelijk om meerdere auto’s tegelijk te selecteren, dan zie je het verschil.

Versie 2

Versie 2 omzeilt het CORS probleem. Je hebt hiervoor wel een access token nodig van mapbox.com. De video hieronder laat in het kort zien hoe de app werkt (zowel versie 1 als 2).


Code versie 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 een initiatief van de Dash/Plotly community waarbij je elke vrijdag een dataset krijgt en mensen een visual of kleine app maken, waarbij ze inzichten uit de dataset proberen te krijgen. De vrijdag daarop om 18:00, is er een zoomsessie waarbij sommigen uitleggen waarom ze gemaakt hebben wat ze tonen. In de thread op de communitysite wordt ook de code gedeeld, om van elkaar te leren en als het kan een demo.