npc0 commited on
Commit
15b2d37
·
verified ·
1 Parent(s): 3a4572c

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +154 -91
src/streamlit_app.py CHANGED
@@ -3,87 +3,125 @@ from streamlit_folium import st_folium
3
  import folium
4
  from folium.plugins import Draw
5
  import pandas as pd
 
6
  from shapely.geometry import Polygon, Point
7
  import numpy as np
 
8
 
9
- st.set_page_config(layout="wide", page_title="Multiplex Coop Map Filter")
10
 
11
- st.title("🗺️ Multiplex Coop Housing Filter")
12
- st.write("Draw a polygon on the map to filter the data points within it. Use the form below to apply additional filters based on property attributes.")
13
 
14
- # --- 1. Create a Sample DataFrame with more attributes ---
15
  @st.cache_data
16
- def load_sample_data():
17
- num_points = 100
18
- data = {
19
- 'id': range(1, num_points + 1),
20
- 'name': [f'Property {i}' for i in range(1, num_points + 1)],
21
- 'latitude': np.random.uniform(34.03, 34.07, num_points),
22
- 'longitude': np.random.uniform(-118.28, -118.21, num_points),
23
- 'zn_type': np.random.choice(['Residential (0)', 'Residential Apartment (101)', 'Commercial Residential (6)'], num_points),
24
- 'zn_area': np.random.randint(200, 2000, num_points), # Lot Area in Sq Metres
25
- 'fsi_total': np.round(np.random.uniform(0.5, 3.0, num_points), 2), # Floor Space Index
26
- 'prcnt_cver': np.random.randint(20, 70, num_points), # Building Percent Coverage
27
- 'height_metres': np.round(np.random.uniform(5, 30, num_points), 1), # Height in Metres
28
- 'stories': np.random.randint(2, 10, num_points) # Number of Stories
29
- }
30
- df = pd.DataFrame(data)
31
- return df
32
-
33
- df = load_sample_data()
34
-
35
- # Initialize filtered_df with the full dataframe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  filtered_df = df.copy()
37
 
38
  # --- 2. Initialize the Folium Map with Drawing Tools ---
39
- # Center the map around the sample data (e.g., Los Angeles area)
40
  m = folium.Map(location=[df['latitude'].mean(), df['longitude'].mean()], zoom_start=12)
41
 
42
- # Add drawing tools
43
  draw = Draw(
44
  export=True,
45
  filename="drawn_polygon.geojson",
46
  position="topleft",
47
  draw_options={
48
- "polyline": False,
49
- "rectangle": False,
50
- "circlemarker": False,
51
- "circle": False,
52
- "marker": False,
53
  "polygon": {
54
- "allowIntersection": False,
55
- "drawError": {
56
- "color": "#e0115f",
57
- "message": "Oups!",
58
- },
59
- "shapeOptions": {
60
- "color": "#ef233c",
61
- "fillOpacity": 0.5,
62
- },
63
  },
64
  },
65
  edit_options={"edit": False, "remove": True},
66
  )
67
  m.add_child(draw)
68
 
69
- # Add all data points to the map initially (these will be updated after filtering)
70
- for idx, row in df.iterrows():
 
 
 
71
  folium.CircleMarker(
72
  location=[row['latitude'], row['longitude']],
73
- radius=5,
74
  color='blue',
75
  fill=True,
76
  fill_color='blue',
77
- fill_opacity=0.7,
78
  tooltip=(
79
- f"ID: {row['id']}<br>Name: {row['name']}<br>Zoning: {row['zn_type']}<br>"
80
- f"Area: {row['zn_area']} m²<br>FSI: {row['fsi_total']}<br>"
81
- f"Coverage: {row['prcnt_cver']}%<br>Height: {row['height_metres']}m<br>"
82
- f"Stories: {row['stories']}"
 
83
  )
84
  ).add_to(m)
85
 
86
  st.subheader("Draw a Polygon on the Map")
 
87
  output = st_folium(m, width=1000, height=600, returned_objects=["all_draw_features"])
88
 
89
  polygon_drawn = False
@@ -98,23 +136,23 @@ if output and output["all_draw_features"]:
98
  ]
