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()