ahuang11 commited on
Commit
4c8f4a0
·
1 Parent(s): e8afe79

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +124 -449
app.py CHANGED
@@ -1,479 +1,154 @@
1
- from pathlib import Path
2
-
3
- import duckdb
4
- import holoviews as hv
5
- import pandas as pd
6
  import panel as pn
7
- from bokeh.models import HoverTool
8
- from langchain.callbacks.base import BaseCallbackHandler
9
- from langchain.chat_models import ChatOpenAI
10
-
11
- pn.extension(sizing_mode="stretch_width", notifications=True)
12
- hv.extension("bokeh")
13
-
14
- INSTRUCTIONS = """
15
- #### Name Chronicles lets you explore the history of names in the United States.
16
- - Enter a name to add to plot.
17
- - See stats by hovering a line.
18
- - Click on a line to see the gender distribution.
19
- - Get a random name based on selected criteria.
20
- - Ask AI for some background info on a name.
21
- - Have ideas? [Open an issue](https://github.com/ahuang11/name-chronicles/issues).
22
- """
23
-
24
- RANDOM_NAME_QUERY = """
25
- SELECT name, count,
26
- CASE
27
- WHEN female_percent >= 0.2 AND female_percent <= 0.8 AND male_percent >= 0.2 AND male_percent <= 0.8 THEN 'unisex'
28
- WHEN female_percent > 0.6 THEN 'female'
29
- WHEN male_percent > 0.6 THEN 'male'
30
- END AS gender
31
- FROM (
32
- SELECT
33
- name,
34
- MAX(male + female) AS count,
35
- (SUM(female) / CAST(SUM(male + female) AS REAL)) AS female_percent,
36
- (SUM(male) / CAST(SUM(male + female) AS REAL)) AS male_percent
37
- FROM names
38
- WHERE name LIKE ?
39
- GROUP BY name
40
- )
41
- WHERE count >= ? AND count <= ?
42
- AND gender = ?
43
- ORDER BY RANDOM()
44
- LIMIT 100
45
- """
46
-
47
- TOP_NAMES_WILDCARD_QUERY = """
48
- SELECT name, SUM(male + female) as count
49
- FROM names
50
- WHERE lower(name) LIKE ?
51
- GROUP BY name
52
- ORDER BY count DESC
53
- LIMIT 10
54
- """
55
-
56
- TOP_NAMES_SELECT_QUERY = """
57
- SELECT name, SUM(male + female) as count
58
- FROM names
59
- WHERE lower(name) = ?
60
- GROUP BY name
61
- ORDER BY count DESC
62
  """
63
 
64
- DATA_QUERY = """
65
- SELECT name, year, male, female, SUM(male + female) AS count
66
- FROM names
67
- WHERE name in ({placeholders})
68
- GROUP BY name, year, male, female
69
- ORDER BY name, year
70
  """
71
 
72
 
73
- class StreamHandler(BaseCallbackHandler):
74
- def __init__(self, container, initial_text="", target_attr="value"):
75
- self.container = container
76
- self.text = initial_text
77
- self.target_attr = target_attr
78
-
79
- def on_llm_new_token(self, token: str, **kwargs) -> None:
80
- self.text += token
81
- setattr(self.container, self.target_attr, self.text)
82
-
83
-
84
- class NameChronicles:
85
  def __init__(self):
86
- super().__init__()
87
- self.db_path = Path("data/names.db")
88
-
89
- # Main
90
- self.holoviews_pane = pn.pane.HoloViews(
91
- min_height=675, sizing_mode="stretch_both"
92
- )
93
- self.selection = hv.streams.Selection1D()
94
-
95
- # Sidebar
96
-
97
- # Name Widgets
98
- self.names_input = pn.widgets.TextInput(name="Name Input", placeholder="Andrew")
99
- self.names_input.param.watch(self._add_name, "value")
100
-
101
- self.names_choice = pn.widgets.MultiChoice(
102
- name="Selected Names",
103
- options=["Andrew"],
104
- solid=False,
105
  )
106
- self.names_choice.param.watch(self._update_plot, "value")
107
 