99
 
100
  if polygons:
101
- polygon_coords = polygons[-1][0] # Get the last drawn polygon's coordinates
102
- # Shapely Polygon expects (lon, lat) tuples, Folium gives (lat, lon)
103
  shapely_polygon = Polygon([(lon, lat) for lat, lon in polygon_coords])
104
  polygon_drawn = True
105
 
106
- # Apply spatial filter
107
  filtered_df = df[
108
  df.apply(
109
  lambda row: shapely_polygon.contains(Point(row['longitude'], row['latitude'])),
110
  axis=1
111
  )
112
  ].copy() # Use .copy() to avoid SettingWithCopyWarning
113
- st.success(f"Initially filtered {len(filtered_df)} points within the drawn polygon.")
114
  else:
115
- st.info("Draw a polygon on the map to spatially filter points.")
116
  else:
117
- st.info("Draw a polygon on the map to spatially filter points.")
118
 
119
  # --- 3. Attribute Filtering Form ---
120
  st.subheader("Filter Property Attributes")
@@ -123,21 +161,29 @@ with st.form("attribute_filters"):
123
  col1, col2 = st.columns(2)
124
 
125
  with col1:
126
- # Zoning Type
 
127
  all_zoning_types = ['All Resdidential Zoning (0, 101, 6)'] + sorted(df['zn_type'].unique().tolist())
128
  selected_zn_type = st.selectbox("Zoning Type", all_zoning_types, key="zn_type_select")
129
 
130
- # Lot Area in Sq Metres
131
- min_zn_area = st.number_input("Minimum Lot Area in Sq Metres", min_value=0, value=0, step=10, key="zn_area_input")
 
 
 
 
 
 
 
132
 
133
- # Floor Space Index (FSI)
134
  min_fsi_total = st.number_input("Minimum Floor Space Index (FSI)", min_value=0.0, value=0.0, step=0.1, format="%.2f", key="fsi_total_input")
135
 
136
  with col2:
137
- # Building Percent Coverage
138
  max_prcnt_cver = st.number_input("Maximum Building Percent Coverage (%)", min_value=0, value=100, step=1, key="prcnt_cver_input")
139
 
140
- # Height or Stories selection
141
  height_stories_option = st.radio(
142
  "Filter by",
143
  ("Height", "Stories"),
@@ -158,8 +204,8 @@ with st.form("attribute_filters"):
158
  if selected_zn_type != 'All Resdidential Zoning (0, 101, 6)':
159
  filtered_df = filtered_df[filtered_df['zn_type'] == selected_zn_type]
160
 
161
- if min_zn_area > 0:
162
- filtered_df = filtered_df[filtered_df['zn_area'] >= min_zn_area]
163
 
164
  if min_fsi_total > 0:
165
  filtered_df = filtered_df[filtered_df['fsi_total'] >= min_fsi_total]
@@ -172,23 +218,36 @@ with st.form("attribute_filters"):
172
  elif height_stories_option == "Stories" and min_stories_value > 0:
173
  filtered_df = filtered_df[filtered_df['stories'] >= min_stories_value]
174
 
175
- st.success(f"Applied attribute filters. Total points after all filters: {len(filtered_df)}")
176
  else:
177
- # If form not submitted, the filtered_df remains as it was after spatial filtering
178
  st.info("Adjust filters and click 'Apply Attribute Filters'.")
179
 
180
 
181
  # --- 4. Display Filtered Data on a New Map and as a Table ---
182
- st.subheader("Filtered Data Points")
183
 
184
  if not filtered_df.empty:
185
- # Create a new map to show only the filtered points
186
- # Adjust map center and zoom if filtered_df is very small or empty,
187
- # otherwise use the original map's center or the filtered_df's center.
188
  if len(filtered_df) > 0:
189
- filtered_map_center = [filtered_df['latitude'].mean(), filtered_df['longitude'].mean()]
190
- filtered_map_zoom = 14 if len(filtered_df) < 5 else 12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  else:
 
192
  filtered_map_center = [df['latitude'].mean(), df['longitude'].mean()]
193
  filtered_map_zoom = 12
194
 
@@ -204,39 +263,43 @@ if not filtered_df.empty:
204
  fill_opacity=0.5
205
  ).add_to(filtered_m)
