File size: 5,485 Bytes
f97f945
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1685498
 
f97f945
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import json
import requests
import requests_cache as R
from cachetools import TTLCache, cached

from typing import Literal, Tuple, Optional

import ipyleaflet as L
from ipyleaflet import AwesomeIcon

from shiny.express import ui

from utils.config import ors_api_key, BRANDCOLORS, BASEMAPS, ONE_WEEK_SEC, LOCATIONS, TRIP_DISTANCE


# Cache expires after 1 week
# R.install_cache('/client/requests_cache/yassir_requests_cache',  expire_after=ONE_WEEK_SEC)  # Sqlite
R.install_cache(backend='memory',  expire_after=ONE_WEEK_SEC)  # Sqlite


# ---------------------------------------------------------------
# Helper functions for map and location inputs on predict page
# ---------------------------------------------------------------

@cached(cache=TTLCache(maxsize=300, ttl=ONE_WEEK_SEC))  # Memory
def get_bounds(country: str) -> Tuple[float]:
    headers = {
        'User-Agent': 'Yassir ETA Shiny App/1.0 ([email protected])'
    }

    response = requests.get(
        f"http://nominatim.openstreetmap.org/search?q={country}&format=json", headers=headers)

    boundingbox = json.loads(response.text)[0]["boundingbox"]

    # Extract the bounds as float datatype
    lat_min, lat_max, lon_min, lon_max = (float(b) for b in boundingbox)

    return lat_min, lat_max, lon_min, lon_max


@cached(cache=TTLCache(maxsize=3000, ttl=ONE_WEEK_SEC))  # Memory
def ops_trip_distance(origin: tuple, destination: tuple) -> float:
    """
    The road distance calculated using openrouteservice with the driving car is the shortest 
    or optimal road distance based on the available road data and routing algorithm.

    origin is a tuple of lat, lon
    destination is a tuple of lat, lon
    
    Returns: the calculiated trip distance or a default value
    """

    # OpenRouteService API URL
    # https://giscience.github.io/openrouteservice/api-reference/endpoints/directions/extra-info/
    url = 'https://api.openrouteservice.org/v2/directions/driving-car'

    # Request parameters
    params = {
        'api_key': ors_api_key,
        'start': f'{origin[1]},{origin[0]}',  # lon, lat
        'end': f'{destination[1]},{destination[0]}'  # lon, lat
    }

    # Send request
    # https://openrouteservice.org/dev/#/api-docs/v2/directions/{profile}/get
    response = requests.get(url, params=params)
    data = response.json()

    # Extract distance
    if response.status_code == 200 and data.get('features'):
        # Distance in meters
        distance = data['features'][0]['properties']['summary']['distance']
    else:
        distance = TRIP_DISTANCE # Default
        back_to_nairobi()

    return distance


def update_marker(map: L.Map, loc: tuple, on_move: object, name: str, icon: AwesomeIcon):
    remove_layer(map, name)
    m = L.Marker(location=loc, draggable=True, name=name, icon=icon)
    m.on_move(on_move)
    map.add_layer(m)


def update_line(map: L.Map, loc1: tuple, loc2: tuple):
    remove_layer(map, "line")
    map.add_layer(
        L.Polyline(locations=[loc1, loc2],
                   color=BRANDCOLORS['red'], weight=3, name="line")
    )


def update_basemap(map: L.Map, basemap: str):
    for layer in map.layers:
        if isinstance(layer, L.TileLayer):
            map.remove_layer(layer)
    map.add_layer(L.basemap_to_tiles(BASEMAPS[basemap]))


def remove_layer(map: L.Map, name: str):
    for layer in map.layers:
        if layer.name == name:
            map.remove_layer(layer)


def on_move1(**kwargs):
    return on_move("origin", **kwargs)


def on_move2(**kwargs):
    return on_move("destination", **kwargs)

# When the markers are moved, update the numeric location inputs to include the new
# location (which results in the locations() reactive value getting updated,
# which invalidates any downstream reactivity that depends on it)


def on_move(loc_type: Literal['origin', 'destination'], **kwargs):
    location = kwargs["location"]
    loc_lat, loc_lon = location

    ui.update_numeric(f"{loc_type}_lat", value=loc_lat)
    ui.update_numeric(f"{loc_type}_lon", value=loc_lon)

    # origin_lat
    # origin_lon
    # destination_lat
    # destination_lon

    # Re-center to Kenya region


def back_to_nairobi():
    ui.update_numeric("origin_lat", value=LOCATIONS["Nairobi"]['latitude'])
    ui.update_numeric(
        "origin_lon", value=LOCATIONS["Nairobi"]['longitude'])
    ui.update_numeric(
        "destination_lat", value=LOCATIONS["National Museum of Kenya"]['latitude'])
    ui.update_numeric(
        "destination_lon", value=LOCATIONS["National Museum of Kenya"]['longitude'])


def validate_inputs(origin_lat: float = None, origin_lon: float = None, destination_lat: float = None, destination_lon: float = None) -> bool:
    lat_min, lat_max, lon_min, lon_max = get_bounds(country='Kenya')

    valid = True
    
    for lat, lon in [(origin_lat, origin_lon), (destination_lat, destination_lon)]:
        if lat is not None and lon is not None:
            if (lat < lat_min or lat > lat_max) or (lon < lon_min or lon > lon_max):
                ui.notification_show(
                    "๐Ÿ˜ฎ Location is outside Kenya, taking you back to Nairobi", type="error")
                valid = False
                back_to_nairobi()
                break


    return valid


# Footer
footer = ui.tags.footer(
    ui.tags.div(
        "ยฉ 2024. Made with ๐Ÿ’–",
        style=f"text-align: center; padding: 10px; color: #fff; background-color: {BRANDCOLORS['purple-dark']}; margin-top: 50px;"
    )
)