jcheng5 commited on
Commit
bd02426
·
1 Parent(s): a829b31

Initial commit

Browse files
Files changed (7) hide show
  1. .gitignore +2 -0
  2. Dockerfile +13 -0
  3. README.md +2 -10
  4. app.py +431 -0
  5. requirements.txt +7 -0
  6. styles.css +56 -0
  7. superzip.csv +0 -0
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ .venv
2
+ __pycache__
Dockerfile ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9
2
+
3
+ WORKDIR /code
4
+
5
+ COPY ./requirements.txt /code/requirements.txt
6
+
7
+ RUN pip install --no-cache-dir --upgrade -r /code/requirements.txt
8
+
9
+ COPY . .
10
+
11
+ EXPOSE 7860
12
+
13
+ CMD ["shiny", "run", "app.py", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,11 +1,3 @@
1
- ---
2
- title: Superzip
3
- emoji: 👀
4
- colorFrom: red
5
- colorTo: yellow
6
- sdk: docker
7
- pinned: false
8
- license: cc0-1.0
9
- ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ ## Data
 
 
 
 
 
 
 
 
2
 
3
+ The `superzip.csv` is the result of [this script](https://github.com/rstudio/shinycoreci-apps/blob/main/apps/063-superzip-example/global.R)
app.py ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import List, Optional, Tuple
3
+
4
+ import ipyleaflet as leaf
5
+ import ipywidgets
6
+ import matplotlib as mpl
7
+ import numpy as np
8
+ import pandas as pd
9
+ import plotly.figure_factory as ff
10
+ import plotly.graph_objs as go
11
+ from htmltools import head_content
12
+ from ipyleaflet import basemaps
13
+ from matplotlib import cm
14
+ from shiny import *
15
+ from shiny.types import SilentException
16
+
17
+ from shinywidgets import *
18
+
19
+ color_palette = cm.get_cmap("viridis", 10)
20
+
21
+
22
+ # TODO: how to handle nas (pd.isna)?
23
+ def col_numeric(domain: Tuple[float, float], na_color: str = "#808080"):
24
+ rescale = mpl.colors.Normalize(domain[0], domain[1])
25
+
26
+ def _(vals: List[float]) -> List[str]:
27
+ cols = color_palette(rescale(vals))
28
+ return [mpl.colors.to_hex(v) for v in cols]
29
+
30
+ return _
31
+
32
+
33
+ # TODO: when this issue is fixed, we won't have to sample anymore
34
+ # https://github.com/rstudio/prism/issues/119
35
+ app_dir = os.path.dirname(__file__)
36
+ allzips = pd.read_csv(os.path.join(app_dir, "superzip.csv")).sample(
37
+ n=10000, random_state=42
38
+ )
39
+
40
+ # ------------------------------------------------------------------------
41
+ # Define user interface
42
+ # ------------------------------------------------------------------------
43
+
44
+ vars = {
45
+ "Score": "Overall score",
46
+ "College": "% college educated",
47
+ "Income": "Median income",
48
+ "Population": "Population",
49
+ }
50
+
51
+ css = open(os.path.join(app_dir, "styles.css"), "r").readlines()
52
+
53
+ ui_map = ui.TagList(
54
+ output_widget("map", width="100%", height="100%"),
55
+ ui.panel_fixed(
56
+ ui.h2("SuperZIP explorer"),
57
+ ui.input_select("variable", "Heatmap variable", vars),
58
+ output_widget("density_score", height="200px"),
59
+ output_widget("density_college", height="200px"),
60
+ output_widget("density_income", height="200px"),
61
+ output_widget("density_pop", height="200px"),
62
+ id="controls",
63
+ class_="panel panel-default",
64
+ width="330px",
65
+ height="auto",
66
+ draggable=True,
67
+ top="60px",
68
+ left="auto",
69
+ right="20px",
70
+ bottom="auto",
71
+ ),
72
+ ui.div(
73
+ "Data compiled for ",
74
+ ui.tags.em("Coming Apart: The State of White America, 1960-2010"),
75
+ " by Charles Murray (Crown Forum, 2012).",
76
+ id="cite",
77
+ ),
78
+ )
79
+
80
+ app_ui = ui.page_navbar(
81
+ ui.nav(
82
+ "Interactive map",
83
+ ui.div(head_content(ui.tags.style(css)), ui_map, class_="outer"),
84
+ ),
85
+ ui.nav(
86
+ "Data explorer",
87
+ ui.row(
88
+ ui.column(3, ui.output_ui("data_intro")),
89
+ ui.column(9, output_widget("data", height="100%")),
90
+ ),
91
+ ui.row(
92
+ ui.column(2),
93
+ ui.column(8, output_widget("table_map")),
94
+ ui.column(2),
95
+ ),
96
+ ),
97
+ title="Superzip",
98
+ )
99
+
100
+ # ------------------------------------------------------------------------
101
+ # non-reactive helper functions
102
+ # ------------------------------------------------------------------------
103
+
104
+
105
+ def density_plot(
106
+ overall: pd.DataFrame,
107
+ in_bounds: pd.DataFrame,
108
+ var: str,
109
+ selected: Optional[pd.DataFrame] = None,
110
+ title: Optional[str] = None,
111
+ showlegend: bool = False,
112
+ ):
113
+ dat = [overall[var], in_bounds[var]]
114
+ if var == "Population":
115
+ dat = [np.log10(x) for x in dat]
116
+
117
+ # Create distplot with curve_type set to 'normal'
118
+ fig = ff.create_distplot(
119
+ dat,
120
+ ["Overall", "In bounds"],
121
+ colors=["black", "#6DCD59"],
122
+ show_rug=False,
123
+ show_hist=False,
124
+ )
125
+ # Remove tick labels
126
+ fig.update_layout(
127
+ # hovermode="x",
128
+ height=200,
129
+ showlegend=showlegend,
130
+ margin=dict(l=0, r=0, t=0, b=0),
131
+ legend=dict(x=0.5, y=1, orientation="h", xanchor="center", yanchor="bottom"),
132
+ xaxis=dict(
133
+ title=title if title is not None else var,
134
+ showgrid=False,
135
+ showline=False,
136
+ zeroline=False,
137
+ ),
138
+ yaxis=dict(
139
+ showgrid=False,
140
+ showline=False,
141
+ showticklabels=False,
142
+ zeroline=False,
143
+ ),
144
+ )
145
+ # hovermode itsn't working properly when dynamically, absolutely positioned
146
+ for _, trace in enumerate(fig.data):
147
+ trace.update(hoverinfo="none")
148
+
149
+ if selected is not None:
150
+ x = selected[var].tolist()[0]
151
+ if var == "Population":
152
+ x = np.log10(x)
153
+ fig.add_shape(
154
+ type="line",
155
+ x0=x,
156
+ x1=x,
157
+ y0=0,
158
+ y1=1,
159
+ yref="paper",
160
+ line=dict(width=1, dash="dashdot", color="gray"),
161
+ )
162
+
163
+ return go.FigureWidget(data=fig.data, layout=fig.layout)
164
+
165
+
166
+ def create_map(**kwargs):
167
+ map = leaf.Map(
168
+ center=(37.45, -88.85),
169
+ zoom=4,
170
+ scroll_wheel_zoom=True,
171
+ attribution_control=False,
172
+ **kwargs,
173
+ )
174
+ map.add_layer(leaf.basemap_to_tiles(basemaps.CartoDB.DarkMatter))
175
+ return map
176
+
177
+
178
+ # ------------------------------------------------------------------------
179
+ # Server logic
180
+ # ------------------------------------------------------------------------
181
+
182
+
183
+ def server(input: Inputs, output: Outputs, session: Session):
184
+ # ------------------------------------------------------------------------
185
+ # Main map logic
186
+ # ------------------------------------------------------------------------
187
+ map = create_map(layout=ipywidgets.Layout(width="100%", height="100%"))
188
+ register_widget("map", map)
189
+
190
+ # Keeps track of whether we're showing markers (zoomed in) or heatmap (zoomed out)
191
+ show_markers = reactive.Value(False)
192
+
193
+ @reactive.Effect
194
+ def _():
195
+ nzips = zips_in_bounds().shape[0]
196
+ show_markers.set(nzips < 200)
197
+
198
+ # When the variable changes, either update marker colors or redraw the heatmap
199
+ @reactive.Effect
200
+ @reactive.event(input.variable)
201
+ def _():
202
+ zips = zips_in_bounds()
203
+ if not show_markers():
204
+ remove_heatmap()
205
+ map.add_layer(layer_heatmap())
206
+ else:
207
+ zip_colors = dict(zip(zips.Zipcode, zips_marker_color()))
208
+ for x in map.layers:
209
+ if x.name.startswith("marker-"):
210
+ zipcode = int(x.name.split("-")[1])
211
+ if zipcode in zip_colors:
212
+ x.color = zip_colors[zipcode]
213
+
214
+ # When bounds change, maybe add new markers
215
+ @reactive.Effect
216
+ @reactive.event(lambda: zips_in_bounds())
217
+ def _():
218
+ if not show_markers():
219
+ return
220
+ zips = zips_in_bounds()
221
+ if zips.empty:
222
+ return
223
+
224
+ # Be careful not to create markers until we know we need to add it
225
+ current_markers = set(
226
+ [m.name for m in map.layers if m.name.startswith("marker-")]
227
+ )
228
+ zips["Color"] = zips_marker_color()
229
+ for _, row in zips.iterrows():
230
+ if ("marker-" + str(row.Zipcode)) not in current_markers:
231
+ map.add_layer(create_marker(row, color=row.Color))
232
+
233
+ # Change from heatmap to markers: remove the heatmap and show markers
234
+ # Change from markers to heatmap: hide the markers and add the heatmap
235
+ @reactive.Effect
236
+ @reactive.event(show_markers)
237
+ def _():
238
+ if show_markers():
239
+ map.remove_layer(layer_heatmap())
240
+ else:
241
+ map.add_layer(layer_heatmap())
242
+
243
+ opacity = 0.6 if show_markers() else 0.0
244
+
245
+ for x in map.layers:
246
+ if x.name.startswith("marker-"):
247
+ x.fill_opacity = opacity
248
+ x.opacity = opacity
249
+
250
+ @reactive.Calc
251
+ def zips_in_bounds():
252
+ bb = reactive_read(map, "bounds")
253
+ if not bb:
254
+ # TODO: this should really be `raise SilentException`...why doesn't it work?
255
+ # return pd.DataFrame()
256
+ raise SilentException
257
+
258
+ lats = (bb[0][0], bb[1][0])
259
+ lons = (bb[0][1], bb[1][1])
260
+ return allzips[
261
+ (allzips.Lat >= lats[0])
262
+ & (allzips.Lat <= lats[1])
263
+ & (allzips.Long >= lons[0])
264
+ & (allzips.Long <= lons[1])
265
+ ]
266
+
267
+ @reactive.Calc
268
+ def zips_marker_color():
269
+ vals = allzips[input.variable()]
270
+ domain = (vals.min(), vals.max())
271
+ vals_in_bb = zips_in_bounds()[input.variable()]
272
+ return col_numeric(domain)(vals_in_bb)
273
+
274
+ @reactive.Calc
275
+ def layer_heatmap():
276
+ locs = allzips[["Lat", "Long", input.variable()]].to_numpy()
277
+ return leaf.Heatmap(
278
+ locations=locs.tolist(),
279
+ name="heatmap",
280
+ # R> cat(paste0(round(scales::rescale(log10(1:10), to = c(0.05, 1)), 2), ": '", viridis::viridis(10), "'"), sep = "\n")
281
+ gradient={
282
+ 0.05: "#440154",
283
+ 0.34: "#482878",
284
+ 0.5: "#3E4A89",
285
+ 0.62: "#31688E",
286
+ 0.71: "#26828E",
287
+ 0.79: "#1F9E89",
288
+ 0.85: "#35B779",
289
+ 0.91: "#6DCD59",
290
+ 0.96: "#B4DE2C",
291
+ 1: "#FDE725",
292
+ },
293
+ )
294
+
295
+ def remove_heatmap():
296
+ for x in map.layers:
297
+ if x.name == "heatmap":
298
+ map.remove_layer(x)
299
+
300
+ zip_selected = reactive.Value(None)
301
+
302
+ @output(id="density_score")
303
+ @render_widget
304
+ def _():
305
+ return density_plot(
306
+ allzips,
307
+ zips_in_bounds(),
308
+ selected=zip_selected(),
309
+ var="Score",
310
+ title="Overall Score",
311
+ showlegend=True,
312
+ )
313
+
314
+ @output(id="density_income")
315
+ @render_widget
316
+ def _():
317
+ return density_plot(
318
+ allzips, zips_in_bounds(), selected=zip_selected(), var="Income"
319
+ )
320
+
321
+ @output(id="density_college")
322
+ @render_widget
323
+ def _():
324
+ return density_plot(
325
+ allzips, zips_in_bounds(), selected=zip_selected(), var="College"
326
+ )
327
+
328
+ @output(id="density_pop")
329
+ @render_widget
330
+ def _():
331
+ return density_plot(
332
+ allzips,
333
+ zips_in_bounds(),
334
+ selected=zip_selected(),
335
+ var="Population",
336
+ title="log10(Population)",
337
+ )
338
+
339
+ def create_marker(row, **kwargs):
340
+ m = leaf.CircleMarker(
341
+ location=(row.Lat, row.Long),
342
+ popup=ipywidgets.HTML(
343
+ f"""
344
+ {row.City}, {row.State} ({row.Zipcode})<br/>
345
+ {row.Score:.1f} overall score<br/>
346
+ {row.College:.1f}% college educated<br/>
347
+ ${row.Income:.0f}k median income<br/>
348
+ {row.Population} people<br/>
349
+ """
350
+ ),
351
+ name=f"marker-{row.Zipcode}",
352
+ **kwargs,
353
+ )
354
+
355
+ def _on_click(**kwargs):
356
+ coords = kwargs["coordinates"]
357
+ idx = (allzips.Lat == coords[0]) & (allzips.Long == coords[1])
358
+ zip_selected.set(allzips[idx])
359
+
360
+ m.on_click(_on_click)
361
+
362
+ return m
363
+
364
+ @output(id="data_intro")
365
+ @render.ui
366
+ def _():
367
+ zips = zips_in_bounds()
368
+
369
+ md = ui.markdown(
370
+ f"""
371
+ {zips.shape[0]} zip codes are currently within the map's viewport, and amongst them:
372
+
373
+ * {100*zips.Superzip.mean():.1f}% are superzips
374
+ * Mean income is ${zips.Income.mean():.0f}k 💰
375
+ * Mean population is {zips.Population.mean():.0f} 👨🏽👩🏽👦🏽
376
+ * Mean college educated is {zips.College.mean():.1f}% 🎓
377
+
378
+ Use the filter controls on the table's columns to drill down further or
379
+ click on a row to
380
+ """,
381
+ )
382
+
383
+ return ui.div(md, class_="my-3 lead")
384
+
385
+ selected_table_row = reactive.Value(pd.DataFrame())
386
+
387
+ @output(id="data")
388
+ @render_widget
389
+ def _():
390
+ import qgrid
391
+
392
+ dat = zips_in_bounds().drop(["Lat", "Long", "Color"], axis=1, errors="ignore")
393
+
394
+ w = qgrid.show_grid(
395
+ dat,
396
+ grid_options={"editable": False},
397
+ column_definitions={"index": {"maxWidth": 0, "minWidth": 0, "width": 0}},
398
+ )
399
+
400
+ def _on_change(event, widget):
401
+ idx = event["new"][0]
402
+ selected_table_row.set(zips_in_bounds().iloc[[idx]])
403
+
404
+ w.on("selection_changed", _on_change)
405
+
406
+ return w
407
+
408
+ table_map = create_map()
409
+
410
+ @output(id="table_map")
411
+ @render_widget
412
+ def _():
413
+ if selected_table_row().empty:
414
+ return None
415
+ else:
416
+ return table_map
417
+
418
+ # TODO: currently there is a bug where clicking the popup causes an error,
419
+ # but I _think_ this'll get fixed in the next release of ipywidgets/ipyleaflet
420
+ # https://github.com/jupyter-widgets/ipywidgets/issues/3384
421
+ @reactive.Effect
422
+ @reactive.event(selected_table_row)
423
+ def _():
424
+ for x in table_map.layers:
425
+ if x.name.startswith("marker"):
426
+ table_map.remove_layer(x)
427
+ for _, row in selected_table_row().iterrows():
428
+ table_map.add_layer(create_marker(row))
429
+
430
+
431
+ app = App(app_ui, server)
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ shiny
2
+ shinywidgets
3
+ matplotlib
4
+ scipy
5
+ qgrid@git+https://github.com/cpsievert/qgrid
6
+ ipyleaflet
7
+ plotly
styles.css ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ input[type="number"] {
2
+ max-width: 80%;
3
+ }
4
+
5
+ div.outer {
6
+ position: fixed;
7
+ top: 55px;
8
+ left: 0;
9
+ right: 0;
10
+ bottom: 0;
11
+ overflow: hidden;
12
+ padding: 0;
13
+ }
14
+
15
+ /* Customize fonts */
16
+ body, label, input, button, select {
17
+ font-family: 'Helvetica Neue', Helvetica;
18
+ font-weight: 200;
19
+ }
20
+ h1, h2, h3, h4 { font-weight: 400; }
21
+
22
+ #controls {
23
+ /* Appearance */
24
+ background-color: white;
25
+ padding: 0 20px 20px 20px;
26
+ cursor: move;
27
+ /* Fade out while not hovering */
28
+ opacity: 0.65;
29
+ zoom: 0.9;
30
+ transition: opacity 500ms 1s;
31
+ z-index: 700;
32
+ }
33
+ #controls:hover {
34
+ /* Fade in while hovering */
35
+ opacity: 0.95;
36
+ transition-delay: 0;
37
+ }
38
+
39
+ /* Position and style citation */
40
+ #cite {
41
+ position: absolute;
42
+ bottom: 10px;
43
+ left: 10px;
44
+ font-size: 12px;
45
+ z-index: 700;
46
+ }
47
+
48
+ /* If not using map tiles, show a white background */
49
+ .leaflet-container {
50
+ background-color: white !important;
51
+ }
52
+
53
+ /* FigureWidget doesn't support config?!? */
54
+ .plotly .modebar-container {
55
+ display: none;
56
+ }
superzip.csv ADDED
The diff for this file is too large to render. See raw diff