108
- # Reset Widgets
109
- self.clear_button = pn.widgets.Button(
110
- name="Clear Names", button_style="outline", button_type="primary"
111
- )
112
- self.clear_button.on_click(
113
- lambda event: setattr(self.names_choice, "value", [])
114
- )
115
- self.refresh_button = pn.widgets.Button(
116
- name="Refresh Plot", button_style="outline", button_type="primary"
117
  )
118
- self.refresh_button.on_click(self._refresh_plot)
 
 
119
 
120
- # Randomize Widgets
121
- self.name_pattern = pn.widgets.TextInput(
122
- name="Name Pattern", placeholder="*na*"
123
- )
124
- self.count_range = pn.widgets.IntRangeSlider(
125
- name="Peak Count Range",
126
- value=(10000, 50000),
127
- start=0,
128
- end=100000,
129
- step=1000,
130
- margin=(5, 20),
131
- )
132
- self.gender_select = pn.widgets.RadioButtonGroup(
133
- name="Gender",
134
- options=["Female", "Unisex", "Male"],
135
- button_style="outline",
136
- button_type="primary",
137
- )
138
- randomize_name = pn.widgets.Button(
139
- name="Get Name", button_style="outline", button_type="primary"
140
- )
141
- randomize_name.param.watch(self._randomize_name, "clicks")
142
- self.randomize_pane = pn.Card(
143
- self.name_pattern,
144
- self.count_range,
145
- self.gender_select,
146
- randomize_name,
147
- title="Get Random Name",
148
- collapsed=True,
149
- )
150
 
151
- # AI Widgets
152
- self.ai_key = pn.widgets.PasswordInput(
153
- name="OpenAI Key",
154
- placeholder="",
155
- )
156
- self.ai_prompt = pn.widgets.TextInput(
157
- name="AI Prompt",
158
- value="Share a little history about the name:",
159
- )
160
- ai_button = pn.widgets.Button(
161
- name="Get Response",
162
- button_style="outline",
163
- button_type="primary",
164
- )
165
- ai_button.on_click(self._prompt_ai)
166
- self.ai_response = pn.widgets.TextAreaInput(
167
- placeholder="",
168
- disabled=True,
169
- height=350,
170
- )
171
- self.ai_pane = pn.Card(
172
- self.ai_key,
173
- self.ai_prompt,
174
- ai_button,
175
- self.ai_response,
176
- collapsed=True,
177
- title="Ask AI",
178
- )
179
 
180
- pn.state.onload(self._initialize_database)
 
 
181
 
182
- # Database Methods
183
 
184
- def _initialize_database(self):
185
- """
186
- Initialize database with data from the Social Security Administration.
187
- """
188
- self.conn = duckdb.connect(":memory:")
189
- df = pd.concat(
190
- [
191
- pd.read_csv(
192
- path,
193
- header=None,
194
- names=["state", "gender", "year", "name", "count"],
195
- )
196
- for path in Path("data").glob("*.TXT")
197
- ]
198
- )
199
- df_processed = (
200
- df.groupby(["gender", "year", "name"], as_index=False)[["count"]]
201
- .sum()
202
- .pivot(index=["name", "year"], columns="gender", values="count")
203
- .reset_index()
204
- .rename(columns={"F": "female", "M": "male"})
205
- .fillna(0)
206
- )
207
- self.conn.execute("DROP TABLE IF EXISTS names")
208
- self.conn.execute("CREATE TABLE names AS SELECT * FROM df_processed")
209
 
210
- if self.names_choice.value == []:
211
- self.names_choice.value = ["Andrew"]
212
- else:
213
- self.names_choice.param.trigger("value")
214
- self.main.objects = [self.holoviews_pane]
 
 
215
 
216
- def _query_names(self, names):
217
- """
218
- Query the database for the given name.
219
- """
220
- dfs = []
221
- for name in names:
222
  if "*" in name or "%" in name:
223
  name = name.replace("*", "%")