206
 
207
- # Add filtered points to the new map
208
- for idx, row in filtered_df.iterrows():
209
- folium.CircleMarker(
210
- location=[row['latitude'], row['longitude']],
211
- radius=7,
212
- color='green',
213
- fill=True,
214
- fill_color='green',
215
- fill_opacity=0.8,
216
- tooltip=(
217
- f"ID: {row['id']}<br>Name: {row['name']}<br>Zoning: {row['zn_type']}<br>"
218
- f"Area: {row['zn_area']} m²<br>FSI: {row['fsi_total']}<br>"
219
- f"Coverage: {row['prcnt_cver']}%<br>Height: {row['height_metres']}m<br>"
220
- f"Stories: {row['stories']}"
221
- )
222
- ).add_to(filtered_m)
 
 
223
 
224
  st_folium(filtered_m, width=1000, height=500)
225
 
226
- st.subheader("Filtered Data Table")
227
- st.dataframe(filtered_df)
 
 
228
 
229
  # --- 5. Export Data Button ---
230
  csv = filtered_df.to_csv(index=False).encode('utf-8')
231
  st.download_button(
232
  label="Export Filtered Data to CSV",
233
  data=csv,
234
- file_name="multiplex_coop_filtered_data.csv",
235
  mime="text/csv",
236
  )
237
 
238
  else:
239
- st.warning("No data points match the current filters. Try adjusting your criteria or drawing a different polygon.")
240
 
241
  st.markdown("---")
242
- st.markdown("This app demonstrates spatial filtering using a drawn polygon and attribute filtering based on the provided HTML structure.")
 
3
  import folium
4
  from folium.plugins import Draw
5
  import pandas as pd
6
+ import geopandas as gpd
7
  from shapely.geometry import Polygon, Point
8
  import numpy as np
9
+ import re # For parsing STATEDAREA
10
 
11
+ st.set_page_config(layout="wide", page_title="Multiplex Coop Housing Filter")
12
 
13
+ st.title("🗺️ Multiplex Coop Housing Filter (Hugging Face Data)")
14
+ st.write("This app uses the `ProjectMultiplexCoop/PropertyBoundaries` dataset from Hugging Face. Draw a polygon on the map to spatially filter properties. Use the form below to apply additional filters based on property attributes. **Note: FSI, Building Coverage, Height, and Stories are synthetic for demonstration as they are not directly available in the dataset.**")
15
 
16
+ # --- 1. Load Data from Hugging Face and Process ---
17
  @st.cache_data
