diff --git a/geoelevation/map_viewer/geo_map_viewer.py b/geoelevation/map_viewer/geo_map_viewer.py index 51e1c80..5ae01cc 100644 --- a/geoelevation/map_viewer/geo_map_viewer.py +++ b/geoelevation/map_viewer/geo_map_viewer.py @@ -26,14 +26,17 @@ try: except ImportError: Image = None # type: ignore ImageDraw = None # type: ignore # Define as None if import fails - ImageType = None # type: ignore + # MODIFIED: Added ImageType definition for type hinting even if PIL is missing. + # WHY: Allows static analysis tools to understand the intended type even if the library isn't installed. + # HOW: Defined ImageType = Any inside the except block. + ImageType = Any # type: ignore # Define ImageType as Any if PIL is not available # This logger might not be configured yet if this is the first import in the process # So, direct print or rely on higher-level logger configuration. print("ERROR: GeoMapViewer - Pillow (PIL) library not found. Image operations will fail.") try: - import cv2 # OpenCV for drawing operations + import cv2 # OpenCV for windowing and drawing import numpy as np CV2_NUMPY_LIBS_AVAILABLE = True except ImportError: @@ -107,7 +110,7 @@ class GeoElevationMapViewer: initial_display_scale: float = 1.0 # Scale factor for the map image ) -> None: """ - Initializes the GeoElevationMapViewer. + Initializes the GeoElevationMapViewer. Args: elevation_manager_instance: Instance of ElevationManager for fetching elevations. @@ -160,7 +163,7 @@ class GeoElevationMapViewer: self._current_stitched_map_pil: Optional[ImageType] = None self._current_map_geo_bounds_deg: Optional[Tuple[float, float, float, float]] = None self._current_map_render_zoom: Optional[int] = None - self._current_stitched_map_pixel_shape: Optional[Tuple[int, int]] = None # H, W + self._current_stitched_map_pixel_shape: Optional[Tuple[int, int]] = (0, 0) # H, W self._last_user_click_pixel_coords_on_displayed_image: Optional[Tuple[int, int]] = None # MODIFIED: Added attribute to store the DEM tile bbox if a map view was initiated for a point with DEM data. @@ -214,6 +217,7 @@ class GeoElevationMapViewer: logger.critical(f"Failed to initialize map components: {e_init_map_comp}", exc_info=True) raise + def display_map_for_point( self, center_latitude: float, @@ -225,10 +229,10 @@ class GeoElevationMapViewer: draws a marker at the point, and sends initial info back to the GUI. Applies the current display scale. The zoom level is calculated to fit the DEM tile. """ - if not self.map_tile_fetch_manager or not self.map_display_window_controller or not self.elevation_manager: + if not self.map_tile_fetch_manager or not self.map_display_window_controller or not self.elevation_manager or not self.map_service_provider: # Added check for map_service_provider logger.error("Map or Elevation components not ready for display_map_for_point.") - # MODIFIED: If components aren't ready, send error info back to GUI queue. - # WHY: The GUI needs to know the map view failed. + # MODIFIED: Send error info to GUI queue if components aren't ready. + # WHY: GUI should update even if map isn't displayed. # HOW: Put an error message into the queue. error_payload = {"type": "map_info_update", "latitude": center_latitude, "longitude": center_longitude, "elevation_str": "Map Error", "map_area_size_str": "Error: Components N/A"} @@ -252,6 +256,10 @@ class GeoElevationMapViewer: # HOW: Set _dem_tile_geo_bbox_for_current_map to None. self._dem_tile_geo_bbox_for_current_map = None + # MODIFIED: Initialize map_tile_xy_ranges to None before the try block. + # WHY: To ensure the variable is defined even if an exception occurs before its assignment. + # HOW: Added the initialization here. + map_tile_xy_ranges = None try: # MODIFIED: 1. Get DEM tile info and its geographic bounds. @@ -303,36 +311,52 @@ class GeoElevationMapViewer: # MODIFIED: 3. Calculate the appropriate zoom level to fit the map_fetch_geo_bbox into the target pixel size. # WHY: To prevent creating excessively large map images like 28160x40192 px. # HOW: Calculate geographic height of map_fetch_geo_bbox and use calculate_zoom_level_for_geographic_size. - map_bbox_size_km = calculate_geographic_bbox_size_km(map_fetch_geo_bbox) calculated_zoom = None - if map_bbox_size_km: - _, map_bbox_height_km = map_bbox_size_km - map_bbox_height_meters = map_bbox_height_km * 1000.0 - # Use the center latitude of the fetch box for zoom calculation accuracy - center_lat_fetch_bbox = (map_fetch_geo_bbox[1] + map_fetch_geo_bbox[3]) / 2.0 + zoom_calculation_successful = False + map_area_size_km = None # Added variable to store size for logging + + # MODIFIED: Check PyProj availability before calculating size. + # WHY: calculate_geographic_bbox_size_km requires PyProj. + # HOW: Added check. + if PYPROJ_AVAILABLE: # type: ignore + map_area_size_km = calculate_geographic_bbox_size_km(map_fetch_geo_bbox) + if map_area_size_km: + width_km, height_km = map_area_size_km + map_bbox_height_meters = height_km * 1000.0 + # Use the center latitude of the fetch box for zoom calculation accuracy + center_lat_fetch_bbox = (map_fetch_geo_bbox[1] + map_fetch_geo_bbox[3]) / 2.0 + + calculated_zoom = calculate_zoom_level_for_geographic_size( + center_lat_fetch_bbox, + map_bbox_height_meters, + TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW, # Target pixel height + self.map_service_provider.tile_size # Tile size from the map service + ) + if calculated_zoom is not None: + logger.info(f"Calculated zoom level {calculated_zoom} to fit BBox height ({map_bbox_height_meters:.2f}m) into {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}px.") + zoom_calculation_successful = True + else: + logger.warning("Could not calculate appropriate zoom level. Falling back to default zoom.") - calculated_zoom = calculate_zoom_level_for_geographic_size( - center_lat_fetch_bbox, - map_bbox_height_meters, - TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW, # Target pixel height - self.map_service_provider.tile_size # Tile size from the map service - ) - if calculated_zoom is not None: - logger.info(f"Calculated zoom level {calculated_zoom} to fit BBox height ({map_bbox_height_meters:.2f}m) into {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}px.") else: - logger.warning("Could not calculate appropriate zoom level. Falling back to default zoom.") + logger.warning("Could not calculate geographic size of fetch BBox. Falling back to default zoom.") + else: + logger.warning("PyProj not available. Cannot calculate geographic size for zoom calculation. Falling back to default zoom.") - # MODIFIED: 4. Use the calculated zoom level for tile ranges and stitching, falling back to effective_zoom if calculation failed. - # WHY: This is the core change to control the stitched image pixel size. - # HOW: Replace `effective_zoom` with `calculated_zoom` (or `effective_zoom` if `calculated_zoom` is None) in the calls below. - zoom_to_use = calculated_zoom if calculated_zoom is not None else effective_zoom + # MODIFIED: Determine the final zoom level to use. + # WHY: Use the calculated zoom if successful, otherwise use the default zoom as a fallback. + # HOW: Check zoom_calculation_successful. + zoom_to_use = calculated_zoom if zoom_calculation_successful else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL logger.debug(f"Using zoom level {zoom_to_use} for tile fetching and stitching.") + # map_tile_xy_ranges assignment is here - line 346 originally map_tile_xy_ranges = get_tile_ranges_for_bbox(map_fetch_geo_bbox, zoom_to_use) + + if not map_tile_xy_ranges: - # This might happen if the BBox is valid but so small it doesn't intersect any tiles at this zoom + # This might happen if the BBox is very small or outside standard tile limits, mercantile.tiles might be empty. logger.warning(f"No map tile ranges found for fetch BBox {map_fetch_geo_bbox} at zoom {zoom_to_use}. Showing placeholder.") self.map_display_window_controller.show_map(None) # MODIFIED: Send initial info to GUI even if map fails, with error status. @@ -341,6 +365,7 @@ class GeoElevationMapViewer: self._send_initial_point_info_to_gui(center_latitude, center_longitude, "Map Tiles N/A", "Map Tiles N/A") return # Exit after showing placeholder/sending error + # MODIFIED: Pass the chosen zoom_to_use to stitch_map_image. stitched_pil = self.map_tile_fetch_manager.stitch_map_image( zoom_to_use, map_tile_xy_ranges[0], map_tile_xy_ranges[1] @@ -357,15 +382,15 @@ class GeoElevationMapViewer: self._current_stitched_map_pil = stitched_pil # MODIFIED: Store the *actual* geographic bounds covered by the stitched tiles. - # WHY: This is needed for pixel-to-geo conversions and calculating the displayed area size. + # WHY: Needed for pixel-to-geo conversions and calculating the displayed area size. # HOW: Get bounds from map_tile_fetch_manager after stitching. - # MODIFIED: Pass the zoom level *actually used* for stitching to get_bounds_for_tile_range. + # MODIFIED: Pass the zoom level *actually used* for stitching (zoom_to_use) to get_bounds_for_tile_range. # WHY: The bounds calculated must correspond to the tiles that were actually stitched. # HOW: Replaced `effective_zoom` with `zoom_to_use`. self._current_map_geo_bounds_deg = self.map_tile_fetch_manager._get_bounds_for_tile_range( zoom_to_use, map_tile_xy_ranges ) - # MODIFIED: Store the zoom level *actually used* for stitching. + # MODIFIED: Store the zoom level *actually used* for stitching (zoom_to_use). # WHY: Consistency in context. # HOW: Assigned `zoom_to_use` to _current_map_render_zoom. self._current_map_render_zoom = zoom_to_use @@ -405,10 +430,21 @@ class GeoElevationMapViewer: # Calculate and send map area size map_area_size_str = "N/A" if self._current_map_geo_bounds_deg: - size_km = calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg) - if size_km: - width_km, height_km = size_km - map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H" + # MODIFIED: Check PyProj availability before calculating size. + # WHY: calculate_geographic_bbox_size_km requires PyProj. + # HOW: Added check. + if PYPROJ_AVAILABLE: # type: ignore + size_km = calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg) + if size_km: + width_km, height_km = size_km + map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H" + else: + map_area_size_str = "Size Calc Failed" + logger.warning("calculate_geographic_bbox_size_km returned None for current map bounds.") + else: + map_area_size_str = "PyProj N/A (Size Unknown)" + logger.warning("PyProj not available, cannot calculate map area size.") + self._send_initial_point_info_to_gui( center_latitude, center_longitude, initial_elev_str, map_area_size_str @@ -434,10 +470,13 @@ class GeoElevationMapViewer: def display_map_for_area( self, area_geo_bbox: Tuple[float, float, float, float], # west, south, east, north - target_map_zoom: Optional[int] = None + target_map_zoom: Optional[int] = None # This parameter is now effectively ignored for area view sizing ) -> None: - """Displays a map for a geographic area, applying the current display scale.""" - if not self.map_tile_fetch_manager or not self.map_display_window_controller: + """ + Displays a map for a geographic area, applying the current display scale. + Calculates the zoom level dynamically to fit the requested area into a target pixel size. + """ + if not self.map_tile_fetch_manager or not self.map_display_window_controller or not self.map_service_provider: # Added check for map_service_provider logger.error("Map components not ready for display_map_for_area.") # MODIFIED: Send error info to GUI queue if components aren't ready. # WHY: GUI should update even if map isn't displayed. @@ -449,30 +488,83 @@ class GeoElevationMapViewer: if self.map_display_window_controller: self.map_display_window_controller.show_map(None) # Show placeholder return - # MODIFIED: Default zoom for area view can still be the global default map display zoom. - # WHY: For area view, the user requested a specific bounding box, not necessarily tied to a DEM tile size. - # A fixed default zoom might be acceptable, or we could calculate zoom based on area bbox size too (future). - # HOW: Keep effective_zoom logic using DEFAULT_MAP_DISPLAY_ZOOM_LEVEL. - effective_zoom = target_map_zoom if target_map_zoom is not None else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL + # MODIFIED: Remove the effective_zoom calculation that defaulted to DEFAULT_MAP_DISPLAY_ZOOM_LEVEL. + # WHY: The goal is to calculate the zoom dynamically based on the area size, not use a fixed default. + # effective_zoom = target_map_zoom if target_map_zoom is not None else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL + # logger.info( + # f"Requesting map display for area: BBox {area_geo_bbox}, " + # f"Zoom: {effective_zoom}, CurrentDisplayScale: {self.current_display_scale_factor:.2f}" + # ) logger.info( - f"Requesting map display for area: BBox {area_geo_bbox}, " - f"Zoom: {effective_zoom}, CurrentDisplayScale: {self.current_display_scale_factor:.2f}" + f"Requesting map display for area: BBox {area_geo_bbox}, " + f"Target Pixel Size: {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}x{TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}, " + f"CurrentDisplayScale: {self.current_display_scale_factor:.2f}" ) + + # MODIFIED: Clear the stored DEM tile bbox as this is an area view. # WHY: The DEM boundary is specific to the initial point view. # HOW: Set _dem_tile_geo_bbox_for_current_map to None. self._dem_tile_geo_bbox_for_current_map = None + calculated_zoom: Optional[int] = None + zoom_calculation_successful = False + map_area_size_km: Optional[Tuple[float, float]] = None + + # MODIFIED: Initialize map_tile_xy_ranges to None before the try block. + # WHY: To ensure the variable is defined even if an exception occurs before its assignment. + # HOW: Added the initialization here. + map_tile_xy_ranges = None + try: - # MODIFIED: Use the provided area_geo_bbox directly for tile range calculation. - # WHY: For area view, we want to show the requested area, not necessarily tied to a single DEM tile. - # HOW: Passed area_geo_bbox to get_tile_ranges_for_bbox. - # MODIFIED: Pass the effective_zoom (default 15 or user-provided) to get_tile_ranges_for_bbox. - # WHY: For area view, we use the specified or default zoom. - # HOW: Replaced `zoom_to_use` with `effective_zoom`. - map_tile_xy_ranges = get_tile_ranges_for_bbox(area_geo_bbox, effective_zoom) + # MODIFIED: Calculate the geographic size of the requested area bounding box. + # WHY: Needed to determine the appropriate zoom level to fit this area into a target pixel size. + # HOW: Call map_utils.calculate_geographic_bbox_size_km. + # MODIFIED: Check PyProj availability before calculating size. + # WHY: calculate_geographic_bbox_size_km requires PyProj. + # HOW: Added check. + if PYPROJ_AVAILABLE: # type: ignore + map_area_size_km = calculate_geographic_bbox_size_km(area_geo_bbox) + if map_area_size_km: + width_km, height_km = map_area_size_km + logger.debug(f"Calculated geographic size of requested area: {width_km:.2f}km W x {height_km:.2f}km H") + + # MODIFIED: Calculate the appropriate zoom level to fit the area into the target pixel size. + # WHY: To prevent creating excessively large map images for large geographic areas. + # HOW: Use calculate_zoom_level_for_geographic_size based on the area's height. + map_bbox_height_meters = height_km * 1000.0 + # Use the center latitude of the requested area BBox for zoom calculation accuracy + center_lat_area_bbox = (area_geo_bbox[1] + area_geo_bbox[3]) / 2.0 + + calculated_zoom = calculate_zoom_level_for_geographic_size( + center_lat_area_bbox, + map_bbox_height_meters, + TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW, # Target pixel height (reuse constant from point view) + self.map_service_provider.tile_size # Tile size from the map service + ) + if calculated_zoom is not None: + logger.info(f"Calculated zoom level {calculated_zoom} to fit Area BBox height ({map_bbox_height_meters:.2f}m) into {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}px.") + zoom_calculation_successful = True + else: + logger.warning("Could not calculate appropriate zoom level for area. Falling back to default zoom.") + + else: + logger.warning("Could not calculate geographic size of requested area BBox. Falling back to default zoom.") + else: + logger.warning("PyProj not available. Cannot calculate geographic size for zoom calculation. Falling back to default zoom.") + + + # MODIFIED: Determine the final zoom level to use. + # WHY: Use the calculated zoom if successful, otherwise use the default zoom as a fallback. + # HOW: Check zoom_calculation_successful. + zoom_to_use = calculated_zoom if zoom_calculation_successful else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL + logger.debug(f"Using zoom level {zoom_to_use} for tile fetching and stitching for area.") + + # map_tile_xy_ranges assignment is here - corresponds to line 346 in point view + map_tile_xy_ranges = get_tile_ranges_for_bbox(area_geo_bbox, zoom_to_use) + if not map_tile_xy_ranges: - logger.warning(f"No map tile ranges found for area BBox {area_geo_bbox} at zoom {effective_zoom}. Showing placeholder.") + logger.warning(f"No map tile ranges found for area BBox {area_geo_bbox} at zoom {zoom_to_use}. Showing placeholder.") self.map_display_window_controller.show_map(None) # MODIFIED: Send error info to GUI queue even if map fails. # WHY: GUI should update even if map isn't displayed. @@ -481,9 +573,9 @@ class GeoElevationMapViewer: return # Exit after showing placeholder/sending error - # MODIFIED: Pass the effective_zoom to stitch_map_image. + # MODIFIED: Pass the chosen zoom_to_use to stitch_map_image. stitched_pil = self.map_tile_fetch_manager.stitch_map_image( - effective_zoom, map_tile_xy_ranges[0], map_tile_xy_ranges[1] + zoom_to_use, map_tile_xy_ranges[0], map_tile_xy_ranges[1] ) if not stitched_pil: logger.error("Failed to stitch map image for area display.") @@ -498,16 +590,16 @@ class GeoElevationMapViewer: # MODIFIED: Store the *actual* geographic bounds covered by the stitched tiles for the area view. # WHY: Needed for pixel-to-geo conversions and calculating the displayed area size. # HOW: Get bounds from map_tile_fetch_manager after stitching. - # MODIFIED: Pass the zoom level *actually used* for stitching (effective_zoom) to get_bounds_for_tile_range. + # MODIFIED: Pass the zoom level *actually used* for stitching (zoom_to_use) to get_bounds_for_tile_range. # WHY: The bounds calculated must correspond to the tiles that were actually stitched. - # HOW: Replaced `zoom_to_use` with `effective_zoom`. + # HOW: Replaced `effective_zoom` with `zoom_to_use`. self._current_map_geo_bounds_deg = self.map_tile_fetch_manager._get_bounds_for_tile_range( - effective_zoom, map_tile_xy_ranges + zoom_to_use, map_tile_xy_ranges ) - # MODIFIED: Store the zoom level *actually used* for stitching (effective_zoom). + # MODIFIED: Store the zoom level *actually used* for stitching (zoom_to_use). # WHY: Consistency in context. - # HOW: Assigned `effective_zoom` to _current_map_render_zoom. - self._current_map_render_zoom = effective_zoom + # HOW: Assigned `zoom_to_use` to _current_map_render_zoom. + self._current_map_render_zoom = zoom_to_use self._current_stitched_map_pixel_shape = (stitched_pil.height, stitched_pil.width) # MODIFIED: Draw the *requested* area bounding box on the map image. @@ -524,10 +616,22 @@ class GeoElevationMapViewer: # HOW: Calculate size and send message (using N/A for point info in this case). map_area_size_str = "N/A" if self._current_map_geo_bounds_deg: - size_km = calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg) - if size_km: - width_km, height_km = size_km - map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H" + # MODIFIED: Check PyProj availability before calculating size. + # WHY: calculate_geographic_bbox_size_km requires PyProj. + # HOW: Added check. + if PYPROJ_AVAILABLE: # type: ignore + # Calculate size of the *stitched* area's bounds (which might be slightly larger than requested) + size_km = calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg) + if size_km: + width_km, height_km = size_km + map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H" + else: + map_area_size_str = "Size Calc Failed" + logger.warning("calculate_geographic_bbox_size_km returned None for current map bounds.") + else: + map_area_size_str = "PyProj N/A (Size Unknown)" + logger.warning("PyProj not available, cannot calculate map area size.") + # Send info for area view (point info is N/A) self._send_initial_point_info_to_gui(None, None, "N/A (Area View)", map_area_size_str) @@ -640,8 +744,8 @@ class GeoElevationMapViewer: # Relative position of the target geo point within the *unscaled* map's Mercator extent # Need to handle potential division by zero if map width/height is zero (e.g., invalid bounds) - relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc - relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc + relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc if total_map_width_merc > 0 else 0.0 + relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Convert these relative positions to pixel coordinates on the *unscaled* image @@ -735,8 +839,8 @@ class GeoElevationMapViewer: target_merc_x, target_merc_y = local_mercantile.xy(lon, lat) # type: ignore # Handle relative position calculation, ensuring bounds are respected - relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc - relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc + relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc if total_map_width_merc > 0 else 0.0 + relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 pixel_x_on_unscaled = int(round(relative_merc_x_in_map * unscaled_width)) pixel_y_on_unscaled = int(round(relative_merc_y_in_map * unscaled_height)) @@ -888,7 +992,7 @@ class GeoElevationMapViewer: logger.debug( f"Map mouse click (on scaled img) received at pixel ({pixel_x_on_displayed_img}, {pixel_y_on_displayed_img})" ) - # Store the pixel coordinates of the click on the *displayed* (scaled) image. + # Store the pixel coordinates of the click on the *displayed* (scalata) image. self._last_user_click_pixel_coords_on_displayed_image = (pixel_x_on_displayed_img, pixel_y_on_displayed_img) # MODIFIED: Check if map context is ready before proceeding with conversion and elevation fetch. @@ -1039,7 +1143,7 @@ class GeoElevationMapViewer: self._current_stitched_map_pil = None self._current_map_geo_bounds_deg = None self._current_map_render_zoom = None - self._current_stitched_map_pixel_shape = None + self._current_stitched_map_pixel_shape = (0, 0) # Reset to default tuple self._last_user_click_pixel_coords_on_displayed_image = None self._dem_tile_geo_bbox_for_current_map = None