voxcity 0.6.11__py3-none-any.whl → 0.6.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

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