Spaces:
Paused
Paused
| """ | |
| Map Handler for Geographic Drawing | |
| Supports drawing custom shapes on interactive maps | |
| """ | |
| import folium | |
| from folium.plugins import Draw | |
| import streamlit as st | |
| from streamlit_folium import st_folium | |
| from typing import List, Tuple, Dict, Any, Optional | |
| import json | |
| import numpy as np | |
| from shapely.geometry import Polygon, shape | |
| from shapely.ops import transform | |
| import pyproj | |
| from functools import partial | |
| class MapDrawingHandler: | |
| """Handle map-based shape drawing and coordinate conversion""" | |
| # Default center (Delhi, India for Indian Building Regulations context) | |
| DEFAULT_CENTER = [28.6139, 77.2090] | |
| DEFAULT_ZOOM = 15 | |
| def __init__(self, center: List[float] = None, zoom: int = None): | |
| self.center = center or self.DEFAULT_CENTER | |
| self.zoom = zoom or self.DEFAULT_ZOOM | |
| self.drawn_shapes = [] | |
| def create_drawing_map(self, | |
| existing_shape: List[Tuple[float, float]] = None, | |
| height: int = 500) -> folium.Map: | |
| """Create a Folium map with drawing controls""" | |
| m = folium.Map( | |
| location=self.center, | |
| zoom_start=self.zoom, | |
| tiles='OpenStreetMap' | |
| ) | |
| # Add alternative tile layers | |
| folium.TileLayer('cartodbpositron', name='Light Map').add_to(m) | |
| folium.TileLayer('cartodbdark_matter', name='Dark Map').add_to(m) | |
| # Add satellite imagery option | |
| folium.TileLayer( | |
| tiles='https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', | |
| attr='Esri', | |
| name='Satellite' | |
| ).add_to(m) | |
| # Add draw control | |
| draw = Draw( | |
| export=True, | |
| position='topleft', | |
| draw_options={ | |
| 'polyline': False, | |
| 'rectangle': True, | |
| 'polygon': True, | |
| 'circle': False, | |
| 'marker': False, | |
| 'circlemarker': False | |
| }, | |
| edit_options={ | |
| 'edit': True, | |
| 'remove': True | |
| } | |
| ) | |
| draw.add_to(m) | |
| # Add existing shape if provided | |
| if existing_shape: | |
| # Convert to lat/lng for display | |
| geo_coords = self.meters_to_latlng(existing_shape) | |
| folium.Polygon( | |
| locations=geo_coords, | |
| color='blue', | |
| fill=True, | |
| fill_opacity=0.3, | |
| popup='Existing Site Boundary' | |
| ).add_to(m) | |
| # Add layer control | |
| folium.LayerControl().add_to(m) | |
| return m | |
| def extract_drawn_shape(self, map_data: Dict) -> Optional[List[Tuple[float, float]]]: | |
| """Extract drawn shape from map data and convert to meters""" | |
| if not map_data or 'all_drawings' not in map_data: | |
| return None | |
| drawings = map_data.get('all_drawings', []) | |
| if not drawings: | |
| return None | |
| # Get the last drawn polygon | |
| for drawing in reversed(drawings): | |
| geom = drawing.get('geometry', {}) | |
| if geom.get('type') in ['Polygon', 'Rectangle']: | |
| coords = geom.get('coordinates', [[]])[0] | |
| if coords: | |
| # Convert from [lng, lat] to [lat, lng] then to meters | |
| latlng_coords = [(c[1], c[0]) for c in coords] | |
| return self.latlng_to_meters(latlng_coords) | |
| return None | |
| def latlng_to_meters(self, | |
| coords: List[Tuple[float, float]], | |
| reference: Tuple[float, float] = None) -> List[Tuple[float, float]]: | |
| """ | |
| Convert lat/lng coordinates to local meter coordinates | |
| Uses UTM projection centered on the shape | |
| """ | |
| if not coords: | |
| return [] | |
| # Get reference point (centroid of shape) | |
| if reference is None: | |
| lats = [c[0] for c in coords] | |
| lngs = [c[1] for c in coords] | |
| reference = (sum(lats) / len(lats), sum(lngs) / len(lngs)) | |
| # Determine UTM zone | |
| utm_zone = int((reference[1] + 180) / 6) + 1 | |
| is_northern = reference[0] >= 0 | |
| # Create projection | |
| proj_string = f"+proj=utm +zone={utm_zone} +{'north' if is_northern else 'south'} +ellps=WGS84 +datum=WGS84 +units=m +no_defs" | |
| try: | |
| wgs84 = pyproj.CRS('EPSG:4326') | |
| utm = pyproj.CRS(proj_string) | |
| transformer = pyproj.Transformer.from_crs(wgs84, utm, always_xy=True) | |
| # Transform coordinates | |
| meter_coords = [] | |
| for lat, lng in coords: | |
| x, y = transformer.transform(lng, lat) | |
| meter_coords.append((x, y)) | |
| # Normalize to origin | |
| xs = [c[0] for c in meter_coords] | |
| ys = [c[1] for c in meter_coords] | |
| min_x, min_y = min(xs), min(ys) | |
| normalized = [(x - min_x, y - min_y) for x, y in meter_coords] | |
| return normalized | |
| except Exception as e: | |
| print(f"Projection error: {e}") | |
| # Fallback: simple equirectangular approximation | |
| lat_scale = 111320 # meters per degree latitude | |
| lng_scale = lat_scale * np.cos(np.radians(reference[0])) | |
| meter_coords = [] | |
| ref_lat, ref_lng = reference | |
| for lat, lng in coords: | |
| x = (lng - ref_lng) * lng_scale | |
| y = (lat - ref_lat) * lat_scale | |
| meter_coords.append((x, y)) | |
| # Normalize to origin | |
| xs = [c[0] for c in meter_coords] | |
| ys = [c[1] for c in meter_coords] | |
| min_x, min_y = min(xs), min(ys) | |
| return [(x - min_x, y - min_y) for x, y in meter_coords] | |
| def meters_to_latlng(self, | |
| coords: List[Tuple[float, float]], | |
| reference: Tuple[float, float] = None) -> List[Tuple[float, float]]: | |
| """Convert meter coordinates back to lat/lng (approximate)""" | |
| if reference is None: | |
| reference = self.center | |
| lat_scale = 111320 | |
| lng_scale = lat_scale * np.cos(np.radians(reference[0])) | |
| latlng_coords = [] | |
| for x, y in coords: | |
| lat = reference[0] + y / lat_scale | |
| lng = reference[1] + x / lng_scale | |
| latlng_coords.append((lat, lng)) | |
| return latlng_coords | |
| def validate_shape(self, coords: List[Tuple[float, float]]) -> Tuple[bool, str]: | |
| """Validate drawn shape""" | |
| if not coords or len(coords) < 3: | |
| return False, "Shape must have at least 3 points" | |
| try: | |
| polygon = Polygon(coords) | |
| if not polygon.is_valid: | |
| polygon = polygon.buffer(0) | |
| if not polygon.is_valid: | |
| return False, "Invalid polygon shape" | |
| area = polygon.area | |
| if area < 1000: # Less than 1000 sq meters | |
| return False, f"Shape too small ({area:.0f} sq.m). Minimum 1000 sq.m required." | |
| if area > 10000000: # More than 10 sq km | |
| return False, f"Shape too large ({area/1000000:.2f} sq.km). Maximum 10 sq.km allowed." | |
| return True, f"Valid shape: {area:.0f} sq.m" | |
| except Exception as e: | |
| return False, f"Validation error: {str(e)}" | |
| class ShapePreviewRenderer: | |
| """Render shape previews for UI""" | |
| def render_shape_svg(coords: List[Tuple[float, float]], | |
| width: int = 200, | |
| height: int = 150) -> str: | |
| """Generate SVG preview of shape""" | |
| if not coords: | |
| return "" | |
| # Normalize coordinates to fit SVG viewport | |
| xs = [c[0] for c in coords] | |
| ys = [c[1] for c in coords] | |
| min_x, max_x = min(xs), max(xs) | |
| min_y, max_y = min(ys), max(ys) | |
| shape_width = max_x - min_x | |
| shape_height = max_y - min_y | |
| if shape_width == 0 or shape_height == 0: | |
| return "" | |
| # Scale and translate | |
| padding = 10 | |
| scale_x = (width - 2 * padding) / shape_width | |
| scale_y = (height - 2 * padding) / shape_height | |
| scale = min(scale_x, scale_y) | |
| offset_x = padding + (width - 2 * padding - shape_width * scale) / 2 | |
| offset_y = padding + (height - 2 * padding - shape_height * scale) / 2 | |
| # Generate path | |
| path_points = [] | |
| for x, y in coords: | |
| sx = offset_x + (x - min_x) * scale | |
| sy = height - (offset_y + (y - min_y) * scale) # Flip Y | |
| path_points.append(f"{sx:.1f},{sy:.1f}") | |
| path_d = "M " + " L ".join(path_points) + " Z" | |
| svg = f""" | |
| <svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg"> | |
| <rect width="100%" height="100%" fill="#f0f0f0" rx="5"/> | |
| <path d="{path_d}" fill="#4CAF50" fill-opacity="0.3" stroke="#2E7D32" stroke-width="2"/> | |
| </svg> | |
| """ | |
| return svg | |