SXXXXXXX_GeoElevation/geoelevation/map_viewer/geo_map_viewer.py
2025-05-14 07:55:57 +02:00

1154 lines
69 KiB
Python

# geoelevation/map_viewer/geo_map_viewer.py
"""
Orchestrates map display functionalities for the GeoElevation application.
This module initializes and manages map services, tile fetching/caching,
and the map display window. It handles requests to show maps centered on
specific points or covering defined areas, applying a specified display scale.
It also processes user interactions (mouse clicks) on the map, converting
pixel coordinates to geographic coordinates, fetching elevation for those points
using the core ElevationManager, and sending this information back to the
main GUI via a queue.
"""
# Standard library imports
import logging
import math
import queue # For type hinting, actual queue object is passed in from multiprocessing
import sys # Import sys for logging stream
from typing import Optional, Tuple, Dict, Any, List
# Third-party imports
try:
from PIL import Image, ImageDraw # Import ImageDraw for drawing operations
ImageType = Image.Image # type: ignore
PIL_IMAGE_LIB_AVAILABLE = True
except ImportError:
Image = None # type: ignore
ImageDraw = None # type: ignore # Define as None if import fails
# 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 windowing and drawing
import numpy as np
CV2_NUMPY_LIBS_AVAILABLE = True
except ImportError:
cv2 = None # type: ignore
np = None # type: ignore
CV2_NUMPY_LIBS_AVAILABLE = False
print("ERROR: GeoMapViewer - OpenCV or NumPy not found. Drawing and image operations will fail.")
try:
import mercantile # For Web Mercator tile calculations and coordinate conversions
MERCANTILE_LIB_AVAILABLE_DISPLAY = True
except ImportError:
mercantile = None # type: ignore
MERCANTILE_LIB_AVAILABLE_DISPLAY = False
print("ERROR: MapDisplay - 'mercantile' library not found. Coordinate conversions will fail.")
# Local application/package imports
# Imports from other modules within the 'map_viewer' subpackage
from .map_services import BaseMapService
from .map_services import OpenStreetMapService # Default service if none specified
from .map_manager import MapTileManager
from .map_utils import get_bounding_box_from_center_size
from .map_utils import get_tile_ranges_for_bbox
from .map_utils import MapCalculationError
# MODIFIED: Import the new utility functions for geographic size and HGT tile bounds.
# WHY: Needed for calculating displayed map area size and getting DEM tile bounds.
# HOW: Added imports from map_utils.
from .map_utils import calculate_geographic_bbox_size_km
from .map_utils import get_hgt_tile_geographic_bounds
# MODIFIED: Import the new utility function to calculate zoom level for geographic size.
# WHY: This is the core function needed to determine the appropriate zoom for the map point view.
# HOW: Added import from map_utils.
from .map_utils import calculate_zoom_level_for_geographic_size
from .map_utils import PYPROJ_AVAILABLE
# Imports from the parent 'geoelevation' package
from geoelevation.elevation_manager import ElevationManager
# Module-level logger. Will be configured by the calling process (run_map_viewer_process_target)
# or use root logger if not specifically configured.
logger = logging.getLogger(__name__) # Uses 'geoelevation.map_viewer.geo_map_viewer'
# Default configuration values specific to the map viewer's operation
DEFAULT_MAP_TILE_CACHE_DIRECTORY = "map_tile_cache_ge"
DEFAULT_MAP_DISPLAY_ZOOM_LEVEL = 15
DEFAULT_MAP_VIEW_AREA_SIZE_KM = 5.0 # This default might become less relevant for point views
# MODIFIED: Define constants for drawing the DEM tile boundary.
# WHY: Improves code clarity and makes colors/thickness easily adjustable.
# HOW: Added constants for DEM boundary color and thickness.
DEM_BOUNDARY_COLOR = "red"
DEM_BOUNDARY_THICKNESS_PX = 3 # Pixel thickness on the unscaled map image
# MODIFIED: Define target pixel dimensions for the stitched map image in the point view.
# WHY: This is the desired output size that determines the calculated zoom level.
# HOW: Added a constant.
TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW = 1024 # Target width and height in pixels
class GeoElevationMapViewer:
"""
Manages the display of maps and user interaction for GeoElevation.
This class is intended to be instantiated and run in a separate process.
"""
def __init__(
self,
elevation_manager_instance: ElevationManager,
gui_output_communication_queue: queue.Queue, # For sending data back to GUI
initial_display_scale: float = 1.0 # Scale factor for the map image
) -> None:
"""
Initializes the GeoElevationMapViewer.
Args:
elevation_manager_instance: Instance of ElevationManager for fetching elevations.
gui_output_communication_queue: Queue to send interaction data to the main GUI.
initial_display_scale: Initial scaling factor for the map display.
"""
logger.info("Initializing GeoElevationMapViewer instance...")
# MODIFIED: Added a check for critical dependencies at init.
# WHY: Ensure the class can function before proceeding.
# HOW: Raise ImportError if dependencies are missing.
if not CV2_NUMPY_LIBS_AVAILABLE:
critical_msg = "OpenCV and/or NumPy are not available for GeoElevationMapViewer operation."
logger.critical(critical_msg)
raise ImportError(critical_msg)
# PIL and mercantile are also critical for map viewer logic
if not PIL_IMAGE_LIB_AVAILABLE:
critical_msg = "Pillow (PIL) library is not available for GeoElevationMapViewer operation."
logger.critical(critical_msg)
raise ImportError(critical_msg)
# MODIFIED: Added check for ImageDraw availability, as it's needed for drawing.
# WHY: Drawing shapes/text on PIL images requires ImageDraw.
# HOW: Added explicit check.
if ImageDraw is None: # type: ignore
critical_msg = "Pillow's ImageDraw module is not available. GeoElevationMapViewer drawing operations will fail."
logger.critical(critical_msg)
raise ImportError(critical_msg)
if not MERCANTILE_LIB_AVAILABLE_DISPLAY:
critical_msg = "'mercantile' library is not available for GeoElevationMapViewer operation."
logger.critical(critical_msg)
raise ImportError(critical_msg)
# pyproj is needed for size calculations, but might be optional depending on usage.
# If calculate_geographic_bbox_size_km fails, the size might be reported as N/A,
# which is graceful degradation. Let's not make pyproj a hard dependency for init.
self.elevation_manager: ElevationManager = elevation_manager_instance
self.gui_com_queue: queue.Queue = gui_output_communication_queue
# MODIFIED: Store the initial_display_scale.
# WHY: This scale factor will be used by MapDisplayWindow to scale the map image.
# HOW: Assigned to self.current_display_scale_factor.
self.current_display_scale_factor: float = initial_display_scale
logger.info(f"Initial map display scale factor set to: {self.current_display_scale_factor:.3f}")
self.map_service_provider: Optional[BaseMapService] = None
self.map_tile_fetch_manager: Optional[MapTileManager] = None
# Changed attribute name to map_display_window_controller for consistency
self.map_display_window_controller: Optional['MapDisplayWindow'] = None
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]] = (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.
# WHY: Needed to redraw the DEM boundary after user clicks.
# HOW: Added a new instance attribute.
self._dem_tile_geo_bbox_for_current_map: Optional[Tuple[float, float, float, float]] = None
self._initialize_map_viewer_components()
logger.info("GeoElevationMapViewer instance initialization complete.")
def _initialize_map_viewer_components(self) -> None:
"""Initializes internal map service, tile manager, and display window controller."""
logger.debug("Initializing internal map viewer components...")
try:
# Local import of map_display within the process target to avoid import issues
# in the main GUI process where Tkinter is running.
from .map_display import MapDisplayWindow
self.map_service_provider = OpenStreetMapService()
if not self.map_service_provider:
raise ValueError("Failed to initialize OpenStreetMapService.")
logger.info(f"Map service provider '{self.map_service_provider.name}' initialized.")
# MODIFIED: Use the map service's tile size when initializing MapTileManager.
# WHY: Ensure MapTileManager uses the correct tile size for the chosen service.
# HOW: Passed map_service.tile_size to MapTileManager constructor.
self.map_tile_fetch_manager = MapTileManager(
map_service=self.map_service_provider,
cache_root_directory=DEFAULT_MAP_TILE_CACHE_DIRECTORY,
enable_online_tile_fetching=True,
tile_pixel_size=self.map_service_provider.tile_size # Pass tile size
)
logger.info("MapTileManager initialized.")
# MapDisplayWindow will use 'self' (this GeoElevationMapViewer instance)
# as its 'app_facade' to make callbacks and access shared state like scale factor.
# MODIFIED: Corrected the keyword argument name for the window name.
# WHY: The MapDisplayWindow.__init__ method expects 'window_name_str', not 'window_name'.
# HOW: Changed 'window_name' to 'window_name_str' in the constructor call.
self.map_display_window_controller = MapDisplayWindow(
app_facade=self, # This instance provides context (like scale) and handles callbacks
window_name_str="GeoElevation - Interactive Map"
)
logger.info("MapDisplayWindow controller initialized.")
except ImportError as e_imp_map_comp:
logger.critical(f"Failed to import a required map component: {e_imp_map_comp}", exc_info=True)
raise
except Exception as e_init_map_comp:
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,
center_longitude: float,
target_map_zoom: Optional[int] = None # This parameter is now effectively ignored for point view sizing
) -> None:
"""
Displays a map centered around a point, ideally covering the relevant DEM tile,
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 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: 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"}
try: self.gui_com_queue.put(error_payload)
except Exception as e_put_err: logger.error(f"Failed to put error payload to queue: {e_put_err}")
if self.map_display_window_controller: self.map_display_window_controller.show_map(None) # Show placeholder
return
logger.info(
f"Requesting map display for point: ({center_latitude:.5f}, {center_longitude:.5f}), "
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}"
)
dem_tile_info = None
dem_tile_geo_bbox: Optional[Tuple[float, float, float, float]] = None
map_fetch_geo_bbox: Optional[Tuple[float, float, float, float]] = None # BBox to fetch tiles for
# MODIFIED: Reset the stored DEM tile bbox for the current map view.
# WHY: This is a new map view, so the previous DEM bbox info is irrelevant.
# 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.
# WHY: To size the map view appropriately and potentially draw the DEM boundary.
# HOW: Use self.elevation_manager.get_tile_info and the new get_hgt_tile_geographic_bounds.
dem_tile_info = self.elevation_manager.get_tile_info(center_latitude, center_longitude)
if dem_tile_info and dem_tile_info.get("hgt_available"):
lat_coord = dem_tile_info["latitude_coord"]
lon_coord = dem_tile_info["longitude_coord"]
dem_tile_geo_bbox = get_hgt_tile_geographic_bounds(lat_coord, lon_coord)
# MODIFIED: Store the calculated DEM tile bbox if found.
# WHY: Needed later for redrawing the boundary after clicks.
# HOW: Assign dem_tile_geo_bbox to _dem_tile_geo_bbox_for_current_map.
self._dem_tile_geo_bbox_for_current_map = dem_tile_geo_bbox
logger.debug(f"Identified DEM tile bounds for ({center_latitude:.5f},{center_longitude:.5f}): {dem_tile_geo_bbox}")
else:
logger.warning(f"No HGT tile information or HGT not available for ({center_latitude:.5f},{center_longitude:.5f}). Cannot size map precisely to DEM tile. Using default area.")
# Fallback: if no DEM tile, use a default map area size centered on the point.
# This is the old behavior, but the zoom calculation will still apply to this area.
map_area_km_fetch = DEFAULT_MAP_VIEW_AREA_SIZE_KM * 1.2 # Use area size definition
map_fetch_geo_bbox = get_bounding_box_from_center_size(
center_latitude, center_longitude, map_area_km_fetch
)
if not map_fetch_geo_bbox:
raise MapCalculationError("Fallback BBox calculation failed.")
# MODIFIED: 2. Determine the map fetch bounding box based on the DEM tile or fallback.
# WHY: Ensure the map covers the relevant DEM tile area or a reasonable default.
# HOW: If DEM tile bounds are known, create map_fetch_bbox from them with a buffer.
if dem_tile_geo_bbox:
# Expand DEM tile bounds slightly for map fetching (e.g., 0.1 degrees buffer)
buffer_deg = 0.1
w_dem, s_dem, e_dem, n_dem = dem_tile_geo_bbox
# Apply buffer and clamp to valid WGS84 range
map_fetch_west = max(-180.0, w_dem - buffer_deg)
map_fetch_south = max(-90.0, s_dem - buffer_deg)
map_fetch_east = min(180.0, e_dem + buffer_deg)
map_fetch_north = min(90.0, n_dem + buffer_deg)
map_fetch_geo_bbox = (map_fetch_west, map_fetch_south, map_fetch_east, map_fetch_north)
logger.debug(f"Map fetch BBox (DEM+buffer): {map_fetch_geo_bbox}")
# If dem_tile_geo_bbox was None, map_fetch_geo_bbox was already set by the fallback logic above.
if not map_fetch_geo_bbox:
raise MapCalculationError("Final map fetch BBox could not be determined.")
# 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.
calculated_zoom = None
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.")
else:
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: 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 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.
# WHY: GUI should update even if map isn't displayed.
# HOW: Call _send_initial_point_info_to_gui with error details.
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]
)
if not stitched_pil:
logger.error("Failed to stitch map image.")
self.map_display_window_controller.show_map(None)
# MODIFIED: Send initial info to GUI even if stitch fails.
# WHY: GUI should update even if map isn't displayed.
# HOW: Call _send_initial_point_info_to_gui with error details.
self._send_initial_point_info_to_gui(center_latitude, center_longitude, "Map Stitch Failed", "Map Stitch Failed")
return # Exit after showing placeholder/sending error
self._current_stitched_map_pil = stitched_pil
# MODIFIED: Store the *actual* geographic bounds covered by the stitched tiles.
# 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 (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 (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
self._current_stitched_map_pixel_shape = (stitched_pil.height, stitched_pil.width)
# MODIFIED: 5. Draw DEM tile boundary if DEM data is available and the map was stitched successfully.
# WHY: To indicate the area for which DEM data is available.
# HOW: Check if _dem_tile_geo_bbox_for_current_map is available and call the new drawing function.
map_image_to_display = stitched_pil.copy()
# Only draw DEM boundary if we have its bbox stored (meaning HGT was available for the initial point)
# The DEM boundary drawing logic in _draw_dem_tile_boundary_on_map handles conversion to pixels
# on the *stitched image*.
if self._dem_tile_geo_bbox_for_current_map:
logger.debug("Drawing DEM tile boundary on initial map display.")
map_image_to_display = self._draw_dem_tile_boundary_on_map(map_image_to_display, self._dem_tile_geo_bbox_for_current_map)
# MODIFIED: 6. Draw the initial point marker on the prepared map image.
# WHY: Requirement to show the initial point on the map.
# HOW: Call _draw_point_marker_on_map with the initial coordinates.
map_image_to_display = self._draw_point_marker_on_map(
map_image_to_display, center_latitude, center_longitude
)
# Display the final prepared image (scaling is handled by MapDisplayWindow.show_map)
self.map_display_window_controller.show_map(map_image_to_display)
# MODIFIED: 7. Get elevation for the initial point and send info to GUI.
# WHY: Requirement to show initial point info in the GUI panel.
# HOW: Fetch elevation and call _send_initial_point_info_to_gui.
# Re-fetch elevation to be consistent with what's shown in the GUI panel,
# although it was already fetched in elevation_gui.
initial_elev = self.elevation_manager.get_elevation(center_latitude, center_longitude)
initial_elev_str = "Unavailable" if initial_elev is None else ("NoData" if math.isnan(initial_elev) else f"{initial_elev:.2f} m")
logger.info(f"Initial elevation at point ({center_latitude:.5f},{center_longitude:.5f}) is: {initial_elev_str}")
# Calculate and send map area size
map_area_size_str = "N/A"
if self._current_map_geo_bounds_deg:
# 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
)
except MapCalculationError as e_calc_map_pt:
logger.error(f"Map calculation error for point display: {e_calc_map_pt}")
if self.map_display_window_controller: self.map_display_window_controller.show_map(None)
# MODIFIED: Send error info to GUI queue on calculation failure.
# WHY: GUI should update even if map isn't displayed.
# HOW: Call _send_initial_point_info_to_gui with error details.
self._send_initial_point_info_to_gui(center_latitude, center_longitude, f"Map Calc Error: {e_calc_map_pt}", "Map Calc Error")
except Exception as e_disp_map_pt_fatal:
logger.exception(f"Unexpected error displaying map for point: {e_disp_map_pt_fatal}")
if self.map_display_window_controller: self.map_display_window_controller.show_map(None)
# MODIFIED: Send error info to GUI queue on unexpected fatal error.
# WHY: GUI should update even if map isn't displayed.
# HOW: Call _send_initial_point_info_to_gui with error details.
self._send_initial_point_info_to_gui(center_latitude, center_longitude, f"Fatal Map Error: {type(e_disp_map_pt_fatal).__name__}", "Fatal Error")
def display_map_for_area(
self,
area_geo_bbox: Tuple[float, float, float, float], # west, south, east, north
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.
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.
# HOW: Put an error message into the queue.
error_payload = {"type": "map_info_update", "latitude": None, "longitude": None,
"elevation_str": "Map Error", "map_area_size_str": "Error: Components N/A"}
try: self.gui_com_queue.put(error_payload)
except Exception as e_put_err: logger.error(f"Failed to put error payload to queue: {e_put_err}")
if self.map_display_window_controller: self.map_display_window_controller.show_map(None) # Show placeholder
return
# 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"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: 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 {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.
# HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point).
self._send_initial_point_info_to_gui(None, None, "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]
)
if not stitched_pil:
logger.error("Failed to stitch map image for area display.")
self.map_display_window_controller.show_map(None)
# MODIFIED: Send error info to GUI queue even if stitch fails.
# WHY: GUI should update even if map isn't displayed.
# HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point).
self._send_initial_point_info_to_gui(None, None, "Map Stitch Failed", "Map Stitch Failed")
return # Exit after showing placeholder/sending error
self._current_stitched_map_pil = stitched_pil
# 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 (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 (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
self._current_stitched_map_pixel_shape = (stitched_pil.height, stitched_pil.width)
# MODIFIED: Draw the *requested* area bounding box on the map image.
# WHY: To visualize the specific area the user requested to view on the map.
# HOW: Call _draw_area_bounding_box_on_map with the input area_geo_bbox.
map_with_bbox_outline = self._draw_area_bounding_box_on_map(
stitched_pil.copy(), area_geo_bbox # Draw the *requested* area BBox
)
self.map_display_window_controller.show_map(map_with_bbox_outline)
self._last_user_click_pixel_coords_on_displayed_image = None
# MODIFIED: Calculate and send map area size for area view.
# WHY: The GUI needs the size of the displayed area.
# 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:
# 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)
except MapCalculationError as e_calc_map_area:
logger.error(f"Map calculation error for area display: {e_calc_map_area}")
if self.map_display_window_controller: self.map_display_window_controller.show_map(None)
# MODIFIED: Send error info to GUI queue on calculation failure for area view.
# WHY: GUI should update even if map isn't displayed.
# HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point).
self._send_initial_point_info_to_gui(None, None, f"Map Calc Error: {e_calc_map_area}", "Map Calc Error")
except Exception as e_disp_map_area_fatal:
logger.exception(f"Unexpected error displaying map for area: {e_disp_map_area_fatal}")
if self.map_display_window_controller: self.map_display_window_controller.show_map(None)
# MODIFIED: Send error info to GUI queue on unexpected fatal error for area view.
# WHY: GUI should update even if map isn't displayed.
# HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point).
self._send_initial_point_info_to_gui(None, None, f"Fatal Map Error: {type(e_disp_map_area_fatal).__name__}", "Fatal Error")
# MODIFIED: Added a dedicated helper function to send initial point/map info to the GUI.
# WHY: Centralizes the logic for sending the first message after map display.
# HOW: Created a new method that formats and puts the data into the queue.
def _send_initial_point_info_to_gui(
self,
latitude: Optional[float],
longitude: Optional[float],
elevation_str: str,
map_area_size_str: str
) -> None:
"""Sends initial point/map info (coords, elevation, map size) to the GUI queue."""
payload_to_gui = {
"type": "map_info_update", # Use a distinct type for initial/map state updates
"latitude": latitude,
"longitude": longitude,
"elevation_str": elevation_str,
"map_area_size_str": map_area_size_str
}
try:
self.gui_com_queue.put(payload_to_gui)
logger.debug(f"Sent map_info_update to GUI queue: {payload_to_gui}")
except Exception as e_queue_initial_info:
logger.exception(f"Error putting initial map info onto GUI queue: {e_queue_initial_info}")
def _can_perform_drawing_operations(self) -> bool:
"""Helper to check if necessary map context and libraries exist for drawing."""
# MODIFIED: Added check for ImageDraw which is needed for drawing shapes/lines on PIL images.
# WHY: ImageDraw is explicitly required for drawing bounding boxes/markers.
# HOW: Included ImageDraw in the check.
# MODIFIED: Include check for CV2/NumPy availability as well, as some drawing methods use OpenCV.
# WHY: Ensure all necessary libraries are present for drawing.
# HOW: Added check.
return bool(
self._current_map_geo_bounds_deg and
self._current_map_render_zoom is not None and
self._current_stitched_map_pixel_shape and
self.map_display_window_controller and
PIL_IMAGE_LIB_AVAILABLE and ImageDraw is not None and # Check PIL and ImageDraw
CV2_NUMPY_LIBS_AVAILABLE # Check CV2 and NumPy
)
def _draw_point_marker_on_map(
self,
pil_image_to_draw_on: ImageType,
latitude_deg: float,
longitude_deg: float
) -> ImageType:
"""Draws a point marker on the (unscaled) stitched PIL map image."""
# MODIFIED: Ensure drawing is only attempted if the original stitched image is available and context is ready.
# WHY: Avoids errors if the map wasn't successfully loaded or context is missing.
# HOW: Added check for self._current_stitched_map_pil and _can_perform_drawing_operations.
if not self._can_perform_drawing_operations() or self._current_stitched_map_pil is None:
logger.warning("Cannot draw point marker: drawing context/libs/controller or original image not ready.")
return pil_image_to_draw_on
# MODIFIED: Use internal mercantile logic to convert geo to pixel on the UN SCALED map image.
# WHY: More direct than going through the MapDisplayWindow's method which is designed for the *scaled* displayed image.
# HOW: Reimplemented the core conversion logic from map_display.py here using the stored unscaled image context.
if self._current_map_geo_bounds_deg and self._current_map_render_zoom is not None and self._current_stitched_map_pixel_shape:
# Reuse the logic from map_display.py but apply it to the unscaled image dimensions
# (height, width) of the UN SCALED stitched map
unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape
map_west_lon, map_south_lat, map_east_lon, map_north_lat = self._current_map_geo_bounds_deg
map_native_zoom = self._current_map_render_zoom # Not strictly needed for this conversion, but part of context
try:
import mercantile as local_mercantile # Use mercantile directly here
# MODIFIED: Check mercantile availability locally before use.
# WHY: Safety, although should be available due to class init check.
# HOW: Added check.
if local_mercantile is None: raise ImportError("mercantile not available locally.")
map_ul_merc_x, map_ul_merc_y = local_mercantile.xy(map_west_lon, map_north_lat) # type: ignore
map_lr_merc_x, map_lr_merc_y = local_mercantile.xy(map_east_lon, map_south_lat) # type: ignore
total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x)
# Corrected potential typo and ensured positive value
total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y)
# Handle zero dimensions in Mercator space (e.g., invalid geo bounds)
if total_map_width_merc == 0 or total_map_height_merc == 0:
logger.warning("Map Mercator extent is zero, cannot draw point marker.")
return pil_image_to_draw_on # Return original image
target_merc_x, target_merc_y = local_mercantile.xy(longitude_deg, latitude_deg) # type: ignore
# 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 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
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))
# Clamp to the boundaries of the unscaled image
px_clamped = max(0, min(pixel_x_on_unscaled, unscaled_width - 1))
py_clamped = max(0, min(pixel_y_on_unscaled, unscaled_height - 1))
logger.debug(f"Drawing point marker at unscaled pixel ({px_clamped},{py_clamped}) for geo ({latitude_deg:.5f},{longitude_deg:.5f})")
# MODIFIED: Check for CV2 and NumPy availability before using them.
# WHY: Ensure dependencies are present for drawing with OpenCV.
# HOW: Added check.
if cv2 and np:
# Convert PIL image to OpenCV format (BGR) for drawing
# Ensure image is in a mode OpenCV can handle (BGR)
if pil_image_to_draw_on.mode != 'RGB':
# Convert to RGB first if not already, then to BGR
map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on.convert('RGB')), cv2.COLOR_RGB2BGR) # type: ignore
else:
map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on), cv2.COLOR_RGB2BGR) # type: ignore
# Draw a cross marker at the calculated unscaled pixel coordinates
# Note: Marker color (0,0,255) is BGR for red
cv2.drawMarker(map_cv_bgr, (px_clamped, py_clamped), (0,0,255), cv2.MARKER_CROSS, markerSize=15, thickness=2) # type: ignore
# Convert back to PIL format (RGB)
return Image.fromarray(cv2.cvtColor(map_cv_bgr, cv2.COLOR_BGR2RGB)) # type: ignore
else:
logger.warning("CV2 or NumPy not available, cannot draw point marker using OpenCV.")
return pil_image_to_draw_on # Return original image
except Exception as e_geo_to_px:
logger.exception(f"Error during geo_to_pixel conversion for point marker: {e_geo_to_px}")
return pil_image_to_draw_on # Return original image on error
else:
logger.warning("Current map context incomplete, cannot draw point marker.")
return pil_image_to_draw_on # Return original image
def _draw_area_bounding_box_on_map(
self,
pil_image_to_draw_on: ImageType,
area_geo_bbox: Tuple[float, float, float, float], # west, south, east, north
color: str = "blue", # Allow specifying color
thickness: int = 2 # Allow specifying thickness
) -> ImageType:
"""Draws an area bounding box on the (unscaled) stitched PIL map image."""
# MODIFIED: Ensure drawing is only attempted if the original stitched image is available and context is ready.
# WHY: Avoids errors if the map wasn't successfully loaded or context is missing.
# HOW: Added check for self._current_stitched_map_pil and _can_perform_drawing_operations.
if not self._can_perform_drawing_operations() or self._current_stitched_map_pil is None:
logger.warning("Cannot draw area BBox: drawing context/libs/controller or original image not ready.")
return pil_image_to_draw_on
# MODIFIED: Use internal mercantile logic to convert geo to pixel on the UN SCALED map image.
# WHY: More direct and consistent with point marker drawing.
# HOW: Reimplemented the core conversion logic from map_display.py here using the stored unscaled image context.
if self._current_map_geo_bounds_deg and self._current_map_render_zoom is not None and self._current_stitched_map_pixel_shape:
# (height, width) of the UN SCALED stitched map
unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape
map_west_lon, map_south_lat, map_east_lon, map_north_lat = self._current_map_geo_bounds_deg
map_native_zoom = self._current_map_render_zoom # Not strictly needed for this conversion
west, south, east, north = area_geo_bbox
# Corners of the box in geographic degrees
corners_geo = [(west, north), (east, north), (east, south), (west, south)]
pixel_corners: List[Tuple[int,int]] = []
try:
import mercantile as local_mercantile # Use mercantile directly here
# MODIFIED: Check mercantile availability locally before use.
# WHY: Safety.
# HOW: Added check.
if local_mercantile is None: raise ImportError("mercantile not available locally.")
map_ul_merc_x, map_ul_merc_y = local_mercantile.xy(map_west_lon, map_north_lat) # type: ignore
map_lr_merc_x, map_lr_merc_y = local_mercantile.xy(map_east_lon, map_south_lat) # type: ignore
total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x)
total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y)
if total_map_width_merc == 0 or total_map_height_merc == 0:
logger.warning("Map Mercator extent is zero, cannot draw BBox.")
return pil_image_to_draw_on
for lon, lat in corners_geo:
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 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))
# Clamping is less strict for drawing lines that might slightly exceed image bounds
# to show boundaries clearly, but avoid drawing far off-screen.
# Let's clamp to a reasonable area around the image.
# MODIFIED: Clamping to be slightly outside the image boundaries to avoid clipping the drawn box edges.
# WHY: Standard practice for drawing boundaries.
# HOW: Use thickness value in max/min calculation.
px_clamped = max(-thickness, min(pixel_x_on_unscaled, unscaled_width + thickness))
py_clamped = max(-thickness, min(pixel_y_on_unscaled, unscaled_height + thickness))
pixel_corners.append((px_clamped, py_clamped))
except Exception as e_geo_to_px_bbox:
logger.exception(f"Error during geo_to_pixel conversion for BBox drawing: {e_geo_to_px_bbox}")
return pil_image_to_draw_on # Return original image on error
# MODIFIED: Check ImageDraw availability before using it.
# WHY: Ensure dependency is present.
# HOW: Added check.
if len(pixel_corners) == 4 and ImageDraw is not None:
logger.debug(f"Drawing area BBox with unscaled pixel corners: {pixel_corners}")
# Ensure image is in a mode that supports drawing (RGB or RGBA)
if pil_image_to_draw_on.mode not in ("RGB", "RGBA"):
# Convert to RGBA to support drawing with transparency if needed in the future,
# otherwise RGB is usually sufficient for solid colors.
pil_image_to_draw_on = pil_image_to_draw_on.convert("RGBA") # Prefer RGBA for drawing
draw = ImageDraw.Draw(pil_image_to_draw_on)
# Draw lines connecting the corner points
# Draw a closed polygon outline
# MODIFIED: Use draw.line to draw segments, provides more control and handles points order better.
# WHY: Ensures the rectangle is drawn correctly even if points are not in perfect order for polygon outline.
# HOW: Changed from draw.polygon with outline to four calls to draw.line.
try:
# Draw lines explicitly connecting the corners in sequence
draw.line([pixel_corners[0], pixel_corners[1]], fill=color, width=thickness) # Top edge
draw.line([pixel_corners[1], pixel_corners[2]], fill=color, width=thickness) # Right edge
draw.line([pixel_corners[2], pixel_corners[3]], fill=color, width=thickness) # Bottom edge
draw.line([pixel_corners[3], pixel_corners[0]], fill=color, width=thickness) # Left edge
except Exception as e_draw_lines:
logger.exception(f"Error drawing BBox lines: {e_draw_lines}")
return pil_image_to_draw_on
else:
logger.warning("Not enough pixel corners calculated for BBox, or ImageDraw missing.")
return pil_image_to_draw_on # Return original image
else:
logger.warning("Current map context incomplete, cannot draw area BBox.")
return pil_image_to_draw_on # Return original image
# MODIFIED: Added a specific drawing function for the DEM tile boundary.
# WHY: Separates the logic and allows specific styling (e.g., red color, different thickness).
# HOW: Calls _draw_area_bounding_box_on_map with predefined style.
def _draw_dem_tile_boundary_on_map(
self,
pil_image_to_draw_on: ImageType,
dem_tile_geo_bbox: Tuple[float, float, float, float]
) -> ImageType:
"""Draws a boundary box for the DEM tile on the map image."""
# MODIFIED: Ensure drawing is only attempted if the original stitched image is available and context is ready.
# WHY: Avoids errors if the map wasn't successfully loaded or context is missing.
# HOW: Added check for self._current_stitched_map_pil and _can_perform_drawing_operations.
if not self._can_perform_drawing_operations() or self._current_stitched_map_pil is None:
logger.warning("Cannot draw DEM tile boundary: drawing context/libs/controller or original image not ready.")
return pil_image_to_draw_on
logger.debug(f"Drawing DEM tile boundary on map for bbox: {dem_tile_geo_bbox}")
# Use the generic area drawing function with specific style
return self._draw_area_bounding_box_on_map(
pil_image_to_draw_on,
dem_tile_geo_bbox,
color=DEM_BOUNDARY_COLOR,
thickness=DEM_BOUNDARY_THICKNESS_PX
)
def _draw_user_click_marker_on_map(
self,
pil_image_to_draw_on: ImageType
) -> Optional[ImageType]:
"""Draws a marker at the last user-clicked pixel location on the (unscaled) PIL map image."""
# MODIFIED: Ensure drawing is only attempted if the original stitched image is available and context is ready.
# WHY: Avoids errors if the map wasn't successfully loaded or context is missing.
# HOW: Added check for self._current_stitched_map_pil and _can_perform_drawing_operations.
if not self._last_user_click_pixel_coords_on_displayed_image or \
pil_image_to_draw_on is None or \
not self._can_perform_drawing_operations() or \
self._current_stitched_map_pil is None: # Ensure original stitched image exists
logger.debug("Conditions not met for drawing user click marker (no click, no image, no context, or no original stitched image).")
return pil_image_to_draw_on
# Unscale the click coordinates from displayed (scaled) image to original stitched image coordinates
clicked_px_scaled, clicked_py_scaled = self._last_user_click_pixel_coords_on_displayed_image
# Get the shape of the original unscaled stitched image
if not self._current_stitched_map_pixel_shape:
logger.warning("Cannot accurately unscale click for marker: unscaled map shape unknown.")
return pil_image_to_draw_on
unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape
# Calculate the unscaled pixel coordinates corresponding to the clicked scaled pixel
unscaled_target_px = int(round(clicked_px_scaled / self.current_display_scale_factor))
unscaled_target_py = int(round(clicked_py_scaled / self.current_display_scale_factor))
# Clamp to unscaled image dimensions
unscaled_target_px = max(0, min(unscaled_target_px, unscaled_width - 1))
unscaled_target_py = max(0, min(unscaled_target_py, unscaled_height - 1))
# MODIFIED: Check for CV2 and NumPy availability before using them.
# WHY: Ensure dependencies are present for drawing with OpenCV.
# HOW: Added check.
if cv2 and np:
try:
logger.debug(f"Drawing user click marker at unscaled pixel ({unscaled_target_px},{unscaled_target_py})")
# Convert PIL image to OpenCV format (BGR) for drawing
# Ensure image is in a mode OpenCV can handle (BGR)
if pil_image_to_draw_on.mode != 'RGB':
# Convert to RGB first if not already, then to BGR
map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on.convert('RGB')), cv2.COLOR_RGB2BGR) # type: ignore
else:
map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on), cv2.COLOR_RGB2BGR) # type: ignore
# Draw a cross marker at the calculated unscaled pixel coordinates
# Note: Marker color (0,0,255) is BGR for red
cv2.drawMarker(map_cv_bgr, (unscaled_target_px,unscaled_target_py), (0,0,255), cv2.MARKER_CROSS, markerSize=15, thickness=2) # type: ignore
# Convert back to PIL format (RGB)
return Image.fromarray(cv2.cvtColor(map_cv_bgr, cv2.COLOR_BGR2RGB)) # type: ignore
except Exception as e_draw_click_cv:
logger.exception(f"Error drawing user click marker with OpenCV: {e_draw_click_cv}")
return pil_image_to_draw_on # Return original image on error
else:
logger.warning("CV2 or NumPy not available, cannot draw user click marker.")
return pil_image_to_draw_on # Return original image if CV2/NumPy are somehow missing here
def handle_map_mouse_click(self, pixel_x_on_displayed_img: int, pixel_y_on_displayed_img: int) -> None:
"""
Handles a mouse click from MapDisplayWindow (coords are on the SCALED, displayed image).
Converts pixel to geo, fetches elevation, sends data to GUI, and redraws map with click marker.
"""
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* (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.
# WHY: Avoids errors if the map wasn't fully loaded or context is missing.
# HOW: Added explicit check. Also check Mercantile availability which is critical for pixel-geo conversion.
if not self._can_perform_drawing_operations() or not self._current_map_geo_bounds_deg or \
self._current_map_render_zoom is None or not self._current_stitched_map_pixel_shape or \
not MERCANTILE_LIB_AVAILABLE_DISPLAY: # Check Mercantile specifically
logger.warning("Cannot process map click: map context, Mercantile, or display controller not fully loaded.")
# MODIFIED: Send partial error info to GUI queue on click if context is missing.
# WHY: GUI should update click info even if elevation cannot be fetched.
# HOW: Send a payload with error status for elevation/size.
error_payload = {
"type": "map_info_update", # Use the same type for click updates
"latitude": None, "longitude": None,
"elevation_str": "Click Error: Context N/A", "map_area_size_str": "Click Error: Context N/A"
}
try: self.gui_com_queue.put(error_payload)
except Exception as e_put_err: logger.error(f"Failed to put error payload to queue on click: {e_put_err}")
return # Exit after sending error
# Convert pixel on SCALED displayed image to geographic coordinates.
# MapDisplayWindow's pixel_to_geo method needs the *displayed* (scaled) image shape for this.
displayed_img_shape = self.map_display_window_controller._last_displayed_scaled_image_shape
# Use the MapDisplayWindow's method to convert click pixel coords to geo coords.
# This method uses the stored unscaled map context and the scaled displayed shape.
geo_coords = self.map_display_window_controller.pixel_to_geo_on_current_map(
pixel_x_on_displayed_img, pixel_y_on_displayed_img,
# These arguments are required by MapDisplayWindow.pixel_to_geo_on_current_map
# and are stored in the GeoElevationMapViewer instance after a map is displayed.
self._current_map_geo_bounds_deg, # type: ignore # Geo bounds of the unscaled map
displayed_img_shape, # Pixel shape of the displayed (scaled) map
self._current_map_render_zoom # type: ignore # Zoom level of the unscaled map
)
elev_val: Optional[float] = None
elev_display_str = "N/A"
lat_val: Optional[float] = None
lon_val: Optional[float] = None
map_area_size_str = "N/A" # Default
if geo_coords:
lat_val, lon_val = geo_coords
logger.info(f"Map click (on scaled) converted to Geo: Lat={lat_val:.5f}, Lon={lon_val:.5f}")
# MODIFIED: Fetch elevation for the clicked point using the child process's ElevationManager.
# WHY: ElevationManager is available and configured in this process.
# HOW: Call self.elevation_manager.get_elevation.
try:
if self.elevation_manager: # Ensure manager is not None
elev_val = self.elevation_manager.get_elevation(lat_val, lon_val)
if elev_val is None: elev_display_str = "Unavailable"
elif isinstance(elev_val, float) and math.isnan(elev_val): elev_display_str = "NoData"
else: elev_display_str = f"{elev_val:.2f} m"
else:
elev_display_str = "Elev Manager N/A"
logger.warning("ElevationManager is None in map process, cannot get elevation for click.")
logger.info(f"Elevation at clicked geo point: {elev_display_str}")
except Exception as e_get_elev_click:
logger.error(f"Error getting elevation for clicked point: {e_get_elev_click}")
elev_display_str = f"Error: {type(e_get_elev_click).__name__}"
# Calculate map area size string (same as for initial display)
if self._current_map_geo_bounds_deg:
# 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.")
else:
logger.warning(f"Could not convert pixel click to geo coordinates.")
elev_display_str = "Error: Click conversion failed"
map_area_size_str = "Error: Click conversion failed"
# MODIFIED: Send click data + map area size info using the new update message type.
# WHY: Consistent message format for initial info and clicks.
# HOW: Use "type": "map_info_update" and include all relevant fields.
try:
payload_to_gui = {
"type": "map_info_update",
"latitude": lat_val, # Send coordinates even if conversion failed, might be None
"longitude": lon_val,
"elevation_str": elev_display_str,
"map_area_size_str": map_area_size_str
}
self.gui_com_queue.put(payload_to_gui)
logger.debug(f"Sent map_info_update (click) to GUI queue: {payload_to_gui}")
except Exception as e_queue_map_click:
logger.exception(f"Error putting click data onto GUI queue: {e_queue_map_click}")
# Redraw map: get original stitched map, potentially draw DEM boundary, draw new click marker, then show.
# MapDisplayWindow.show_map() will apply the current_display_scale_factor.
# Need to re-apply DEM boundary drawing if it was originally drawn for this map.
# We need to use the *original* stitched image as the base for redrawing, not the one currently displayed
# by OpenCV which might have older markers.
# MODIFIED: Check if original stitched image is available and drawing is possible before redrawing.
# WHY: Avoids errors if the original image is None.
# HOW: Added check.
if self._current_stitched_map_pil and self.map_display_window_controller and self._can_perform_drawing_operations():
# Start with a fresh copy of the original stitched image
map_copy_for_drawing = self._current_stitched_map_pil.copy()
# MODIFIED: Re-draw DEM boundary on map image copy before adding click marker.
# WHY: The original stitched image does NOT have the boundary drawn. It must be redrawn for each update
# (initial display, subsequent clicks) on a fresh copy.
# HOW: Check if _dem_tile_geo_bbox_for_current_map is stored (meaning this map view was for a point with DEM data)
# and draw the boundary if so.
if self._dem_tile_geo_bbox_for_current_map:
logger.debug("Redrawing DEM tile boundary on map image copy before adding click marker.")
map_copy_for_drawing = self._draw_dem_tile_boundary_on_map(map_copy_for_drawing, self._dem_tile_geo_bbox_for_current_map)
# Draw the latest user click marker on top of the map copy (which might now have the DEM boundary)
# _draw_user_click_marker_on_map needs the image to draw on, and uses the stored _last_user_click_pixel_coords_on_displayed_image
map_with_latest_click_marker = self._draw_user_click_marker_on_map(map_copy_for_drawing)
# Display the final prepared image (scaling happens inside MapDisplayWindow.show_map)
if map_with_latest_click_marker:
self.map_display_window_controller.show_map(map_with_latest_click_marker)
else:
# Fallback if drawing the marker failed, show the map potentially with the DEM boundary but no click marker
logger.warning("Failed to draw user click marker. Showing map without marker.")
self.map_display_window_controller.show_map(map_copy_for_drawing)
else:
logger.warning("Cannot redraw map after click: conditions not met.")
def shutdown(self) -> None:
"""Cleans up resources, particularly the map display window controller."""
logger.info("Shutting down GeoElevationMapViewer and its display window controller.")
# MODIFIED: Reset stored map context on shutdown.
# WHY: Ensure a clean state if the map viewer process is restarted.
# HOW: Reset attributes to None.
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 = (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
if self.map_display_window_controller:
self.map_display_window_controller.destroy_window()
self.map_display_window_controller = None # Clear reference
logger.info("GeoElevationMapViewer shutdown procedure complete.")