SXXXXXXX_GeoElevation/geoelevation/elevation_gui.py
2025-05-14 08:47:38 +02:00

1390 lines
84 KiB
Python

# 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("<<ComboboxSelected>>", 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