Py.Cafe

marie-anne/

2025-figurefriday-w15

Draw range circles on a map based on the selected vehicle and marketed range.

DocsPricing
  • app.py
  • electric_car_data_with_scraped_ranges-v1.csv
  • nl_cities_extended.csv
  • requirements.txt
  • worldcities_cleaned.csv
app.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
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)