Spaces:
Sleeping
Sleeping
File size: 20,130 Bytes
66d8ac0 9a00844 66d8ac0 9a00844 66d8ac0 9a00844 66d8ac0 9a00844 66d8ac0 9a00844 66d8ac0 9a00844 66d8ac0 9a00844 66d8ac0 9a00844 66d8ac0 9a00844 66d8ac0 9a00844 66d8ac0 9a00844 66d8ac0 |
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 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 |
'''
Module for creating a Bokeh Layout object to be embedded in HTML templates using Flask.
This module:
- Loads data from the specified CSV files
- Prepares and processes the data
- Creates Bokeh Figure and DataTable objects
- Builds a Bokeh Layout combining these elements
Dependencies:
- CSV files located in the "data" folder
'''
import ast
from bokeh.layouts import column, row
from bokeh.models import CDSView, ColumnDataSource, CustomJS, DataTable, DatetimeTickFormatter
from bokeh.models import HTMLTemplateFormatter, HoverTool, IndexFilter, TableColumn, TapTool
from bokeh.plotting import figure
from bokeh.transform import linear_cmap
import math
import pandas as pd
import xyzservices.providers as xyz
PARKING_HISTORY_CSV_FILEPATH = "./data/parking_occupancy_history.csv"
GENERAL_INFO_CSV_FILEPATH = "./data/parking_general_information.csv"
PARKING_ID_HOMEPAGE = 'LPA0740'
DATA_TABLE_COLUMNS_FILTER = [
"parking",
"heure",
"nombre_de_places_disponibles",
"capacité_total",
"nombre de niveaux",
"hauteur limite (mètre)",
"téléphone",
"tarifs",
"adresse"
]
LATITUDE_LYON = 45.764043
LONGITUDE_LYON = 4.835659
CIRCLE_SIZE_BOUNDS = (10, 25)
ZOOM_LEVEL = 10000
def get_address(string_dict):
"""
Extract an address from a string representing a dictionary.
Parameters:
- string_dict (str): A string containing address information.
Returns:
- str: A formatted address string (street, postal code, locality).
"""
address_keys = ["schema:streetAddress", "schema:postalCode", "schema:addressLocality"]
string_dict = string_dict.strip('"').replace('"', "'")
string_dict = string_dict.replace("': ", '": "').replace(", '", '", "').replace("'\"", '"' ).replace("\"'", '"' ).replace("{'", '{"').replace("'}", '"}')
address_dict = ast.literal_eval(string_dict)
address = [str(address_dict.get(key)) for key in address_keys]
adress_string = " ".join(address)
return adress_string
def get_parking_capacity(capacity_str):
"""
Parse and retrieve the 'mv:maximumValue' from a string representing a list of dictionaries.
The input string contains data in a JSON-like format, and this function extracts the
'mv:maximumValue' from the last dictionary in the list.
Parameters:
- capacity_str (str): A string representation of a list of dictionaries.
Returns:
- int or None: The value of the 'mv:maximumValue' key, or None if the key is not present.
Raises:
- ValueError: If the input string cannot be evaluated as a valid list of dictionaries.
"""
str_clean = capacity_str.replace("'", '"')
str_clean = str_clean.replace(": ,", ': None,')
data_list = eval(str_clean)
last_dict = data_list[-1]
return last_dict.get("mv:maximumValue")
def clean_phone_number(phone_number):
"""
Format a phone number by ensuring it starts with '0' and adding spaces every 2 digits.
Parameters:
- phone_number (int or str): The input phone number.
Returns:
- str: A formatted phone number (e.g., "01 23 45 67 89").
"""
if not pd.isna(phone_number):
phone_number = "0" + str(int(phone_number))
phone_number_slices_list = [phone_number[i: i+2] for i in range(0, 10, 2)]
phone_number = " ".join(phone_number_slices_list)
return phone_number
def latlon_to_webmercator(lat, lon):
"""
Convert latitude and longitude to Web Mercator coordinates.
Parameters:
- lat (float): Latitude in degrees
- lon (float): Longitude in degrees
Returns:
- (float, float): Web Mercator x, y coordinates
"""
R = 6378137 # Radius of the Earth in meters (WGS 84 standard)
x = R * math.radians(lon) # Convert longitude to radians and scale
y = R * math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) # Transform latitude
return x, y
def normalize_number(nb, data_range, expected_range):
"""
Normalize a number to fit within a target range while preserving its relative position.
This function maps a given input value (`nb`) from an original data range (`data_range`)
to a new expected target range (`expected_range`). The input number is scaled such that
its relative position in `data_range` is maintained in `expected_range`.
Parameters:
- nb (float): The input number to be normalized.
- data_range (tuple of float): A tuple containing two floats representing the input's original range (min, max).
- data_range[0] (float): The lower bound of the input's original range.
- data_range[1] (float): The upper bound of the input's original range.
- expected_range (tuple of float): A tuple containing two floats representing the desired target range (min, max).
- expected_range[0] (float): The lower bound of the desired target range.
- expected_range[1] (float): The upper bound of the desired target range.
Returns:
- float: The normalized value scaled to fit within the `expected_range`.
"""
result = nb
if (data_range[1] - data_range[0]) != 0:
result = expected_range[0] + (nb - data_range[0]) / (data_range[1] - data_range[0]) * (expected_range[1] - expected_range[0])
return result
def prepare_general_info_dataframe(csv_filepath):
"""
Preprocess parking data from a CSV file.
Reads the file at `csv_filepath`, cleans and formats the data,
including address, phone number, capacity, coordinates (in lat/lon and Web Mercator),
and fills missing values. Renames columns for clarity.
Parameters:
- csv_filepath (str): Path to the CSV file with parking information.
Returns:
- pd.DataFrame: A cleaned DataFrame with standardized columns for further processing.
"""
df_general_info = pd.read_csv(csv_filepath, sep=";")
df_general_info['adresse'] = df_general_info['address'].apply(get_address)
df_general_info['capacité_total'] = df_general_info['capacity'].apply(get_parking_capacity)
df_general_info['téléphone'] = df_general_info['telephone'].apply(clean_phone_number)
df_general_info['lat'] = df_general_info['lat'].astype(str).str.replace(',', '.').astype(float)
df_general_info['lon'] = df_general_info['lon'].astype(str).str.replace(',', '.').astype(float)
df_general_info[["lon_mercator", "lat_mercator"]] = df_general_info.apply(
lambda row: pd.Series(latlon_to_webmercator(row["lat"], row["lon"])),
axis=1
)
df_general_info["resumetarifshoraires"] = df_general_info["resumetarifshoraires"].fillna(" ")
df_general_info.rename(
columns={
"name": "parking",
"url": "site_web",
"numberoflevels": "nombre de niveaux",
"vehicleheightlimitinm": "hauteur limite (mètre)",
"resumetarifshoraires": "tarifs",
},
inplace=True
)
return df_general_info
def prepare_global_dataframe(df_general_info, df_parking_history):
"""
Merges general parking information with parking_history data.
Combines data from `df_general_info` and `df_parking_history` into a single DataFrame,
enriching historical data with additional details like parking address, capacity,
and coordinates. Formats columns, renames for clarity, and sorts by date.
Parameters:
- df_general_info (pd.DataFrame): DataFrame containing general parking information.
- df_parking_history (pd.DataFrame): DataFrame containing historical parking data.
Returns:
- pd.DataFrame: A merged and formatted DataFrame for further analysis or visualization.
"""
df_global = pd.merge(
left=df_parking_history,
right=df_general_info[['identifier',
'parking',
'site_web',
'adresse',
'nombre de niveaux',
'hauteur limite (mètre)',
'téléphone',
'tarifs',
'lon_mercator',
'lat_mercator',
'capacité_total']],
how='left', left_on='parking_id',
right_on='identifier'
)
df_global['heure'] = df_global['date'].apply(lambda x: x.strftime('%d %B %Y %H:%M:%S'))
df_global.rename(
columns={
"nb_of_available_parking_spaces": "nombre_de_places_disponibles",
},
inplace=True
)
df_global.sort_values('date', inplace=True)
return df_global
def prepare_sources(df_global, initial_parking_id=PARKING_ID_HOMEPAGE, data_table_columns_filter=DATA_TABLE_COLUMNS_FILTER):
"""
Prepares data sources for visualizations.
Generates ColumnDataSource objects for the global data, line plot, map,
and a transposed table based on the most recent values and selected parking.
Parameters:
- df_global (pd.DataFrame): The merged global DataFrame with parking data.
- initial_parking_id (int): ID of the parking lot to initialize plots.
- data_table_columns_filter (list): List of columns to include in the table.
Returns:
- tuple: Sources for global data, line plot, map, and transposed table.
"""
df_more_recent_value = df_global.groupby('parking_id').agg({'date': 'max'})
df_map = df_global.merge(df_more_recent_value , on=['parking_id', 'date'])
df_line_plot = df_global[df_global['parking_id']==initial_parking_id]
df_table = df_map[df_map['parking_id']==initial_parking_id]
transposed_data = {
"Field": data_table_columns_filter,
"Value": [df_table.iloc[0][col] for col in data_table_columns_filter]
}
source_original = ColumnDataSource(df_global)
source_line_plot = ColumnDataSource(df_line_plot)
source_map = ColumnDataSource(df_map)
source_table = ColumnDataSource(transposed_data)
return source_original, source_line_plot, source_map, source_table
def add_circle_size_to_source_map(source_map, circle_size_bounds=CIRCLE_SIZE_BOUNDS):
"""
Adds normalized circle sizes to the source map based on available spaces.
Parameters:
- source_map (ColumnDataSource): Map data source with parking availability.
- circle_size_bounds (tuple): Min and max bounds for circle sizes.
Returns:
- None: Updates `source_map` in place with a `normalized_circle_size` field.
"""
available_spaces_range = (
min(source_map.data["nombre_de_places_disponibles"]),
max(source_map.data["nombre_de_places_disponibles"])
)
normalized_circle_sizes = [normalize_number(x, available_spaces_range, circle_size_bounds)
for x in source_map.data["nombre_de_places_disponibles"]]
source_map.data['normalized_circle_size'] = normalized_circle_sizes
def generate_map_plot(source_map, lyon_x, lyon_y, zoom_level=ZOOM_LEVEL):
"""
Creates an interactive map plot with parking data.
Parameters:
- source_map (ColumnDataSource): Data source for map visualization.
- lyon_x, lyon_y (float): Mercator coordinates for map center.
- zoom_level (float): Zoom level for the map.
Returns:
- Figure: Bokeh map plot with hover and selection tools.
"""
color_mapper = linear_cmap(field_name="nombre_de_places_disponibles",
palette="Viridis256",
low=min(source_map.data["nombre_de_places_disponibles"]),
high=max(source_map.data["nombre_de_places_disponibles"]))
hover_map = HoverTool(
tooltips = [
('nom', '@parking'),
('places disponibles', "@nombre_de_places_disponibles"),
('capacité', '@{capacité_total}'),
],
)
tap_tool = TapTool()
p_map = figure(
x_range=(lyon_x - zoom_level, lyon_x + zoom_level),
y_range=(lyon_y - zoom_level, lyon_y + zoom_level),
x_axis_type="mercator",
y_axis_type="mercator",
tools=[hover_map, 'pan', 'wheel_zoom'],
)
p_map.add_tools(tap_tool)
p_map.toolbar.active_tap = tap_tool
p_map.add_tile(xyz.OpenStreetMap.Mapnik)
circle_renderer = p_map.scatter(
x="lon_mercator",
y="lat_mercator",
source=source_map,
size="normalized_circle_size",
fill_color=color_mapper,
fill_alpha=1,
)
circle_renderer.nonselection_glyph = None
circle_renderer.selection_glyph = None
return p_map
def generate_line_plot(source_line_plot):
"""
Creates a line plot to show the history of available parking spaces.
Parameters:
- source_line_plot (ColumnDataSource): Data source for the line plot.
Returns:
- Figure: Bokeh line plot with hover and zoom tools.
"""
hover_line = HoverTool(
tooltips = [
('Places disponibles', "@nombre_de_places_disponibles"),
('Heure', '@date{%a-%H:%M:%S}'),
],
formatters={'@date': 'datetime'},
)
p_line = figure(
title=f"Historique des places disponibles - LINE PLOT",
height = 400,
width = 700,
x_axis_type="datetime",
x_axis_label="Date",
y_axis_label="Nombre de places disponibles",
tools=[hover_line, "crosshair", "pan", "wheel_zoom"],
align = ('center', 'center')
)
p_line.line(
"date",
"nombre_de_places_disponibles",
source=source_line_plot,
line_width=2,
legend_field = "parking",
)
p_line.legend.location = "top_left"
p_line.xaxis.formatter = DatetimeTickFormatter(days="%d/%m/%Y")
return p_line
def generate_data_table(source_table):
"""
Creates a data table to display parking information.
Parameters:
- source_table (ColumnDataSource): Data source for the table.
Returns:
- DataTable: A Bokeh data table displaying parking details.
"""
columns_tranposed = [
TableColumn(field="Field", title="Champ"),
TableColumn(field="Value", title="Valeur"),
]
data_table = DataTable(
source=source_table,
columns=columns_tranposed,
editable=True,
width=1000,
height=200,
index_position=None,
header_row=False,
fit_columns = True,
)
return data_table
def generate_data_table_url(source_line_plot):
"""
Creates a data table with clickable URLs for parking websites.
Parameters:
- source_line_plot (ColumnDataSource): Data source for the table.
Returns:
- DataTable: A Bokeh data table displaying parking website links.
"""
cds_view = CDSView()
cds_view.filter = IndexFilter([0])
column = TableColumn(
field="site_web",
title="site web",
formatter=HTMLTemplateFormatter(template='<a href="<%= site_web %>"><%= site_web %></a>')
)
data_url = DataTable(
source=source_line_plot,
columns=[column],
editable=True,
width=600,
height=50,
index_position=None,
view=cds_view
)
return data_url
def create_selection_callback(source_map, source_line_plot, source_table, source_original, p_line):
"""
Creates a CustomJS callback for updating data source based on user selection.
Parameters:
- source_map (ColumnDataSource): The source for the map data.
- source_line_plot (ColumnDataSource): The source for the line plot data.
- source_table (ColumnDataSource): The source for the data table.
- source_original (ColumnDataSource): The source for the original dataset.
- p_line (Figure): Bokeh step plot.
Returns:
- CustomJS: The JavaScript callback.
"""
callback = CustomJS(
args=dict(
s_map=source_map,
s_line=source_line_plot,
s_table=source_table,
s_original=source_original,
p_line=p_line),
code=
"""
var data_map = s_map.data
var data_original = s_original.data
var selected_index = cb_obj.indices[0]
if (selected_index !== undefined) {
var parking_id = data_map['identifier'][selected_index]
// Update s_line
var line_plot_data = {};
for (var key in data_original) {
line_plot_data[key] = [];
}
for (var i = 0; i < data_original['parking_id'].length; i++) {
if (data_original['parking_id'][i] === parking_id) {
for (var key in data_original) {
line_plot_data[key].push(data_original[key][i]);
}
}
}
s_line.data = line_plot_data
s_line.change.emit()
// Specify new axis range for the history plots
var x_min = Math.min(...line_plot_data['date'].map(d => new Date(d).getTime()));
var x_max = Math.max(...line_plot_data['date'].map(d => new Date(d).getTime()));
var y_min = Math.min(...line_plot_data['nombre_de_places_disponibles']);
var y_max = Math.max(...line_plot_data['nombre_de_places_disponibles']);
var x_padding = 0.1 * (x_max - x_min);
var y_padding = 0.1 * (y_max - y_min);
p_line.x_range.setv({ start: x_min - x_padding, end: x_max + x_padding });
p_line.y_range.setv({ start: y_min - y_padding, end: y_max + y_padding });
p_line.change.emit();
// Update s_table
var max_date_index = 0
var max_date = new Date(Math.max(...line_plot_data['date'].map(d => new Date(d))))
for (var i = 0; i < line_plot_data['date'].length; i++) {
if (new Date(line_plot_data['date'][i]).getTime() === max_date.getTime()) {
max_date_index = i;
break;
}
}
var filter_columns = ["parking", "heure", "capacité_total", "nombre_de_places_disponibles", "nombre de niveaux", "hauteur limite (mètre)", "téléphone", "tarifs", "adresse"];
var table_data = {
"Field": [],
"Value": []
};
for (var key of filter_columns) {
var value = line_plot_data[key][max_date_index];
table_data["Field"].push(key);
table_data["Value"].push(value);
}
s_table.data = table_data
}
"""
)
return callback
def main():
"""
Main function:
- Loads and processes data from CSV files.
- Prepares data sources for Bokeh visualizations.
- Creates interactive map, line plot, and data table components.
- Builds and returns a cohesive Bokeh layout.
"""
df_general_info = prepare_general_info_dataframe(GENERAL_INFO_CSV_FILEPATH)
df_parking_history = pd.read_csv(PARKING_HISTORY_CSV_FILEPATH, index_col='id', parse_dates=[4])
df_global = prepare_global_dataframe(df_general_info, df_parking_history)
source_original, source_line_plot, source_map, source_table = prepare_sources(df_global)
add_circle_size_to_source_map(source_map, circle_size_bounds=CIRCLE_SIZE_BOUNDS)
lyon_x, lyon_y = latlon_to_webmercator(LATITUDE_LYON, LONGITUDE_LYON)
p_map = generate_map_plot(source_map, lyon_x, lyon_y, zoom_level=ZOOM_LEVEL)
p_line = generate_line_plot(source_line_plot)
data_table = generate_data_table(source_table)
data_table_url = generate_data_table_url(source_line_plot)
callback = create_selection_callback(source_map, source_line_plot,
source_table,
source_original,
p_line)
source_map.selected.js_on_change('indices', callback)
first_row_layout = row([p_map, p_line])
bokeh_general_layout = column([first_row_layout, data_table, data_table_url])
return bokeh_general_layout
bokeh_general_layout = main()
|