voxcity 0.6.11__py3-none-any.whl → 0.6.12__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.

Potentially problematic release.


This version of voxcity might be problematic. Click here for more details.

@@ -1,833 +1,1086 @@
1
- """
2
- This module provides functions for drawing and manipulating rectangles and polygons on interactive maps.
3
- It serves as a core component for defining geographical regions of interest in the VoxCity library.
4
-
5
- Key Features:
6
- - Interactive rectangle drawing on maps using ipyleaflet
7
- - Rectangle rotation with coordinate system transformations
8
- - City-centered map initialization
9
- - Fixed-dimension rectangle creation from center points
10
- - Building footprint visualization and polygon drawing
11
- - Support for both WGS84 and Web Mercator projections
12
- - Coordinate format handling between (lon,lat) and (lat,lon)
13
-
14
- The module maintains consistent coordinate order conventions:
15
- - Internal storage: (lon,lat) format to match GeoJSON standard
16
- - ipyleaflet interface: (lat,lon) format as required by the library
17
- - All return values: (lon,lat) format for consistency
18
-
19
- Dependencies:
20
- - ipyleaflet: For interactive map display and drawing controls
21
- - pyproj: For coordinate system transformations
22
- - geopy: For distance calculations
23
- - shapely: For geometric operations
24
- """
25
-
26
- import math
27
- from pyproj import Proj, transform
28
- from ipyleaflet import (
29
- Map,
30
- DrawControl,
31
- Rectangle,
32
- Polygon as LeafletPolygon,
33
- WidgetControl
34
- )
35
- from geopy import distance
36
- import shapely.geometry as geom
37
- import geopandas as gpd
38
- from ipywidgets import VBox, HBox, Button, FloatText, Label, Output, HTML
39
- import pandas as pd
40
- from IPython.display import display, clear_output
41
-
42
- from .utils import get_coordinates_from_cityname
43
-
44
- def rotate_rectangle(m, rectangle_vertices, angle):
45
- """
46
- Project rectangle to Mercator, rotate, and re-project to lat-lon coordinates.
47
-
48
- This function performs a rotation of a rectangle in geographic space by:
49
- 1. Converting coordinates from WGS84 (lat/lon) to Web Mercator projection
50
- 2. Performing the rotation in the projected space for accurate distance preservation
51
- 3. Converting back to WGS84 coordinates
52
- 4. Visualizing the result on the provided map
53
-
54
- The rotation is performed around the rectangle's centroid using a standard 2D rotation matrix.
55
- The function handles coordinate system transformations to ensure geometrically accurate rotations
56
- despite the distortions inherent in geographic projections.
57
-
58
- Args:
59
- m (ipyleaflet.Map): Map object to draw the rotated rectangle on.
60
- The map must be initialized and have a valid center and zoom level.
61
- rectangle_vertices (list): List of (lon, lat) tuples defining the rectangle vertices.
62
- The vertices should be ordered in a counter-clockwise direction.
63
- Example: [(lon1,lat1), (lon2,lat2), (lon3,lat3), (lon4,lat4)]
64
- angle (float): Rotation angle in degrees.
65
- Positive angles rotate counter-clockwise.
66
- Negative angles rotate clockwise.
67
-
68
- Returns:
69
- list: List of rotated (lon, lat) tuples defining the new rectangle vertices.
70
- The vertices maintain their original ordering.
71
- Returns None if no rectangle vertices are provided.
72
-
73
- Note:
74
- The function uses EPSG:4326 (WGS84) for geographic coordinates and
75
- EPSG:3857 (Web Mercator) for the rotation calculations.
76
- """
77
- if not rectangle_vertices:
78
- print("Draw a rectangle first!")
79
- return
80
-
81
- # Define projections - need to convert between coordinate systems for accurate rotation
82
- wgs84 = Proj(init='epsg:4326') # WGS84 lat-lon (standard GPS coordinates)
83
- mercator = Proj(init='epsg:3857') # Web Mercator (projection used by most web maps)
84
-
85
- # Project vertices from WGS84 to Web Mercator for proper distance calculations
86
- projected_vertices = [transform(wgs84, mercator, lon, lat) for lon, lat in rectangle_vertices]
87
-
88
- # Calculate the centroid to use as rotation center
89
- centroid_x = sum(x for x, y in projected_vertices) / len(projected_vertices)
90
- centroid_y = sum(y for x, y in projected_vertices) / len(projected_vertices)
91
-
92
- # Convert angle to radians (negative for clockwise rotation)
93
- angle_rad = -math.radians(angle)
94
-
95
- # Rotate each vertex around the centroid using standard 2D rotation matrix
96
- rotated_vertices = []
97
- for x, y in projected_vertices:
98
- # Translate point to origin for rotation
99
- temp_x = x - centroid_x
100
- temp_y = y - centroid_y
101
-
102
- # Apply rotation matrix
103
- rotated_x = temp_x * math.cos(angle_rad) - temp_y * math.sin(angle_rad)
104
- rotated_y = temp_x * math.sin(angle_rad) + temp_y * math.cos(angle_rad)
105
-
106
- # Translate point back to original position
107
- new_x = rotated_x + centroid_x
108
- new_y = rotated_y + centroid_y
109
-
110
- rotated_vertices.append((new_x, new_y))
111
-
112
- # Convert coordinates back to WGS84 (lon/lat)
113
- new_vertices = [transform(mercator, wgs84, x, y) for x, y in rotated_vertices]
114
-
115
- # Create and add new polygon layer to map
116
- polygon = ipyleaflet.Polygon(
117
- locations=[(lat, lon) for lon, lat in new_vertices], # Convert to (lat,lon) for ipyleaflet
118
- color="red",
119
- fill_color="red"
120
- )
121
- m.add_layer(polygon)
122
-
123
- return new_vertices
124
-
125
- def draw_rectangle_map(center=(40, -100), zoom=4):
126
- """
127
- Create an interactive map for drawing rectangles with ipyleaflet.
128
-
129
- This function initializes an interactive map that allows users to draw rectangles
130
- by clicking and dragging on the map surface. The drawn rectangles are captured
131
- and their vertices are stored in geographic coordinates.
132
-
133
- The map interface provides:
134
- - A rectangle drawing tool activated by default
135
- - Real-time coordinate capture of drawn shapes
136
- - Automatic vertex ordering in counter-clockwise direction
137
- - Console output of vertex coordinates for verification
138
-
139
- Drawing Controls:
140
- - Click and drag to draw a rectangle
141
- - Release to complete the rectangle
142
- - Only one rectangle can be active at a time
143
- - Drawing a new rectangle clears the previous one
144
-
145
- Args:
146
- center (tuple): Center coordinates (lat, lon) for the map view.
147
- Defaults to (40, -100) which centers on the continental United States.
148
- Format: (latitude, longitude) in decimal degrees.
149
- zoom (int): Initial zoom level for the map. Defaults to 4.
150
- Range: 0 (most zoomed out) to 18 (most zoomed in).
151
- Recommended: 3-6 for countries, 10-15 for cities.
152
-
153
- Returns:
154
- tuple: (Map object, list of rectangle vertices)
155
- - Map object: ipyleaflet.Map instance for displaying and interacting with the map
156
- - rectangle_vertices: Empty list that will be populated with (lon,lat) tuples
157
- when a rectangle is drawn. Coordinates are stored in GeoJSON order (lon,lat).
158
-
159
- Note:
160
- The function disables all drawing tools except rectangles to ensure
161
- consistent shape creation. The rectangle vertices are automatically
162
- converted to (lon,lat) format when stored, regardless of the input
163
- center coordinate order.
164
- """
165
- # Initialize the map centered at specified coordinates
166
- m = Map(center=center, zoom=zoom)
167
-
168
- # List to store the vertices of drawn rectangle
169
- rectangle_vertices = []
170
-
171
- def handle_draw(target, action, geo_json):
172
- """Handle draw events on the map."""
173
- # Clear any previously stored vertices
174
- rectangle_vertices.clear()
175
-
176
- # Process only if a rectangle polygon was drawn
177
- if action == 'created' and geo_json['geometry']['type'] == 'Polygon':
178
- # Extract coordinates from GeoJSON format
179
- coordinates = geo_json['geometry']['coordinates'][0]
180
- print("Vertices of the drawn rectangle:")
181
- # Store all vertices except last (GeoJSON repeats first vertex at end)
182
- for coord in coordinates[:-1]:
183
- # Keep GeoJSON (lon,lat) format
184
- rectangle_vertices.append((coord[0], coord[1]))
185
- print(f"Longitude: {coord[0]}, Latitude: {coord[1]}")
186
-
187
- # Configure drawing controls - only enable rectangle drawing
188
- draw_control = DrawControl()
189
- draw_control.polyline = {}
190
- draw_control.polygon = {}
191
- draw_control.circle = {}
192
- draw_control.rectangle = {
193
- "shapeOptions": {
194
- "color": "#6bc2e5",
195
- "weight": 4,
196
- }
197
- }
198
- m.add_control(draw_control)
199
-
200
- # Register event handler for drawing actions
201
- draw_control.on_draw(handle_draw)
202
-
203
- return m, rectangle_vertices
204
-
205
- def draw_rectangle_map_cityname(cityname, zoom=15):
206
- """
207
- Create an interactive map centered on a specified city for drawing rectangles.
208
-
209
- This function extends draw_rectangle_map() by automatically centering the map
210
- on a specified city using geocoding. It provides a convenient way to focus
211
- the drawing interface on a particular urban area without needing to know
212
- its exact coordinates.
213
-
214
- The function uses the utils.get_coordinates_from_cityname() function to
215
- geocode the city name and obtain its coordinates. The resulting map is
216
- zoomed to an appropriate level for urban-scale analysis.
217
-
218
- Args:
219
- cityname (str): Name of the city to center the map on.
220
- Can include country or state for better accuracy.
221
- Examples: "Tokyo, Japan", "New York, NY", "Paris, France"
222
- zoom (int): Initial zoom level for the map. Defaults to 15.
223
- Range: 0 (most zoomed out) to 18 (most zoomed in).
224
- Default of 15 is optimized for city-level visualization.
225
-
226
- Returns:
227
- tuple: (Map object, list of rectangle vertices)
228
- - Map object: ipyleaflet.Map instance centered on the specified city
229
- - rectangle_vertices: Empty list that will be populated with (lon,lat)
230
- tuples when a rectangle is drawn
231
-
232
- Note:
233
- If the city name cannot be geocoded, the function will raise an error.
234
- For better results, provide specific city names with country/state context.
235
- The function inherits all drawing controls and behavior from draw_rectangle_map().
236
- """
237
- # Get coordinates for the specified city
238
- center = get_coordinates_from_cityname(cityname)
239
- m, rectangle_vertices = draw_rectangle_map(center=center, zoom=zoom)
240
- return m, rectangle_vertices
241
-
242
- def center_location_map_cityname(cityname, east_west_length, north_south_length, zoom=15):
243
- """
244
- Create an interactive map centered on a city where clicking creates a rectangle of specified dimensions.
245
-
246
- This function provides a specialized interface for creating fixed-size rectangles
247
- centered on user-selected points. Instead of drawing rectangles by dragging,
248
- users click a point on the map and a rectangle of the specified dimensions
249
- is automatically created centered on that point.
250
-
251
- The function handles:
252
- - Automatic city geocoding and map centering
253
- - Distance calculations in meters using geopy
254
- - Conversion between geographic and metric distances
255
- - Rectangle creation with specified dimensions
256
- - Visualization of created rectangles
257
-
258
- Workflow:
259
- 1. Map is centered on the specified city
260
- 2. User clicks a point on the map
261
- 3. A rectangle is created centered on that point
262
- 4. Rectangle dimensions are maintained in meters regardless of latitude
263
- 5. Previous rectangles are automatically cleared
264
-
265
- Args:
266
- cityname (str): Name of the city to center the map on.
267
- Can include country or state for better accuracy.
268
- Examples: "Tokyo, Japan", "New York, NY"
269
- east_west_length (float): Width of the rectangle in meters.
270
- This is the dimension along the east-west direction.
271
- The actual ground distance is maintained regardless of projection distortion.
272
- north_south_length (float): Height of the rectangle in meters.
273
- This is the dimension along the north-south direction.
274
- The actual ground distance is maintained regardless of projection distortion.
275
- zoom (int): Initial zoom level for the map. Defaults to 15.
276
- Range: 0 (most zoomed out) to 18 (most zoomed in).
277
- Default of 15 is optimized for city-level visualization.
278
-
279
- Returns:
280
- tuple: (Map object, list of rectangle vertices)
281
- - Map object: ipyleaflet.Map instance centered on the specified city
282
- - rectangle_vertices: Empty list that will be populated with (lon,lat)
283
- tuples when a point is clicked and the rectangle is created
284
-
285
- Note:
286
- - Rectangle dimensions are specified in meters but stored as geographic coordinates
287
- - The function uses geopy's distance calculations for accurate metric distances
288
- - Only one rectangle can exist at a time; clicking a new point removes the previous rectangle
289
- - Rectangle vertices are returned in GeoJSON (lon,lat) order
290
- """
291
-
292
- # Get coordinates for the specified city
293
- center = get_coordinates_from_cityname(cityname)
294
-
295
- # Initialize map centered on the city
296
- m = Map(center=center, zoom=zoom)
297
-
298
- # List to store rectangle vertices
299
- rectangle_vertices = []
300
-
301
- def handle_draw(target, action, geo_json):
302
- """Handle draw events on the map."""
303
- # Clear previous vertices and remove any existing rectangles
304
- rectangle_vertices.clear()
305
- for layer in m.layers:
306
- if isinstance(layer, Rectangle):
307
- m.remove_layer(layer)
308
-
309
- # Process only if a point was drawn on the map
310
- if action == 'created' and geo_json['geometry']['type'] == 'Point':
311
- # Extract point coordinates from GeoJSON (lon,lat)
312
- lon, lat = geo_json['geometry']['coordinates'][0], geo_json['geometry']['coordinates'][1]
313
- print(f"Point drawn at Longitude: {lon}, Latitude: {lat}")
314
-
315
- # Calculate corner points using geopy's distance calculator
316
- # Each point is calculated as a destination from center point using bearing
317
- north = distance.distance(meters=north_south_length/2).destination((lat, lon), bearing=0)
318
- south = distance.distance(meters=north_south_length/2).destination((lat, lon), bearing=180)
319
- east = distance.distance(meters=east_west_length/2).destination((lat, lon), bearing=90)
320
- west = distance.distance(meters=east_west_length/2).destination((lat, lon), bearing=270)
321
-
322
- # Create rectangle vertices in counter-clockwise order (lon,lat)
323
- rectangle_vertices.extend([
324
- (west.longitude, south.latitude),
325
- (west.longitude, north.latitude),
326
- (east.longitude, north.latitude),
327
- (east.longitude, south.latitude)
328
- ])
329
-
330
- # Create and add new rectangle to map (ipyleaflet expects lat,lon)
331
- rectangle = Rectangle(
332
- bounds=[(north.latitude, west.longitude), (south.latitude, east.longitude)],
333
- color="red",
334
- fill_color="red",
335
- fill_opacity=0.2
336
- )
337
- m.add_layer(rectangle)
338
-
339
- print("Rectangle vertices:")
340
- for vertex in rectangle_vertices:
341
- print(f"Longitude: {vertex[0]}, Latitude: {vertex[1]}")
342
-
343
- # Configure drawing controls - only enable point drawing
344
- draw_control = DrawControl()
345
- draw_control.polyline = {}
346
- draw_control.polygon = {}
347
- draw_control.circle = {}
348
- draw_control.rectangle = {}
349
- draw_control.marker = {}
350
- m.add_control(draw_control)
351
-
352
- # Register event handler for drawing actions
353
- draw_control.on_draw(handle_draw)
354
-
355
- return m, rectangle_vertices
356
-
357
- def display_buildings_and_draw_polygon(building_gdf=None, rectangle_vertices=None, zoom=17):
358
- """
359
- Displays building footprints and enables polygon drawing on an interactive map.
360
-
361
- This function creates an interactive map that visualizes building footprints and
362
- allows users to draw arbitrary polygons. It's particularly useful for selecting
363
- specific buildings or areas within an urban context.
364
-
365
- The function provides three key features:
366
- 1. Building Footprint Visualization:
367
- - Displays building polygons from a GeoDataFrame
368
- - Uses consistent styling for all buildings
369
- - Handles simple polygon geometries only
370
-
371
- 2. Interactive Polygon Drawing:
372
- - Enables free-form polygon drawing
373
- - Captures vertices in consistent (lon,lat) format
374
- - Maintains GeoJSON compatibility
375
- - Supports multiple polygons with unique IDs and colors
376
-
377
- 3. Map Initialization:
378
- - Automatic centering based on input data
379
- - Fallback to default location if no data provided
380
- - Support for both building data and rectangle bounds
381
-
382
- Args:
383
- building_gdf (GeoDataFrame, optional): A GeoDataFrame containing building footprints.
384
- Must have geometry column with Polygon type features.
385
- Geometries should be in [lon, lat] coordinate order.
386
- If None, only the base map is displayed.
387
- rectangle_vertices (list, optional): List of [lon, lat] coordinates defining rectangle corners.
388
- Used to set the initial map view extent.
389
- Takes precedence over building_gdf for determining map center.
390
- zoom (int): Initial zoom level for the map. Default=17.
391
- Range: 0 (most zoomed out) to 18 (most zoomed in).
392
- Default of 17 is optimized for building-level detail.
393
-
394
- Returns:
395
- tuple: (map_object, drawn_polygons)
396
- - map_object: ipyleaflet Map instance with building footprints and drawing controls
397
- - drawn_polygons: List of dictionaries with 'id', 'vertices', and 'color' keys for all drawn polygons.
398
- Each polygon has a unique ID and color for easy identification.
399
-
400
- Note:
401
- - Building footprints are displayed in blue with 20% opacity
402
- - Only simple Polygon geometries are supported (no MultiPolygons)
403
- - Drawing tools are restricted to polygon creation only
404
- - All coordinates are handled in (lon,lat) order internally
405
- - The function automatically determines appropriate map bounds
406
- - Each polygon gets a unique ID and different colors for easy identification
407
- - Use get_polygon_vertices() helper function to extract specific polygon data
408
- """
409
- # ---------------------------------------------------------
410
- # 1. Determine a suitable map center via bounding box logic
411
- # ---------------------------------------------------------
412
- if rectangle_vertices is not None:
413
- # Get bounds from rectangle vertices
414
- lons = [v[0] for v in rectangle_vertices]
415
- lats = [v[1] for v in rectangle_vertices]
416
- min_lon, max_lon = min(lons), max(lons)
417
- min_lat, max_lat = min(lats), max(lats)
418
- center_lon = (min_lon + max_lon) / 2
419
- center_lat = (min_lat + max_lat) / 2
420
- elif building_gdf is not None and len(building_gdf) > 0:
421
- # Get bounds from GeoDataFrame
422
- bounds = building_gdf.total_bounds # Returns [minx, miny, maxx, maxy]
423
- min_lon, min_lat, max_lon, max_lat = bounds
424
- center_lon = (min_lon + max_lon) / 2
425
- center_lat = (min_lat + max_lat) / 2
426
- else:
427
- # Fallback: If no inputs or invalid data, pick a default
428
- center_lon, center_lat = -100.0, 40.0
429
-
430
- # Create the ipyleaflet map (needs lat,lon)
431
- m = Map(center=(center_lat, center_lon), zoom=zoom, scroll_wheel_zoom=True)
432
-
433
- # -----------------------------------------
434
- # 2. Add building footprints to the map if provided
435
- # -----------------------------------------
436
- if building_gdf is not None:
437
- for idx, row in building_gdf.iterrows():
438
- # Only handle simple Polygons
439
- if isinstance(row.geometry, geom.Polygon):
440
- # Get coordinates from geometry
441
- coords = list(row.geometry.exterior.coords)
442
- # Convert to (lat,lon) for ipyleaflet, skip last repeated coordinate
443
- lat_lon_coords = [(c[1], c[0]) for c in coords[:-1]]
444
-
445
- # Create the polygon layer
446
- bldg_layer = LeafletPolygon(
447
- locations=lat_lon_coords,
448
- color="blue",
449
- fill_color="blue",
450
- fill_opacity=0.2,
451
- weight=2
452
- )
453
- m.add_layer(bldg_layer)
454
-
455
- # -----------------------------------------------------------------
456
- # 3. Enable drawing of polygons, capturing the vertices in Lon-Lat
457
- # -----------------------------------------------------------------
458
- # Store multiple polygons with IDs and colors
459
- drawn_polygons = [] # List of dicts with 'id', 'vertices', 'color' keys
460
- polygon_counter = 0
461
- polygon_colors = ['red', 'blue', 'green', 'orange', 'purple', 'brown', 'pink', 'gray', 'olive', 'cyan']
462
-
463
- draw_control = DrawControl(
464
- polygon={
465
- "shapeOptions": {
466
- "color": "red",
467
- "fillColor": "red",
468
- "fillOpacity": 0.2
469
- }
470
- },
471
- rectangle={}, # Disable rectangles (or enable if needed)
472
- circle={}, # Disable circles
473
- circlemarker={}, # Disable circlemarkers
474
- polyline={}, # Disable polylines
475
- marker={} # Disable markers
476
- )
477
-
478
- def handle_draw(self, action, geo_json):
479
- """
480
- Callback for whenever a shape is created or edited.
481
- ipyleaflet's DrawControl returns standard GeoJSON (lon, lat).
482
- We'll keep them as (lon, lat).
483
- """
484
- if action == 'created' and geo_json['geometry']['type'] == 'Polygon':
485
- nonlocal polygon_counter
486
- polygon_counter += 1
487
-
488
- # The polygon's first ring
489
- coordinates = geo_json['geometry']['coordinates'][0]
490
- vertices = [(coord[0], coord[1]) for coord in coordinates[:-1]]
491
-
492
- # Assign color (cycle through colors)
493
- color = polygon_colors[polygon_counter % len(polygon_colors)]
494
-
495
- # Store polygon data
496
- polygon_data = {
497
- 'id': polygon_counter,
498
- 'vertices': vertices,
499
- 'color': color
500
- }
501
- drawn_polygons.append(polygon_data)
502
-
503
- print(f"Polygon {polygon_counter} drawn with {len(vertices)} vertices (color: {color}):")
504
- for i, (lon, lat) in enumerate(vertices):
505
- print(f" Vertex {i+1}: (lon, lat) = ({lon}, {lat})")
506
- print(f"Total polygons: {len(drawn_polygons)}")
507
-
508
- draw_control.on_draw(handle_draw)
509
- m.add_control(draw_control)
510
-
511
- return m, drawn_polygons
512
-
513
- def draw_additional_buildings(building_gdf=None, initial_center=None, zoom=17, rectangle_vertices=None):
514
- """
515
- Creates an interactive map for drawing building footprints with height input.
516
-
517
- This function provides an interface for users to:
518
- 1. Draw building footprints on an interactive map
519
- 2. Set building height values through a UI widget
520
- 3. Add new buildings to the existing building_gdf
521
-
522
- The workflow is:
523
- - User draws a polygon on the map
524
- - Height input widget appears
525
- - User enters height and clicks "Add Building"
526
- - Building is added to GeoDataFrame and displayed on map
527
-
528
- Args:
529
- building_gdf (GeoDataFrame, optional): Existing building footprints to display.
530
- If None, creates a new empty GeoDataFrame.
531
- Expected columns: ['id', 'height', 'min_height', 'geometry', 'building_id']
532
- - 'id': Integer ID from data sources (e.g., OSM building id)
533
- - 'height': Building height in meters (set by user input)
534
- - 'min_height': Minimum height in meters (defaults to 0.0)
535
- - 'geometry': Building footprint polygon
536
- - 'building_id': Unique building identifier
537
- initial_center (tuple, optional): Initial map center as (lon, lat).
538
- If None, centers on existing buildings or defaults to (-100, 40).
539
- zoom (int): Initial zoom level (default=17).
540
-
541
- Returns:
542
- tuple: (map_object, updated_building_gdf)
543
- - map_object: ipyleaflet Map instance with drawing controls
544
- - updated_building_gdf: GeoDataFrame that automatically updates when buildings are added
545
-
546
- Example:
547
- >>> # Start with empty buildings
548
- >>> m, buildings = draw_additional_buildings()
549
- >>> # Draw buildings on the map...
550
- >>> print(buildings) # Will contain all drawn buildings
551
- """
552
-
553
- # Initialize or copy the building GeoDataFrame
554
- if building_gdf is None:
555
- # Create empty GeoDataFrame with required columns
556
- updated_gdf = gpd.GeoDataFrame(
557
- columns=['id', 'height', 'min_height', 'geometry', 'building_id'],
558
- crs='EPSG:4326'
559
- )
560
- else:
561
- # Make a copy to avoid modifying the original
562
- updated_gdf = building_gdf.copy()
563
- # Ensure all required columns exist
564
- if 'height' not in updated_gdf.columns:
565
- updated_gdf['height'] = 10.0 # Default height
566
- if 'min_height' not in updated_gdf.columns:
567
- updated_gdf['min_height'] = 0.0 # Default min_height
568
- if 'building_id' not in updated_gdf.columns:
569
- updated_gdf['building_id'] = range(len(updated_gdf))
570
- if 'id' not in updated_gdf.columns:
571
- updated_gdf['id'] = range(len(updated_gdf))
572
-
573
- # Determine map center
574
- if initial_center is not None:
575
- center_lon, center_lat = initial_center
576
- elif updated_gdf is not None and len(updated_gdf) > 0:
577
- bounds = updated_gdf.total_bounds
578
- min_lon, min_lat, max_lon, max_lat = bounds
579
- center_lon = (min_lon + max_lon) / 2
580
- center_lat = (min_lat + max_lat) / 2
581
- elif rectangle_vertices is not None:
582
- center_lon, center_lat = (rectangle_vertices[0][0] + rectangle_vertices[2][0]) / 2, (rectangle_vertices[0][1] + rectangle_vertices[2][1]) / 2
583
- else:
584
- center_lon, center_lat = -100.0, 40.0
585
-
586
- # Create the map
587
- m = Map(center=(center_lat, center_lon), zoom=zoom, scroll_wheel_zoom=True)
588
-
589
- # Display existing buildings
590
- building_layers = {}
591
- for idx, row in updated_gdf.iterrows():
592
- if isinstance(row.geometry, geom.Polygon):
593
- coords = list(row.geometry.exterior.coords)
594
- lat_lon_coords = [(c[1], c[0]) for c in coords[:-1]]
595
-
596
- height = row.get('height', 10.0)
597
- min_height = row.get('min_height', 0.0)
598
- building_id = row.get('building_id', idx)
599
- bldg_id = row.get('id', idx)
600
- bldg_layer = LeafletPolygon(
601
- locations=lat_lon_coords,
602
- color="blue",
603
- fill_color="blue",
604
- fill_opacity=0.3,
605
- weight=2,
606
- popup=HTML(f"<b>Building ID:</b> {building_id}<br>"
607
- f"<b>ID:</b> {bldg_id}<br>"
608
- f"<b>Height:</b> {height}m<br>"
609
- f"<b>Min Height:</b> {min_height}m")
610
- )
611
- m.add_layer(bldg_layer)
612
- building_layers[idx] = bldg_layer
613
-
614
- # Create UI widgets
615
- height_input = FloatText(
616
- value=10.0,
617
- description='Height (m):',
618
- disabled=False,
619
- style={'description_width': 'initial'}
620
- )
621
-
622
- add_button = Button(
623
- description='Add Building',
624
- button_style='success',
625
- disabled=True
626
- )
627
-
628
- clear_button = Button(
629
- description='Clear Drawing',
630
- button_style='warning',
631
- disabled=True
632
- )
633
-
634
- status_output = Output()
635
-
636
- # Create control panel
637
- control_panel = VBox([
638
- HTML("<h3>Draw Building Tool</h3>"),
639
- HTML("<p>1. Draw a polygon on the map<br>2. Set height<br>3. Click 'Add Building'</p>"),
640
- height_input,
641
- HBox([add_button, clear_button]),
642
- status_output
643
- ])
644
-
645
- # Add control panel to map
646
- widget_control = WidgetControl(widget=control_panel, position='topright')
647
- m.add_control(widget_control)
648
-
649
- # Store the current drawn polygon
650
- current_polygon = {'vertices': [], 'layer': None}
651
-
652
- # Drawing control
653
- draw_control = DrawControl(
654
- polygon={
655
- "shapeOptions": {
656
- "color": "red",
657
- "fillColor": "red",
658
- "fillOpacity": 0.3,
659
- "weight": 3
660
- }
661
- },
662
- rectangle={},
663
- circle={},
664
- circlemarker={},
665
- polyline={},
666
- marker={}
667
- )
668
-
669
- def handle_draw(self, action, geo_json):
670
- """Handle polygon drawing events"""
671
- with status_output:
672
- clear_output()
673
-
674
- if action == 'created' and geo_json['geometry']['type'] == 'Polygon':
675
- # Store vertices
676
- coordinates = geo_json['geometry']['coordinates'][0]
677
- current_polygon['vertices'] = [(coord[0], coord[1]) for coord in coordinates[:-1]]
678
-
679
- # Enable buttons
680
- add_button.disabled = False
681
- clear_button.disabled = False
682
-
683
- with status_output:
684
- print(f"Polygon drawn with {len(current_polygon['vertices'])} vertices")
685
- print("Set height and click 'Add Building'")
686
-
687
- def add_building_click(b):
688
- """Handle add building button click"""
689
- # Use nonlocal to modify the outer scope variable
690
- nonlocal updated_gdf
691
-
692
- with status_output:
693
- clear_output()
694
-
695
- if current_polygon['vertices']:
696
- # Create polygon geometry
697
- polygon = geom.Polygon(current_polygon['vertices'])
698
-
699
- # Get next building ID and ID values (ensure uniqueness)
700
- if len(updated_gdf) > 0:
701
- next_building_id = int(updated_gdf['building_id'].max() + 1)
702
- next_id = int(updated_gdf['id'].max() + 1)
703
- else:
704
- next_building_id = 1
705
- next_id = 1
706
-
707
- # Create new row data
708
- new_row_data = {
709
- 'geometry': polygon,
710
- 'height': float(height_input.value),
711
- 'min_height': 0.0, # Default value as requested
712
- 'building_id': next_building_id,
713
- 'id': next_id
714
- }
715
-
716
- # Add any additional columns
717
- for col in updated_gdf.columns:
718
- if col not in new_row_data:
719
- new_row_data[col] = None
720
-
721
- # Append the new building in-place
722
- new_index = len(updated_gdf)
723
- updated_gdf.loc[new_index] = new_row_data
724
-
725
- # Add to map
726
- coords = list(polygon.exterior.coords)
727
- lat_lon_coords = [(c[1], c[0]) for c in coords[:-1]]
728
-
729
- new_layer = LeafletPolygon(
730
- locations=lat_lon_coords,
731
- color="blue",
732
- fill_color="blue",
733
- fill_opacity=0.3,
734
- weight=2,
735
- popup=HTML(f"<b>Building ID:</b> {next_building_id}<br>"
736
- f"<b>ID:</b> {next_id}<br>"
737
- f"<b>Height:</b> {height_input.value}m<br>"
738
- f"<b>Min Height:</b> 0.0m")
739
- )
740
- m.add_layer(new_layer)
741
-
742
- # Clear drawing
743
- draw_control.clear()
744
- current_polygon['vertices'] = []
745
- add_button.disabled = True
746
- clear_button.disabled = True
747
-
748
- print(f"Building {next_building_id} added successfully!")
749
- print(f"ID: {next_id}, Height: {height_input.value}m, Min Height: 0.0m")
750
- print(f"Total buildings: {len(updated_gdf)}")
751
-
752
- def clear_drawing_click(b):
753
- """Handle clear drawing button click"""
754
- with status_output:
755
- clear_output()
756
- draw_control.clear()
757
- current_polygon['vertices'] = []
758
- add_button.disabled = True
759
- clear_button.disabled = True
760
- print("Drawing cleared")
761
-
762
- # Connect event handlers
763
- draw_control.on_draw(handle_draw)
764
- add_button.on_click(add_building_click)
765
- clear_button.on_click(clear_drawing_click)
766
-
767
- # Add draw control to map
768
- m.add_control(draw_control)
769
-
770
- # Display initial status
771
- with status_output:
772
- print(f"Total buildings loaded: {len(updated_gdf)}")
773
- print("Draw a polygon to add a new building")
774
-
775
- return m, updated_gdf
776
-
777
-
778
- def get_polygon_vertices(drawn_polygons, polygon_id=None):
779
- """
780
- Extract vertices from drawn polygons data structure.
781
-
782
- This helper function provides a convenient way to extract polygon vertices
783
- from the drawn_polygons list returned by display_buildings_and_draw_polygon().
784
-
785
- Args:
786
- drawn_polygons: The drawn_polygons list returned from display_buildings_and_draw_polygon()
787
- polygon_id (int, optional): Specific polygon ID to extract. If None, returns all polygons.
788
-
789
- Returns:
790
- If polygon_id is specified: List of (lon, lat) tuples for that polygon
791
- If polygon_id is None: List of lists, where each inner list contains (lon, lat) tuples
792
-
793
- Example:
794
- >>> m, polygons = display_buildings_and_draw_polygon()
795
- >>> # Draw some polygons...
796
- >>> vertices = get_polygon_vertices(polygons, polygon_id=1) # Get polygon 1
797
- >>> all_vertices = get_polygon_vertices(polygons) # Get all polygons
798
- """
799
- if not drawn_polygons:
800
- return []
801
-
802
- if polygon_id is not None:
803
- # Return specific polygon
804
- for polygon in drawn_polygons:
805
- if polygon['id'] == polygon_id:
806
- return polygon['vertices']
807
- return [] # Polygon not found
808
- else:
809
- # Return all polygons
810
- return [polygon['vertices'] for polygon in drawn_polygons]
811
-
812
-
813
- # Simple convenience function
814
- def create_building_editor(building_gdf=None, initial_center=None, zoom=17, rectangle_vertices=None):
815
- """
816
- Creates and displays an interactive building editor.
817
-
818
- Args:
819
- building_gdf: Existing buildings GeoDataFrame (optional)
820
- initial_center: Map center as (lon, lat) tuple (optional)
821
- zoom: Initial zoom level (default=17)
822
-
823
- Returns:
824
- GeoDataFrame: The building GeoDataFrame that automatically updates
825
-
826
- Example:
827
- >>> buildings = create_building_editor()
828
- >>> # Draw buildings on the displayed map
829
- >>> print(buildings) # Automatically contains all drawn buildings
830
- """
831
- m, gdf = draw_additional_buildings(building_gdf, initial_center, zoom, rectangle_vertices)
832
- 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 Proj, transform
28
+ from ipyleaflet import (
29
+ Map,
30
+ DrawControl,
31
+ Rectangle,
32
+ Polygon as LeafletPolygon,
33
+ WidgetControl,
34
+ Circle,
35
+ basemaps,
36
+ basemap_to_tiles
37
+ )
38
+ from geopy import distance
39
+ import shapely.geometry as geom
40
+ import geopandas as gpd
41
+ from ipywidgets import VBox, HBox, Button, FloatText, Label, Output, HTML
42
+ import pandas as pd
43
+ from IPython.display import display, clear_output
44
+
45
+ from .utils import get_coordinates_from_cityname
46
+
47
+ def rotate_rectangle(m, rectangle_vertices, angle):
48
+ """
49
+ Project rectangle to Mercator, rotate, and re-project to lat-lon coordinates.
50
+
51
+ This function performs a rotation of a rectangle in geographic space by:
52
+ 1. Converting coordinates from WGS84 (lat/lon) to Web Mercator projection
53
+ 2. Performing the rotation in the projected space for accurate distance preservation
54
+ 3. Converting back to WGS84 coordinates
55
+ 4. Visualizing the result on the provided map
56
+
57
+ The rotation is performed around the rectangle's centroid using a standard 2D rotation matrix.
58
+ The function handles coordinate system transformations to ensure geometrically accurate rotations
59
+ despite the distortions inherent in geographic projections.
60
+
61
+ Args:
62
+ m (ipyleaflet.Map): Map object to draw the rotated rectangle on.
63
+ The map must be initialized and have a valid center and zoom level.
64
+ rectangle_vertices (list): List of (lon, lat) tuples defining the rectangle vertices.
65
+ The vertices should be ordered in a counter-clockwise direction.
66
+ Example: [(lon1,lat1), (lon2,lat2), (lon3,lat3), (lon4,lat4)]
67
+ angle (float): Rotation angle in degrees.
68
+ Positive angles rotate counter-clockwise.
69
+ Negative angles rotate clockwise.
70
+
71
+ Returns:
72
+ list: List of rotated (lon, lat) tuples defining the new rectangle vertices.
73
+ The vertices maintain their original ordering.
74
+ Returns None if no rectangle vertices are provided.
75
+
76
+ Note:
77
+ The function uses EPSG:4326 (WGS84) for geographic coordinates and
78
+ EPSG:3857 (Web Mercator) for the rotation calculations.
79
+ """
80
+ if not rectangle_vertices:
81
+ print("Draw a rectangle first!")
82
+ return
83
+
84
+ # Define projections - need to convert between coordinate systems for accurate rotation
85
+ wgs84 = Proj(init='epsg:4326') # WGS84 lat-lon (standard GPS coordinates)
86
+ mercator = Proj(init='epsg:3857') # Web Mercator (projection used by most web maps)
87
+
88
+ # Project vertices from WGS84 to Web Mercator for proper distance calculations
89
+ projected_vertices = [transform(wgs84, mercator, lon, lat) for lon, lat in rectangle_vertices]
90
+
91
+ # Calculate the centroid to use as rotation center
92
+ centroid_x = sum(x for x, y in projected_vertices) / len(projected_vertices)
93
+ centroid_y = sum(y for x, y in projected_vertices) / len(projected_vertices)
94
+
95
+ # Convert angle to radians (negative for clockwise rotation)
96
+ angle_rad = -math.radians(angle)
97
+
98
+ # Rotate each vertex around the centroid using standard 2D rotation matrix
99
+ rotated_vertices = []
100
+ for x, y in projected_vertices:
101
+ # Translate point to origin for rotation
102
+ temp_x = x - centroid_x
103
+ temp_y = y - centroid_y
104
+
105
+ # Apply rotation matrix
106
+ rotated_x = temp_x * math.cos(angle_rad) - temp_y * math.sin(angle_rad)
107
+ rotated_y = temp_x * math.sin(angle_rad) + temp_y * math.cos(angle_rad)
108
+
109
+ # Translate point back to original position
110
+ new_x = rotated_x + centroid_x
111
+ new_y = rotated_y + centroid_y
112
+
113
+ rotated_vertices.append((new_x, new_y))
114
+
115
+ # Convert coordinates back to WGS84 (lon/lat)
116
+ new_vertices = [transform(mercator, wgs84, x, y) for x, y in rotated_vertices]
117
+
118
+ # Create and add new polygon layer to map
119
+ polygon = LeafletPolygon(
120
+ locations=[(lat, lon) for lon, lat in new_vertices], # Convert to (lat,lon) for ipyleaflet
121
+ color="red",
122
+ fill_color="red"
123
+ )
124
+ m.add_layer(polygon)
125
+
126
+ return new_vertices
127
+
128
+ def draw_rectangle_map(center=(40, -100), zoom=4):
129
+ """
130
+ Create an interactive map for drawing rectangles with ipyleaflet.
131
+
132
+ This function initializes an interactive map that allows users to draw rectangles
133
+ by clicking and dragging on the map surface. The drawn rectangles are captured
134
+ and their vertices are stored in geographic coordinates.
135
+
136
+ The map interface provides:
137
+ - A rectangle drawing tool activated by default
138
+ - Real-time coordinate capture of drawn shapes
139
+ - Automatic vertex ordering in counter-clockwise direction
140
+ - Console output of vertex coordinates for verification
141
+
142
+ Drawing Controls:
143
+ - Click and drag to draw a rectangle
144
+ - Release to complete the rectangle
145
+ - Only one rectangle can be active at a time
146
+ - Drawing a new rectangle clears the previous one
147
+
148
+ Args:
149
+ center (tuple): Center coordinates (lat, lon) for the map view.
150
+ Defaults to (40, -100) which centers on the continental United States.
151
+ Format: (latitude, longitude) in decimal degrees.
152
+ zoom (int): Initial zoom level for the map. Defaults to 4.
153
+ Range: 0 (most zoomed out) to 18 (most zoomed in).
154
+ Recommended: 3-6 for countries, 10-15 for cities.
155
+
156
+ Returns:
157
+ tuple: (Map object, list of rectangle vertices)
158
+ - Map object: ipyleaflet.Map instance for displaying and interacting with the map
159
+ - rectangle_vertices: Empty list that will be populated with (lon,lat) tuples
160
+ when a rectangle is drawn. Coordinates are stored in GeoJSON order (lon,lat).
161
+
162
+ Note:
163
+ The function disables all drawing tools except rectangles to ensure
164
+ consistent shape creation. The rectangle vertices are automatically
165
+ converted to (lon,lat) format when stored, regardless of the input
166
+ center coordinate order.
167
+ """
168
+ # Initialize the map centered at specified coordinates
169
+ m = Map(center=center, zoom=zoom)
170
+
171
+ # List to store the vertices of drawn rectangle
172
+ rectangle_vertices = []
173
+
174
+ def handle_draw(target, action, geo_json):
175
+ """Handle draw events on the map."""
176
+ # Clear any previously stored vertices
177
+ rectangle_vertices.clear()
178
+
179
+ # Process only if a rectangle polygon was drawn
180
+ if action == 'created' and geo_json['geometry']['type'] == 'Polygon':
181
+ # Extract coordinates from GeoJSON format
182
+ coordinates = geo_json['geometry']['coordinates'][0]
183
+ print("Vertices of the drawn rectangle:")
184
+ # Store all vertices except last (GeoJSON repeats first vertex at end)
185
+ for coord in coordinates[:-1]:
186
+ # Keep GeoJSON (lon,lat) format
187
+ rectangle_vertices.append((coord[0], coord[1]))
188
+ print(f"Longitude: {coord[0]}, Latitude: {coord[1]}")
189
+
190
+ # Configure drawing controls - only enable rectangle drawing
191
+ draw_control = DrawControl()
192
+ draw_control.polyline = {}
193
+ draw_control.polygon = {}
194
+ draw_control.circle = {}
195
+ draw_control.rectangle = {
196
+ "shapeOptions": {
197
+ "color": "#6bc2e5",
198
+ "weight": 4,
199
+ }
200
+ }
201
+ m.add_control(draw_control)
202
+
203
+ # Register event handler for drawing actions
204
+ draw_control.on_draw(handle_draw)
205
+
206
+ return m, rectangle_vertices
207
+
208
+ def draw_rectangle_map_cityname(cityname, zoom=15):
209
+ """
210
+ Create an interactive map centered on a specified city for drawing rectangles.
211
+
212
+ This function extends draw_rectangle_map() by automatically centering the map
213
+ on a specified city using geocoding. It provides a convenient way to focus
214
+ the drawing interface on a particular urban area without needing to know
215
+ its exact coordinates.
216
+
217
+ The function uses the utils.get_coordinates_from_cityname() function to
218
+ geocode the city name and obtain its coordinates. The resulting map is
219
+ zoomed to an appropriate level for urban-scale analysis.
220
+
221
+ Args:
222
+ cityname (str): Name of the city to center the map on.
223
+ Can include country or state for better accuracy.
224
+ Examples: "Tokyo, Japan", "New York, NY", "Paris, France"
225
+ zoom (int): Initial zoom level for the map. Defaults to 15.
226
+ Range: 0 (most zoomed out) to 18 (most zoomed in).
227
+ Default of 15 is optimized for city-level visualization.
228
+
229
+ Returns:
230
+ tuple: (Map object, list of rectangle vertices)
231
+ - Map object: ipyleaflet.Map instance centered on the specified city
232
+ - rectangle_vertices: Empty list that will be populated with (lon,lat)
233
+ tuples when a rectangle is drawn
234
+
235
+ Note:
236
+ If the city name cannot be geocoded, the function will raise an error.
237
+ For better results, provide specific city names with country/state context.
238
+ The function inherits all drawing controls and behavior from draw_rectangle_map().
239
+ """
240
+ # Get coordinates for the specified city
241
+ center = get_coordinates_from_cityname(cityname)
242
+ m, rectangle_vertices = draw_rectangle_map(center=center, zoom=zoom)
243
+ return m, rectangle_vertices
244
+
245
+ def center_location_map_cityname(cityname, east_west_length, north_south_length, zoom=15):
246
+ """
247
+ Create an interactive map centered on a city where clicking creates a rectangle of specified dimensions.
248
+
249
+ This function provides a specialized interface for creating fixed-size rectangles
250
+ centered on user-selected points. Instead of drawing rectangles by dragging,
251
+ users click a point on the map and a rectangle of the specified dimensions
252
+ is automatically created centered on that point.
253
+
254
+ The function handles:
255
+ - Automatic city geocoding and map centering
256
+ - Distance calculations in meters using geopy
257
+ - Conversion between geographic and metric distances
258
+ - Rectangle creation with specified dimensions
259
+ - Visualization of created rectangles
260
+
261
+ Workflow:
262
+ 1. Map is centered on the specified city
263
+ 2. User clicks a point on the map
264
+ 3. A rectangle is created centered on that point
265
+ 4. Rectangle dimensions are maintained in meters regardless of latitude
266
+ 5. Previous rectangles are automatically cleared
267
+
268
+ Args:
269
+ cityname (str): Name of the city to center the map on.
270
+ Can include country or state for better accuracy.
271
+ Examples: "Tokyo, Japan", "New York, NY"
272
+ east_west_length (float): Width of the rectangle in meters.
273
+ This is the dimension along the east-west direction.
274
+ The actual ground distance is maintained regardless of projection distortion.
275
+ north_south_length (float): Height of the rectangle in meters.
276
+ This is the dimension along the north-south direction.
277
+ The actual ground distance is maintained regardless of projection distortion.
278
+ zoom (int): Initial zoom level for the map. Defaults to 15.
279
+ Range: 0 (most zoomed out) to 18 (most zoomed in).
280
+ Default of 15 is optimized for city-level visualization.
281
+
282
+ Returns:
283
+ tuple: (Map object, list of rectangle vertices)
284
+ - Map object: ipyleaflet.Map instance centered on the specified city
285
+ - rectangle_vertices: Empty list that will be populated with (lon,lat)
286
+ tuples when a point is clicked and the rectangle is created
287
+
288
+ Note:
289
+ - Rectangle dimensions are specified in meters but stored as geographic coordinates
290
+ - The function uses geopy's distance calculations for accurate metric distances
291
+ - Only one rectangle can exist at a time; clicking a new point removes the previous rectangle
292
+ - Rectangle vertices are returned in GeoJSON (lon,lat) order
293
+ """
294
+
295
+ # Get coordinates for the specified city
296
+ center = get_coordinates_from_cityname(cityname)
297
+
298
+ # Initialize map centered on the city
299
+ m = Map(center=center, zoom=zoom)
300
+
301
+ # List to store rectangle vertices
302
+ rectangle_vertices = []
303
+
304
+ def handle_draw(target, action, geo_json):
305
+ """Handle draw events on the map."""
306
+ # Clear previous vertices and remove any existing rectangles
307
+ rectangle_vertices.clear()
308
+ for layer in m.layers:
309
+ if isinstance(layer, Rectangle):
310
+ m.remove_layer(layer)
311
+
312
+ # Process only if a point was drawn on the map
313
+ if action == 'created' and geo_json['geometry']['type'] == 'Point':
314
+ # Extract point coordinates from GeoJSON (lon,lat)
315
+ lon, lat = geo_json['geometry']['coordinates'][0], geo_json['geometry']['coordinates'][1]
316
+ print(f"Point drawn at Longitude: {lon}, Latitude: {lat}")
317
+
318
+ # Calculate corner points using geopy's distance calculator
319
+ # Each point is calculated as a destination from center point using bearing
320
+ north = distance.distance(meters=north_south_length/2).destination((lat, lon), bearing=0)
321
+ south = distance.distance(meters=north_south_length/2).destination((lat, lon), bearing=180)
322
+ east = distance.distance(meters=east_west_length/2).destination((lat, lon), bearing=90)
323
+ west = distance.distance(meters=east_west_length/2).destination((lat, lon), bearing=270)
324
+
325
+ # Create rectangle vertices in counter-clockwise order (lon,lat)
326
+ rectangle_vertices.extend([
327
+ (west.longitude, south.latitude),
328
+ (west.longitude, north.latitude),
329
+ (east.longitude, north.latitude),
330
+ (east.longitude, south.latitude)
331
+ ])
332
+
333
+ # Create and add new rectangle to map (ipyleaflet expects lat,lon)
334
+ rectangle = Rectangle(
335
+ bounds=[(north.latitude, west.longitude), (south.latitude, east.longitude)],
336
+ color="red",
337
+ fill_color="red",
338
+ fill_opacity=0.2
339
+ )
340
+ m.add_layer(rectangle)
341
+
342
+ print("Rectangle vertices:")
343
+ for vertex in rectangle_vertices:
344
+ print(f"Longitude: {vertex[0]}, Latitude: {vertex[1]}")
345
+
346
+ # Configure drawing controls - only enable point drawing
347
+ draw_control = DrawControl()
348
+ draw_control.polyline = {}
349
+ draw_control.polygon = {}
350
+ draw_control.circle = {}
351
+ draw_control.rectangle = {}
352
+ draw_control.marker = {}
353
+ m.add_control(draw_control)
354
+
355
+ # Register event handler for drawing actions
356
+ draw_control.on_draw(handle_draw)
357
+
358
+ return m, rectangle_vertices
359
+
360
+ def display_buildings_and_draw_polygon(building_gdf=None, rectangle_vertices=None, zoom=17):
361
+ """
362
+ Displays building footprints and enables polygon drawing on an interactive map.
363
+
364
+ This function creates an interactive map that visualizes building footprints and
365
+ allows users to draw arbitrary polygons. It's particularly useful for selecting
366
+ specific buildings or areas within an urban context.
367
+
368
+ The function provides three key features:
369
+ 1. Building Footprint Visualization:
370
+ - Displays building polygons from a GeoDataFrame
371
+ - Uses consistent styling for all buildings
372
+ - Handles simple polygon geometries only
373
+
374
+ 2. Interactive Polygon Drawing:
375
+ - Enables free-form polygon drawing
376
+ - Captures vertices in consistent (lon,lat) format
377
+ - Maintains GeoJSON compatibility
378
+ - Supports multiple polygons with unique IDs and colors
379
+
380
+ 3. Map Initialization:
381
+ - Automatic centering based on input data
382
+ - Fallback to default location if no data provided
383
+ - Support for both building data and rectangle bounds
384
+
385
+ Args:
386
+ building_gdf (GeoDataFrame, optional): A GeoDataFrame containing building footprints.
387
+ Must have geometry column with Polygon type features.
388
+ Geometries should be in [lon, lat] coordinate order.
389
+ If None, only the base map is displayed.
390
+ rectangle_vertices (list, optional): List of [lon, lat] coordinates defining rectangle corners.
391
+ Used to set the initial map view extent.
392
+ Takes precedence over building_gdf for determining map center.
393
+ zoom (int): Initial zoom level for the map. Default=17.
394
+ Range: 0 (most zoomed out) to 18 (most zoomed in).
395
+ Default of 17 is optimized for building-level detail.
396
+
397
+ Returns:
398
+ tuple: (map_object, drawn_polygons)
399
+ - map_object: ipyleaflet Map instance with building footprints and drawing controls
400
+ - drawn_polygons: List of dictionaries with 'id', 'vertices', and 'color' keys for all drawn polygons.
401
+ Each polygon has a unique ID and color for easy identification.
402
+
403
+ Note:
404
+ - Building footprints are displayed in blue with 20% opacity
405
+ - Only simple Polygon geometries are supported (no MultiPolygons)
406
+ - Drawing tools are restricted to polygon creation only
407
+ - All coordinates are handled in (lon,lat) order internally
408
+ - The function automatically determines appropriate map bounds
409
+ - Each polygon gets a unique ID and different colors for easy identification
410
+ - Use get_polygon_vertices() helper function to extract specific polygon data
411
+ """
412
+ # ---------------------------------------------------------
413
+ # 1. Determine a suitable map center via bounding box logic
414
+ # ---------------------------------------------------------
415
+ if rectangle_vertices is not None:
416
+ # Get bounds from rectangle vertices
417
+ lons = [v[0] for v in rectangle_vertices]
418
+ lats = [v[1] for v in rectangle_vertices]
419
+ min_lon, max_lon = min(lons), max(lons)
420
+ min_lat, max_lat = min(lats), max(lats)
421
+ center_lon = (min_lon + max_lon) / 2
422
+ center_lat = (min_lat + max_lat) / 2
423
+ elif building_gdf is not None and len(building_gdf) > 0:
424
+ # Get bounds from GeoDataFrame
425
+ bounds = building_gdf.total_bounds # Returns [minx, miny, maxx, maxy]
426
+ min_lon, min_lat, max_lon, max_lat = bounds
427
+ center_lon = (min_lon + max_lon) / 2
428
+ center_lat = (min_lat + max_lat) / 2
429
+ else:
430
+ # Fallback: If no inputs or invalid data, pick a default
431
+ center_lon, center_lat = -100.0, 40.0
432
+
433
+ # Create the ipyleaflet map (needs lat,lon)
434
+ m = Map(center=(center_lat, center_lon), zoom=zoom, scroll_wheel_zoom=True)
435
+
436
+ # -----------------------------------------
437
+ # 2. Add building footprints to the map if provided
438
+ # -----------------------------------------
439
+ if building_gdf is not None:
440
+ for idx, row in building_gdf.iterrows():
441
+ # Only handle simple Polygons
442
+ if isinstance(row.geometry, geom.Polygon):
443
+ # Get coordinates from geometry
444
+ coords = list(row.geometry.exterior.coords)
445
+ # Convert to (lat,lon) for ipyleaflet, skip last repeated coordinate
446
+ lat_lon_coords = [(c[1], c[0]) for c in coords[:-1]]
447
+
448
+ # Create the polygon layer
449
+ bldg_layer = LeafletPolygon(
450
+ locations=lat_lon_coords,
451
+ color="blue",
452
+ fill_color="blue",
453
+ fill_opacity=0.2,
454
+ weight=2
455
+ )
456
+ m.add_layer(bldg_layer)
457
+
458
+ # -----------------------------------------------------------------
459
+ # 3. Enable drawing of polygons, capturing the vertices in Lon-Lat
460
+ # -----------------------------------------------------------------
461
+ # Store multiple polygons with IDs and colors
462
+ drawn_polygons = [] # List of dicts with 'id', 'vertices', 'color' keys
463
+ polygon_counter = 0
464
+ polygon_colors = ['red', 'blue', 'green', 'orange', 'purple', 'brown', 'pink', 'gray', 'olive', 'cyan']
465
+
466
+ draw_control = DrawControl(
467
+ polygon={
468
+ "shapeOptions": {
469
+ "color": "red",
470
+ "fillColor": "red",
471
+ "fillOpacity": 0.2
472
+ }
473
+ },
474
+ rectangle={}, # Disable rectangles (or enable if needed)
475
+ circle={}, # Disable circles
476
+ circlemarker={}, # Disable circlemarkers
477
+ polyline={}, # Disable polylines
478
+ marker={} # Disable markers
479
+ )
480
+
481
+ def handle_draw(self, action, geo_json):
482
+ """
483
+ Callback for whenever a shape is created or edited.
484
+ ipyleaflet's DrawControl returns standard GeoJSON (lon, lat).
485
+ We'll keep them as (lon, lat).
486
+ """
487
+ if action == 'created' and geo_json['geometry']['type'] == 'Polygon':
488
+ nonlocal polygon_counter
489
+ polygon_counter += 1
490
+
491
+ # The polygon's first ring
492
+ coordinates = geo_json['geometry']['coordinates'][0]
493
+ vertices = [(coord[0], coord[1]) for coord in coordinates[:-1]]
494
+
495
+ # Assign color (cycle through colors)
496
+ color = polygon_colors[polygon_counter % len(polygon_colors)]
497
+
498
+ # Store polygon data
499
+ polygon_data = {
500
+ 'id': polygon_counter,
501
+ 'vertices': vertices,
502
+ 'color': color
503
+ }
504
+ drawn_polygons.append(polygon_data)
505
+
506
+ print(f"Polygon {polygon_counter} drawn with {len(vertices)} vertices (color: {color}):")
507
+ for i, (lon, lat) in enumerate(vertices):
508
+ print(f" Vertex {i+1}: (lon, lat) = ({lon}, {lat})")
509
+ print(f"Total polygons: {len(drawn_polygons)}")
510
+
511
+ draw_control.on_draw(handle_draw)
512
+ m.add_control(draw_control)
513
+
514
+ return m, drawn_polygons
515
+
516
+ def draw_additional_buildings(building_gdf=None, initial_center=None, zoom=17, rectangle_vertices=None):
517
+ """
518
+ Creates an interactive map for drawing building footprints with height input.
519
+
520
+ This function provides an interface for users to:
521
+ 1. Draw building footprints on an interactive map
522
+ 2. Set building height values through a UI widget
523
+ 3. Add new buildings to the existing building_gdf
524
+
525
+ The workflow is:
526
+ - User draws a polygon on the map
527
+ - Height input widget appears
528
+ - User enters height and clicks "Add Building"
529
+ - Building is added to GeoDataFrame and displayed on map
530
+
531
+ Args:
532
+ building_gdf (GeoDataFrame, optional): Existing building footprints to display.
533
+ If None, creates a new empty GeoDataFrame.
534
+ Expected columns: ['id', 'height', 'min_height', 'geometry', 'building_id']
535
+ - 'id': Integer ID from data sources (e.g., OSM building id)
536
+ - 'height': Building height in meters (set by user input)
537
+ - 'min_height': Minimum height in meters (defaults to 0.0)
538
+ - 'geometry': Building footprint polygon
539
+ - 'building_id': Unique building identifier
540
+ initial_center (tuple, optional): Initial map center as (lon, lat).
541
+ If None, centers on existing buildings or defaults to (-100, 40).
542
+ zoom (int): Initial zoom level (default=17).
543
+
544
+ Returns:
545
+ tuple: (map_object, updated_building_gdf)
546
+ - map_object: ipyleaflet Map instance with drawing controls
547
+ - updated_building_gdf: GeoDataFrame that automatically updates when buildings are added
548
+
549
+ Example:
550
+ >>> # Start with empty buildings
551
+ >>> m, buildings = draw_additional_buildings()
552
+ >>> # Draw buildings on the map...
553
+ >>> print(buildings) # Will contain all drawn buildings
554
+ """
555
+
556
+ # Initialize or copy the building GeoDataFrame
557
+ if building_gdf is None:
558
+ # Create empty GeoDataFrame with required columns
559
+ updated_gdf = gpd.GeoDataFrame(
560
+ columns=['id', 'height', 'min_height', 'geometry', 'building_id'],
561
+ crs='EPSG:4326'
562
+ )
563
+ else:
564
+ # Make a copy to avoid modifying the original
565
+ updated_gdf = building_gdf.copy()
566
+ # Ensure all required columns exist
567
+ if 'height' not in updated_gdf.columns:
568
+ updated_gdf['height'] = 10.0 # Default height
569
+ if 'min_height' not in updated_gdf.columns:
570
+ updated_gdf['min_height'] = 0.0 # Default min_height
571
+ if 'building_id' not in updated_gdf.columns:
572
+ updated_gdf['building_id'] = range(len(updated_gdf))
573
+ if 'id' not in updated_gdf.columns:
574
+ updated_gdf['id'] = range(len(updated_gdf))
575
+
576
+ # Determine map center
577
+ if initial_center is not None:
578
+ center_lon, center_lat = initial_center
579
+ elif updated_gdf is not None and len(updated_gdf) > 0:
580
+ bounds = updated_gdf.total_bounds
581
+ min_lon, min_lat, max_lon, max_lat = bounds
582
+ center_lon = (min_lon + max_lon) / 2
583
+ center_lat = (min_lat + max_lat) / 2
584
+ elif rectangle_vertices is not None:
585
+ center_lon, center_lat = (rectangle_vertices[0][0] + rectangle_vertices[2][0]) / 2, (rectangle_vertices[0][1] + rectangle_vertices[2][1]) / 2
586
+ else:
587
+ center_lon, center_lat = -100.0, 40.0
588
+
589
+ # Create the map
590
+ m = Map(center=(center_lat, center_lon), zoom=zoom, scroll_wheel_zoom=True)
591
+
592
+ # Display existing buildings
593
+ building_layers = {}
594
+ for idx, row in updated_gdf.iterrows():
595
+ if isinstance(row.geometry, geom.Polygon):
596
+ coords = list(row.geometry.exterior.coords)
597
+ lat_lon_coords = [(c[1], c[0]) for c in coords[:-1]]
598
+
599
+ height = row.get('height', 10.0)
600
+ min_height = row.get('min_height', 0.0)
601
+ building_id = row.get('building_id', idx)
602
+ bldg_id = row.get('id', idx)
603
+ bldg_layer = LeafletPolygon(
604
+ locations=lat_lon_coords,
605
+ color="blue",
606
+ fill_color="blue",
607
+ fill_opacity=0.3,
608
+ weight=2,
609
+ popup=HTML(f"<b>Building ID:</b> {building_id}<br>"
610
+ f"<b>ID:</b> {bldg_id}<br>"
611
+ f"<b>Height:</b> {height}m<br>"
612
+ f"<b>Min Height:</b> {min_height}m")
613
+ )
614
+ m.add_layer(bldg_layer)
615
+ building_layers[idx] = bldg_layer
616
+
617
+ # Create UI widgets
618
+ height_input = FloatText(
619
+ value=10.0,
620
+ description='Height (m):',
621
+ disabled=False,
622
+ style={'description_width': 'initial'}
623
+ )
624
+
625
+ add_button = Button(
626
+ description='Add Building',
627
+ button_style='success',
628
+ disabled=True
629
+ )
630
+
631
+ clear_button = Button(
632
+ description='Clear Drawing',
633
+ button_style='warning',
634
+ disabled=True
635
+ )
636
+
637
+ status_output = Output()
638
+ hover_info = HTML("")
639
+
640
+ # Create control panel
641
+ control_panel = VBox([
642
+ HTML("<h3>Draw Building Tool</h3>"),
643
+ HTML("<p>1. Draw a polygon on the map<br>2. Set height<br>3. Click 'Add Building'</p>"),
644
+ height_input,
645
+ HBox([add_button, clear_button]),
646
+ status_output
647
+ ])
648
+
649
+ # Add control panel to map
650
+ widget_control = WidgetControl(widget=control_panel, position='topright')
651
+ m.add_control(widget_control)
652
+
653
+ # Store the current drawn polygon
654
+ current_polygon = {'vertices': [], 'layer': None}
655
+
656
+ # Drawing control
657
+ draw_control = DrawControl(
658
+ polygon={
659
+ "shapeOptions": {
660
+ "color": "red",
661
+ "fillColor": "red",
662
+ "fillOpacity": 0.3,
663
+ "weight": 3
664
+ }
665
+ },
666
+ rectangle={},
667
+ circle={},
668
+ circlemarker={},
669
+ polyline={},
670
+ marker={}
671
+ )
672
+
673
+ def handle_draw(self, action, geo_json):
674
+ """Handle polygon drawing events"""
675
+ with status_output:
676
+ clear_output()
677
+
678
+ if action == 'created' and geo_json['geometry']['type'] == 'Polygon':
679
+ # Store vertices
680
+ coordinates = geo_json['geometry']['coordinates'][0]
681
+ current_polygon['vertices'] = [(coord[0], coord[1]) for coord in coordinates[:-1]]
682
+
683
+ # Enable buttons
684
+ add_button.disabled = False
685
+ clear_button.disabled = False
686
+
687
+ with status_output:
688
+ print(f"Polygon drawn with {len(current_polygon['vertices'])} vertices")
689
+ print("Set height and click 'Add Building'")
690
+
691
+ def add_building_click(b):
692
+ """Handle add building button click"""
693
+ # Use nonlocal to modify the outer scope variable
694
+ nonlocal updated_gdf
695
+
696
+ with status_output:
697
+ clear_output()
698
+
699
+ if current_polygon['vertices']:
700
+ # Create polygon geometry
701
+ polygon = geom.Polygon(current_polygon['vertices'])
702
+
703
+ # Get next building ID and ID values (ensure uniqueness)
704
+ if len(updated_gdf) > 0:
705
+ next_building_id = int(updated_gdf['building_id'].max() + 1)
706
+ next_id = int(updated_gdf['id'].max() + 1)
707
+ else:
708
+ next_building_id = 1
709
+ next_id = 1
710
+
711
+ # Create new row data
712
+ new_row_data = {
713
+ 'geometry': polygon,
714
+ 'height': float(height_input.value),
715
+ 'min_height': 0.0, # Default value as requested
716
+ 'building_id': next_building_id,
717
+ 'id': next_id
718
+ }
719
+
720
+ # Add any additional columns
721
+ for col in updated_gdf.columns:
722
+ if col not in new_row_data:
723
+ new_row_data[col] = None
724
+
725
+ # Append the new building in-place
726
+ new_index = len(updated_gdf)
727
+ updated_gdf.loc[new_index] = new_row_data
728
+
729
+ # Add to map
730
+ coords = list(polygon.exterior.coords)
731
+ lat_lon_coords = [(c[1], c[0]) for c in coords[:-1]]
732
+
733
+ new_layer = LeafletPolygon(
734
+ locations=lat_lon_coords,
735
+ color="blue",
736
+ fill_color="blue",
737
+ fill_opacity=0.3,
738
+ weight=2,
739
+ popup=HTML(f"<b>Building ID:</b> {next_building_id}<br>"
740
+ f"<b>ID:</b> {next_id}<br>"
741
+ f"<b>Height:</b> {height_input.value}m<br>"
742
+ f"<b>Min Height:</b> 0.0m")
743
+ )
744
+ m.add_layer(new_layer)
745
+
746
+ # Clear drawing
747
+ draw_control.clear()
748
+ current_polygon['vertices'] = []
749
+ add_button.disabled = True
750
+ clear_button.disabled = True
751
+
752
+ print(f"Building {next_building_id} added successfully!")
753
+ print(f"ID: {next_id}, Height: {height_input.value}m, Min Height: 0.0m")
754
+ print(f"Total buildings: {len(updated_gdf)}")
755
+
756
+ def clear_drawing_click(b):
757
+ """Handle clear drawing button click"""
758
+ with status_output:
759
+ clear_output()
760
+ draw_control.clear()
761
+ current_polygon['vertices'] = []
762
+ add_button.disabled = True
763
+ clear_button.disabled = True
764
+ print("Drawing cleared")
765
+
766
+ # Connect event handlers
767
+ draw_control.on_draw(handle_draw)
768
+ add_button.on_click(add_building_click)
769
+ clear_button.on_click(clear_drawing_click)
770
+
771
+ # Add draw control to map
772
+ m.add_control(draw_control)
773
+
774
+ # Display initial status
775
+ with status_output:
776
+ print(f"Total buildings loaded: {len(updated_gdf)}")
777
+ print("Draw a polygon to add a new building")
778
+
779
+ return m, updated_gdf
780
+
781
+
782
+ def get_polygon_vertices(drawn_polygons, polygon_id=None):
783
+ """
784
+ Extract vertices from drawn polygons data structure.
785
+
786
+ This helper function provides a convenient way to extract polygon vertices
787
+ from the drawn_polygons list returned by display_buildings_and_draw_polygon().
788
+
789
+ Args:
790
+ drawn_polygons: The drawn_polygons list returned from display_buildings_and_draw_polygon()
791
+ polygon_id (int, optional): Specific polygon ID to extract. If None, returns all polygons.
792
+
793
+ Returns:
794
+ If polygon_id is specified: List of (lon, lat) tuples for that polygon
795
+ If polygon_id is None: List of lists, where each inner list contains (lon, lat) tuples
796
+
797
+ Example:
798
+ >>> m, polygons = display_buildings_and_draw_polygon()
799
+ >>> # Draw some polygons...
800
+ >>> vertices = get_polygon_vertices(polygons, polygon_id=1) # Get polygon 1
801
+ >>> all_vertices = get_polygon_vertices(polygons) # Get all polygons
802
+ """
803
+ if not drawn_polygons:
804
+ return []
805
+
806
+ if polygon_id is not None:
807
+ # Return specific polygon
808
+ for polygon in drawn_polygons:
809
+ if polygon['id'] == polygon_id:
810
+ return polygon['vertices']
811
+ return [] # Polygon not found
812
+ else:
813
+ # Return all polygons
814
+ return [polygon['vertices'] for polygon in drawn_polygons]
815
+
816
+
817
+ # Simple convenience function
818
+ def create_building_editor(building_gdf=None, initial_center=None, zoom=17, rectangle_vertices=None):
819
+ """
820
+ Creates and displays an interactive building editor.
821
+
822
+ Args:
823
+ building_gdf: Existing buildings GeoDataFrame (optional)
824
+ initial_center: Map center as (lon, lat) tuple (optional)
825
+ zoom: Initial zoom level (default=17)
826
+
827
+ Returns:
828
+ GeoDataFrame: The building GeoDataFrame that automatically updates
829
+
830
+ Example:
831
+ >>> buildings = create_building_editor()
832
+ >>> # Draw buildings on the displayed map
833
+ >>> print(buildings) # Automatically contains all drawn buildings
834
+ """
835
+ m, gdf = draw_additional_buildings(building_gdf, initial_center, zoom, rectangle_vertices)
836
+ display(m)
837
+ return gdf
838
+
839
+
840
+ def draw_additional_trees(tree_gdf=None, initial_center=None, zoom=17, rectangle_vertices=None):
841
+ """
842
+ Creates an interactive map to add trees by clicking and setting parameters.
843
+
844
+ Users can:
845
+ - Set tree parameters: top height, bottom height, crown diameter
846
+ - Click multiple times to add multiple trees with the same parameters
847
+ - Update parameters at any time to change subsequent trees
848
+
849
+ Args:
850
+ tree_gdf (GeoDataFrame, optional): Existing trees to display.
851
+ Expected columns: ['tree_id', 'top_height', 'bottom_height', 'crown_diameter', 'geometry']
852
+ initial_center (tuple, optional): (lon, lat) for initial map center.
853
+ zoom (int): Initial zoom level. Default=17.
854
+ rectangle_vertices (list, optional): If provided, used to set center like buildings.
855
+
856
+ Returns:
857
+ tuple: (map_object, updated_tree_gdf)
858
+ """
859
+ # Initialize or copy the tree GeoDataFrame
860
+ if tree_gdf is None:
861
+ updated_trees = gpd.GeoDataFrame(
862
+ columns=['tree_id', 'top_height', 'bottom_height', 'crown_diameter', 'geometry'],
863
+ crs='EPSG:4326'
864
+ )
865
+ else:
866
+ updated_trees = tree_gdf.copy()
867
+ # Ensure required columns exist
868
+ if 'tree_id' not in updated_trees.columns:
869
+ updated_trees['tree_id'] = range(1, len(updated_trees) + 1)
870
+ for col, default in [('top_height', 10.0), ('bottom_height', 4.0), ('crown_diameter', 6.0)]:
871
+ if col not in updated_trees.columns:
872
+ updated_trees[col] = default
873
+
874
+ # Determine map center
875
+ if initial_center is not None:
876
+ center_lon, center_lat = initial_center
877
+ elif updated_trees is not None and len(updated_trees) > 0:
878
+ min_lon, min_lat, max_lon, max_lat = updated_trees.total_bounds
879
+ center_lon = (min_lon + max_lon) / 2
880
+ center_lat = (min_lat + max_lat) / 2
881
+ elif rectangle_vertices is not None:
882
+ center_lon, center_lat = (rectangle_vertices[0][0] + rectangle_vertices[2][0]) / 2, (rectangle_vertices[0][1] + rectangle_vertices[2][1]) / 2
883
+ else:
884
+ center_lon, center_lat = -100.0, 40.0
885
+
886
+ # Create map
887
+ m = Map(center=(center_lat, center_lon), zoom=zoom, scroll_wheel_zoom=True)
888
+ # Add aerial/satellite basemap
889
+ try:
890
+ m.add_layer(basemap_to_tiles(basemaps.Esri.WorldImagery))
891
+ except Exception:
892
+ # Fallback silently if basemap cannot be added
893
+ pass
894
+
895
+ # If rectangle_vertices provided, draw its edges on the map
896
+ if rectangle_vertices is not None and len(rectangle_vertices) >= 4:
897
+ try:
898
+ lat_lon_coords = [(lat, lon) for lon, lat in rectangle_vertices]
899
+ rect_outline = LeafletPolygon(
900
+ locations=lat_lon_coords,
901
+ color="#fed766",
902
+ weight=2,
903
+ fill_color="#fed766",
904
+ fill_opacity=0.0
905
+ )
906
+ m.add_layer(rect_outline)
907
+ except Exception:
908
+ pass
909
+
910
+ # Display existing trees as circles
911
+ tree_layers = {}
912
+ for idx, row in updated_trees.iterrows():
913
+ if row.geometry is not None and hasattr(row.geometry, 'x'):
914
+ lat = row.geometry.y
915
+ lon = row.geometry.x
916
+ # Ensure integer radius in meters as required by ipyleaflet Circle
917
+ radius_m = max(int(round(float(row.get('crown_diameter', 6.0)) / 2.0)), 1)
918
+ tree_id_val = int(row.get('tree_id', idx+1))
919
+ circle = Circle(location=(lat, lon), radius=radius_m, color='#2ab7ca', weight=1, opacity=1.0, fill_color='#2ab7ca', fill_opacity=0.3)
920
+ m.add_layer(circle)
921
+ tree_layers[tree_id_val] = circle
922
+
923
+ # UI widgets for parameters
924
+ top_height_input = FloatText(value=10.0, description='Top height (m):', disabled=False, style={'description_width': 'initial'})
925
+ bottom_height_input = FloatText(value=4.0, description='Bottom height (m):', disabled=False, style={'description_width': 'initial'})
926
+ crown_diameter_input = FloatText(value=6.0, description='Crown diameter (m):', disabled=False, style={'description_width': 'initial'})
927
+
928
+ add_mode_button = Button(description='Add', button_style='success')
929
+ remove_mode_button = Button(description='Remove', button_style='')
930
+ status_output = Output()
931
+ hover_info = HTML("")
932
+
933
+ control_panel = VBox([
934
+ HTML("<h3 style=\"margin:0 0 4px 0;\">Tree Placement Tool</h3>"),
935
+ 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>"),
936
+ HBox([add_mode_button, remove_mode_button]),
937
+ top_height_input,
938
+ bottom_height_input,
939
+ crown_diameter_input,
940
+ hover_info,
941
+ status_output
942
+ ])
943
+
944
+ widget_control = WidgetControl(widget=control_panel, position='topright')
945
+ m.add_control(widget_control)
946
+
947
+ # State for mode
948
+ mode = 'add'
949
+
950
+ def set_mode(new_mode):
951
+ nonlocal mode
952
+ mode = new_mode
953
+ # Visual feedback
954
+ add_mode_button.button_style = 'success' if mode == 'add' else ''
955
+ remove_mode_button.button_style = 'danger' if mode == 'remove' else ''
956
+ # No on-screen mode label
957
+
958
+ def on_click_add(b):
959
+ set_mode('add')
960
+
961
+ def on_click_remove(b):
962
+ set_mode('remove')
963
+
964
+ add_mode_button.on_click(on_click_add)
965
+ remove_mode_button.on_click(on_click_remove)
966
+
967
+ # Consecutive placements by map click
968
+ def handle_map_click(**kwargs):
969
+ nonlocal updated_trees
970
+ with status_output:
971
+ clear_output()
972
+
973
+ if kwargs.get('type') == 'click':
974
+ lat, lon = kwargs.get('coordinates', (None, None))
975
+ if lat is None or lon is None:
976
+ return
977
+ if mode == 'add':
978
+ # Determine next tree_id
979
+ next_tree_id = int(updated_trees['tree_id'].max() + 1) if len(updated_trees) > 0 else 1
980
+
981
+ # Clamp/validate parameters
982
+ th = float(top_height_input.value)
983
+ bh = float(bottom_height_input.value)
984
+ cd = float(crown_diameter_input.value)
985
+ if bh > th:
986
+ bh, th = th, bh
987
+ if cd < 0:
988
+ cd = 0.0
989
+
990
+ # Create new tree row
991
+ new_row = {
992
+ 'tree_id': next_tree_id,
993
+ 'top_height': th,
994
+ 'bottom_height': bh,
995
+ 'crown_diameter': cd,
996
+ 'geometry': geom.Point(lon, lat)
997
+ }
998
+
999
+ # Append
1000
+ new_index = len(updated_trees)
1001
+ updated_trees.loc[new_index] = new_row
1002
+
1003
+ # Add circle layer representing crown diameter (radius in meters)
1004
+ radius_m = max(int(round(new_row['crown_diameter'] / 2.0)), 1)
1005
+ circle = Circle(location=(lat, lon), radius=radius_m, color='#2ab7ca', weight=1, opacity=1.0, fill_color='#2ab7ca', fill_opacity=0.3)
1006
+ m.add_layer(circle)
1007
+
1008
+ tree_layers[next_tree_id] = circle
1009
+
1010
+ with status_output:
1011
+ print(f"Tree {next_tree_id} added at (lon, lat)=({lon:.6f}, {lat:.6f})")
1012
+ print(f"Top: {new_row['top_height']} m, Bottom: {new_row['bottom_height']} m, Crown: {new_row['crown_diameter']} m")
1013
+ print(f"Total trees: {len(updated_trees)}")
1014
+ else:
1015
+ # Remove mode: find the nearest tree within its crown radius + 5m
1016
+ candidate_id = None
1017
+ candidate_idx = None
1018
+ candidate_dist = None
1019
+ for idx2, row2 in updated_trees.iterrows():
1020
+ if row2.geometry is None or not hasattr(row2.geometry, 'x'):
1021
+ continue
1022
+ lat2 = row2.geometry.y
1023
+ lon2 = row2.geometry.x
1024
+ dist_m = distance.distance((lat, lon), (lat2, lon2)).meters
1025
+ rad_m = max(int(round(float(row2.get('crown_diameter', 6.0)) / 2.0)), 1)
1026
+ thr_m = rad_m + 5
1027
+ 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):
1028
+ candidate_dist = dist_m
1029
+ candidate_id = int(row2.get('tree_id', idx2+1))
1030
+ candidate_idx = idx2
1031
+
1032
+ if candidate_id is not None:
1033
+ # Remove layer
1034
+ layer = tree_layers.get(candidate_id)
1035
+ if layer is not None:
1036
+ m.remove_layer(layer)
1037
+ del tree_layers[candidate_id]
1038
+ # Remove from gdf
1039
+ updated_trees.drop(index=candidate_idx, inplace=True)
1040
+ updated_trees.reset_index(drop=True, inplace=True)
1041
+ with status_output:
1042
+ print(f"Removed tree {candidate_id} (distance {candidate_dist:.2f} m)")
1043
+ print(f"Total trees: {len(updated_trees)}")
1044
+ else:
1045
+ with status_output:
1046
+ print("No tree near the clicked location to remove")
1047
+ elif kwargs.get('type') == 'mousemove':
1048
+ lat, lon = kwargs.get('coordinates', (None, None))
1049
+ if lat is None or lon is None:
1050
+ return
1051
+ # Find a tree the cursor is over (within crown radius)
1052
+ shown = False
1053
+ for _, row2 in updated_trees.iterrows():
1054
+ if row2.geometry is None or not hasattr(row2.geometry, 'x'):
1055
+ continue
1056
+ lat2 = row2.geometry.y
1057
+ lon2 = row2.geometry.x
1058
+ dist_m = distance.distance((lat, lon), (lat2, lon2)).meters
1059
+ rad_m = max(int(round(float(row2.get('crown_diameter', 6.0)) / 2.0)), 1)
1060
+ if dist_m <= rad_m:
1061
+ hover_info.value = (
1062
+ f"<div style=\"color:#d61f1f; font-weight:600; margin:2px 0;\">"
1063
+ f"Tree {int(row2.get('tree_id', 0))} | Top {float(row2.get('top_height', 10.0))} m | "
1064
+ f"Bottom {float(row2.get('bottom_height', 0.0))} m | Crown {float(row2.get('crown_diameter', 6.0))} m"
1065
+ f"</div>"
1066
+ )
1067
+ shown = True
1068
+ break
1069
+ if not shown:
1070
+ hover_info.value = ""
1071
+ m.on_interaction(handle_map_click)
1072
+
1073
+ with status_output:
1074
+ print(f"Total trees loaded: {len(updated_trees)}")
1075
+ print("Set parameters, then click on the map to add trees")
1076
+
1077
+ return m, updated_trees
1078
+
1079
+
1080
+ def create_tree_editor(tree_gdf=None, initial_center=None, zoom=17, rectangle_vertices=None):
1081
+ """
1082
+ Convenience wrapper to display the tree editor map and return the GeoDataFrame.
1083
+ """
1084
+ m, gdf = draw_additional_trees(tree_gdf, initial_center, zoom, rectangle_vertices)
1085
+ display(m)
833
1086
  return gdf