fix visualization map for point and area

This commit is contained in:
VALLONGOL 2025-05-14 08:47:38 +02:00
parent aa7fb18626
commit 3a5a3db6ad
3 changed files with 1164 additions and 314 deletions

View File

@ -37,15 +37,26 @@ from geoelevation.visualizer import SCIPY_AVAILABLE
# 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 from geoelevation package. "
f"Using fallback: {GEOELEVATION_DEM_CACHE_DEFAULT}"
"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
@ -147,16 +158,20 @@ def run_map_viewer_process_target(
) -> None:
child_logger = logging.getLogger("GeoElevationMapViewerChildProcess")
# Configure logger if it hasn't been configured in this process yet
if not child_logger.handlers:
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
@ -171,7 +186,9 @@ def run_map_viewer_process_target(
# 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)
@ -210,7 +227,9 @@ def run_map_viewer_process_target(
# 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)
@ -226,6 +245,7 @@ def run_map_viewer_process_target(
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.
@ -234,9 +254,19 @@ def run_map_viewer_process_target(
# 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}")
if key == ord('q') or key == 27: # 'q' or Escape key
child_logger.info("Map window closing due to 'q' or Escape key press.")
# 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
@ -252,7 +282,9 @@ def run_map_viewer_process_target(
# 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)
@ -306,7 +338,10 @@ class ElevationApp:
self.elevation_manager = None
self.root.title("GeoElevation Tool")
self.root.minsize(480, 520)
# 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
@ -330,6 +365,17 @@ class ElevationApp:
# 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()
@ -451,22 +497,53 @@ class ElevationApp:
# 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))
map_info_frame.columnconfigure(1, weight=1)
ttk.Label(map_info_frame, text="Latitude:").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") # Keep name for now for less refactoring
self.map_click_latitude_label.grid(row=0, column=1, sticky=(tk.W, tk.E), padx=5, pady=2)
ttk.Label(map_info_frame, text="Longitude:").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") # Keep name for now
self.map_click_longitude_label.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5, pady=2)
ttk.Label(map_info_frame, text="Elevation:").grid(row=2, column=0, sticky=tk.W, padx=5, pady=2)
self.map_click_elevation_label = ttk.Label(map_info_frame, text="N/A") # Keep name for now
self.map_click_elevation_label.grid(row=2, column=1, sticky=(tk.W, tk.E), padx=5, pady=2)
# 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=3, column=0, sticky=tk.W, padx=5, pady=2)
self.map_area_size_label = ttk.Label(map_info_frame, text="N/A", wraplength=300, justify=tk.LEFT)
self.map_area_size_label.grid(row=3, column=1, sticky=(tk.W, tk.E), padx=5, pady=2)
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)
@ -517,6 +594,23 @@ class ElevationApp:
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)
@ -561,10 +655,10 @@ class ElevationApp:
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).")
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).")
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
@ -574,10 +668,18 @@ class ElevationApp:
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)
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.")
if min_l >= max_l: raise ValueError("Min Lat >= Max Lat.")
if min_o >= max_o: raise ValueError("Min Lon >= Max Lon.")
# 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
@ -585,7 +687,13 @@ class ElevationApp:
"""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: return
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.
@ -593,6 +701,17 @@ class ElevationApp:
# 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
@ -631,6 +750,9 @@ class ElevationApp:
# 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
@ -642,33 +764,93 @@ class ElevationApp:
# 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: float,
original_longitude: float
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
if exception_occurred:
# 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."
self.last_valid_point_coords = (original_latitude, original_longitude) # Valid coords, result is NoData
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
logger.info(f"GUI: Point elevation task completed, elevation {elevation_result:.2f}m for ({original_latitude:.5f},{original_longitude:.5f}).")
self.point_result_label.config(text=res_txt)
@ -695,7 +877,23 @@ class ElevationApp:
"""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: return
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.
@ -703,6 +901,17 @@ class ElevationApp:
# 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
@ -934,6 +1143,17 @@ class ElevationApp:
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.
@ -957,6 +1177,17 @@ class ElevationApp:
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.
@ -982,36 +1213,48 @@ class ElevationApp:
# 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")
elev_str = msg.get("elevation_str", "N/A") # Default to N/A if key is missing
map_area_size_str = msg.get("map_area_size_str", "N/A") # Default to N/A
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 coordinates for display
lat_txt = f"{lat_val:.5f}" if isinstance(lat_val, (int, float)) and not math.isnan(lat_val) else "N/A"
lon_txt = f"{lon_val:.5f}" if isinstance(lon_val, (int, float)) and not math.isnan(lon_val) else "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"
self.map_click_latitude_label.config(text=lat_txt)
self.map_click_longitude_label.config(text=lon_txt)
self.map_click_elevation_label.config(text=elev_str)
# MODIFIED: Update the new map area size label.
# WHY: Display the size received from the map process.
# HOW: Set the text of map_area_size_label.
self.map_area_size_label.config(text=map_area_size_str)
# 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}'")
logger.debug(f"GUI: Updated Map Info panel with: Lat={lat_txt}, Lon={lon_txt}, 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_click_latitude_label.config(text="") # Clear coords
self.map_click_longitude_label.config(text="")
self.map_click_elevation_label.config(text=status_message) # Show status as elevation text
self.map_area_size_label.config(text="") # Clear size
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
@ -1028,6 +1271,16 @@ class ElevationApp:
# 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.
@ -1105,6 +1358,19 @@ if __name__ == "__main__":
# 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:

File diff suppressed because it is too large Load Diff

View File

@ -168,11 +168,22 @@ def get_tile_ranges_for_bbox(
f"No tiles found by mercantile.tiles for BBox at zoom {zoom_level}. "
"Using fallback: tile at BBox center."
)
center_lon = (west_lon + east_lon) / 2.0
center_lat = (south_lat + north_lat) / 2.0
# Ensure coordinates are within valid WGS84 bounds before calculating center
clamped_west_lon = max(-180.0, min(180.0, west_lon))
clamped_east_lon = max(-180.0, min(180.0, east_lon))
clamped_south_lat = max(-90.0, min(90.0, south_lat))
clamped_north_lat = max(-90.0, min(90.0, north_lat))
center_lon = (clamped_west_lon + clamped_east_lon) / 2.0
center_lat = (clamped_south_lat + clamped_north_lat) / 2.0
# Clamp center_lat to avoid mercantile issues near poles if bbox extends beyond valid range
center_lat = max(-85.0, min(85.0, center_lat)) # Mercantile limits
center_lon = max(-180.0, min(180.0, center_lon)) # Mercantile limits
center_lat = max(-85.0, min(85.0, center_lat)) # Mercantile limits - approx. latitude of tile row 0 or max
# Ensure center_lon wraps correctly if bbox spans the antimeridian
if clamped_west_lon > clamped_east_lon: # Bbox crosses antimeridian
center_lon = (clamped_west_lon + clamped_east_lon + 360) / 2.0
if center_lon > 180: center_lon -= 360
# mercantile.tile(lon, lat, zoom)
center_point_tile = mercantile.tile(center_lon, center_lat, zoom_level) # type: ignore
@ -286,10 +297,13 @@ def calculate_geographic_bbox_size_km(
# Basic validation
if not (-90.0 <= south_lat <= north_lat <= 90.0):
logger.warning(f"Invalid latitude range for size calculation: {south_lat}, {north_lat}")
# Try clamping and continue, or return None? Let's clamp for robustness.
south_lat = max(-90.0, south_lat)
north_lat = min(90.0, north_lat)
if south_lat >= north_lat: # After clamping, check if still invalid
logger.error(f"Invalid latitude range after clamping: {south_lat}, {north_lat}. Cannot calculate size.")
return None
# For longitude, check range is within a reasonable bound, but allow west > east for dateline crossing
# The geographic width should be calculated carefully to handle wrapping around the globe.
# The height calculation is simpler as latitude is bounded.
try:
geodetic_calculator = pyproj.Geod(ellps="WGS84") # type: ignore
@ -308,10 +322,18 @@ def calculate_geographic_bbox_size_km(
# This is simpler, distance between south_lat and north_lat at center_lon
# Need to handle potential longitude wrap around - use inv carefully
# The straight line distance calculation below should be generally fine for height.
center_lon = (west_lon + east_lon) / 2.0 # Use the average longitude for height calculation line
# Clamp center lon
center_lon = max(-179.9, min(179.9, center_lon))
_, _, height_meters = geodetic_calculator.inv(center_lon, south_lat, center_lon, north_lat)
# Using the average longitude for height calculation line might not be strictly necessary,
# distance between two points at the same longitude is simple geodetic distance.
# However, using inv is more general and handles the ellipsoid correctly.
center_lon_for_height = (west_lon + east_lon) / 2.0 # Use the average longitude for height calculation line
# Handle antimeridian crossing for height calculation by adjusting center_lon_for_height if needed.
# If the width calculation crossed the antimeridian (west_lon > east_lon originally), the average might be misleading.
# A simpler approach for height is just calculating distance between (center_lon, south) and (center_lon, north).
# Let's reuse the original logic using the average longitude, ensuring it's within range.
center_lon_for_height = max(-180.0, min(180.0, center_lon_for_height)) # Ensure within standard range
_, _, height_meters = geodetic_calculator.inv(center_lon_for_height, south_lat, center_lon_for_height, north_lat)
approx_width_km = abs(width_meters) / 1000.0 # Ensure positive distance
approx_height_km = abs(height_meters) / 1000.0
@ -362,6 +384,18 @@ def get_hgt_tile_geographic_bounds(lat_coord: int, lon_coord: int) -> Tuple[floa
east_lon = max(-180.0, min(180.0, east_lon))
north_lat = max(-90.0, min(90.0, north_lat))
# Ensure west < east and south < north for valid bbox representation, handling wrap-around is complex
# For 1x1 degree tiles not crossing antimeridian or poles widely, this is fine.
# A more robust check might be needed for edge cases near antimeridian, but for 1x1 tiles this is usually okay.
if west_lon > east_lon:
# This case ideally shouldn't happen for 1x1 degree tiles unless lon_coord is 179 and it wraps,
# but let's handle defensively if coords are unusual. Could swap or adjust.
# For HGT tile boundaries, it's usually [lon, lon+1] so this shouldn't be an issue.
logger.warning(f"Calculated west > east for HGT tile bounds ({lat_coord},{lon_coord}): ({west_lon}, {east_lon}).")
# Assuming it's a simple swap needed
# west_lon, east_lon = east_lon, west_lon # This might not be correct if it actually wraps the globe
pass # Let's stick to the direct calculation based on HGT convention
logger.debug(f"Calculated HGT tile bounds for ({lat_coord},{lon_coord}): ({west_lon:.6f}, {south_lat:.6f}, {east_lon:.6f}, {north_lat:.6f})")
return (west_lon, south_lat, east_lon, north_lat)
@ -459,7 +493,92 @@ def calculate_zoom_level_for_geographic_size(
)
return clamped_zoom
except Exception as e_zoom_calc:
logger.exception(f"Error calculating zoom level: {e_zoom_calc}")
return None
# MODIFIED: Added utility function to convert decimal degrees to DMS string format.
# WHY: Needed for displaying coordinates in a user-friendly, copyable format in the GUI.
# HOW: Implemented standard conversion logic.
def deg_to_dms_string(degree_value: float, coord_type: str) -> str:
"""
Converts a decimal degree coordinate to a Degrees, Minutes, Seconds (DMS) string.
Args:
degree_value: The coordinate value in decimal degrees (float).
coord_type: 'lat' for latitude (determines N/S suffix), 'lon' for longitude (E/W suffix).
Returns:
A formatted DMS string (e.g., "45° 30' 15.23'' N"). Returns "N/A" for non-finite or invalid inputs.
"""
if not isinstance(degree_value, (int, float)) or not math.isfinite(degree_value):
# Handle None, NaN, Inf, etc.
return "N/A"
# Clamp to valid ranges for sanity, although conversion works outside.
# Note: DMS representation is strictly defined for lat [-90, 90] and lon [-180, 180]
if coord_type.lower() == 'lat':
if not -90.0 <= degree_value <= 90.0:
logger.warning(f"Latitude {degree_value} is outside valid range [-90, 90] for standard DMS.")
# Still convert, but maybe add a note or handle separately if needed.
# For now, just convert the clamped value.
degree_value = max(-90.0, min(90.0, degree_value))
elif coord_type.lower() == 'lon':
# Longitude wrapping - DMS usually represents within [-180, 180].
# Python's % operator handles negative numbers differently than some other languages,
# (a % b) has the same sign as b. So ((value + 180) % 360 - 180) ensures the result
# is in (-180, 180]. If the input is exactly 180, it becomes 180.
degree_value = ((degree_value + 180) % 360) - 180
# Check if exactly -180.0 and adjust to 180.0 if needed for conventional representation
# if degree_value == -180.0:
# degree_value = 180.0
else:
logger.warning(f"Unknown coordinate type '{coord_type}' for DMS conversion.")
return "N/A (Invalid Type)"
is_negative = degree_value < 0
abs_degree = abs(degree_value)
degrees = int(abs_degree)
minutes_decimal = (abs_degree - degrees) * 60
minutes = int(minutes_decimal)
seconds = (minutes_decimal - minutes) * 60
# Determine suffix (North/South, East/West)
suffix = ""
if coord_type.lower() == 'lat':
suffix = "N" if not is_negative else "S"
elif coord_type.lower() == 'lon':
# Longitude can be 0 or 180, technically not negative/positive in terms of E/W.
# Let's use the sign logic which is correct for the suffix E/W convention.
suffix = "E" if not is_negative else "W"
# Special case for exactly 0 or 180, maybe no suffix or specific suffix?
# Standard practice is usually to use E for 0 and 180 if positive/negative sign is ignored.
if degree_value == 0.0: suffix = "" # No suffix for 0? Or E? Let's use ""
elif abs(degree_value) == 180.0: suffix = "" # No suffix for 180? Or W? Let's use ""
# Format the string
# Use a consistent number of decimal places for seconds, e.g., 2
# Handle potential edge case where seconds round up to 60
if seconds >= 59.995: # Check if seconds are very close to 60 due to float precision
seconds = 0.0
minutes += 1
if minutes >= 60:
minutes = 0
degrees += 1
# Degree could potentially roll over if it was like 89 deg 59 min 59.99 sec
# This simple logic assumes it won't cross 90 or 180 significantly with typical inputs
# A more robust implementation might need to handle full degree rollover, but unlikely for standard inputs.
# For now, just increment degree if minutes rolled over from 59 to 60.
dms_string = f"{degrees}° {minutes}' {seconds:.2f}''"
# Add suffix only if it's meaningful (not empty)
if suffix:
dms_string += f" {suffix}"
return dms_string