1247 lines
75 KiB
Python
1247 lines
75 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: This constant holds the path to the default DEM cache directory
|
|
# as configured in the.config.py module and exposed by __init__.py.
|
|
# HOW: Changed the import statement to directly import GEOELEVATION_DEM_CACHE_DEFAULT. The alias is no longer needed
|
|
# in the import statement itself, as the imported name is already the desired one. The existing try...except
|
|
# block correctly handles the case where this specific constant might not be available from the package.
|
|
try:
|
|
from geoelevation import GEOELEVATION_DEM_CACHE_DEFAULT
|
|
# MODIFIED: Import the deg_to_dms_string utility function from map_utils.py
|
|
# WHY: Needed to convert coordinates to DMS format for the GUI display.
|
|
# HOW: Added the import statement from the map_viewer.map_utils sub-module.
|
|
from geoelevation.map_viewer.map_utils import deg_to_dms_string
|
|
# MODIFIED: Import the utility functions for geographic bounds calculation from map_utils.py.
|
|
# WHY: Needed to get geographic bounds for 2D plot aspect ratio correction.
|
|
# HOW: Added the import statements.
|
|
from geoelevation.map_viewer.map_utils import get_hgt_tile_geographic_bounds # For single tile browse
|
|
from geoelevation.map_viewer.map_utils import get_combined_geographic_bounds_from_tile_info_list # For area composite
|
|
# MODIFIED: Import the multiprocessing target functions from the new module.
|
|
# WHY: These functions have been moved to their own module as part of refactoring.
|
|
# HOW: Added the import statement.
|
|
from geoelevation import process_targets # Import the new module containing targets
|
|
|
|
|
|
except ImportError:
|
|
# This fallback is if the main __init__.py itself has issues exporting the constant,
|
|
# which shouldn't happen if __init__.py and config.py are correct.
|
|
GEOELEVATION_DEM_CACHE_DEFAULT = "elevation_data_cache_gui_fallback_critical"
|
|
# MODIFIED: Define dummy deg_to_dms_string and bounds functions if import fails.
|
|
# WHY: Avoid NameError if the imports fail critically.
|
|
# HOW: Define simple functions that return default/error values.
|
|
def deg_to_dms_string(degree_value: float, coord_type: str) -> str: # type: ignore
|
|
return "Import Error"
|
|
|
|
# Dummy bounds functions
|
|
def get_hgt_tile_geographic_bounds(lat_coord: int, lon_coord: int) -> Optional[Tuple[float, float, float, float]]: # type: ignore
|
|
logger.error("map_utils bounds functions not available due to import error.")
|
|
return None
|
|
|
|
def get_combined_geographic_bounds_from_tile_info_list(tile_info_list: List[Dict]) -> Optional[Tuple[float, float, float, float]]: # type: ignore
|
|
logger.error("map_utils bounds functions not available due to import error.")
|
|
return None
|
|
|
|
# MODIFIED: Define dummy process_targets object if the module import fails.
|
|
# WHY: Prevent NameError when trying to access process_targets attributes.
|
|
# HOW: Define a simple dummy object.
|
|
class process_targets: # type: ignore
|
|
@staticmethod
|
|
def process_target_show_image(*args: Any, **kwargs: Any) -> None: pass
|
|
@staticmethod
|
|
def process_target_show_3d(*args: Any, **kwargs: Any) -> None: pass
|
|
@staticmethod
|
|
def process_target_create_show_area(*args: Any, **kwargs: Any) -> None: pass
|
|
@staticmethod
|
|
def run_map_viewer_process_target(*args: Any, **kwargs: Any) -> None: pass
|
|
|
|
|
|
logging.getLogger(__name__).critical(
|
|
f"elevation_gui.py: CRITICAL - Could not import core dependencies from geoelevation package. "
|
|
f"Using fallback cache: {GEOELEVATION_DEM_CACHE_DEFAULT}. Some features unavailable."
|
|
)
|
|
|
|
|
|
try:
|
|
import cv2
|
|
MAP_VIEWER_SYSTEM_AVAILABLE = True
|
|
except ImportError as e_map_import_gui:
|
|
logging.warning(
|
|
f"GUI: Map viewer components (e.g. OpenCV) not found, map functionality will be disabled: {e_map_import_gui}"
|
|
)
|
|
cv2 = None # type: ignore
|
|
MAP_VIEWER_SYSTEM_AVAILABLE = False
|
|
|
|
if TYPE_CHECKING:
|
|
import numpy as np_typing
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# --- MULTIPROCESSING TARGET FUNCTIONS (MOVED TO process_targets.py) ---
|
|
# The actual functions are now imported from geoelevation.process_targets
|
|
|
|
class ElevationApp:
|
|
"""Main application class for the GeoElevation Tool GUI."""
|
|
def __init__(
|
|
self,
|
|
parent_widget: tk.Tk,
|
|
elevation_manager_instance: Optional[ElevationManager] = None
|
|
) -> None:
|
|
"""
|
|
Initializes the main GUI application window and components.
|
|
|
|
Args:
|
|
parent_widget: The root Tkinter window.
|
|
elevation_manager_instance: Optional existing ElevationManager instance.
|
|
If None, a new one is created.
|
|
"""
|
|
self.root: tk.Tk = parent_widget
|
|
self.elevation_manager: Optional[ElevationManager] = elevation_manager_instance
|
|
|
|
# MODIFIED: Use the correctly imported GEOELEVATION_DEM_CACHE_DEFAULT constant
|
|
# WHY: This constant holds the path to the default DEM cache directory
|
|
# as configured in the.config.py module and exposed by __init__.py.
|
|
# HOW: Changed the import statement to directly import GEOELEVATION_DEM_CACHE_DEFAULT. The alias is no longer needed
|
|
# in the import statement itself, as the imported name is already the desired one. The existing try...except
|
|
# block correctly handles the case where this specific constant might not be available from the package.
|
|
default_dem_cache = GEOELEVATION_DEM_CACHE_DEFAULT
|
|
|
|
if self.elevation_manager is None:
|
|
try:
|
|
if not RASTERIO_AVAILABLE:
|
|
logger.warning("Rasterio library not found. Elevation functions limited.")
|
|
self.elevation_manager = ElevationManager(
|
|
tile_directory=default_dem_cache
|
|
)
|
|
except Exception as e_manager_init:
|
|
logger.critical(f"Failed to initialize ElevationManager: {e_manager_init}", exc_info=True)
|
|
messagebox.showerror("Init Error", f"Could not start Elevation Manager:\n{e_manager_init}", parent=self.root)
|
|
self.elevation_manager = None
|
|
|
|
self.root.title("GeoElevation Tool")
|
|
# MODIFIED: Increased minsize height to accommodate new info fields.
|
|
# WHY: Need more space for decimal and DMS coordinate fields.
|
|
# HOW: Changed the minsize tuple.
|
|
self.root.minsize(480, 580) # Increased height
|
|
|
|
self.last_valid_point_coords: Optional[Tuple[float, float]] = None
|
|
self.last_area_coords: Optional[Tuple[float, float, float, float]] = None
|
|
self.is_processing_task: bool = False # MODIFIED: Ensure this attribute is initialized here.
|
|
|
|
|
|
self.map_display_scale_factor_var: tk.DoubleVar = tk.DoubleVar(value=0.5)
|
|
self.scale_options_map: Dict[str, float] = {
|
|
"25% (1:4)": 0.25,
|
|
"33% (1:3)": 0.33333,
|
|
"50% (1:2)": 0.5,
|
|
"75% (3:4)": 0.75,
|
|
"100% (Original)": 1.0,
|
|
"150% (3:2)": 1.5,
|
|
"200% (2:1)": 2.0
|
|
}
|
|
|
|
self.map_viewer_process_handle: Optional[multiprocessing.Process] = None
|
|
self.map_interaction_message_queue: Optional[multiprocessing.Queue] = None
|
|
# MODIFIED: Store the default DEM cache path to pass to the map viewer process.
|
|
# WHY: The map process needs this path to initialize its own ElevationManager.
|
|
# HOW: Assigned the resolved default_dem_cache to a new instance attribute.
|
|
self._dem_data_cache_dir_for_map_process = default_dem_cache
|
|
|
|
# MODIFIED: Declare StringVar instances for the Map Info Entry widgets.
|
|
# WHY: Tkinter Entry widgets are typically managed via associated StringVar instances for dynamic updates.
|
|
# HOW: Declared new instance attributes of type tk.StringVar.
|
|
self.map_lat_decimal_var: tk.StringVar = tk.StringVar(value="N/A")
|
|
self.map_lon_decimal_var: tk.StringVar = tk.StringVar(value="N/A")
|
|
self.map_lat_dms_var: tk.StringVar = tk.StringVar(value="N/A") # Added DMS variables
|
|
self.map_lon_dms_var: tk.StringVar = tk.StringVar(value="N/A") # Added DMS variables
|
|
self.map_elevation_var: tk.StringVar = tk.StringVar(value="N/A")
|
|
self.map_area_size_var: tk.StringVar = tk.StringVar(value="N/A")
|
|
|
|
|
|
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 Entry widgets
|
|
|
|
# --- Map Info Widgets ---
|
|
# MODIFIED: Replace ttk.Label for displaying values with ttk.Entry (readonly)
|
|
# WHY: To make the displayed information copyable.
|
|
# HOW: Changed widget type, configured state and textvariable, adjusted grid row/column.
|
|
ttk.Label(map_info_frame, text="Latitude (Dec):").grid(row=0, column=0, sticky=tk.W, padx=5, pady=2)
|
|
# self.map_click_latitude_label = ttk.Label(map_info_frame, text="N/A") # Old Label
|
|
self.map_lat_decimal_entry = ttk.Entry(map_info_frame, textvariable=self.map_lat_decimal_var, state="readonly", width=25)
|
|
self.map_lat_decimal_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=2)
|
|
|
|
ttk.Label(map_info_frame, text="Longitude (Dec):").grid(row=1, column=0, sticky=tk.W, padx=5, pady=2)
|
|
# self.map_click_longitude_label = ttk.Label(map_info_frame, text="N/A") # Old Label
|
|
self.map_lon_decimal_entry = ttk.Entry(map_info_frame, textvariable=self.map_lon_decimal_var, state="readonly", width=25)
|
|
self.map_lon_decimal_entry.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=2)
|
|
|
|
# MODIFIED: Add new Labels and Entries for DMS format.
|
|
# WHY: To display coordinates in DMS format as requested.
|
|
# HOW: Added new rows with Labels and Entry widgets.
|
|
ttk.Label(map_info_frame, text="Latitude (DMS):").grid(row=2, column=0, sticky=tk.W, padx=5, pady=2)
|
|
self.map_lat_dms_entry = ttk.Entry(map_info_frame, textvariable=self.map_lat_dms_var, state="readonly", width=25)
|
|
self.map_lat_dms_entry.grid(row=2, column=1, sticky=(tk.W, tk.E), padx=5, pady=2)
|
|
|
|
ttk.Label(map_info_frame, text="Longitude (DMS):").grid(row=3, column=0, sticky=tk.W, padx=5, pady=2)
|
|
self.map_lon_dms_entry = ttk.Entry(map_info_frame, textvariable=self.map_lon_dms_var, state="readonly", width=25)
|
|
self.map_lon_dms_entry.grid(row=3, column=1, sticky=(tk.W, tk.E), padx=5, pady=2)
|
|
|
|
|
|
ttk.Label(map_info_frame, text="Elevation:").grid(row=4, column=0, sticky=tk.W, padx=5, pady=2)
|
|
# self.map_click_elevation_label = ttk.Label(map_info_frame, text="N/A") # Old Label
|
|
self.map_elevation_entry = ttk.Entry(map_info_frame, textvariable=self.map_elevation_var, state="readonly", width=25)
|
|
self.map_elevation_entry.grid(row=4, column=1, sticky=(tk.W, tk.E), padx=5, pady=2)
|
|
|
|
# MODIFIED: Added a new Label for displaying the map area size.
|
|
# WHY: To fulfill the requirement to show the size of the currently displayed map patch.
|
|
# HOW: Created and gridded a new ttk.Label.
|
|
ttk.Label(map_info_frame, text="Map Area Size:").grid(row=5, column=0, sticky=tk.W, padx=5, pady=2)
|
|
# MODIFIED: Make Area Size display also an Entry for copyability.
|
|
# WHY: Consistency and copyability for all map info fields.
|
|
# HOW: Changed widget type, configured state and textvariable.
|
|
# self.map_area_size_label = ttk.Label(map_info_frame, text="N/A", wraplength=300, justify=tk.LEFT) # Old Label
|
|
self.map_area_size_entry = ttk.Entry(map_info_frame, textvariable=self.map_area_size_var, state="readonly", width=25) # Increased width slightly
|
|
self.map_area_size_entry.grid(row=5, column=1, sticky=(tk.W, tk.E), padx=5, pady=2)
|
|
|
|
|
|
main_app_frame.columnconfigure(0, weight=1)
|
|
main_app_frame.rowconfigure(0, weight=0)
|
|
main_app_frame.rowconfigure(1, weight=0)
|
|
main_app_frame.rowconfigure(2, weight=0)
|
|
main_app_frame.rowconfigure(3, weight=0)
|
|
|
|
if not MAP_VIEWER_SYSTEM_AVAILABLE:
|
|
map_display_options_frame.grid_remove()
|
|
# MODIFIED: Also hide the renamed "Map Info" frame if map viewer system is unavailable.
|
|
# WHY: The panel is only relevant when the map viewer is working.
|
|
# HOW: Called grid_remove on the map_info_frame.
|
|
map_info_frame.grid_remove()
|
|
|
|
|
|
def _apply_initial_widget_states(self) -> None:
|
|
if self.elevation_manager is None:
|
|
self.get_elevation_button.config(state=tk.DISABLED)
|
|
self.download_area_button.config(state=tk.DISABLED)
|
|
self.point_result_label.config(text="Result: Elevation Manager Init Failed.")
|
|
self.area_download_status_label.config(text="Status: Elevation Manager Init Failed.")
|
|
|
|
self.show_2d_browse_button.config(state=tk.DISABLED)
|
|
self.show_3d_dem_button.config(state=tk.DISABLED)
|
|
self.show_area_composite_button.config(state=tk.DISABLED)
|
|
|
|
point_map_initial_state = tk.DISABLED
|
|
area_map_initial_state = tk.DISABLED
|
|
scale_combo_state = tk.DISABLED
|
|
point_map_text = "Map (Sys N/A)"
|
|
area_map_text = "Map (Sys N/A)"
|
|
|
|
if MAP_VIEWER_SYSTEM_AVAILABLE:
|
|
point_map_initial_state = tk.NORMAL # Enabled if system ok, but needs point data
|
|
# area_map_initial_state remains DISABLED until area data is ready
|
|
scale_combo_state = "readonly"
|
|
point_map_text = "View Point on Map"
|
|
area_map_text = "View Area on Map"
|
|
|
|
self.view_map_for_point_button.config(state=point_map_initial_state, text=point_map_text)
|
|
self.view_map_for_area_button.config(state=area_map_initial_state, text=area_map_text)
|
|
self.map_scale_combobox.config(state=scale_combo_state)
|
|
|
|
if not (MATPLOTLIB_AVAILABLE and PIL_AVAILABLE):
|
|
self.show_2d_browse_button.config(text="Browse (Libs N/A)")
|
|
self.show_area_composite_button.config(text="Composite (Libs N/A)")
|
|
if not MATPLOTLIB_AVAILABLE:
|
|
self.show_3d_dem_button.config(text="DEM (MPL N/A)")
|
|
|
|
# MODIFIED: Set initial text for Map Info Entry widgets.
|
|
# WHY: Display default "N/A" and ensure text variables are linked correctly.
|
|
# HOW: Use set() on the StringVar instances.
|
|
self.map_lat_decimal_var.set("N/A")
|
|
self.map_lon_decimal_var.set("N/A")
|
|
self.map_lat_dms_var.set("N/A") # Set initial value for DMS fields
|
|
self.map_lon_dms_var.set("N/A") # Set initial value for DMS fields
|
|
self.map_elevation_var.set("N/A")
|
|
self.map_area_size_var.set("N/A")
|
|
|
|
# MODIFIED: Ensure Map Info Entry widgets are in readonly state initially.
|
|
# WHY: They should not be editable by the user.
|
|
# HOW: Set the 'state' option on the Entry widgets. (Already done in _build_gui_layout, but can double-check here if needed).
|
|
# self.map_lat_decimal_entry.config(state="readonly") # Redundant with _build_gui_layout
|
|
# ... and so on for other map info entries.
|
|
|
|
|
|
def _on_map_display_scale_changed(self, event: Optional[tk.Event] = None) -> None:
|
|
selected_display_text = self.map_scale_combobox.get()
|
|
new_numeric_scale = self.scale_options_map.get(selected_display_text)
|
|
if new_numeric_scale is not None:
|
|
self.map_display_scale_factor_var.set(new_numeric_scale)
|
|
logger.info(f"Map display scale factor set to: {new_numeric_scale:.3f} ({selected_display_text})")
|
|
# MODIFIED: Add a messagebox to inform the user about the scale change application.
|
|
# WHY: To clarify that the scale change applies to the *next* map view.
|
|
# HOW: Display an informational messagebox.
|
|
messagebox.showinfo(
|
|
"Map Scale Changed",
|
|
f"Map display scale set to {selected_display_text}.\n"
|
|
"This will apply the next time a map view is opened.",
|
|
parent=self.root
|
|
)
|
|
else:
|
|
logger.warning(f"Invalid map scale option selected: '{selected_display_text}'")
|
|
|
|
def _set_busy_state(self, is_busy: bool) -> None:
|
|
"""Sets the GUI to a busy or idle state."""
|
|
self.is_processing_task = is_busy
|
|
# MODIFIED: Add check for elevation_manager before configuring buttons.
|
|
# WHY: Avoids AttributeError if manager init failed.
|
|
# HOW: Added 'if self.elevation_manager:'.
|
|
if self.elevation_manager:
|
|
new_widget_state = tk.DISABLED if is_busy else tk.NORMAL
|
|
self.get_elevation_button.config(state=new_widget_state)
|
|
self.download_area_button.config(state=new_widget_state)
|
|
# MODIFIED: Also disable/enable visualization buttons based on busy state.
|
|
# WHY: Prevent triggering new tasks while one is in progress.
|
|
# HOW: Include visualization buttons in the state change.
|
|
self.show_2d_browse_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_valid_point_coords and MATPLOTLIB_AVAILABLE and PIL_AVAILABLE else tk.DISABLED))
|
|
self.show_3d_dem_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_valid_point_coords and MATPLOTLIB_AVAILABLE else tk.DISABLED))
|
|
self.show_area_composite_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_area_coords and MATPLOTLIB_AVAILABLE and PIL_AVAILABLE else tk.DISABLED))
|
|
if MAP_VIEWER_SYSTEM_AVAILABLE:
|
|
# Re-enable map buttons based on busy state and data availability.
|
|
self.view_map_for_point_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_valid_point_coords else tk.DISABLED))
|
|
self.view_map_for_area_button.config(state=tk.DISABLED if is_busy else (tk.NORMAL if self.last_area_coords else tk.DISABLED))
|
|
|
|
|
|
def _validate_point_coordinates(self, lat_s: str, lon_s: str) -> Optional[Tuple[float, float]]:
|
|
try:
|
|
if not lat_s: raise ValueError("Latitude empty.")
|
|
lat_v = float(lat_s.strip())
|
|
if not (-90.0 <= lat_v <= 90.0): raise ValueError("Latitude out of [-90, 90].") # Allow 90 exactly for validation
|
|
if not lon_s: raise ValueError("Longitude empty.")
|
|
lon_v = float(lon_s.strip())
|
|
if not (-180.0 <= lon_v <= 180.0): raise ValueError("Longitude out of [-180, 180].") # Allow 180 exactly for validation
|
|
return lat_v, lon_v
|
|
except ValueError as e: logger.error(f"Invalid coordinate: {e}"); messagebox.showerror("Input Error", f"Invalid coordinate:\n{e}", parent=self.root); return None
|
|
|
|
def _validate_area_boundary_coordinates(self) -> Optional[Tuple[float, float, float, float]]:
|
|
try:
|
|
min_ls,max_ls = self.min_latitude_entry.get().strip(), self.max_latitude_entry.get().strip()
|
|
min_os,max_os = self.min_longitude_entry.get().strip(), self.max_longitude_entry.get().strip()
|
|
if not all([min_ls, max_ls, min_os, max_os]): raise ValueError("All bounds must be filled.")
|
|
min_l,max_l,min_o,max_o = float(min_ls),float(max_ls),float(min_os),float(max_os)
|
|
# MODIFIED: Adjusted validation range to allow 90 and 180 exactly.
|
|
# WHY: Standard geographic range includes these boundaries.
|
|
# HOW: Changed < 90.0 to <= 90.0 and < 180.0 to <= 180.0.
|
|
if not (-90<=min_l<=90 and -90<=max_l<=90 and -180<=min_o<=180 and -180<=max_o<=180):
|
|
raise ValueError("Coordinates out of valid range [-90, 90] / [-180, 180].")
|
|
# The order check should still be strict for a defined bounding box area.
|
|
if min_l >= max_l: raise ValueError("Min Lat must be less than Max Lat.")
|
|
# Handle potential longitude wrap-around if min_o > max_o but the range is valid (e.g., crosses antimeridian)
|
|
# For simplicity in GUI input, let's assume min_o should generally be <= max_o unless explicitly designing for wrap-around input.
|
|
# Let's keep the simple check for now.
|
|
if min_o >= max_o: raise ValueError("Min Lon must be less than Max Lon.") # This simplifies downstream logic
|
|
|
|
return min_l, min_o, max_l, max_o
|
|
except ValueError as e: logger.error(f"Invalid area: {e}"); messagebox.showerror("Input Error", f"Invalid area:\n{e}", parent=self.root); return None
|
|
|
|
def _trigger_get_elevation_task(self) -> None:
|
|
"""Starts a background thread to get elevation for a point."""
|
|
if self.is_processing_task or not self.elevation_manager: return
|
|
coords = self._validate_point_coordinates(self.latitude_entry.get(), self.longitude_entry.get())
|
|
if not coords:
|
|
# MODIFIED: Clear Map Info fields if point validation fails.
|
|
# WHY: Avoids showing stale info if the user enters invalid coords.
|
|
# HOW: Call the UI update method with None values.
|
|
self._get_elevation_task_complete_ui_update(None, ValueError("Invalid point coordinates"), None, None)
|
|
return # Exit if validation fails
|
|
|
|
lat, lon = coords
|
|
|
|
# MODIFIED: Start busy state and update status label immediately on GUI thread.
|
|
# WHY: Provide immediate visual feedback that processing has started.
|
|
# HOW: Call _set_busy_state(True) and update label text.
|
|
self._set_busy_state(True)
|
|
self.point_result_label.config(text="Result: Requesting elevation... Please wait.")
|
|
# MODIFIED: Clear Map Info fields when a new Get Elevation task starts.
|
|
# WHY: Indicate that new information is being fetched.
|
|
# HOW: Set StringVar values to "..." or empty.
|
|
self.map_lat_decimal_var.set("...") # Indicate loading
|
|
self.map_lon_decimal_var.set("...")
|
|
self.map_lat_dms_var.set("...") # Indicate loading for DMS fields
|
|
self.map_lon_dms_var.set("...") # Indicate loading for DMS fields
|
|
self.map_elevation_var.set("...")
|
|
self.map_area_size_var.set("N/A (Point)") # Indicate this is for a point
|
|
|
|
|
|
self.root.update_idletasks() # Force GUI update
|
|
|
|
|
|
# Start elevation retrieval in a background thread
|
|
elev_thread = threading.Thread(
|
|
target=self._perform_background_get_elevation,
|
|
args=(lat, lon),
|
|
daemon=True # Thread will exit if main program exits
|
|
)
|
|
elev_thread.start()
|
|
|
|
|
|
def _perform_background_get_elevation(self, latitude: float, longitude: float) -> None:
|
|
"""Background task to retrieve elevation."""
|
|
result_elevation: Optional[float] = None
|
|
exception_occurred: Optional[Exception] = None
|
|
try:
|
|
# Call the actual elevation retrieval logic (which might involve downloads)
|
|
# Using the shared manager instance in the main process.
|
|
if self.elevation_manager:
|
|
logger.info(f"GUI Thread: Calling elevation_manager.get_elevation for ({latitude:.5f},{longitude:.5f}) in background thread.")
|
|
# This call will handle its own progress (e.g., the NullHandler logging or the CLI progress dialog if applicable)
|
|
result_elevation = self.elevation_manager.get_elevation(latitude, longitude)
|
|
else:
|
|
raise RuntimeError("ElevationManager is not initialized.")
|
|
|
|
except Exception as e:
|
|
logger.exception(f"GUI Thread Error: Exception during background get_elevation for ({latitude:.5f},{longitude:.5f}).")
|
|
exception_occurred = e
|
|
finally:
|
|
# MODIFIED: Use root.after to call the UI update method on the main GUI thread.
|
|
# WHY: Tkinter GUI updates must happen on the main thread.
|
|
# HOW: Schedule _get_elevation_task_complete_ui_update call.
|
|
# MODIFIED: Pass original coords even on error for UI update context.
|
|
# WHY: The UI update method needs the original coordinates to populate the entry fields, even if the elevation fetch failed.
|
|
# HOW: Pass latitude, longitude as arguments.
|
|
self.root.after(
|
|
0, # Schedule to run as soon as the main loop is free
|
|
self._get_elevation_task_complete_ui_update, # Callback function
|
|
result_elevation, # Pass the result/error back
|
|
exception_occurred,
|
|
latitude, longitude # Pass original coords for context
|
|
)
|
|
|
|
# MODIFIED: Added a new UI update function for point elevation task completion.
|
|
# WHY: Centralizes the logic to update GUI elements after the background task finishes.
|
|
# HOW: Created this method to update labels and button states.
|
|
# MODIFIED: Updated to populate Map Info Entry widgets with Decimal and DMS coordinates and Elevation.
|
|
# WHY: Implement Feature 3 - show coordinates in Entry widgets and in DMS format.
|
|
# HOW: Access StringVar instances and set their values. Use deg_to_dms_string for conversion.
|
|
def _get_elevation_task_complete_ui_update(
|
|
self,
|
|
elevation_result: Optional[float],
|
|
exception_occurred: Optional[Exception],
|
|
original_latitude: Optional[float], # Now Optional as it might be None on validation failure
|
|
original_longitude: Optional[float] # Now Optional as it might be None on validation failure
|
|
) -> None:
|
|
"""Updates GUI elements after a point elevation task completes."""
|
|
res_txt = "Result: "
|
|
self.last_valid_point_coords = None # Reset valid point state initially
|
|
|
|
# MODIFIED: Clear/reset Map Info fields if coordinates were not successfully obtained or were invalid.
|
|
# WHY: Ensure the Map Info panel reflects the status correctly.
|
|
# HOW: Reset StringVars to "N/A".
|
|
if original_latitude is None or original_longitude is None:
|
|
res_txt += f"Error: {type(exception_occurred).__name__} (Invalid Input)" if exception_occurred else "Input Error."
|
|
logger.error(f"GUI: Point elevation task completed with invalid input.")
|
|
self.map_lat_decimal_var.set("N/A")
|
|
self.map_lon_decimal_var.set("N/A")
|
|
self.map_lat_dms_var.set("N/A")
|
|
self.map_lon_dms_var.set("N/A")
|
|
self.map_elevation_var.set("N/A")
|
|
self.map_area_size_var.set("N/A") # Area size doesn't apply to a single point result, but clear it.
|
|
|
|
elif exception_occurred:
|
|
res_txt += f"Error: {type(exception_occurred).__name__}"
|
|
messagebox.showerror("Error", f"Error retrieving elevation:\n{exception_occurred}", parent=self.root)
|
|
logger.error(f"GUI: Point elevation task completed with error for ({original_latitude:.5f},{original_longitude:.5f}).")
|
|
# MODIFIED: Update Map Info fields with error state.
|
|
# WHY: Show the error message in the GUI panel.
|
|
# HOW: Set StringVars.
|
|
self.map_lat_decimal_var.set(f"{original_latitude:.5f}") # Show input coords
|
|
self.map_lon_decimal_var.set(f"{original_longitude:.5f}")
|
|
# MODIFIED: Convert available coordinates to DMS even if elevation failed, if the coords are valid.
|
|
# WHY: Show DMS coordinates if the input was valid, even if data wasn't available.
|
|
# HOW: Use deg_to_dms_string.
|
|
self.map_lat_dms_var.set(deg_to_dms_string(original_latitude, 'lat'))
|
|
self.map_lon_dms_var.set(deg_to_dms_string(original_longitude, 'lon'))
|
|
self.map_elevation_var.set(f"Error: {type(exception_occurred).__name__}")
|
|
self.map_area_size_var.set("N/A") # Area size doesn't apply
|
|
|
|
elif elevation_result is None:
|
|
res_txt += "Data unavailable."
|
|
messagebox.showwarning("Info", "Could not retrieve elevation for the point.", parent=self.root)
|
|
logger.warning(f"GUI: Point elevation task completed, data unavailable for ({original_latitude:.5f},{original_longitude:.5f}).")
|
|
# MODIFIED: Update Map Info fields for data unavailable state.
|
|
# WHY: Show results in the GUI panel.
|
|
# HOW: Set StringVars.
|
|
self.map_lat_decimal_var.set(f"{original_latitude:.5f}")
|
|
self.map_lon_decimal_var.set(f"{original_longitude:.5f}")
|
|
self.map_lat_dms_var.set(deg_to_dms_string(original_latitude, 'lat'))
|
|
self.map_lon_dms_var.set(deg_to_dms_string(original_longitude, 'lon'))
|
|
self.map_elevation_var.set("Unavailable")
|
|
self.map_area_size_var.set("N/A") # Area size doesn't apply
|
|
self.last_valid_point_coords = (original_latitude, original_longitude) # Coords were valid, but no data
|
|
|
|
elif isinstance(elevation_result, float) and math.isnan(elevation_result):
|
|
res_txt += "Point on NoData area."
|
|
logger.info(f"GUI: Point elevation task completed, NoData for ({original_latitude:.5f},{original_longitude:.5f}).")
|
|
# MODIFIED: Update Map Info fields for NoData state.
|
|
# WHY: Show results in the GUI panel.
|
|
# HOW: Set StringVars.
|
|
self.map_lat_decimal_var.set(f"{original_latitude:.5f}")
|
|
self.map_lon_decimal_var.set(f"{original_longitude:.5f}")
|
|
self.map_lat_dms_var.set(deg_to_dms_string(original_latitude, 'lat'))
|
|
self.map_lon_dms_var.set(deg_to_dms_string(original_longitude, 'lon'))
|
|
self.map_elevation_var.set("NoData")
|
|
self.map_area_size_var.set("N/A") # Area size doesn't apply
|
|
self.last_valid_point_coords = (original_latitude, original_longitude) # Valid coords, result is NoData
|
|
|
|
else:
|
|
res_txt += f"Elevation {elevation_result:.2f}m"
|
|
logger.info(
|
|
f"GUI: Point elevation task completed, elevation {elevation_result:.2f}m for ({original_latitude:.5f},{original_longitude:.5f})."
|
|
)
|
|
# MODIFIED: Update Map Info fields for successful elevation retrieval.
|
|
# WHY: Show results in the GUI panel.
|
|
# HOW: Set StringVars.
|
|
self.map_lat_decimal_var.set(f"{original_latitude:.5f}")
|
|
self.map_lon_decimal_var.set(f"{original_longitude:.5f}")
|
|
self.map_lat_dms_var.set(deg_to_dms_string(original_latitude, 'lat'))
|
|
self.map_lon_dms_var.set(deg_to_dms_string(original_longitude, 'lon'))
|
|
self.map_elevation_var.set(f"{elevation_result:.2f} m")
|
|
self.map_area_size_var.set("N/A") # Area size doesn't apply
|
|
self.last_valid_point_coords = (original_latitude, original_longitude) # Valid coords, got elevation
|
|
|
|
|
|
self.point_result_label.config(text=res_txt)
|
|
|
|
# MODIFIED: Re-enable point-specific visualization buttons if valid coords were obtained.
|
|
# WHY: These actions require a valid point location.
|
|
# HOW: Check if last_valid_point_coords is set and dependency libraries are available.
|
|
# The _set_busy_state(False) call already handles re-enabling based on last_valid_point_coords,
|
|
# but let's ensure the state is correctly set before clearing busy.
|
|
if self.last_valid_point_coords:
|
|
if MATPLOTLIB_AVAILABLE and PIL_AVAILABLE: self.show_2d_browse_button.config(state=tk.NORMAL)
|
|
if MATPLOTLIB_AVAILABLE: self.show_3d_dem_button.config(state=tk.NORMAL)
|
|
if MAP_VIEWER_SYSTEM_AVAILABLE: self.view_map_for_point_button.config(state=tk.NORMAL)
|
|
else:
|
|
# Ensure buttons are disabled if no valid point was obtained
|
|
self.show_2d_browse_button.config(state=tk.DISABLED)
|
|
self.show_3d_dem_button.config(state=tk.DISABLED)
|
|
if MAP_VIEWER_SYSTEM_AVAILABLE: self.view_map_for_point_button.config(state=tk.DISABLED)
|
|
|
|
|
|
self._set_busy_state(False) # Clear busy state, which also updates button states
|
|
|
|
|
|
def _trigger_download_area_task(self) -> None:
|
|
"""Starts a background thread to download tiles for an area."""
|
|
if self.is_processing_task or not self.elevation_manager: return
|
|
bounds = self._validate_area_boundary_coordinates()
|
|
if not bounds:
|
|
# MODIFIED: Clear area status and related map info fields if area validation fails.
|
|
# WHY: Avoids showing stale info.
|
|
# HOW: Update labels and Map Info StringVars.
|
|
self.area_download_status_label.config(text="Status: Invalid input.")
|
|
self.last_area_coords = None
|
|
# Also clear map info fields that are area-related or generic
|
|
self.map_area_size_var.set("N/A")
|
|
# Optionally clear point-specific map info fields too if they don't apply to area context
|
|
# self.map_lat_decimal_var.set("N/A") # Decide if we want to clear point info on area task start
|
|
# self.map_lon_decimal_var.set("N/A")
|
|
# self.map_lat_dms_var.set("N/A")
|
|
# self.map_lon_dms_var.set("N/A")
|
|
# self.map_elevation_var.set("N/A")
|
|
|
|
return # Exit if validation fails
|
|
|
|
self.last_area_coords = bounds # Store validated bounds
|
|
|
|
# MODIFIED: Start busy state and update status label immediately on GUI thread.
|
|
# WHY: Provide immediate visual feedback that processing has started.
|
|
# HOW: Call _set_busy_state(True) and update label text.
|
|
self._set_busy_state(True)
|
|
self.area_download_status_label.config(text="Status: Starting download task... Please wait.")
|
|
# MODIFIED: Clear Map Info fields when a new Download Area task starts.
|
|
# WHY: Indicate that the context has shifted to an area task.
|
|
# HOW: Set StringVar values to "..." or empty.
|
|
self.map_lat_decimal_var.set("N/A") # Point info not relevant for area view
|
|
self.map_lon_decimal_var.set("N/A")
|
|
self.map_lat_dms_var.set("N/A") # Point info not relevant for area view
|
|
self.map_lon_dms_var.set("N/A") # Point info not relevant for area view
|
|
self.map_elevation_var.set("N/A") # Point info not relevant for area view
|
|
self.map_area_size_var.set("...") # Show progress for area size info related to map
|
|
|
|
|
|
self.root.update_idletasks() # Force GUI update
|
|
|
|
|
|
# Start download in a background thread
|
|
dl_thread = threading.Thread(target=self._perform_background_area_download, args=bounds, daemon=True)
|
|
dl_thread.start()
|
|
|
|
|
|
def _perform_background_area_download(self, min_l: float, min_o: float, max_l: float, max_o: float) -> None:
|
|
"""Background task for downloading area tiles."""
|
|
status_str, success_bool, processed_count, obtained_count = "Status: Unknown error.", False, 0, 0
|
|
try:
|
|
# Update status label on the GUI thread using root.after
|
|
self.root.after(0, lambda: self.area_download_status_label.config(text="Status: Downloading..."))
|
|
if self.elevation_manager:
|
|
logger.info(f"GUI Thread: Starting background download for area: Lat [{min_l:.2f}-{max_l:.2f}], Lon [{min_o:.2f}-{max_o:.2f}].")
|
|
# Call ElevationManager method to perform the download
|
|
processed_count, obtained_count = self.elevation_manager.download_area(min_l, min_o, max_l, max_o)
|
|
status_str = f"Status: Complete. Processed {processed_count}, Obtained {obtained_count} HGT."
|
|
success_bool = True
|
|
logger.info(f"GUI Thread: Background download complete: {status_str}")
|
|
else:
|
|
status_str = "Status: Error - Elevation Manager N/A."
|
|
logger.error("GUI Thread: Background download failed - Elevation Manager is None.")
|
|
except Exception as e:
|
|
logger.exception("GUI Thread Error: Unhandled exception during area download task.")
|
|
status_str = f"Status: Error: {type(e).__name__}"
|
|
finally:
|
|
# Update GUI and re-enable buttons on the GUI thread regardless of success/failure
|
|
self.root.after(0, self._area_download_task_complete_ui_update, status_str, success_bool, processed_count, obtained_count)
|
|
|
|
def _area_download_task_complete_ui_update(self, msg: str, success: bool, proc: int, obt: int) -> None:
|
|
"""Updates GUI after area download task finishes."""
|
|
self.area_download_status_label.config(text=msg)
|
|
# MODIFIED: Re-enable area visualization buttons if the download was successful.
|
|
# WHY: These actions require downloaded area data.
|
|
# HOW: Check success flag and library availability.
|
|
if success:
|
|
summary = f"Processed {proc} tile locations.\nSuccessfully obtained {obt} HGT files (downloaded or found in cache)."
|
|
messagebox.showinfo("Download Complete", summary, parent=self.root)
|
|
if MATPLOTLIB_AVAILABLE and PIL_AVAILABLE:
|
|
# Re-enable composite image button only if at least one tile was obtained
|
|
self.show_area_composite_button.config(state=tk.NORMAL if obt > 0 else tk.DISABLED)
|
|
if MAP_VIEWER_SYSTEM_AVAILABLE:
|
|
# Re-enable area map button only if at least one tile was obtained
|
|
self.view_map_for_area_button.config(state=tk.NORMAL if obt > 0 else tk.DISABLED)
|
|
else:
|
|
err_brief = msg.split(":")[-1].strip() if ":" in msg else "An error occurred."
|
|
messagebox.showerror("Download Error", f"Area download failed:\n{err_brief}\nCheck logs for details.", parent=self.root)
|
|
# MODIFIED: Ensure area visualization buttons are disabled on failure.
|
|
# WHY: No data available for visualization.
|
|
# HOW: Explicitly disable buttons.
|
|
self.show_area_composite_button.config(state=tk.DISABLED)
|
|
if MAP_VIEWER_SYSTEM_AVAILABLE: self.view_map_for_area_button.config(state=tk.DISABLED)
|
|
|
|
self._set_busy_state(False) # Clear busy state, which also updates button states
|
|
|
|
|
|
def _start_visualization_process(self, func: callable, args_t: tuple, name_id: str) -> None:
|
|
"""Helper to start a visualization task in a separate process."""
|
|
logger.info(f"GUI: Attempting to start visualization process '{name_id}'...")
|
|
try:
|
|
# Configure logging for the child process if needed (optional, can also configure in target function)
|
|
# multiprocessing.log_to_stderr(logging.DEBUG) # Example: uncomment for verbose process logging
|
|
|
|
proc = multiprocessing.Process(target=func, args=args_t, daemon=True, name=name_id)
|
|
proc.start()
|
|
logger.info(f"GUI: Started process '{name_id}' (PID: {proc.pid}).")
|
|
# Note: The GUI main thread does not wait for these visualization processes to finish.
|
|
except Exception as e:
|
|
logger.exception(f"GUI Error: Failed to start visualization process '{name_id}'.")
|
|
messagebox.showerror("Process Error", f"Could not start {name_id}:\n{e}", parent=self.root)
|
|
|
|
def _trigger_2d_browse_display(self) -> None:
|
|
"""Triggers display of the browse image for the last valid point."""
|
|
if not (MATPLOTLIB_AVAILABLE and PIL_AVAILABLE):
|
|
messagebox.showwarning("Dependencies Missing", "Matplotlib or Pillow not available.", parent=self.root); return
|
|
if not self.last_valid_point_coords or not self.elevation_manager:
|
|
messagebox.showinfo("Info", "Please get elevation for a point first.", parent=self.root); return
|
|
|
|
lat, lon = self.last_valid_point_coords
|
|
logger.info(f"GUI: Requesting 2D browse image display for ({lat:.5f},{lon:.5f}).")
|
|
# get_tile_info ensures browse image download is *attempted*, but doesn't guarantee success.
|
|
info = self.elevation_manager.get_tile_info(lat, lon)
|
|
# Pass image path and tile name to the target process
|
|
if info and info.get("browse_available") and info.get("browse_image_path"):
|
|
browse_img_path = info["browse_image_path"]
|
|
tile_name = info.get("tile_base_name","?")
|
|
window_title = f"Browse: {tile_name.upper()}"
|
|
|
|
# MODIFIED: Get geographic bounds for the single tile.
|
|
# WHY: Needed to pass as extent to Matplotlib for correct aspect ratio.
|
|
# HOW: Use get_hgt_tile_geographic_bounds and format as [west, east, south, north].
|
|
tile_geo_bounds = None
|
|
if info.get("hgt_available"): # Get bounds only if HGT is available (lat/lon coords should be in info)
|
|
tile_lat_coord = info.get("latitude_coord")
|
|
tile_lon_coord = info.get("longitude_coord")
|
|
if tile_lat_coord is not None and tile_lon_coord is not None:
|
|
bounds_tuple = get_hgt_tile_geographic_bounds(tile_lat_coord, tile_lon_coord)
|
|
if bounds_tuple:
|
|
# Format as [left, right, bottom, top] which is [west, east, south, north]
|
|
tile_geo_bounds = [bounds_tuple[0], bounds_tuple[2], bounds_tuple[1], bounds_tuple[3]]
|
|
logger.debug(f"Passing geographic extent {tile_geo_bounds} for browse image.")
|
|
else:
|
|
logger.warning("Could not get precise geographic bounds for single tile, aspect ratio might be incorrect.")
|
|
else:
|
|
logger.warning("Tile coordinates missing in info, cannot get precise geographic bounds for browse image aspect ratio.")
|
|
else:
|
|
logger.warning("HGT not available for tile, cannot get precise geographic bounds for browse image aspect ratio.")
|
|
# Proceed without extent, image will be displayed assuming square pixels.
|
|
|
|
|
|
# The process target will load and display the image from the path
|
|
# MODIFIED: Pass the extent to the process target.
|
|
# WHY: So the target function can pass it to show_image_matplotlib.
|
|
# HOW: Added extent to the args tuple.
|
|
args = (browse_img_path, tile_name, window_title, tile_geo_bounds)
|
|
self._start_visualization_process(process_targets.process_target_show_image, args, "2DBrowse") # Call imported target
|
|
else:
|
|
logger.warning(f"GUI: Browse image not available for ({lat:.5f},{lon:.5f}).")
|
|
messagebox.showinfo("Image Info", "Browse image not available for this tile.", parent=self.root)
|
|
|
|
|
|
def _trigger_3d_dem_display(self) -> None:
|
|
"""Triggers display of the 3D DEM plot for the last valid point."""
|
|
if not MATPLOTLIB_AVAILABLE:
|
|
messagebox.showwarning("Dependencies Missing", "Matplotlib not available.", parent=self.root); return
|
|
# SciPy is optional for advanced features but required by the plotting function implementation
|
|
# Let's make sure we warn the user if SciPy is needed for full functionality
|
|
if not SCIPY_AVAILABLE:
|
|
logger.warning("GUI: SciPy not available. Advanced 3D plot features (smoothing/interpolation) will be disabled.")
|
|
# Decide if we want to show a warning box here or just rely on the log.
|
|
# messagebox.showwarning("Dependencies Missing", "SciPy not available. Advanced 3D features disabled.", parent=self.root)
|
|
|
|
|
|
if not self.last_valid_point_coords or not self.elevation_manager:
|
|
messagebox.showinfo("Info", "Please get elevation for a point first.", parent=self.root); return
|
|
|
|
lat, lon = self.last_valid_point_coords
|
|
logger.info(f"GUI: Requesting 3D DEM display for ({lat:.5f},{lon:.5f}).")
|
|
# get_hgt_data ensures HGT download is *attempted*.
|
|
data = self.elevation_manager.get_hgt_data(lat, lon)
|
|
|
|
if data is not None:
|
|
# Retrieve tile info again for tile name, even if HGT data was obtained directly.
|
|
# get_tile_info won't re-download if HGT is already there.
|
|
info = self.elevation_manager.get_tile_info(lat, lon)
|
|
tile_name = info.get("tile_base_name","?") if info else "?"
|
|
plot_title = f"3D View: Tile {tile_name.upper()}"
|
|
|
|
# Configuration parameters for the 3D plot processing (passed to the target process)
|
|
# These can be made configurable in the GUI if needed later.
|
|
config_initial_subsample = 1 # Initial subsampling factor of the raw data
|
|
config_smooth_sigma = 0.5 # Sigma for Gaussian smoothing (set to None to disable)
|
|
config_interpolation_factor = 3 # Interpolation factor (e.g., 3x for 3x denser grid)
|
|
config_plot_grid_points = 300 # Target number of points along each axis for the final plot grid
|
|
|
|
# Pass the raw NumPy array and config to the target process
|
|
# The process target will handle data processing (subsampling, smoothing, interpolation) and plotting.
|
|
args = (data, plot_title, config_initial_subsample, config_smooth_sigma,
|
|
config_interpolation_factor, config_plot_grid_points)
|
|
self._start_visualization_process(process_targets.process_target_show_3d, args, "3DDEM") # Call imported target
|
|
else:
|
|
logger.warning(f"GUI: HGT data not available for ({lat:.5f},{lon:.5f}). Cannot show 3D plot.")
|
|
messagebox.showerror("3D Data Error", "Could not retrieve HGT data for this tile.", parent=self.root)
|
|
|
|
def _trigger_area_composite_display(self) -> None:
|
|
"""Triggers display of the composite browse image for the last downloaded area."""
|
|
if not (MATPLOTLIB_AVAILABLE and PIL_AVAILABLE):
|
|
messagebox.showwarning("Dependencies Missing", "Matplotlib or Pillow not available.", parent=self.root); return
|
|
if not self.last_area_coords or not self.elevation_manager:
|
|
messagebox.showinfo("Info", "Please download an area first.", parent=self.root); return
|
|
|
|
min_l, min_o, max_l, max_o = self.last_area_coords
|
|
logger.info(f"GUI: Requesting area composite display for area: Lat [{min_l:.2f}-{max_l:.2f}], Lon [{min_o:.2f}-{max_o:.2f}]")
|
|
|
|
# get_area_tile_info returns info for tiles that *should* cover the area,
|
|
# and includes paths to locally available files (doesn't trigger download here).
|
|
info_list = self.elevation_manager.get_area_tile_info(min_l, min_o, max_l, max_o)
|
|
|
|
# Check if any browse images are potentially available before starting the process
|
|
available_browse_images = [
|
|
info for info in info_list if info.get("browse_available") and info.get("browse_image_path")
|
|
]
|
|
|
|
if not available_browse_images:
|
|
logger.warning("GUI: No browse images found for the downloaded area.")
|
|
messagebox.showinfo("Area Info", "No browse images were found locally for the specified area.", parent=self.root); return
|
|
|
|
# MODIFIED: Calculate combined geographic bounds for all relevant tiles in the area.
|
|
# WHY: Needed to pass as extent to Matplotlib for correct aspect ratio of the composite.
|
|
# HOW: Use get_combined_geographic_bounds_from_tile_info_list on the full info_list.
|
|
combined_geo_bounds = get_combined_geographic_bounds_from_tile_info_list(info_list)
|
|
composite_extent = None
|
|
if combined_geo_bounds:
|
|
# Format as [left, right, bottom, top] which is [west, east, south, north]
|
|
composite_extent = [combined_geo_bounds[0], combined_geo_bounds[2], combined_geo_bounds[1], combined_geo_bounds[3]]
|
|
logger.debug(f"Passing combined geographic extent {composite_extent} for area composite.")
|
|
else:
|
|
logger.warning("Could not get combined geographic bounds for area composite, aspect ratio might be incorrect.")
|
|
# Proceed without extent.
|
|
|
|
title = f"Area: Lat [{min_l:.1f}-{max_l:.1f}], Lon [{min_o:.1f}-{max_o:.1f}]"
|
|
# Pass the filtered list of available info dicts to the process
|
|
# The process target will create the composite image from these.
|
|
# MODIFIED: Pass the combined extent to the process target.
|
|
# WHY: So the target function can pass it to show_image_matplotlib.
|
|
# HOW: Added extent to the args tuple.
|
|
args = (available_browse_images, title, composite_extent)
|
|
self._start_visualization_process(process_targets.process_target_create_show_area, args, "AreaComposite") # Call imported target
|
|
|
|
|
|
def _start_map_viewer_process(self, base_args_tuple: tuple) -> None:
|
|
"""Helper to start the map viewer process."""
|
|
if not MAP_VIEWER_SYSTEM_AVAILABLE:
|
|
messagebox.showerror("Map System Unavailable", "Map viewer dependencies (OpenCV, etc.) are not available.", parent=self.root); return
|
|
|
|
if not self.map_interaction_message_queue:
|
|
logger.error("GUI: Cannot start map viewer: interaction queue not initialized.");
|
|
messagebox.showerror("Internal Error", "Map communication queue not ready.", parent=self.root); return
|
|
|
|
# Check if a map viewer process is already running
|
|
if self.map_viewer_process_handle and self.map_viewer_process_handle.is_alive():
|
|
messagebox.showinfo("Info", "Map viewer is already open.", parent=self.root)
|
|
logger.info("GUI: Map viewer process already alive.")
|
|
return
|
|
|
|
logger.info("GUI: Attempting to start map viewer process...")
|
|
try:
|
|
# The target function `run_map_viewer_process_target` requires the DEM cache directory path.
|
|
# This was stored during GUI initialization.
|
|
dem_cache_dir = self._dem_data_cache_dir_for_map_process
|
|
|
|
# Include the DEM cache directory and display scale in the arguments tuple.
|
|
# base_args_tuple already contains (map_interaction_q, operation_mode, center_latitude, center_longitude, area_bounding_box).
|
|
# Add dem_cache_dir and display_scale_factor to the end.
|
|
scale_val = self.map_display_scale_factor_var.get()
|
|
full_args = base_args_tuple + (dem_cache_dir, scale_val)
|
|
|
|
self.map_viewer_process_handle = multiprocessing.Process(
|
|
target=process_targets.run_map_viewer_process_target, # Call imported target
|
|
args=full_args,
|
|
daemon=True, # Daemon process will be terminated when the main GUI process exits
|
|
name="GeoElevationMapViewer"
|
|
)
|
|
self.map_viewer_process_handle.start()
|
|
logger.info(f"GUI: Started map viewer process PID: {self.map_viewer_process_handle.pid}, Scale: {scale_val:.3f}.")
|
|
# Ensure the GUI starts checking the queue for messages from the new process.
|
|
# The initial `self.root.after` call in __init__ handles this, but doesn't hurt to be sure.
|
|
# self.root.after(100, self._process_map_interaction_queue_messages) # Already scheduled
|
|
|
|
except Exception as e:
|
|
logger.exception("GUI Error: Failed to start map viewer process.")
|
|
messagebox.showerror("Process Error",f"Could not start map viewer:\n{e}",parent=self.root)
|
|
|
|
|
|
def _trigger_view_map_for_point(self) -> None:
|
|
"""Triggers the display of a map centered on the last valid point."""
|
|
# MODIFIED: Check map system availability first.
|
|
# WHY: Prevent starting process logic if dependencies are missing.
|
|
# HOW: Added initial check.
|
|
if not MAP_VIEWER_SYSTEM_AVAILABLE:
|
|
messagebox.showerror("Map System Unavailable", "Map viewer dependencies are not available.", parent=self.root); return
|
|
|
|
if not self.last_valid_point_coords:
|
|
messagebox.showinfo("Info", "Please get elevation for a point first.", parent=self.root); return
|
|
|
|
lat, lon = self.last_valid_point_coords
|
|
logger.info(f"GUI: Requesting map view for point ({lat:.5f},{lon:.5f}).")
|
|
|
|
# MODIFIED: Clear Map Info fields when starting a new map view.
|
|
# WHY: Indicate that new map information is loading.
|
|
# HOW: Set StringVar values.
|
|
self.map_lat_decimal_var.set("...") # Indicate loading
|
|
self.map_lon_decimal_var.set("...")
|
|
self.map_lat_dms_var.set("...") # Indicate loading for DMS fields
|
|
self.map_lon_dms_var.set("...") # Indicate loading for DMS fields
|
|
self.map_elevation_var.set("...")
|
|
self.map_area_size_var.set("N/A (Point)") # Indicate this is for a point
|
|
|
|
|
|
# Prepare base arguments for the map viewer process target function.
|
|
# It needs the queue, mode, point coords, area bbox (None for point mode), and cache dir (added in _start_map_viewer_process).
|
|
# The display scale is also added in _start_map_viewer_process.
|
|
base_args = (self.map_interaction_message_queue, "point", lat, lon, None)
|
|
|
|
# Call the helper to start the process
|
|
self._start_map_viewer_process(base_args)
|
|
|
|
|
|
def _trigger_view_map_for_area(self) -> None:
|
|
"""Triggers the display of a map covering the last downloaded area."""
|
|
# MODIFIED: Check map system availability first.
|
|
# WHY: Prevent starting process logic if dependencies are missing.
|
|
# HOW: Added initial check.
|
|
if not MAP_VIEWER_SYSTEM_AVAILABLE:
|
|
messagebox.showerror("Map System Unavailable", "Map viewer dependencies are not available.", parent=self.root); return
|
|
|
|
if not self.last_area_coords:
|
|
messagebox.showinfo("Info", "Please download an area first.", parent=self.root); return
|
|
|
|
min_l, min_o, max_l, max_o = self.last_area_coords
|
|
logger.info(f"GUI: Requesting map view for area Lat [{min_l:.2f}-{max_l:.2f}], Lon [{min_o:.2f}-{max_o:.2f}].")
|
|
|
|
# MODIFIED: Clear Map Info fields when starting a new map view.
|
|
# WHY: Indicate that new map information is loading.
|
|
# HOW: Set StringVar values. Point info N/A for area view.
|
|
self.map_lat_decimal_var.set("N/A") # Point info not relevant for area view
|
|
self.map_lon_decimal_var.set("N/A")
|
|
self.map_lat_dms_var.set("N/A") # Point info not relevant for area view
|
|
self.map_lon_dms_var.set("N/A") # Point info not relevant for area view
|
|
self.map_elevation_var.set("N/A") # Point info not relevant for area view
|
|
self.map_area_size_var.set("Loading...")
|
|
|
|
|
|
# Prepare base arguments for the map viewer process target function.
|
|
# It needs the queue, mode, point coords (None for area mode), area bbox, and cache dir (added in _start_map_viewer_process).
|
|
# The display scale is also added in _start_map_viewer_process.
|
|
area_bbox_for_process = (min_o, min_l, max_o, max_l) # Pass as (west, south, east, north) as expected by map_utils/geo_map_viewer
|
|
base_args = (self.map_interaction_message_queue, "area", None, None, area_bbox_for_process)
|
|
|
|
# Call the helper to start the process
|
|
self._start_map_viewer_process(base_args)
|
|
|
|
|
|
def _process_map_interaction_queue_messages(self) -> None:
|
|
"""Periodically checks the queue for messages from the map viewer process and updates the GUI."""
|
|
if not self.map_interaction_message_queue:
|
|
logger.debug("GUI: Map interaction queue is not initialized, stopping message processing.")
|
|
return # Stop scheduling if queue is gone
|
|
|
|
try:
|
|
# Process all messages currently in the queue without blocking
|
|
while not self.map_interaction_message_queue.empty():
|
|
msg = self.map_interaction_message_queue.get_nowait()
|
|
msg_t = msg.get("type")
|
|
|
|
# MODIFIED: Handle the new message type 'map_info_update'.
|
|
# WHY: This message is sent by the map process for initial point info, click info, and errors.
|
|
# HOW: Check for the type and update all relevant Map Info labels.
|
|
# MODIFIED: Update Entry widgets using StringVar and handle new DMS fields from message.
|
|
# WHY: Populate the copyable Entry widgets and show DMS coordinates.
|
|
# HOW: Use set() on StringVars, access new keys in the message payload.
|
|
if msg_t == "map_info_update":
|
|
# 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')}")
|
|
|
|
# MODIFIED: Example of how to use the new combined bounds function in test/debug.
|
|
# WHY: Demonstrate usage of the new function.
|
|
# HOW: Added test data and print statements.
|
|
test_tile_list_info = [
|
|
{'latitude_coord': 44, 'longitude_coord': 7, 'tile_base_name': 'n44e007', 'hgt_file_path': 'path/to/n44e007.hgt', 'browse_image_path': 'path/to/n44e007.jpg', 'hgt_available': True, 'browse_available': True},
|
|
{'latitude_coord': 45, 'longitude_coord': 7, 'tile_base_name': 'n45e007', 'hgt_file_path': 'path/to/n45e007.hgt', 'browse_image_path': 'path/to/n45e007.jpg', 'hgt_available': True, 'browse_available': True},
|
|
{'latitude_coord': 44, 'longitude_coord': 8, 'tile_base_name': 'n44e008', 'hgt_file_path': 'path/to/n44e008.hgt', 'browse_image_path': 'path/to/n44e008.jpg', 'hgt_available': True, 'browse_available': True},
|
|
{'latitude_coord': 45, 'longitude_coord': 8, 'tile_base_name': 'n45e008', 'hgt_file_path': 'path/to/n45e008.hgt', 'browse_image_path': 'path/to/n45e008.jpg', 'hgt_available': True, 'browse_available': True},
|
|
]
|
|
combined_bounds = get_combined_geographic_bounds_from_tile_info_list(test_tile_list_info)
|
|
print(f"Test Combined Bounds: {combined_bounds}")
|
|
|
|
|
|
test_root = tk.Tk()
|
|
app_test_instance: Optional[ElevationApp] = None
|
|
try:
|
|
app_test_instance = ElevationApp(test_root)
|
|
test_root.mainloop()
|
|
except Exception as e_test_run:
|
|
logger.critical(f"Fatal error in direct test run: {e_test_run}", exc_info=True)
|
|
# Attempt cleanup on fatal error
|
|
if app_test_instance:
|
|
try:
|
|
app_test_instance._on_application_closing()
|
|
except Exception as e_cleanup:
|
|
logger.error(f"Error during cleanup after fatal test run error: {e_cleanup}")
|
|
elif test_root.winfo_exists():
|
|
# If app instance wasn't created, just destroy the root if it still exists
|
|
test_root.destroy()
|
|
sys.exit(1) # Exit with error code |