voxcity 0.6.26__py3-none-any.whl → 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. voxcity/__init__.py +10 -4
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/gba.py +210 -0
  4. voxcity/downloader/gee.py +5 -1
  5. voxcity/downloader/mbfp.py +1 -1
  6. voxcity/downloader/oemj.py +80 -8
  7. voxcity/downloader/utils.py +73 -73
  8. voxcity/errors.py +30 -0
  9. voxcity/exporter/__init__.py +9 -1
  10. voxcity/exporter/cityles.py +129 -34
  11. voxcity/exporter/envimet.py +51 -26
  12. voxcity/exporter/magicavoxel.py +42 -5
  13. voxcity/exporter/netcdf.py +27 -0
  14. voxcity/exporter/obj.py +103 -28
  15. voxcity/generator/__init__.py +47 -0
  16. voxcity/generator/api.py +721 -0
  17. voxcity/generator/grids.py +381 -0
  18. voxcity/generator/io.py +94 -0
  19. voxcity/generator/pipeline.py +282 -0
  20. voxcity/generator/update.py +429 -0
  21. voxcity/generator/voxelizer.py +392 -0
  22. voxcity/geoprocessor/__init__.py +75 -6
  23. voxcity/geoprocessor/conversion.py +153 -0
  24. voxcity/geoprocessor/draw.py +1488 -1169
  25. voxcity/geoprocessor/heights.py +199 -0
  26. voxcity/geoprocessor/io.py +101 -0
  27. voxcity/geoprocessor/merge_utils.py +91 -0
  28. voxcity/geoprocessor/mesh.py +26 -10
  29. voxcity/geoprocessor/network.py +35 -6
  30. voxcity/geoprocessor/overlap.py +84 -0
  31. voxcity/geoprocessor/raster/__init__.py +82 -0
  32. voxcity/geoprocessor/raster/buildings.py +435 -0
  33. voxcity/geoprocessor/raster/canopy.py +258 -0
  34. voxcity/geoprocessor/raster/core.py +150 -0
  35. voxcity/geoprocessor/raster/export.py +93 -0
  36. voxcity/geoprocessor/raster/landcover.py +159 -0
  37. voxcity/geoprocessor/raster/raster.py +110 -0
  38. voxcity/geoprocessor/selection.py +85 -0
  39. voxcity/geoprocessor/utils.py +824 -820
  40. voxcity/models.py +113 -0
  41. voxcity/simulator/common/__init__.py +22 -0
  42. voxcity/simulator/common/geometry.py +98 -0
  43. voxcity/simulator/common/raytracing.py +450 -0
  44. voxcity/simulator/solar/__init__.py +66 -0
  45. voxcity/simulator/solar/integration.py +336 -0
  46. voxcity/simulator/solar/kernels.py +62 -0
  47. voxcity/simulator/solar/radiation.py +648 -0
  48. voxcity/simulator/solar/sky.py +668 -0
  49. voxcity/simulator/solar/temporal.py +792 -0
  50. voxcity/simulator/view.py +36 -2286
  51. voxcity/simulator/visibility/__init__.py +29 -0
  52. voxcity/simulator/visibility/landmark.py +392 -0
  53. voxcity/simulator/visibility/view.py +508 -0
  54. voxcity/utils/__init__.py +11 -0
  55. voxcity/utils/classes.py +194 -0
  56. voxcity/utils/lc.py +80 -39
  57. voxcity/utils/logging.py +61 -0
  58. voxcity/utils/orientation.py +51 -0
  59. voxcity/utils/shape.py +230 -0
  60. voxcity/utils/weather/__init__.py +26 -0
  61. voxcity/utils/weather/epw.py +146 -0
  62. voxcity/utils/weather/files.py +36 -0
  63. voxcity/utils/weather/onebuilding.py +486 -0
  64. voxcity/visualizer/__init__.py +24 -0
  65. voxcity/visualizer/builder.py +43 -0
  66. voxcity/visualizer/grids.py +141 -0
  67. voxcity/visualizer/maps.py +187 -0
  68. voxcity/visualizer/palette.py +228 -0
  69. voxcity/visualizer/renderer.py +1145 -0
  70. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/METADATA +162 -48
  71. voxcity-1.0.2.dist-info/RECORD +81 -0
  72. voxcity/generator.py +0 -1302
  73. voxcity/geoprocessor/grid.py +0 -1739
  74. voxcity/geoprocessor/polygon.py +0 -1344
  75. voxcity/simulator/solar.py +0 -2339
  76. voxcity/utils/visualization.py +0 -2849
  77. voxcity/utils/weather.py +0 -1038
  78. voxcity-0.6.26.dist-info/RECORD +0 -38
  79. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/WHEEL +0 -0
  80. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/AUTHORS.rst +0 -0
  81. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,1170 +1,1489 @@