18
+ def load_and_process_data():
19
+ """
20
+ Loads the geospatial data from Hugging Face, processes relevant columns,
21
+ and generates synthetic data for missing attributes.
22
+ """
23
+ try:
24
+ # Load the geospatial data using geopandas
25
+ # Ensure you have 'huggingface_hub', 'geopandas', 'fiona', 'pyproj', 'shapely' installed.
26
+ gdf = gpd.read_parquet("hf://datasets/ProjectMultiplexCoop/PropertyBoundaries/Property_Boundaries_4326.parquet")
27
+ except Exception as e:
28
+ st.error(f"Failed to load data from Hugging Face. Please ensure `huggingface_hub`, `geopandas`, `fiona`, and `pyproj` are installed. Error: {e}")
29
+ st.stop()
30
+
31
+ # Process STATEDAREA to numeric (Lot Area in Sq Metres)
32
+ # The format is like "17366.998291 sq.m"
33
+ def parse_stated_area(area_str):
34
+ if pd.isna(area_str):
35
+ return np.nan
36
+ match = re.search(r'(\d+\.?\d*)\s*sq\.m', str(area_str))
37
+ if match:
38
+ return float(match.group(1))
39
+ return np.nan
40
+
41
+ gdf['zn_area'] = gdf['STATEDAREA'].apply(parse_stated_area)
42
+
43
+ # Map FEATURE_TYPE to zn_type (Zoning Type)
44
+ gdf['zn_type'] = gdf['FEATURE_TYPE']
45
+
46
+ # Generate synthetic data for attributes not present in the Hugging Face dataset
47
+ # but required for the filter functionality as per the original HTML.
48
+ num_rows = len(gdf)
49
+ gdf['fsi_total'] = np.round(np.random.uniform(0.5, 3.0, num_rows), 2)
50
+ gdf['prcnt_cver'] = np.random.randint(20, 70, num_rows)
51
+ gdf['height_metres'] = np.round(np.random.uniform(5, 30, num_rows), 1)
52
+ gdf['stories'] = np.random.randint(2, 10, num_rows)
53
+
54
+ # Add unique ID and a display name
55
+ gdf['id'] = range(1, num_rows + 1)
56
+ gdf['name'] = gdf['PARCELID'].apply(lambda x: f"Parcel {x}")
57
+
58
+ # Ensure geometries are valid for centroid calculation and plotting
59
+ # .buffer(0) is a common trick to fix minor geometry issues
60
+ gdf['geometry'] = gdf['geometry'].buffer(0)
61
+ # Extract centroids for point-based filtering and initial map markers
62
+ gdf['latitude'] = gdf.geometry.centroid.y
63
+ gdf['longitude'] = gdf.geometry.centroid.x
64
+
65
+ # Select and reorder relevant columns for display and filtering
66
+ df_processed = gdf[[
67
+ 'id', 'name', 'latitude', 'longitude', 'geometry',
68
+ 'zn_type', 'zn_area', 'fsi_total', 'prcnt_cver', 'height_metres', 'stories',
69
+ 'PARCELID', # Original Parcel ID for reference
70
+ 'ADDRESS_NUMBER', 'LINEAR_NAME_FULL' # For detailed address in tooltips
71
+ ]].copy()
72
+
73
+ return df_processed
74
+
75
+ df = load_and_process_data()
76
+
77
+ # Initialize filtered_df with the full dataframe for initial state
78
  filtered_df = df.copy()
79
 
80
  # --- 2. Initialize the Folium Map with Drawing Tools ---
81
+ # Center the map around the mean of the actual data's centroids
82
  m = folium.Map(location=[df['latitude'].mean(), df['longitude'].mean()], zoom_start=12)
83
 
84
+ # Add drawing tools to the map
85
  draw = Draw(
86
  export=True,
87
  filename="drawn_polygon.geojson",
88
  position="topleft",
89
  draw_options={
90
+ "polyline": False, "rectangle": False, "circlemarker": False,
91
+ "circle": False, "marker": False,
 
 
 
92
  "polygon": {
93
+ "allowIntersection": False, # Restricts polygons to not intersect themselves
94
+ "drawError": {"color": "#e0115f", "message": "Oups!"},
95
+ "shapeOptions": {"color": "#ef233c", "fillOpacity": 0.5},
 
 
 
 
 
 
96
  },
97
  },
98
  edit_options={"edit": False, "remove": True},
99
  )
100
  m.add_child(draw)
101
 
102
+ # Add a sample of points to the initial map for responsiveness
103
+ # Plotting all 500k+ polygons/points at once can cause performance issues.
104
+ sample_df_for_initial_map = df.sample(min(1000, len(df)), random_state=42) # Sample up to 1000 points
105
+
106
+ for idx, row in sample_df_for_initial_map.iterrows():
107
  folium.CircleMarker(
108
  location=[row['latitude'], row['longitude']],
109
+ radius=3, # Smaller radius for denser data points
110
  color='blue',
111
  fill=True,
112
  fill_color='blue',
113
+ fill_opacity=0.5,
114
  tooltip=(
115
+ f"Parcel ID: {row['PARCELID']}<br>Name: {row['name']}<br>Zoning: {row['zn_type']}<br>"
116
+ f"Area: {row['zn_area'] if pd.notna(row['zn_area']) else 'N/A'} m²<br>"
117
+ f"FSI: {row['fsi_total']}<br>Coverage: {row['prcnt_cver']}%<br>"
118
+ f"Height: {row['height_metres']}m<br>Stories: {row['stories']}<br>"
119
+ f"Address: {row['ADDRESS_NUMBER'] if pd.notna(row['ADDRESS_NUMBER']) else ''} {row['LINEAR_NAME_FULL'] if pd.notna(row['LINEAR_NAME_FULL']) else ''}"
120
  )
121
  ).add_to(m)
