Nguyen Thai Thao Uyen commited on
Commit
60d3409
·
1 Parent(s): 3839ddb

Test dashboard

Browse files
Files changed (3) hide show
  1. app.py +202 -151
  2. requirements.txt +6 -4
  3. shared.py +38 -0
app.py CHANGED
@@ -1,151 +1,202 @@
1
- from pathlib import Path
2
- from typing import List, Dict, Tuple
3
- import matplotlib.colors as mpl_colors
4
-
5
- import pandas as pd
6
- import seaborn as sns
7
- import shinyswatch
8
-
9
- from shiny import App, Inputs, Outputs, Session, reactive, render, req, ui
10
-
11
- sns.set_theme()
12
-
13
- www_dir = Path(__file__).parent.resolve() / "www"
14
-
15
- df = pd.read_csv(Path(__file__).parent / "penguins.csv", na_values="NA")
16
- numeric_cols: List[str] = df.select_dtypes(include=["float64"]).columns.tolist()
17
- species: List[str] = df["Species"].unique().tolist()
18
- species.sort()
19
-
20
- app_ui = ui.page_fillable(
21
- shinyswatch.theme.minty(),
22
- ui.layout_sidebar(
23
- ui.sidebar(
24
- # Artwork by @allison_horst
25
- ui.input_selectize(
26
- "xvar",
27
- "X variable",
28
- numeric_cols,
29
- selected="Bill Length (mm)",
30
- ),
31
- ui.input_selectize(
32
- "yvar",
33
- "Y variable",
34
- numeric_cols,
35
- selected="Bill Depth (mm)",
36
- ),
37
- ui.input_checkbox_group(
38
- "species", "Filter by species", species, selected=species
39
- ),
40
- ui.hr(),
41
- ui.input_switch("by_species", "Show species", value=True),
42
- ui.input_switch("show_margins", "Show marginal plots", value=True),
43
- ),
44
- ui.output_ui("value_boxes"),
45
- ui.output_plot("scatter", fill=True),
46
- ui.help_text(
47
- "Artwork by ",
48
- ui.a("@allison_horst", href="https://twitter.com/allison_horst"),
49
- class_="text-end",
50
- ),
51
- ),
52
- )
53
-
54
-
55
- def server(input: Inputs, output: Outputs, session: Session):
56
- @reactive.Calc
57
- def filtered_df() -> pd.DataFrame:
58
- """Returns a Pandas data frame that includes only the desired rows"""
59
-
60
- # This calculation "req"uires that at least one species is selected
61
- req(len(input.species()) > 0)
62
-
63
- # Filter the rows so we only include the desired species
64
- return df[df["Species"].isin(input.species())]
65
-
66
- @output
67
- @render.plot
68
- def scatter():
69
- """Generates a plot for Shiny to display to the user"""
70
-
71
- # The plotting function to use depends on whether margins are desired
72
- plotfunc = sns.jointplot if input.show_margins() else sns.scatterplot
73
-
74
- plotfunc(
75
- data=filtered_df(),
76
- x=input.xvar(),
77
- y=input.yvar(),
78
- palette=palette,
79
- hue="Species" if input.by_species() else None,
80
- hue_order=species,
81
- legend=False,
82
- )
83
-
84
- @output
85
- @render.ui
86
- def value_boxes():
87
- df = filtered_df()
88
-
89
- def penguin_value_box(title: str, count: int, bgcol: str, showcase_img: str):
90
- return ui.value_box(
91
- title,
92
- count,
93
- {"class_": "pt-1 pb-0"},
94
- showcase=ui.fill.as_fill_item(
95
- ui.tags.img(
96
- {"style": "object-fit:contain;"},
97
- src=showcase_img,
98
- )
99
- ),
100
- theme_color=None,
101
- style=f"background-color: {bgcol};",
102
- )
103
-
104
- if not input.by_species():
105
- return penguin_value_box(
106
- "Penguins",
107
- len(df.index),
108
- bg_palette["default"],
109
- # Artwork by @allison_horst
110
- showcase_img="penguins.png",
111
- )
112
-
113
- value_boxes = [
114
- penguin_value_box(
115
- name,
116
- len(df[df["Species"] == name]),
117
- bg_palette[name],
118
- # Artwork by @allison_horst
119
- showcase_img=f"{name}.png",
120
- )
121
- for name in species
122
- # Only include boxes for _selected_ species
123
- if name in input.species()
124
- ]
125
-
126
- return ui.layout_column_wrap(*value_boxes, width = 1 / len(value_boxes))
127
-
128
-
129
- # "darkorange", "purple", "cyan4"
130
- colors = [[255, 140, 0], [160, 32, 240], [0, 139, 139]]
131
- colors = [(r / 255.0, g / 255.0, b / 255.0) for r, g, b in colors]
132
-
133
- palette: Dict[str, Tuple[float, float, float]] = {
134
- "Adelie": colors[0],
135
- "Chinstrap": colors[1],
136
- "Gentoo": colors[2],
137
- "default": sns.color_palette()[0], # type: ignore
138
- }
139
-
140
- bg_palette = {}
141
- # Use `sns.set_style("whitegrid")` to help find approx alpha value
142
- for name, col in palette.items():
143
- # Adjusted n_colors until `axe` accessibility did not complain about color contrast
144
- bg_palette[name] = mpl_colors.to_hex(sns.light_palette(col, n_colors=7)[1]) # type: ignore
145
-
146
-
147
- app = App(
148
- app_ui,
149
- server,
150
- static_assets=str(www_dir),
151
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ipyleaflet as L
2
+ from faicons import icon_svg
3
+ from geopy.distance import geodesic, great_circle
4
+ from shared import BASEMAPS, CITIES
5
+ from shiny import reactive
6
+ from shiny.express import input, render, ui
7
+ from shinywidgets import render_widget
8
+
9
+ city_names = sorted(list(CITIES.keys()))
10
+
11
+ ui.page_opts(title="Location Distance Calculator", fillable=True)
12
+ {"class": "bslib-page-dashboard"}
13
+
14
+ with ui.sidebar():
15
+ ui.input_selectize("loc1", "Location 1", choices=city_names, selected="New York")
16
+ ui.input_selectize("loc2", "Location 2", choices=city_names, selected="London")
17
+ ui.input_selectize(
18
+ "basemap",
19
+ "Choose a basemap",
20
+ choices=list(BASEMAPS.keys()),
21
+ selected="WorldImagery",
22
+ )
23
+ ui.input_dark_mode(mode="dark")
24
+
25
+ with ui.layout_column_wrap(fill=False):
26
+ with ui.value_box(showcase=icon_svg("globe"), theme="gradient-blue-indigo"):
27
+ "Great Circle Distance"
28
+
29
+ @render.text
30
+ def great_circle_dist():
31
+ circle = great_circle(loc1xy(), loc2xy())
32
+ return f"{circle.kilometers.__round__(1)} km"
33
+
34
+ with ui.value_box(showcase=icon_svg("ruler"), theme="gradient-blue-indigo"):
35
+ "Geodisic Distance"
36
+
37
+ @render.text
38
+ def geo_dist():
39
+ dist = geodesic(loc1xy(), loc2xy())
40
+ return f"{dist.kilometers.__round__(1)} km"
41
+
42
+ with ui.value_box(showcase=icon_svg("mountain"), theme="gradient-blue-indigo"):
43
+ "Altitude Difference"
44
+
45
+ @render.text
46
+ def altitude():
47
+ try:
48
+ return f'{loc1()["altitude"] - loc2()["altitude"]} m'
49
+ except TypeError:
50
+ return "N/A (altitude lookup failed)"
51
+
52
+
53
+ with ui.card():
54
+ ui.card_header("Map (drag the markers to change locations)")
55
+
56
+ @render_widget
57
+ def map():
58
+ return L.Map(zoom=4, center=(0, 0))
59
+
60
+
61
+ # Reactive values to store location information
62
+ loc1 = reactive.value()
63
+ loc2 = reactive.value()
64
+
65
+
66
+ # Update the reactive values when the selectize inputs change
67
+ @reactive.effect
68
+ def _():
69
+ loc1.set(CITIES.get(input.loc1(), loc_str_to_coords(input.loc1())))
70
+ loc2.set(CITIES.get(input.loc2(), loc_str_to_coords(input.loc2())))
71
+
72
+
73
+ # When a marker is moved, the input value gets updated to "lat, lon",
74
+ # so we decode that into a dict (and also look up the altitude)
75
+ def loc_str_to_coords(x: str) -> dict:
76
+ latlon = x.split(", ")
77
+ if len(latlon) != 2:
78
+ return {}
79
+
80
+ lat = float(latlon[0])
81
+ lon = float(latlon[1])
82
+
83
+ try:
84
+ import requests
85
+
86
+ query = f"https://api.open-elevation.com/api/v1/lookup?locations={lat},{lon}"
87
+ r = requests.get(query).json()
88
+ altitude = r["results"][0]["elevation"]
89
+ except Exception:
90
+ altitude = None
91
+
92
+ return {"latitude": lat, "longitude": lon, "altitude": altitude}
93
+
94
+
95
+ # Convenient way to get the lat/lons as a tuple
96
+ @reactive.calc
97
+ def loc1xy():
98
+ return loc1()["latitude"], loc1()["longitude"]
99
+
100
+
101
+ @reactive.calc
102
+ def loc2xy():
103
+ return loc2()["latitude"], loc2()["longitude"]
104
+
105
+
106
+ # Add marker for first location
107
+ @reactive.effect
108
+ def _():
109
+ update_marker(map.widget, loc1xy(), on_move1, "loc1")
110
+
111
+
112
+ # Add marker for second location
113
+ @reactive.effect
114
+ def _():
115
+ update_marker(map.widget, loc2xy(), on_move2, "loc2")
116
+
117
+
118
+ # Add line and fit bounds when either marker is moved
119
+ @reactive.effect
120
+ def _():
121
+ update_line(map.widget, loc1xy(), loc2xy())
122
+
123
+
124
+ # If new bounds fall outside of the current view, fit the bounds
125
+ @reactive.effect
126
+ def _():
127
+ l1 = loc1xy()
128
+ l2 = loc2xy()
129
+
130
+ lat_rng = [min(l1[0], l2[0]), max(l1[0], l2[0])]
131
+ lon_rng = [min(l1[1], l2[1]), max(l1[1], l2[1])]
132
+ new_bounds = [
133
+ [lat_rng[0], lon_rng[0]],
134
+ [lat_rng[1], lon_rng[1]],
135
+ ]
136
+
137
+ b = map.widget.bounds
138
+ if len(b) == 0:
139
+ map.widget.fit_bounds(new_bounds)
140
+ elif (
141
+ lat_rng[0] < b[0][0]
142
+ or lat_rng[1] > b[1][0]
143
+ or lon_rng[0] < b[0][1]
144
+ or lon_rng[1] > b[1][1]
145
+ ):
146
+ map.widget.fit_bounds(new_bounds)
147
+
148
+
149
+ # Update the basemap
150
+ @reactive.effect
151
+ def _():
152
+ update_basemap(map.widget, input.basemap())
153
+
154
+
155
+ # ---------------------------------------------------------------
156
+ # Helper functions
157
+ # ---------------------------------------------------------------
158
+
159
+
160
+ def update_marker(map: L.Map, loc: tuple, on_move: object, name: str):
161
+ remove_layer(map, name)
162
+ m = L.Marker(location=loc, draggable=True, name=name)
163
+ m.on_move(on_move)
164
+ map.add_layer(m)
165
+
166
+
167
+ def update_line(map: L.Map, loc1: tuple, loc2: tuple):
168
+ remove_layer(map, "line")
169
+ map.add_layer(
170
+ L.Polyline(locations=[loc1, loc2], color="blue", weight=2, name="line")
171
+ )
172
+
173
+
174
+ def update_basemap(map: L.Map, basemap: str):
175
+ for layer in map.layers:
176
+ if isinstance(layer, L.TileLayer):
177
+ map.remove_layer(layer)
178
+ map.add_layer(L.basemap_to_tiles(BASEMAPS[input.basemap()]))
179
+
180
+
181
+ def remove_layer(map: L.Map, name: str):
182
+ for layer in map.layers:
183
+ if layer.name == name:
184
+ map.remove_layer(layer)
185
+
186
+
187
+ def on_move1(**kwargs):
188
+ return on_move("loc1", **kwargs)
189
+
190
+
191
+ def on_move2(**kwargs):
192
+ return on_move("loc2", **kwargs)
193
+
194
+
195
+ # When the markers are moved, update the selectize inputs to include the new
196
+ # location (which results in the locations() reactive value getting updated,
197
+ # which invalidates any downstream reactivity that depends on it)
198
+ def on_move(id, **kwargs):
199
+ loc = kwargs["location"]
200
+ loc_str = f"{loc[0]}, {loc[1]}"
201
+ choices = city_names + [loc_str]
202
+ ui.update_selectize(id, selected=loc_str, choices=choices)
requirements.txt CHANGED
@@ -1,4 +1,6 @@
1
- shiny==0.9.0
2
- shinyswatch==0.6.0
3
- seaborn==0.12.2
4
- matplotlib==3.7.1
 
 
 
1
+ shiny
2
+ shinywidgets
3
+ ipyleaflet
4
+ geopy
5
+ faicons
6
+ requests
shared.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from ipyleaflet import basemaps
2
+
3
+ BASEMAPS = {
4
+ "WorldImagery": basemaps.Esri.WorldImagery,
5
+ "Mapnik": basemaps.OpenStreetMap.Mapnik,
6
+ "Positron": basemaps.CartoDB.Positron,
7
+ "DarkMatter": basemaps.CartoDB.DarkMatter,
8
+ "NatGeoWorldMap": basemaps.Esri.NatGeoWorldMap,
9
+ "France": basemaps.OpenStreetMap.France,
10
+ "DE": basemaps.OpenStreetMap.DE,
11
+ }
12
+
13
+
14
+ CITIES = {
15
+ "New York": {"latitude": 40.7128, "longitude": -74.0060, "altitude": 33},
16
+ "London": {"latitude": 51.5074, "longitude": -0.1278, "altitude": 36},
17
+ "Paris": {"latitude": 48.8566, "longitude": 2.3522, "altitude": 35},
18
+ "Tokyo": {"latitude": 35.6895, "longitude": 139.6917, "altitude": 44},
19
+ "Sydney": {"latitude": -33.8688, "longitude": 151.2093, "altitude": 39},
20
+ "Los Angeles": {"latitude": 34.0522, "longitude": -118.2437, "altitude": 71},
21
+ "Berlin": {"latitude": 52.5200, "longitude": 13.4050, "altitude": 34},
22
+ "Rome": {"latitude": 41.9028, "longitude": 12.4964, "altitude": 21},
23
+ "Beijing": {"latitude": 39.9042, "longitude": 116.4074, "altitude": 44},
24
+ "Moscow": {"latitude": 55.7558, "longitude": 37.6176, "altitude": 156},
25
+ "Cairo": {"latitude": 30.0444, "longitude": 31.2357, "altitude": 23},
26
+ "Rio de Janeiro": {"latitude": -22.9068, "longitude": -43.1729, "altitude": 8},
27
+ "Toronto": {"latitude": 43.6511, "longitude": -79.3832, "altitude": 76},
28
+ "Dubai": {"latitude": 25.2769, "longitude": 55.2963, "altitude": 52},
29
+ "Mumbai": {"latitude": 19.0760, "longitude": 72.8777, "altitude": 14},
30
+ "Seoul": {"latitude": 37.5665, "longitude": 126.9780, "altitude": 38},
31
+ "Madrid": {"latitude": 40.4168, "longitude": -3.7038, "altitude": 667},
32
+ "Amsterdam": {"latitude": 52.3676, "longitude": 4.9041, "altitude": -2},
33
+ "Buenos Aires": {"latitude": -34.6037, "longitude": -58.3816, "altitude": 25},
34
+ "Stockholm": {"latitude": 59.3293, "longitude": 18.0686, "altitude": 14},
35
+ "Boulder": {"latitude": 40.0150, "longitude": -105.2705, "altitude": 1634},
36
+ "Lhasa": {"latitude": 29.6500, "longitude": 91.1000, "altitude": 3650},
37
+ "Khatmandu": {"latitude": 27.7172, "longitude": 85.3240, "altitude": 1400},
38
+ }