1
- """
2
- This module provides functions for drawing and manipulating rectangles and polygons on interactive maps.
3
- It serves as a core component for defining geographical regions of interest in the VoxCity library.
4
-
5
- Key Features:
6
- - Interactive rectangle drawing on maps using ipyleaflet
7
- - Rectangle rotation with coordinate system transformations
8
- - City-centered map initialization
9
- - Fixed-dimension rectangle creation from center points
10
- - Building footprint visualization and polygon drawing
11
- - Support for both WGS84 and Web Mercator projections
12
- - Coordinate format handling between (lon,lat) and (lat,lon)
13
-
14
- The module maintains consistent coordinate order conventions:
15
- - Internal storage: (lon,lat) format to match GeoJSON standard
16
- - ipyleaflet interface: (lat,lon) format as required by the library
17
- - All return values: (lon,lat) format for consistency
18
-
19
- Dependencies:
20
- - ipyleaflet: For interactive map display and drawing controls
21
- - pyproj: For coordinate system transformations
22
- - geopy: For distance calculations
23
- - shapely: For geometric operations
24
- """
25
-
26
- import math
27
- from pyproj import Proj, transform
28
- from ipyleaflet import (
29
- Map,
30
- DrawControl,
31
- Rectangle,
32
- Polygon as LeafletPolygon,
33
- WidgetControl,
34
- Circle,
35
- basemaps,
36
- basemap_to_tiles,
37
- TileLayer
38
- )
39
- from geopy import distance
40
- import shapely.geometry as geom
41
- import geopandas as gpd
42
- from ipywidgets import VBox, HBox, Button, FloatText, Label, Output, HTML, Checkbox
43
- import pandas as pd
44
- from IPython.display import display, clear_output
45
-
46
- from .utils import get_coordinates_from_cityname
47
-
48
- def rotate_rectangle(m, rectangle_vertices, angle):
49
- """
50
- Project rectangle to Mercator, rotate, and re-project to lat-lon coordinates.
51
-
52
- This function performs a rotation of a rectangle in geographic space by:
53
- 1. Converting coordinates from WGS84 (lat/lon) to Web Mercator projection
54
- 2. Performing the rotation in the projected space for accurate distance preservation
55
- 3. Converting back to WGS84 coordinates
56
- 4. Visualizing the result on the provided map
57
-
58
- The rotation is performed around the rectangle's centroid using a standard 2D rotation matrix.
59
- The function handles coordinate system transformations to ensure geometrically accurate rotations
60
- despite the distortions inherent in geographic projections.
61
-
62
- Args:
63
- m (ipyleaflet.Map): Map object to draw the rotated rectangle on.
64
- The map must be initialized and have a valid center and zoom level.
65
- rectangle_vertices (list): List of (lon, lat) tuples defining the rectangle vertices.
66
- The vertices should be ordered in a counter-clockwise direction.
67
- Example: [(lon1,lat1), (lon2,lat2), (lon3,lat3), (lon4,lat4)]
68
- angle (float): Rotation angle in degrees.
69
- Positive angles rotate counter-clockwise.
70
- Negative angles rotate clockwise.
71
-
72
- Returns:
73
- list: List of rotated (lon, lat) tuples defining the new rectangle vertices.
74
- The vertices maintain their original ordering.
75
- Returns None if no rectangle vertices are provided.
76
-
77
- Note:
78
- The function uses EPSG:4326 (WGS84) for geographic coordinates and
79
- EPSG:3857 (Web Mercator) for the rotation calculations.
80
- """
81
- if not rectangle_vertices:
82
- print("Draw a rectangle first!")
83
- return
84
-
85
- # Define projections - need to convert between coordinate systems for accurate rotation
86
- wgs84 = Proj(init='epsg:4326') # WGS84 lat-lon (standard GPS coordinates)
87
- mercator = Proj(init='epsg:3857') # Web Mercator (projection used by most web maps)
88
-
89
- # Project vertices from WGS84 to Web Mercator for proper distance calculations
90
- projected_vertices = [transform(wgs84, mercator, lon, lat) for lon, lat in rectangle_vertices]
91
-
92
- # Calculate the centroid to use as rotation center
93
- centroid_x = sum(x for x, y in projected_vertices) / len(projected_vertices)
94
- centroid_y = sum(y for x, y in projected_vertices) / len(projected_vertices)
95
-
96
- # Convert angle to radians (negative for clockwise rotation)
97
- angle_rad = -math.radians(angle)
98
-
99
- # Rotate each vertex around the centroid using standard 2D rotation matrix
100
- rotated_vertices = []
101
- for x, y in projected_vertices:
102
- # Translate point to origin for rotation
103
- temp_x = x - centroid_x
104
- temp_y = y - centroid_y
105
-
106
- # Apply rotation matrix
107
- rotated_x = temp_x * math.cos(angle_rad) - temp_y * math.sin(angle_rad)
108
- rotated_y = temp_x * math.sin(angle_rad) + temp_y * math.cos(angle_rad)
109
-
110
- # Translate point back to original position
111
- new_x = rotated_x + centroid_x
112
- new_y = rotated_y + centroid_y
113
-
114
- rotated_vertices.append((new_x, new_y))
115
-
116
- # Convert coordinates back to WGS84 (lon/lat)
117
- new_vertices = [transform(mercator, wgs84, x, y) for x, y in rotated_vertices]
118
-
119
- # Create and add new polygon layer to map
120
- polygon = LeafletPolygon(
121
- locations=[(lat, lon) for lon, lat in new_vertices], # Convert to (lat,lon) for ipyleaflet
122
- color="red",
123
- fill_color="red"
124
- )
125
- m.add_layer(polygon)
126
-
127
- return new_vertices
128
-
129
- def draw_rectangle_map(center=(40, -100), zoom=4):
130
- """
131
- Create an interactive map for drawing rectangles with ipyleaflet.
132
-
133
- This function initializes an interactive map that allows users to draw rectangles
134
- by clicking and dragging on the map surface. The drawn rectangles are captured
135
- and their vertices are stored in geographic coordinates.
136
-
137
- The map interface provides:
138
- - A rectangle drawing tool activated by default
139
- - Real-time coordinate capture of drawn shapes
140
- - Automatic vertex ordering in counter-clockwise direction
141
- - Console output of vertex coordinates for verification
142
-
143
- Drawing Controls:
144
- - Click and drag to draw a rectangle
145
- - Release to complete the rectangle
146
- - Only one rectangle can be active at a time
147
- - Drawing a new rectangle clears the previous one
148
-
149
- Args:
150
- center (tuple): Center coordinates (lat, lon) for the map view.
151
- Defaults to (40, -100) which centers on the continental United States.
152
- Format: (latitude, longitude) in decimal degrees.
153
- zoom (int): Initial zoom level for the map. Defaults to 4.
154
- Range: 0 (most zoomed out) to 18 (most zoomed in).
155
- Recommended: 3-6 for countries, 10-15 for cities.
156
-
157
- Returns:
158
- tuple: (Map object, list of rectangle vertices)
159
- - Map object: ipyleaflet.Map instance for displaying and interacting with the map
160
- - rectangle_vertices: Empty list that will be populated with (lon,lat) tuples
161
- when a rectangle is drawn. Coordinates are stored in GeoJSON order (lon,lat).
162
-
163
- Note:
164
- The function disables all drawing tools except rectangles to ensure
165
- consistent shape creation. The rectangle vertices are automatically
166
- converted to (lon,lat) format when stored, regardless of the input
167
- center coordinate order.
168
- """
169
- # Initialize the map centered at specified coordinates
170
- m = Map(center=center, zoom=zoom)
171
-
172
- # List to store the vertices of drawn rectangle
173
- rectangle_vertices = []
174
-
175
- def handle_draw(target, action, geo_json):
176
- """Handle draw events on the map."""
177
- # Clear any previously stored vertices
178
- rectangle_vertices.clear()
179
-
180
- # Process only if a rectangle polygon was drawn
181
- if action == 'created' and geo_json['geometry']['type'] == 'Polygon':
182
- # Extract coordinates from GeoJSON format
183
- coordinates = geo_json['geometry']['coordinates'][0]
184
- print("Vertices of the drawn rectangle:")
185
- # Store all vertices except last (GeoJSON repeats first vertex at end)
186
- for coord in coordinates[:-1]:
187
- # Keep GeoJSON (lon,lat) format
188
- rectangle_vertices.append((coord[0], coord[1]))
189
- print(f"Longitude: {coord[0]}, Latitude: {coord[1]}")
190
-
191
- # Configure drawing controls - only enable rectangle drawing
192
- draw_control = DrawControl()
193
- draw_control.polyline = {}
194
- draw_control.polygon = {}
195
- draw_control.circle = {}
196
- draw_control.rectangle = {
197
- "shapeOptions": {
198
- "color": "#6bc2e5",
199
- "weight": 4,
200
- }
201
- }
202
- m.add_control(draw_control)
203
-
204
- # Register event handler for drawing actions
205
- draw_control.on_draw(handle_draw)
206
-
207
- return m, rectangle_vertices
208
-
209
- def draw_rectangle_map_cityname(cityname, zoom=15):
210
- """
211
- Create an interactive map centered on a specified city for drawing rectangles.
212
-
213
- This function extends draw_rectangle_map() by automatically centering the map
214
- on a specified city using geocoding. It provides a convenient way to focus
215
- the drawing interface on a particular urban area without needing to know
216
- its exact coordinates.
217
-
218
- The function uses the utils.get_coordinates_from_cityname() function to
219
- geocode the city name and obtain its coordinates. The resulting map is
220
- zoomed to an appropriate level for urban-scale analysis.
221
-
222
- Args:
223
- cityname (str): Name of the city to center the map on.
224
- Can include country or state for better accuracy.
225
- Examples: "Tokyo, Japan", "New York, NY", "Paris, France"
226
- zoom (int): Initial zoom level for the map. Defaults to 15.
227
- Range: 0 (most zoomed out) to 18 (most zoomed in).
228
- Default of 15 is optimized for city-level visualization.
229
-
230
- Returns:
231
- tuple: (Map object, list of rectangle vertices)
232
- - Map object: ipyleaflet.Map instance centered on the specified city
233
- - rectangle_vertices: Empty list that will be populated with (lon,lat)
234
- tuples when a rectangle is drawn
235
-
236
- Note:
237
- If the city name cannot be geocoded, the function will raise an error.
238
- For better results, provide specific city names with country/state context.
239
- The function inherits all drawing controls and behavior from draw_rectangle_map().
240
- """
241
- # Get coordinates for the specified city
242
- center = get_coordinates_from_cityname(cityname)
243
- m, rectangle_vertices = draw_rectangle_map(center=center, zoom=zoom)
244
- return m, rectangle_vertices
245
-
246
- def center_location_map_cityname(cityname, east_west_length, north_south_length, zoom=15):
247
- """
248
- Create an interactive map centered on a city where clicking creates a rectangle of specified dimensions.
249
-
250
- This function provides a specialized interface for creating fixed-size rectangles
251
- centered on user-selected points. Instead of drawing rectangles by dragging,
252
- users click a point on the map and a rectangle of the specified dimensions
253
- is automatically created centered on that point.
254
-
255
- The function handles:
256
- - Automatic city geocoding and map centering
257
- - Distance calculations in meters using geopy
258
- - Conversion between geographic and metric distances
259
- - Rectangle creation with specified dimensions
260
- - Visualization of created rectangles
261
-
262
- Workflow:
263
- 1. Map is centered on the specified city
264
- 2. User clicks a point on the map
265
- 3. A rectangle is created centered on that point
266
- 4. Rectangle dimensions are maintained in meters regardless of latitude
267
- 5. Previous rectangles are automatically cleared
268
-
269
- Args:
270
- cityname (str): Name of the city to center the map on.
271
- Can include country or state for better accuracy.
272
- Examples: "Tokyo, Japan", "New York, NY"
273
- east_west_length (float): Width of the rectangle in meters.
274
- This is the dimension along the east-west direction.
275
- The actual ground distance is maintained regardless of projection distortion.
276
- north_south_length (float): Height of the rectangle in meters.
277
- This is the dimension along the north-south direction.
278
- The actual ground distance is maintained regardless of projection distortion.
279
- zoom (int): Initial zoom level for the map. Defaults to 15.
280
- Range: 0 (most zoomed out) to 18 (most zoomed in).
281
- Default of 15 is optimized for city-level visualization.
282
-
283
- Returns:
284
- tuple: (Map object, list of rectangle vertices)
285
- - Map object: ipyleaflet.Map instance centered on the specified city
286
- - rectangle_vertices: Empty list that will be populated with (lon,lat)
287
- tuples when a point is clicked and the rectangle is created
288
-
289
- Note:
290
- - Rectangle dimensions are specified in meters but stored as geographic coordinates
291
- - The function uses geopy's distance calculations for accurate metric distances
292
- - Only one rectangle can exist at a time; clicking a new point removes the previous rectangle
293
- - Rectangle vertices are returned in GeoJSON (lon,lat) order
294
- """
295
-
296
- # Get coordinates for the specified city
297
- center = get_coordinates_from_cityname(cityname)
298
-
299
- # Initialize map centered on the city
300
- m = Map(center=center, zoom=zoom)
301
-
302
- # List to store rectangle vertices
303
- rectangle_vertices = []
304
-
305
- def handle_draw(target, action, geo_json):
306
- """Handle draw events on the map."""
307
- # Clear previous vertices and remove any existing rectangles
308
- rectangle_vertices.clear()
309
- for layer in m.layers:
310
- if isinstance(layer, Rectangle):
311
- m.remove_layer(layer)
312
-
313
- # Process only if a point was drawn on the map
314
- if action == 'created' and geo_json['geometry']['type'] == 'Point':
315
- # Extract point coordinates from GeoJSON (lon,lat)
316
- lon, lat = geo_json['geometry']['coordinates'][0], geo_json['geometry']['coordinates'][1]
317
- print(f"Point drawn at Longitude: {lon}, Latitude: {lat}")
318
-
319
- # Calculate corner points using geopy's distance calculator
320
- # Each point is calculated as a destination from center point using bearing
321
- north = distance.distance(meters=north_south_length/2).destination((lat, lon), bearing=0)
322
- south = distance.distance(meters=north_south_length/2).destination((lat, lon), bearing=180)
323
- east = distance.distance(meters=east_west_length/2).destination((lat, lon), bearing=90)
324
- west = distance.distance(meters=east_west_length/2).destination((lat, lon), bearing=270)
325
-
326
- # Create rectangle vertices in counter-clockwise order (lon,lat)
327
- rectangle_vertices.extend([
328
- (west.longitude, south.latitude),
329
- (west.longitude, north.latitude),
330
- (east.longitude, north.latitude),
331
- (east.longitude, south.latitude)
332
- ])
333
-
334
- # Create and add new rectangle to map (ipyleaflet expects lat,lon)
335
- rectangle = Rectangle(
336
- bounds=[(north.latitude, west.longitude), (south.latitude, east.longitude)],
337
- color="red",
338
- fill_color="red",
339
- fill_opacity=0.2
340
- )
341
- m.add_layer(rectangle)
342
-
343
- print("Rectangle vertices:")
344
- for vertex in rectangle_vertices:
345
- print(f"Longitude: {vertex[0]}, Latitude: {vertex[1]}")
346
-
347
- # Configure drawing controls - only enable point drawing
348
- draw_control = DrawControl()
349
- draw_control.polyline = {}
350
- draw_control.polygon = {}
351
- draw_control.circle = {}
352
- draw_control.rectangle = {}
353
- draw_control.marker = {}
354
- m.add_control(draw_control)
355
-
356
- # Register event handler for drawing actions
357
- draw_control.on_draw(handle_draw)
358
-
359
- return m, rectangle_vertices
360
-
361
- def display_buildings_and_draw_polygon(building_gdf=None, rectangle_vertices=None, zoom=17):
362
- """
363
- Displays building footprints and enables polygon drawing on an interactive map.
364
-
365
- This function creates an interactive map that visualizes building footprints and
366
- allows users to draw arbitrary polygons. It's particularly useful for selecting
367
- specific buildings or areas within an urban context.
368
-
369
- The function provides three key features:
370
- 1. Building Footprint Visualization:
371
- - Displays building polygons from a GeoDataFrame
372
- - Uses consistent styling for all buildings
373
- - Handles simple polygon geometries only
374
-
375
- 2. Interactive Polygon Drawing:
376
- - Enables free-form polygon drawing
377
- - Captures vertices in consistent (lon,lat) format
378
- - Maintains GeoJSON compatibility
379
- - Supports multiple polygons with unique IDs and colors
380
-
381
- 3. Map Initialization:
382
- - Automatic centering based on input data
383
- - Fallback to default location if no data provided
384
- - Support for both building data and rectangle bounds
385
-
386
- Args:
387
- building_gdf (GeoDataFrame, optional): A GeoDataFrame containing building footprints.
388
- Must have geometry column with Polygon type features.
389
- Geometries should be in [lon, lat] coordinate order.
390
- If None, only the base map is displayed.
391
- rectangle_vertices (list, optional): List of [lon, lat] coordinates defining rectangle corners.
392
- Used to set the initial map view extent.
393
- Takes precedence over building_gdf for determining map center.
394
- zoom (int): Initial zoom level for the map. Default=17.
395
- Range: 0 (most zoomed out) to 18 (most zoomed in).
396
- Default of 17 is optimized for building-level detail.
397
-
398
- Returns:
399
- tuple: (map_object, drawn_polygons)
400
- - map_object: ipyleaflet Map instance with building footprints and drawing controls
401
- - drawn_polygons: List of dictionaries with 'id', 'vertices', and 'color' keys for all drawn polygons.
402
- Each polygon has a unique ID and color for easy identification.
403
-
404
- Note:
405
- - Building footprints are displayed in blue with 20% opacity
406
- - Only simple Polygon geometries are supported (no MultiPolygons)
407
- - Drawing tools are restricted to polygon creation only
408
- - All coordinates are handled in (lon,lat) order internally
409
- - The function automatically determines appropriate map bounds
410
- - Each polygon gets a unique ID and different colors for easy identification
411
- - Use get_polygon_vertices() helper function to extract specific polygon data
412
- """
413
- # ---------------------------------------------------------
414
- # 1. Determine a suitable map center via bounding box logic
415
- # ---------------------------------------------------------
416
- if rectangle_vertices is not None:
417
- # Get bounds from rectangle vertices
418
- lons = [v[0] for v in rectangle_vertices]
419
- lats = [v[1] for v in rectangle_vertices]
420
- min_lon, max_lon = min(lons), max(lons)
421
- min_lat, max_lat = min(lats), max(lats)
422
- center_lon = (min_lon + max_lon) / 2
423
- center_lat = (min_lat + max_lat) / 2
424
- elif building_gdf is not None and len(building_gdf) > 0:
425
- # Get bounds from GeoDataFrame
426
- bounds = building_gdf.total_bounds # Returns [minx, miny, maxx, maxy]
427
- min_lon, min_lat, max_lon, max_lat = bounds
428
- center_lon = (min_lon + max_lon) / 2
429
- center_lat = (min_lat + max_lat) / 2
430
- else:
431
- # Fallback: If no inputs or invalid data, pick a default
432
- center_lon, center_lat = -100.0, 40.0
433
-
434
- # Create the ipyleaflet map (needs lat,lon)
435
- m = Map(center=(center_lat, center_lon), zoom=zoom, scroll_wheel_zoom=True)
436
-
437
- # -----------------------------------------
438
- # 2. Add building footprints to the map if provided
439
- # -----------------------------------------
440
- if building_gdf is not None:
441
- for idx, row in building_gdf.iterrows():
442
- # Only handle simple Polygons
443
- if isinstance(row.geometry, geom.Polygon):
444
- # Get coordinates from geometry
445
- coords = list(row.geometry.exterior.coords)
446
- # Convert to (lat,lon) for ipyleaflet, skip last repeated coordinate
447
- lat_lon_coords = [(c[1], c[0]) for c in coords[:-1]]
448
-
449
- # Create the polygon layer
450
- bldg_layer = LeafletPolygon(
451
- locations=lat_lon_coords,
452
- color="blue",
453
- fill_color="blue",
454
- fill_opacity=0.2,
455
- weight=2
456
- )
457
- m.add_layer(bldg_layer)
458
-
459
- # -----------------------------------------------------------------
460
- # 3. Enable drawing of polygons, capturing the vertices in Lon-Lat
461
- # -----------------------------------------------------------------
462
- # Store multiple polygons with IDs and colors
463
- drawn_polygons = [] # List of dicts with 'id', 'vertices', 'color' keys
464
- polygon_counter = 0
465
- polygon_colors = ['red', 'blue', 'green', 'orange', 'purple', 'brown', 'pink', 'gray', 'olive', 'cyan']
466
-
467
- draw_control = DrawControl(
468
- polygon={
469
- "shapeOptions": {
470
- "color": "red",
471
- "fillColor": "red",
472
- "fillOpacity": 0.2
473
- }
474
- },
475
- rectangle={}, # Disable rectangles (or enable if needed)
476
- circle={}, # Disable circles
477
- circlemarker={}, # Disable circlemarkers
478
- polyline={}, # Disable polylines
479
- marker={} # Disable markers
480
- )
481
-
482
- def handle_draw(self, action, geo_json):
483
- """
484
- Callback for whenever a shape is created or edited.
485
- ipyleaflet's DrawControl returns standard GeoJSON (lon, lat).
486
- We'll keep them as (lon, lat).
487
- """
488
- if action == 'created' and geo_json['geometry']['type'] == 'Polygon':
489
- nonlocal polygon_counter
490
- polygon_counter += 1
491
-
492
- # The polygon's first ring
493
- coordinates = geo_json['geometry']['coordinates'][0]
494
- vertices = [(coord[0], coord[1]) for coord in coordinates[:-1]]
495
-
496
- # Assign color (cycle through colors)
497
- color = polygon_colors[polygon_counter % len(polygon_colors)]
498
-
499
- # Store polygon data
500
- polygon_data = {
501
- 'id': polygon_counter,
502
- 'vertices': vertices,
503
- 'color': color
504
- }
505
- drawn_polygons.append(polygon_data)
506
-
507
- print(f"Polygon {polygon_counter} drawn with {len(vertices)} vertices (color: {color}):")
508
- for i, (lon, lat) in enumerate(vertices):
509
- print(f" Vertex {i+1}: (lon, lat) = ({lon}, {lat})")
510
- print(f"Total polygons: {len(drawn_polygons)}")
511
-
512
- draw_control.on_draw(handle_draw)
513
- m.add_control(draw_control)
514
-
515
- return m, drawn_polygons
516
-
517
- def draw_additional_buildings(building_gdf=None, initial_center=None, zoom=17, rectangle_vertices=None):
518
- """
519
- Creates an interactive map for drawing building footprints with height input.
520
-
521
- This function provides an interface for users to:
522
- 1. Draw building footprints on an interactive map
523
- 2. Set building height values through a UI widget
524
- 3. Add new buildings to the existing building_gdf
525
-
526
- The workflow is:
527
- - User draws a polygon on the map
528
- - Height input widget appears
529
- - User enters height and clicks "Add Building"
530
- - Building is added to GeoDataFrame and displayed on map
531
-
532
- Args:
533
- building_gdf (GeoDataFrame, optional): Existing building footprints to display.
534
- If None, creates a new empty GeoDataFrame.
535
- Expected columns: ['id', 'height', 'min_height', 'geometry', 'building_id']
536
- - 'id': Integer ID from data sources (e.g., OSM building id)
537
- - 'height': Building height in meters (set by user input)
538
- - 'min_height': Minimum height in meters (defaults to 0.0)
539
- - 'geometry': Building footprint polygon
540
- - 'building_id': Unique building identifier
541
- initial_center (tuple, optional): Initial map center as (lon, lat).
542
- If None, centers on existing buildings or defaults to (-100, 40).
543
- zoom (int): Initial zoom level (default=17).
544
-
545
- Returns:
546
- tuple: (map_object, updated_building_gdf)
547
- - map_object: ipyleaflet Map instance with drawing controls
548
- - updated_building_gdf: GeoDataFrame that automatically updates when buildings are added
549
-
550
- Example:
551
- >>> # Start with empty buildings
552
- >>> m, buildings = draw_additional_buildings()
553
- >>> # Draw buildings on the map...
554
- >>> print(buildings) # Will contain all drawn buildings
555
- """
556
-
557
- # Initialize or copy the building GeoDataFrame
558
- if building_gdf is None:
559
- # Create empty GeoDataFrame with required columns
560
- updated_gdf = gpd.GeoDataFrame(
561
- columns=['id', 'height', 'min_height', 'geometry', 'building_id'],
562
- crs='EPSG:4326'
563
- )
564
- else:
565
- # Make a copy to avoid modifying the original
566
- updated_gdf = building_gdf.copy()
567
- # Ensure all required columns exist
568
- if 'height' not in updated_gdf.columns:
569
- updated_gdf['height'] = 10.0 # Default height
570
- if 'min_height' not in updated_gdf.columns:
571
- updated_gdf['min_height'] = 0.0 # Default min_height
572
- if 'building_id' not in updated_gdf.columns:
573
- updated_gdf['building_id'] = range(len(updated_gdf))
574
- if 'id' not in updated_gdf.columns:
575
- updated_gdf['id'] = range(len(updated_gdf))
576
-
577
- # Determine map center
578
- if initial_center is not None:
579
- center_lon, center_lat = initial_center
580
- elif updated_gdf is not None and len(updated_gdf) > 0:
581
- bounds = updated_gdf.total_bounds
582
- min_lon, min_lat, max_lon, max_lat = bounds
583
- center_lon = (min_lon + max_lon) / 2
584
- center_lat = (min_lat + max_lat) / 2
585
- elif rectangle_vertices is not None:
586
- center_lon, center_lat = (rectangle_vertices[0][0] + rectangle_vertices[2][0]) / 2, (rectangle_vertices[0][1] + rectangle_vertices[2][1]) / 2
587
- else:
588
- center_lon, center_lat = -100.0, 40.0
589
-
590
- # Create the map
591
- m = Map(center=(center_lat, center_lon), zoom=zoom, scroll_wheel_zoom=True)
592
-
593
- # Display existing buildings
594
- building_layers = {}
595
- for idx, row in updated_gdf.iterrows():
596
- if isinstance(row.geometry, geom.Polygon):
597
- coords = list(row.geometry.exterior.coords)
598
- lat_lon_coords = [(c[1], c[0]) for c in coords[:-1]]
599
-
600
- height = row.get('height', 10.0)
601
- min_height = row.get('min_height', 0.0)
602
- building_id = row.get('building_id', idx)
603
- bldg_id = row.get('id', idx)
604
- bldg_layer = LeafletPolygon(
605
- locations=lat_lon_coords,
606
- color="blue",
607
- fill_color="blue",
608
- fill_opacity=0.3,
609
- weight=2,
610
- popup=HTML(f"<b>Building ID:</b> {building_id}<br>"
611
- f"<b>ID:</b> {bldg_id}<br>"
612
- f"<b>Height:</b> {height}m<br>"
613
- f"<b>Min Height:</b> {min_height}m")
614
- )
615
- m.add_layer(bldg_layer)
616
- building_layers[idx] = bldg_layer
617
-
618
- # Create UI widgets
619
- height_input = FloatText(
620
- value=10.0,
621
- description='Height (m):',
622
- disabled=False,
623
- style={'description_width': 'initial'}
624
- )
625
-
626
- add_button = Button(
627
- description='Add Building',
628
- button_style='success',
629
- disabled=True
630
- )
631
-
632
- clear_button = Button(
633
- description='Clear Drawing',
634
- button_style='warning',
635
- disabled=True
636
- )
637
-
638
- status_output = Output()
639
- hover_info = HTML("")
640
-
641
- # Create control panel
642
- control_panel = VBox([
643
- HTML("<h3>Draw Building Tool</h3>"),
644
- HTML("<p>1. Draw a polygon on the map<br>2. Set height<br>3. Click 'Add Building'</p>"),
645
- height_input,
646
- HBox([add_button, clear_button]),
647
- status_output
648
- ])
649
-
650
- # Add control panel to map
651
- widget_control = WidgetControl(widget=control_panel, position='topright')
652
- m.add_control(widget_control)
653
-
654
- # Store the current drawn polygon
655
- current_polygon = {'vertices': [], 'layer': None}
656
-
657
- # Drawing control
658
- draw_control = DrawControl(
659
- polygon={
660
- "shapeOptions": {
661
- "color": "red",
662
- "fillColor": "red",
663
- "fillOpacity": 0.3,
664
- "weight": 3
665
- }
666
- },
667
- rectangle={},
668
- circle={},
669
- circlemarker={},
670
- polyline={},
671
- marker={}
672
- )
673
-
674
- def handle_draw(self, action, geo_json):
675
- """Handle polygon drawing events"""
676
- with status_output:
677
- clear_output()
678
-
679
- if action == 'created' and geo_json['geometry']['type'] == 'Polygon':
680
- # Store vertices
681
- coordinates = geo_json['geometry']['coordinates'][0]
682
- current_polygon['vertices'] = [(coord[0], coord[1]) for coord in coordinates[:-1]]
683
-
684
- # Enable buttons
685
- add_button.disabled = False
686
- clear_button.disabled = False
687
-
688
- with status_output:
689
- print(f"Polygon drawn with {len(current_polygon['vertices'])} vertices")
690
- print("Set height and click 'Add Building'")
691
-
692
- def add_building_click(b):
693
- """Handle add building button click"""
694
- # Use nonlocal to modify the outer scope variable
695
- nonlocal updated_gdf
696
-
697
- with status_output:
698
- clear_output()
699
-
700
- if current_polygon['vertices']:
701
- # Create polygon geometry
702
- polygon = geom.Polygon(current_polygon['vertices'])
703
-
704
- # Get next building ID and ID values (ensure uniqueness)
705
- if len(updated_gdf) > 0:
706
- next_building_id = int(updated_gdf['building_id'].max() + 1)
707
- next_id = int(updated_gdf['id'].max() + 1)
708
- else:
709
- next_building_id = 1
710
- next_id = 1
711
-
712
- # Create new row data
713
- new_row_data = {
714
- 'geometry': polygon,
715
- 'height': float(height_input.value),
716
- 'min_height': 0.0, # Default value as requested
717
- 'building_id': next_building_id,
718
- 'id': next_id
719
- }
720
-
721
- # Add any additional columns
722
- for col in updated_gdf.columns:
723
- if col not in new_row_data:
724
- new_row_data[col] = None
725
-
726
- # Append the new building in-place
727
- new_index = len(updated_gdf)
728
- updated_gdf.loc[new_index] = new_row_data
729
-
730
- # Add to map
731
- coords = list(polygon.exterior.coords)
732
- lat_lon_coords = [(c[1], c[0]) for c in coords[:-1]]
733
-
734
- new_layer = LeafletPolygon(
735
- locations=lat_lon_coords,
736
- color="blue",
737
- fill_color="blue",
738
- fill_opacity=0.3,
739
- weight=2,
740
- popup=HTML(f"<b>Building ID:</b> {next_building_id}<br>"
741
- f"<b>ID:</b> {next_id}<br>"
742
- f"<b>Height:</b> {height_input.value}m<br>"
743
- f"<b>Min Height:</b> 0.0m")
744
- )
745
- m.add_layer(new_layer)
746
-
747
- # Clear drawing
748
- draw_control.clear()
749
- current_polygon['vertices'] = []
750
- add_button.disabled = True
751
- clear_button.disabled = True
752
-
753
- print(f"Building {next_building_id} added successfully!")
754
- print(f"ID: {next_id}, Height: {height_input.value}m, Min Height: 0.0m")
755
- print(f"Total buildings: {len(updated_gdf)}")
756
-
757
- def clear_drawing_click(b):
758
- """Handle clear drawing button click"""
759
- with status_output:
760
- clear_output()
761
- draw_control.clear()
762
- current_polygon['vertices'] = []
763
- add_button.disabled = True
764
- clear_button.disabled = True
765
- print("Drawing cleared")
766
-
767
- # Connect event handlers
768
- draw_control.on_draw(handle_draw)
769
- add_button.on_click(add_building_click)
770
- clear_button.on_click(clear_drawing_click)
771
-
772
- # Add draw control to map
773
- m.add_control(draw_control)
774
-
775
- # Display initial status
776
- with status_output:
777
- print(f"Total buildings loaded: {len(updated_gdf)}")
778
- print("Draw a polygon to add a new building")
779
-
780
- return m, updated_gdf
781
-
782
-
783
- def get_polygon_vertices(drawn_polygons, polygon_id=None):
784
- """
785
- Extract vertices from drawn polygons data structure.
786
-
787
- This helper function provides a convenient way to extract polygon vertices
788
- from the drawn_polygons list returned by display_buildings_and_draw_polygon().
789
-
790
- Args:
791
- drawn_polygons: The drawn_polygons list returned from display_buildings_and_draw_polygon()
792
- polygon_id (int, optional): Specific polygon ID to extract. If None, returns all polygons.
793
-
794
- Returns:
795
- If polygon_id is specified: List of (lon, lat) tuples for that polygon
796
- If polygon_id is None: List of lists, where each inner list contains (lon, lat) tuples
797
-
798
- Example:
799
- >>> m, polygons = display_buildings_and_draw_polygon()
800
- >>> # Draw some polygons...
801
- >>> vertices = get_polygon_vertices(polygons, polygon_id=1) # Get polygon 1
802
- >>> all_vertices = get_polygon_vertices(polygons) # Get all polygons
803
- """
804
- if not drawn_polygons:
805
- return []
806
-
807
- if polygon_id is not None:
808
- # Return specific polygon
809
- for polygon in drawn_polygons:
810
- if polygon['id'] == polygon_id:
811
- return polygon['vertices']
812
- return [] # Polygon not found
813
- else:
814
- # Return all polygons
815
- return [polygon['vertices'] for polygon in drawn_polygons]
816
-
817
-
818
- # Simple convenience function
819
- def create_building_editor(building_gdf=None, initial_center=None, zoom=17, rectangle_vertices=None):
820
- """
821
- Creates and displays an interactive building editor.
822
-
823
- Args:
824
- building_gdf: Existing buildings GeoDataFrame (optional)
825
- initial_center: Map center as (lon, lat) tuple (optional)
826
- zoom: Initial zoom level (default=17)
827
-
828
- Returns:
829
- GeoDataFrame: The building GeoDataFrame that automatically updates
830
-
831
- Example:
832
- >>> buildings = create_building_editor()
833
- >>> # Draw buildings on the displayed map
834
- >>> print(buildings) # Automatically contains all drawn buildings
835
- """
836
- m, gdf = draw_additional_buildings(building_gdf, initial_center, zoom, rectangle_vertices)
837
- display(m)
838
- return gdf
839
-
840
-
841
- def draw_additional_trees(tree_gdf=None, initial_center=None, zoom=17, rectangle_vertices=None):
842
- """
843
- Creates an interactive map to add trees by clicking and setting parameters.
844
-
845
- Users can:
846
- - Set tree parameters: top height, bottom height, crown diameter
847
- - Click multiple times to add multiple trees with the same parameters
848
- - Update parameters at any time to change subsequent trees
849
-
850
- Args:
851
- tree_gdf (GeoDataFrame, optional): Existing trees to display.
852
- Expected columns: ['tree_id', 'top_height', 'bottom_height', 'crown_diameter', 'geometry']
853
- initial_center (tuple, optional): (lon, lat) for initial map center.
854
- zoom (int): Initial zoom level. Default=17.
855
- rectangle_vertices (list, optional): If provided, used to set center like buildings.
856
-
857
- Returns:
858
- tuple: (map_object, updated_tree_gdf)
859
- """
860
- # Initialize or copy the tree GeoDataFrame
861
- if tree_gdf is None:
862
- updated_trees = gpd.GeoDataFrame(
863
- columns=['tree_id', 'top_height', 'bottom_height', 'crown_diameter', 'geometry'],
864
- crs='EPSG:4326'
865
- )
866
- else:
867
- updated_trees = tree_gdf.copy()
868
- # Ensure required columns exist
869
- if 'tree_id' not in updated_trees.columns:
870
- updated_trees['tree_id'] = range(1, len(updated_trees) + 1)
871
- for col, default in [('top_height', 10.0), ('bottom_height', 4.0), ('crown_diameter', 6.0)]:
872
- if col not in updated_trees.columns:
873
- updated_trees[col] = default
874
-
875
- # Determine map center
876
- if initial_center is not None:
877
- center_lon, center_lat = initial_center
878
- elif updated_trees is not None and len(updated_trees) > 0:
879
- min_lon, min_lat, max_lon, max_lat = updated_trees.total_bounds
880
- center_lon = (min_lon + max_lon) / 2
881
- center_lat = (min_lat + max_lat) / 2
882
- elif rectangle_vertices is not None:
883
- center_lon, center_lat = (rectangle_vertices[0][0] + rectangle_vertices[2][0]) / 2, (rectangle_vertices[0][1] + rectangle_vertices[2][1]) / 2
884
- else:
885
- center_lon, center_lat = -100.0, 40.0
886
-
887
- # Create map
888
- m = Map(center=(center_lat, center_lon), zoom=zoom, scroll_wheel_zoom=True)
889
- # Add Google Satellite basemap with Esri fallback
890
- try:
891
- google_sat = TileLayer(
892
- url='https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
893
- name='Google Satellite',
894
- attribution='Google Satellite'
895
- )
896
- # Replace default base layer with Google Satellite
897
- m.layers = tuple([google_sat])
898
- except Exception:
899
- try:
900
- m.layers = tuple([basemap_to_tiles(basemaps.Esri.WorldImagery)])
901
- except Exception:
902
- # Fallback silently if basemap cannot be added
903
- pass
904
-
905
- # If rectangle_vertices provided, draw its edges on the map
906
- if rectangle_vertices is not None and len(rectangle_vertices) >= 4:
907
- try:
908
- lat_lon_coords = [(lat, lon) for lon, lat in rectangle_vertices]
909
- rect_outline = LeafletPolygon(
910
- locations=lat_lon_coords,
911
- color="#fed766",
912
- weight=2,
913
- fill_color="#fed766",
914
- fill_opacity=0.0
915
- )
916
- m.add_layer(rect_outline)
917
- except Exception:
918
- pass
919
-
920
- # Display existing trees as circles
921
- tree_layers = {}
922
- for idx, row in updated_trees.iterrows():
923
- if row.geometry is not None and hasattr(row.geometry, 'x'):
924
- lat = row.geometry.y
925
- lon = row.geometry.x
926
- # Ensure integer radius in meters as required by ipyleaflet Circle
927
- radius_m = max(int(round(float(row.get('crown_diameter', 6.0)) / 2.0)), 1)
928
- tree_id_val = int(row.get('tree_id', idx+1))
929
- circle = Circle(location=(lat, lon), radius=radius_m, color='#2ab7ca', weight=1, opacity=1.0, fill_color='#2ab7ca', fill_opacity=0.3)
930
- m.add_layer(circle)
931
- tree_layers[tree_id_val] = circle
932
-
933
- # UI widgets for parameters
934
- top_height_input = FloatText(value=10.0, description='Top height (m):', disabled=False, style={'description_width': 'initial'})
935
- bottom_height_input = FloatText(value=4.0, description='Bottom height (m):', disabled=False, style={'description_width': 'initial'})
936
- crown_diameter_input = FloatText(value=6.0, description='Crown diameter (m):', disabled=False, style={'description_width': 'initial'})
937
- fixed_prop_checkbox = Checkbox(value=True, description='Fixed proportion', indent=False)
938
-
939
- add_mode_button = Button(description='Add', button_style='success')
940
- remove_mode_button = Button(description='Remove', button_style='')
941
- status_output = Output()
942
- hover_info = HTML("")
943
-
944
- control_panel = VBox([
945
- HTML("<h3 style=\"margin:0 0 4px 0;\">Tree Placement Tool</h3>"),
946
- HTML("<div style=\"margin:0 0 6px 0;\">1. Choose Add/Remove mode<br>2. Set tree parameters (top, bottom, crown)<br>3. Click on the map to add/remove consecutively<br>4. Hover over a tree to view parameters</div>"),
947
- HBox([add_mode_button, remove_mode_button]),
948
- top_height_input,
949
- bottom_height_input,
950
- crown_diameter_input,
951
- fixed_prop_checkbox,
952
- hover_info,
953
- status_output
954
- ])
955
-
956
- widget_control = WidgetControl(widget=control_panel, position='topright')
957
- m.add_control(widget_control)
958
-
959
- # State for mode
960
- mode = 'add'
961
- # Fixed proportion state
962
- base_bottom_ratio = bottom_height_input.value / top_height_input.value if top_height_input.value else 0.4
963
- base_crown_ratio = crown_diameter_input.value / top_height_input.value if top_height_input.value else 0.6
964
- updating_params = False
965
-
966
- def recompute_from_top(new_top: float):
967
- nonlocal updating_params
968
- if new_top <= 0:
969
- return
970
- new_bottom = max(0.0, base_bottom_ratio * new_top)
971
- new_crown = max(0.0, base_crown_ratio * new_top)
972
- updating_params = True
973
- bottom_height_input.value = new_bottom
974
- crown_diameter_input.value = new_crown
975
- updating_params = False
976
-
977
- def recompute_from_bottom(new_bottom: float):
978
- nonlocal updating_params
979
- if base_bottom_ratio <= 0:
980
- return
981
- new_top = max(0.0, new_bottom / base_bottom_ratio)
982
- new_crown = max(0.0, base_crown_ratio * new_top)
983
- updating_params = True
984
- top_height_input.value = new_top
985
- crown_diameter_input.value = new_crown
986
- updating_params = False
987
-
988
- def recompute_from_crown(new_crown: float):
989
- nonlocal updating_params
990
- if base_crown_ratio <= 0:
991
- return
992
- new_top = max(0.0, new_crown / base_crown_ratio)
993
- new_bottom = max(0.0, base_bottom_ratio * new_top)
994
- updating_params = True
995
- top_height_input.value = new_top
996
- bottom_height_input.value = new_bottom
997
- updating_params = False
998
-
999
- def on_toggle_fixed(change):
1000
- nonlocal base_bottom_ratio, base_crown_ratio
1001
- if change['name'] == 'value':
1002
- if change['new']:
1003
- # Capture current ratios as baseline
1004
- top = float(top_height_input.value) or 1.0
1005
- bot = float(bottom_height_input.value)
1006
- crn = float(crown_diameter_input.value)
1007
- base_bottom_ratio = max(0.0, bot / top)
1008
- base_crown_ratio = max(0.0, crn / top)
1009
- else:
1010
- # Keep last ratios but do not auto-update
1011
- pass
1012
-
1013
- def on_top_change(change):
1014
- if change['name'] == 'value' and fixed_prop_checkbox.value and not updating_params:
1015
- try:
1016
- recompute_from_top(float(change['new']))
1017
- except Exception:
1018
- pass
1019
-
1020
- def on_bottom_change(change):
1021
- if change['name'] == 'value' and fixed_prop_checkbox.value and not updating_params:
1022
- try:
1023
- recompute_from_bottom(float(change['new']))
1024
- except Exception:
1025
- pass
1026
-
1027
- def on_crown_change(change):
1028
- if change['name'] == 'value' and fixed_prop_checkbox.value and not updating_params:
1029
- try:
1030
- recompute_from_crown(float(change['new']))
1031
- except Exception:
1032
- pass
1033
-
1034
- fixed_prop_checkbox.observe(on_toggle_fixed, names='value')
1035
- top_height_input.observe(on_top_change, names='value')
1036
- bottom_height_input.observe(on_bottom_change, names='value')
1037
- crown_diameter_input.observe(on_crown_change, names='value')
1038
-
1039
- def set_mode(new_mode):
1040
- nonlocal mode
1041
- mode = new_mode
1042
- # Visual feedback
1043
- add_mode_button.button_style = 'success' if mode == 'add' else ''
1044
- remove_mode_button.button_style = 'danger' if mode == 'remove' else ''
1045
- # No on-screen mode label
1046
-
1047
- def on_click_add(b):
1048
- set_mode('add')
1049
-
1050
- def on_click_remove(b):
1051
- set_mode('remove')
1052
-
1053
- add_mode_button.on_click(on_click_add)
1054
- remove_mode_button.on_click(on_click_remove)
1055
-
1056
- # Consecutive placements by map click
1057
- def handle_map_click(**kwargs):
1058
- nonlocal updated_trees
1059
- with status_output:
1060
- clear_output()
1061
-
1062
- if kwargs.get('type') == 'click':
1063
- lat, lon = kwargs.get('coordinates', (None, None))
1064
- if lat is None or lon is None:
1065
- return
1066
- if mode == 'add':
1067
- # Determine next tree_id
1068
- next_tree_id = int(updated_trees['tree_id'].max() + 1) if len(updated_trees) > 0 else 1
1069
-
1070
- # Clamp/validate parameters
1071
- th = float(top_height_input.value)
1072
- bh = float(bottom_height_input.value)
1073
- cd = float(crown_diameter_input.value)
1074
- if bh > th:
1075
- bh, th = th, bh
1076
- if cd < 0:
1077
- cd = 0.0
1078
-
1079
- # Create new tree row
1080
- new_row = {
1081
- 'tree_id': next_tree_id,
1082
- 'top_height': th,
1083
- 'bottom_height': bh,
1084
- 'crown_diameter': cd,
1085
- 'geometry': geom.Point(lon, lat)
1086
- }
1087
-
1088
- # Append
1089
- new_index = len(updated_trees)
1090
- updated_trees.loc[new_index] = new_row
1091
-
1092
- # Add circle layer representing crown diameter (radius in meters)
1093
- radius_m = max(int(round(new_row['crown_diameter'] / 2.0)), 1)
1094
- circle = Circle(location=(lat, lon), radius=radius_m, color='#2ab7ca', weight=1, opacity=1.0, fill_color='#2ab7ca', fill_opacity=0.3)
1095
- m.add_layer(circle)
1096
-
1097
- tree_layers[next_tree_id] = circle
1098
-
1099
- # Suppress status prints on add
1100
- else:
1101
- # Remove mode: find the nearest tree within its crown radius + 5m
1102
- candidate_id = None
1103
- candidate_idx = None
1104
- candidate_dist = None
1105
- for idx2, row2 in updated_trees.iterrows():
1106
- if row2.geometry is None or not hasattr(row2.geometry, 'x'):
1107
- continue
1108
- lat2 = row2.geometry.y
1109
- lon2 = row2.geometry.x
1110
- dist_m = distance.distance((lat, lon), (lat2, lon2)).meters
1111
- rad_m = max(int(round(float(row2.get('crown_diameter', 6.0)) / 2.0)), 1)
1112
- thr_m = rad_m + 5
1113
- if (candidate_dist is None and dist_m <= thr_m) or (candidate_dist is not None and dist_m < candidate_dist and dist_m <= thr_m):
1114
- candidate_dist = dist_m
1115
- candidate_id = int(row2.get('tree_id', idx2+1))
1116
- candidate_idx = idx2
1117
-
1118
- if candidate_id is not None:
1119
- # Remove layer
1120
- layer = tree_layers.get(candidate_id)
1121
- if layer is not None:
1122
- m.remove_layer(layer)
1123
- del tree_layers[candidate_id]
1124
- # Remove from gdf
1125
- updated_trees.drop(index=candidate_idx, inplace=True)
1126
- updated_trees.reset_index(drop=True, inplace=True)
1127
- # Suppress status prints on remove
1128
- else:
1129
- # Suppress status prints when nothing to remove
1130
- pass
1131
- elif kwargs.get('type') == 'mousemove':
1132
- lat, lon = kwargs.get('coordinates', (None, None))
1133
- if lat is None or lon is None:
1134
- return
1135
- # Find a tree the cursor is over (within crown radius)
1136
- shown = False
1137
- for _, row2 in updated_trees.iterrows():
1138
- if row2.geometry is None or not hasattr(row2.geometry, 'x'):
1139
- continue
1140
- lat2 = row2.geometry.y
1141
- lon2 = row2.geometry.x
1142
- dist_m = distance.distance((lat, lon), (lat2, lon2)).meters
1143
- rad_m = max(int(round(float(row2.get('crown_diameter', 6.0)) / 2.0)), 1)
1144
- if dist_m <= rad_m:
1145
- hover_info.value = (
1146
- f"<div style=\"color:#d61f1f; font-weight:600; margin:2px 0;\">"
1147
- f"Tree {int(row2.get('tree_id', 0))} | Top {float(row2.get('top_height', 10.0))} m | "
1148
- f"Bottom {float(row2.get('bottom_height', 0.0))} m | Crown {float(row2.get('crown_diameter', 6.0))} m"
1149
- f"</div>"
1150
- )
1151
- shown = True
1152
- break
1153
- if not shown:
1154
- hover_info.value = ""
1155
- m.on_interaction(handle_map_click)
1156
-
1157
- with status_output:
1158
- print(f"Total trees loaded: {len(updated_trees)}")
1159
- print("Set parameters, then click on the map to add trees")
1160
-
1161
- return m, updated_trees
1162
-
1163
-
1164
- def create_tree_editor(tree_gdf=None, initial_center=None, zoom=17, rectangle_vertices=None):
1165
- """
1166
- Convenience wrapper to display the tree editor map and return the GeoDataFrame.
1167
- """
1168
- m, gdf = draw_additional_trees(tree_gdf, initial_center, zoom, rectangle_vertices)
1169
- display(m)
1
+ """
2
+ This module provides functions for drawing and manipulating rectangles and polygons on interactive maps.
3
+ It serves as a core component for defining geographical regions of interest in the VoxCity library.
4
+
5
+ Key Features:
6
+ - Interactive rectangle drawing on maps using ipyleaflet
7
+ - Rectangle rotation with coordinate system transformations
8
+ - City-centered map initialization
9
+ - Fixed-dimension rectangle creation from center points
10
+ - Building footprint visualization and polygon drawing
11
+ - Support for both WGS84 and Web Mercator projections
12
+ - Coordinate format handling between (lon,lat) and (lat,lon)
13
+
14
+ The module maintains consistent coordinate order conventions:
15
+ - Internal storage: (lon,lat) format to match GeoJSON standard
16
+ - ipyleaflet interface: (lat,lon) format as required by the library
17
+ - All return values: (lon,lat) format for consistency
18
+
19
+ Dependencies:
20
+ - ipyleaflet: For interactive map display and drawing controls
21
+ - pyproj: For coordinate system transformations
22
+ - geopy: For distance calculations
23
+ - shapely: For geometric operations
24
+ """
25
+
26
+ import math
27
+ from pyproj import Transformer
28
+ from ipyleaflet import (
29
+ Map,
30
+ DrawControl,
31
+ Rectangle,
32
+ Polygon as LeafletPolygon,
33
+ Polyline,
34
+ WidgetControl,
35
+ Circle,
36
+ basemaps,
37
+ basemap_to_tiles,
38
+ TileLayer,
39
+ )
40
+ from geopy import distance
41
+ import shapely.geometry as geom
42
+ import geopandas as gpd
43
+ from ipywidgets import (
44
+ VBox,
45
+ HBox,
46
+ Button,
47
+ FloatText,
48
+ Label,
49
+ Output,
50
+ HTML,
51
+ Checkbox,
52
+ ToggleButton,
53
+ Layout,
54
+ )
55
+ import pandas as pd
56
+ from IPython.display import display, clear_output
57
+
58
+ from .utils import get_coordinates_from_cityname
59
+
60
+ # Import VoxCity for type checking (avoid circular import with TYPE_CHECKING)
61
+ try:
62
+ from typing import TYPE_CHECKING
63
+ if TYPE_CHECKING:
64
+ from ..models import VoxCity
65
+ except ImportError:
66
+ pass
67
+
68
+ def rotate_rectangle(m, rectangle_vertices, angle):
69
+ """
70
+ Project rectangle to Mercator, rotate, and re-project to lat-lon coordinates.
71
+
72
+ This function performs a rotation of a rectangle in geographic space by:
73
+ 1. Converting coordinates from WGS84 (lat/lon) to Web Mercator projection
74
+ 2. Performing the rotation in the projected space for accurate distance preservation
75
+ 3. Converting back to WGS84 coordinates
76
+ 4. Visualizing the result on the provided map
77
+
78
+ The rotation is performed around the rectangle's centroid using a standard 2D rotation matrix.
79
+ The function handles coordinate system transformations to ensure geometrically accurate rotations
80
+ despite the distortions inherent in geographic projections.
81
+
82
+ Args:
83
+ m (ipyleaflet.Map): Map object to draw the rotated rectangle on.
84
+ The map must be initialized and have a valid center and zoom level.
85
+ rectangle_vertices (list): List of (lon, lat) tuples defining the rectangle vertices.
86
+ The vertices should be ordered in a counter-clockwise direction.
87
+ Example: [(lon1,lat1), (lon2,lat2), (lon3,lat3), (lon4,lat4)]
88
+ angle (float): Rotation angle in degrees.
89
+ Positive angles rotate counter-clockwise.
90
+ Negative angles rotate clockwise.
91
+
92
+ Returns:
93
+ list: List of rotated (lon, lat) tuples defining the new rectangle vertices.
94
+ The vertices maintain their original ordering.
95
+ Returns None if no rectangle vertices are provided.
96
+
97
+ Note:
98
+ The function uses EPSG:4326 (WGS84) for geographic coordinates and
99
+ EPSG:3857 (Web Mercator) for the rotation calculations.
100
+ """
101
+ if not rectangle_vertices:
102
+ print("Draw a rectangle first!")
103
+ return
104
+
105
+ # Define transformers (modern pyproj API)
106
+ to_merc = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
107
+ to_wgs84 = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True)
108
+
109
+ # Project vertices from WGS84 to Web Mercator for proper distance calculations
110
+ projected_vertices = [to_merc.transform(lon, lat) for lon, lat in rectangle_vertices]
111
+
112
+ # Calculate the centroid to use as rotation center
113
+ centroid_x = sum(x for x, y in projected_vertices) / len(projected_vertices)
114
+ centroid_y = sum(y for x, y in projected_vertices) / len(projected_vertices)
115
+
116
+ # Convert angle to radians (negative for clockwise rotation)
117
+ angle_rad = -math.radians(angle)
118
+
119
+ # Rotate each vertex around the centroid using standard 2D rotation matrix
120
+ rotated_vertices = []
121
+ for x, y in projected_vertices:
122
+ # Translate point to origin for rotation
123
+ temp_x = x - centroid_x
124
+ temp_y = y - centroid_y
125
+
126
+ # Apply rotation matrix
127
+ rotated_x = temp_x * math.cos(angle_rad) - temp_y * math.sin(angle_rad)
128
+ rotated_y = temp_x * math.sin(angle_rad) + temp_y * math.cos(angle_rad)
129
+
130
+ # Translate point back to original position
131
+ new_x = rotated_x + centroid_x
132
+ new_y = rotated_y + centroid_y
133
+
134
+ rotated_vertices.append((new_x, new_y))
135
+
136
+ # Convert coordinates back to WGS84 (lon/lat)
137
+ new_vertices = [to_wgs84.transform(x, y) for x, y in rotated_vertices]
138
+
139
+ # Create and add new polygon layer to map
140
+ polygon = LeafletPolygon(
141
+ locations=[(lat, lon) for lon, lat in new_vertices], # Convert to (lat,lon) for ipyleaflet
142
+ color="red",
143
+ fill_color="red"
144
+ )
145
+ m.add_layer(polygon)
146
+
147
+ return new_vertices
148
+
149
+ def draw_rectangle_map(center=(40, -100), zoom=4):
150
+ """
151
+ Create an interactive map for drawing rectangles with ipyleaflet.
152
+
153
+ This function initializes an interactive map that allows users to draw rectangles
154
+ by clicking and dragging on the map surface. The drawn rectangles are captured
155
+ and their vertices are stored in geographic coordinates.
156
+
157
+ The map interface provides:
158
+ - A rectangle drawing tool activated by default
159
+ - Real-time coordinate capture of drawn shapes
160
+ - Automatic vertex ordering in counter-clockwise direction
161
+ - Console output of vertex coordinates for verification
162
+
163
+ Drawing Controls:
164
+ - Click and drag to draw a rectangle
165
+ - Release to complete the rectangle
166
+ - Only one rectangle can be active at a time
167
+ - Drawing a new rectangle clears the previous one
168
+
169
+ Args:
170
+ center (tuple): Center coordinates (lat, lon) for the map view.
171
+ Defaults to (40, -100) which centers on the continental United States.
172
+ Format: (latitude, longitude) in decimal degrees.
173
+ zoom (int): Initial zoom level for the map. Defaults to 4.
174
+ Range: 0 (most zoomed out) to 18 (most zoomed in).
175
+ Recommended: 3-6 for countries, 10-15 for cities.
176
+
177
+ Returns:
178
+ tuple: (Map object, list of rectangle vertices)
179
+ - Map object: ipyleaflet.Map instance for displaying and interacting with the map
180
+ - rectangle_vertices: Empty list that will be populated with (lon,lat) tuples
181
+ when a rectangle is drawn. Coordinates are stored in GeoJSON order (lon,lat).
182
+
183
+ Note:
184
+ The function disables all drawing tools except rectangles to ensure
185
+ consistent shape creation. The rectangle vertices are automatically
186
+ converted to (lon,lat) format when stored, regardless of the input
187
+ center coordinate order.
188
+ """
189
+ # Initialize the map centered at specified coordinates
190
+ m = Map(center=center, zoom=zoom)
191
+
192
+ # List to store the vertices of drawn rectangle
193
+ rectangle_vertices = []
194
+
195
+ def handle_draw(target, action, geo_json):
196
+ """Handle draw events on the map."""
197
+ # Clear any previously stored vertices
198
+ rectangle_vertices.clear()
199
+
200
+ # Process only if a rectangle polygon was drawn
201
+ if action == 'created' and geo_json['geometry']['type'] == 'Polygon':
202
+ # Extract coordinates from GeoJSON format
203
+ coordinates = geo_json['geometry']['coordinates'][0]
204
+ print("Vertices of the drawn rectangle:")
205
+ # Store all vertices except last (GeoJSON repeats first vertex at end)
206
+ for coord in coordinates[:-1]:
207
+ # Keep GeoJSON (lon,lat) format
208
+ rectangle_vertices.append((coord[0], coord[1]))
209
+ print(f"Longitude: {coord[0]}, Latitude: {coord[1]}")
210
+
211
+ # Configure drawing controls - only enable rectangle drawing
212
+ draw_control = DrawControl()
213
+ draw_control.polyline = {}
214
+ draw_control.polygon = {}
215
+ draw_control.circle = {}
216
+ draw_control.rectangle = {
217
+ "shapeOptions": {
218
+ "color": "#6bc2e5",
219
+ "weight": 4,
220
+ }
221
+ }
222
+ m.add_control(draw_control)
223
+
224
+ # Register event handler for drawing actions
225
+ draw_control.on_draw(handle_draw)
226
+
227
+ return m, rectangle_vertices
228
+
229
+ def draw_rectangle_map_cityname(cityname, zoom=15):
230
+ """
231
+ Create an interactive map centered on a specified city for drawing rectangles.
232
+
233
+ This function extends draw_rectangle_map() by automatically centering the map
234
+ on a specified city using geocoding. It provides a convenient way to focus
235
+ the drawing interface on a particular urban area without needing to know
236
+ its exact coordinates.
237
+
238
+ The function uses the utils.get_coordinates_from_cityname() function to
239
+ geocode the city name and obtain its coordinates. The resulting map is
240
+ zoomed to an appropriate level for urban-scale analysis.
241
+
242
+ Args:
243
+ cityname (str): Name of the city to center the map on.
244
+ Can include country or state for better accuracy.
245
+ Examples: "Tokyo, Japan", "New York, NY", "Paris, France"
246
+ zoom (int): Initial zoom level for the map. Defaults to 15.
247
+ Range: 0 (most zoomed out) to 18 (most zoomed in).
248
+ Default of 15 is optimized for city-level visualization.
249
+
250
+ Returns:
251
+ tuple: (Map object, list of rectangle vertices)
252
+ - Map object: ipyleaflet.Map instance centered on the specified city
253
+ - rectangle_vertices: Empty list that will be populated with (lon,lat)
254
+ tuples when a rectangle is drawn
255
+
256
+ Note:
257
+ If the city name cannot be geocoded, the function will raise an error.
258
+ For better results, provide specific city names with country/state context.
259
+ The function inherits all drawing controls and behavior from draw_rectangle_map().
260
+ """
261
+ # Get coordinates for the specified city
262
+ center = get_coordinates_from_cityname(cityname)
263
+ m, rectangle_vertices = draw_rectangle_map(center=center, zoom=zoom)
264
+ return m, rectangle_vertices
265
+
266
+ def center_location_map_cityname(cityname, east_west_length, north_south_length, zoom=15):
267
+ """
268
+ Create an interactive map centered on a city where clicking creates a rectangle of specified dimensions.
269
+
270
+ This function provides a specialized interface for creating fixed-size rectangles
271
+ centered on user-selected points. Instead of drawing rectangles by dragging,
272
+ users click a point on the map and a rectangle of the specified dimensions
273
+ is automatically created centered on that point.
274
+
275
+ The function handles:
276
+ - Automatic city geocoding and map centering
277
+ - Distance calculations in meters using geopy
278
+ - Conversion between geographic and metric distances
279
+ - Rectangle creation with specified dimensions
280
+ - Visualization of created rectangles
281
+
282
+ Workflow:
283
+ 1. Map is centered on the specified city
284
+ 2. User clicks a point on the map
285
+ 3. A rectangle is created centered on that point
286
+ 4. Rectangle dimensions are maintained in meters regardless of latitude
287
+ 5. Previous rectangles are automatically cleared
288
+
289
+ Args:
290
+ cityname (str): Name of the city to center the map on.
291
+ Can include country or state for better accuracy.
292
+ Examples: "Tokyo, Japan", "New York, NY"
293
+ east_west_length (float): Width of the rectangle in meters.
294
+ This is the dimension along the east-west direction.
295
+ The actual ground distance is maintained regardless of projection distortion.
296
+ north_south_length (float): Height of the rectangle in meters.
297
+ This is the dimension along the north-south direction.
298
+ The actual ground distance is maintained regardless of projection distortion.
299
+ zoom (int): Initial zoom level for the map. Defaults to 15.
300
+ Range: 0 (most zoomed out) to 18 (most zoomed in).
301
+ Default of 15 is optimized for city-level visualization.
302
+
303
+ Returns:
304
+ tuple: (Map object, list of rectangle vertices)
305
+ - Map object: ipyleaflet.Map instance centered on the specified city
306
+ - rectangle_vertices: Empty list that will be populated with (lon,lat)
307
+ tuples when a point is clicked and the rectangle is created
308
+
309
+ Note:
310
+ - Rectangle dimensions are specified in meters but stored as geographic coordinates
311
+ - The function uses geopy's distance calculations for accurate metric distances
312
+ - Only one rectangle can exist at a time; clicking a new point removes the previous rectangle
313
+ - Rectangle vertices are returned in GeoJSON (lon,lat) order
314
+ """
315
+
316
+ # Get coordinates for the specified city
317
+ center = get_coordinates_from_cityname(cityname)
318
+
319
+ # Initialize map centered on the city
320
+ m = Map(center=center, zoom=zoom)
321
+
322
+ # List to store rectangle vertices
323
+ rectangle_vertices = []
324
+
325
+ def handle_draw(target, action, geo_json):
326
+ """Handle draw events on the map."""
327
+ # Clear previous vertices and remove any existing rectangles
328
+ rectangle_vertices.clear()
329
+ for layer in m.layers:
330
+ if isinstance(layer, Rectangle):
331
+ m.remove_layer(layer)
332
+
333
+ # Process only if a point was drawn on the map
334
+ if action == 'created' and geo_json['geometry']['type'] == 'Point':
335
+ # Extract point coordinates from GeoJSON (lon,lat)
336
+ lon, lat = geo_json['geometry']['coordinates'][0], geo_json['geometry']['coordinates'][1]
337
+ print(f"Point drawn at Longitude: {lon}, Latitude: {lat}")
338
+
339
+ # Calculate corner points using geopy's distance calculator
340
+ # Each point is calculated as a destination from center point using bearing
341
+ north = distance.distance(meters=north_south_length/2).destination((lat, lon), bearing=0)
342
+ south = distance.distance(meters=north_south_length/2).destination((lat, lon), bearing=180)
343
+ east = distance.distance(meters=east_west_length/2).destination((lat, lon), bearing=90)
344
+ west = distance.distance(meters=east_west_length/2).destination((lat, lon), bearing=270)
345
+
346
+ # Create rectangle vertices in counter-clockwise order (lon,lat)
347
+ rectangle_vertices.extend([
348
+ (west.longitude, south.latitude),
349
+ (west.longitude, north.latitude),
350
+ (east.longitude, north.latitude),
351
+ (east.longitude, south.latitude)
352
+ ])
353
+
354
+ # Create and add new rectangle to map (ipyleaflet expects lat,lon)
355
+ rectangle = Rectangle(
356
+ bounds=[(north.latitude, west.longitude), (south.latitude, east.longitude)],
357
+ color="red",
358
+ fill_color="red",
359
+ fill_opacity=0.2
360
+ )
361
+ m.add_layer(rectangle)
362
+
363
+ print("Rectangle vertices:")
364
+ for vertex in rectangle_vertices:
365
+ print(f"Longitude: {vertex[0]}, Latitude: {vertex[1]}")
366
+
367
+ # Configure drawing controls - only enable point drawing
368
+ draw_control = DrawControl()
369
+ draw_control.polyline = {}
370
+ draw_control.polygon = {}
371
+ draw_control.circle = {}
372
+ draw_control.rectangle = {}
373
+ draw_control.marker = {}
374
+ m.add_control(draw_control)
375
+
376
+ # Register event handler for drawing actions
377
+ draw_control.on_draw(handle_draw)
378
+
379
+ return m, rectangle_vertices
380
+
381
+ def display_buildings_and_draw_polygon(voxcity=None, building_gdf=None, rectangle_vertices=None, zoom=17):
382
+ """
383
+ Displays building footprints and enables polygon drawing on an interactive map.
384
+
385
+ This function creates an interactive map that visualizes building footprints and
386
+ allows users to draw arbitrary polygons. It's particularly useful for selecting
387
+ specific buildings or areas within an urban context.
388
+
389
+ The function provides three key features:
390
+ 1. Building Footprint Visualization:
391
+ - Displays building polygons from a GeoDataFrame
392
+ - Uses consistent styling for all buildings
393
+ - Handles simple polygon geometries only
394
+
395
+ 2. Interactive Polygon Drawing:
396
+ - Enables free-form polygon drawing
397
+ - Captures vertices in consistent (lon,lat) format
398
+ - Maintains GeoJSON compatibility
399
+ - Supports multiple polygons with unique IDs and colors
400
+
401
+ 3. Map Initialization:
402
+ - Automatic centering based on input data
403
+ - Fallback to default location if no data provided
404
+ - Support for both building data and rectangle bounds
405
+
406
+ Args:
407
+ voxcity (VoxCity, optional): A VoxCity object from which to extract building_gdf
408
+ and rectangle_vertices. If provided, these values will be used unless
409
+ explicitly overridden by the building_gdf or rectangle_vertices parameters.
410
+ building_gdf (GeoDataFrame, optional): A GeoDataFrame containing building footprints.
411
+ Must have geometry column with Polygon type features.
412
+ Geometries should be in [lon, lat] coordinate order.
413
+ If None and voxcity is provided, uses voxcity.extras['building_gdf'].
414
+ If None and no voxcity provided, only the base map is displayed.
415
+ rectangle_vertices (list, optional): List of [lon, lat] coordinates defining rectangle corners.
416
+ Used to set the initial map view extent.
417
+ Takes precedence over building_gdf for determining map center.
418
+ If None and voxcity is provided, uses voxcity.extras['rectangle_vertices'].
419
+ zoom (int): Initial zoom level for the map. Default=17.
420
+ Range: 0 (most zoomed out) to 18 (most zoomed in).
421
+ Default of 17 is optimized for building-level detail.
422
+
423
+ Returns:
424
+ tuple: (map_object, drawn_polygons)
425
+ - map_object: ipyleaflet Map instance with building footprints and drawing controls
426
+ - drawn_polygons: List of dictionaries with 'id', 'vertices', and 'color' keys for all drawn polygons.
427
+ Each polygon has a unique ID and color for easy identification.
428
+
429
+ Examples:
430
+ Using a VoxCity object:
431
+ >>> m, polygons = display_buildings_and_draw_polygon(voxcity=my_voxcity)
432
+
433
+ Using explicit parameters:
434
+ >>> m, polygons = display_buildings_and_draw_polygon(building_gdf=buildings, rectangle_vertices=rect)
435
+
436
+ Override specific parameters from VoxCity:
437
+ >>> m, polygons = display_buildings_and_draw_polygon(voxcity=my_voxcity, zoom=15)
438
+
439
+ Note:
440
+ - Building footprints are displayed in blue with 20% opacity
441
+ - Only simple Polygon geometries are supported (no MultiPolygons)
442
+ - Drawing tools are restricted to polygon creation only
443
+ - All coordinates are handled in (lon,lat) order internally
444
+ - The function automatically determines appropriate map bounds
445
+ - Each polygon gets a unique ID and different colors for easy identification
446
+ - Use get_polygon_vertices() helper function to extract specific polygon data
447
+ """
448
+ # ---------------------------------------------------------
449
+ # 0. Extract data from VoxCity object if provided
450
+ # ---------------------------------------------------------
451
+ if voxcity is not None:
452
+ # Extract building_gdf if not explicitly provided
453
+ if building_gdf is None:
454
+ building_gdf = voxcity.extras.get('building_gdf', None)
455
+
456
+ # Extract rectangle_vertices if not explicitly provided
457
+ if rectangle_vertices is None:
458
+ rectangle_vertices = voxcity.extras.get('rectangle_vertices', None)
459
+
460
+ # ---------------------------------------------------------
461
+ # 1. Determine a suitable map center via bounding box logic
462
+ # ---------------------------------------------------------
463
+ if rectangle_vertices is not None:
464
+ # Get bounds from rectangle vertices
465
+ lons = [v[0] for v in rectangle_vertices]
466
+ lats = [v[1] for v in rectangle_vertices]
467
+ min_lon, max_lon = min(lons), max(lons)
468
+ min_lat, max_lat = min(lats), max(lats)
469
+ center_lon = (min_lon + max_lon) / 2
470
+ center_lat = (min_lat + max_lat) / 2
471
+ elif building_gdf is not None and len(building_gdf) > 0:
472
+ # Get bounds from GeoDataFrame
473
+ bounds = building_gdf.total_bounds # Returns [minx, miny, maxx, maxy]
474
+ min_lon, min_lat, max_lon, max_lat = bounds
475
+ center_lon = (min_lon + max_lon) / 2
476
+ center_lat = (min_lat + max_lat) / 2
477
+ else:
478
+ # Fallback: If no inputs or invalid data, pick a default
479
+ center_lon, center_lat = -100.0, 40.0
480
+
481
+ # Create the ipyleaflet map (needs lat,lon)
482
+ m = Map(center=(center_lat, center_lon), zoom=zoom, scroll_wheel_zoom=True)
483
+
484
+ # -----------------------------------------
485
+ # 2. Add building footprints to the map if provided
486
+ # -----------------------------------------
487
+ if building_gdf is not None:
488
+ for idx, row in building_gdf.iterrows():
489
+ # Only handle simple Polygons
490
+ if isinstance(row.geometry, geom.Polygon):
491
+ # Get coordinates from geometry
492
+ coords = list(row.geometry.exterior.coords)
493
+ # Convert to (lat,lon) for ipyleaflet, skip last repeated coordinate
494
+ lat_lon_coords = [(c[1], c[0]) for c in coords[:-1]]
495
+
496
+ # Create the polygon layer
497
+ bldg_layer = LeafletPolygon(
498
+ locations=lat_lon_coords,
499
+ color="blue",
500
+ fill_color="blue",
501
+ fill_opacity=0.2,
502
+ weight=2
503
+ )
504
+ m.add_layer(bldg_layer)
505
+
506
+ # -----------------------------------------------------------------
507
+ # 3. Enable drawing of polygons, capturing the vertices in Lon-Lat
508
+ # -----------------------------------------------------------------
509
+ # Store multiple polygons with IDs and colors
510
+ drawn_polygons = [] # List of dicts with 'id', 'vertices', 'color' keys
511
+ polygon_counter = 0
512
+ polygon_colors = ['red', 'blue', 'green', 'orange', 'purple', 'brown', 'pink', 'gray', 'olive', 'cyan']
513
+
514
+ draw_control = DrawControl(
515
+ polygon={
516
+ "shapeOptions": {
517
+ "color": "red",
518
+ "fillColor": "red",
519
+ "fillOpacity": 0.2
520
+ }
521
+ },
522
+ rectangle={}, # Disable rectangles (or enable if needed)
523
+ circle={}, # Disable circles
524
+ circlemarker={}, # Disable circlemarkers
525
+ polyline={}, # Disable polylines
526
+ marker={} # Disable markers
527
+ )
528
+
529
+ def handle_draw(self, action, geo_json):
530
+ """
531
+ Callback for whenever a shape is created or edited.
532
+ ipyleaflet's DrawControl returns standard GeoJSON (lon, lat).
533
+ We'll keep them as (lon, lat).
534
+ """
535
+ if action == 'created' and geo_json['geometry']['type'] == 'Polygon':
536
+ nonlocal polygon_counter
537
+ polygon_counter += 1
538
+
539
+ # The polygon's first ring
540
+ coordinates = geo_json['geometry']['coordinates'][0]
541
+ vertices = [(coord[0], coord[1]) for coord in coordinates[:-1]]
542
+
543
+ # Assign color (cycle through colors)
544
+ color = polygon_colors[polygon_counter % len(polygon_colors)]
545
+
546
+ # Store polygon data
547
+ polygon_data = {
548
+ 'id': polygon_counter,
549
+ 'vertices': vertices,
550
+ 'color': color
551
+ }
552
+ drawn_polygons.append(polygon_data)
553
+
554
+ print(f"Polygon {polygon_counter} drawn with {len(vertices)} vertices (color: {color}):")
555
+ for i, (lon, lat) in enumerate(vertices):
556
+ print(f" Vertex {i+1}: (lon, lat) = ({lon}, {lat})")
557
+ print(f"Total polygons: {len(drawn_polygons)}")
558
+
559
+ draw_control.on_draw(handle_draw)
560
+ m.add_control(draw_control)
561
+
562
+ return m, drawn_polygons
563
+
564
+
565
+
566
+ def draw_additional_buildings(
567
+ voxcity=None,
568
+ building_gdf=None,
569
+ initial_center=None,
570
+ zoom=17,
571
+ rectangle_vertices=None,
572
+ ):
573
+ """
574
+ Interactive map editor: Draw rectangles, freehand polygons, and DELETE existing buildings.
575
+
576
+ Args:
577
+ initial_center (tuple): (Longitude, Latitude) - Standard GeoJSON order.
578
+ """
579
+ # --- Data Initialization ---
580
+ if voxcity is not None:
581
+ if building_gdf is None:
582
+ building_gdf = voxcity.extras.get("building_gdf", None)
583
+ if rectangle_vertices is None:
584
+ rectangle_vertices = voxcity.extras.get("rectangle_vertices", None)
585
+
586
+ if building_gdf is None:
587
+ updated_gdf = gpd.GeoDataFrame(
588
+ columns=["id", "height", "min_height", "geometry", "building_id"],
589
+ crs="EPSG:4326",
590
+ )
591
+ else:
592
+ updated_gdf = building_gdf.copy()
593
+ updated_gdf = updated_gdf.reset_index(drop=True)
594
+ defaults = {"height": 10.0, "min_height": 0.0, "building_id": 0, "id": 0}
595
+ for col, val in defaults.items():
596
+ if col not in updated_gdf.columns:
597
+ updated_gdf[col] = (
598
+ val if col not in ["building_id", "id"] else range(len(updated_gdf))
599
+ )
600
+
601
+ # --- Map Setup (Corrected for Lon, Lat input) ---
602
+ if initial_center is not None:
603
+ # User provides (Lon, Lat) per GeoJSON standard
604
+ center_lon, center_lat = initial_center
605
+ elif not updated_gdf.empty:
606
+ # GDF bounds are (minx, miny, maxx, maxy) -> (Lon, Lat, Lon, Lat)
607
+ b = updated_gdf.total_bounds
608
+ center_lon, center_lat = (b[0] + b[2]) / 2, (b[1] + b[3]) / 2
609
+ else:
610
+ center_lon, center_lat = -100.0, 40.0
611
+
612
+ # ipyleaflet expects (Lat, Lon), so we flip it here
613
+ m = Map(center=(center_lat, center_lon), zoom=zoom, scroll_wheel_zoom=True)
614
+
615
+ # --- UI Setup ---
616
+ style_html = HTML(
617
+ """
618
+ <style>
619
+ .vox-panel { font-family: 'Segoe UI', sans-serif; }
620
+ .vox-header { font-size: 14px; font-weight: 600; color: #333; border-bottom: 2px solid #2196F3; padding-bottom: 4px; margin-bottom: 2px; }
621
+ .vox-section { font-size: 10px; font-weight: 600; color: #666; text-transform: uppercase; letter-spacing: 0.5px; margin: 4px 0 2px 0; }
622
+ .vox-section-add { color: #2e7d32; }
623
+ .vox-section-remove { color: #c62828; }
624
+ .vox-status { padding: 6px 8px; border-radius: 3px; font-size: 11px; margin-top: 6px; font-weight: 500; line-height: 1.3; }
625
+ .vox-status-info { background-color: #e3f2fd; color: #0d47a1; border-left: 3px solid #0d47a1; }
626
+ .vox-status-success { background-color: #e8f5e9; color: #1b5e20; border-left: 3px solid #1b5e20; }
627
+ .vox-status-warn { background-color: #fff3e0; color: #e65100; border-left: 3px solid #e65100; }
628
+ .vox-status-danger { background-color: #ffebee; color: #c62828; border-left: 3px solid #c62828; }
629
+ .vox-divider { height: 1px; background: #e0e0e0; margin: 6px 0; }
630
+ </style>
631
+ """
632
+ )
633
+
634
+ # --- ADD BUILDINGS Section ---
635
+ add_section_label = HTML("<div class='vox-panel vox-section vox-section-add'>➕ ADD</div>")
636
+
637
+ rect_btn = ToggleButton(
638
+ value=False,
639
+ description="📐 Rectangle",
640
+ icon="",
641
+ layout=Layout(width="100px"),
642
+ tooltip="Click 3 corners on map to draw rectangle",
643
+ )
644
+ freehand_btn = HTML("<span style='font-size:10px; color:#666; margin-left:5px;'>or 🖊️ left toolbar</span>")
645
+
646
+ h_in = FloatText(
647
+ value=10.0,
648
+ description="Height:",
649
+ layout=Layout(width="120px"),
650
+ style={"description_width": "45px"},
651
+ )
652
+ mh_in = FloatText(
653
+ value=0.0,
654
+ description="Base:",
655
+ layout=Layout(width="100px"),
656
+ style={"description_width": "35px"},
657
+ )
658
+ add_btn = Button(
659
+ description="Add Building",
660
+ button_style="success",
661
+ icon="plus",
662
+ disabled=True,
663
+ layout=Layout(flex="1"),
664
+ )
665
+ clr_btn = Button(
666
+ description="Clear",
667
+ button_style="warning",
668
+ icon="eraser",
669
+ disabled=True,
670
+ layout=Layout(width="70px"),
671
+ tooltip="Clear drawing",
672
+ )
673
+
674
+ # --- REMOVE BUILDINGS Section ---
675
+ divider = HTML("<div class='vox-divider'></div>")
676
+ remove_section_label = HTML("<div class='vox-panel vox-section vox-section-remove'>🗑️ REMOVE</div>")
677
+
678
+ del_btn = ToggleButton(
679
+ value=False,
680
+ description="👆 Click",
681
+ icon="",
682
+ button_style="danger",
683
+ layout=Layout(width="80px"),
684
+ tooltip="Click on buildings to remove",
685
+ )
686
+ poly_del_btn = ToggleButton(
687
+ value=False,
688
+ description="⬡ Area",
689
+ icon="",
690
+ button_style="danger",
691
+ layout=Layout(width="75px"),
692
+ tooltip="Draw polygon to remove buildings inside",
693
+ )
694
+
695
+ # --- Status Bar ---
696
+ status_bar = HTML(
697
+ value="<div class='vox-panel vox-status vox-status-info'>Ready. Select a tool above.</div>"
698
+ )
699
+
700
+ # Layout rows - compact
701
+ add_tools_row = HBox([rect_btn, freehand_btn], layout=Layout(margin="1px 0"))
702
+ input_row = HBox([h_in, mh_in], layout=Layout(margin="2px 0"))
703
+ action_row = HBox([add_btn, clr_btn], layout=Layout(margin="2px 0"))
704
+ remove_tools_row = HBox([del_btn, poly_del_btn], layout=Layout(margin="1px 0"))
705
+
706
+ panel = VBox(
707
+ [
708
+ style_html,
709
+ HTML("<div class='vox-panel'><div class='vox-header'>🏙️ Building Editor</div></div>"),
710
+ add_section_label,
711
+ add_tools_row,
712
+ input_row,
713
+ action_row,
714
+ divider,
715
+ remove_section_label,
716
+ remove_tools_row,
717
+ status_bar,
718
+ ],
719
+ layout=Layout(
720
+ width="280px",
721
+ padding="8px",
722
+ background_color="white",
723
+ border_radius="6px",
724
+ box_shadow="0px 2px 8px rgba(0,0,0,0.12)",
725
+ ),
726
+ )
727
+
728
+ m.add_control(WidgetControl(widget=panel, position="topright"))
729
+
730
+ # --- Global State & Transformers ---
731
+ state = {"poly": [], "clicks": [], "temp_layers": [], "preview": None, "removal_poly": None, "removal_preview": None}
732
+ to_merc = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
733
+ to_geo = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True)
734
+ _style_attr = getattr(m, "default_style", {})
735
+ original_style = _style_attr.copy() if isinstance(_style_attr, dict) else {"cursor": "grab"}
736
+
737
+ # Track building layers for polygon removal mode
738
+ building_layers = {}
739
+
740
+ # --- Helper Functions ---
741
+ def set_status(msg, type="info"):
742
+ status_bar.value = (
743
+ f"<div class='vox-panel vox-status vox-status-{type}'>{msg}</div>"
744
+ )
745
+
746
+ def add_polygon_to_map(poly_geom, gdf_index, height):
747
+ coords = list(poly_geom.exterior.coords)
748
+ leaflet_poly = LeafletPolygon(
749
+ locations=[(c[1], c[0]) for c in coords[:-1]], # Flip (Lon, Lat) -> (Lat, Lon) for Leaflet
750
+ color="#2196F3",
751
+ fill_color="#2196F3",
752
+ fill_opacity=0.4,
753
+ weight=1,
754
+ popup=HTML(f"<b>ID:</b> {gdf_index}<br><b>H:</b> {height}m"),
755
+ )
756
+
757
+ def on_poly_click(**kwargs):
758
+ if del_btn.value:
759
+ m.remove_layer(leaflet_poly)
760
+ if gdf_index in building_layers:
761
+ del building_layers[gdf_index]
762
+ try:
763
+ updated_gdf.drop(index=gdf_index, inplace=True)
764
+ set_status(f"🗑️ Removed #{gdf_index}. Click more or deselect.", "danger")
765
+ except KeyError:
766
+ pass
767
+
768
+ leaflet_poly.on_click(on_poly_click)
769
+ m.add_layer(leaflet_poly)
770
+ building_layers[gdf_index] = leaflet_poly
771
+
772
+ # --- Render Existing ---
773
+ for idx, row in updated_gdf.iterrows():
774
+ if isinstance(row.geometry, geom.Polygon):
775
+ add_polygon_to_map(row.geometry, idx, row.get("height", 0))
776
+
777
+ def clear_removal_preview():
778
+ if state["removal_preview"]:
779
+ try:
780
+ m.remove_layer(state["removal_preview"])
781
+ except Exception:
782
+ pass
783
+ state["removal_preview"] = None
784
+ state["removal_poly"] = None
785
+
786
+ # --- Logic ---
787
+ def on_mode_change(change):
788
+ if change["owner"] is rect_btn and change["new"]:
789
+ del_btn.value = False
790
+ poly_del_btn.value = False
791
+ draw_control.clear()
792
+ clear_removal_preview()
793
+ m.default_style = {"cursor": "crosshair"}
794
+ set_status("📐 <b>Step 1/3:</b> Click first corner", "info")
795
+ elif change["owner"] is del_btn and change["new"]:
796
+ rect_btn.value = False
797
+ poly_del_btn.value = False
798
+ clear_all(None)
799
+ clear_removal_preview()
800
+ m.default_style = {"cursor": "no-drop"}
801
+ set_status("👆 Click on buildings to delete", "danger")
802
+ elif change["owner"] is poly_del_btn and change["new"]:
803
+ rect_btn.value = False
804
+ del_btn.value = False
805
+ clear_all(None)
806
+ clear_removal_preview()
807
+ m.default_style = {"cursor": "crosshair"}
808
+ set_status("⬡ Use polygon tool (◇) on left", "danger")
809
+ elif not rect_btn.value and not del_btn.value and not poly_del_btn.value:
810
+ m.default_style = original_style
811
+ clear_removal_preview()
812
+ set_status("Ready. Select a tool above.", "info")
813
+
814
+ rect_btn.observe(on_mode_change, names="value")
815
+ del_btn.observe(on_mode_change, names="value")
816
+ poly_del_btn.observe(on_mode_change, names="value")
817
+
818
+ def clear_preview():
819
+ if state["preview"]:
820
+ try:
821
+ m.remove_layer(state["preview"])
822
+ except Exception:
823
+ pass
824
+ state["preview"] = None
825
+
826
+ def clear_temps():
827
+ while state["temp_layers"]:
828
+ try:
829
+ m.remove_layer(state["temp_layers"].pop())
830
+ except Exception:
831
+ pass
832
+
833
+ def refresh_markers():
834
+ clear_temps()
835
+ for lon, lat in state["clicks"]:
836
+ pt = Circle(
837
+ location=(lat, lon),
838
+ radius=2,
839
+ color="red",
840
+ fill_color="red",
841
+ fill_opacity=1.0,
842
+ )
843
+ m.add_layer(pt)
844
+ state["temp_layers"].append(pt)
845
+ if len(state["clicks"]) >= 2:
846
+ (l1, la1), (l2, la2) = state["clicks"][0], state["clicks"][1]
847
+ line = Polyline(
848
+ locations=[(la1, l1), (la2, l2)],
849
+ color="red",
850
+ weight=3,
851
+ )
852
+ m.add_layer(line)
853
+ state["temp_layers"].append(line)
854
+
855
+ def build_rect(points):
856
+ (lon1, lat1), (lon2, lat2), (lon3, lat3) = points[:3]
857
+ x1, y1 = to_merc.transform(lon1, lat1)
858
+ x2, y2 = to_merc.transform(lon2, lat2)
859
+ x3, y3 = to_merc.transform(lon3, lat3)
860
+ wx, wy = x2 - x1, y2 - y1
861
+ if math.hypot(wx, wy) < 0.5:
862
+ return None, "Width too small"
863
+ ux, uy = wx / math.hypot(wx, wy), wy / math.hypot(wx, wy)
864
+ px, py = -uy, ux
865
+ vx, vy = x3 - x1, y3 - y1
866
+ h_len = vx * px + vy * py
867
+ if abs(h_len) < 0.5:
868
+ return None, "Height too small"
869
+ hx, hy = px * h_len, py * h_len
870
+ corners_merc = [
871
+ (x1, y1),
872
+ (x2, y2),
873
+ (x2 + hx, y2 + hy),
874
+ (x1 + hx, y1 + hy),
875
+ ]
876
+ return [to_geo.transform(*p) for p in corners_merc], None
877
+
878
+ def handle_map_interaction(**kwargs):
879
+ if not rect_btn.value:
880
+ return
881
+
882
+ if kwargs.get("type") == "click":
883
+ coords = kwargs.get("coordinates")
884
+ if not coords:
885
+ return
886
+ lat, lon = coords
887
+ state["clicks"].append((lon, lat))
888
+ refresh_markers()
889
+
890
+ count = len(state["clicks"])
891
+ if count == 1:
892
+ set_status("📐 <b>Step 2/3:</b> Click second corner", "info")
893
+ elif count == 2:
894
+ (l1, la1), (l2, la2) = state["clicks"]
895
+ x1, y1 = to_merc.transform(l1, la1)
896
+ x2, y2 = to_merc.transform(l2, la2)
897
+ if math.hypot(x2 - x1, y2 - y1) < 0.5:
898
+ state["clicks"].pop()
899
+ refresh_markers()
900
+ set_status("⚠️ Too close! Click further away", "warn")
901
+ else:
902
+ set_status("📐 <b>Step 3/3:</b> Click opposite side", "info")
903
+ elif count == 3:
904
+ verts, err = build_rect(state["clicks"])
905
+ if err:
906
+ state["clicks"].pop()
907
+ set_status(f"⚠️ {err} - try again", "warn")
908
+ else:
909
+ clear_preview()
910
+ clear_temps()
911
+ state["poly"] = verts
912
+ poly_locs = [(lat, lon) for lon, lat in verts]
913
+ preview = LeafletPolygon(
914
+ locations=poly_locs,
915
+ color="#4CAF50",
916
+ fill_color="#4CAF50",
917
+ fill_opacity=0.3,
918
+ )
919
+ state["preview"] = preview
920
+ m.add_layer(preview)
921
+ add_btn.disabled = False
922
+ clr_btn.disabled = False
923
+ state["clicks"] = []
924
+ rect_btn.value = False
925
+ set_status("✅ Shape ready! Set height → <b>Add</b>", "success")
926
+
927
+ elif kwargs.get("type") == "mousemove":
928
+ coords = kwargs.get("coordinates")
929
+ if not coords:
930
+ return
931
+ lat_c, lon_c = coords
932
+ if len(state["clicks"]) == 1:
933
+ clear_preview()
934
+ (lon1, lat1) = state["clicks"][0]
935
+ line = Polyline(
936
+ locations=[(lat1, lon1), (lat_c, lon_c)],
937
+ color="#FF5722",
938
+ weight=2,
939
+ dash_array="5, 5",
940
+ )
941
+ state["preview"] = line
942
+ m.add_layer(line)
943
+ elif len(state["clicks"]) == 2:
944
+ tentative_clicks = state["clicks"] + [(lon_c, lat_c)]
945
+ verts, err = build_rect(tentative_clicks)
946
+ clear_preview()
947
+ if not err:
948
+ poly_locs = [(lat, lon) for lon, lat in verts]
949
+ poly = LeafletPolygon(
950
+ locations=poly_locs,
951
+ color="#FF5722",
952
+ weight=2,
953
+ fill_color="#FF5722",
954
+ fill_opacity=0.1,
955
+ dash_array="5, 5",
956
+ )
957
+ state["preview"] = poly
958
+ m.add_layer(poly)
959
+
960
+ m.on_interaction(handle_map_interaction)
961
+
962
+ def handle_freehand(self, action, geo_json):
963
+ if action == "created" and geo_json["geometry"]["type"] == "Polygon":
964
+ coords = geo_json["geometry"]["coordinates"][0]
965
+ polygon_coords = [(c[0], c[1]) for c in coords[:-1]]
966
+
967
+ # Check if we're in polygon removal mode
968
+ if poly_del_btn.value:
969
+ # Create the removal polygon and find buildings to remove
970
+ removal_polygon = geom.Polygon(polygon_coords)
971
+ state["removal_poly"] = removal_polygon
972
+
973
+ # Find all buildings within or intersecting the drawn polygon
974
+ buildings_to_remove = []
975
+ for idx, row in updated_gdf.iterrows():
976
+ if isinstance(row.geometry, geom.Polygon):
977
+ if removal_polygon.contains(row.geometry) or removal_polygon.intersects(row.geometry):
978
+ buildings_to_remove.append(idx)
979
+
980
+ if buildings_to_remove:
981
+ # Highlight buildings that will be removed
982
+ clear_removal_preview()
983
+
984
+ # Create a preview polygon showing the selection area
985
+ preview_locs = [(lat, lon) for lon, lat in polygon_coords]
986
+ preview = LeafletPolygon(
987
+ locations=preview_locs,
988
+ color="#FF0000",
989
+ fill_color="#FF0000",
990
+ fill_opacity=0.2,
991
+ weight=2,
992
+ dash_array="5, 5",
993
+ )
994
+ state["removal_preview"] = preview
995
+ m.add_layer(preview)
996
+
997
+ # Remove the buildings
998
+ removed_count = 0
999
+ for idx in buildings_to_remove:
1000
+ if idx in building_layers:
1001
+ try:
1002
+ m.remove_layer(building_layers[idx])
1003
+ del building_layers[idx]
1004
+ except Exception:
1005
+ pass
1006
+ try:
1007
+ updated_gdf.drop(index=idx, inplace=True)
1008
+ removed_count += 1
1009
+ except KeyError:
1010
+ pass
1011
+
1012
+ draw_control.clear()
1013
+ clear_removal_preview()
1014
+ poly_del_btn.value = False # Exit poly delete mode after removal
1015
+ m.default_style = original_style
1016
+ set_status(f"🗑️ Removed {removed_count} building(s)", "success")
1017
+ else:
1018
+ draw_control.clear()
1019
+ set_status("⚠️ No buildings in area", "warn")
1020
+ else:
1021
+ # Normal mode - adding a freehand polygon as building
1022
+ rect_btn.value = False
1023
+ del_btn.value = False
1024
+ poly_del_btn.value = False
1025
+ state["clicks"] = []
1026
+ clear_preview()
1027
+ clear_temps()
1028
+ state["poly"] = polygon_coords
1029
+ add_btn.disabled = False
1030
+ clr_btn.disabled = False
1031
+ set_status("✅ Shape ready! Set height → <b>Add</b>", "success")
1032
+
1033
+ draw_control = DrawControl(
1034
+ polygon={"shapeOptions": {"color": "#FF5722", "fillColor": "#FF5722", "fillOpacity": 0.2}},
1035
+ rectangle={},
1036
+ circle={},
1037
+ polyline={},
1038
+ marker={},
1039
+ circlemarker={},
1040
+ )
1041
+ draw_control.on_draw(handle_freehand)
1042
+ m.add_control(draw_control)
1043
+
1044
+ def add_geom(b):
1045
+ if not state["poly"]:
1046
+ return
1047
+ try:
1048
+ poly = geom.Polygon(state["poly"])
1049
+ new_idx = (updated_gdf.index.max() + 1) if not updated_gdf.empty else 1
1050
+ updated_gdf.loc[new_idx] = {
1051
+ "geometry": poly,
1052
+ "height": h_in.value,
1053
+ "min_height": mh_in.value,
1054
+ "building_id": new_idx,
1055
+ "id": new_idx,
1056
+ }
1057
+ add_polygon_to_map(poly, new_idx, h_in.value)
1058
+ clear_all(None)
1059
+ set_status(f"🏢 Added! H={h_in.value}m (ID:{new_idx})", "success")
1060
+ except Exception as e:
1061
+ set_status(f"❌ Error: {str(e)[:30]}", "danger")
1062
+
1063
+ def clear_all(b):
1064
+ draw_control.clear()
1065
+ clear_preview()
1066
+ clear_temps()
1067
+ state["clicks"] = []
1068
+ state["poly"] = []
1069
+ add_btn.disabled = True
1070
+ clr_btn.disabled = True
1071
+ if b:
1072
+ set_status("Cleared. Draw new shape.", "warn")
1073
+
1074
+ add_btn.on_click(add_geom)
1075
+ clr_btn.on_click(clear_all)
1076
+
1077
+ return m, updated_gdf
1078
+
1079
+
1080
+ def get_polygon_vertices(drawn_polygons, polygon_id=None):
1081
+ """
1082
+ Extract vertices from drawn polygons data structure.
1083
+
1084
+ This helper function provides a convenient way to extract polygon vertices
1085
+ from the drawn_polygons list returned by display_buildings_and_draw_polygon().
1086
+
1087
+ Args:
1088
+ drawn_polygons: The drawn_polygons list returned from display_buildings_and_draw_polygon()
1089
+ polygon_id (int, optional): Specific polygon ID to extract. If None, returns all polygons.
1090
+
1091
+ Returns:
1092
+ If polygon_id is specified: List of (lon, lat) tuples for that polygon
1093
+ If polygon_id is None: List of lists, where each inner list contains (lon, lat) tuples
1094
+
1095
+ Example:
1096
+ >>> m, polygons = display_buildings_and_draw_polygon()
1097
+ >>> # Draw some polygons...
1098
+ >>> vertices = get_polygon_vertices(polygons, polygon_id=1) # Get polygon 1
1099
+ >>> all_vertices = get_polygon_vertices(polygons) # Get all polygons
1100
+ """
1101
+ if not drawn_polygons:
1102
+ return []
1103
+
1104
+ if polygon_id is not None:
1105
+ # Return specific polygon
1106
+ for polygon in drawn_polygons:
1107
+ if polygon['id'] == polygon_id:
1108
+ return polygon['vertices']
1109
+ return [] # Polygon not found
1110
+ else:
1111
+ # Return all polygons
1112
+ return [polygon['vertices'] for polygon in drawn_polygons]
1113
+
1114
+
1115
+ # Simple convenience function
1116
+ def create_building_editor(building_gdf=None, initial_center=None, zoom=17, rectangle_vertices=None):
1117
+ """
1118
+ Creates and displays an interactive building editor.
1119
+
1120
+ Args:
1121
+ building_gdf: Existing buildings GeoDataFrame (optional)
1122
+ initial_center: Map center as (lon, lat) tuple (optional)
1123
+ zoom: Initial zoom level (default=17)
1124
+
1125
+ Returns:
1126
+ GeoDataFrame: The building GeoDataFrame that automatically updates
1127
+
1128
+ Example:
1129
+ >>> buildings = create_building_editor()
1130
+ >>> # Draw buildings on the displayed map
1131
+ >>> print(buildings) # Automatically contains all drawn buildings
1132
+ """
1133
+ m, gdf = draw_additional_buildings(
1134
+ building_gdf=building_gdf,
1135
+ initial_center=initial_center,
1136
+ zoom=zoom,
1137
+ rectangle_vertices=rectangle_vertices,
1138
+ )
1139
+ display(m)
1140
+ return gdf
1141
+
1142
+
1143
+ def draw_additional_trees(voxcity=None, initial_center=None, zoom=17):
1144
+ """
1145
+ Creates an interactive map to add trees by clicking and setting parameters.
1146
+
1147
+ Users can:
1148
+ - Set tree parameters: top height, bottom height, crown diameter
1149
+ - Click multiple times to add multiple trees with the same parameters
1150
+ - Update parameters at any time to change subsequent trees
1151
+
1152
+ Args:
1153
+ voxcity (VoxCity, optional): A VoxCity object from which to extract tree_gdf
1154
+ and rectangle_vertices. If provided, tree_gdf is extracted from
1155
+ voxcity.extras['tree_gdf'] and rectangle_vertices from
1156
+ voxcity.extras['rectangle_vertices'].
1157
+ initial_center (tuple, optional): (lon, lat) for initial map center.
1158
+ zoom (int): Initial zoom level. Default=17.
1159
+
1160
+ Returns:
1161
+ tuple: (map_object, updated_tree_gdf)
1162
+
1163
+ Examples:
1164
+ Using a VoxCity object:
1165
+ >>> m, tree_gdf = draw_additional_trees(voxcity=my_voxcity)
1166
+
1167
+ Using with custom center and zoom:
1168
+ >>> m, tree_gdf = draw_additional_trees(voxcity=my_voxcity, initial_center=(-73.98, 40.75), zoom=18)
1169
+ """
1170
+ # ---------------------------------------------------------
1171
+ # Extract data from VoxCity object if provided
1172
+ # ---------------------------------------------------------
1173
+ tree_gdf = None
1174
+ rectangle_vertices = None
1175
+ if voxcity is not None:
1176
+ tree_gdf = voxcity.extras.get('tree_gdf', None)
1177
+ rectangle_vertices = voxcity.extras.get('rectangle_vertices', None)
1178
+
1179
+ # Initialize or copy the tree GeoDataFrame
1180
+ if tree_gdf is None:
1181
+ updated_trees = gpd.GeoDataFrame(
1182
+ columns=['tree_id', 'top_height', 'bottom_height', 'crown_diameter', 'geometry'],
1183
+ crs='EPSG:4326'
1184
+ )
1185
+ else:
1186
+ updated_trees = tree_gdf.copy()
1187
+ # Ensure required columns exist
1188
+ if 'tree_id' not in updated_trees.columns:
1189
+ updated_trees['tree_id'] = range(1, len(updated_trees) + 1)
1190
+ for col, default in [('top_height', 10.0), ('bottom_height', 4.0), ('crown_diameter', 6.0)]:
1191
+ if col not in updated_trees.columns:
1192
+ updated_trees[col] = default
1193
+
1194
+ # Determine map center
1195
+ if initial_center is not None:
1196
+ center_lon, center_lat = initial_center
1197
+ elif updated_trees is not None and len(updated_trees) > 0:
1198
+ min_lon, min_lat, max_lon, max_lat = updated_trees.total_bounds
1199
+ center_lon = (min_lon + max_lon) / 2
1200
+ center_lat = (min_lat + max_lat) / 2
1201
+ elif rectangle_vertices is not None:
1202
+ center_lon, center_lat = (rectangle_vertices[0][0] + rectangle_vertices[2][0]) / 2, (rectangle_vertices[0][1] + rectangle_vertices[2][1]) / 2
1203
+ else:
1204
+ center_lon, center_lat = -100.0, 40.0
1205
+
1206
+ # Create map
1207
+ m = Map(center=(center_lat, center_lon), zoom=zoom, scroll_wheel_zoom=True)
1208
+ # Add Google Satellite basemap with Esri fallback
1209
+ try:
1210
+ google_sat = TileLayer(
1211
+ url='https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
1212
+ name='Google Satellite',
1213
+ attribution='Google Satellite'
1214
+ )
1215
+ # Replace default base layer with Google Satellite
1216
+ m.layers = tuple([google_sat])
1217
+ except Exception:
1218
+ try:
1219
+ m.layers = tuple([basemap_to_tiles(basemaps.Esri.WorldImagery)])
1220
+ except Exception:
1221
+ # Fallback silently if basemap cannot be added
1222
+ pass
1223
+
1224
+ # If rectangle_vertices provided, draw its edges on the map
1225
+ if rectangle_vertices is not None and len(rectangle_vertices) >= 4:
1226
+ try:
1227
+ lat_lon_coords = [(lat, lon) for lon, lat in rectangle_vertices]
1228
+ rect_outline = LeafletPolygon(
1229
+ locations=lat_lon_coords,
1230
+ color="#fed766",
1231
+ weight=2,
1232
+ fill_color="#fed766",
1233
+ fill_opacity=0.0
1234
+ )
1235
+ m.add_layer(rect_outline)
1236
+ except Exception:
1237
+ pass
1238
+
1239
+ # Display existing trees as circles
1240
+ tree_layers = {}
1241
+ for idx, row in updated_trees.iterrows():
1242
+ if row.geometry is not None and hasattr(row.geometry, 'x'):
1243
+ lat = row.geometry.y
1244
+ lon = row.geometry.x
1245
+ # Ensure integer radius in meters as required by ipyleaflet Circle
1246
+ radius_m = max(int(round(float(row.get('crown_diameter', 6.0)) / 2.0)), 1)
1247
+ tree_id_val = int(row.get('tree_id', idx+1))
1248
+ circle = Circle(location=(lat, lon), radius=radius_m, color='#2ab7ca', weight=1, opacity=1.0, fill_color='#2ab7ca', fill_opacity=0.3)
1249
+ m.add_layer(circle)
1250
+ tree_layers[tree_id_val] = circle
1251
+
1252
+ # UI widgets for parameters
1253
+ top_height_input = FloatText(value=10.0, description='Top height (m):', disabled=False, style={'description_width': 'initial'})
1254
+ bottom_height_input = FloatText(value=4.0, description='Bottom height (m):', disabled=False, style={'description_width': 'initial'})
1255
+ crown_diameter_input = FloatText(value=6.0, description='Crown diameter (m):', disabled=False, style={'description_width': 'initial'})
1256
+ fixed_prop_checkbox = Checkbox(value=True, description='Fixed proportion', indent=False)
1257
+
1258
+ add_mode_button = Button(description='Add', button_style='success')
1259
+ remove_mode_button = Button(description='Remove', button_style='')
1260
+ status_output = Output()
1261
+ hover_info = HTML("")
1262
+
1263
+ control_panel = VBox([
1264
+ HTML("<h3 style=\"margin:0 0 4px 0;\">Tree Placement Tool</h3>"),
1265
+ HTML("<div style=\"margin:0 0 6px 0;\">1. Choose Add/Remove mode<br>2. Set tree parameters (top, bottom, crown)<br>3. Click on the map to add/remove consecutively<br>4. Hover over a tree to view parameters</div>"),
1266
+ HBox([add_mode_button, remove_mode_button]),
1267
+ top_height_input,
1268
+ bottom_height_input,
1269
+ crown_diameter_input,
1270
+ fixed_prop_checkbox,
1271
+ hover_info,
1272
+ status_output
1273
+ ])
1274
+
1275
+ widget_control = WidgetControl(widget=control_panel, position='topright')
1276
+ m.add_control(widget_control)
1277
+
1278
+ # State for mode
1279
+ mode = 'add'
1280
+ # Fixed proportion state
1281
+ base_bottom_ratio = bottom_height_input.value / top_height_input.value if top_height_input.value else 0.4
1282
+ base_crown_ratio = crown_diameter_input.value / top_height_input.value if top_height_input.value else 0.6
1283
+ updating_params = False
1284
+
1285
+ def recompute_from_top(new_top: float):
1286
+ nonlocal updating_params
1287
+ if new_top <= 0:
1288
+ return
1289
+ new_bottom = max(0.0, base_bottom_ratio * new_top)
1290
+ new_crown = max(0.0, base_crown_ratio * new_top)
1291
+ updating_params = True
1292
+ bottom_height_input.value = new_bottom
1293
+ crown_diameter_input.value = new_crown
1294
+ updating_params = False
1295
+
1296
+ def recompute_from_bottom(new_bottom: float):
1297
+ nonlocal updating_params
1298
+ if base_bottom_ratio <= 0:
1299
+ return
1300
+ new_top = max(0.0, new_bottom / base_bottom_ratio)
1301
+ new_crown = max(0.0, base_crown_ratio * new_top)
1302
+ updating_params = True
1303
+ top_height_input.value = new_top
1304
+ crown_diameter_input.value = new_crown
1305
+ updating_params = False
1306
+
1307
+ def recompute_from_crown(new_crown: float):
1308
+ nonlocal updating_params
1309
+ if base_crown_ratio <= 0:
1310
+ return
1311
+ new_top = max(0.0, new_crown / base_crown_ratio)
1312
+ new_bottom = max(0.0, base_bottom_ratio * new_top)
1313
+ updating_params = True
1314
+ top_height_input.value = new_top
1315
+ bottom_height_input.value = new_bottom
1316
+ updating_params = False
1317
+
1318
+ def on_toggle_fixed(change):
1319
+ nonlocal base_bottom_ratio, base_crown_ratio
1320
+ if change['name'] == 'value':
1321
+ if change['new']:
1322
+ # Capture current ratios as baseline
1323
+ top = float(top_height_input.value) or 1.0
1324
+ bot = float(bottom_height_input.value)
1325
+ crn = float(crown_diameter_input.value)
1326
+ base_bottom_ratio = max(0.0, bot / top)
1327
+ base_crown_ratio = max(0.0, crn / top)
1328
+ else:
1329
+ # Keep last ratios but do not auto-update
1330
+ pass
1331
+
1332
+ def on_top_change(change):
1333
+ if change['name'] == 'value' and fixed_prop_checkbox.value and not updating_params:
1334
+ try:
1335
+ recompute_from_top(float(change['new']))
1336
+ except Exception:
1337
+ pass
1338
+
1339
+ def on_bottom_change(change):
1340
+ if change['name'] == 'value' and fixed_prop_checkbox.value and not updating_params:
1341
+ try:
1342
+ recompute_from_bottom(float(change['new']))
1343
+ except Exception:
1344
+ pass
1345
+
1346
+ def on_crown_change(change):
1347
+ if change['name'] == 'value' and fixed_prop_checkbox.value and not updating_params:
1348
+ try:
1349
+ recompute_from_crown(float(change['new']))
1350
+ except Exception:
1351
+ pass
1352
+
1353
+ fixed_prop_checkbox.observe(on_toggle_fixed, names='value')
1354
+ top_height_input.observe(on_top_change, names='value')
1355
+ bottom_height_input.observe(on_bottom_change, names='value')
1356
+ crown_diameter_input.observe(on_crown_change, names='value')
1357
+
1358
+ def set_mode(new_mode):
1359
+ nonlocal mode
1360
+ mode = new_mode
1361
+ # Visual feedback
1362
+ add_mode_button.button_style = 'success' if mode == 'add' else ''
1363
+ remove_mode_button.button_style = 'danger' if mode == 'remove' else ''
1364
+ # No on-screen mode label
1365
+
1366
+ def on_click_add(b):
1367
+ set_mode('add')
1368
+
1369
+ def on_click_remove(b):
1370
+ set_mode('remove')
1371
+
1372
+ add_mode_button.on_click(on_click_add)
1373
+ remove_mode_button.on_click(on_click_remove)
1374
+
1375
+ # Consecutive placements by map click
1376
+ def handle_map_click(**kwargs):
1377
+ nonlocal updated_trees
1378
+ with status_output:
1379
+ clear_output()
1380
+
1381
+ if kwargs.get('type') == 'click':
1382
+ lat, lon = kwargs.get('coordinates', (None, None))
1383
+ if lat is None or lon is None:
1384
+ return
1385
+ if mode == 'add':
1386
+ # Determine next tree_id
1387
+ next_tree_id = int(updated_trees['tree_id'].max() + 1) if len(updated_trees) > 0 else 1
1388
+
1389
+ # Clamp/validate parameters
1390
+ th = float(top_height_input.value)
1391
+ bh = float(bottom_height_input.value)
1392
+ cd = float(crown_diameter_input.value)
1393
+ if bh > th:
1394
+ bh, th = th, bh
1395
+ if cd < 0:
1396
+ cd = 0.0
1397
+
1398
+ # Create new tree row
1399
+ new_row = {
1400
+ 'tree_id': next_tree_id,
1401
+ 'top_height': th,
1402
+ 'bottom_height': bh,
1403
+ 'crown_diameter': cd,
1404
+ 'geometry': geom.Point(lon, lat)
1405
+ }
1406
+
1407
+ # Append
1408
+ new_index = len(updated_trees)
1409
+ updated_trees.loc[new_index] = new_row
1410
+
1411
+ # Add circle layer representing crown diameter (radius in meters)
1412
+ radius_m = max(int(round(new_row['crown_diameter'] / 2.0)), 1)
1413
+ circle = Circle(location=(lat, lon), radius=radius_m, color='#2ab7ca', weight=1, opacity=1.0, fill_color='#2ab7ca', fill_opacity=0.3)
1414
+ m.add_layer(circle)
1415
+
1416
+ tree_layers[next_tree_id] = circle
1417
+
1418
+ # Suppress status prints on add
1419
+ else:
1420
+ # Remove mode: find the nearest tree within its crown radius + 5m
1421
+ candidate_id = None
1422
+ candidate_idx = None
1423
+ candidate_dist = None
1424
+ for idx2, row2 in updated_trees.iterrows():
1425
+ if row2.geometry is None or not hasattr(row2.geometry, 'x'):
1426
+ continue
1427
+ lat2 = row2.geometry.y
1428
+ lon2 = row2.geometry.x
1429
+ dist_m = distance.distance((lat, lon), (lat2, lon2)).meters
1430
+ rad_m = max(int(round(float(row2.get('crown_diameter', 6.0)) / 2.0)), 1)
1431
+ thr_m = rad_m + 5
1432
+ if (candidate_dist is None and dist_m <= thr_m) or (candidate_dist is not None and dist_m < candidate_dist and dist_m <= thr_m):
1433
+ candidate_dist = dist_m
1434
+ candidate_id = int(row2.get('tree_id', idx2+1))
1435
+ candidate_idx = idx2
1436
+
1437
+ if candidate_id is not None:
1438
+ # Remove layer
1439
+ layer = tree_layers.get(candidate_id)
1440
+ if layer is not None:
1441
+ m.remove_layer(layer)
1442
+ del tree_layers[candidate_id]
1443
+ # Remove from gdf
1444
+ updated_trees.drop(index=candidate_idx, inplace=True)
1445
+ updated_trees.reset_index(drop=True, inplace=True)
1446
+ # Suppress status prints on remove
1447
+ else:
1448
+ # Suppress status prints when nothing to remove
1449
+ pass
1450
+ elif kwargs.get('type') == 'mousemove':
1451
+ lat, lon = kwargs.get('coordinates', (None, None))
1452
+ if lat is None or lon is None:
1453
+ return
1454
+ # Find a tree the cursor is over (within crown radius)
1455
+ shown = False
1456
+ for _, row2 in updated_trees.iterrows():
1457
+ if row2.geometry is None or not hasattr(row2.geometry, 'x'):
1458
+ continue
1459
+ lat2 = row2.geometry.y
1460
+ lon2 = row2.geometry.x
1461
+ dist_m = distance.distance((lat, lon), (lat2, lon2)).meters
1462
+ rad_m = max(int(round(float(row2.get('crown_diameter', 6.0)) / 2.0)), 1)
1463
+ if dist_m <= rad_m:
1464
+ hover_info.value = (
1465
+ f"<div style=\"color:#d61f1f; font-weight:600; margin:2px 0;\">"
1466
+ f"Tree {int(row2.get('tree_id', 0))} | Top {float(row2.get('top_height', 10.0))} m | "
1467
+ f"Bottom {float(row2.get('bottom_height', 0.0))} m | Crown {float(row2.get('crown_diameter', 6.0))} m"
1468
+ f"</div>"
1469
+ )
1470
+ shown = True
1471
+ break
1472
+ if not shown:
1473
+ hover_info.value = ""
1474
+ m.on_interaction(handle_map_click)
1475
+
1476
+ with status_output:
1477
+ print(f"Total trees loaded: {len(updated_trees)}")
1478
+ print("Set parameters, then click on the map to add trees")
1479
+
1480
+ return m, updated_trees
1481
+
1482
+
1483
+ def create_tree_editor(tree_gdf=None, initial_center=None, zoom=17, rectangle_vertices=None):
1484
+ """
1485
+ Convenience wrapper to display the tree editor map and return the GeoDataFrame.
1486
+ """
1487
+ m, gdf = draw_additional_trees(tree_gdf, initial_center, zoom, rectangle_vertices)
1488
+ display(m)
1170
1489
  return gdf