224
- top_names_query = TOP_NAMES_WILDCARD_QUERY
225
- else:
226
- top_names_query = TOP_NAMES_SELECT_QUERY
227
- top_names = (
228
- self.conn.execute(top_names_query, [name.lower()])
229
- .fetch_df()["name"]
230
- .tolist()
231
  )
232
- if len(top_names) == 0:
233
- pn.state.notifications.info(f"No names found matching {name!r}")
234
- continue
235
- data_query = DATA_QUERY.format(
236
- placeholders=", ".join(["?"] * len(top_names))
237
  )
238
- df = self.conn.execute(data_query, top_names).fetch_df()
239
- dfs.append(df)
240
-
241
- if len(dfs) > 0:
242
- self.df = pd.concat(dfs).drop_duplicates(
243
- subset=["name", "year", "male", "female"]
244
- )
245
- else:
246
- self.df = pd.DataFrame(columns=["name", "year", "male", "female"])
247
-
248
- # Widget Methods
249
-
250
- def _randomize_name(self, event):
251
- name_pattern = self.name_pattern.value.lower()
252
- if not name_pattern:
253
- name_pattern = "%"
254
- else:
255
- name_pattern = name_pattern.replace("*", "%")
256
- count_range = self.count_range.value
257
- gender_select = self.gender_select.value.lower()
258
- random_names = (
259
- self.conn.execute(
260
- RANDOM_NAME_QUERY, [name_pattern, *count_range, gender_select]
261
- )
262
- .fetch_df()["name"]
263
- .tolist()
264
- )
265
- if random_names:
266
- for i in range(len(random_names)):
267
- random_name = random_names[i]
268
- if random_name in self.names_choice.value:
269
- continue
270
- self.names_input.value = random_name
271
- break
272
- else:
273
- pn.state.notifications.info(
274
- "All names matching the criteria are already added!"
275
- )
276
- else:
277
- pn.state.notifications.info("No names found matching the criteria!")
278
-
279
- def _add_name(self, event):
280
- name = event.new.strip().title()
281
- self.names_input.value = ""
282
- if not name:
283
- return
284
- elif name in self.names_choice.options and name in self.names_choice.value:
285
- pn.state.notifications.info(f"{name!r} already added!")
286
- return
287
- elif len(self.names_choice.value) > 10:
288
- pn.state.notifications.info(
289
- "Maximum of 10 names allowed; please remove some first!"
290
  )
291
- return
292
- value = self.names_choice.value.copy()
293
- options = self.names_choice.options.copy()
294
- if name not in options:
295
- options.append(name)
296
- if name not in value:
297
- value.append(name)
298
- self.names_choice.param.update(
299
- options=options,
300
- value=value,
301
- )
302
-
303
- def _prompt_ai(self, event):
304
- if not self.ai_key.value:
305
- pn.state.notifications.info("Please enter an API key!")
306
- return
307
-
308
- if not self.ai_prompt.value:
309
- pn.state.notifications.info("Please enter a prompt!")
310
- return
311
-
312
- stream_handler = StreamHandler(self.ai_response)
313
- chat = ChatOpenAI(
314
- max_tokens=500,
315
- openai_api_key=self.ai_key.value,
316
- streaming=True,
317
- callbacks=[stream_handler],
318
- )
319
- self.ai_response.loading = True
320
- try:
321
- if self.selection.index:
322
- names = [self._name_indices[self.selection.index[0]]]
323
- else:
324
- names = self.names_choice.value[:3]
325
- chat.predict(f"{self.ai_prompt.value} {names}")
326
  finally:
327
- self.ai_response.loading = False
328
-
329
- # Plot Methods
330
-
331
- def _click_plot(self, index):
332
- gender_nd_overlay = hv.NdOverlay(kdims=["Gender"])
333
- if not index:
334
- return hv.NdOverlay(
335
- {
336
- "curve": self._curve_nd_overlay,
337
- "scatter": self._scatter_nd_overlay,
338
- "label": self._label_nd_overlay,
339
- }
340
- )
341
-
342
- name = self._name_indices[index[0]]
343
- df_name = self.df.loc[self.df["name"] == name].copy()
344
- df_name["female"] += df_name["male"]
345
- gender_nd_overlay["Male"] = hv.Area(
346
- df_name, ["year"], ["male"], label="Male"
347
- ).opts(alpha=0.3, color="#add8e6", line_alpha=0)
348
- gender_nd_overlay["Female"] = hv.Area(
349
- df_name, ["year"], ["male", "female"], label="Female"
350
- ).opts(alpha=0.3, color="#ffb6c1", line_alpha=0)
351
- return hv.NdOverlay(
352
- {
353
- "curve": self._curve_nd_overlay[[index[0]]],
354
- "scatter": self._scatter_nd_overlay,
355
- "label": self._label_nd_overlay[[index[0]]].opts(text_color="black"),
356
- "gender": gender_nd_overlay,
357
- },
358
- kdims=["Gender"],
359
- ).opts(legend_position="top_left")
360
-
361
- @staticmethod
362
- def _format_y(value):
363
- return f"{value / 1000}k"
364
-
365
- def _update_plot(self, event):
366
- names = event.new
367
- print(names)
368
- self._query_names(names)
369
-
370
- self._scatter_nd_overlay = hv.NdOverlay()
371
- self._curve_nd_overlay = hv.NdOverlay(kdims=["Name"]).opts(
372
- gridstyle={"xgrid_line_width": 0},
373
- show_grid=True,
374
- fontscale=1.28,
375
- xlabel="Year",
376
- ylabel="Count",
377
- yformatter=self._format_y,
378
- legend_limit=0,
379
- padding=(0.2, 0.05),
380
- title="Name Chronicles",
381
- responsive=True,
382
- )
383
- self._label_nd_overlay = hv.NdOverlay(kdims=["Name"])
384
- hover_tool = HoverTool(
385
- tooltips=[("Name", "@name"), ("Year", "@year"), ("Count", "@count")],
386
- )
387
- self._name_indices = {}
388
- scatter_cycle = hv.Cycle("Category10")
389
- curve_cycle = hv.Cycle("Category10")
390
- label_cycle = hv.Cycle("Category10")
391
- for i, (name, df_name) in enumerate(self.df.groupby("name")):
392
- df_name_total = df_name.groupby(
393
- ["name", "year", "male", "female"], as_index=False
394
- )["count"].sum()
395
- df_name_total["male"] = df_name_total["male"] / df_name_total["count"]
396
- df_name_total["female"] = df_name_total["female"] / df_name_total["count"]
397
- df_name_peak = df_name.loc[[df_name["count"].idxmax()]]
398
- df_name_peak[
399
- "label"
400
- ] = f'{df_name_peak["name"].item()} ({df_name_peak["year"].item()})'
401
-
402
- hover_tool = HoverTool(
403
- tooltips=[
404
- ("Name", "@name"),
405
- ("Year", "@year"),
406
- ("Count", "@count{(0a)}"),
407
- ("Male", "@male{(0%)}"),
408
- ("Female", "@female{(0%)}"),
409
- ],
410
- )
411
- self._scatter_nd_overlay[i] = hv.Scatter(
412
- df_name_total, ["year"], ["count", "male", "female", "name"], label=name
413
- ).opts(
414
- color=scatter_cycle,
415
- size=4,
416
- alpha=0.15,
417
- marker="y",
418
- tools=["tap", hover_tool],
419
- line_width=3,
420
- show_legend=False,
421
- )
422
- self._curve_nd_overlay[i] = hv.Curve(
423
- df_name_total, ["year"], ["count"], label=name
424
- ).opts(
425
- color=curve_cycle,
426
- tools=["tap"],
427
- line_width=3,
428
- )
429
- self._label_nd_overlay[i] = hv.Labels(
430
- df_name_peak, ["year", "count"], ["label"], label=name
431
- ).opts(
432
- text_align="right",
433
- text_baseline="bottom",
434
- text_color=label_cycle,
435
- )
436
- self._name_indices[i] = name
437
- self.selection.source = self._curve_nd_overlay
438
- if len(self._name_indices) == 1:
439
- self.selection.update(index=[0])
440
- else:
441
- self.selection.update(index=[])
442
- self.dynamic_map = hv.DynamicMap(
443
- self._click_plot, kdims=[], streams=[self.selection]
444
- ).opts(responsive=True)
445
- self._refresh_plot()
446
-
447
- def _refresh_plot(self, event=None):
448
- self.holoviews_pane.object = self.dynamic_map.clone()
449
 