122
 
123
  st.subheader("Draw a Polygon on the Map")
124
+ st.info(f"Displaying a sample of {len(sample_df_for_initial_map)} points on the map for responsiveness. All {len(df)} properties will be used for filtering.")
125
  output = st_folium(m, width=1000, height=600, returned_objects=["all_draw_features"])
126
 
127
  polygon_drawn = False
 
136
  ]
137
 
138
  if polygons:
139
+ polygon_coords = polygons[-1][0] # Get the coordinates of the last drawn polygon
140
+ # Shapely Polygon expects (lon, lat) tuples, Folium provides (lat, lon)
141
  shapely_polygon = Polygon([(lon, lat) for lat, lon in polygon_coords])
142
  polygon_drawn = True
143
 
144
+ # Apply spatial filter to the full dataframe based on centroid containment
145
  filtered_df = df[
146
  df.apply(
147
  lambda row: shapely_polygon.contains(Point(row['longitude'], row['latitude'])),
148
  axis=1
149
  )
150
  ].copy() # Use .copy() to avoid SettingWithCopyWarning
151
+ st.success(f"Initially filtered {len(filtered_df)} properties within the drawn polygon.")
152
  else:
153
+ st.info("Draw a polygon on the map to spatially filter properties.")
154
  else:
155
+ st.info("Draw a polygon on the map to spatially filter properties.")
156
 
157
  # --- 3. Attribute Filtering Form ---
158
  st.subheader("Filter Property Attributes")
 
161
  col1, col2 = st.columns(2)
162
 
163
  with col1:
164
+ # Zoning Type filter
165
+ # Get unique zoning types from the loaded data, including a default 'All' option
166
  all_zoning_types = ['All Resdidential Zoning (0, 101, 6)'] + sorted(df['zn_type'].unique().tolist())
167
  selected_zn_type = st.selectbox("Zoning Type", all_zoning_types, key="zn_type_select")
168
 
169
+ # Lot Area in Sq Metres filter
170
+ # Use actual min/max from data for number input range
171
+ min_zn_area = st.number_input(
172
+ "Minimum Lot Area in Sq Metres",
173
+ min_value=float(df['zn_area'].min() if pd.notna(df['zn_area'].min()) else 0),
174
+ value=float(df['zn_area'].min() if pd.notna(df['zn_area'].min()) else 0),
175
+ step=100.0,
176
+ key="zn_area_input"
177
+ )
178
 
179
+ # Floor Space Index (FSI) filter - Synthetic data
180
  min_fsi_total = st.number_input("Minimum Floor Space Index (FSI)", min_value=0.0, value=0.0, step=0.1, format="%.2f", key="fsi_total_input")
181
 
182
  with col2:
183
+ # Building Percent Coverage filter - Synthetic data
184
  max_prcnt_cver = st.number_input("Maximum Building Percent Coverage (%)", min_value=0, value=100, step=1, key="prcnt_cver_input")
185
 
