1492 lines
89 KiB
Python
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.") |