450
  def view(self):
451
- reset_row = pn.Row(self.clear_button, self.refresh_button)
452
- data_url = pn.pane.Markdown(
453
- "<center>Data from the <a href='https://www.ssa.gov/oact/babynames/limits.html' "
454
- "target='_blank'>U.S. Social Security Administration</a></center>",
455
- align="end",
456
- )
457
- sidebar = pn.Column(
458
- INSTRUCTIONS,
459
- self.names_input,
460
- self.names_choice,
461
- reset_row,
462
- pn.layout.Divider(),
463
- self.randomize_pane,
464
- self.ai_pane,
465
- data_url,
466
- )
467
- self.main = pn.Column(
468
- pn.widgets.StaticText(value="Loading, this may take a few seconds...", sizing_mode="stretch_both"),
469
- )
470
  template = pn.template.FastListTemplate(
471
- sidebar=[sidebar],
472
- main=[self.main],
473
- title="Name Chronicles",
 
 
474
  theme="dark",
 
 
475
  )
476
- return template
477
 
478
 
479
- NameChronicles().view().servable()
 
 
1
+ import cartopy.crs as ccrs
2
+ import fugue.api as fa
3
+ import geopandas as gpd
4
+ import geoviews as gv
 
5
  import panel as pn
6
+ import datasets
7
+ import pyarrow as pa
8
+ from holoviews.streams import RangeXY
9
+ from shapely import wkt
10
+
11
+ gv.extension("bokeh")
12
+ pn.extension("tabulator")
13
+
14
+ INTRO = """
15
+ *Have you ever looked at a street name and wondered how common it is?*
16
+
17
+ Put your curiosity to rest with MapnStreets! By simply entering a name
18
+ in the provided box, you can discover the prevalence of a street name.
19
+ The map will display the locations of all streets with that name,
20
+ and for more detailed information, you can click on the table to
21
+ highlight their exact whereabouts.
22
+
23
+ Uses [TIGER/Line® Edges](https://www2.census.gov/geo/tiger/TIGER_RD18/LAYER/EDGES/)
24
+ data provided by the US Census Bureau.
25
+
26
+ Powered by OSS:
27
+ [Fugue](https://fugue-tutorials.readthedocs.io),
28
+ [Panel](https://panel.holoviz.org/),
29
+ [GeoPandas](https://geopandas.org/),
30
+ [GeoViews](https://geoviews.org/),
31
+ [Parquet](https://parquet.apache.org/),
32
+ [DuckDB](https://duckdb.org/),
33
+ [Ray](https://ray.io/),
34
+ and all their supporting dependencies.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  """
36
 
37
+ QUERY_FMT = """
38
+ df = CREATE USING load_hf(path="ahuang11/tiger_layer_edges")
39
+ df_sel = SELECT STATEFP, COUNTYFP, FULLNAME, geometry \
40
+ FROM df WHERE FULLNAME == '{{name}}'
 
 
41
  """
42
 
43
 
44
+ class MapnStreets:
 
 
 
 
 
 
 
 
 
 
 
45
  def __init__(self):
46
+ self.gdf = None
47
+ self.name_input = pn.widgets.TextInput(
48
+ value="*Andrew St",
49
+ placeholder="Enter a name...",
50
+ margin=(9, 5, 5, 25),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  )
52
+ pn.bind(self.process_name, self.name_input, watch=True)
53
 
54
+ features = gv.tile_sources.CartoDark()
55
+ self.holoviews_pane = pn.pane.HoloViews(
56
+ features, sizing_mode="stretch_both", min_height=800
 
 
 
 
 
 
57
  )
