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

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