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")
# 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
row = df[df["city"] == selected_city].iloc[0]
lat, lon = row["lat"], row["lon"]
# Initialize figure with city marker
fig = go.Figure()
# Add city point - changed from scattermapbox to scattermap
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
# Keeping the original configuration but just using open-source tiles
fig.update_layout(
mapbox_style="open-street-map", # Use open-street-map instead of carto-positron
mapbox=dict(
center={"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)