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