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.