186
+ # Height or Stories selection - Synthetic data
187
  height_stories_option = st.radio(
188
  "Filter by",
189
  ("Height", "Stories"),
 
204
  if selected_zn_type != 'All Resdidential Zoning (0, 101, 6)':
205
  filtered_df = filtered_df[filtered_df['zn_type'] == selected_zn_type]
206
 
207
+ # Handle NaN values for zn_area before comparison by treating NaN as 0 for min comparison
208
+ filtered_df = filtered_df[filtered_df['zn_area'].fillna(0) >= min_zn_area]
209
 
210
  if min_fsi_total > 0:
211
  filtered_df = filtered_df[filtered_df['fsi_total'] >= min_fsi_total]
 
218
  elif height_stories_option == "Stories" and min_stories_value > 0:
219
  filtered_df = filtered_df[filtered_df['stories'] >= min_stories_value]
220
 
221
+ st.success(f"Applied attribute filters. Total properties after all filters: {len(filtered_df)}")
222
  else:
 
223
  st.info("Adjust filters and click 'Apply Attribute Filters'.")
224
 
225
 
226
  # --- 4. Display Filtered Data on a New Map and as a Table ---
227
+ st.subheader("Filtered Properties")
228
 
229
  if not filtered_df.empty:
230
+ # Create a new map to show only the filtered properties
 
 
231
  if len(filtered_df) > 0:
232
+ # Calculate bounds for filtered data to set appropriate zoom
233
+ min_lat, max_lat = filtered_df['latitude'].min(), filtered_df['latitude'].max()
234
+ min_lon, max_lon = filtered_df['longitude'].min(), filtered_df['longitude'].max()
235
+
236
+ # Adjust map center and zoom dynamically based on filtered data extent
237
+ if min_lat == max_lat and min_lon == max_lon:
238
+ filtered_map_center = [min_lat, min_lon]
239
+ filtered_map_zoom = 18 # Very close zoom for single point
240
+ else:
241
+ filtered_map_center = [filtered_df['latitude'].mean(), filtered_df['longitude'].mean()]
242
+ # Simple heuristic for zoom level based on spatial extent
243
+ lat_diff = max_lat - min_lat
244
+ lon_diff = max_lon - min_lon
245
+ if max(lat_diff, lon_diff) < 0.001: filtered_map_zoom = 18
246
+ elif max(lat_diff, lon_diff) < 0.01: filtered_map_zoom = 16
247
+ elif max(lat_diff, lon_diff) < 0.1: filtered_map_zoom = 14
248
+ else: filtered_map_zoom = 12
249
  else:
250
+ # Fallback to original map center if no data is filtered
251
  filtered_map_center = [df['latitude'].mean(), df['longitude'].mean()]
252
  filtered_map_zoom = 12
253
 
 
263
  fill_opacity=0.5
264
  ).add_to(filtered_m)
265
 
266
+ # Convert filtered_df back to GeoDataFrame for direct plotting of geometries
267
+ filtered_gdf = gpd.GeoDataFrame(filtered_df, geometry='geometry')
268
+
269
+ # Add filtered polygons to the map as GeoJSON layer
270
+ folium.GeoJson(
271
+ filtered_gdf.to_json(),
272
+ style_function=lambda x: {
273
+ 'fillColor': 'green',
274
+ 'color': 'darkgreen',
275
+ 'weight': 1,
276
+ 'fillOpacity': 0.7
277
+ },
278
+ tooltip=folium.GeoJsonTooltip(
279
+ fields=['PARCELID', 'zn_type', 'zn_area', 'fsi_total', 'prcnt_cver', 'height_metres', 'stories', 'ADDRESS_NUMBER', 'LINEAR_NAME_FULL'],
280
+ aliases=['Parcel ID:', 'Zoning Type:', 'Lot Area (m²):', 'FSI:', 'Coverage (%):', 'Height (m):', 'Stories:', 'Address Num:', 'Street:'],
281
+ localize=True
282
+ )
283
+ ).add_to(filtered_m)
284
 
285
  st_folium(filtered_m, width=1000, height=500)
286
 
287
+ st.subheader("Filtered Properties Table")
288
+ # Display relevant columns in the table
289
+ display_cols = ['PARCELID', 'zn_type', 'zn_area', 'fsi_total', 'prcnt_cver', 'height_metres', 'stories', 'ADDRESS_NUMBER', 'LINEAR_NAME_FULL']
290
+ st.dataframe(filtered_df[display_cols])
291
 
292
  # --- 5. Export Data Button ---
293
  csv = filtered_df.to_csv(index=False).encode('utf-8')
294
  st.download_button(
295
  label="Export Filtered Data to CSV",
296
  data=csv,
297
+ file_name="multiplex_coop_filtered_properties.csv",
298
  mime="text/csv",
299
  )
300
 
301
  else:
302
+ st.warning("No properties match the current filters. Try adjusting your criteria or drawing a different polygon.")
303
 
304
  st.markdown("---")
305
+ st.markdown("This app demonstrates spatial and attribute filtering on the ProjectMultiplexCoop/PropertyBoundaries dataset from Hugging Face. FSI, Building Coverage, Height, and Stories are synthetic for demonstration.")