# 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 from geoelevation.elevation_manager import ElevationManager from geoelevation.elevation_manager import RASTERIO_AVAILABLE 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: The previous import was attempting to use the internal name `DEFAULT_CACHE_DIR` which is not exposed # by geoelevation/__init__.py. The correct name exposed in __init__.py (and sourced from config.py) # is GEOELEVATION_DEM_CACHE_DEFAULT. This change aligns the import with the package's defined public API. # 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 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 a dummy deg_to_dms_string function if import fails. # WHY: Avoid NameError if the import fails critically. # HOW: Define a simple function that returns "Import Error". def deg_to_dms_string(degree_value: float, coord_type: str) -> str: # type: ignore return "Import Error" logging.getLogger(__name__).critical( "elevation_gui.py: CRITICAL - Could not import GEOELEVATION_DEM_CACHE_DEFAULT or deg_to_dms_string from geoelevation package. " f"Using fallback cache: {GEOELEVATION_DEM_CACHE_DEFAULT}. DMS conversion 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 --- # (Queste funzioni rimangono identiche alla versione precedente completa che ti ho fornito) def process_target_show_image(image_path: str, tile_name: str, window_title: str) -> None: try: from geoelevation.image_processor import load_prepare_single_browse as local_lpst from geoelevation.image_processor import PIL_AVAILABLE as local_pil_ok from geoelevation.visualizer import show_image_matplotlib as local_sim from geoelevation.visualizer import MATPLOTLIB_AVAILABLE as local_mpl_ok import os as local_os if not (local_pil_ok and local_mpl_ok): print("PROCESS ERROR (show_image): Pillow/Matplotlib missing in child process.") return prepared_image = local_lpst(image_path, tile_name) if prepared_image: print(f"PROCESS (show_image): Showing '{window_title}'") local_sim(prepared_image, window_title) else: print(f"PROCESS ERROR (show_image): Could not prepare {local_os.path.basename(image_path)}") except Exception as e_proc_img: print(f"PROCESS ERROR in process_target_show_image: {e_proc_img}") import traceback as tb tb.print_exc() def process_target_show_3d( hgt_data: Optional["np_typing.ndarray"], plot_title: str, initial_subsample: int, smooth_sigma: Optional[float], interpolation_factor: int, plot_grid_points: int ) -> None: try: from geoelevation.visualizer import show_3d_matplotlib as local_s3d from geoelevation.visualizer import MATPLOTLIB_AVAILABLE as local_mpl_ok from geoelevation.visualizer import SCIPY_AVAILABLE as local_scipy_ok if not local_mpl_ok: print("PROCESS ERROR (show_3d): Matplotlib missing in child process.") return effective_smooth_sigma = smooth_sigma effective_interpolation_factor = interpolation_factor if (interpolation_factor > 1 or smooth_sigma is not None) and not local_scipy_ok: print("PROCESS WARNING (show_3d): SciPy missing in child. Disabling 3D plot smoothing/interpolation.") effective_smooth_sigma = None effective_interpolation_factor = 1 if hgt_data is not None: print(f"PROCESS (show_3d): Plotting '{plot_title}' (InitialSub:{initial_subsample}, Smooth:{effective_smooth_sigma}, Interp:{effective_interpolation_factor}x, PlotGridTarget:{plot_grid_points})") local_s3d(hgt_data, plot_title, initial_subsample, effective_smooth_sigma, effective_interpolation_factor, plot_grid_points) else: print("PROCESS ERROR (show_3d): No HGT data array received for plotting.") except Exception as e_proc_3d: print(f"PROCESS ERROR in process_target_show_3d: {e_proc_3d}") import traceback as tb tb.print_exc() def process_target_create_show_area(tile_info_list_data: List[Dict], window_title_str: str) -> None: try: from geoelevation.image_processor import create_composite_area_image as local_ccai from geoelevation.image_processor import PIL_AVAILABLE as local_pil_ok from geoelevation.visualizer import show_image_matplotlib as local_sim from geoelevation.visualizer import MATPLOTLIB_AVAILABLE as local_mpl_ok if not (local_pil_ok and local_mpl_ok): print("PROCESS ERROR (show_area): Pillow/Matplotlib libraries are missing in the child process.") return print("PROCESS (show_area): Creating composite area image...") composite_pil_image = local_ccai(tile_info_list_data) if composite_pil_image: print(f"PROCESS (show_area): Displaying composite image '{window_title_str}'") local_sim(composite_pil_image, window_title_str) else: print("PROCESS ERROR (show_area): Failed to create composite area image.") except Exception as e_proc_area: print(f"PROCESS ERROR in process_target_create_show_area: {e_proc_area}") import traceback as tb tb.print_exc() def run_map_viewer_process_target( map_interaction_q: multiprocessing.Queue, operation_mode: str, center_latitude: Optional[float], center_longitude: Optional[float], area_bounding_box: Optional[Tuple[float, float, float, float]], dem_data_cache_dir: str, display_scale_factor: float ) -> None: child_logger = logging.getLogger("GeoElevationMapViewerChildProcess") # Configure logger if it hasn't been configured in this process yet if not child_logger.hasHandlers(): # Use basicConfig as this is a fresh process, ensure it doesn't inherit handlers from root # and send output to sys.stdout/stderr which is captured by the parent process's terminal. # Level should ideally be inherited or passed, but setting INFO here as a default. # Also ensure the geoelevation logger in this process has a handler for its messages to be seen. logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", stream=sys.stdout # Explicitly route to stdout ) # Ensure geoelevation logger sends messages if its level is below root default (e.g. INFO) geoelevation_proc_logger = logging.getLogger("geoelevation") if not geoelevation_proc_logger.hasHandlers(): geoelevation_proc_logger.addHandler(logging.StreamHandler(sys.stdout)) # Add a stream handler child_map_viewer_instance: Optional[Any] = None child_map_system_ok = False try: # Attempt to import components needed in the child process from geoelevation.map_viewer.geo_map_viewer import GeoElevationMapViewer as ChildGeoMapViewer from geoelevation.elevation_manager import ElevationManager as ChildElevationManager import cv2 as child_cv2 # Ensure cv2 is available in the child process child_map_system_ok = True except ImportError as e_child_imp_map_corrected: child_logger.error(f"Map viewer components or OpenCV not found in child process: {e_child_imp_map_corrected}") # MODIFIED: Send an error message back to the GUI queue if critical imports fail in child process. # WHY: The GUI process needs to know that the map process failed to start properly. # HOW: Put a specific error message in the queue before exiting. # 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": f"Fatal Error: {type(e_child_imp_map_corrected).__name__}", "map_area_size_str": "Map System N/A"} try: map_interaction_q.put(error_payload) except Exception as e_put_err: child_logger.error(f"Failed to put error payload to queue before exit: {e_put_err}") # Note: No return here yet, the finally block will execute. # Let's add a return right after sending the error. return # Exit if critical libraries are missing # MODIFIED: Added the main operational logic for the map viewer process. # WHY: Centralizes the try/except/finally for the core map viewing loop. # HOW: Wrapped the map viewer initialization and display logic in a try block. try: child_logger.info(f"Initializing for mode '{operation_mode}', display scale: {display_scale_factor:.2f}") # MODIFIED: Pass the correct DEM cache directory path to the child ElevationManager. # WHY: The map viewer process needs its own ElevationManager instance, configured with the same cache path. # HOW: Used the dem_data_cache_dir parameter passed from the GUI. local_em = ChildElevationManager(tile_directory=dem_data_cache_dir) child_map_viewer_instance = ChildGeoMapViewer( elevation_manager_instance=local_em, gui_output_communication_queue=map_interaction_q, initial_display_scale=display_scale_factor ) if operation_mode == "point" and center_latitude is not None and center_longitude is not None: # MODIFIED: Call display_map_for_point with the center coordinates. # WHY: This method contains the logic to stitch the map, draw markers, and send initial info. # HOW: Called the method on the initialized child_map_viewer_instance. child_map_viewer_instance.display_map_for_point(center_latitude, center_longitude) elif operation_mode == "area" and area_bounding_box: # MODIFIED: Call display_map_for_area with the area bounding box. # WHY: This method handles stitching the map for an area and potentially drawing a boundary. # HOW: Called the method on the initialized child_map_viewer_instance. child_map_viewer_instance.display_map_for_area(area_bounding_box) else: child_logger.error(f"Invalid mode ('{operation_mode}') or missing parameters passed to map process.") # MODIFIED: Send an error message to the GUI queue if the mode or parameters are invalid. # WHY: The GUI process needs feedback if the map process received bad instructions. # HOW: Put a specific error message in the queue before exiting. # 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": f"Fatal Error: Invalid Map Args", "map_area_size_str": "Invalid Args"} try: map_interaction_q.put(error_payload) except Exception as e_put_err: child_logger.error(f"Failed to put error payload to queue before exit: {e_put_err}") # No need to call shutdown here, as the instance might not be fully initialized return # Exit on invalid parameters child_logger.info("Map display method called. Entering event loop.") # The map display window (managed by MapDisplayWindow) runs its own internal # event loop when cv2.imshow is called periodically, and handles user input via callbacks. # The loop here just needs to keep the process alive and allow the window # to process events via waitKey. We also check if the window is still alive. is_map_active = True while is_map_active: # cv2.waitKey(milliseconds) is crucial for processing window events and mouse callbacks # A short delay (e.g., 100ms) prevents the loop from consuming 100% CPU. key = child_cv2.waitKey(100) # Check for key press every 100ms (optional, but good practice) # Check if the window still exists and is active. # is_window_alive() uses cv2.getWindowProperty which is thread-safe. if child_map_viewer_instance.map_display_window_controller: if child_map_viewer_instance.map_display_window_controller.is_window_alive(): # Optional: Check for specific keys to close the window (e.g., 'q' or Escape) if key != -1: # A key was pressed child_logger.debug(f"Map window received key press: {key}") # Convert key code to character if it's printable for logging try: char_key = chr(key & 0xFF) # Mask to get ASCII value logger.debug(f"Key code {key} corresponds to character '{char_key}'.") if char_key in ('q', 'Q'): # 'q' or 'Q' key child_logger.info("Map window closing due to 'q'/'Q' key press.") is_map_active = False # Signal loop to exit except ValueError: # Non-printable key pass # Just ignore non-printable keys if key == 27: # Escape key child_logger.info("Map window closing due to Escape key press.") is_map_active = False # Signal loop to exit else: child_logger.info("Map window reported as not alive by is_window_alive().") is_map_active = False # Signal loop to exit if window closed by user 'X' button else: child_logger.info("map_display_window_controller is None. Assuming window closed or initialization failed.") is_map_active = False # Signal loop to exit child_logger.info("Map window process event loop exited.") except Exception as e_map_proc_child_fatal_final: child_logger.error(f"Fatal error in map viewer process target: {e_map_proc_child_fatal_final}", exc_info=True) # MODIFIED: Send a fatal error message back to the GUI queue if an exception occurs in the main loop. # WHY: The GUI process needs to know if the map process crashed unexpectedly. # HOW: Put a specific error message in the queue before exiting. # 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": f"Fatal Error: {type(e_map_proc_child_fatal_final).__name__}", "map_area_size_str": "Fatal Error"} try: map_interaction_q.put(error_payload) except Exception as e_put_err: child_logger.error(f"Failed to put error payload to queue before exit: {e_put_err}") finally: # MODIFIED: Ensure shutdown is called regardless of how the process loop exits. # WHY: Cleans up OpenCV resources. # HOW: Moved the shutdown call into the finally block. if child_map_viewer_instance and hasattr(child_map_viewer_instance, 'shutdown'): child_map_viewer_instance.shutdown() child_logger.info("Terminated child map viewer process.") 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. """ 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: Replaced the hardcoded or previously mis-aliased variable name # with the correct imported constant. The try...except block ensures # a fallback is used if the import somehow fails. 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 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") 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) 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 Decimal/Elevation entries map_info_frame.columnconfigure(3, weight=1) # Column for DMS entries # --- 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("...") self.map_lon_decimal_var.set("...") self.map_lat_dms_var.set("...") # Clear DMS fields self.map_lon_dms_var.set("...") # Clear 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 # MODIFIED: Start the elevation retrieval in a separate thread. # WHY: Prevent the GUI from freezing while waiting for network or disk I/O. # HOW: Create and start a threading.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() # MODIFIED: Added a new background task function for point elevation retrieval. # WHY: Encapsulates the potentially blocking operation to run in a separate thread. # HOW: Created this method to call elevation_manager.get_elevation. 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. 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 doesn't apply 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("...") # Show progress for area size info related to map self.root.update_idletasks() # Force GUI update # Start download in a background thread to keep GUI responsive # Pass bounds as separate arguments to avoid potential issues with tuple packing/unpacking in target 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()}" # The process target will load and display the image from the path args = (browse_img_path, tile_name, window_title) self._start_visualization_process(process_target_show_image, args, "2DBrowse") 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_target_show_3d, args, "3DDEM") 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 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. args = (available_browse_images, title) self._start_visualization_process(process_target_create_show_area, args, "AreaComposite") 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) logger.error("GUI: Map viewer process start failed: MAP_VIEWER_SYSTEM_AVAILABLE is False.") 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=run_map_viewer_process_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("Loading...") # 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": # 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. elif msg_t == "map_fetching_status": status_message = msg.get("status", "Fetching...") # 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") 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.") 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. 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')}") 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