1619 lines
99 KiB
Python
1619 lines
99 KiB
Python
# geoelevation/map_viewer/geo_map_viewer.py
|
|
"""
|
|
Orchestrates map display functionalities for the GeoElevation application.
|
|
|
|
This module initializes and manages map services, tile fetching/caching,
|
|
and the map display window. It handles requests to show maps centered on
|
|
specific points or covering defined areas, applying a specified display scale.
|
|
It also processes user interactions (mouse clicks) on the map, converting
|
|
pixel coordinates to geographic coordinates, fetching elevation for those points
|
|
using the core ElevationManager, and sending this information back to the
|
|
main GUI via a queue.
|
|
"""
|
|
|
|
# Standard library imports
|
|
import logging
|
|
import math
|
|
import queue # For type hinting, actual queue object is passed in from multiprocessing
|
|
import sys # Import sys for logging stream
|
|
from typing import Optional, Tuple, Dict, Any, List
|
|
|
|
# Third-party imports
|
|
try:
|
|
from PIL import Image, ImageDraw # Import ImageDraw for drawing operations
|
|
ImageType = Image.Image # type: ignore
|
|
PIL_IMAGE_LIB_AVAILABLE = True
|
|
except ImportError:
|
|
Image = None # type: ignore
|
|
ImageDraw = None # type: ignore # Define as None if import fails
|
|
# MODIFIED: Added ImageType definition for type hinting even if PIL is missing.
|
|
# WHY: Allows static analysis tools to understand the intended type even if the library isn't installed.
|
|
# HOW: Defined ImageType = Any inside the except block.
|
|
ImageType = Any # type: ignore # Define ImageType as Any if PIL is not available
|
|
# This logger might not be configured yet if this is the first import in the process
|
|
# So, direct print or rely on higher-level logger configuration.
|
|
print("ERROR: GeoMapViewer - Pillow (PIL) library not found. Image operations will fail.")
|
|
|
|
|
|
try:
|
|
import cv2 # OpenCV for windowing and drawing
|
|
import numpy as np
|
|
CV2_NUMPY_LIBS_AVAILABLE = True
|
|
except ImportError:
|
|
cv2 = None # type: ignore
|
|
np = None # type: ignore
|
|
CV2_NUMPY_LIBS_AVAILABLE = False
|
|
print("ERROR: GeoMapViewer - OpenCV or NumPy not found. Drawing and image operations will fail.")
|
|
|
|
try:
|
|
import mercantile # For Web Mercator tile calculations and coordinate conversions
|
|
MERCANTILE_LIB_AVAILABLE_DISPLAY = True
|
|
except ImportError:
|
|
mercantile = None # type: ignore
|
|
MERCANTILE_LIB_AVAILABLE_DISPLAY = False
|
|
print("ERROR: MapDisplay - 'mercantile' library not found. Coordinate conversions will fail.")
|
|
|
|
|
|
# Local application/package imports
|
|
# Imports from other modules within the 'map_viewer' subpackage
|
|
from .map_services import BaseMapService
|
|
from .map_services import OpenStreetMapService # Default service if none specified
|
|
from .map_manager import MapTileManager
|
|
from .map_utils import get_bounding_box_from_center_size
|
|
from .map_utils import get_tile_ranges_for_bbox
|
|
from .map_utils import MapCalculationError
|
|
# MODIFIED: Import the new utility functions for geographic size and HGT tile bounds.
|
|
# WHY: Needed for calculating displayed map area size and getting DEM tile bounds.
|
|
# HOW: Added imports from map_utils.
|
|
from . import map_utils
|
|
from .map_utils import calculate_geographic_bbox_size_km
|
|
from .map_utils import get_hgt_tile_geographic_bounds
|
|
# MODIFIED: Import the new utility function to calculate zoom level for geographic size.
|
|
# WHY: This is the core function needed to determine the appropriate zoom for the map point view.
|
|
# HOW: Added import from map_utils.
|
|
from .map_utils import calculate_zoom_level_for_geographic_size
|
|
from .map_utils import PYPROJ_AVAILABLE
|
|
|
|
|
|
# Imports from the parent 'geoelevation' package
|
|
from geoelevation.elevation_manager import ElevationManager
|
|
|
|
# Module-level logger. Will be configured by the calling process (run_map_viewer_process_target)
|
|
# or use root logger if not specifically configured.
|
|
logger = logging.getLogger(__name__) # Uses 'geoelevation.map_viewer.geo_map_viewer'
|
|
|
|
# Default configuration values specific to the map viewer's operation
|
|
DEFAULT_MAP_TILE_CACHE_DIRECTORY = "map_tile_cache_ge"
|
|
DEFAULT_MAP_DISPLAY_ZOOM_LEVEL = 15
|
|
DEFAULT_MAP_VIEW_AREA_SIZE_KM = 5.0 # This default might become less relevant for point views
|
|
|
|
# MODIFIED: Define constants for drawing the DEM tile boundary.
|
|
# WHY: Improves code clarity and makes colors/thickness easily adjustable.
|
|
# HOW: Added constants for DEM boundary color and thickness.
|
|
DEM_BOUNDARY_COLOR = "red"
|
|
DEM_BOUNDARY_THICKNESS_PX = 3 # Pixel thickness on the unscaled map image
|
|
# MODIFIED: Define 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
|
|
DEM_TILE_LABEL_COLOR = TILE_TEXT_COLOR
|
|
DEM_TILE_LABEL_BG_COLOR = TILE_TEXT_BG_COLOR
|
|
_DEFAULT_FONT_FOR_LABELS = DEFAULT_FONT # Use the font loaded in image_processor
|
|
except ImportError:
|
|
# Fallback values if image_processor constants are not available (shouldn't happen if image_processor loads font)
|
|
DEM_TILE_LABEL_COLOR = "white"
|
|
DEM_TILE_LABEL_BG_COLOR = "rgba(0, 0, 0, 150)" # Semi-transparent black background
|
|
_DEFAULT_FONT_FOR_LABELS = None # No font available from image_processor
|
|
logger.warning("Could not import text style constants or default font from image_processor. Using fallbacks for DEM tile labels.")
|
|
|
|
|
|
# MODIFIED: Base font size for DEM tile labels for zoom-based scaling.
|
|
# WHY: Provide a starting point for font size calculation.
|
|
# HOW: Added a constant.
|
|
DEM_TILE_LABEL_BASE_FONT_SIZE = 12 # px
|
|
# MODIFIED: Base zoom level for DEM tile label font scaling.
|
|
# WHY: Font size will be proportional to (current_zoom - BASE_ZOOM).
|
|
# HOW: Added a constant.
|
|
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
|
|
) -> None:
|
|
"""
|
|
Initializes the GeoElevationMapViewer.
|
|
|
|
Args:
|
|
elevation_manager_instance: Instance of ElevationManager for fetching elevations.
|
|
gui_output_communication_queue: Queue to send interaction data to the main GUI.
|
|
initial_display_scale: Initial scaling factor for the map display.
|
|
"""
|
|
logger.info("Initializing GeoElevationMapViewer instance...")
|
|
# MODIFIED: Added a check for critical dependencies at init.
|
|
# WHY: Ensure the class can function before proceeding.
|
|
# HOW: Raise ImportError if dependencies are missing.
|
|
if not CV2_NUMPY_LIBS_AVAILABLE:
|
|
critical_msg = "OpenCV and/or NumPy are not available for GeoElevationMapViewer operation."
|
|
logger.critical(critical_msg)
|
|
raise ImportError(critical_msg)
|
|
# PIL and mercantile are also critical for map viewer logic
|
|
if not PIL_IMAGE_LIB_AVAILABLE:
|
|
critical_msg = "Pillow (PIL) library is not available for GeoElevationMapViewer operation."
|
|
logger.critical(critical_msg)
|
|
raise ImportError(critical_msg)
|
|
# MODIFIED: Added check for ImageDraw availability, as it's needed for drawing.
|
|
# WHY: Drawing shapes/text on PIL images requires ImageDraw.
|
|
# HOW: Added explicit check.
|
|
if ImageDraw is None: # type: ignore
|
|
critical_msg = "Pillow's ImageDraw module is not available. GeoElevationMapViewer drawing operations will fail."
|
|
logger.critical(critical_msg)
|
|
raise ImportError(critical_msg)
|
|
if not MERCANTILE_LIB_AVAILABLE_DISPLAY:
|
|
critical_msg = "'mercantile' library is not available for GeoElevationMapViewer operation."
|
|
logger.critical(critical_msg)
|
|
raise ImportError(critical_msg)
|
|
# pyproj is needed for size calculations, but might be optional depending on usage.
|
|
# If calculate_geographic_bbox_size_km fails, the size might be reported as N/A,
|
|
# which is graceful degradation. Let's not make pyproj a hard dependency for init.
|
|
|
|
|
|
self.elevation_manager: ElevationManager = elevation_manager_instance
|
|
self.gui_com_queue: queue.Queue = gui_output_communication_queue
|
|
|
|
# MODIFIED: Store the initial_display_scale.
|
|
# WHY: This scale factor will be used by MapDisplayWindow to scale the map image.
|
|
# HOW: Assigned to self.current_display_scale_factor.
|
|
self.current_display_scale_factor: float = initial_display_scale
|
|
logger.info(f"Initial map display scale factor set to: {self.current_display_scale_factor:.3f}")
|
|
|
|
self.map_service_provider: Optional[BaseMapService] = None
|
|
self.map_tile_fetch_manager: Optional[MapTileManager] = None
|
|
# Changed attribute name to map_display_window_controller for consistency
|
|
self.map_display_window_controller: Optional['MapDisplayWindow'] = None
|
|
|
|
self._current_stitched_map_pil: Optional[ImageType] = None
|
|
self._current_map_geo_bounds_deg: Optional[Tuple[float, float, float, float]] = None
|
|
self._current_map_render_zoom: Optional[int] = None
|
|
self._current_stitched_map_pixel_shape: Optional[Tuple[int, int]] = (0, 0) # H, W
|
|
|
|
self._last_user_click_pixel_coords_on_displayed_image: Optional[Tuple[int, int]] = None
|
|
# MODIFIED: Added attribute to store the DEM tile bbox if a map view was initiated for a point with DEM data.
|
|
# WHY: Needed to redraw the DEM boundary after user clicks.
|
|
# HOW: Added a new instance attribute.
|
|
self._dem_tile_geo_bbox_for_current_map: Optional[Tuple[float, float, float, float]] = None
|
|
|
|
# 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
|
|
self._dem_tiles_info_for_current_map: List[Dict] = [] # Store list of tile info dicts
|
|
|
|
|
|
self._initialize_map_viewer_components()
|
|
logger.info("GeoElevationMapViewer instance initialization complete.")
|
|
|
|
def _initialize_map_viewer_components(self) -> None:
|
|
"""Initializes internal map service, tile manager, and display window controller."""
|
|
logger.debug("Initializing internal map viewer components...")
|
|
try:
|
|
# Local import of map_display within the process target to avoid import issues
|
|
# in the main GUI process where Tkinter is running.
|
|
from .map_display import MapDisplayWindow
|
|
|
|
self.map_service_provider = OpenStreetMapService()
|
|
if not self.map_service_provider:
|
|
raise ValueError("Failed to initialize OpenStreetMapService.")
|
|
logger.info(f"Map service provider '{self.map_service_provider.name}' initialized.")
|
|
|
|
# MODIFIED: Use the map service's tile size when initializing MapTileManager.
|
|
# WHY: Ensure MapTileManager uses the correct tile size for the chosen service.
|
|
# HOW: Passed map_service.tile_size to MapTileManager constructor.
|
|
self.map_tile_fetch_manager = MapTileManager(
|
|
map_service=self.map_service_provider,
|
|
cache_root_directory=DEFAULT_MAP_TILE_CACHE_DIRECTORY,
|
|
enable_online_tile_fetching=True,
|
|
tile_pixel_size=self.map_service_provider.tile_size # Pass tile size
|
|
)
|
|
logger.info("MapTileManager initialized.")
|
|
|
|
# MapDisplayWindow will use 'self' (this GeoElevationMapViewer instance)
|
|
# as its 'app_facade' to make callbacks and access shared state like scale factor.
|
|
# MODIFIED: Corrected the keyword argument name for the window name.
|
|
# WHY: The MapDisplayWindow.__init__ method expects 'window_name_str', not 'window_name'.
|
|
# HOW: Changed 'window_name' to 'window_name_str' in the constructor call.
|
|
self.map_display_window_controller = MapDisplayWindow(
|
|
app_facade=self, # This instance provides context (like scale) and handles callbacks
|
|
window_name_str="GeoElevation - Interactive Map"
|
|
)
|
|
logger.info("MapDisplayWindow controller initialized.")
|
|
|
|
except ImportError as e_imp_map_comp:
|
|
logger.critical(f"Failed to import a required map component: {e_imp_map_comp}", exc_info=True)
|
|
raise
|
|
except Exception as e_init_map_comp:
|
|
logger.critical(f"Failed to initialize map components: {e_init_map_comp}", exc_info=True)
|
|
raise
|
|
|
|
|
|
def display_map_for_point(
|
|
self,
|
|
center_latitude: float,
|
|
center_longitude: float,
|
|
target_map_zoom: Optional[int] = None # This parameter is now effectively ignored for point view sizing
|
|
) -> None:
|
|
"""
|
|
Displays a map centered around a point, ideally covering the relevant DEM tile,
|
|
draws a marker at the point, and sends initial info back to the GUI.
|
|
Applies the current display scale. The zoom level is calculated to fit the DEM tile.
|
|
"""
|
|
if not self.map_tile_fetch_manager or not self.map_display_window_controller or not self.elevation_manager or not self.map_service_provider: # Added check for map_service_provider
|
|
logger.error("Map or Elevation components not ready for display_map_for_point.")
|
|
# MODIFIED: If components aren't ready, send error info to GUI queue if components aren't ready.
|
|
# WHY: GUI needs to know the map view failed.
|
|
# HOW: Put an error message into the queue.
|
|
# MODIFIED: Include DMS fields with error state for consistency with GUI update logic.
|
|
error_payload = {"type": "map_info_update", "latitude": center_latitude, "longitude": center_longitude,
|
|
"latitude_dms_str": "Error", "longitude_dms_str": "Error", # Added DMS fields
|
|
"elevation_str": "Map Error", "map_area_size_str": "Error: Components N/A"}
|
|
try: self.gui_com_queue.put(error_payload)
|
|
except Exception as e_put_err: logger.error(f"Failed to put error payload to queue before exit: {e_put_err}")
|
|
if self.map_display_window_controller: self.map_display_window_controller.show_map(None) # Show placeholder
|
|
return
|
|
|
|
logger.info(
|
|
f"Requesting map display for point: ({center_latitude:.5f}, {center_longitude:.5f}), "
|
|
f"Target Pixel Size: {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}x{TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}, "
|
|
f"CurrentDisplayScale: {self.current_display_scale_factor:.2f}"
|
|
)
|
|
|
|
dem_tile_info = None
|
|
dem_tile_geo_bbox: Optional[Tuple[float, float, float, float]] = None
|
|
map_fetch_geo_bbox: Optional[Tuple[float, float, float, float]] = None # BBox to fetch tiles for
|
|
|
|
# MODIFIED: Clear stored area view state when switching to point view.
|
|
# WHY: Ensure state is clean.
|
|
# HOW: Set attributes to None/empty.
|
|
self._current_requested_area_geo_bbox = None
|
|
self._dem_tiles_info_for_current_map = []
|
|
self._dem_tile_geo_bbox_for_current_map = None # Keep this one as it's specific to point view
|
|
|
|
|
|
# MODIFIED: Initialize map_tile_xy_ranges to None before the try block.
|
|
# WHY: To ensure the variable is defined even if an exception occurs before its assignment.
|
|
# HOW: Added the initialization here.
|
|
map_tile_xy_ranges = None
|
|
|
|
try:
|
|
# MODIFIED: 1. Get DEM tile info and its geographic bounds.
|
|
# WHY: To size the map view appropriately and potentially draw the DEM boundary.
|
|
# HOW: Use self.elevation_manager.get_tile_info and the new get_hgt_tile_geographic_bounds.
|
|
dem_tile_info = self.elevation_manager.get_tile_info(center_latitude, center_longitude)
|
|
if dem_tile_info and dem_tile_info.get("hgt_available"):
|
|
lat_coord = dem_tile_info["latitude_coord"]
|
|
lon_coord = dem_tile_info["longitude_coord"]
|
|
dem_tile_geo_bbox = get_hgt_tile_geographic_bounds(lat_coord, lon_coord)
|
|
# MODIFIED: Store the calculated DEM tile bbox if found.
|
|
# WHY: Needed later for redrawing the boundary after clicks.
|
|
# HOW: Assign dem_tile_geo_bbox to _dem_tile_geo_bbox_for_current_map.
|
|
self._dem_tile_geo_bbox_for_current_map = dem_tile_geo_bbox
|
|
logger.debug(f"Identified DEM tile bounds for ({center_latitude:.5f},{center_longitude:.5f}): {dem_tile_geo_bbox}")
|
|
else:
|
|
logger.warning(f"No HGT tile information or HGT not available for ({center_latitude:.5f},{center_longitude:.5f}). Cannot size map precisely to DEM tile. Using default area.")
|
|
# Fallback: if no DEM tile, use a default map area size centered on the point.
|
|
# This is the old behavior, but the zoom calculation will still apply to this area.
|
|
map_area_km_fetch = DEFAULT_MAP_VIEW_AREA_SIZE_KM * 1.2 # Use area size definition
|
|
map_fetch_geo_bbox = get_bounding_box_from_center_size(
|
|
center_latitude, center_longitude, map_area_km_fetch
|
|
)
|
|
if not map_fetch_geo_bbox:
|
|
raise MapCalculationError("Fallback BBox calculation failed.")
|
|
|
|
|
|
# MODIFIED: 2. Determine the map fetch bounding box based on the DEM tile or fallback.
|
|
# WHY: Ensure the map covers the relevant DEM tile area or a reasonable default.
|
|
# HOW: If DEM tile bounds are known, create map_fetch_bbox from them with a buffer.
|
|
if dem_tile_geo_bbox:
|
|
# Expand DEM tile bounds slightly for map fetching (e.g., 0.1 degrees buffer)
|
|
buffer_deg = 0.1
|
|
w_dem, s_dem, e_dem, n_dem = dem_tile_geo_bbox
|
|
# Apply buffer and clamp to valid WGS84 range
|
|
map_fetch_west = max(-180.0, w_dem - buffer_deg)
|
|
map_fetch_south = max(-90.0, s_dem - buffer_deg)
|
|
map_fetch_east = min(180.0, e_dem + buffer_deg)
|
|
map_fetch_north = min(90.0, n_dem + buffer_deg)
|
|
map_fetch_geo_bbox = (map_fetch_west, map_fetch_south, map_fetch_east, map_fetch_north)
|
|
logger.debug(f"Map fetch BBox (DEM+buffer): {map_fetch_geo_bbox}")
|
|
# If dem_tile_geo_bbox was None, map_fetch_geo_bbox was already set by the fallback logic above.
|
|
|
|
|
|
if not map_fetch_geo_bbox:
|
|
raise MapCalculationError("Final map fetch BBox could not be determined.")
|
|
|
|
|
|
# MODIFIED: 3. Calculate the appropriate zoom level to fit the map_fetch_geo_bbox into the target pixel size.
|
|
# WHY: To prevent creating excessively large map images like 28160x40192 px.
|
|
# HOW: Calculate geographic height of map_fetch_geo_bbox and use calculate_zoom_level_for_geographic_size.
|
|
calculated_zoom = None
|
|
zoom_calculation_successful = False
|
|
map_area_size_km = None # Added variable to store size for logging
|
|
|
|
# MODIFIED: Check PyProj availability before calculating size.
|
|
# WHY: calculate_geographic_bbox_size_km requires PyProj.
|
|
# HOW: Added check.
|
|
if PYPROJ_AVAILABLE: # type: ignore
|
|
map_area_size_km = calculate_geographic_bbox_size_km(map_fetch_geo_bbox)
|
|
if map_area_size_km:
|
|
width_km, height_km = map_area_size_km
|
|
map_bbox_height_meters = height_km * 1000.0
|
|
# Use the center latitude of the fetch box for zoom calculation accuracy
|
|
center_lat_fetch_bbox = (map_fetch_geo_bbox[1] + map_fetch_geo_bbox[3]) / 2.0
|
|
|
|
calculated_zoom = calculate_zoom_level_for_geographic_size(
|
|
center_lat_fetch_bbox,
|
|
map_bbox_height_meters,
|
|
TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW, # Target pixel height
|
|
self.map_service_provider.tile_size # Tile size from the map service
|
|
)
|
|
if calculated_zoom is not None:
|
|
logger.info(f"Calculated zoom level {calculated_zoom} to fit BBox height ({map_bbox_height_meters:.2f}m) into {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}px.")
|
|
zoom_calculation_successful = True
|
|
else:
|
|
logger.warning("Could not calculate appropriate zoom level. Falling back to default zoom.")
|
|
|
|
else:
|
|
logger.warning("Could not calculate geographic size of fetch BBox. Falling back to default zoom.")
|
|
else:
|
|
logger.warning("PyProj not available. Cannot calculate geographic size for zoom calculation. Falling back to default zoom.")
|
|
|
|
|
|
# MODIFIED: Determine the final zoom level to use.
|
|
# WHY: Use the calculated zoom if successful, otherwise use the default zoom as a fallback.
|
|
# HOW: Check zoom_calculation_successful.
|
|
zoom_to_use = calculated_zoom if zoom_calculation_successful else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL
|
|
logger.debug(f"Using zoom level {zoom_to_use} for tile fetching and stitching.")
|
|
|
|
|
|
# map_tile_xy_ranges assignment is here - line 346 originally
|
|
map_tile_xy_ranges = get_tile_ranges_for_bbox(map_fetch_geo_bbox, zoom_to_use)
|
|
|
|
|
|
if not map_tile_xy_ranges:
|
|
# This might happen if the BBox is very small or outside standard tile limits, mercantile.tiles might be empty.
|
|
logger.warning(f"No map tile ranges found for fetch BBox {map_fetch_geo_bbox} at zoom {zoom_to_use}. Showing placeholder.")
|
|
self.map_display_window_controller.show_map(None)
|
|
# MODIFIED: Send initial info to GUI even if map fails, with error status.
|
|
# WHY: GUI needs to update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details.
|
|
# MODIFIED: Include DMS fields with error state for consistency with GUI update logic.
|
|
self._send_initial_point_info_to_gui(center_latitude, center_longitude, "Map Tiles N/A", "Map Tiles N/A") # Added DMS fields handled in send function
|
|
return # Exit after showing placeholder/sending error
|
|
|
|
|
|
# MODIFIED: Pass the chosen zoom_to_use to stitch_map_image.
|
|
stitched_pil = self.map_tile_fetch_manager.stitch_map_image(
|
|
zoom_to_use, map_tile_xy_ranges[0], map_tile_xy_ranges[1]
|
|
)
|
|
if not stitched_pil:
|
|
logger.error("Failed to stitch map image.")
|
|
self.map_display_window_controller.show_map(None)
|
|
# MODIFIED: Send initial info to GUI even if stitch fails.
|
|
# WHY: GUI needs to update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details.
|
|
# MODIFIED: Include DMS fields with error state for consistency with GUI update logic.
|
|
self._send_initial_point_info_to_gui(center_latitude, center_longitude, "Map Stitch Failed", "Map Stitch Failed") # Added DMS fields handled in send function
|
|
return # Exit after showing placeholder/sending error
|
|
|
|
|
|
self._current_stitched_map_pil = stitched_pil
|
|
# MODIFIED: Store the *actual* geographic bounds covered by the stitched tiles.
|
|
# WHY: Needed for pixel-to-geo conversions and calculating the displayed area size.
|
|
# HOW: Get bounds from map_tile_fetch_manager after stitching.
|
|
# MODIFIED: Pass the zoom level *actually used* for stitching (zoom_to_use) to get_bounds_for_tile_range.
|
|
# WHY: The bounds calculated must correspond to the tiles that were actually stitched.
|
|
# HOW: Replaced `effective_zoom` with `zoom_to_use`.
|
|
self._current_map_geo_bounds_deg = self.map_tile_fetch_manager._get_bounds_for_tile_range(
|
|
zoom_to_use, map_tile_xy_ranges
|
|
)
|
|
# MODIFIED: Store the zoom level *actually used* for stitching (zoom_to_use).
|
|
# WHY: Consistency in context.
|
|
# HOW: Assigned `zoom_to_use` to _current_map_render_zoom.
|
|
self._current_map_render_zoom = zoom_to_use
|
|
self._current_stitched_map_pixel_shape = (stitched_pil.height, stitched_pil.width)
|
|
|
|
# MODIFIED: 5. Draw DEM tile boundary if DEM data is available and the map was stitched successfully.
|
|
# WHY: To indicate the area for which DEM data is available.
|
|
# HOW: Check if _dem_tile_geo_bbox_for_current_map is available and call the new drawing function.
|
|
map_image_to_display = stitched_pil.copy()
|
|
# Only draw DEM boundary if we have its bbox stored (meaning HGT was available for the initial point)
|
|
# The DEM boundary drawing logic in _draw_dem_tile_boundary_on_map handles conversion to pixels
|
|
# on the *stitched image*.
|
|
if self._dem_tile_geo_bbox_for_current_map:
|
|
logger.debug("Drawing DEM tile boundary on initial map display.")
|
|
map_image_to_display = self._draw_dem_tile_boundary_on_map(map_image_to_display, self._dem_tile_geo_bbox_for_current_map)
|
|
|
|
|
|
# MODIFIED: 6. Draw the initial point marker on the prepared map image.
|
|
# WHY: Requirement to show the initial point on the map.
|
|
# HOW: Call _draw_point_marker_on_map with the initial coordinates.
|
|
map_image_to_display = self._draw_point_marker_on_map(
|
|
map_image_to_display, center_latitude, center_longitude
|
|
)
|
|
|
|
# Display the final prepared image (scaling is handled by MapDisplayWindow.show_map)
|
|
self.map_display_window_controller.show_map(map_image_to_display)
|
|
|
|
# MODIFIED: 7. Get elevation for the initial point and send info to GUI.
|
|
# WHY: Requirement to show initial point info in the GUI panel.
|
|
# HOW: Fetch elevation and call _send_initial_point_info_to_gui.
|
|
# Re-fetch elevation to be consistent with what's shown in the GUI panel,
|
|
# although it was already fetched in elevation_gui.
|
|
initial_elev = self.elevation_manager.get_elevation(center_latitude, center_longitude)
|
|
initial_elev_str = "Unavailable" if initial_elev is None else ("NoData" if math.isnan(initial_elev) else f"{initial_elev:.2f} m")
|
|
# MODIFIED: Corrected log format to use center_longitude.
|
|
# WHY: Fix NameError.
|
|
# HOW: Replaced 'longitude' with 'center_longitude'.
|
|
logger.info(f"Initial elevation at point ({center_latitude:.5f},{center_longitude:.5f}) is: {initial_elev_str}")
|
|
|
|
|
|
# Calculate and send map area size
|
|
map_area_size_str = "N/A"
|
|
if self._current_map_geo_bounds_deg:
|
|
# MODIFIED: Check PyProj availability before calculating size.
|
|
# WHY: calculate_geographic_bbox_size_km requires PyProj.
|
|
# HOW: Added check.
|
|
if PYPROJ_AVAILABLE: # type: ignore
|
|
size_km = calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg)
|
|
if size_km:
|
|
width_km, height_km = size_km
|
|
map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H"
|
|
else:
|
|
map_area_size_str = "Size Calc Failed"
|
|
logger.warning("calculate_geographic_bbox_size_km returned None for current map bounds.")
|
|
else:
|
|
map_area_size_str = "PyProj N/A (Size Unknown)"
|
|
logger.warning("PyProj not available, cannot calculate map area size.")
|
|
|
|
|
|
# MODIFIED: Send initial point info to GUI, including float coordinates. DMS strings are handled in the send function.
|
|
# WHY: Consistent with GUI update logic expecting float coords.
|
|
# HOW: Pass center_latitude and center_longitude.
|
|
self._send_initial_point_info_to_gui(
|
|
center_latitude, center_longitude, initial_elev_str, map_area_size_str
|
|
)
|
|
|
|
|
|
except MapCalculationError as e_calc_map_pt:
|
|
logger.error(f"Map calculation error for point display: {e_calc_map_pt}")
|
|
if self.map_display_window_controller: self.map_display_window_controller.show_map(None)
|
|
# MODIFIED: Send error info to GUI queue on calculation failure.
|
|
# WHY: GUI needs to update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details.
|
|
# MODIFIED: Include original coords in error message if available.
|
|
self._send_initial_point_info_to_gui(center_latitude, center_longitude, f"Map Calc Error: {e_calc_map_pt}", "Map Calc Error") # DMS handled in send function
|
|
except Exception as e_disp_map_pt_fatal:
|
|
logger.exception(f"Unexpected error displaying map for point: {e_disp_map_pt_fatal}")
|
|
if self.map_display_window_controller: self.map_display_window_controller.show_map(None)
|
|
# MODIFIED: Send error info to GUI queue on unexpected fatal error.
|
|
# WHY: GUI needs to update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details.
|
|
# MODIFIED: Include original coords in error message if available.
|
|
self._send_initial_point_info_to_gui(center_latitude, center_longitude, f"Fatal Map Error: {type(e_disp_map_pt_fatal).__name__}", "Fatal Error") # DMS handled in send function
|
|
|
|
|
|
def display_map_for_area(
|
|
self,
|
|
area_geo_bbox: Tuple[float, float, float, float], # west, south, east, north
|
|
target_map_zoom: Optional[int] = None # This parameter is now effectively ignored for area view sizing
|
|
) -> None:
|
|
"""
|
|
Displays a map for a geographic area, applying the current display scale.
|
|
Calculates the zoom level dynamically to fit the requested area into a target pixel size.
|
|
"""
|
|
if not self.map_tile_fetch_manager or not self.map_display_window_controller or not self.map_service_provider or not self.elevation_manager: # Added check for map_service_provider and elevation_manager
|
|
logger.error("Map components not ready for display_map_for_area.")
|
|
# MODIFIED: Send error info to GUI queue if components aren't ready.
|
|
# WHY: GUI needs to update even if map isn't displayed.
|
|
# HOW: Put an error message into the queue.
|
|
# MODIFIED: Include DMS fields with error state for consistency with GUI update logic.
|
|
error_payload = {"type": "map_info_update", "latitude": None, "longitude": None,
|
|
"latitude_dms_str": "Error", "longitude_dms_str": "Error", # Added DMS fields
|
|
"elevation_str": "Map Error", "map_area_size_str": "Error: Components N/A"}
|
|
try: self.gui_com_queue.put(error_payload)
|
|
except Exception as e_put_err: logger.error(f"Failed to put error payload to queue before exit: {e_put_err}")
|
|
if self.map_display_window_controller: self.map_display_window_controller.show_map(None) # Show placeholder
|
|
return
|
|
|
|
# MODIFIED: Store the requested area bbox and clear point view state.
|
|
# WHY: Needed to redraw the requested area boundary (blue) on clicks. Clear point state for cleanliness.
|
|
# HOW: Set _current_requested_area_geo_bbox and _dem_tile_geo_bbox_for_current_map to None.
|
|
self._current_requested_area_geo_bbox = area_geo_bbox # Store the original requested area
|
|
self._dem_tile_geo_bbox_for_current_map = None
|
|
self._dem_tiles_info_for_current_map = [] # Initialize list for this view
|
|
|
|
|
|
logger.info(
|
|
f"Requesting map display for area: BBox {area_geo_bbox}, "
|
|
f"Target Pixel Size: {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}x{TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}, "
|
|
f"CurrentDisplayScale: {self.current_display_scale_factor:.2f}"
|
|
)
|
|
|
|
|
|
combined_dem_geo_bbox: Optional[Tuple[float, float, float, float]] = None
|
|
calculated_zoom: Optional[int] = None
|
|
zoom_calculation_successful = False
|
|
map_area_size_km: Optional[Tuple[float, float]] = None
|
|
|
|
# MODIFIED: Initialize map_tile_xy_ranges to None before the try block.
|
|
# WHY: To ensure the variable is defined even if an exception occurs before its assignment.
|
|
# HOW: Added the initialization here.
|
|
map_tile_xy_ranges = None
|
|
|
|
try:
|
|
# --- Determine the full geographic extent of all relevant DEM tiles ---
|
|
logger.debug("Getting DEM tile info for the REQUESTED area...")
|
|
# Pass the bounds of the *REQUESTED* area to find relevant DEM tiles
|
|
all_relevant_dem_tiles_info = self.elevation_manager.get_area_tile_info(
|
|
area_geo_bbox[1], # min_lat (south)
|
|
area_geo_bbox[0], # min_lon (west)
|
|
area_geo_bbox[3], # max_lat (north)
|
|
area_geo_bbox[2] # max_lon (east)
|
|
)
|
|
# Store only the info for tiles where HGT data is available that fall within the *requested area*
|
|
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.")
|
|
|
|
if not dem_tiles_info_in_requested_area:
|
|
logger.warning("No DEM tiles with HGT data found in the requested area. Cannot display relevant DEM context.")
|
|
# Decide fallback: Display requested area with map tiles, or show placeholder?
|
|
# Let's show a placeholder map for the requested area, indicating no DEM data found.
|
|
logger.warning(f"No DEM tiles with HGT data found in the requested area {area_geo_bbox}. Showing placeholder.")
|
|
self.map_display_window_controller.show_map(None)
|
|
# Send info to GUI with status
|
|
self._send_initial_point_info_to_gui(None, None, "No DEM Data in Area", "Area: No DEM Data") # DMS handled in send function
|
|
# We still set state variables to reflect no DEM tiles were found for this view.
|
|
self._dem_tiles_info_for_current_map = []
|
|
self._current_map_geo_bounds_deg = None # No stitched map
|
|
self._current_map_render_zoom = None
|
|
self._current_stitched_map_pixel_shape = (0, 0)
|
|
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 = dem_tiles_info_in_requested_area
|
|
|
|
# Calculate the combined geographic bounding box of ALL these relevant DEM tiles
|
|
min_lon_combined, min_lat_combined, max_lon_combined, max_lat_combined = 180.0, 90.0, -180.0, -90.0
|
|
first_tile_bounds = get_hgt_tile_geographic_bounds(
|
|
dem_tiles_info_in_requested_area[0]["latitude_coord"],
|
|
dem_tiles_info_in_requested_area[0]["longitude_coord"]
|
|
)
|
|
min_lon_combined, min_lat_combined, max_lon_combined, max_lat_combined = first_tile_bounds # Initialize with first tile
|
|
|
|
for tile_info in dem_tiles_info_in_requested_area[1:]:
|
|
tile_bounds = get_hgt_tile_geographic_bounds(
|
|
tile_info["latitude_coord"],
|
|
tile_info["longitude_coord"]
|
|
)
|
|
min_lon_combined = min(min_lon_combined, tile_bounds[0])
|
|
min_lat_combined = min(min_lat_combined, tile_bounds[1])
|
|
max_lon_combined = max(max_lon_combined, tile_bounds[2])
|
|
max_lat_combined = max(max_lat_combined, tile_bounds[3])
|
|
|
|
combined_dem_geo_bbox = (min_lon_combined, min_lat_combined, max_lon_combined, max_lat_combined)
|
|
logger.debug(f"Combined DEM tiles BBox for requested area: {combined_dem_geo_bbox}")
|
|
|
|
|
|
# --- Calculate Zoom to fit the Combined DEM BBox into target pixel size ---
|
|
calculated_zoom = None
|
|
zoom_calculation_successful = False
|
|
|
|
# MODIFIED: Check PyProj availability before calculating size.
|
|
# WHY: calculate_geographic_bbox_size_km requires PyProj.
|
|
# HOW: Added check.
|
|
if PYPROJ_AVAILABLE: # type: ignore
|
|
# Use the COMBINED DEM BBox to calculate geographic size
|
|
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
|
|
# Use the center latitude of the COMBINED DEM BBox for zoom calculation accuracy
|
|
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, # Target pixel height (reuse constant)
|
|
self.map_service_provider.tile_size # Tile size from the map service
|
|
)
|
|
if calculated_zoom is not None:
|
|
logger.info(f"Calculated zoom level {calculated_zoom} to fit COMBINED DEM BBox height ({map_bbox_height_meters:.2f}m) into {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}px.")
|
|
zoom_calculation_successful = True
|
|
else:
|
|
logger.warning("Could not calculate appropriate zoom level for combined DEM area. Falling back to default zoom.")
|
|
|
|
else:
|
|
logger.warning("Could not calculate geographic size of combined DEM BBox. Falling back to default zoom.")
|
|
else:
|
|
logger.warning("PyProj not available. Cannot calculate geographic size for zoom calculation. Falling back to default zoom.")
|
|
|
|
|
|
# MODIFIED: Determine the final zoom level to use.
|
|
# WHY: Use the calculated zoom if successful, otherwise use the default zoom as a fallback.
|
|
# HOW: Check zoom_calculation_successful.
|
|
zoom_to_use = calculated_zoom if zoom_calculation_successful else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL
|
|
# 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}. Clamping.")
|
|
zoom_to_use = self.map_service_provider.max_zoom
|
|
logger.debug(f"Using final zoom level {zoom_to_use} for tile fetching and stitching for area.")
|
|
|
|
|
|
# --- Fetch and Stitch Map Tiles for the Combined DEM Area ---
|
|
# Use the COMBINED DEM BBox and the calculated zoom to get map tile ranges
|
|
map_tile_xy_ranges = get_tile_ranges_for_bbox(combined_dem_geo_bbox, zoom_to_use)
|
|
|
|
if not map_tile_xy_ranges:
|
|
logger.warning(f"No map tile ranges found for combined DEM BBox {combined_dem_geo_bbox} at zoom {zoom_to_use}. Showing placeholder.")
|
|
self.map_display_window_controller.show_map(None)
|
|
# MODIFIED: Send error info to GUI queue even if map fails.
|
|
# WHY: GUI needs to update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point).
|
|
# MODIFIED: Include DMS fields with error state for consistency with GUI update logic.
|
|
self._send_initial_point_info_to_gui(None, None, "Map Tiles N/A", "Map Tiles N/A") # Added 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 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 area display.")
|
|
self.map_display_window_controller.show_map(None)
|
|
# MODIFIED: Send error info to GUI queue even if stitch fails.
|
|
# WHY: GUI needs to update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point).
|
|
# MODIFIED: Include DMS fields with error state for consistency with GUI update logic.
|
|
self._send_initial_point_info_to_gui(None, None, "Map Stitch Failed", "Map Stitch Failed") # Added 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 after showing placeholder/sending error
|
|
|
|
|
|
self._current_stitched_map_pil = stitched_pil
|
|
# MODIFIED: Store the *actual* geographic bounds covered by the stitched tiles for the area view.
|
|
# WHY: Needed for pixel-to-geo conversions and calculating the displayed area size.
|
|
# HOW: Get bounds from map_tile_fetch_manager after stitching.
|
|
# MODIFIED: Pass the zoom level *actually used* for stitching (zoom_to_use) to get_bounds_for_tile_range.
|
|
# WHY: The bounds calculated must correspond to the tiles that were actually stitched.
|
|
# HOW: Replaced `effective_zoom` with `zoom_to_use`.
|
|
self._current_map_geo_bounds_deg = self.map_tile_fetch_manager._get_bounds_for_tile_range(
|
|
zoom_to_use, map_tile_xy_ranges
|
|
)
|
|
# MODIFIED: Store the zoom level *actually used* for stitching (zoom_to_use).
|
|
# WHY: Consistency in context.
|
|
# HOW: Assigned `zoom_to_use` to _current_map_render_zoom.
|
|
self._current_map_render_zoom = zoom_to_use
|
|
self._current_stitched_map_pixel_shape = (stitched_pil.height, stitched_pil.width)
|
|
|
|
# --- Drawing Overlays for Area View (Initial Display) ---
|
|
map_image_with_overlays = stitched_pil.copy()
|
|
|
|
# MODIFIED: Draw the *requested* area bounding box (blue).
|
|
# WHY: To visualize the specific area the user requested.
|
|
# HOW: Call _draw_area_bounding_box_on_map with the original requested area bbox.
|
|
if self._current_requested_area_geo_bbox:
|
|
logger.debug("Drawing requested area boundary (blue) on initial map display.")
|
|
map_image_with_overlays = self._draw_area_bounding_box_on_map(
|
|
map_image_with_overlays,
|
|
self._current_requested_area_geo_bbox,
|
|
color=AREA_BOUNDARY_COLOR,
|
|
thickness=AREA_BOUNDARY_THICKNESS_PX
|
|
)
|
|
|
|
# MODIFIED: Draw the DEM tile boundaries and labels (red) for all relevant tiles.
|
|
# WHY: To visualize which DEM data is available in the area.
|
|
# HOW: Call _draw_dem_tile_boundaries_with_labels_on_map using the stored tile info.
|
|
if self._dem_tiles_info_for_current_map:
|
|
logger.debug(f"Drawing {len(self._dem_tiles_info_for_current_map)} DEM tile boundaries and labels (red).")
|
|
map_image_with_overlays = self._draw_dem_tile_boundaries_with_labels_on_map(
|
|
map_image_with_overlays,
|
|
self._dem_tiles_info_for_current_map
|
|
)
|
|
else:
|
|
logger.info("No DEM tiles with HGT data found in the area for boundary drawing.")
|
|
|
|
|
|
# Display the final prepared image (scaling is handled by MapDisplayWindow.show_map)
|
|
self.map_display_window_controller.show_map(map_image_with_overlays)
|
|
self._last_user_click_pixel_coords_on_displayed_image = None
|
|
|
|
# MODIFIED: Calculate and send map area size for area view.
|
|
# WHY: The GUI needs the size of the displayed area.
|
|
# HOW: Calculate size and send message (using N/A for point info in this case).
|
|
map_area_size_str = "N/A"
|
|
if self._current_map_geo_bounds_deg:
|
|
# MODIFIED: Check PyProj availability before calculating size.
|
|
# WHY: calculate_geographic_bbox_size_km requires PyProj.
|
|
# HOW: Added check.
|
|
if PYPROJ_AVAILABLE: # type: ignore
|
|
# Calculate size of the *stitched* area's bounds (which might be slightly larger than requested)
|
|
size_km = calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg)
|
|
if size_km:
|
|
width_km, height_km = size_km
|
|
# MODIFIED: Include info that this is the size of the DEM area shown.
|
|
# WHY: Clarify what size is being reported in the GUI.
|
|
# HOW: Added "(DEM Area)" text.
|
|
map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H (DEM Area)"
|
|
else:
|
|
map_area_size_str = "Size Calc Failed"
|
|
logger.warning("calculate_geographic_bbox_size_km returned None for current map bounds.")
|
|
else:
|
|
map_area_size_str = "PyProj N/A (Size Unknown)"
|
|
logger.warning("PyProj not available, cannot calculate map area size.")
|
|
|
|
|
|
# MODIFIED: Send initial point info (N/A) to GUI, including DMS strings.
|
|
# WHY: Consistent message format for initial info and clicks.
|
|
# HOW: Use _send_initial_point_info_to_gui with None for point coords.
|
|
self._send_initial_point_info_to_gui(None, None, "N/A (Area View)", map_area_size_str) # DMS handled in send function
|
|
|
|
|
|
except MapCalculationError as e_calc_map_area:
|
|
logger.error(f"Map calculation error for area display: {e_calc_map_area}")
|
|
if self.map_display_window_controller: self.map_display_window_controller.show_map(None)
|
|
# MODIFIED: Send error info to GUI queue on calculation failure for area view.
|
|
# WHY: GUI needs to update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point).
|
|
# MODIFIED: Include DMS fields with error state for consistency with GUI update logic.
|
|
self._send_initial_point_info_to_gui(None, None, f"Map Calc Error: {e_calc_map_area}", "Map Calc Error") # Added DMS handled in send function
|
|
except Exception as e_disp_map_area_fatal:
|
|
logger.exception(f"Unexpected error displaying map for area: {e_disp_map_area_fatal}")
|
|
if self.map_display_window_controller: self.map_display_window_controller.show_map(None)
|
|
# MODIFIED: Send error info to GUI queue on unexpected fatal error for area view.
|
|
# WHY: GUI needs to update even if map isn't displayed.
|
|
# HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point).
|
|
# MODIFIED: Include DMS fields with error state for consistency with GUI update logic.
|
|
self._send_initial_point_info_to_gui(None, None, f"Fatal Map Error: {type(e_disp_map_area_fatal).__name__}", "Fatal Error") # Added DMS handled in send function
|
|
|
|
|
|
# MODIFIED: Added a dedicated helper function to send initial point/map info to the GUI.
|
|
# WHY: Centralizes the logic for sending the first message after map display.
|
|
# HOW: Created a new method that formats and puts the data into the queue.
|
|
# 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_initial_point_info_to_gui(
|
|
self,
|
|
latitude: Optional[float],
|
|
longitude: Optional[float],
|
|
elevation_str: str,
|
|
map_area_size_str: str
|
|
) -> None:
|
|
"""Sends initial point/map info (coords, elevation, map size) to the GUI queue."""
|
|
# 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_initial_info:
|
|
logger.exception(f"Error putting initial map info onto GUI queue: {e_queue_initial_info}")
|
|
|
|
|
|
def _can_perform_drawing_operations(self) -> bool:
|
|
"""Helper to check if necessary map context and libraries exist for drawing."""
|
|
# MODIFIED: Added check for ImageDraw which is needed for drawing shapes/lines on PIL images.
|
|
# WHY: ImageDraw is explicitly required for drawing bounding boxes/markers.
|
|
# HOW: Included ImageDraw in the check.
|
|
# MODIFIED: Include check for CV2/NumPy availability as well, as some drawing methods use OpenCV.
|
|
# WHY: Ensure all necessary libraries are present for drawing.
|
|
# HOW: Added check.
|
|
# MODIFIED: Added check for MERCANTILE_LIB_AVAILABLE_DISPLAY.
|
|
# WHY: Mercator conversions are needed for drawing.
|
|
# HOW: Added check.
|
|
return bool(
|
|
self._current_map_geo_bounds_deg is not None and # Check for None
|
|
self._current_map_render_zoom is not None and
|
|
self._current_stitched_map_pixel_shape is not None and # Added check for None
|
|
self.map_display_window_controller is not None and # Check for None
|
|
PIL_IMAGE_LIB_AVAILABLE and ImageDraw is not None and # Check PIL and ImageDraw
|
|
CV2_NUMPY_LIBS_AVAILABLE and # Check CV2 and NumPy
|
|
MERCANTILE_LIB_AVAILABLE_DISPLAY # Mercantile needed for geo to pixel conversion
|
|
)
|
|
|
|
# MODIFIED: Added helper method to convert geo coordinates to pixel on the UN SCALED stitched image.
|
|
# WHY: Centralizes the geo-to-pixel logic for the unscaled image, used by multiple drawing methods.
|
|
# HOW: Created a new method encapsulating the conversion using mercantile and the stored map context.
|
|
# MODIFIED: Added check for _can_perform_drawing_operations at the start.
|
|
# WHY: To ensure necessary context is available before attempting conversion.
|
|
# HOW: Added an initial check.
|
|
def _geo_to_pixel_on_unscaled_map(self, latitude_deg: float, longitude_deg: float) -> Optional[Tuple[int, int]]:
|
|
"""Converts geographic coordinates to pixel coordinates on the UN SCALED stitched map image."""
|
|
if not self._can_perform_drawing_operations() or self._current_map_geo_bounds_deg is None or self._current_stitched_map_pixel_shape is None:
|
|
logger.warning("Map context incomplete for geo_to_pixel_on_unscaled_map conversion.")
|
|
return None
|
|
|
|
unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape
|
|
map_west_lon, map_south_lat, map_east_lon, map_north_lat = self._current_map_geo_bounds_deg
|
|
|
|
if unscaled_width <= 0 or unscaled_height <= 0:
|
|
logger.warning("Unscaled map dimensions are zero, cannot convert geo to pixel.")
|
|
return None
|
|
|
|
try:
|
|
import mercantile as local_mercantile # Use mercantile directly here
|
|
if local_mercantile is None: raise ImportError("mercantile not available locally.")
|
|
|
|
|
|
map_ul_merc_x, map_ul_merc_y = local_mercantile.xy(map_west_lon, map_north_lat) # type: ignore
|
|
map_lr_merc_x, map_lr_merc_y = local_mercantile.xy(map_east_lon, map_south_lat) # type: ignore
|
|
|
|
total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x)
|
|
total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y)
|
|
|
|
if total_map_width_merc <= 0 or total_map_height_merc <= 0:
|
|
logger.warning("Map Mercator extent is zero, cannot convert geo to pixel on unscaled map.")
|
|
return None
|
|
|
|
target_merc_x, target_merc_y = local_mercantile.xy(longitude_deg, latitude_deg) # type: ignore
|
|
|
|
# Relative position within the *unscaled* map's Mercator extent
|
|
relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc if total_map_width_merc > 0 else 0.0
|
|
relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Y Mercator increases North, pixel Y downwards
|
|
|
|
# Convert to pixel coordinates on the *unscaled* image
|
|
pixel_x_on_unscaled = int(round(relative_merc_x_in_map * unscaled_width))
|
|
pixel_y_on_unscaled = int(round(relative_merc_y_in_map * unscaled_height))
|
|
|
|
# Clamp to the boundaries of the unscaled image (allow slight overflow for boundary drawing)
|
|
# px_clamped = max(-thickness, min(pixel_x_on_unscaled, unscaled_width + thickness)) # Clamping with thickness is for drawing lines
|
|
# py_clamped = max(-thickness, min(pixel_y_on_unscaled, unscaled_height + thickness))
|
|
px_clamped = max(0, min(pixel_x_on_unscaled, unscaled_width -1)) # Simple clamp for point locations
|
|
py_clamped = max(0, min(pixel_y_on_unscaled, unscaled_height -1))
|
|
|
|
return (px_clamped, py_clamped)
|
|
|
|
except Exception as e_geo_to_px_unscaled:
|
|
logger.exception(f"Error during geo_to_pixel_on_unscaled_map conversion for geo ({latitude_deg:.5f},{longitude_deg:.5f}): {e_geo_to_px_unscaled}")
|
|
return None
|
|
|
|
|
|
def _draw_point_marker_on_map(
|
|
self,
|
|
pil_image_to_draw_on: ImageType,
|
|
latitude_deg: float,
|
|
longitude_deg: float
|
|
) -> ImageType:
|
|
"""Draws a point marker on the (unscaled) stitched PIL map image."""
|
|
# MODIFIED: Ensure drawing is only attempted if the original stitched image is available and context is ready.
|
|
# WHY: Avoids errors if the map wasn't successfully loaded or context is missing.
|
|
# HOW: Added check for self._current_stitched_map_pil and _can_perform_drawing_operations.
|
|
if not self._can_perform_drawing_operations() or self._current_stitched_map_pil is None:
|
|
logger.warning("Cannot draw point marker: drawing context/libs/controller or original image not ready.")
|
|
return pil_image_to_draw_on
|
|
|
|
# MODIFIED: Use the new helper method to convert geo to pixel on the UN SCALED map image.
|
|
# WHY: Centralizes the geo-to-pixel logic for the unscaled image.
|
|
# HOW: Replaced direct mercantile/pixel calculation with a call to _geo_to_pixel_on_unscaled_map.
|
|
pixel_coords_on_unscaled = self._geo_to_pixel_on_unscaled_map(latitude_deg, longitude_deg)
|
|
|
|
if pixel_coords_on_unscaled:
|
|
px_clamped, py_clamped = pixel_coords_on_unscaled
|
|
|
|
logger.debug(f"Drawing point marker at unscaled pixel ({px_clamped},{py_clamped}) for geo ({latitude_deg:.5f},{longitude_deg:.5f})")
|
|
|
|
# MODIFIED: Check for CV2 and NumPy availability before using them.
|
|
# WHY: Ensure dependencies are present for drawing with OpenCV.
|
|
# HOW: Added check.
|
|
if cv2 and np:
|
|
try:
|
|
# Convert PIL image to OpenCV format (BGR) for drawing
|
|
# Ensure image is in a mode OpenCV can handle (BGR)
|
|
if pil_image_to_draw_on.mode != 'RGB':
|
|
# Convert to RGB first if not already, then to BGR
|
|
map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on.convert('RGB')), cv2.COLOR_RGB2BGR) # type: ignore
|
|
else:
|
|
map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on), cv2.COLOR_RGB2BGR) # type: ignore
|
|
|
|
# Draw a cross marker at the calculated unscaled pixel coordinates
|
|
# Note: Marker color (0,0,255) is BGR for red
|
|
cv2.drawMarker(map_cv_bgr, (px_clamped, py_clamped), (0,0,255), cv2.MARKER_CROSS, markerSize=15, thickness=2) # type: ignore
|
|
# Convert back to PIL format (RGB)
|
|
return Image.fromarray(cv2.cvtColor(map_cv_bgr, cv2.COLOR_BGR2RGB)) # type: ignore
|
|
except Exception as e_draw_click_cv:
|
|
logger.exception(f"Error drawing user click marker with OpenCV: {e_draw_click_cv}")
|
|
return pil_image_to_draw_on # Return original image on error
|
|
else:
|
|
logger.warning("CV2 or NumPy not available, cannot draw point marker using OpenCV.")
|
|
return pil_image_to_draw_on # Return original image if CV2/NumPy are somehow missing here
|
|
else:
|
|
logger.warning(f"Geo-to-pixel conversion failed for ({latitude_deg:.5f},{longitude_deg:.5f}), cannot draw point marker.")
|
|
return pil_image_to_draw_on # Return original image
|
|
|
|
|
|
def _draw_area_bounding_box_on_map(
|
|
self,
|
|
pil_image_to_draw_on: ImageType,
|
|
area_geo_bbox: Tuple[float, float, float, float], # west, south, east, north
|
|
color: str = "blue", # Allow specifying color
|
|
thickness: int = 2 # Allow specifying thickness
|
|
) -> ImageType:
|
|
"""Draws an area bounding box on the (unscaled) stitched PIL map image."""
|
|
# MODIFIED: Ensure drawing is only attempted if the original stitched image is available and context is ready.
|
|
# WHY: Avoids errors if the map wasn't successfully loaded or context is missing.
|
|
# HOW: Added check for self._current_stitched_map_pil and _can_perform_drawing_operations.
|
|
if not self._can_perform_drawing_operations() or self._current_stitched_map_pil is None:
|
|
logger.warning("Cannot draw area BBox: drawing context/libs/controller or original image not ready.")
|
|
return pil_image_to_draw_on
|
|
|
|
# MODIFIED: Use the new helper method (_geo_to_pixel_on_unscaled_map) for consistency in converting geo to pixel.
|
|
# NOTE: This helper clamps to the unscaled image edge. For drawing lines that might slightly exceed the image,
|
|
# we need different clamping. Recalculate pixel coords suitable for line drawing *after* the helper call,
|
|
# or modify the helper to take a padding argument. For now, let's recalculate for line drawing.
|
|
west, south, east, north = area_geo_bbox
|
|
# Corners of the box in geographic degrees
|
|
corners_geo = [(west, north), (east, north), (east, south), (west, south)]
|
|
pixel_corners: List[Tuple[int,int]] = []
|
|
|
|
try:
|
|
# Convert all corners to pixel coordinates on the *unscaled* image, suitable for drawing lines
|
|
if self._current_map_geo_bounds_deg and self._current_stitched_map_pixel_shape:
|
|
unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape
|
|
map_west_lon, map_south_lat, map_east_lon, map_north_lat = self._current_map_geo_bounds_deg
|
|
|
|
map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat) # type: ignore
|
|
map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat) # type: ignore
|
|
|
|
total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x)
|
|
total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y)
|
|
|
|
if total_map_width_merc <= 0 or total_map_height_merc <= 0:
|
|
raise ValueError("Map Mercator extent is zero for drawing lines.")
|
|
|
|
import mercantile as local_mercantile # Use mercantile directly here
|
|
if local_mercantile is None: raise ImportError("mercantile not available locally.")
|
|
|
|
|
|
for lon, lat in corners_geo:
|
|
target_merc_x, target_merc_y = local_mercantile.xy(lon, lat) # type: ignore
|
|
|
|
relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc if total_map_width_merc > 0 else 0.0
|
|
relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Y Mercator increases North, pixel Y downwards
|
|
|
|
pixel_x_on_unscaled_raw = int(round(relative_merc_x_in_map * unscaled_width))
|
|
pixel_y_on_unscaled_raw = int(round(relative_merc_y_in_map * unscaled_height))
|
|
|
|
# Clamp with padding for line drawing
|
|
px_clamped_for_line = max(-thickness, min(pixel_x_on_unscaled_raw, unscaled_width + thickness))
|
|
py_clamped_for_line = max(-thickness, min(pixel_y_on_unscaled_raw, unscaled_height + thickness))
|
|
|
|
pixel_corners.append((px_clamped_for_line, py_clamped_for_line))
|
|
|
|
else:
|
|
logger.warning("Unscaled map dimensions or geo bounds not available, cannot clamp pixel corners for drawing.")
|
|
return pil_image_to_draw_on # Cannot draw if dimensions are unknown
|
|
|
|
|
|
except Exception as e_geo_to_px_bbox:
|
|
logger.exception(f"Error during geo_to_pixel conversion for BBox drawing: {e_geo_to_px_bbox}")
|
|
return pil_image_to_draw_on # Return original image on error
|
|
|
|
# MODIFIED: Check ImageDraw availability before using it.
|
|
# WHY: Ensure dependency is present.
|
|
# HOW: Added check.
|
|
if len(pixel_corners) == 4 and ImageDraw is not None:
|
|
logger.debug(f"Drawing area BBox with unscaled pixel corners: {pixel_corners}")
|
|
# Ensure image is in a mode that supports drawing (RGB or RGBA)
|
|
# Converting here ensures drawing is possible if the input image was, e.g., L mode
|
|
if pil_image_to_draw_on.mode not in ("RGB", "RGBA"):
|
|
pil_image_to_draw_on = pil_image_to_draw_on.convert("RGBA") # Prefer RGBA for drawing
|
|
draw = ImageDraw.Draw(pil_image_to_draw_on)
|
|
|
|
# Draw lines connecting the corner points
|
|
# Draw a closed polygon outline
|
|
# MODIFIED: Use draw.line to draw segments, provides more control and handles points order better.
|
|
# WHY: Ensures the rectangle is drawn correctly even if points are not in perfect order for polygon outline.
|
|
# HOW: Changed from draw.polygon with outline to four calls to draw.line.
|
|
try:
|
|
# Draw lines explicitly connecting the corners in sequence
|
|
draw.line([pixel_corners[0], pixel_corners[1]], fill=color, width=thickness) # Top edge
|
|
draw.line([pixel_corners[1], pixel_corners[2]], fill=color, width=thickness) # Right edge
|
|
draw.line([pixel_corners[2], pixel_corners[3]], fill=color, width=thickness) # Bottom edge
|
|
draw.line([pixel_corners[3], pixel_corners[0]], fill=color, width=thickness) # Left edge
|
|
except Exception as e_draw_lines:
|
|
logger.exception(f"Error drawing BBox lines: {e_draw_lines}")
|
|
|
|
|
|
return pil_image_to_draw_on
|
|
else:
|
|
logger.warning("Not enough pixel corners calculated for BBox, or ImageDraw missing.")
|
|
return pil_image_to_draw_on # Return original image
|
|
|
|
# else: No need for this else, handled by initial _can_perform_drawing_operations check
|
|
# logger.warning("Current map context incomplete, cannot draw area BBox.")
|
|
# return pil_image_to_draw_on # Return original image
|
|
|
|
|
|
# MODIFIED: Added a specific drawing function for the DEM tile boundary.
|
|
# WHY: Separates the logic and allows specific styling (e.g., red color, different thickness).
|
|
# HOW: Calls _draw_area_bounding_box_on_map with predefined style.
|
|
def _draw_dem_tile_boundary_on_map(
|
|
self,
|
|
pil_image_to_draw_on: ImageType,
|
|
dem_tile_geo_bbox: Tuple[float, float, float, float]
|
|
) -> ImageType:
|
|
"""Draws a boundary box for the DEM tile on the map image."""
|
|
# MODIFIED: Ensure drawing is only attempted if the original stitched image is available and context is ready.
|
|
# WHY: Avoids errors if the map wasn't successfully loaded or context is missing.
|
|
# HOW: Added check for self._current_stitched_map_pil and _can_perform_drawing_operations.
|
|
if not self._can_perform_drawing_operations() or self._current_stitched_map_pil is None:
|
|
logger.warning("Cannot draw DEM tile boundary: drawing context/libs/controller or original image not ready.")
|
|
return pil_image_to_draw_on
|
|
|
|
logger.debug(f"Drawing DEM tile boundary on map for bbox: {dem_tile_geo_bbox}")
|
|
# Use the generic area drawing function with specific style
|
|
return self._draw_area_bounding_box_on_map(
|
|
pil_image_to_draw_on,
|
|
dem_tile_geo_bbox,
|
|
color=DEM_BOUNDARY_COLOR,
|
|
thickness=DEM_BOUNDARY_THICKNESS_PX
|
|
)
|
|
|
|
# MODIFIED: Added a new drawing function to draw boundaries and labels for multiple DEM tiles.
|
|
# WHY: Implement Feature 1: visualize multiple DEM tiles and their names for area view.
|
|
# HOW: Iterates through the list of tile info, draws each boundary and label.
|
|
def _draw_dem_tile_boundaries_with_labels_on_map(
|
|
self,
|
|
pil_image_to_draw_on: ImageType,
|
|
dem_tiles_info_list: List[Dict]
|
|
) -> ImageType:
|
|
"""
|
|
Draws boundaries and names for a list of DEM tiles on the (unscaled) stitched PIL map.
|
|
Draws only for tiles marked as available HGT.
|
|
"""
|
|
# MODIFIED: Enhanced drawing context check.
|
|
# WHY: Ensure all necessary conditions are met before attempting drawing.
|
|
# HOW: Added check for PIL_IMAGE_LIB_AVAILABLE and ImageDraw is not None explicitly here as well.
|
|
if not self._can_perform_drawing_operations() or self._current_stitched_map_pil is None or not PIL_IMAGE_LIB_AVAILABLE or ImageDraw is None:
|
|
logger.warning("Cannot draw multiple DEM tile boundaries/labels: drawing context/libs/controller or original image or Pillow/ImageDraw not ready.")
|
|
return pil_image_to_draw_on
|
|
|
|
if not dem_tiles_info_list:
|
|
logger.debug("No DEM tile info provided for drawing multiple boundaries.")
|
|
return pil_image_to_draw_on # Nothing to draw
|
|
|
|
logger.debug(f"Drawing {len(dem_tiles_info_list)} DEM tile boundaries and labels.")
|
|
|
|
# Ensure image is in a mode that supports drawing (RGB or RGBA)
|
|
# Converting here ensures drawing is possible if the input image was, e.g., L mode
|
|
if pil_image_to_draw_on.mode not in ("RGB", "RGBA"):
|
|
pil_image_to_draw_on = pil_image_to_draw_on.convert("RGBA")
|
|
draw = ImageDraw.Draw(pil_image_to_draw_on)
|
|
|
|
# Attempt to use a font loaded in image_processor for consistency
|
|
# Fallback to default PIL font if import failed or font wasn't loaded
|
|
font_to_use = _DEFAULT_FONT_FOR_LABELS
|
|
if font_to_use is None:
|
|
if PIL_IMAGE_LIB_AVAILABLE: # Ensure PIL is available for load_default
|
|
font_to_use = ImageDraw.ImageFont.load_default() # type: ignore
|
|
logger.debug("Using default PIL font for DEM tile labels.")
|
|
else:
|
|
logger.error("Pillow (PIL) not available, cannot even load default font for DEM tile labels.")
|
|
# Cannot draw text without a font source. Continue without text drawing.
|
|
pass # drawing boundaries might still be possible
|
|
|
|
# MODIFIED: Calculate font size based on current map zoom.
|
|
# WHY: To make labels more readable at different zoom levels.
|
|
# HOW: Use a simple linear scaling based on a base zoom and base font size.
|
|
current_map_zoom = self._current_map_render_zoom # Use the zoom level the map was rendered at
|
|
if current_map_zoom is None:
|
|
logger.warning("Current map zoom is None, cannot scale font. Using base size.")
|
|
current_map_zoom = DEM_TILE_LABEL_BASE_ZOOM # Default to base zoom for size calc
|
|
|
|
# Simple linear scaling: size increases by 1 for each zoom level above base zoom
|
|
# Example: at zoom 10, size 12; at zoom 11, size 13; at zoom 9, size 11.
|
|
# Ensure minimum sensible font size (e.g., 6)
|
|
scaled_font_size = max(6, DEM_TILE_LABEL_BASE_FONT_SIZE + (current_map_zoom - DEM_TILE_LABEL_BASE_ZOOM))
|
|
logger.debug(f"Calculated label font size {scaled_font_size} for zoom {current_map_zoom}.")
|
|
|
|
# Update the font instance with the calculated size (if using truetype font)
|
|
# If load_default is used, resizing is often not possible or behaves differently.
|
|
# Let's re-load the font with the scaled size if it's a truetype font.
|
|
# This requires knowing the path of the original font used by image_processor, which is tricky.
|
|
# Alternative: Store the font path and size calculation logic from image_processor here.
|
|
# Or, maybe simpler, if using load_default fallback, just use the default size.
|
|
# Let's assume if _DEFAULT_FONT_FOR_LABELS is not None, it's a truetype font we can resize.
|
|
if font_to_use and hasattr(font_to_use, 'font'): # Check if it looks like a truetype font object with a 'font' attribute
|
|
try:
|
|
# Get the original font object from Pillow's internal structure (might be implementation-dependent)
|
|
# Accessing internal `font` attribute is a heuristic for Pillow's FreeTypeFont
|
|
original_font_path = font_to_use.font.path # type: ignore
|
|
font_index = font_to_use.font.index # type: ignore # Handle TTC files
|
|
font_to_use = ImageDraw.ImageFont.truetype(original_font_path, scaled_font_size, index=font_index) # type: ignore
|
|
logger.debug(f"Resized truetype font to {scaled_font_size}.")
|
|
except Exception as e_resize_font:
|
|
logger.warning(f"Could not resize truetype font: {e_resize_font}. Using original size.")
|
|
# Keep the font_to_use as it was (original size)
|
|
|
|
|
|
for tile_info in dem_tiles_info_list:
|
|
# Draw only if HGT data is available for this tile
|
|
if not tile_info.get("hgt_available"):
|
|
logger.debug(f"Skipping drawing boundary/label for tile {tile_info.get('tile_base_name', '?')}: HGT not available.")
|
|
continue # Skip this tile if no HGT
|
|
|
|
lat_coord = tile_info.get("latitude_coord")
|
|
lon_coord = tile_info.get("longitude_coord")
|
|
tile_base_name = tile_info.get("tile_base_name")
|
|
|
|
if lat_coord is None or lon_coord is None or tile_base_name is None:
|
|
logger.warning(f"Skipping drawing for invalid tile info entry: {tile_info}")
|
|
continue
|
|
|
|
try:
|
|
# Get the precise geographic bounds for this HGT tile
|
|
tile_geo_bbox = get_hgt_tile_geographic_bounds(lat_coord, lon_coord)
|
|
west, south, east, north = tile_geo_bbox
|
|
|
|
# Corners of this specific tile's bbox in geographic degrees
|
|
tile_corners_geo = [(west, north), (east, north), (east, south), (west, south)]
|
|
tile_pixel_corners_on_unscaled: List[Tuple[int,int]] = []
|
|
|
|
# Convert tile corners to unscaled pixel coordinates, suitable for drawing lines.
|
|
# Recalculate pixel coords relative to the unscaled map using mercantile again for line drawing accuracy,
|
|
# and clamp with padding.
|
|
if self._current_map_geo_bounds_deg and self._current_stitched_map_pixel_shape:
|
|
unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape
|
|
map_west_lon_stitched, map_south_lat_stitched, map_east_lon_stitched, map_north_lat_stitched = self._current_map_geo_bounds_deg
|
|
|
|
map_ul_merc_x_stitched, map_ul_merc_y_stitched = mercantile.xy(map_west_lon_stitched, map_north_lat_stitched) # type: ignore
|
|
map_lr_merc_x_stitched, map_lr_merc_y_stitched = mercantile.xy(map_east_lon_stitched, map_south_lat_stitched) # type: ignore
|
|
|
|
total_map_width_merc = abs(map_lr_merc_x_stitched - map_ul_merc_x_stitched)
|
|
total_map_height_merc = abs(map_ul_merc_y_stitched - map_lr_merc_y_stitched)
|
|
|
|
if total_map_width_merc <= 0 or total_map_height_merc <= 0:
|
|
raise ValueError("Map Mercator extent is zero for drawing tile boundaries.")
|
|
|
|
import mercantile as local_mercantile # Use mercantile directly here
|
|
if local_mercantile is None: raise ImportError("mercantile not available locally.")
|
|
|
|
|
|
for lon, lat in tile_corners_geo:
|
|
target_merc_x, target_merc_y = local_mercantile.xy(lon, lat) # type: ignore
|
|
|
|
relative_merc_x_in_map = (target_merc_x - map_ul_merc_x_stitched) / total_map_width_merc if total_map_width_merc > 0 else 0.0
|
|
relative_merc_y_in_map = (map_ul_merc_y_stitched - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Y Mercator increases North, pixel Y downwards
|
|
|
|
pixel_x_on_unscaled_raw = int(round(relative_merc_x_in_map * unscaled_width))
|
|
pixel_y_on_unscaled_raw = int(round(relative_merc_y_in_map * unscaled_height))
|
|
|
|
# Clamp with padding for line drawing
|
|
px_clamped_for_line = max(-DEM_BOUNDARY_THICKNESS_PX, min(pixel_x_on_unscaled_raw, unscaled_width + DEM_BOUNDARY_THICKNESS_PX))
|
|
py_clamped_for_line = max(-DEM_BOUNDARY_THICKNESS_PX, min(pixel_y_on_unscaled_raw, unscaled_height + DEM_BOUNDARY_THICKNESS_PX))
|
|
|
|
tile_pixel_corners_on_unscaled.append((px_clamped_for_line, py_clamped_for_line))
|
|
|
|
else:
|
|
logger.warning(f"Unscaled map dimensions or geo bounds not available, cannot clamp pixel corners for tile ({lat_coord},{lon_coord}).")
|
|
raise ValueError("Unscaled map dimensions missing for line drawing.")
|
|
|
|
|
|
if len(tile_pixel_corners_on_unscaled) == 4:
|
|
# Draw the tile boundary (red)
|
|
draw.line([tile_pixel_corners_on_unscaled[0], tile_pixel_corners_on_unscaled[1]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Top
|
|
draw.line([tile_pixel_corners_on_unscaled[1], tile_pixel_corners_on_unscaled[2]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Right
|
|
draw.line([tile_pixel_corners_on_unscaled[2], tile_pixel_corners_on_unscaled[3]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Bottom
|
|
draw.line([tile_pixel_corners_on_unscaled[3], tile_pixel_corners_on_unscaled[0]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Left
|
|
|
|
# --- Draw Tile Name Label ---
|
|
label_text = tile_base_name.upper()
|
|
# Find pixel position for label - bottom-right corner area of the tile's pixel box.
|
|
# Get the precise unscaled pixel coords for the SE corner using the helper (which clamps to edge)
|
|
se_pixel_on_unscaled = self._geo_to_pixel_on_unscaled_map(south, east)
|
|
|
|
label_margin = 3 # Small margin from the border
|
|
|
|
if font_to_use is not None and se_pixel_on_unscaled and self._current_stitched_map_pixel_shape: # Draw text only if a font is available and position is calculable
|
|
try:
|
|
unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape
|
|
se_px, se_py = se_pixel_on_unscaled
|
|
|
|
# Calculate text size using textbbox (requires Pillow >= 8.0)
|
|
# Use the bottom-right corner pixel as the anchor point for calculation (not drawing)
|
|
try:
|
|
# textbbox relative to (0,0)
|
|
text_bbox = draw.textbbox((0,0), label_text, font=font_to_use) # type: ignore
|
|
text_width = text_bbox[2] - text_bbox[0]
|
|
text_height = text_bbox[3] - text_bbox[1]
|
|
|
|
# Target bottom-right corner for text drawing relative to the unscaled image pixel
|
|
target_text_br_x = se_px - label_margin
|
|
target_text_br_y = se_py - label_margin
|
|
|
|
# Top-left corner for drawing the text based on target bottom-right and text size
|
|
label_text_x = target_text_br_x - text_width
|
|
label_text_y = target_text_br_y - text_height
|
|
|
|
# Clamp text position to be within the visible area of the unscaled map image
|
|
label_text_x = max(0, min(label_text_x, unscaled_width - text_width - 1))
|
|
label_text_y = max(0, min(label_text_y, unscaled_height - text_height - 1))
|
|
|
|
|
|
# Draw background rectangle for the text
|
|
bg_padding = 1 # Small padding around text background
|
|
bg_coords = [
|
|
label_text_x - bg_padding,
|
|
label_text_y - bg_padding,
|
|
label_text_x + text_width + bg_padding,
|
|
label_text_y + text_height + bg_padding,
|
|
]
|
|
draw.rectangle(bg_coords, fill=DEM_TILE_LABEL_BG_COLOR)
|
|
|
|
# Draw the text itself
|
|
# Use anchor='la' (left, top-of-ascent) and calculated top-left position
|
|
draw.text((label_text_x, label_text_y), label_text, fill=DEM_TILE_LABEL_COLOR, font=font_to_use) # type: ignore
|
|
|
|
|
|
except AttributeError:
|
|
# Fallback for older Pillow versions using textsize
|
|
logger.warning("Pillow textbbox not available (Pillow < 8.0). Using textsize fallback for labels.")
|
|
text_width, text_height = draw.textsize(label_text, font=font_to_use) # type: ignore
|
|
# Rough position calculation based on textsize
|
|
if self._current_stitched_map_pixel_shape and se_pixel_on_unscaled:
|
|
unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape
|
|
se_px, se_py = se_pixel_on_unscaled
|
|
label_text_x = se_px - text_width - label_margin
|
|
label_text_y = se_py - text_height - label_margin
|
|
|
|
# Clamp text position to be within the visible area
|
|
label_text_x = max(0, min(label_text_x, unscaled_width - text_width - 1))
|
|
label_text_y = max(0, min(label_text_y, unscaled_height - text_height - 1))
|
|
|
|
# Draw background
|
|
bg_padding = 1
|
|
bg_coords = [label_text_x - bg_padding, label_text_y - bg_padding,
|
|
label_text_x + text_width + bg_padding, label_text_y + text_height + bg_padding]
|
|
draw.rectangle(bg_coords, fill=DEM_TILE_LABEL_BG_COLOR)
|
|
|
|
# Draw text using font fallback
|
|
draw.text((label_text_x, label_text_y), label_text, fill=DEM_TILE_LABEL_COLOR, font=font_to_use) # type: ignore
|
|
else:
|
|
logger.warning(f"Could not get SE pixel coords for tile ({lat_coord},{lon_coord}) label positioning (textsize fallback).")
|
|
|
|
|
|
except Exception as e_draw_label:
|
|
logger.warning(f"Error drawing label '{label_text}' for tile ({lat_coord},{lon_coord}): {e_draw_label}")
|
|
else:
|
|
logger.debug(f"No font available, skipping drawing label '{label_text}' for tile ({lat_coord},{lon_coord}).")
|
|
|
|
|
|
except ValueError as ve: # Catch explicit ValueErrors raised for conversion/drawing issues for a single tile
|
|
logger.warning(f"Value error during drawing for tile ({lat_coord},{lon_coord}): {ve}. Skipping this tile.")
|
|
continue # Skip this tile and continue with the next
|
|
|
|
|
|
except Exception as e_draw_tile:
|
|
logger.exception(f"Unexpected error drawing boundary/label for tile ({lat_coord},{lon_coord}): {e_draw_tile}. Skipping this tile.")
|
|
continue # Skip this tile and continue with the next
|
|
|
|
|
|
# Return the image with all drawn boundaries and labels
|
|
return pil_image_to_draw_on
|
|
|
|
|
|
def _draw_user_click_marker_on_map(
|
|
self,
|
|
pil_image_to_draw_on: ImageType
|
|
) -> Optional[ImageType]:
|
|
"""Draws a marker at the last user-clicked pixel location on the (unscaled) PIL map image."""
|
|
# MODIFIED: Ensure drawing is only attempted if the original stitched image is available and context is ready.
|
|
# WHY: Avoids errors if the map wasn't successfully loaded or context is missing.
|
|
# HOW: Added check for self._current_stitched_map_pil and _can_perform_drawing_operations.
|
|
if not self._last_user_click_pixel_coords_on_displayed_image or \
|
|
pil_image_to_draw_on is None or \
|
|
not self._can_perform_drawing_operations() or \
|
|
self._current_stitched_map_pil is None: # Ensure original stitched image exists
|
|
logger.debug("Conditions not met for drawing user click marker (no click, no image, no context, or no original stitched image).")
|
|
return pil_image_to_draw_on
|
|
|
|
# Unscale the click coordinates from displayed (scaled) image to original stitched image coordinates
|
|
clicked_px_scaled, clicked_py_scaled = self._last_user_click_pixel_coords_on_displayed_image
|
|
|
|
# Get the shape of the original unscaled stitched image
|
|
if not self._current_stitched_map_pixel_shape:
|
|
logger.warning("Cannot accurately unscale click for marker: unscaled map shape unknown.")
|
|
return pil_image_to_draw_on
|
|
|
|
unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape
|
|
|
|
# Calculate the unscaled pixel coordinates corresponding to the clicked scaled pixel
|
|
unscaled_target_px = int(round(clicked_px_scaled / self.current_display_scale_factor))
|
|
unscaled_target_py = int(round(clicked_py_scaled / self.current_display_scale_factor))
|
|
|
|
# Clamp to unscaled image dimensions
|
|
unscaled_target_px = max(0, min(unscaled_target_px, unscaled_width - 1))
|
|
unscaled_target_py = max(0, min(unscaled_target_py, unscaled_height - 1))
|
|
|
|
# MODIFIED: Check for CV2 and NumPy availability before using them.
|
|
# WHY: Ensure dependencies are present for drawing with OpenCV.
|
|
# HOW: Added check.
|
|
if cv2 and np:
|
|
try:
|
|
logger.debug(f"Drawing user click marker at unscaled pixel ({unscaled_target_px},{unscaled_target_py})")
|
|
# Convert PIL image to OpenCV format (BGR) for drawing
|
|
# Ensure image is in a mode OpenCV can handle (BGR)
|
|
if pil_image_to_draw_on.mode != 'RGB':
|
|
# Convert to RGB first if not already, then to BGR
|
|
map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on.convert('RGB')), cv2.COLOR_RGB2BGR) # type: ignore
|
|
else:
|
|
map_cv_bgr = cv2.cvtColor(np.array(pil_image_to_draw_on), cv2.COLOR_RGB2BGR) # type: ignore
|
|
|
|
# Draw a cross marker at the calculated unscaled pixel coordinates
|
|
# Note: Marker color (0,0,255) is BGR for red
|
|
cv2.drawMarker(map_cv_bgr, (unscaled_target_px,unscaled_target_py), (0,0,255), cv2.MARKER_CROSS, markerSize=15, thickness=2) # type: ignore
|
|
# Convert back to PIL format (RGB)
|
|
return Image.fromarray(cv2.cvtColor(map_cv_bgr, cv2.COLOR_BGR2RGB)) # type: ignore
|
|
except Exception as e_draw_click_cv:
|
|
logger.exception(f"Error drawing user click marker with OpenCV: {e_draw_click_cv}")
|
|
return pil_image_to_draw_on # Return original image on error
|
|
else:
|
|
logger.warning("CV2 or NumPy not available, cannot draw user click marker.")
|
|
return pil_image_to_draw_on # Return original image if CV2/NumPy are somehow missing here
|
|
|
|
|
|
def handle_map_mouse_click(self, pixel_x_on_displayed_img: int, pixel_y_on_displayed_img: int) -> None:
|
|
"""
|
|
Handles a mouse click from MapDisplayWindow (coords are on the SCALED, displayed image).
|
|
Converts pixel to geo, fetches elevation, sends data to GUI, and redraws map with click marker.
|
|
"""
|
|
logger.debug(
|
|
f"Map mouse click (on scaled img) received at pixel ({pixel_x_on_displayed_img}, {pixel_y_on_displayed_img})"
|
|
)
|
|
# Store the pixel coordinates of the click on the *displayed* (scalata) image.
|
|
self._last_user_click_pixel_coords_on_displayed_image = (pixel_x_on_displayed_img, pixel_y_on_displayed_img)
|
|
|
|
# MODIFIED: Check if map context is ready before proceeding with conversion and elevation fetch.
|
|
# WHY: Avoids errors if the map wasn't fully loaded or context is missing.
|
|
# HOW: Added explicit check. Also check Mercantile availability which is critical for pixel-geo conversion.
|
|
if not self._can_perform_drawing_operations() or not self._current_map_geo_bounds_deg or \
|
|
self._current_map_render_zoom is None or self._current_stitched_map_pixel_shape is None or \
|
|
not MERCANTILE_LIB_AVAILABLE_DISPLAY: # Check Mercantile specifically
|
|
logger.warning("Cannot process map click: map context, Mercantile, or display controller not fully loaded.")
|
|
# MODIFIED: Send partial error info to GUI queue on click if context is missing.
|
|
# WHY: GUI needs to update click info even if elevation cannot be fetched.
|
|
# HOW: Send a payload with error status for elevation/size.
|
|
# MODIFIED: Include DMS fields with error state for consistency with GUI update logic.
|
|
error_payload = {"type": "map_info_update", "latitude": None, "longitude": None,
|
|
"latitude_dms_str": "Error", "longitude_dms_str": "Error", # Added DMS fields
|
|
"elevation_str": "Click Error: Context N/A", "map_area_size_str": "Click Error: Context N/A"}
|
|
try: self.gui_com_queue.put(error_payload)
|
|
except Exception as e_put_err: logger.error(f"Failed to put error payload to queue on click: {e_put_err}")
|
|
return # Exit after sending error
|
|
|
|
|
|
# Convert pixel on SCALED displayed image to geographic coordinates.
|
|
# MapDisplayWindow's pixel_to_geo method needs the *displayed* (scaled) image shape for this.
|
|
displayed_img_shape = self.map_display_window_controller._last_displayed_scaled_image_shape
|
|
|
|
# Use the MapDisplayWindow's method to convert click pixel coords to geo coords.
|
|
# This method uses the stored unscaled map context and the scaled displayed shape.
|
|
geo_coords = self.map_display_window_controller.pixel_to_geo_on_current_map(
|
|
pixel_x_on_displayed_img, pixel_y_on_displayed_img,
|
|
# These arguments are required by MapDisplayWindow.pixel_to_geo_on_current_map
|
|
# and are stored in the GeoElevationMapViewer instance after a map is displayed.
|
|
self._current_map_geo_bounds_deg, # type: ignore # Geo bounds of the unscaled map
|
|
displayed_img_shape, # Pixel shape of the displayed (scaled) map
|
|
self._current_map_render_zoom # type: ignore # Zoom level of the unscaled map
|
|
)
|
|
|
|
elev_val: Optional[float] = None
|
|
elev_display_str = "N/A"
|
|
lat_val: Optional[float] = None
|
|
# MODIFIED: Renamed lon_val to lon_val_float to distinguish from DMS later if needed (though not used here)
|
|
lon_val_float: Optional[float] = None # Keep as float for internal use
|
|
|
|
|
|
map_area_size_str = "N/A" # Default
|
|
|
|
if geo_coords:
|
|
lat_val, lon_val_float = geo_coords
|
|
logger.info(f"Map click (on scaled) converted to Geo: Lat={lat_val:.5f}, Lon={lon_val_float:.5f}") # Use lon_val_float in log
|
|
# MODIFIED: Fetch elevation for the clicked point using the child process's ElevationManager.
|
|
# WHY: ElevationManager is available and configured in this process.
|
|
# HOW: Call self.elevation_manager.get_elevation.
|
|
try:
|
|
if self.elevation_manager: # Ensure manager is not None
|
|
# MODIFIED: Pass lon_val_float to get_elevation
|
|
elev_val = self.elevation_manager.get_elevation(lat_val, lon_val_float)
|
|
if elev_val is None: elev_display_str = "Unavailable"
|
|
elif isinstance(elev_val, float) and math.isnan(elev_val): elev_display_str = "NoData"
|
|
else: elev_display_str = f"{elev_val:.2f} m"
|
|
else:
|
|
elev_display_str = "Elev Manager N/A"
|
|
logger.warning("ElevationManager is None in map process, cannot get elevation for click.")
|
|
|
|
logger.info(f"Elevation at clicked geo point: {elev_display_str}")
|
|
except Exception as e_get_elev_click:
|
|
logger.error(f"Error getting elevation for clicked point: {e_get_elev_click}")
|
|
elev_display_str = f"Error: {type(e_get_elev_click).__name__}"
|
|
|
|
# Calculate map area size string (same as for initial display)
|
|
# For area view, this reports the size of the COMBINED DEM tiles displayed.
|
|
# For point view, it reports the size of the map fetch area (often ~1x1 degree buffer).
|
|
if self._current_map_geo_bounds_deg:
|
|
# MODIFIED: Check PyProj availability before calculating size.
|
|
# WHY: calculate_geographic_bbox_size_km requires PyProj.
|
|
# HOW: Added check.
|
|
if PYPROJ_AVAILABLE: # type: ignore
|
|
size_km = calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg)
|
|
if size_km:
|
|
width_km, height_km = size_km
|
|
# MODIFIED: Add "DEM Area" to the size string for area view, "Map Area" for point view.
|
|
# WHY: Clarify what area size is being reported.
|
|
# HOW: Conditional string formatting based on whether _current_requested_area_geo_bbox is set (implies area view).
|
|
if self._current_requested_area_geo_bbox:
|
|
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.")
|
|
else:
|
|
map_area_size_str = "PyProj N/A (Size Unknown)"
|
|
logger.warning("PyProj not available, cannot calculate map area size.")
|
|
|
|
|
|
else:
|
|
logger.warning(f"Could not convert pixel click to geo coordinates.")
|
|
elev_display_str = "Error: Click conversion failed"
|
|
map_area_size_str = "Error: Click conversion failed"
|
|
|
|
# MODIFIED: Send click data + map area size info using the new update message type.
|
|
# WHY: Consistent message format for initial info and clicks. Include DMS strings.
|
|
# HOW: Use "type": "map_info_update" and include all relevant fields, including DMS conversion.
|
|
# MODIFIED: Use _send_initial_point_info_to_gui helper which now handles DMS conversion and payload structure.
|
|
# WHY: Centralize sending logic.
|
|
# HOW: Call the helper function with available data.
|
|
self._send_initial_point_info_to_gui(
|
|
lat_val, lon_val_float, elev_display_str, map_area_size_str # Pass float coords and strings
|
|
)
|
|
|
|
|
|
# Redraw map: get original stitched map, draw persistent overlays (DEM boundary for point, Area box + all DEMs for area), draw new click marker, then show.
|
|
# MapDisplayWindow.show_map() will apply the current_display_scale_factor.
|
|
# Need to re-apply boundary drawing as it's not on the base stitched image.
|
|
# MODIFIED: Check if original stitched image is available and drawing is possible before redrawing.
|
|
# WHY: Avoids errors if the original image is None.
|
|
# HOW: Added check.
|
|
if self._current_stitched_map_pil and self.map_display_window_controller and self._can_perform_drawing_operations():
|
|
# Start with a fresh copy of the original stitched image
|
|
map_copy_for_drawing = self._current_stitched_map_pil.copy()
|
|
|
|
# MODIFIED: Determine which overlays to redraw based on the current view type (Point vs Area).
|
|
# WHY: To correctly redraw the blue area boundary and/or red DEM tile boundaries/labels.
|
|
# HOW: Check if _current_requested_area_geo_bbox or _dem_tile_geo_bbox_for_current_map are set.
|
|
if self._current_requested_area_geo_bbox: # This is an AREA view
|
|
logger.debug("Redrawing overlays for Area View after click.")
|
|
# Redraw the requested area boundary (blue)
|
|
map_copy_for_drawing = self._draw_area_bounding_box_on_map(
|
|
map_copy_for_drawing,
|
|
self._current_requested_area_geo_bbox,
|
|
color=AREA_BOUNDARY_COLOR,
|
|
thickness=AREA_BOUNDARY_THICKNESS_PX
|
|
)
|
|
# Redraw the DEM tile boundaries and labels (red) for all relevant tiles
|
|
if self._dem_tiles_info_for_current_map:
|
|
map_copy_for_drawing = self._draw_dem_tile_boundaries_with_labels_on_map(
|
|
map_copy_for_drawing,
|
|
self._dem_tiles_info_for_current_map
|
|
)
|
|
|
|
elif self._dem_tile_geo_bbox_for_current_map: # This is a POINT view (and DEM was available)
|
|
logger.debug("Redrawing DEM tile boundary for Point View after click.")
|
|
# Redraw the single DEM tile boundary (red)
|
|
map_copy_for_drawing = self._draw_dem_tile_boundary_on_map(
|
|
map_copy_for_drawing,
|
|
self._dem_tile_geo_bbox_for_current_map
|
|
)
|
|
|
|
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 after click.")
|
|
pass # map_copy_for_drawing is just the base stitched image
|
|
|
|
|
|
# Draw the latest user click marker on top of the map copy (which now has persistent overlays)
|
|
# _draw_user_click_marker_on_map needs the image to draw on, and uses the stored _last_user_click_pixel_coords_on_displayed_image
|
|
map_with_latest_click_marker = self._draw_user_click_marker_on_map(map_copy_for_drawing)
|
|
|
|
# Display the final prepared image (scaling happens inside MapDisplayWindow.show_map)
|
|
if map_with_latest_click_marker:
|
|
self.map_display_window_controller.show_map(map_with_latest_click_marker)
|
|
else:
|
|
# Fallback if drawing the marker failed, show the map potentially with persistent overlays but no click marker
|
|
logger.warning("Failed to draw user click marker. Showing map without marker.")
|
|
self.map_display_window_controller.show_map(map_copy_for_drawing)
|
|
else:
|
|
logger.warning("Cannot redraw map after click: conditions not met.")
|
|
|
|
|
|
|
|
def shutdown(self) -> None:
|
|
"""Cleans up resources, particularly the map display window controller."""
|
|
logger.info("Shutting down GeoElevationMapViewer and its display window controller.")
|
|
# MODIFIED: Reset stored map context on shutdown.
|
|
# WHY: Ensure a clean state if the map viewer process is restarted.
|
|
# HOW: Reset attributes to None.
|
|
self._current_stitched_map_pil = None
|
|
self._current_map_geo_bounds_deg = None
|
|
self._current_map_render_zoom = None
|
|
self._current_stitched_map_pixel_shape = (0, 0) # Reset to default tuple
|
|
self._last_user_click_pixel_coords_on_displayed_image = None
|
|
self._dem_tile_geo_bbox_for_current_map = None
|
|
# MODIFIED: Clear new area view state attributes on shutdown.
|
|
# WHY: Clean state.
|
|
# HOW: Reset attributes.
|
|
self._current_requested_area_geo_bbox = None
|
|
self._dem_tiles_info_for_current_map = []
|
|
|
|
|
|
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.") |