SXXXXXXX_GeoElevation/geoelevation/map_viewer/geo_map_viewer.py

1492 lines
89 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
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
# MODIFIED: Import the utility function to calculate bbox from pixel size and zoom.
# WHY: Needed for interactive zoom implementation.
# HOW: Added import from map_utils.
from .map_utils import calculate_geographic_bbox_from_pixel_size_and_zoom
from .map_utils import PYPROJ_AVAILABLE
# MODIFIED: Import the map_utils module explicitly.
# WHY: Required to call map_utils.deg_to_dms_string.
# HOW: Added this import line.
from . import map_utils
# MODIFIED: Import drawing functions from the new map_drawing module.
# WHY: Drawing logic has been moved to a separate module.
# HOW: Added import for drawing functions.
from . import map_drawing # Import the module containing drawing functions
# 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 constants for drawing the Requested Area boundary.
# WHY: Improves code clarity and makes colors/thickness easily adjustable. Distinct from DEM color.
# HOW: Added constants for Area boundary color and thickness.
AREA_BOUNDARY_COLOR = "blue"
AREA_BOUNDARY_THICKNESS_PX = 2
# 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
# MODIFIED: Define text drawing parameters for DEM tile labels.
# WHY: Centralize style for labels.
# HOW: Added constants for color, background color, font size. Reusing constants from image_processor for consistency.
try:
# Attempt to import constants from image_processor for consistency
from geoelevation.image_processor import TILE_TEXT_COLOR, TILE_TEXT_BG_COLOR, DEFAULT_FONT
# These constants will be used directly by map_drawing functions
except ImportError:
# Fallback constants if image_processor constants are not available
# map_drawing needs to handle these fallbacks internally if it can't import them.
pass # No need to define fallbacks here, map_drawing handles it.
# MODIFIED: Base font size and zoom level for DEM tile label scaling.
# WHY: Used by map_drawing for font size calculation.
# HOW: Added constants.
DEM_TILE_LABEL_BASE_FONT_SIZE = 12 # px
DEM_TILE_LABEL_BASE_ZOOM = 10 # At zoom 10, font size will be BASE_FONT_SIZE
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
# MODIFIED: Add parameters for initial view definition.
# WHY: The class needs to know how to load the first map view.
# HOW: Added new parameters to the constructor.
initial_operation_mode: str = "point", # "point" or "area"
initial_point_coords: Optional[Tuple[float, float]] = None,
initial_area_bbox: Optional[Tuple[float, float, float, float]] = None
) -> 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.
initial_operation_mode (str): "point" or "area". Defines the type of the initial view.
initial_point_coords (Optional[Tuple[float, float]]): (lat, lon) for point view.
initial_area_bbox (Optional[Tuple[float, float, float, float]]): (west, south, east, north) for area view.
"""
logger.info("Initializing GeoElevationMapViewer instance...")
# MODIFIED: 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
# --- Current Map View State ---
self._current_stitched_map_pil: Optional[ImageType] = None # The base, unscaled stitched image
self._current_map_geo_bounds_deg: Optional[Tuple[float, float, float, float]] = None # Bounds of the _current_stitched_map_pil
self._current_map_render_zoom: Optional[int] = None # Zoom level _current_stitched_map_pil was rendered at
self._current_stitched_map_pixel_shape: Optional[Tuple[int, int]] = (0, 0) # Shape (H, W) of _current_stitched_map_pil
# --- Interactive State ---
self._last_user_click_pixel_coords_on_displayed_image: Optional[Tuple[int, int]] = None # Pixel coords on the SCALED, displayed image
# --- Initial View State (for Reset) ---
# MODIFIED: Added attributes to store the initial map view parameters for reset functionality.
# WHY: Need to remember the original parameters passed to re-load the initial view.
# HOW: Added new instance attributes.
self._initial_operation_mode: str = initial_operation_mode
self._initial_point_coords: Optional[Tuple[float, float]] = initial_point_coords
self._initial_area_bbox: Optional[Tuple[float, float, float, float]] = initial_area_bbox
# --- View Specific State (for Drawing Overlays) ---
# MODIFIED: Added attributes to store info for POINT view drawing on clicks.
# WHY: Needed to redraw the single DEM tile boundary on clicks for point view.
# HOW: Added new instance attributes.
self._dem_tile_geo_bbox_for_current_point_view: Optional[Tuple[float, float, float, float]] = None
# MODIFIED: Added attributes to store info for AREA view drawing on clicks.
# WHY: Needed to redraw the requested area boundary (blue) and all DEM tile boundaries/labels (red) on clicks for area view.
# HOW: Added new instance attributes.
self._current_requested_area_geo_bbox: Optional[Tuple[float, float, float, float]] = None # The original bbox from GUI
self._dem_tiles_info_for_current_map_area_view: List[Dict] = [] # Store list of tile info dicts for DEMs in area view
self._initialize_map_viewer_components()
# MODIFIED: Load and display the initial map view based on the provided parameters.
# WHY: The class is responsible for setting up its initial display state.
# HOW: Call the new internal method _load_and_display_initial_view.
self._load_and_display_initial_view()
logger.info("GeoElevationMapViewer instance initialization complete.")
# MODIFIED: Added the missing _can_perform_drawing_operations method.
# WHY: The _trigger_map_redraw_with_overlays method calls this non-existent method,
# leading to an AttributeError. This method checks if the necessary libraries
# and basic context are available to attempt drawing operations.
# HOW: Defined the method to check for PIL (Image, ImageDraw) and Mercantile library availability.
def _can_perform_drawing_operations(self) -> bool:
"""Checks if conditions are met to perform drawing operations (libraries)."""
# Check for essential drawing libraries/modules
# PIL (Image, ImageDraw) is needed for creating/manipulating images and drawing shapes/text.
# Mercantile is needed for converting geographic coordinates to pixel coordinates for drawing.
# OpenCV/NumPy are needed specifically for drawing point markers, but boundary/label drawing
# uses PIL/ImageDraw. The individual drawing functions check for CV2/NumPy internally if needed.
# This method checks for the *minimum* set of libraries needed for the overlay logic
# in _trigger_map_redraw_with_overlays to proceed and call the drawing functions.
if not (PIL_IMAGE_LIB_AVAILABLE and ImageDraw is not None and MERCANTILE_LIB_AVAILABLE_DISPLAY):
# Log a warning if fundamental drawing libraries are missing
# This warning might be logged once during initial check.
# Avoid excessive logging from this check if it's called often.
logger.debug("Drawing capability check failed: Essential libraries (PIL, ImageDraw, Mercantile) are not fully available.")
return False
# If all required libraries are present, drawing is *potentially* possible.
# The redraw logic will also check if there's an image and context to draw on.
return True
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
# MODIFIED: New internal method to load and display the initial map view.
# WHY: Encapsulates the logic previously at the start of display_map_for_point/area
# and called by run_map_viewer_process_target. Handles setting up the first view state.
# HOW: Created this method, moved the core logic into it, and it uses the initial_ parameters
# stored in __init__.
def _load_and_display_initial_view(self) -> None:
"""
Loads and displays the initial map view based on the mode and parameters
passed during the GeoElevationMapViewer initialization.
Sets up the initial view state and triggers the first redraw.
"""
logger.info(f"Loading initial map view for mode '{self._initial_operation_mode}'...")
# --- Clear State from Potential Previous Views (should be clear by init, but defensive) ---
# MODIFIED: Use the helper method to clear state.
self._update_current_map_state(None, None, None) # Clear current map state
self._dem_tile_geo_bbox_for_current_point_view = None
self._current_requested_area_geo_bbox = None
self._dem_tiles_info_for_current_map_area_view = []
self._last_user_click_pixel_coords_on_displayed_image = None # No click marker initially
map_fetch_geo_bbox: Optional[Tuple[float, float, float, float]] = None
zoom_to_use: Optional[int] = None
dem_tiles_info_for_drawing: List[Dict] = [] # List of DEM tile infos to draw boundaries/labels for
try:
if self._initial_operation_mode == "point" and self._initial_point_coords is not None:
center_latitude, center_longitude = self._initial_point_coords
logger.info(f"Preparing initial POINT view for ({center_latitude:.5f}, {center_longitude:.5f}).")
# Determine map fetch bbox (based on DEM tile or fallback) and calculate initial zoom
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)
self._dem_tile_geo_bbox_for_current_point_view = dem_tile_geo_bbox # Store for redraw
logger.debug(f"Identified DEM tile bounds for initial point view: {dem_tile_geo_bbox}")
# Use DEM tile bounds (with buffer) for map fetch bbox
if dem_tile_geo_bbox:
buffer_deg = 0.1
w_dem, s_dem, e_dem, n_dem = dem_tile_geo_bbox
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) for initial point view: {map_fetch_geo_bbox}")
else:
logger.warning("No HGT tile information or HGT not available for initial point. 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.
map_area_km_fetch = DEFAULT_MAP_VIEW_AREA_SIZE_KM * 1.2
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 for initial point view.")
if not map_fetch_geo_bbox:
raise MapCalculationError("Final map fetch BBox could not be determined for initial point view.")
# Calculate appropriate zoom to fit the map_fetch_geo_bbox into target pixel size
calculated_zoom = None
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
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,
self.map_service_provider.tile_size
)
if calculated_zoom is not None:
logger.info(f"Calculated zoom level {calculated_zoom} to fit BBox height ({map_bbox_height_meters:.2f}m) for initial point view.")
else:
logger.warning("Could not calculate appropriate zoom level for initial point view. Falling back to default zoom.")
else:
logger.warning("Could not calculate geographic size of fetch BBox for initial point view. Falling back to default zoom.")
else:
logger.warning("Pyproj not available. Cannot calculate geographic size for zoom calculation for initial point view. Falling back to default zoom.")
# Determine the final zoom level to use
zoom_to_use = calculated_zoom if calculated_zoom is not None else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL
# Clamp zoom to service max zoom
if self.map_service_provider and zoom_to_use > self.map_service_provider.max_zoom:
logger.warning(f"Calculated zoom {zoom_to_use} exceeds service max zoom {self.map_service_provider.max_zoom} for initial point view. Clamping.")
zoom_to_use = self.map_service_provider.max_zoom
logger.debug(f"Using final zoom level {zoom_to_use} for tile fetching for initial point view.")
elif self._initial_operation_mode == "area" and self._initial_area_bbox is not None:
area_geo_bbox = self._initial_area_bbox
logger.info(f"Preparing initial AREA view for BBox {area_geo_bbox}.")
self._current_requested_area_geo_bbox = area_geo_bbox # Store requested area bbox
# Determine the full geographic extent of all relevant DEM tiles in the *requested* area
logger.debug("Getting DEM tile info for the REQUESTED area for initial view...")
all_relevant_dem_tiles_info = self.elevation_manager.get_area_tile_info(
area_geo_bbox[1], area_geo_bbox[0], area_geo_bbox[3], area_geo_bbox[2]
)
dem_tiles_info_in_requested_area = [info for info in all_relevant_dem_tiles_info if info.get("hgt_available")]
logger.info(f"Found {len(dem_tiles_info_in_requested_area)} DEM tiles with HGT data in the REQUESTED area for initial view.")
if not dem_tiles_info_in_requested_area:
logger.warning("No DEM tiles with HGT data found in the requested area for initial view. Cannot display relevant DEM context.")
# Decide fallback: Display requested area with map tiles, or show placeholder?
# Let's show a placeholder map indicating no DEM data found.
logger.warning(f"No DEM tiles with HGT data found in the requested area {area_geo_bbox} for initial view. Showing placeholder.")
self._update_current_map_state(None, None, None) # Update state to reflect no map
# Send info to GUI with status
self._send_map_info_update_to_gui(None, None, "No DEM Data in Area", "Area: No DEM Data") # DMS handled in send function
# No DEM tiles to draw, list remains empty.
return # Exit if no DEM tiles found
# Store the list of relevant DEM tiles (with HGT data) for this view
self._dem_tiles_info_for_current_map_area_view = dem_tiles_info_in_requested_area
# Calculate the combined geographic bounding box of ALL these relevant DEM tiles
combined_dem_geo_bbox = map_utils.get_combined_geographic_bounds_from_tile_info_list(dem_tiles_info_in_requested_area)
if not combined_dem_geo_bbox:
logger.error("Failed to calculate combined geographic bounds for DEM tiles for initial area view. Cannot display map.")
self._update_current_map_state(None, None, None) # Update state to reflect no map
# Send error info to GUI
self._send_map_info_update_to_gui(None, None, "DEM Bounds Calc Error", "Map Error") # DMS handled in send function
# Reset state variables related to the stitched map
self._current_map_geo_bounds_deg = None
self._current_map_render_zoom = None
self._current_stitched_map_pil = None
self._current_stitched_map_pixel_shape = (0, 0)
return # Exit if combined bounds calculation fails
map_fetch_geo_bbox = combined_dem_geo_bbox # Fetch map tiles for the combined DEM area
# Calculate appropriate zoom to fit the COMBINED DEM BBox into target pixel size
calculated_zoom = None
if PYPROJ_AVAILABLE: # type: ignore
map_area_size_km = calculate_geographic_bbox_size_km(combined_dem_geo_bbox)
if map_area_size_km:
width_km, height_km = map_area_size_km
map_bbox_height_meters = height_km * 1000.0
center_lat_combined_bbox = (combined_dem_geo_bbox[1] + combined_dem_geo_bbox[3]) / 2.0
calculated_zoom = calculate_zoom_level_for_geographic_size(
center_lat_combined_bbox,
map_bbox_height_meters,
TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW,
self.map_service_provider.tile_size
)
if calculated_zoom is not None:
logger.info(f"Calculated zoom level {calculated_zoom} to fit COMBINED DEM BBox height ({map_bbox_height_meters:.2f}m) for initial area view.")
else:
logger.warning("Could not calculate appropriate zoom level for combined DEM area for initial view. Falling back to default zoom.")
else:
logger.warning("Could not calculate geographic size of combined DEM BBox for initial view. Falling back to default zoom.")
else:
logger.warning("Pyproj not available. Cannot calculate geographic size for zoom calculation for initial view. Falling back to default zoom.")
# Determine the final zoom level to use
zoom_to_use = calculated_zoom if calculated_zoom is not None else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL
# Clamp zoom to service max zoom
if self.map_service_provider and zoom_to_use > self.map_service_provider.max_zoom:
logger.warning(f"Calculated zoom {zoom_to_use} exceeds service max zoom {self.map_service_provider.max_zoom} for initial area view. Clamping.")
zoom_to_use = self.map_service_provider.max_zoom
logger.debug(f"Using final zoom level {zoom_to_use} for tile fetching for initial area view.")
else: # Invalid initial mode or parameters
logger.error(f"Invalid initial operation mode ('{self._initial_operation_mode}') or missing parameters passed to GeoElevationMapViewer.")
self._update_current_map_state(None, None, None) # Update state to reflect no map
# Send error info to GUI
self._send_map_info_update_to_gui(None, None, "Fatal Error: Invalid Init Args", "Map System N/A") # DMS handled in send function
return # Exit on invalid parameters
# --- Fetch and Stitch Map Tiles for the Determined BBox and Zoom ---
if map_fetch_geo_bbox is None or zoom_to_use is None or self.map_tile_fetch_manager is None:
logger.error("Map fetch bbox, zoom, or tile manager is None after initial view setup. Cannot fetch/stitch.")
self._update_current_map_state(None, None, None) # Update state to reflect no map
self._send_map_info_update_to_gui(None, None, "Fetch Setup Error", "Map Error") # DMS handled in send function
return
map_tile_xy_ranges = get_tile_ranges_for_bbox(map_fetch_geo_bbox, zoom_to_use)
if not map_tile_xy_ranges:
logger.warning(f"No map tile ranges found for fetch BBox {map_fetch_geo_bbox} at zoom {zoom_to_use}. Showing placeholder.")
self._update_current_map_state(None, None, None) # Update state to reflect no map
# Send info to GUI even if map fails, with error status.
self._send_map_info_update_to_gui(None, None, "Map Tiles N/A", "Map Tiles N/A") # DMS handled in send function
return # Exit after showing placeholder/sending error
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 initial view.")
self._update_current_map_state(None, None, None) # Update state to reflect no map
# Send initial info to GUI even if stitch fails.
self._send_map_info_update_to_gui(None, None, "Map Stitch Failed", "Map Stitch Failed") # DMS handled in send function
return # Exit after showing placeholder/sending error
# --- Update Current Map State ---
# Use the helper method to update current map state.
# Pass the actual bounds covered by the newly stitched tiles.
actual_stitched_bounds = self.map_tile_fetch_manager._get_bounds_for_tile_range(
zoom_to_use, map_tile_xy_ranges # Pass the zoom and ranges used for stitching
)
self._update_current_map_state(stitched_pil, actual_stitched_bounds, zoom_to_use)
# --- Send Initial Info to GUI ---
# Calculate and send map area size of the *stitched* area
map_area_size_str = "N/A"
if self._current_map_geo_bounds_deg is not None and PYPROJ_AVAILABLE: # type: ignore # Check not None and PyProj
size_km = calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg)
if size_km:
width_km, height_km = size_km
# Indicate if this is the size of the DEM area shown (if initial mode was area)
if self._initial_operation_mode == "area":
map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H (DEM Area Shown)"
else: # Point view or fallback
map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H (Map Area Shown)"
else:
map_area_size_str = "Size Calc Failed"
logger.warning("calculate_geographic_bbox_size_km returned None for current map bounds.")
elif self._current_map_geo_bounds_deg: # Bounds exist but PyProj missing
map_area_size_str = "PyProj N/A (Size Unknown)"
logger.warning("Pyproj not available, cannot calculate map area size.")
# Send initial info to GUI (point info is handled by _send_initial_point_info_to_gui based on whether _initial_point_coords is set)
initial_point_lat = self._initial_point_coords[0] if self._initial_point_coords else None
initial_point_lon = self._initial_point_coords[1] if self._initial_point_coords else None
self._send_map_info_update_to_gui(initial_point_lat, initial_point_lon, "N/A (Initial Load)", map_area_size_str) # Set initial elevation status
# --- Trigger Initial Redraw ---
# Redrawing is now handled by _trigger_map_redraw_with_overlays after state update.
self._trigger_map_redraw_with_overlays()
except MapCalculationError as e_calc_initial:
logger.error(f"Map calculation error during initial view load: {e_calc_initial}")
self._update_current_map_state(None, None, None) # Update state to reflect no map
# Send error info to GUI
self._send_map_info_update_to_gui(None, None, f"Map Calc Error: {e_calc_initial}", "Map Error") # DMS handled in send function
except Exception as e_initial_fatal:
logger.critical(f"FATAL: Unexpected error loading initial map view: {e_initial_fatal}", exc_info=True)
self._update_current_map_state(None, None, None) # Update state to reflect no map
# Send error info to GUI
self._send_map_info_update_to_gui(None, None, f"Fatal Map Error: {type(e_initial_fatal).__name__}", "Fatal Error") # DMS handled in send function
def _update_current_map_state(
self,
stitched_image: Optional[ImageType],
geo_bounds: Optional[Tuple[float, float, float, float]],
zoom_level: Optional[int]
) -> None:
"""Updates the current map view state attributes and displays the new map."""
self._current_stitched_map_pil = stitched_image
self._current_map_geo_bounds_deg = geo_bounds
self._current_map_render_zoom = zoom_level
# MODIFIED: Update pixel shape only if image is not None.
# WHY: Avoids AttributeError if stitched_image is None.
# HOW: Added conditional check.
if stitched_image is not None:
self._current_stitched_map_pixel_shape = (stitched_image.height, stitched_image.width)
else:
self._current_stitched_map_pixel_shape = (0, 0) # Reset if no image
# Always clear the last click position when the base map image changes
# MODIFIED: Also clear the last click marker on any map state update (e.g., zoom, recenter).
# WHY: The pixel coordinates of the marker are only valid for the specific image they were drawn on.
# Clearing prevents drawing the marker in the wrong place on a new map view.
# HOW: Set _last_user_click_pixel_coords_on_displayed_image to None here.
self._last_user_click_pixel_coords_on_displayed_image = None
# Display the new map image (draw overlays and click marker will happen later in redraw logic if needed)
# If stitched_image is None, show_map will display a placeholder.
if self.map_display_window_controller:
# Pass the *base* stitched image (overlays drawn later) or None if stitch failed
self.map_display_window_controller.show_map(stitched_image)
else:
logger.error("MapDisplayWindow controller is None, cannot show updated map.")
# MODIFIED: This method remains within GeoElevationMapViewer. It checks state before triggering drawing.
# It calls drawing functions from the map_drawing module.
def _trigger_map_redraw_with_overlays(self) -> None:
"""
Triggers a redraw of the current map view, reapplying persistent overlays
and the last user click marker. Called after map state changes or clicks.
"""
logger.debug("Triggering map redraw with overlays...")
# Check if we have a base image to draw on and drawing is possible
# MODIFIED: Check _can_perform_drawing_operations which includes PIL/ImageDraw/CV2/Mercantile checks.
# WHY: Ensure all dependencies and map context are ready for drawing.
# HOW: Replaced individual checks with a single call.
# MODIFIED: Adjusted condition to first check if drawing is possible, THEN if there's an image.
# WHY: The _can_perform_drawing_operations check is lighter than the image check.
# HOW: Changed the order of the condition.
if not self._can_perform_drawing_operations() or self._current_stitched_map_pil is None:
logger.warning("Cannot redraw overlays: drawing libraries/context missing or no base map image.")
# If there was a previous map displayed, ensure the placeholder is shown.
if self.map_display_window_controller: self.map_display_window_controller.show_map(None)
return # Nothing to draw on
# Start with a fresh copy of the base stitched image
map_copy_for_drawing = self._current_stitched_map_pil.copy()
# --- Redraw Persistent Overlays based on View Type ---
if self._current_requested_area_geo_bbox: # This is an AREA view
logger.debug("Redrawing overlays for Area View.")
# Redraw the requested area boundary (blue)
# MODIFIED: Call imported drawing function, pass map context.
map_copy_for_drawing = map_drawing.draw_area_bounding_box(
map_copy_for_drawing,
self._current_requested_area_geo_bbox,
self._current_map_geo_bounds_deg, # Pass context
self._current_stitched_map_pixel_shape # Pass context
# color and thickness are default in draw_area_bounding_box
)
# Redraw the DEM tile boundaries and labels (red) for all relevant tiles in the area view
if self._dem_tiles_info_for_current_map_area_view:
# MODIFIED: Call imported drawing function, pass map context.
map_copy_for_drawing = map_drawing.draw_dem_tile_boundaries_with_labels(
map_copy_for_drawing,
self._dem_tiles_info_for_current_map_area_view,
self._current_map_geo_bounds_deg, # Pass context
self._current_map_render_zoom, # Pass context
self._current_stitched_map_pixel_shape # Pass context
)
elif self._dem_tile_geo_bbox_for_current_point_view: # This is a POINT view (and DEM was available)
logger.debug("Redrawing overlays for Point View.")
# Redraw the single DEM tile boundary (red)
# MODIFIED: Call imported drawing function, pass map context.
map_copy_for_drawing = map_drawing.draw_dem_tile_boundary(
map_copy_for_drawing,
self._dem_tile_geo_bbox_for_current_point_view,
self._current_map_geo_bounds_deg, # Pass context
self._current_stitched_map_pixel_shape # Pass context
)
else:
# Neither area bbox nor single DEM tile bbox is stored. No persistent overlays to redraw.
logger.debug("No persistent overlays (Area box or DEM boundary) stored for redrawing.")
pass # map_copy_for_drawing is just the base stitched image
# --- Redraw User Click Marker ---
# The draw_user_click_marker function itself checks if a click position is stored.
# MODIFIED: Call imported drawing function, pass map context.
map_with_latest_click_marker = map_drawing.draw_user_click_marker(
map_copy_for_drawing,
self._last_user_click_pixel_coords_on_displayed_image,
self.current_display_scale_factor, # Pass current scale
self._current_stitched_map_pixel_shape # Pass context
)
# --- Display the Final Image ---
# show_map handles the final scaling before displaying
if map_with_latest_click_marker and self.map_display_window_controller:
self.map_display_window_controller.show_map(map_with_latest_click_marker)
logger.debug("Map redraw complete.")
else:
logger.warning("Final image for redraw is None or MapDisplayWindow not available.")
# If final image is None but base was not, show the base image without marker.
if self._current_stitched_map_pil and self.map_display_window_controller:
logger.warning("Failed to draw click marker. Showing base map with persistent overlays.")
self.map_display_window_controller.show_map(map_copy_for_drawing) # Show the image with only persistent overlays
elif self.map_display_window_controller:
# If base image was None too, ensure placeholder is shown.
self.map_display_window_controller.show_map(None)
def _calculate_bbox_for_zoom_level(
self,
center_latitude: float,
center_longitude: float,
target_zoom: int
) -> Optional[Tuple[float, float, float, float]]:
"""
Calculates a geographic bounding box centered on a point at a given zoom level
designed to fit within a target pixel size (e.g., the scaled window size).
"""
if self.map_display_window_controller is None or self.map_service_provider is None:
logger.error("MapDisplayWindow or MapService is None, cannot calculate bbox for zoom.")
return None
# Use the current dimensions of the *scaled, displayed* window as the target pixel size.
# This ensures that zooming keeps the map approximately filling the window.
# The MapDisplayWindow keeps track of the shape of the image it actually displays after scaling.
displayed_h, displayed_w = self.map_display_window_controller._last_displayed_scaled_image_shape
if displayed_w <= 0 or displayed_h <= 0:
logger.warning("Displayed map dimensions are zero or invalid, cannot calculate zoom bbox accurately. Using fallback target pixel size.")
# Fallback target pixel size if displayed dimensions are zero (e.g., before first map is shown)
# Scale target pixel dim by current display scale.
target_px_w = int(TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW * self.current_display_scale_factor) # Use default target scaled by current scale
target_px_h = int(TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW * self.current_display_scale_factor)
# Ensure min sensible size
target_px_w = max(256, target_px_w)
target_px_h = max(256, target_px_h)
else:
target_px_w, target_px_h = displayed_w, displayed_h # Use current displayed size
# Calculate the geographic bbox needed for the new zoom level, centered at the click point
# MODIFIED: Call the imported calculate_geographic_bbox_from_pixel_size_and_zoom.
# WHY: This logic has been moved to map_utils.
# HOW: Call the function using the imported module name.
calculated_bbox = map_utils.calculate_geographic_bbox_from_pixel_size_and_zoom(
center_latitude,
center_longitude,
target_px_w,
target_px_h,
target_zoom,
self.map_service_provider.tile_size # Use the tile size of the current service
)
return calculated_bbox
# MODIFIED: New method to reset the map view to the initial state.
# WHY: Implement reset functionality.
# HOW: Recalls either display_map_for_point or display_map_for_area based on initial parameters.
def _reset_to_initial_view(self) -> None:
"""Resets the map view to the state it was in when first displayed."""
logger.info("Resetting map view to initial state...")
# Check which type of view was initially requested and re-trigger it.
# This requires storing the initial operation mode and its parameters.
# Let's add attributes for initial operation mode and parameters.
# *** Need to add _initial_operation_mode, _initial_point_coords, _initial_area_bbox attributes in __init__. ***
# *** Set them in display_map_for_point/area. ***
# Assuming these attributes are now stored:
if hasattr(self, '_initial_operation_mode') and self._initial_operation_mode == "point" and hasattr(self, '_initial_point_coords') and self._initial_point_coords is not None:
logger.debug("Restoring initial POINT view state by re-triggering display_map_for_point.")
lat, lon = self._initial_point_coords
# Clear current map state *before* calling the display function, to ensure it's treated as a new display request.
# MODIFIED: Use the helper method to clear state.
self._update_current_map_state(None, None, None)
# Clear view-specific states as they will be set by display_map_for_point logic
self._dem_tile_geo_bbox_for_current_point_view = None
self._current_requested_area_geo_bbox = None # Ensure this is cleared for point view
self._dem_tiles_info_for_current_map_area_view = [] # Ensure this is cleared for point view
# Call the logic to load the initial point view.
# This logic is now in _load_and_display_initial_view, which uses the _initial_ parameters.
# Since we are *resetting* based on stored _initial_ parameters, we just need to call the loading logic.
# The _load_and_display_initial_view method itself uses self._initial_operation_mode etc.
# So, we don't need to pass lat, lon here, just call the loader.
# However, the current _load_and_display_initial_view reads from _initial_*.
# The simplest is to re-call _load_and_and_display_initial_view.
# Let's re-trigger the logic that loads the initial view based on the _initial_* attributes.
self._load_and_display_initial_view()
# The loader handles setting all _current_... and _initial_... again, sending info, and first redraw.
elif hasattr(self, '_initial_operation_mode') and self._initial_operation_mode == "area" and hasattr(self, '_initial_area_bbox') and self._initial_area_bbox is not None:
logger.debug("Restoring initial AREA view state by re-triggering display_map_for_area.")
bbox = self._initial_area_bbox
# Clear current map state *before* calling the display function
# MODIFIED: Use the helper method to clear state.
self._update_current_map_state(None, None, None)
# Clear view-specific states as they will be set by display_map_for_area logic
self._dem_tile_geo_bbox_for_current_point_view = None # Ensure this is cleared for area view
# _current_requested_area_geo_bbox and _dem_tiles_info_for_current_map_area_view will be set by display_map_for_area logic
# Call the logic to load the initial area view.
# Same as point view, just call _load_and_display_initial_view.
self._load_and_display_initial_view()
# The loader handles everything else.
else:
logger.error("Initial view parameters were not stored or are incomplete. Cannot perform reset.")
# Send info to GUI
# MODIFIED: Include DMS fields with error state.
self._send_map_info_update_to_gui(None, None, "Reset Error: No Init Params", "Map Error") # Added DMS handled in send function
# Ensure placeholder is shown
if self.map_display_window_controller: self.map_display_window_controller.show_map(None)
# Clear current state
self._update_current_map_state(None, None, None)
# Clear view specific states
self._dem_tile_geo_bbox_for_current_point_view = None
self._current_requested_area_geo_bbox = None
self._dem_tiles_info_for_current_map_area_view = []
# MODIFIED: Added a dedicated helper function to send map info updates to the GUI.
# WHY: Centralizes the logic for sending information like click coordinates, elevation, and map area size.
# Called by handle_map_click_event and _load_and_display_initial_view.
# HOW: Created a new method that formats the payload and puts it into the queue.
# MODIFIED: Updated to include DMS strings in the payload sent to the GUI.
# WHY: The GUI now expects to receive DMS strings directly from the map process for click updates.
# HOW: Calculate DMS strings using map_utils.deg_to_dms_string and add them to the payload dictionary.
def _send_map_info_update_to_gui(
self,
latitude: Optional[float],
longitude: Optional[float],
elevation_str: str,
map_area_size_str: str
) -> None:
"""Sends map info (coords, elevation, map size) to the GUI queue."""
# MODIFIED: Calculate DMS strings for latitude and longitude if available.
# WHY: To send DMS format back to the GUI for display.
# HOW: Use map_utils.deg_to_dms_string. Handle None coords.
lat_dms_str = "N/A"
lon_dms_str = "N/A"
if latitude is not None and math.isfinite(latitude):
# MODIFIED: Use the imported deg_to_dms_string function.
# WHY: Perform the conversion here before sending to GUI.
# HOW: Call the function.
lat_dms_str = map_utils.deg_to_dms_string(latitude, 'lat')
if longitude is not None and math.isfinite(longitude):
# MODIFIED: Use the imported deg_to_dms_string function.
# WHY: Perform the conversion here before sending to GUI.
# HOW: Call the function.
lon_dms_str = map_utils.deg_to_dms_string(longitude, 'lon')
payload_to_gui = {
"type": "map_info_update", # Use a distinct type for initial/map state updates
"latitude": latitude, # Send float latitude
"longitude": longitude, # Send float longitude
"latitude_dms_str": lat_dms_str, # Send DMS latitude string
"longitude_dms_str": lon_dms_str, # Send DMS longitude string
"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_info:
logger.exception(f"Error putting map info onto GUI queue: {e_queue_info}")
# MODIFIED: Added a new helper function to send map fetching status updates to the GUI.
# WHY: To provide feedback to the user in the GUI while the map viewer is downloading/stitching tiles.
# HOW: Created a new method that formats a status message payload and puts it into the queue.
def _send_map_fetching_status_to_gui(self, status_message: str) -> None:
"""Sends a map fetching status message to the GUI queue."""
payload_to_gui = {
"type": "map_fetching_status", # Use a distinct type for fetching status updates
"status": status_message
}
try:
self.gui_com_queue.put(payload_to_gui)
logger.debug(f"Sent map_fetching_status to GUI queue: {payload_to_gui}")
except Exception as e_queue_status:
logger.exception(f"Error putting map fetching status onto GUI queue: {e_queue_status}")
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
# MODIFIED: Clear view specific state attributes on shutdown.
# WHY: Clean state.
# HOW: Reset attributes.
self._dem_tile_geo_bbox_for_current_point_view = None
self._current_requested_area_geo_bbox = None
self._dem_tiles_info_for_current_map_area_view = []
# MODIFIED: Clear initial view state attributes on shutdown.
# WHY: Clean state for reset functionality.
# HOW: Reset attributes.
if hasattr(self, '_initial_operation_mode'): del self._initial_operation_mode
if hasattr(self, '_initial_point_coords'): del self._initial_point_coords
if hasattr(self, '_initial_area_bbox'): del self._initial_area_bbox
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.")
# MODIFIED: Added the missing method handle_map_click_event.
# WHY: The MapDisplayWindow calls this method when a mouse click occurs.
# It was defined in a previous version's plan but not fully implemented/included.
# This is where the core logic for processing clicks, getting elevation,
# and updating the GUI and map view happens.
# HOW: Defined the method to receive event type, pixel coordinates, and flags,
# perform pixel-to_geo conversion, get elevation, update GUI queue, and trigger redraw.
# MODIFIED: Added logic to handle Right Click for zoom out.
# WHY: To add the requested zoom out functionality.
# HOW: Added an `elif is_right_click:` block to process Right Click events,
# decreasing the zoom level and triggering a map reload centered on the click point.
# MODIFIED: Added logic to handle Ctrl + Left Click for panning (recenter without zoom change).
# WHY: To implement the requested panning functionality.
# HOW: Added an `elif is_ctrl_held:` block within the `if is_left_click:` block to process Ctrl + Left Click events.
# This block uses logic similar to zoom in/out but keeps the current zoom level.
def handle_map_click_event(self, event_type: int, x_pixel: int, y_pixel: int, flags: int) -> None:
"""
Handles mouse click events received from the MapDisplayWindow.
Converts pixel coordinates to geographic, retrieves elevation (Left Click),
recensers/zooms (Shift+Left Click, Right Click), pans (Ctrl+Left Click),
sends info to GUI, and triggers map redraw to show click marker.
Args:
event_type: OpenCV mouse event type (e.g., cv2.EVENT_LBUTTONDOWN).
x_pixel: X coordinate of the click on the *scaled* displayed image.
y_pixel: Y coordinate of the click on the *scaled* displayed image.
flags: OpenCV mouse event flags (e.g., indicates modifier keys like Shift, Ctrl).
"""
# MODIFIED: Added check for CV2/NumPy availability to avoid NameError if flags are checked later.
# WHY: The 'flags' value is an integer, but checking for specific flags like cv2.EVENT_FLAG_SHIFTKEY
# requires cv2 to be available.
# HOW: Added an initial check.
if not CV2_NUMPY_LIBS_AVAILABLE:
logger.error("Cannot handle map click event: OpenCV/NumPy not available.")
# Send an error message to GUI? Maybe too verbose.
return
logger.debug(f"Handling map click event type {event_type} at scaled pixel ({x_pixel},{y_pixel}) with flags {flags}.")
# --- Determine Action Based on Event Type and Flags ---
is_left_click = event_type == cv2.EVENT_LBUTTONDOWN # type: ignore
is_right_click = event_type == cv2.EVENT_RBUTTONDOWN # type: ignore
is_shift_held = (flags & cv2.EVENT_FLAG_SHIFTKEY) != 0 # type: ignore # Check if Shift flag is set
# MODIFIED: Added check for Ctrl flag.
# WHY: To detect Ctrl + Click events.
# HOW: Used the bitwise AND operator with cv2.EVENT_FLAG_CTRLKEY.
is_ctrl_held = (flags & cv2.EVENT_FLAG_CTRLKEY) != 0 # type: ignore # Check if Ctrl flag is set
# Process Left Clicks (Standard for Elevation, Shift for Zoom In/Recenter, Ctrl for Pan/Recenter)
if is_left_click:
if is_shift_held:
logger.info(f"Shift + Left Click detected at scaled pixel ({x_pixel},{y_pixel}). Attempting to recenter and zoom IN.")
# --- Recenter and Zoom IN ---
# The core logic for recentering and zooming (increase zoom level) is already here.
# We will reuse this logic for the zoom-in part.
if self._current_map_geo_bounds_deg is None or self._current_stitched_map_pixel_shape is None or self._current_map_render_zoom is None or self.map_display_window_controller is None or self.map_tile_fetch_manager is None or self.map_service_provider is None:
logger.warning("Cannot recenter/zoom IN: Missing current map context or map controller/manager/service.")
self._send_map_info_update_to_gui(None, None, "Zoom In Failed: Context N/A", "Map Error")
return
# 1. Convert clicked pixel on *scaled* image to geographic coordinates (to use as new center).
displayed_h, displayed_w = self.map_display_window_controller._last_displayed_scaled_image_shape
if displayed_w <= 0 or displayed_h <= 0:
logger.error("Cannot recenter/zoom IN: Invalid displayed map dimensions.")
self._send_map_info_update_to_gui(None, None, "Zoom In Failed: Display Dims N/A", "Map Error")
return
clicked_geo_coords = self.map_display_window_controller.pixel_to_geo_on_current_map(
x_pixel, y_pixel,
self._current_map_geo_bounds_deg,
(displayed_h, displayed_w),
self._current_map_render_zoom
)
if clicked_geo_coords is None:
logger.warning(f"Zoom In failed: Pixel to geo conversion failed for pixel ({x_pixel},{y_pixel}).")
self._send_map_info_update_to_gui(None, None, "Zoom In Failed: Geo Conv Error", "Map Error")
return
clicked_lat, clicked_lon = clicked_geo_coords
logger.debug(f"Shift+Left Clicked Geo Coords (Recenter/Zoom In): ({clicked_lat:.5f}, {clicked_lon:.5f}).")
# 2. Determine new zoom level: Increase by 1.
new_zoom_level = self._current_map_render_zoom + 1
# Clamp zoom to service max zoom
if self.map_service_provider and new_zoom_level > self.map_service_provider.max_zoom:
new_zoom_level = self.map_service_provider.max_zoom
logger.warning(f"Shift+Left Click zoom IN clamped to service max zoom: {new_zoom_level}.")
# Ensure minimum zoom level (should not be needed when zooming in, but defensive)
if new_zoom_level < 0: new_zoom_level = 0
logger.info(f"Attempting to recenter map on ({clicked_lat:.5f},{clicked_lon:.5f}) at new zoom level {new_zoom_level} (Zoom In).")
# 3. Calculate the geographic bounding box for the new view, centered on the click point.
new_map_fetch_geo_bbox = map_utils.calculate_geographic_bbox_from_pixel_size_and_zoom(
clicked_lat, clicked_lon,
displayed_w, displayed_h, # Target pixel dimensions (use the current displayed size)
new_zoom_level,
self.map_service_provider.tile_size
)
if new_map_fetch_geo_bbox is None:
logger.warning("Zoom In failed: Could not calculate new map fetch BBox.")
self._send_map_info_update_to_gui(None, None, "Zoom In Failed: BBox Calc Error", "Map Error")
return
# 4. Fetch and Stitch the new map area.
self._send_map_fetching_status_to_gui(f"Fetching map (Zoom In) zoom {new_zoom_level}...")
try:
map_tile_xy_ranges = map_utils.get_tile_ranges_for_bbox(new_map_fetch_geo_bbox, new_zoom_level)
if not map_tile_xy_ranges:
logger.warning("Zoom In failed: No map tile ranges found for the new BBox/zoom.")
self._update_current_map_state(None, None, None)
self._send_map_info_update_to_gui(None, None, "Zoom In Failed: No Map Tiles", "Map Tiles N/A")
return
stitched_pil = self.map_tile_fetch_manager.stitch_map_image(
new_zoom_level, map_tile_xy_ranges[0], map_tile_xy_ranges[1]
)
if not stitched_pil:
logger.error("Zoom In failed: Failed to stitch new map image.")
self._update_current_map_state(None, None, None)
self._send_map_info_update_to_gui(None, None, "Zoom In Failed: Stitch Failed", "Map Stitch Failed")
return
# 5. Update current map state and trigger redraw.
actual_stitched_bounds = self.map_tile_fetch_manager._get_bounds_for_tile_range(
new_zoom_level, map_tile_xy_ranges
)
# Update state with the newly fetched/stitched map (this clears the old click marker)
self._update_current_map_state(stitched_pil, actual_stitched_bounds, new_zoom_level)
# Calculate and send the map area size of the NEW stitched area.
map_area_size_str = "N/A"
if self._current_map_geo_bounds_deg is not None and PYPROJ_AVAILABLE: # type: ignore
size_km = map_utils.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 (Zoomed View)"
map_area_size_str += f" Z[{new_zoom_level}]" # Add zoom level info
else:
map_area_size_str = "Size Calc Failed"
elif self._current_map_geo_bounds_deg:
map_area_size_str = "PyProj N/A (Size Unknown)"
# Send the updated map info (centered coords, elevation N/A for now, new area size)
self._send_map_info_update_to_gui(clicked_lat, clicked_lon, "N/A (Zoom In)", map_area_size_str) # Use helper
# Trigger redraw to show the new map (click marker is cleared by state update)
self._trigger_map_redraw_with_overlays() # This adds the click marker automatically if set (but it's None here)
except Exception as e_zoom_in_fetch:
logger.exception(f"Unexpected error during zoom IN fetch/stitch: {e_zoom_in_fetch}")
self._update_current_map_state(None, None, None)
self._send_map_info_update_to_gui(None, None, f"Zoom In Error: {type(e_zoom_in_fetch).__name__}", "Map Error")
# MODIFIED: Added block for Ctrl + Left Click for Pan/Recenter.
# WHY: To implement the panning functionality without changing zoom.
# HOW: Checked for `is_ctrl_held`. Copied and adapted logic from zoom handlers, keeping the current zoom level.
elif is_ctrl_held:
logger.info(f"Ctrl + Left Click detected at scaled pixel ({x_pixel},{y_pixel}). Attempting to recenter (Pan).")
# --- Recenter (Pan) ---
# Logic is similar to Zoom In/Out, but keeps the *current* zoom level.
if self._current_map_geo_bounds_deg is None or self._current_stitched_map_pixel_shape is None or self._current_map_render_zoom is None or self.map_display_window_controller is None or self.map_tile_fetch_manager is None or self.map_service_provider is None:
logger.warning("Cannot recenter (Pan): Missing current map context or map controller/manager/service.")
self._send_map_info_update_to_gui(None, None, "Pan Failed: Context N/A", "Map Error")
return
# 1. Convert clicked pixel on *scaled* image to geographic coordinates (to use as new center).
displayed_h, displayed_w = self.map_display_window_controller._last_displayed_scaled_image_shape
if displayed_w <= 0 or displayed_h <= 0:
logger.error("Cannot recenter (Pan): Invalid displayed map dimensions.")
self._send_map_info_update_to_gui(None, None, "Pan Failed: Display Dims N/A", "Map Error")
return
clicked_geo_coords = self.map_display_window_controller.pixel_to_geo_on_current_map(
x_pixel, y_pixel,
self._current_map_geo_bounds_deg,
(displayed_h, displayed_w),
self._current_map_render_zoom
)
if clicked_geo_coords is None:
logger.warning(f"Pan failed: Pixel to geo conversion failed for pixel ({x_pixel},{y_pixel}).")
self._send_map_info_update_to_gui(None, None, "Pan Failed: Geo Conv Error", "Map Error")
return
clicked_lat, clicked_lon = clicked_geo_coords
logger.debug(f"Ctrl+Left Clicked Geo Coords (Pan): ({clicked_lat:.5f}, {clicked_lon:.5f}).")
# 2. Determine new zoom level: Keep the current zoom level.
target_zoom_level = self._current_map_render_zoom
logger.info(f"Attempting to recenter map on ({clicked_lat:.5f},{clicked_lon:.5f}) at current zoom level {target_zoom_level} (Pan).")
# 3. Calculate the geographic bounding box for the new view, centered on the click point.
# Use the *current* zoom level. Target pixel dimensions are the current displayed size.
new_map_fetch_geo_bbox = map_utils.calculate_geographic_bbox_from_pixel_size_and_zoom(
clicked_lat, clicked_lon,
displayed_w, displayed_h, # Target pixel dimensions (use the current displayed size)
target_zoom_level,
self.map_service_provider.tile_size
)
if new_map_fetch_geo_bbox is None:
logger.warning("Pan failed: Could not calculate new map fetch BBox.")
self._send_map_info_update_to_gui(None, None, "Pan Failed: BBox Calc Error", "Map Error")
return
# 4. Fetch and Stitch the new map area.
self._send_map_fetching_status_to_gui(f"Panning map zoom {target_zoom_level}...")
try:
map_tile_xy_ranges = map_utils.get_tile_ranges_for_bbox(new_map_fetch_geo_bbox, target_zoom_level)
if not map_tile_xy_ranges:
logger.warning("Pan failed: No map tile ranges found for the new BBox/zoom.")
self._update_current_map_state(None, None, None)
self._send_map_info_update_to_gui(None, None, "Pan Failed: No Map Tiles", "Map Tiles N/A")
return
stitched_pil = self.map_tile_fetch_manager.stitch_map_image(
target_zoom_level, map_tile_xy_ranges[0], map_tile_xy_ranges[1]
)
if not stitched_pil:
logger.error("Pan failed: Failed to stitch new map image.")
self._update_current_map_state(None, None, None)
self._send_map_info_update_to_gui(None, None, "Pan Failed: Stitch Failed", "Map Stitch Failed")
return
# 5. Update current map state and trigger redraw.
actual_stitched_bounds = self.map_tile_fetch_manager._get_bounds_for_tile_range(
target_zoom_level, map_tile_xy_ranges
)
# Update state with the newly fetched/stitched map (this clears the old click marker)
self._update_current_map_state(stitched_pil, actual_stitched_bounds, target_zoom_level)
# Calculate and send the map area size of the NEW stitched area.
map_area_size_str = "N/A"
if self._current_map_geo_bounds_deg is not None and PYPROJ_AVAILABLE: # type: ignore
size_km = map_utils.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 (Pan View)"
map_area_size_str += f" Z[{target_zoom_level}]" # Add zoom level info
else:
map_area_size_str = "Size Calc Failed"
elif self._current_map_geo_bounds_deg:
map_area_size_str = "PyProj N/A (Size Unknown)"
# Send the updated map info (centered coords, elevation N/A for now, new area size)
self._send_map_info_update_to_gui(clicked_lat, clicked_lon, "N/A (Pan)", map_area_size_str)
# Trigger redraw to show the new map (click marker is cleared by state update)
self._trigger_map_redraw_with_overlays() # This adds the click marker automatically if set (but it's None here)
except Exception as e_pan_fetch:
logger.exception(f"Unexpected error during pan fetch/stitch: {e_pan_fetch}")
self._update_current_map_state(None, None, None)
self._send_map_info_update_to_gui(None, None, f"Pan Error: {type(e_pan_fetch).__name__}", "Map Error")
else: # Standard Left Click (no modifiers: Shift or Ctrl)
logger.info(f"Standard Left Click detected at scaled pixel ({x_pixel},{y_pixel}). Attempting to get elevation.")
# --- Process Standard Left Click (Get Elevation) ---
# The core logic for getting elevation is already here.
if self._current_map_geo_bounds_deg is None or self._current_stitched_map_pixel_shape is None or self._current_map_render_zoom is None or self.map_display_window_controller is None or self.elevation_manager is None:
logger.warning("Cannot get elevation for click: Missing current map context or elevation manager.")
self._send_map_info_update_to_gui(None, None, "Elev Failed: Context N/A", "Map Error")
return
# 1. Convert clicked pixel on *scaled* image to geographic coordinates.
displayed_h, displayed_w = self.map_display_window_controller._last_displayed_scaled_image_shape
if displayed_w <= 0 or displayed_h <= 0:
logger.error("Cannot get elevation for click: Invalid displayed map dimensions.")
self._send_map_info_update_to_gui(None, None, "Elev Failed: Display Dims N/A", "Map Error")
return
clicked_geo_coords = self.map_display_window_controller.pixel_to_geo_on_current_map(
x_pixel, y_pixel,
self._current_map_geo_bounds_deg,
(displayed_h, displayed_w),
self._current_map_render_zoom
)
if clicked_geo_coords is None:
logger.warning(f"Elevation lookup failed: Pixel to geo conversion failed for pixel ({x_pixel},{y_pixel}).")
self._send_map_info_update_to_gui(None, None, "Elev Failed: Geo Conv Error", "Map Error")
return
clicked_lat, clicked_lon = clicked_geo_coords
logger.info(f"Clicked Geo Coords: ({clicked_lat:.5f}, {clicked_lon:.5f}). Requesting elevation...")
# 2. Get Elevation for the clicked point using the ElevationManager instance.
self._send_map_info_update_to_gui(clicked_lat, clicked_lon, "Fetching Elevation...", "Map Area Shown")
try:
elevation_value = self.elevation_manager.get_elevation(clicked_lat, clicked_lon)
# 3. Update GUI with elevation result.
if elevation_value is None:
elevation_str_for_gui = "Unavailable"
logger.warning(f"Elevation data unavailable for clicked point ({clicked_lat:.5f},{clicked_lon:.5f}).")
elif isinstance(elevation_value, float) and math.isnan(elevation_value):
elevation_str_for_gui = "NoData"
logger.info(f"Clicked point ({clicked_lat:.5f},{clicked_lon:.5f}) is on a NoData area.")
else:
elevation_str_for_gui = f"{elevation_value:.2f} m"
logger.info(f"Elevation found for clicked point ({clicked_lat:.5f},{clicked_lon:.5f}): {elevation_str_for_gui}")
# Send the updated point info (coords and elevation) to the GUI.
# Map area size doesn't change on a click, keep showing the size of the currently displayed map patch.
map_area_size_str_for_gui = "N/A"
if self._current_map_geo_bounds_deg is not None and PYPROJ_AVAILABLE: # type: ignore
size_km = map_utils.calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg)
if size_km:
width_km, height_km = size_km
map_area_size_str_for_gui = f"{width_km:.2f} km W x {height_km:.2f} km H (Map Area Shown)"
# MODIFIED: Add zoom level to area size string for more info.
# WHY: Provides helpful context in the GUI.
# HOW: Appended " Z[#]" to the string.
if self._current_map_render_zoom is not None:
map_area_size_str_for_gui += f" Z[{self._current_map_render_zoom}]"
elif self._current_requested_area_geo_bbox: # If it was an area view but size calc failed
map_area_size_str_for_gui = "Size Calc Failed (Area View)"
else:
map_area_size_str_for_gui = "Size Calc Failed" # For point view
elif self._current_map_geo_bounds_deg:
map_area_size_str_for_gui = "PyProj N/A (Size Unknown)"
self._send_map_info_update_to_gui(clicked_lat, clicked_lon, elevation_str_for_gui, map_area_size_str_for_gui)
# 4. Store the clicked pixel coordinates and trigger map redraw to show the marker.
self._last_user_click_pixel_coords_on_displayed_image = (x_pixel, y_pixel)
self._trigger_map_redraw_with_overlays() # Redraw with the new marker
except Exception as e_elev_lookup:
logger.exception(f"Unexpected error during elevation lookup for click ({clicked_lat:.5f},{clicked_lon:.5f}): {e_elev_lookup}")
self._send_map_info_update_to_gui(clicked_lat, clicked_lon, f"Elev Error: {type(e_elev_lookup).__name__}", "Map Area Shown")
# Clear the last click marker on error
self._last_user_click_pixel_coords_on_displayed_image = None
self._trigger_map_redraw_with_overlays() # Redraw without marker
# --- Process Right Clicks ---
elif is_right_click:
logger.info(f"Right Click detected at scaled pixel ({x_pixel},{y_pixel}). Attempting to recenter and zoom OUT.")
# --- Recenter and Zoom OUT ---
# Logic is very similar to Shift+Left Click (Zoom In), but decreasing the zoom level.
if self._current_map_geo_bounds_deg is None or self._current_stitched_map_pixel_shape is None or self._current_map_render_zoom is None or self.map_display_window_controller is None or self.map_tile_fetch_manager is None or self.map_service_provider is None:
logger.warning("Cannot recenter/zoom OUT: Missing current map context or map controller/manager/service.")
self._send_map_info_update_to_gui(None, None, "Zoom Out Failed: Context N/A", "Map Error")
return
# 1. Convert clicked pixel on *scaled* image to geographic coordinates (to use as new center).
displayed_h, displayed_w = self.map_display_window_controller._last_displayed_scaled_image_shape
if displayed_w <= 0 or displayed_h <= 0:
logger.error("Cannot recenter/zoom OUT: Invalid displayed map dimensions.")
self._send_map_info_update_to_gui(None, None, "Zoom Out Failed: Display Dims N/A", "Map Error")
return
clicked_geo_coords = self.map_display_window_controller.pixel_to_geo_on_current_map(
x_pixel, y_pixel,
self._current_map_geo_bounds_deg,
(displayed_h, displayed_w),
self._current_map_render_zoom
)
if clicked_geo_coords is None:
logger.warning(f"Zoom Out failed: Pixel to geo conversion failed for pixel ({x_pixel},{y_pixel}).")
self._send_map_info_update_to_gui(None, None, "Zoom Out Failed: Geo Conv Error", "Map Error")
return
clicked_lat, clicked_lon = clicked_geo_coords
logger.debug(f"Right Clicked Geo Coords (Recenter/Zoom Out): ({clicked_lat:.5f}, {clicked_lon:.5f}).")
# 2. Determine new zoom level: Decrease by 1.
new_zoom_level = self._current_map_render_zoom - 1
# Clamp zoom to minimum (zoom 0)
if new_zoom_level < 0:
new_zoom_level = 0
logger.warning(f"Right Click zoom OUT clamped to minimum zoom: {new_zoom_level}.")
logger.info(f"Attempting to recenter map on ({clicked_lat:.5f},{clicked_lon:.5f}) at new zoom level {new_zoom_level} (Zoom Out).")
# 3. Calculate the geographic bounding box for the new view, centered on the click point.
new_map_fetch_geo_bbox = map_utils.calculate_geographic_bbox_from_pixel_size_and_zoom(
clicked_lat, clicked_lon,
displayed_w, displayed_h, # Target pixel dimensions (use the current displayed size)
new_zoom_level,
self.map_service_provider.tile_size
)
if new_map_fetch_geo_bbox is None:
logger.warning("Zoom Out failed: Could not calculate new map fetch BBox.")
self._send_map_info_update_to_gui(None, None, "Zoom Out Failed: BBox Calc Error", "Map Error")
return
# 4. Fetch and Stitch the new map area.
self._send_map_fetching_status_to_gui(f"Fetching map (Zoom Out) zoom {new_zoom_level}...")
try:
map_tile_xy_ranges = map_utils.get_tile_ranges_for_bbox(new_map_fetch_geo_bbox, new_zoom_level)
if not map_tile_xy_ranges:
logger.warning("Zoom Out failed: No map tile ranges found for the new BBox/zoom.")
self._update_current_map_state(None, None, None)
self._send_map_info_update_to_gui(None, None, "Zoom Out Failed: No Map Tiles", "Map Tiles N/A")
return
stitched_pil = self.map_tile_fetch_manager.stitch_map_image(
new_zoom_level, map_tile_xy_ranges[0], map_tile_xy_ranges[1]
)
if not stitched_pil:
logger.error("Zoom Out failed: Failed to stitch new map image.")
self._update_current_map_state(None, None, None)
self._send_map_info_update_to_gui(None, None, "Zoom Out Failed: Stitch Failed", "Map Stitch Failed")
return
# 5. Update current map state and trigger redraw.
actual_stitched_bounds = self.map_tile_fetch_manager._get_bounds_for_tile_range(
new_zoom_level, map_tile_xy_ranges
)
# Update state with the newly fetched/stitched map (this clears the old click marker)
self._update_current_map_state(stitched_pil, actual_stitched_bounds, new_zoom_level)
# Calculate and send the map area size of the NEW stitched area.
map_area_size_str = "N/A"
if self._current_map_geo_bounds_deg is not None and PYPROJ_AVAILABLE: # type: ignore
size_km = map_utils.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 (Zoomed View)"
map_area_size_str += f" Z[{new_zoom_level}]" # Add zoom level info
else:
map_area_size_str = "Size Calc Failed"
elif self._current_map_geo_bounds_deg:
map_area_size_str = "PyProj N/A (Size Unknown)"
# Send the updated map info (centered coords, elevation N/A for now, new area size)
self._send_map_info_update_to_gui(clicked_lat, clicked_lon, "N/A (Zoom Out)", map_area_size_str)
# Trigger redraw to show the new map (click marker is cleared by state update)
self._trigger_map_redraw_with_overlays() # This adds the click marker automatically if set (but it's None here)
except Exception as e_zoom_out_fetch:
logger.exception(f"Unexpected error during zoom OUT fetch/stitch: {e_zoom_out_fetch}")
self._update_current_map_state(None, None, None)
self._send_map_info_update_to_gui(None, None, f"Zoom Out Error: {type(e_zoom_out_fetch).__name__}", "Map Error")
else:
# Handle other click types if needed in the future (e.g., Middle click)
logger.debug(f"Ignoring unhandled click event type: {event_type}.")
pass # Do nothing for unhandled event types
# MODIFIED: Added a dedicated helper function to send map info updates to the GUI.
# WHY: Centralizes the logic for sending information like click coordinates, elevation, and map area size.
# Called by handle_map_click_event and _load_and_display_initial_view.
# HOW: Created a new method that formats the payload and puts it into the queue.
# MODIFIED: Updated to include DMS strings in the payload sent to the GUI.
# WHY: The GUI now expects to receive DMS strings directly from the map process for click updates.
# HOW: Calculate DMS strings using map_utils.deg_to_dms_string and add them to the payload dictionary.
def _send_map_info_update_to_gui(
self,
latitude: Optional[float],
longitude: Optional[float],
elevation_str: str,
map_area_size_str: str
) -> None:
"""Sends map info (coords, elevation, map size) to the GUI queue."""
# MODIFIED: Calculate DMS strings for latitude and longitude if available.
# WHY: To send DMS format back to the GUI for display.
# HOW: Use map_utils.deg_to_dms_string. Handle None coords.
lat_dms_str = "N/A"
lon_dms_str = "N/A"
if latitude is not None and math.isfinite(latitude):
# MODIFIED: Use the imported deg_to_dms_string function.
# WHY: Perform the conversion here before sending to GUI.
# HOW: Call the function.
lat_dms_str = map_utils.deg_to_dms_string(latitude, 'lat')
if longitude is not None and math.isfinite(longitude):
# MODIFIED: Use the imported deg_to_dms_string function.
# WHY: Perform the conversion here before sending to GUI.
# HOW: Call the function.
lon_dms_str = map_utils.deg_to_dms_string(longitude, 'lon')
payload_to_gui = {
"type": "map_info_update", # Use a distinct type for initial/map state updates
"latitude": latitude, # Send float latitude
"longitude": longitude, # Send float longitude
"latitude_dms_str": lat_dms_str, # Send DMS latitude string
"longitude_dms_str": lon_dms_str, # Send DMS longitude string
"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_info:
logger.exception(f"Error putting map info onto GUI queue: {e_queue_info}")
# MODIFIED: Added a new helper function to send map fetching status updates to the GUI.
# WHY: To provide feedback to the user in the GUI while the map viewer is downloading/stitching tiles.
# HOW: Created a new method that formats a status message payload and puts it into the queue.
def _send_map_fetching_status_to_gui(self, status_message: str) -> None:
"""Sends a map fetching status message to the GUI queue."""
payload_to_gui = {
"type": "map_fetching_status", # Use a distinct type for fetching status updates
"status": status_message
}
try:
self.gui_com_queue.put(payload_to_gui)
logger.debug(f"Sent map_fetching_status to GUI queue: {payload_to_gui}")
except Exception as e_queue_status:
logger.exception(f"Error putting map fetching status onto GUI queue: {e_queue_status}")
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
# MODIFIED: Clear view specific state attributes on shutdown.
# WHY: Clean state.
# HOW: Reset attributes.
self._dem_tile_geo_bbox_for_current_point_view = None
self._current_requested_area_geo_bbox = None
self._dem_tiles_info_for_current_map_area_view = []
# MODIFIED: Clear initial view state attributes on shutdown.
# WHY: Clean state for reset functionality.
# HOW: Reset attributes.
if hasattr(self, '_initial_operation_mode'): del self._initial_operation_mode
if hasattr(self, '_initial_point_coords'): del self._initial_point_coords
if hasattr(self, '_initial_area_bbox'): del self._initial_area_bbox
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.")