58
+ self.tabulator = pn.widgets.Tabulator(width=225, disabled=True)
59
+ self.records_text = pn.widgets.StaticText(value="<h3>0 records found</h3>")
60
+ pn.state.onload(self.onload)
61
 
62
+ def onload(self):
63
+ self.name_input.param.trigger("value")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
+ range_xy = RangeXY()
66
+ line_strings = gv.DynamicMap(
67
+ self.refresh_line_strings, streams=[range_xy]
68
+ ).opts(responsive=True)
69
+ range_xy.source = line_strings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
+ points = gv.DynamicMap(
72
+ pn.bind(self.refresh_points, self.tabulator.param.selection)
73
+ ).opts(responsive=True)
74
 
75
+ self.holoviews_pane.object *= line_strings * points
76
 
77
+ def load_hf(path: str) -> pa.Table:
78
+ return datasets.load_dataset(path).data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
+ def serialize_geom(self, df):
81
+ df["geometry"] = df["geometry"].apply(wkt.loads)
82
+ gdf = gpd.GeoDataFrame(df)
83
+ centroids = gdf["geometry"].centroid
84
+ gdf["Longitude"] = centroids.x
85
+ gdf["Latitude"] = centroids.y
86
+ return gdf
87
 
88
+ def process_name(self, name):
89
+ try:
90
+ name = name.strip()
91
+ self.holoviews_pane.loading = True
92
+ query_fmt = QUERY_FMT
 
93
  if "*" in name or "%" in name:
94
  name = name.replace("*", "%")
95
+ query_fmt = query_fmt.replace("==", "LIKE")
96
+ if name == "%":
97
+ return
98
+ df = fa.as_pandas(
99
+ fa.fugue_sql(query_fmt, name=name, engine="duckdb", as_local=True)
 
 
100
  )
101
+ self.gdf = self.serialize_geom(df)
102
+ county_gdf = self.gdf.drop_duplicates(
103
+ subset=["STATEFP", "COUNTYFP", "FULLNAME"]
 
 
104
  )
105
+ self.records_text.value = f"<h3>{len(county_gdf)} records found</h3>"
106
+ self.tabulator.value = (
107
+ county_gdf["FULLNAME"]
108
+ .value_counts()
109
+ .rename_axis("Name")
110
+ .rename("Count")
111
+ .to_frame()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  )
113
+ self.refresh_line_strings()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  finally:
115
+ self.holoviews_pane.loading = False
116
+
117
+ def refresh_line_strings(self, x_range=None, y_range=None):
118
+ line_strings = gv.Polygons(
119
+ self.gdf[["geometry"]],
120
+ crs=ccrs.PlateCarree(),
121
+ ).opts(fill_alpha=0, line_color="white", line_width=8, alpha=0.6)
122
+ return line_strings.select(x=x_range, y=y_range)
123
+
124
+ def refresh_points(self, selection):
125
+ gdf_selection = self.gdf[
126
+ ["Longitude", "Latitude", "STATEFP", "COUNTYFP", "FULLNAME"]
127
+ ]
128
+ if self.tabulator.selection:
129
+ names = self.tabulator.value.iloc[selection].index.tolist()
130
+ gdf_selection = gdf_selection.loc[gdf_selection["FULLNAME"].isin(names)]
131
+ points = gv.Points(
132
+ gdf_selection,
133
+ kdims=["Longitude", "Latitude"],
134
+ vdims=["STATEFP", "COUNTYFP", "FULLNAME"],
135
+ crs=ccrs.PlateCarree(),
136
+ ).opts(marker="x", tools=["hover"], color="#FF4136", size=8)
137
+ return points
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  def view(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  template = pn.template.FastListTemplate(
141
+ header=[pn.Row(self.name_input, self.records_text)],
142
+ sidebar=[INTRO, self.tabulator],
143
+ main=[
144
+ self.holoviews_pane,
145
+ ],
146
  theme="dark",
147
+ title="MapnStreets",
148
+ sidebar_width=225,
149
  )
150
+ return template.servable()
151
 
152
 
153
+ mapn_streets = MapnStreets()
154
+ mapn_streets.view()