# geoelevation/elevation_gui.py """ Provides the main graphical user interface (GUI) for the GeoElevation application. (Resto della docstring come prima) """ # Standard library imports import tkinter as tk from tkinter import ttk from tkinter import messagebox import logging import math import multiprocessing import threading import os import sys import queue from typing import Optional, Tuple, List, Dict, TYPE_CHECKING, Any # Local application/package imports # MODIFIED: Import _LibraryProgressWindow for displaying the waiting screen. # WHY: This class provides the non-blocking progress window needed. # HOW: Added _LibraryProgressWindow to the import list from geoelevation. from geoelevation import ElevationManager from geoelevation import RASTERIO_AVAILABLE # MODIFIED: Import _LibraryProgressWindow from the top-level package. # WHY: The _LibraryProgressWindow class is defined in geoelevation/__init__.py # as a public-ish utility for library users to show progress. # HOW: Added the import statement. from geoelevation import _LibraryProgressWindow from geoelevation.image_processor import load_prepare_single_browse from geoelevation.image_processor import create_composite_area_image from geoelevation.image_processor import PIL_AVAILABLE from geoelevation.visualizer import show_image_matplotlib from geoelevation.visualizer import show_3d_matplotlib from geoelevation.visualizer import MATPLOTLIB_AVAILABLE from geoelevation.visualizer import SCIPY_AVAILABLE # MODIFIED: Corrected import of default cache directory constant from the geoelevation package's public API # WHY: This constant holds the path to the default DEM cache directory # as configured in the.config.py module and exposed by __init__.py. # HOW: Changed the import statement to directly import GEOELEVATION_DEM_CACHE_DEFAULT. The alias is no longer needed # in the import statement itself, as the imported name is already the desired one. The existing try...except # block correctly handles the case where this specific constant might not be available from the package. try: from geoelevation import GEOELEVATION_DEM_CACHE_DEFAULT # MODIFIED: Import the deg_to_dms_string utility function from map_utils.py # WHY: Needed to convert coordinates to DMS format for the GUI display. # HOW: Added the import statement from the map_viewer.map_utils sub-module. from geoelevation.map_viewer.map_utils import deg_to_dms_string # MODIFIED: Import the utility functions for geographic bounds calculation from map_utils.py. # WHY: Needed to get geographic bounds for 2D plot aspect ratio correction. # HOW: Added the import statements. from geoelevation.map_viewer.map_utils import get_hgt_tile_geographic_bounds # For single tile browse from geoelevation.map_viewer.map_utils import get_combined_geographic_bounds_from_tile_info_list # For area composite # MODIFIED: Import the multiprocessing target functions from the new module. # WHY: These functions have been moved to their own module as part of refactoring. # HOW: Added the import statement. from geoelevation import process_targets # Import the new module containing targets except ImportError: # This fallback is if the main __init__.py itself has issues exporting the constant, # which shouldn't happen if __init__.py and config.py are correct. GEOELEVATION_DEM_CACHE_DEFAULT = "elevation_data_cache_gui_fallback_critical" # MODIFIED: Define dummy deg_to_dms_string and bounds functions if import fails. # WHY: Avoid NameError if the imports fail critically. # HOW: Define simple functions that return default/error values. def deg_to_dms_string(degree_value: float, coord_type: str) -> str: # type: ignore return "Import Error" # Dummy bounds functions def get_hgt_tile_geographic_bounds(lat_coord: int, lon_coord: int) -> Optional[Tuple[float, float, float, float]]: # type: ignore logger.error("map_utils bounds functions not available due to import error.") return None def get_combined_geographic_bounds_from_tile_info_list(tile_info_list: List[Dict]) -> Optional[Tuple[float, float, float, float]]: # type: ignore logger.error("map_utils bounds functions not available due to import error.") return None # MODIFIED: Define dummy process_targets object if the module import fails. # WHY: Prevent NameError when trying to access process_targets attributes. # HOW: Define a simple dummy object. class process_targets: # type: ignore @staticmethod def process_target_show_image(*args: Any, **kwargs: Any) -> None: pass @staticmethod def process_target_show_3d(*args: Any, **kwargs: Any) -> None: pass @staticmethod def process_target_create_show_area(*args: Any, **kwargs: Any) -> None: pass @staticmethod def run_map_viewer_process_target(*args: Any, **kwargs: Any) -> None: pass # MODIFIED: Define a dummy _LibraryProgressWindow class if the import fails. # WHY: Prevent NameError if the _LibraryProgressWindow class is used when the import failed. # HOW: Define a dummy class with necessary methods that do nothing. class _LibraryProgressWindow(threading.Thread): # type: ignore def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(daemon=True) self.is_tk_ready_event = threading.Event() self._please_stop_event = threading.Event() self.tkinter_root = None # Ensure tkinter_root exists even in dummy logger.warning("Map viewer progress window unavailable due to import errors.") def run(self) -> None: self.is_tk_ready_event.set() # Signal readiness immediately def request_progress_window_stop(self) -> None: self._please_stop_event.set() logging.getLogger(__name__).critical( f"elevation_gui.py: CRITICAL - Could not import core dependencies from geoelevation package. " f"Using fallback cache: {GEOELEVATION_DEM_CACHE_DEFAULT}. Some features unavailable." ) try: import cv2 MAP_VIEWER_SYSTEM_AVAILABLE = True except ImportError as e_map_import_gui: logging.warning( f"GUI: Map viewer components (e.g. OpenCV) not found, map functionality will be disabled: {e_map_import_gui}" ) cv2 = None # type: ignore MAP_VIEWER_SYSTEM_AVAILABLE = False if TYPE_CHECKING: import numpy as np_typing logger = logging.getLogger(__name__) # --- MULTIPROCESSING TARGET FUNCTIONS (MOVED TO process_targets.py) --- # The actual functions are now imported from geoelevation.process_targets class ElevationApp: """Main application class for the GeoElevation Tool GUI.""" def __init__( self, parent_widget: tk.Tk, elevation_manager_instance: Optional[ElevationManager] = None ) -> None: """ Initializes the main GUI application window and components. Args: parent_widget: The root Tkinter window. elevation_manager_instance: Optional existing ElevationManager instance. If None, a new one is created. """ logger.debug("GeoElevation GUI initializing...") self.root: tk.Tk = parent_widget self.elevation_manager: Optional[ElevationManager] = elevation_manager_instance # MODIFIED: Use the correctly imported GEOELEVATION_DEM_CACHE_DEFAULT constant # WHY: This constant holds the path to the default DEM cache directory # as configured in the.config.py module and exposed by __init__.py. # HOW: Changed the import statement to directly import GEOELEVATION_DEM_CACHE_DEFAULT. The alias is no longer needed # in the import statement itself, as the imported name is already the desired one. The existing try...except # block correctly handles the case where this specific constant might not be available from the package. default_dem_cache = GEOELEVATION_DEM_CACHE_DEFAULT if self.elevation_manager is None: try: if not RASTERIO_AVAILABLE: logger.warning("Rasterio library not found. Elevation functions limited.") self.elevation_manager = ElevationManager( tile_directory=default_dem_cache ) except Exception as e_manager_init: logger.critical(f"Failed to initialize ElevationManager: {e_manager_init}", exc_info=True) messagebox.showerror("Init Error", f"Could not start Elevation Manager:\n{e_manager_init}", parent=self.root) self.elevation_manager = None self.root.title("GeoElevation Tool") # MODIFIED: Increased minsize height to accommodate new info fields. # WHY: Need more space for decimal and DMS coordinate fields. # HOW: Changed the minsize tuple. self.root.minsize(480, 580) # Increased height self.last_valid_point_coords: Optional[Tuple[float, float]] = None self.last_area_coords: Optional[Tuple[float, float, float, float]] = None self.is_processing_task: bool = False # MODIFIED: Ensure this attribute is initialized here. self.map_display_scale_factor_var: tk.DoubleVar = tk.DoubleVar(value=0.5) self.scale_options_map: Dict[str, float] = { "25% (1:4)": 0.25, "33% (1:3)": 0.33333, "50% (1:2)": 0.5, "75% (3:4)": 0.75, "100% (Original)": 1.0, "150% (3:2)": 1.5, "200% (2:1)": 2.0 } self.map_viewer_process_handle: Optional[multiprocessing.Process] = None self.map_interaction_message_queue: Optional[multiprocessing.Queue] = None # MODIFIED: Store the default DEM cache path to pass to the map viewer process. # WHY: The map process needs this path to initialize its own ElevationManager. # HOW: Assigned the resolved default_dem_cache to a new instance attribute. self._dem_data_cache_dir_for_map_process = default_dem_cache # MODIFIED: Declare StringVar instances for the Map Info Entry widgets. # WHY: Tkinter Entry widgets are typically managed via associated StringVar instances for dynamic updates. # HOW: Declared new instance attributes of type tk.StringVar. self.map_lat_decimal_var: tk.StringVar = tk.StringVar(value="N/A") self.map_lon_decimal_var: tk.StringVar = tk.StringVar(value="N/A") self.map_lat_dms_var: tk.StringVar = tk.StringVar(value="N/A") # Added DMS variables self.map_lon_dms_var: tk.StringVar = tk.StringVar(value="N/A") # Added DMS variables self.map_elevation_var: tk.StringVar = tk.StringVar(value="N/A") self.map_area_size_var: tk.StringVar = tk.StringVar(value="N/A") # MODIFIED: Added attribute to track the map fetch progress window. # WHY: Need a reference to the progress window instance running in its own thread. # HOW: Declared a new Optional attribute of type _LibraryProgressWindow. self._map_fetch_progress_window: Optional[_LibraryProgressWindow] = None if MAP_VIEWER_SYSTEM_AVAILABLE: try: self.map_interaction_message_queue = multiprocessing.Queue() except Exception as e_gui_queue: logger.error(f"GUI: Failed to create multiprocessing queue for map viewer: {e_gui_queue}") self._build_gui_layout() self._apply_initial_widget_states() if self.map_interaction_message_queue: # MODIFIED: Start processing the map queue periodically. # WHY: The GUI needs to receive messages from the map viewer process. # HOW: Scheduled the _process_map_interaction_queue_messages method to run after a delay. self.root.after(100, self._process_map_interaction_queue_messages) # MODIFIED: Handle window closing event gracefully. # WHY: To terminate the separate map process when the main GUI closes. # HOW: Set the WM_DELETE_WINDOW protocol to call _on_application_closing. self.root.protocol("WM_DELETE_WINDOW", self._on_application_closing) logger.debug("GeoElevation GUI initialization complete.") def _get_scale_display_text_from_value(self, target_scale_value: float) -> str: closest_text = "" smallest_difference = float('inf') default_display_text = "50% (1:2)" for display_text, scale_val in self.scale_options_map.items(): difference = abs(scale_val - target_scale_value) if difference < smallest_difference: smallest_difference = difference closest_text = display_text if closest_text and smallest_difference < 0.01: return closest_text else: for text_option, value_option in self.scale_options_map.items(): if abs(value_option - 0.5) < 0.001: return text_option return default_display_text def _build_gui_layout(self) -> None: main_app_frame = ttk.Frame(self.root, padding="10") main_app_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) self.root.columnconfigure(0, weight=1) self.root.rowconfigure(0, weight=1) point_coords_frame = ttk.LabelFrame(main_app_frame, text="Get Elevation for Point", padding="10") point_coords_frame.grid(row=0, column=0, padx=5, pady=5, sticky=(tk.W, tk.E)) point_coords_frame.columnconfigure(1, weight=1) ttk.Label(point_coords_frame, text="Latitude:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3) self.latitude_entry = ttk.Entry(point_coords_frame, width=15) self.latitude_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=3) self.latitude_entry.insert(0, "45.0") ttk.Label(point_coords_frame, text="Longitude:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3) self.longitude_entry = ttk.Entry(point_coords_frame, width=15) self.longitude_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=3) self.longitude_entry.insert(0, "7.0") self.get_elevation_button = ttk.Button(point_coords_frame, text="Get Elevation", command=self._trigger_get_elevation_task) self.get_elevation_button.grid(row=2, column=0, columnspan=2, pady=5, sticky=(tk.W, tk.E)) self.point_result_label = ttk.Label(point_coords_frame, text="Result: ", wraplength=400, justify=tk.LEFT) self.point_result_label.grid(row=3, column=0, columnspan=2, sticky=tk.W, pady=5) point_actions_subframe = ttk.Frame(point_coords_frame) point_actions_subframe.grid(row=4, column=0, columnspan=2, pady=(5,0), sticky=(tk.W, tk.E)) point_actions_subframe.columnconfigure(0, weight=1) point_actions_subframe.columnconfigure(1, weight=1) point_actions_subframe.columnconfigure(2, weight=1) self.show_2d_browse_button = ttk.Button(point_actions_subframe, text="Browse Image (2D)", command=self._trigger_2d_browse_display) self.show_2d_browse_button.grid(row=0, column=0, padx=2, sticky=(tk.W,tk.E)) self.show_3d_dem_button = ttk.Button(point_actions_subframe, text="DEM Tile (3D)", command=self._trigger_3d_dem_display) self.show_3d_dem_button.grid(row=0, column=1, padx=2, sticky=(tk.W,tk.E)) self.view_map_for_point_button = ttk.Button(point_actions_subframe, text="View Point on Map", command=self._trigger_view_map_for_point) self.view_map_for_point_button.grid(row=0, column=2, padx=2, sticky=(tk.W,tk.E)) area_download_frame = ttk.LabelFrame(main_app_frame, text="Pre-Download Tiles for Area", padding="10") area_download_frame.grid(row=1, column=0, padx=5, pady=5, sticky=(tk.W, tk.E)) area_download_frame.columnconfigure(1, weight=1) area_download_frame.columnconfigure(3, weight=1) ttk.Label(area_download_frame, text="Min Lat:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3) self.min_latitude_entry = ttk.Entry(area_download_frame, width=10) self.min_latitude_entry.grid(row=0, column=1, sticky=(tk.W,tk.E), padx=5, pady=3) self.min_latitude_entry.insert(0, "44.0") ttk.Label(area_download_frame, text="Max Lat:").grid(row=0, column=2, sticky=tk.W, padx=(10,5), pady=3) self.max_latitude_entry = ttk.Entry(area_download_frame, width=10) self.max_latitude_entry.grid(row=0, column=3, sticky=(tk.W,tk.E), padx=5, pady=3) self.max_latitude_entry.insert(0, "45.0") ttk.Label(area_download_frame, text="Min Lon:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=3) self.min_longitude_entry = ttk.Entry(area_download_frame, width=10) self.min_longitude_entry.grid(row=1, column=1, sticky=(tk.W,tk.E), padx=5, pady=3) self.min_longitude_entry.insert(0, "7.0") ttk.Label(area_download_frame, text="Max Lon:").grid(row=1, column=2, sticky=tk.W, padx=(10,5), pady=3) self.max_longitude_entry = ttk.Entry(area_download_frame, width=10) self.max_longitude_entry.grid(row=1, column=3, sticky=(tk.W,tk.E), padx=5, pady=3) self.max_longitude_entry.insert(0, "8.0") self.download_area_button = ttk.Button(area_download_frame, text="Download Area Tiles", command=self._trigger_download_area_task) self.download_area_button.grid(row=2, column=0, columnspan=4, pady=10, sticky=(tk.W,tk.E)) self.show_area_composite_button = ttk.Button(area_download_frame, text="Show Area Composite (2D)", command=self._trigger_area_composite_display) self.show_area_composite_button.grid(row=3, column=0, columnspan=4, pady=5, sticky=(tk.W,tk.E)) self.view_map_for_area_button = ttk.Button(area_download_frame, text="View Area on Map", command=self._trigger_view_map_for_area) self.view_map_for_area_button.grid(row=4, column=0, columnspan=4, pady=5, sticky=(tk.W,tk.E)) self.area_download_status_label = ttk.Label(area_download_frame, text="Status: Idle", wraplength=400, justify=tk.LEFT) self.area_download_status_label.grid(row=5, column=0, columnspan=4, sticky=tk.W, pady=5) map_display_options_frame = ttk.LabelFrame(main_app_frame, text="Map Display Options", padding="10") map_display_options_frame.grid(row=2, column=0, padx=5, pady=5, sticky=(tk.W, tk.E)) map_display_options_frame.columnconfigure(1, weight=1) ttk.Label(map_display_options_frame, text="Map Display Scale:").grid(row=0, column=0, sticky=tk.W, padx=5, pady=3) scale_option_display_texts = list(self.scale_options_map.keys()) initial_combobox_display_text = self._get_scale_display_text_from_value(self.map_display_scale_factor_var.get()) self.map_scale_combobox = ttk.Combobox( map_display_options_frame, textvariable=tk.StringVar(value=initial_combobox_display_text), values=scale_option_display_texts, state="readonly", width=20 ) self.map_scale_combobox.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=3) self.map_scale_combobox.bind("<>", self._on_map_display_scale_changed) # MODIFIED: Changed LabelFrame text from "Map Click Information" to "Map Info". # WHY: The panel will now show information beyond just the click location (e.g., area size). # HOW: Updated the text option in the LabelFrame constructor. map_info_frame = ttk.LabelFrame(main_app_frame, text="Map Info", padding="10") map_info_frame.grid(row=3, column=0, padx=5, pady=5, sticky=(tk.W, tk.E)) # MODIFIED: Configured columns to stretch, important for Entry widgets # WHY: Allows the Entry widgets to fill available horizontal space. # HOW: Added columnconfigure calls. map_info_frame.columnconfigure(1, weight=1) # Column for Entry widgets # --- Map Info Widgets --- # MODIFIED: Replace ttk.Label for displaying values with ttk.Entry (readonly) # WHY: To make the displayed information copyable. # HOW: Changed widget type, configured state and textvariable, adjusted grid row/column. ttk.Label(map_info_frame, text="Latitude (Dec):").grid(row=0, column=0, sticky=tk.W, padx=5, pady=2) # self.map_click_latitude_label = ttk.Label(map_info_frame, text="N/A") # Old Label self.map_lat_decimal_entry = ttk.Entry(map_info_frame, textvariable=self.map_lat_decimal_var, state="readonly", width=25) self.map_lat_decimal_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=2) ttk.Label(map_info_frame, text="Longitude (Dec):").grid(row=1, column=0, sticky=tk.W, padx=5, pady=2) # self.map_click_longitude_label = ttk.Label(map_info_frame, text="N/A") # Old Label self.map_lon_decimal_entry = ttk.Entry(map_info_frame, textvariable=self.map_lon_decimal_var, state="readonly", width=25) self.map_lon_decimal_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=2) # MODIFIED: Add new Labels and Entries for DMS format. # WHY: To display coordinates in DMS format as requested. # HOW: Added new rows with Labels and Entry widgets. ttk.Label(map_info_frame, text="Latitude (DMS):").grid(row=2, column=0, sticky=tk.W, padx=5, pady=2) self.map_lat_dms_entry = ttk.Entry(map_info_frame, textvariable=self.map_lat_dms_var, state="readonly", width=25) self.map_lat_dms_entry.grid(row=2, column=1, sticky=(tk.W, tk.E), padx=5, pady=2) ttk.Label(map_info_frame, text="Longitude (DMS):").grid(row=3, column=0, sticky=tk.W, padx=5, pady=2) self.map_lon_dms_entry = ttk.Entry(map_info_frame, textvariable=self.map_lon_dms_var, state="readonly", width=25) self.map_lon_dms_entry.grid(row=3, column=1, sticky=(tk.W, tk.E), padx=5, pady=2) ttk.Label(map_info_frame, text="Elevation:").grid(row=4, column=0, sticky=tk.W, padx=5, pady=2) # self.map_click_elevation_label = ttk.Label(map_info_frame, text="N/A") # Old Label self.map_elevation_entry = ttk.Entry(map_info_frame, textvariable=self.map_elevation_var, state="readonly", width=25) self.map_elevation_entry.grid(row=4, column=1, sticky=(tk.W, tk.E), padx=5, pady=2) # MODIFIED: Added a new Label for displaying the map area size. # WHY: To fulfill the requirement to show the size of the currently displayed map patch. # HOW: Created and gridded a new ttk.Label. ttk.Label(map_info_frame, text="Map Area Size:").grid(row=5, column=0, sticky=tk.W, padx=5, pady=2) # MODIFIED: Make Area Size display also an Entry for copyability. # WHY: Consistency and copyability for all map info fields. # HOW: Changed widget type, configured state and textvariable. # self.map_area_size_label = ttk.Label(map_info_frame, text="N/A", wraplength=300, justify=tk.LEFT) # Old Label self.map_area_size_entry = ttk.Entry(map_info_frame, textvariable=self.map_area_size_var, state="readonly", width=25) # Increased width slightly self.map_area_size_entry.grid(row=5, column=1, sticky=(tk.W, tk.E), padx=5, pady=2) main_app_frame.columnconfigure(0, weight=1) main_app_frame.rowconfigure(0, weight=0) main_app_frame.rowconfigure(1, weight=0) main_app_frame.rowconfigure(2, weight=0) main_app_frame.rowconfigure(3, weight=0) if not MAP_VIEWER_SYSTEM_AVAILABLE: map_display_options_frame.grid_remove() # MODIFIED: Also hide the renamed "Map Info" frame if map viewer system is unavailable. # WHY: The panel is only relevant when the map viewer is working. # HOW: Called grid_remove on the map_info_frame. map_info_frame.grid_remove() def _apply_initial_widget_states(self) -> None: if self.elevation_manager is None: self.get_elevation_button.config(state=tk.DISABLED) self.download_area_button.config(state=tk.DISABLED) self.point_result_label.config(text="Result: Elevation Manager Init Failed.") self.area_download_status_label.config(text="Status: Elevation Manager Init Failed.") self.show_2d_browse_button.config(state=tk.DISABLED) self.show_3d_dem_button.config(state=tk.DISABLED) self.show_area_composite_button.config(state=tk.DISABLED) point_map_initial_state = tk.DISABLED area_map_initial_state = tk.DISABLED scale_combo_state = tk.DISABLED point_map_text = "Map (Sys N/A)" area_map_text = "Map (Sys N/A)" if MAP_VIEWER_SYSTEM_AVAILABLE: point_map_initial_state = tk.NORMAL # Enabled if system ok, but needs point data # area_map_initial_state remains DISABLED until area data is ready scale_combo_state = "readonly" point_map_text = "View Point on Map" area_map_text = "View Area on Map" self.view_map_for_point_button.config(state=point_map_initial_state, text=point_map_text) self.view_map_for_area_button.config(state=area_map_initial_state, text=area_map_text) self.map_scale_combobox.config(state=scale_combo_state) if not (MATPLOTLIB_AVAILABLE and PIL_AVAILABLE): self.show_2d_browse_button.config(text="Browse (Libs N/A)") self.show_area_composite_button.config(text="Composite (Libs N/A)") if not MATPLOTLIB_AVAILABLE: self.show_3d_dem_button.config(text="DEM (MPL N/A)") # MODIFIED: Set initial text for Map Info Entry widgets. # WHY: Display default "N/A" and ensure text variables are linked correctly. # HOW: Use set() on the StringVar instances. self.map_lat_decimal_var.set("N/A") self.map_lon_decimal_var.set("N/A") self.map_lat_dms_var.set("N/A") # Set initial value for DMS fields self.map_lon_dms_var.set("N/A") # Set initial value for DMS fields self.map_elevation_var.set("N/A") self.map_area_size_var.set("N/A") # MODIFIED: Ensure Map Info Entry widgets are in readonly state initially. # WHY: They should not be editable by the user. # HOW: Set the 'state' option on the Entry widgets. (Already done in _build_gui_layout, but can double-check here if needed). # self.map_lat_decimal_entry.config(state="readonly") # Redundant with _build_gui_layout # ... and so on for other map info entries. def _on_map_display_scale_changed(self, event: Optional[tk.Event] = None) -> None: selected_display_text = self.map_scale_combobox.get() new_numeric_scale = self.scale_options_map.get(selected_display_text) if new_numeric_scale is not None: self.map_display_scale_factor_var.set(new_numeric_scale) logger.info(f"Map display scale factor set to: {new_numeric_scale:.3f} ({selected_display_text})") # MODIFIED: Add a messagebox to inform the user about the scale change application. # WHY: To clarify that the scale change applies to the *next* map view. # HOW: Display an informational messagebox. messagebox.showinfo( "Map Scale Changed", f"Map display scale set to {selected_display_text}.\n" "This will apply the next time a map view is opened.", parent=self.root ) else: logger.warning(f"Invalid map scale option selected: '{selected_display_text}'") def _set_busy_state(self, is_busy: bool) -> None: """Sets the GUI to a busy or idle state.""" self.is_processing_task = is_busy # MODIFIED: Add check for elevation_manager before configuring buttons. # WHY: Avoids AttributeError if manager init failed. # HOW: Added 'if self.elevation_manager:'. if self.elevation_manager: new_widget_state = tk.DISABLED if is_busy else tk.NORMAL self.get_elevation_button.config(state=new_widget_state) self.download_area_button.config(state=new_widget_state) # MODIFIED: Also disable/enable visualization buttons based on busy state. # WHY: Prevent triggering new tasks while one is in progress. # HOW: Include visualization buttons in the state change. self.show_2d_browse_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_valid_point_coords and MATPLOTLIB_AVAILABLE and PIL_AVAILABLE else tk.DISABLED)) self.show_3d_dem_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_valid_point_coords and MATPLOTLIB_AVAILABLE else tk.DISABLED)) self.show_area_composite_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_area_coords and MATPLOTLIB_AVAILABLE and PIL_AVAILABLE else tk.DISABLED)) if MAP_VIEWER_SYSTEM_AVAILABLE: # Re-enable map buttons based on busy state and data availability. self.view_map_for_point_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_valid_point_coords else tk.DISABLED)) self.view_map_for_area_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_area_coords else tk.DISABLED)) def _validate_point_coordinates(self, lat_s: str, lon_s: str) -> Optional[Tuple[float, float]]: try: if not lat_s: raise ValueError("Latitude empty.") lat_v = float(lat_s.strip()) if not (-90.0 <= lat_v <= 90.0): raise ValueError("Latitude out of [-90, 90].") # Allow 90 exactly for validation if not lon_s: raise ValueError("Longitude empty.") lon_v = float(lon_s.strip()) if not (-180.0 <= lon_v <= 180.0): raise ValueError("Longitude out of [-180, 180].") # Allow 180 exactly for validation return lat_v, lon_v except ValueError as e: logger.error(f"Invalid coordinate: {e}"); messagebox.showerror("Input Error", f"Invalid coordinate:\n{e}", parent=self.root); return None def _validate_area_boundary_coordinates(self) -> Optional[Tuple[float, float, float, float]]: try: min_ls,max_ls = self.min_latitude_entry.get().strip(), self.max_latitude_entry.get().strip() min_os,max_os = self.min_longitude_entry.get().strip(), self.max_longitude_entry.get().strip() if not all([min_ls, max_ls, min_os, max_os]): raise ValueError("All bounds must be filled.") min_l,max_l,min_o,max_o = float(min_ls),float(max_ls),float(min_os),float(max_os) # MODIFIED: Adjusted validation range to allow 90 and 180 exactly. # WHY: Standard geographic range includes these boundaries. # HOW: Changed < 90.0 to <= 90.0 and < 180.0 to <= 180.0. if not (-90<=min_l<=90 and -90<=max_l<=90 and -180<=min_o<=180 and -180<=max_o<=180): raise ValueError("Coordinates out of valid range [-90, 90] / [-180, 180].") # The order check should still be strict for a defined bounding box area. if min_l >= max_l: raise ValueError("Min Lat must be less than Max Lat.") # Handle potential longitude wrap-around if min_o > max_o but the range is valid (e.g., crosses antimeridian) # For simplicity in GUI input, let's assume min_o should generally be <= max_o unless explicitly designing for wrap-around input. # Let's keep the simple check for now. if min_o >= max_o: raise ValueError("Min Lon must be less than Max Lon.") # This simplifies downstream logic return min_l, min_o, max_l, max_o except ValueError as e: logger.error(f"Invalid area: {e}"); messagebox.showerror("Input Error", f"Invalid area:\n{e}", parent=self.root); return None def _trigger_get_elevation_task(self) -> None: """Starts a background thread to get elevation for a point.""" if self.is_processing_task or not self.elevation_manager: return coords = self._validate_point_coordinates(self.latitude_entry.get(), self.longitude_entry.get()) if not coords: # MODIFIED: Clear Map Info fields if point validation fails. # WHY: Avoids showing stale info if the user enters invalid coords. # HOW: Call the UI update method with None values. self._get_elevation_task_complete_ui_update(None, ValueError("Invalid point coordinates"), None, None) return # Exit if validation fails lat, lon = coords # MODIFIED: Start busy state and update status label immediately on GUI thread. # WHY: Provide immediate visual feedback that processing has started. # HOW: Call _set_busy_state(True) and update label text. self._set_busy_state(True) self.point_result_label.config(text="Result: Requesting elevation... Please wait.") # MODIFIED: Clear Map Info fields when a new Get Elevation task starts. # WHY: Indicate that new information is being fetched. # HOW: Set StringVar values to "..." or empty. self.map_lat_decimal_var.set("...") # Indicate loading self.map_lon_decimal_var.set("...") self.map_lat_dms_var.set("...") # Indicate loading for DMS fields self.map_lon_dms_var.set("...") # Indicate loading for DMS fields self.map_elevation_var.set("...") self.map_area_size_var.set("N/A (Point)") # Indicate this is for a point self.root.update_idletasks() # Force GUI update # Start elevation retrieval in a background thread elev_thread = threading.Thread( target=self._perform_background_get_elevation, args=(lat, lon), daemon=True # Thread will exit if main program exits ) elev_thread.start() def _perform_background_get_elevation(self, latitude: float, longitude: float) -> None: """Background task to retrieve elevation.""" result_elevation: Optional[float] = None exception_occurred: Optional[Exception] = None try: # Call the actual elevation retrieval logic (which might involve downloads) # Using the shared manager instance in the main process. if self.elevation_manager: logger.info(f"GUI Thread: Calling elevation_manager.get_elevation for ({latitude:.5f},{longitude:.5f}) in background thread.") # This call will handle its own progress (e.g., the NullHandler logging or the CLI progress dialog if applicable) result_elevation = self.elevation_manager.get_elevation(latitude, longitude) else: raise RuntimeError("ElevationManager is not initialized.") except Exception as e: logger.exception(f"GUI Thread Error: Exception during background get_elevation for ({latitude:.5f},{longitude:.5f}).") exception_occurred = e finally: # MODIFIED: Use root.after to call the UI update method on the main GUI thread. # WHY: Tkinter GUI updates must happen on the main thread. # HOW: Schedule _get_elevation_task_complete_ui_update call. # MODIFIED: Pass original coords even on error for UI update context. # WHY: The UI update method needs the original coordinates to populate the entry fields, even if the elevation fetch failed. # HOW: Pass latitude, longitude as arguments. self.root.after( 0, # Schedule to run as soon as the main loop is free self._get_elevation_task_complete_ui_update, # Callback function result_elevation, # Pass the result/error back exception_occurred, latitude, longitude # Pass original coords for context ) # MODIFIED: Added a new UI update function for point elevation task completion. # WHY: Centralizes the logic to update GUI elements after the background task finishes. # HOW: Created this method to update labels and button states. # MODIFIED: Updated to populate Map Info Entry widgets with Decimal and DMS coordinates and Elevation. # WHY: Implement Feature 3 - show coordinates in Entry widgets and in DMS format. # HOW: Access StringVar instances and set their values. Use deg_to_dms_string for conversion. def _get_elevation_task_complete_ui_update( self, elevation_result: Optional[float], exception_occurred: Optional[Exception], original_latitude: Optional[float], # Now Optional as it might be None on validation failure original_longitude: Optional[float] # Now Optional as it might be None on validation failure ) -> None: """Updates GUI elements after a point elevation task completes.""" res_txt = "Result: " self.last_valid_point_coords = None # Reset valid point state initially # MODIFIED: Clear/reset Map Info fields if coordinates were not successfully obtained or were invalid. # WHY: Ensure the Map Info panel reflects the status correctly. # HOW: Reset StringVars to "N/A". if original_latitude is None or original_longitude is None: res_txt += f"Error: {type(exception_occurred).__name__} (Invalid Input)" if exception_occurred else "Input Error." logger.error(f"GUI: Point elevation task completed with invalid input.") self.map_lat_decimal_var.set("N/A") self.map_lon_decimal_var.set("N/A") self.map_lat_dms_var.set("N/A") self.map_lon_dms_var.set("N/A") self.map_elevation_var.set("N/A") self.map_area_size_var.set("N/A") # Area size doesn't apply to a single point result, but clear it. elif exception_occurred: res_txt += f"Error: {type(exception_occurred).__name__}" messagebox.showerror("Error", f"Error retrieving elevation:\n{exception_occurred}", parent=self.root) logger.error(f"GUI: Point elevation task completed with error for ({original_latitude:.5f},{original_longitude:.5f}).") # MODIFIED: Update Map Info fields with error state. # WHY: Show the error message in the GUI panel. # HOW: Set StringVars. self.map_lat_decimal_var.set(f"{original_latitude:.5f}") # Show input coords self.map_lon_decimal_var.set(f"{original_longitude:.5f}") # MODIFIED: Convert available coordinates to DMS even if elevation failed, if the coords are valid. # WHY: Show DMS coordinates if the input was valid, even if data wasn't available. # HOW: Use deg_to_dms_string. self.map_lat_dms_var.set(deg_to_dms_string(original_latitude, 'lat')) self.map_lon_dms_var.set(deg_to_dms_string(original_longitude, 'lon')) self.map_elevation_var.set(f"Error: {type(exception_occurred).__name__}") self.map_area_size_var.set("N/A") # Area size doesn't apply elif elevation_result is None: res_txt += "Data unavailable." messagebox.showwarning("Info", "Could not retrieve elevation for the point.", parent=self.root) logger.warning(f"GUI: Point elevation task completed, data unavailable for ({original_latitude:.5f},{original_longitude:.5f}).") # MODIFIED: Update Map Info fields for data unavailable state. # WHY: Show results in the GUI panel. # HOW: Set StringVars. self.map_lat_decimal_var.set(f"{original_latitude:.5f}") self.map_lon_decimal_var.set(f"{original_longitude:.5f}") self.map_lat_dms_var.set(deg_to_dms_string(original_latitude, 'lat')) self.map_lon_dms_var.set(deg_to_dms_string(original_longitude, 'lon')) self.map_elevation_var.set("Unavailable") self.map_area_size_var.set("N/A") # Area size doesn't apply self.last_valid_point_coords = (original_latitude, original_longitude) # Coords were valid, but no data elif isinstance(elevation_result, float) and math.isnan(elevation_result): res_txt += "Point on NoData area." logger.info(f"GUI: Point elevation task completed, NoData for ({original_latitude:.5f},{original_longitude:.5f}).") # MODIFIED: Update Map Info fields for NoData state. # WHY: Show results in the GUI panel. # HOW: Set StringVars. self.map_lat_decimal_var.set(f"{original_latitude:.5f}") self.map_lon_decimal_var.set(f"{original_longitude:.5f}") self.map_lat_dms_var.set(deg_to_dms_string(original_latitude, 'lat')) self.map_lon_dms_var.set(deg_to_dms_string(original_longitude, 'lon')) self.map_elevation_var.set("NoData") self.map_area_size_var.set("N/A") # Area size doesn't apply self.last_valid_point_coords = (original_latitude, original_longitude) # Valid coords, result is NoData else: res_txt += f"Elevation {elevation_result:.2f}m" logger.info( f"GUI: Point elevation task completed, elevation {elevation_result:.2f}m for ({original_latitude:.5f},{original_longitude:.5f})." ) # MODIFIED: Update Map Info fields for successful elevation retrieval. # WHY: Show results in the GUI panel. # HOW: Set StringVars. self.map_lat_decimal_var.set(f"{original_latitude:.5f}") self.map_lon_decimal_var.set(f"{original_longitude:.5f}") self.map_lat_dms_var.set(deg_to_dms_string(original_latitude, 'lat')) self.map_lon_dms_var.set(deg_to_dms_string(original_longitude, 'lon')) self.map_elevation_var.set(f"{elevation_result:.2f} m") self.map_area_size_var.set("N/A") # Area size doesn't apply self.last_valid_point_coords = (original_latitude, original_longitude) # Valid coords, got elevation self.point_result_label.config(text=res_txt) # MODIFIED: Re-enable point-specific visualization buttons if valid coords were obtained. # WHY: These actions require a valid point location. # HOW: Check if last_valid_point_coords is set and dependency libraries are available. # The _set_busy_state(False) call already handles re-enabling based on last_valid_point_coords, # but let's ensure the state is correctly set before clearing busy. if self.last_valid_point_coords: if MATPLOTLIB_AVAILABLE and PIL_AVAILABLE: self.show_2d_browse_button.config(state=tk.NORMAL) if MATPLOTLIB_AVAILABLE: self.show_3d_dem_button.config(state=tk.NORMAL) if MAP_VIEWER_SYSTEM_AVAILABLE: self.view_map_for_point_button.config(state=tk.NORMAL) else: # Ensure buttons are disabled if no valid point was obtained self.show_2d_browse_button.config(state=tk.DISABLED) self.show_3d_dem_button.config(state=tk.DISABLED) if MAP_VIEWER_SYSTEM_AVAILABLE: self.view_map_for_point_button.config(state=tk.DISABLED) self._set_busy_state(False) # Clear busy state, which also updates button states def _trigger_download_area_task(self) -> None: """Starts a background thread to download tiles for an area.""" if self.is_processing_task or not self.elevation_manager: return bounds = self._validate_area_boundary_coordinates() if not bounds: # MODIFIED: Clear area status and related map info fields if area validation fails. # WHY: Avoids showing stale info. # HOW: Update labels and Map Info StringVars. self.area_download_status_label.config(text="Status: Invalid input.") self.last_area_coords = None # Also clear map info fields that are area-related or generic self.map_area_size_var.set("N/A") # Optionally clear point-specific map info fields too if they don't apply to area context # self.map_lat_decimal_var.set("N/A") # Decide if we want to clear point info on area task start # self.map_lon_decimal_var.set("N/A") # self.map_lat_dms_var.set("N/A") # self.map_lon_dms_var.set("N/A") # self.map_elevation_var.set("N/A") return # Exit if validation fails self.last_area_coords = bounds # Store validated bounds # MODIFIED: Start busy state and update status label immediately on GUI thread. # WHY: Provide immediate visual feedback that processing has started. # HOW: Call _set_busy_state(True) and update label text. self._set_busy_state(True) self.area_download_status_label.config(text="Status: Starting download task... Please wait.") # MODIFIED: Clear Map Info fields when a new Download Area task starts. # WHY: Indicate that the context has shifted to an area task. # HOW: Set StringVar values to "..." or empty. self.map_lat_decimal_var.set("N/A") # Point info not relevant for area view self.map_lon_decimal_var.set("N/A") self.map_lat_dms_var.set("N/A") # Point info not relevant for area view self.map_lon_dms_var.set("N/A") # Point info not relevant for area view self.map_elevation_var.set("N/A") # Point info not relevant for area view self.map_area_size_var.set("...") # Show progress for area size info related to map self.root.update_idletasks() # Force GUI update # Start download in a background thread dl_thread = threading.Thread(target=self._perform_background_area_download, args=bounds, daemon=True) dl_thread.start() def _perform_background_area_download(self, min_l: float, min_o: float, max_l: float, max_o: float) -> None: """Background task for downloading area tiles.""" status_str, success_bool, processed_count, obtained_count = "Status: Unknown error.", False, 0, 0 try: # Update status label on the GUI thread using root.after self.root.after(0, lambda: self.area_download_status_label.config(text="Status: Downloading...")) if self.elevation_manager: logger.info(f"GUI Thread: Starting background download for area: Lat [{min_l:.2f}-{max_l:.2f}], Lon [{min_o:.2f}-{max_o:.2f}].") # Call ElevationManager method to perform the download processed_count, obtained_count = self.elevation_manager.download_area(min_l, min_o, max_l, max_o) status_str = f"Status: Complete. Processed {processed_count}, Obtained {obtained_count} HGT." success_bool = True logger.info(f"GUI Thread: Background download complete: {status_str}") else: status_str = "Status: Error - Elevation Manager N/A." logger.error("GUI Thread: Background download failed - Elevation Manager is None.") except Exception as e: logger.exception("GUI Thread Error: Unhandled exception during area download task.") status_str = f"Status: Error: {type(e).__name__}" finally: # Update GUI and re-enable buttons on the GUI thread regardless of success/failure self.root.after(0, self._area_download_task_complete_ui_update, status_str, success_bool, processed_count, obtained_count) def _area_download_task_complete_ui_update(self, msg: str, success: bool, proc: int, obt: int) -> None: """Updates GUI after area download task finishes.""" self.area_download_status_label.config(text=msg) # MODIFIED: Re-enable area visualization buttons if the download was successful. # WHY: These actions require downloaded area data. # HOW: Check success flag and library availability. if success: summary = f"Processed {proc} tile locations.\nSuccessfully obtained {obt} HGT files (downloaded or found in cache)." messagebox.showinfo("Download Complete", summary, parent=self.root) if MATPLOTLIB_AVAILABLE and PIL_AVAILABLE: # Re-enable composite image button only if at least one tile was obtained self.show_area_composite_button.config(state=tk.NORMAL if obt > 0 else tk.DISABLED) if MAP_VIEWER_SYSTEM_AVAILABLE: # Re-enable area map button only if at least one tile was obtained self.view_map_for_area_button.config(state=tk.NORMAL if obt > 0 else tk.DISABLED) else: err_brief = msg.split(":")[-1].strip() if ":" in msg else "An error occurred." messagebox.showerror("Download Error", f"Area download failed:\n{err_brief}\nCheck logs for details.", parent=self.root) # MODIFIED: Ensure area visualization buttons are disabled on failure. # WHY: No data available for visualization. # HOW: Explicitly disable buttons. self.show_area_composite_button.config(state=tk.DISABLED) if MAP_VIEWER_SYSTEM_AVAILABLE: self.view_map_for_area_button.config(state=tk.DISABLED) self._set_busy_state(False) # Clear busy state, which also updates button states def _start_visualization_process(self, func: callable, args_t: tuple, name_id: str) -> None: """Helper to start a visualization task in a separate process.""" logger.info(f"GUI: Attempting to start visualization process '{name_id}'...") try: # Configure logging for the child process if needed (optional, can also configure in target function) # multiprocessing.log_to_stderr(logging.DEBUG) # Example: uncomment for verbose process logging proc = multiprocessing.Process(target=func, args=args_t, daemon=True, name=name_id) proc.start() logger.info(f"GUI: Started process '{name_id}' (PID: {proc.pid}).") # Note: The GUI main thread does not wait for these visualization processes to finish. except Exception as e: logger.exception(f"GUI Error: Failed to start visualization process '{name_id}'.") messagebox.showerror("Process Error", f"Could not start {name_id}:\n{e}", parent=self.root) def _trigger_2d_browse_display(self) -> None: """Triggers display of the browse image for the last valid point.""" if not (MATPLOTLIB_AVAILABLE and PIL_AVAILABLE): messagebox.showwarning("Dependencies Missing", "Matplotlib or Pillow not available.", parent=self.root); return if not self.last_valid_point_coords or not self.elevation_manager: messagebox.showinfo("Info", "Please get elevation for a point first.", parent=self.root); return lat, lon = self.last_valid_point_coords logger.info(f"GUI: Requesting 2D browse image display for ({lat:.5f},{lon:.5f}).") # get_tile_info ensures browse image download is *attempted*, but doesn't guarantee success. info = self.elevation_manager.get_tile_info(lat, lon) # Pass image path and tile name to the target process if info and info.get("browse_available") and info.get("browse_image_path"): browse_img_path = info["browse_image_path"] tile_name = info.get("tile_base_name","?") window_title = f"Browse: {tile_name.upper()}" # MODIFIED: Get geographic bounds for the single tile. # WHY: Needed to pass as extent to Matplotlib for correct aspect ratio. # HOW: Use get_hgt_tile_geographic_bounds and format as [west, east, south, north]. tile_geo_bounds = None if info.get("hgt_available"): # Get bounds only if HGT is available (lat/lon coords should be in info) tile_lat_coord = info.get("latitude_coord") tile_lon_coord = info.get("longitude_coord") if tile_lat_coord is not None and tile_lon_coord is not None: bounds_tuple = get_hgt_tile_geographic_bounds(tile_lat_coord, tile_lon_coord) if bounds_tuple: # Format as [left, right, bottom, top] which is [west, east, south, north] tile_geo_bounds = [bounds_tuple[0], bounds_tuple[2], bounds_tuple[1], bounds_tuple[3]] logger.debug(f"Passing geographic extent {tile_geo_bounds} for browse image.") else: logger.warning("Could not get precise geographic bounds for single tile, aspect ratio might be incorrect.") else: logger.warning("Tile coordinates missing in info, cannot get precise geographic bounds for browse image aspect ratio.") else: logger.warning("HGT not available for tile, cannot get precise geographic bounds for browse image aspect ratio.") # Proceed without extent, image will be displayed assuming square pixels. # The process target will load and display the image from the path # MODIFIED: Pass the extent to the process target. # WHY: So the target function can pass it to show_image_matplotlib. # HOW: Added extent to the args tuple. args = (browse_img_path, tile_name, window_title, tile_geo_bounds) self._start_visualization_process(process_targets.process_target_show_image, args, "2DBrowse") # Call imported target else: logger.warning(f"GUI: Browse image not available for ({lat:.5f},{lon:.5f}).") messagebox.showinfo("Image Info", "Browse image not available for this tile.", parent=self.root) def _trigger_3d_dem_display(self) -> None: """Triggers display of the 3D DEM plot for the last valid point.""" if not MATPLOTLIB_AVAILABLE: messagebox.showwarning("Dependencies Missing", "Matplotlib not available.", parent=self.root); return # SciPy is optional for advanced features but required by the plotting function implementation # Let's make sure we warn the user if SciPy is needed for full functionality if not SCIPY_AVAILABLE: logger.warning("GUI: SciPy not available. Advanced 3D plot features (smoothing/interpolation) will be disabled.") # Decide if we want to show a warning box here or just rely on the log. # messagebox.showwarning("Dependencies Missing", "SciPy not available. Advanced 3D features disabled.", parent=self.root) if not self.last_valid_point_coords or not self.elevation_manager: messagebox.showinfo("Info", "Please get elevation for a point first.", parent=self.root); return lat, lon = self.last_valid_point_coords logger.info(f"GUI: Requesting 3D DEM display for ({lat:.5f},{lon:.5f}).") # get_hgt_data ensures HGT download is *attempted*. data = self.elevation_manager.get_hgt_data(lat, lon) if data is not None: # Retrieve tile info again for tile name, even if HGT data was obtained directly. # get_tile_info won't re-download if HGT is already there. info = self.elevation_manager.get_tile_info(lat, lon) tile_name = info.get("tile_base_name","?") if info else "?" plot_title = f"3D View: Tile {tile_name.upper()}" # Configuration parameters for the 3D plot processing (passed to the target process) # These can be made configurable in the GUI if needed later. config_initial_subsample = 1 # Initial subsampling factor of the raw data config_smooth_sigma = 0.5 # Sigma for Gaussian smoothing (set to None to disable) config_interpolation_factor = 3 # Interpolation factor (e.g., 3x for 3x denser grid) config_plot_grid_points = 300 # Target number of points along each axis for the final plot grid # Pass the raw NumPy array and config to the target process # The process target will handle data processing (subsampling, smoothing, interpolation) and plotting. args = (data, plot_title, config_initial_subsample, config_smooth_sigma, config_interpolation_factor, config_plot_grid_points) self._start_visualization_process(process_targets.process_target_show_3d, args, "3DDEM") # Call imported target else: logger.warning(f"GUI: HGT data not available for ({lat:.5f},{lon:.5f}). Cannot show 3D plot.") messagebox.showerror("3D Data Error", "Could not retrieve HGT data for this tile.", parent=self.root) def _trigger_area_composite_display(self) -> None: """Triggers display of the composite browse image for the last downloaded area.""" if not (MATPLOTLIB_AVAILABLE and PIL_AVAILABLE): messagebox.showwarning("Dependencies Missing", "Matplotlib or Pillow not available.", parent=self.root); return if not self.last_area_coords or not self.elevation_manager: messagebox.showinfo("Info", "Please download an area first.", parent=self.root); return min_l, min_o, max_l, max_o = self.last_area_coords logger.info(f"GUI: Requesting area composite display for area: Lat [{min_l:.2f}-{max_l:.2f}], Lon [{min_o:.2f}-{max_o:.2f}]") # get_area_tile_info returns info for tiles that *should* cover the area, # and includes paths to locally available files (doesn't trigger download here). info_list = self.elevation_manager.get_area_tile_info(min_l, min_o, max_l, max_o) # Check if any browse images are potentially available before starting the process available_browse_images = [ info for info in info_list if info.get("browse_available") and info.get("browse_image_path") ] if not available_browse_images: logger.warning("GUI: No browse images found for the downloaded area.") messagebox.showinfo("Area Info", "No browse images were found locally for the specified area.", parent=self.root); return # MODIFIED: Calculate combined geographic bounds for all relevant tiles in the area. # WHY: Needed to pass as extent to Matplotlib for correct aspect ratio of the composite. # HOW: Use get_combined_geographic_bounds_from_tile_info_list on the full info_list. combined_geo_bounds = get_combined_geographic_bounds_from_tile_info_list(info_list) composite_extent = None if combined_geo_bounds: # Format as [left, right, bottom, top] which is [west, east, south, north] composite_extent = [combined_geo_bounds[0], combined_geo_bounds[2], combined_geo_bounds[1], combined_geo_bounds[3]] logger.debug(f"Passing combined geographic extent {composite_extent} for area composite.") else: logger.warning("Could not get combined geographic bounds for area composite, aspect ratio might be incorrect.") # Proceed without extent. title = f"Area: Lat [{min_l:.1f}-{max_l:.1f}], Lon [{min_o:.1f}-{max_o:.1f}]" # Pass the filtered list of available info dicts to the process # The process target will create the composite image from these. # MODIFIED: Pass the combined extent to the process target. # WHY: So the target function can pass it to show_image_matplotlib. # HOW: Added extent to the args tuple. args = (available_browse_images, title, composite_extent) self._start_visualization_process(process_targets.process_target_create_show_area, args, "AreaComposite") # Call imported target def _start_map_viewer_process(self, base_args_tuple: tuple) -> None: """Helper to start the map viewer process.""" if not MAP_VIEWER_SYSTEM_AVAILABLE: messagebox.showerror("Map System Unavailable", "Map viewer dependencies (OpenCV, etc.) are not available.", parent=self.root); return if not self.map_interaction_message_queue: logger.error("GUI: Cannot start map viewer: interaction queue not initialized."); messagebox.showerror("Internal Error", "Map communication queue not ready.", parent=self.root); return # Check if a map viewer process is already running if self.map_viewer_process_handle and self.map_viewer_process_handle.is_alive(): messagebox.showinfo("Info", "Map viewer is already open.", parent=self.root) logger.info("GUI: Map viewer process already alive.") return logger.info("GUI: Attempting to start map viewer process...") try: # The target function `run_map_viewer_process_target` requires the DEM cache directory path. # This was stored during GUI initialization. dem_cache_dir = self._dem_data_cache_dir_for_map_process # Include the DEM cache directory and display scale in the arguments tuple. # base_args_tuple already contains (map_interaction_q, operation_mode, center_latitude, center_longitude, area_bounding_box). # Add dem_cache_dir and display_scale_factor to the end. scale_val = self.map_display_scale_factor_var.get() full_args = base_args_tuple + (dem_cache_dir, scale_val) self.map_viewer_process_handle = multiprocessing.Process( target=process_targets.run_map_viewer_process_target, # Call imported target args=full_args, daemon=True, # Daemon process will be terminated when the main GUI process exits name="GeoElevationMapViewer" ) self.map_viewer_process_handle.start() logger.info(f"GUI: Started map viewer process PID: {self.map_viewer_process_handle.pid}, Scale: {scale_val:.3f}.") # Ensure the GUI starts checking the queue for messages from the new process. # The initial `self.root.after` call in __init__ handles this, but doesn't hurt to be sure. # self.root.after(100, self._process_map_interaction_queue_messages) # Already scheduled except Exception as e: logger.exception("GUI Error: Failed to start map viewer process.") messagebox.showerror("Process Error",f"Could not start map viewer:\n{e}",parent=self.root) def _trigger_view_map_for_point(self) -> None: """Triggers the display of a map centered on the last valid point.""" # MODIFIED: Check map system availability first. # WHY: Prevent starting process logic if dependencies are missing. # HOW: Added initial check. if not MAP_VIEWER_SYSTEM_AVAILABLE: messagebox.showerror("Map System Unavailable", "Map viewer dependencies are not available.", parent=self.root); return if not self.last_valid_point_coords: messagebox.showinfo("Info", "Please get elevation for a point first.", parent=self.root); return lat, lon = self.last_valid_point_coords logger.info(f"GUI: Requesting map view for point ({lat:.5f},{lon:.5f}).") # MODIFIED: Clear Map Info fields when starting a new map view. # WHY: Indicate that new map information is loading. # HOW: Set StringVar values. self.map_lat_decimal_var.set("...") # Indicate loading self.map_lon_decimal_var.set("...") self.map_lat_dms_var.set("...") # Indicate loading for DMS fields self.map_lon_dms_var.set("...") # Indicate loading for DMS fields self.map_elevation_var.set("...") self.map_area_size_var.set("N/A (Point)") # Indicate this is for a point # Prepare base arguments for the map viewer process target function. # It needs the queue, mode, point coords, area bbox (None for point mode), and cache dir (added in _start_map_viewer_process). # The display scale is also added in _start_map_viewer_process. base_args = (self.map_interaction_message_queue, "point", lat, lon, None) # Call the helper to start the process self._start_map_viewer_process(base_args) def _trigger_view_map_for_area(self) -> None: """Triggers the display of a map covering the last downloaded area.""" # MODIFIED: Check map system availability first. # WHY: Prevent starting process logic if dependencies are missing. # HOW: Added initial check. if not MAP_VIEWER_SYSTEM_AVAILABLE: messagebox.showerror("Map System Unavailable", "Map viewer dependencies are not available.", parent=self.root); return if not self.last_area_coords: messagebox.showinfo("Info", "Please download an area first.", parent=self.root); return min_l, min_o, max_l, max_o = self.last_area_coords logger.info(f"GUI: Requesting map view for area Lat [{min_l:.2f}-{max_l:.2f}], Lon [{min_o:.2f}-{max_o:.2f}].") # MODIFIED: Clear Map Info fields when starting a new map view. # WHY: Indicate that new map information is loading. # HOW: Set StringVar values. Point info N/A for area view. self.map_lat_decimal_var.set("N/A") # Point info not relevant for area view self.map_lon_decimal_var.set("N/A") self.map_lat_dms_var.set("N/A") # Point info not relevant for area view self.map_lon_dms_var.set("N/A") # Point info not relevant for area view self.map_elevation_var.set("N/A") # Point info not relevant for area view self.map_area_size_var.set("Loading...") # Prepare base arguments for the map viewer process target function. # It needs the queue, mode, point coords (None for area mode), area bbox, and cache dir (added in _start_map_viewer_process). # The display scale is also added in _start_map_viewer_process. area_bbox_for_process = (min_o, min_l, max_o, max_l) # Pass as (west, south, east, north) as expected by map_utils/geo_map_viewer base_args = (self.map_interaction_message_queue, "area", None, None, area_bbox_for_process) # Call the helper to start the process self._start_map_viewer_process(base_args) def _process_map_interaction_queue_messages(self) -> None: """Periodically checks the queue for messages from the map viewer process and updates the GUI.""" if not self.map_interaction_message_queue: logger.debug("GUI: Map interaction queue is not initialized, stopping message processing.") return # Stop scheduling if queue is gone try: # Process all messages currently in the queue without blocking while not self.map_interaction_message_queue.empty(): msg = self.map_interaction_message_queue.get_nowait() msg_t = msg.get("type") # MODIFIED: Handle the new message type 'map_info_update'. # WHY: This message is sent by the map process for initial point info, click info, and errors. # HOW: Check for the type and update all relevant Map Info labels. # MODIFIED: Update Entry widgets using StringVar and handle new DMS fields from message. # WHY: Populate the copyable Entry widgets and show DMS coordinates. # HOW: Use set() on StringVars, access new keys in the message payload. if msg_t == "map_info_update": logger.debug("GUI: Received 'map_info_update' message from map process.") # MODIFIED: Stop the progress window if it's running. # WHY: A map_info_update message signifies that the fetching/processing is complete (or errored). # HOW: Call _stop_map_fetch_progress_window(). self._stop_map_fetch_progress_window() # Get data from the message payload lat_val = msg.get("latitude") lon_val = msg.get("longitude") lat_dms_str = msg.get("latitude_dms_str", "N/A") # Get DMS strings from message lon_dms_str = msg.get("longitude_dms_str", "N/A") # Get DMS strings from message elev_str = msg.get("elevation_str", "N/A") map_area_size_str = msg.get("map_area_size_str", "N/A") # Format decimal coordinates for display in Entry lat_txt_decimal = f"{lat_val:.5f}" if isinstance(lat_val, (int, float)) and math.isfinite(lat_val) else "N/A" lon_txt_decimal = f"{lon_val:.5f}" if isinstance(lon_val, (int, float)) and math.isfinite(lon_val) else "N/A" # Update the StringVars linked to the Entry widgets self.map_lat_decimal_var.set(lat_txt_decimal) self.map_lon_decimal_var.set(lon_txt_decimal) self.map_lat_dms_var.set(lat_dms_str) # Set DMS strings directly from message self.map_lon_dms_var.set(lon_dms_str) # Set DMS strings directly from message self.map_elevation_var.set(elev_str) self.map_area_size_var.set(map_area_size_str) logger.debug(f"GUI: Updated Map Info panel with: Lat={lat_txt_decimal}, Lon={lon_txt_decimal}, LatDMS='{lat_dms_str}', LonDMS='{lon_dms_str}', Elev='{elev_str}', Size='{map_area_size_str}'") # MODIFIED: Handle the new message type 'map_fetching_status'. # WHY: Receive progress/status updates specifically for map tile fetching. # HOW: Update the map info labels with the status message. # MODIFIED: Update Entry widgets using StringVars for status messages. # WHY: Show status in copyable Entry widgets. # HOW: Use set() on StringVars. # MODIFIED: Show the progress window when a fetching status message is received. # WHY: To provide the user with a visual indication that map data is being fetched. # HOW: Call _start_map_fetch_progress_window(). elif msg_t == "map_fetching_status": logger.debug("GUI: Received 'map_fetching_status' message from map process.") status_message = msg.get("status", "Fetching...") # Show the progress window with the received status message self._start_map_fetch_progress_window(status_message) # Update all map info labels to show the status, maybe clear coords/elev temporarily self.map_lat_decimal_var.set("...") # Indicate fetching/processing self.map_lon_decimal_var.set("...") self.map_lat_dms_var.set("...") # Indicate fetching/processing for DMS self.map_lon_dms_var.set("...") # Indicate fetching/processing for DMS self.map_elevation_var.set(status_message) # Show status as elevation text self.map_area_size_var.set("...") # Indicate fetching/processing # elif msg_t == "another_message_type": # # Handle other message types if needed in the future # pass else: # Log any messages with unrecognized types logger.warning(f"GUI: Received unrecognized message type from map queue: {msg_t} (Full message: {msg})") except queue.Empty: # This exception is expected when the queue is empty, just means no messages were ready. pass except Exception as e: # Catch any other unexpected errors while processing messages logger.error(f"GUI Error: Exception during map queue message processing: {e}", exc_info=True) # Optional: Update GUI status to indicate communication error # MODIFIED: Update Map Info fields with a communication error state. # WHY: Provide feedback if there's an error processing queue messages. # HOW: Set StringVars with error text. self.map_lat_decimal_var.set("Comm Error") self.map_lon_decimal_var.set("Comm Error") self.map_lat_dms_var.set("Comm Error") self.map_lon_dms_var.set("Comm Error") self.map_elevation_var.set(f"Comm Error: {type(e).__name__}") self.map_area_size_var.set("Comm Error") # MODIFIED: Stop the progress window on any processing error. # WHY: The error indicates the operation failed, so the waiting window should be closed. # HOW: Call _stop_map_fetch_progress_window(). self._stop_map_fetch_progress_window() finally: # Schedule this method to run again after a short delay to keep checking the queue. # Only reschedule if the root window still exists. if self.root.winfo_exists(): self.root.after(250, self._process_map_interaction_queue_messages) else: logger.debug("GUI: Root window no longer exists, stopping map queue message processing scheduling.") # MODIFIED: Added helper method to start the map fetch progress window. # WHY: Centralizes the logic for creating and starting the progress window thread. # HOW: Created a new method that checks if a window is already running and starts a new one if not. def _start_map_fetch_progress_window(self, message: str = "Fetching map data...") -> None: """Starts the non-blocking map fetch progress window.""" # Only start a new window if one isn't already active # MODIFIED: Check if _LibraryProgressWindow class is available before instantiating. # WHY: The import might have failed. # HOW: Added `if _LibraryProgressWindow is not None`. if _LibraryProgressWindow is not None and (self._map_fetch_progress_window is None or not self._map_fetch_progress_window.is_alive()): logger.debug(f"GUI: Starting map fetch progress window with message: '{message}'") try: self._map_fetch_progress_window = _LibraryProgressWindow( window_title_text="GeoElevation - Map Loading", progress_message_text=message ) self._map_fetch_progress_window.start() # We don't necessarily need to wait for is_tk_ready_event here in the GUI thread, # as we only interact with the window via request_progress_window_stop() later, # which is thread-safe. except Exception as e_start_prog_win: logger.error(f"GUI Error: Failed to start map fetch progress window: {e_start_prog_win}", exc_info=True) self._map_fetch_progress_window = None # Ensure reference is cleared on failure elif _LibraryProgressWindow is None: logger.warning("GUI: Cannot start map fetch progress window: _LibraryProgressWindow class is not available.") else: logger.debug("GUI: Map fetch progress window already running. Not starting a new one.") # If a window is already running, update its message if needed? # The current _LibraryProgressWindow doesn't have a public method to update the message. # For now, just let it continue with its current message. # MODIFIED: Added helper method to stop the map fetch progress window. # WHY: Centralizes the logic for signaling the progress window thread to stop and cleaning up. # HOW: Created a new method that checks if a window is running and requests it to stop. # MODIFIED: Added logic to explicitly send a quit() signal to the Tkinter mainloop # within the progress window thread using after(0,...). # WHY: This ensures that the mainloop in the progress thread processes the stop # request promptly and terminates, making the join() more reliable. # HOW: Added the `self._map_fetch_progress_window.tkinter_root.after(0, self._map_fetch_progress_window.tkinter_root.quit)` call. def _stop_map_fetch_progress_window(self) -> None: """Signals the map fetch progress window to stop and attempts to join its thread.""" if self._map_fetch_progress_window is not None and self._map_fetch_progress_window.is_alive(): logger.debug("GUI: Signaling map fetch progress window to stop.") try: self._map_fetch_progress_window.request_progress_window_stop() # MODIFIED: Explicitly schedule a quit() call in the progress window's Tkinter thread. # WHY: To ensure the progress window's mainloop processes the stop signal and exits cleanly. # Using after(0, ...) schedules the quit call for the next idle moment in that thread's mainloop. # HOW: Added this block. Need to check if tkinter_root exists on the progress window instance. if self._map_fetch_progress_window.tkinter_root is not None: try: # Schedule quit() in the progress window's Tkinter thread self._map_fetch_progress_window.tkinter_root.after(0, self._map_fetch_progress_window.tkinter_root.quit) logger.debug("GUI: Scheduled quit() for progress window Tkinter thread.") except Exception as e_after_quit: # Catch errors if after or quit fail (e.g., root already destroyed) logger.warning(f"GUI Error scheduling quit() for progress window: {e_after_quit}") else: logger.warning("GUI: Progress window tkinter_root is None, cannot schedule quit().") # Wait briefly for the thread to exit cleanly # MODIFIED: Increased join timeout slightly. # WHY: Give the thread more time to process the quit() and terminate. # HOW: Changed timeout from 1.0 to 2.0 seconds. self._map_fetch_progress_window.join(timeout=2.0) # Wait for up to 2 seconds if self._map_fetch_progress_window.is_alive(): logger.warning("GUI: Map fetch progress window thread did not terminate cleanly after join.") # Regardless of join success, clear the reference. The daemon thread will eventually die. self._map_fetch_progress_window = None logger.debug("GUI: Map fetch progress window reference cleared.") except Exception as e_stop_prog_win: logger.error(f"GUI Error: Failed to stop map fetch progress window cleanly: {e_stop_prog_win}", exc_info=True) self._map_fetch_progress_window = None # Clear reference even on error elif self._map_fetch_progress_window is not None and not self._map_fetch_progress_window.is_alive(): # Window was running but already finished before we tried to stop it. Clean up reference. logger.debug("GUI: Map fetch progress window was already stopped.") self._map_fetch_progress_window = None # Clear the reference else: logger.debug("GUI: No map fetch progress window running to stop.") def _on_application_closing(self) -> None: """Handles cleanup when the main application window is closed.""" logger.info("Main GUI application closing...") # MODIFIED: Terminate the map viewer process if it's running. # WHY: The separate process should not be left running when the main application exits. # HOW: Check if the process handle exists and is alive, then attempt to terminate/kill it. # MODIFIED: Also stop the map fetch progress window thread on closing. # WHY: Ensure the progress window thread exits cleanly when the main GUI application closes. # HOW: Call _stop_map_fetch_progress_window(). self._stop_map_fetch_progress_window() # Ensure progress window is stopped if self.map_viewer_process_handle and self.map_viewer_process_handle.is_alive(): logger.info(f"GUI: Terminating active map viewer process PID: {self.map_viewer_process_handle.pid}...") try: # Use terminate (sends SIGTERM) first for graceful shutdown self.map_viewer_process_handle.terminate() # Wait a bit for the process to exit cleanly self.map_viewer_process_handle.join(timeout=3.0) # Increased timeout slightly # If it's still alive, resort to killing (SIGKILL) if self.map_viewer_process_handle.is_alive(): logger.warning(f"GUI: Map viewer process PID {self.map_viewer_process_handle.pid} did not terminate cleanly. Killing.") self.map_viewer_process_handle.kill() self.map_viewer_process_handle.join(timeout=1.0) # Wait briefly after killing if self.map_viewer_process_handle.is_alive(): logger.error(f"GUI: Map viewer process PID {self.map_viewer_process_handle.pid} is still alive after killing. May be orphaned.") else: logger.info(f"GUI: Map viewer process PID {self.map_viewer_process_handle.pid} has exited.") except Exception as e_terminate: logger.exception(f"GUI Error: Exception during map viewer process termination:") # MODIFIED: Close the multiprocessing queue if it exists. # WHY: Release resources associated with the queue. # HOW: Call close() and potentially join_thread(). if self.map_interaction_message_queue: logger.debug("GUI: Closing map interaction queue.") try: # Closing the queue signals that no more data will be added. self.map_interaction_message_queue.close() # join_thread() waits for the background thread that flushes the queue buffer. # Can sometimes block if the other end is gone or blocked. Use a timeout. # self.map_interaction_message_queue.join_thread(timeout=1.0) # Optional, may hang except Exception as e_queue_close: logger.warning(f"GUI Error: Exception closing map queue: {e_queue_close}") # Finally, destroy the main Tkinter window self.root.destroy() logger.info("Main GUI window destroyed.") # --- Main Execution Block (for direct script testing) --- if __name__ == "__main__": print("Running elevation_gui.py directly for testing purposes...") # Configure logging for the main process when running this script directly logging.basicConfig(level=logging.DEBUG, format="%(asctime)s-%(levelname)s-%(name)s-%(module)s-%(message)s", stream=sys.stdout) logger.info(f"TEST: Rasterio Avail: {RASTERIO_AVAILABLE}, PIL Avail: {PIL_AVAILABLE}, MPL Avail: {MATPLOTLIB_AVAILABLE}, SciPy Avail: {SCIPY_AVAILABLE}, MapViewerSys Avail: {MAP_VIEWER_SYSTEM_AVAILABLE}") # multiprocessing.freeze_support() is essential for creating standalone executables # that use multiprocessing, especially on Windows. It must be called within the # main block of the script that starts the other processes. # MODIFIED: Keep freeze_support() here in the __main__ block as it's the entry point for the GUI when run directly. # WHY: It's a requirement for multiprocessing when the script is frozen (e.g., by PyInstaller). # HOW: Keep the call here. It's also called in geoelevation/__main__.py, which is the # primary entry point for the package as a whole. Calling it here ensures it runs # if *this specific script* is executed directly (python elevation_gui.py), which is # only for testing but good practice. multiprocessing.freeze_support() # MODIFIED: Example of how to use the new DMS conversion function in test/debug. # WHY: Demonstrate usage of the new function. # HOW: Added print statements. test_lat = 45.56789 test_lon = -7.12345 print(f"Test DMS conversion for Lat {test_lat}: {deg_to_dms_string(test_lat, 'lat')}") print(f"Test DMS conversion for Lon {test_lon}: {deg_to_dms_string(test_lon, 'lon')}") test_lat_neg = -30.987 test_lon_pos = 150.65 print(f"Test DMS conversion for Lat {test_lat_neg}: {deg_to_dms_string(test_lat_neg, 'lat')}") print(f"Test DMS conversion for Lon {test_lon_pos}: {deg_to_dms_string(test_lon_pos, 'lon')}") # MODIFIED: Example of how to use the new combined bounds function in test/debug. # WHY: Demonstrate usage of the new function. # HOW: Added test data and print statements. test_tile_list_info = [ {'latitude_coord': 44, 'longitude_coord': 7, 'tile_base_name': 'n44e007', 'hgt_file_path': 'path/to/n44e007.hgt', 'browse_image_path': 'path/to/n44e007.jpg', 'hgt_available': True, 'browse_available': True}, {'latitude_coord': 45, 'longitude_coord': 7, 'tile_base_name': 'n45e007', 'hgt_file_path': 'path/to/n45e007.hgt', 'browse_image_path': 'path/to/n45e007.jpg', 'hgt_available': True, 'browse_available': True}, {'latitude_coord': 44, 'longitude_coord': 8, 'tile_base_name': 'n44e008', 'hgt_file_path': 'path/to/n44e008.hgt', 'browse_image_path': 'path/to/n44e008.jpg', 'hgt_available': True, 'browse_available': True}, {'latitude_coord': 45, 'longitude_coord': 8, 'tile_base_name': 'n45e008', 'hgt_file_path': 'path/to/n45e008.hgt', 'browse_image_path': 'path/to/n45e008.jpg', 'hgt_available': True, 'browse_available': True}, ] combined_bounds = get_combined_geographic_bounds_from_tile_info_list(test_tile_list_info) print(f"Test Combined Bounds: {combined_bounds}") test_root = tk.Tk() app_test_instance: Optional[ElevationApp] = None try: app_test_instance = ElevationApp(test_root) test_root.mainloop() except Exception as e_test_run: logger.critical(f"Fatal error in direct test run: {e_test_run}", exc_info=True) # Attempt cleanup on fatal error if app_test_instance: try: app_test_instance._on_application_closing() except Exception as e_cleanup: logger.error(f"Error during cleanup after fatal test run error: {e_cleanup}") elif test_root.winfo_exists(): # If app instance wasn't created, just destroy the root if it still exists test_root.destroy() sys.exit(1) # Exit with error code