From 3a5a3db6ad00ea453448906beb0af65a2b77d19d Mon Sep 17 00:00:00 2001 From: VALLONGOL Date: Wed, 14 May 2025 08:47:38 +0200 Subject: [PATCH] fix visualization map for point and area --- geoelevation/elevation_gui.py | 366 ++++++-- geoelevation/map_viewer/geo_map_viewer.py | 965 ++++++++++++++++------ geoelevation/map_viewer/map_utils.py | 147 +++- 3 files changed, 1164 insertions(+), 314 deletions(-) diff --git a/geoelevation/elevation_gui.py b/geoelevation/elevation_gui.py index ee68567..5a9de77 100644 --- a/geoelevation/elevation_gui.py +++ b/geoelevation/elevation_gui.py @@ -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: diff --git a/geoelevation/map_viewer/geo_map_viewer.py b/geoelevation/map_viewer/geo_map_viewer.py index 5ae01cc..a8e1faf 100644 --- a/geoelevation/map_viewer/geo_map_viewer.py +++ b/geoelevation/map_viewer/geo_map_viewer.py @@ -65,6 +65,7 @@ from .map_utils import MapCalculationError # MODIFIED: Import the new utility functions for geographic size and HGT tile bounds. # WHY: Needed for calculating displayed map area size and getting DEM tile bounds. # HOW: Added imports from map_utils. +from . import map_utils from .map_utils import calculate_geographic_bbox_size_km from .map_utils import get_hgt_tile_geographic_bounds # MODIFIED: Import the new utility function to calculate zoom level for geographic size. @@ -91,12 +92,43 @@ DEFAULT_MAP_VIEW_AREA_SIZE_KM = 5.0 # This default might become less relevant fo # HOW: Added constants for DEM boundary color and thickness. DEM_BOUNDARY_COLOR = "red" DEM_BOUNDARY_THICKNESS_PX = 3 # Pixel thickness on the unscaled map image +# MODIFIED: Define constants for drawing the Requested Area boundary. +# WHY: Improves code clarity and makes colors/thickness easily adjustable. Distinct from DEM color. +# HOW: Added constants for Area boundary color and thickness. +AREA_BOUNDARY_COLOR = "blue" +AREA_BOUNDARY_THICKNESS_PX = 2 + # MODIFIED: Define target pixel dimensions for the stitched map image in the point view. # WHY: This is the desired output size that determines the calculated zoom level. # HOW: Added a constant. TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW = 1024 # Target width and height in pixels +# MODIFIED: Define text drawing parameters for DEM tile labels. +# WHY: Centralize style for labels. +# HOW: Added constants for color, background color, font size. Reusing constants from image_processor for consistency. +try: + # Attempt to import constants from image_processor for consistency + from geoelevation.image_processor import TILE_TEXT_COLOR, TILE_TEXT_BG_COLOR, DEFAULT_FONT + DEM_TILE_LABEL_COLOR = TILE_TEXT_COLOR + DEM_TILE_LABEL_BG_COLOR = TILE_TEXT_BG_COLOR + _DEFAULT_FONT_FOR_LABELS = DEFAULT_FONT # Use the font loaded in image_processor +except ImportError: + # Fallback values if image_processor constants are not available (shouldn't happen if image_processor loads font) + DEM_TILE_LABEL_COLOR = "white" + DEM_TILE_LABEL_BG_COLOR = "rgba(0, 0, 0, 150)" # Semi-transparent black background + _DEFAULT_FONT_FOR_LABELS = None # No font available from image_processor + logger.warning("Could not import text style constants or default font from image_processor. Using fallbacks for DEM tile labels.") + + +# MODIFIED: Base font size for DEM tile labels for zoom-based scaling. +# WHY: Provide a starting point for font size calculation. +# HOW: Added a constant. +DEM_TILE_LABEL_BASE_FONT_SIZE = 12 # px +# MODIFIED: Base zoom level for DEM tile label font scaling. +# WHY: Font size will be proportional to (current_zoom - BASE_ZOOM). +# HOW: Added a constant. +DEM_TILE_LABEL_BASE_ZOOM = 10 # At zoom 10, font size will be BASE_FONT_SIZE class GeoElevationMapViewer: """ @@ -171,6 +203,12 @@ class GeoElevationMapViewer: # HOW: Added a new instance attribute. self._dem_tile_geo_bbox_for_current_map: Optional[Tuple[float, float, float, float]] = None + # MODIFIED: Added attributes to store info for AREA view drawing on clicks. + # WHY: Needed to redraw the requested area boundary (blue) and all DEM tile boundaries/labels (red) on clicks for area view. + # HOW: Added new instance attributes. + self._current_requested_area_geo_bbox: Optional[Tuple[float, float, float, float]] = None + self._dem_tiles_info_for_current_map: List[Dict] = [] # Store list of tile info dicts + self._initialize_map_viewer_components() logger.info("GeoElevationMapViewer instance initialization complete.") @@ -231,13 +269,15 @@ class GeoElevationMapViewer: """ if not self.map_tile_fetch_manager or not self.map_display_window_controller or not self.elevation_manager or not self.map_service_provider: # Added check for map_service_provider logger.error("Map or Elevation components not ready for display_map_for_point.") - # MODIFIED: Send error info to GUI queue if components aren't ready. - # WHY: GUI should update even if map isn't displayed. - # HOW: Put an error message into the queue. + # MODIFIED: If components aren't ready, send error info to GUI queue if components aren't ready. + # WHY: GUI needs to know the map view failed. + # HOW: Put an error message into the queue. + # MODIFIED: Include DMS fields with error state for consistency with GUI update logic. error_payload = {"type": "map_info_update", "latitude": center_latitude, "longitude": center_longitude, + "latitude_dms_str": "Error", "longitude_dms_str": "Error", # Added DMS fields "elevation_str": "Map Error", "map_area_size_str": "Error: Components N/A"} try: self.gui_com_queue.put(error_payload) - except Exception as e_put_err: logger.error(f"Failed to put error payload to queue: {e_put_err}") + except Exception as e_put_err: logger.error(f"Failed to put error payload to queue before exit: {e_put_err}") if self.map_display_window_controller: self.map_display_window_controller.show_map(None) # Show placeholder return @@ -251,10 +291,13 @@ class GeoElevationMapViewer: dem_tile_geo_bbox: Optional[Tuple[float, float, float, float]] = None map_fetch_geo_bbox: Optional[Tuple[float, float, float, float]] = None # BBox to fetch tiles for - # MODIFIED: Reset the stored DEM tile bbox for the current map view. - # WHY: This is a new map view, so the previous DEM bbox info is irrelevant. - # HOW: Set _dem_tile_geo_bbox_for_current_map to None. - self._dem_tile_geo_bbox_for_current_map = None + # MODIFIED: Clear stored area view state when switching to point view. + # WHY: Ensure state is clean. + # HOW: Set attributes to None/empty. + self._current_requested_area_geo_bbox = None + self._dem_tiles_info_for_current_map = [] + self._dem_tile_geo_bbox_for_current_map = None # Keep this one as it's specific to point view + # MODIFIED: Initialize map_tile_xy_ranges to None before the try block. # WHY: To ensure the variable is defined even if an exception occurs before its assignment. @@ -360,9 +403,10 @@ class GeoElevationMapViewer: logger.warning(f"No map tile ranges found for fetch BBox {map_fetch_geo_bbox} at zoom {zoom_to_use}. Showing placeholder.") self.map_display_window_controller.show_map(None) # MODIFIED: Send initial info to GUI even if map fails, with error status. - # WHY: GUI should update even if map isn't displayed. + # WHY: GUI needs to update even if map isn't displayed. # HOW: Call _send_initial_point_info_to_gui with error details. - self._send_initial_point_info_to_gui(center_latitude, center_longitude, "Map Tiles N/A", "Map Tiles N/A") + # MODIFIED: Include DMS fields with error state for consistency with GUI update logic. + self._send_initial_point_info_to_gui(center_latitude, center_longitude, "Map Tiles N/A", "Map Tiles N/A") # Added DMS fields handled in send function return # Exit after showing placeholder/sending error @@ -374,9 +418,10 @@ class GeoElevationMapViewer: logger.error("Failed to stitch map image.") self.map_display_window_controller.show_map(None) # MODIFIED: Send initial info to GUI even if stitch fails. - # WHY: GUI should update even if map isn't displayed. + # WHY: GUI needs to update even if map isn't displayed. # HOW: Call _send_initial_point_info_to_gui with error details. - self._send_initial_point_info_to_gui(center_latitude, center_longitude, "Map Stitch Failed", "Map Stitch Failed") + # MODIFIED: Include DMS fields with error state for consistency with GUI update logic. + self._send_initial_point_info_to_gui(center_latitude, center_longitude, "Map Stitch Failed", "Map Stitch Failed") # Added DMS fields handled in send function return # Exit after showing placeholder/sending error @@ -425,8 +470,12 @@ class GeoElevationMapViewer: # although it was already fetched in elevation_gui. initial_elev = self.elevation_manager.get_elevation(center_latitude, center_longitude) initial_elev_str = "Unavailable" if initial_elev is None else ("NoData" if math.isnan(initial_elev) else f"{initial_elev:.2f} m") + # MODIFIED: Corrected log format to use center_longitude. + # WHY: Fix NameError. + # HOW: Replaced 'longitude' with 'center_longitude'. logger.info(f"Initial elevation at point ({center_latitude:.5f},{center_longitude:.5f}) is: {initial_elev_str}") + # Calculate and send map area size map_area_size_str = "N/A" if self._current_map_geo_bounds_deg: @@ -446,6 +495,9 @@ class GeoElevationMapViewer: logger.warning("PyProj not available, cannot calculate map area size.") + # MODIFIED: Send initial point info to GUI, including float coordinates. DMS strings are handled in the send function. + # WHY: Consistent with GUI update logic expecting float coords. + # HOW: Pass center_latitude and center_longitude. self._send_initial_point_info_to_gui( center_latitude, center_longitude, initial_elev_str, map_area_size_str ) @@ -455,16 +507,18 @@ class GeoElevationMapViewer: logger.error(f"Map calculation error for point display: {e_calc_map_pt}") if self.map_display_window_controller: self.map_display_window_controller.show_map(None) # MODIFIED: Send error info to GUI queue on calculation failure. - # WHY: GUI should update even if map isn't displayed. + # WHY: GUI needs to update even if map isn't displayed. # HOW: Call _send_initial_point_info_to_gui with error details. - self._send_initial_point_info_to_gui(center_latitude, center_longitude, f"Map Calc Error: {e_calc_map_pt}", "Map Calc Error") + # MODIFIED: Include original coords in error message if available. + self._send_initial_point_info_to_gui(center_latitude, center_longitude, f"Map Calc Error: {e_calc_map_pt}", "Map Calc Error") # DMS handled in send function except Exception as e_disp_map_pt_fatal: logger.exception(f"Unexpected error displaying map for point: {e_disp_map_pt_fatal}") if self.map_display_window_controller: self.map_display_window_controller.show_map(None) # MODIFIED: Send error info to GUI queue on unexpected fatal error. - # WHY: GUI should update even if map isn't displayed. + # WHY: GUI needs to update even if map isn't displayed. # HOW: Call _send_initial_point_info_to_gui with error details. - self._send_initial_point_info_to_gui(center_latitude, center_longitude, f"Fatal Map Error: {type(e_disp_map_pt_fatal).__name__}", "Fatal Error") + # MODIFIED: Include original coords in error message if available. + self._send_initial_point_info_to_gui(center_latitude, center_longitude, f"Fatal Map Error: {type(e_disp_map_pt_fatal).__name__}", "Fatal Error") # DMS handled in send function def display_map_for_area( @@ -476,25 +530,28 @@ class GeoElevationMapViewer: Displays a map for a geographic area, applying the current display scale. Calculates the zoom level dynamically to fit the requested area into a target pixel size. """ - if not self.map_tile_fetch_manager or not self.map_display_window_controller or not self.map_service_provider: # Added check for map_service_provider + if not self.map_tile_fetch_manager or not self.map_display_window_controller or not self.map_service_provider or not self.elevation_manager: # Added check for map_service_provider and elevation_manager logger.error("Map components not ready for display_map_for_area.") # MODIFIED: Send error info to GUI queue if components aren't ready. - # WHY: GUI should update even if map isn't displayed. + # WHY: GUI needs to update even if map isn't displayed. # HOW: Put an error message into the queue. + # 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": "Map Error", "map_area_size_str": "Error: Components N/A"} try: self.gui_com_queue.put(error_payload) - except Exception as e_put_err: logger.error(f"Failed to put error payload to queue: {e_put_err}") + except Exception as e_put_err: logger.error(f"Failed to put error payload to queue before exit: {e_put_err}") if self.map_display_window_controller: self.map_display_window_controller.show_map(None) # Show placeholder return - # MODIFIED: Remove the effective_zoom calculation that defaulted to DEFAULT_MAP_DISPLAY_ZOOM_LEVEL. - # WHY: The goal is to calculate the zoom dynamically based on the area size, not use a fixed default. - # effective_zoom = target_map_zoom if target_map_zoom is not None else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL - # logger.info( - # f"Requesting map display for area: BBox {area_geo_bbox}, " - # f"Zoom: {effective_zoom}, CurrentDisplayScale: {self.current_display_scale_factor:.2f}" - # ) + # MODIFIED: Store the requested area bbox and clear point view state. + # WHY: Needed to redraw the requested area boundary (blue) on clicks. Clear point state for cleanliness. + # HOW: Set _current_requested_area_geo_bbox and _dem_tile_geo_bbox_for_current_map to None. + self._current_requested_area_geo_bbox = area_geo_bbox # Store the original requested area + self._dem_tile_geo_bbox_for_current_map = None + self._dem_tiles_info_for_current_map = [] # Initialize list for this view + + logger.info( f"Requesting map display for area: BBox {area_geo_bbox}, " f"Target Pixel Size: {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}x{TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}, " @@ -502,11 +559,7 @@ class GeoElevationMapViewer: ) - # MODIFIED: Clear the stored DEM tile bbox as this is an area view. - # WHY: The DEM boundary is specific to the initial point view. - # HOW: Set _dem_tile_geo_bbox_for_current_map to None. - self._dem_tile_geo_bbox_for_current_map = None - + combined_dem_geo_bbox: Optional[Tuple[float, float, float, float]] = None calculated_zoom: Optional[int] = None zoom_calculation_successful = False map_area_size_km: Optional[Tuple[float, float]] = None @@ -517,39 +570,91 @@ class GeoElevationMapViewer: map_tile_xy_ranges = None try: - # MODIFIED: Calculate the geographic size of the requested area bounding box. - # WHY: Needed to determine the appropriate zoom level to fit this area into a target pixel size. - # HOW: Call map_utils.calculate_geographic_bbox_size_km. + # --- Determine the full geographic extent of all relevant DEM tiles --- + logger.debug("Getting DEM tile info for the REQUESTED area...") + # Pass the bounds of the *REQUESTED* area to find relevant DEM tiles + all_relevant_dem_tiles_info = self.elevation_manager.get_area_tile_info( + area_geo_bbox[1], # min_lat (south) + area_geo_bbox[0], # min_lon (west) + area_geo_bbox[3], # max_lat (north) + area_geo_bbox[2] # max_lon (east) + ) + # Store only the info for tiles where HGT data is available that fall within the *requested area* + dem_tiles_info_in_requested_area = [ + info for info in all_relevant_dem_tiles_info if info.get("hgt_available") + ] + logger.info(f"Found {len(dem_tiles_info_in_requested_area)} DEM tiles with HGT data in the REQUESTED area.") + + if not dem_tiles_info_in_requested_area: + logger.warning("No DEM tiles with HGT data found in the requested area. Cannot display relevant DEM context.") + # Decide fallback: Display requested area with map tiles, or show placeholder? + # Let's show a placeholder map for the requested area, indicating no DEM data found. + logger.warning(f"No DEM tiles with HGT data found in the requested area {area_geo_bbox}. Showing placeholder.") + self.map_display_window_controller.show_map(None) + # Send info to GUI with status + self._send_initial_point_info_to_gui(None, None, "No DEM Data in Area", "Area: No DEM Data") # DMS handled in send function + # We still set state variables to reflect no DEM tiles were found for this view. + self._dem_tiles_info_for_current_map = [] + self._current_map_geo_bounds_deg = None # No stitched map + self._current_map_render_zoom = None + self._current_stitched_map_pixel_shape = (0, 0) + return # Exit if no DEM tiles found + + # Store the list of relevant DEM tiles (with HGT data) for this view + self._dem_tiles_info_for_current_map = dem_tiles_info_in_requested_area + + # Calculate the combined geographic bounding box of ALL these relevant DEM tiles + min_lon_combined, min_lat_combined, max_lon_combined, max_lat_combined = 180.0, 90.0, -180.0, -90.0 + first_tile_bounds = get_hgt_tile_geographic_bounds( + dem_tiles_info_in_requested_area[0]["latitude_coord"], + dem_tiles_info_in_requested_area[0]["longitude_coord"] + ) + min_lon_combined, min_lat_combined, max_lon_combined, max_lat_combined = first_tile_bounds # Initialize with first tile + + for tile_info in dem_tiles_info_in_requested_area[1:]: + tile_bounds = get_hgt_tile_geographic_bounds( + tile_info["latitude_coord"], + tile_info["longitude_coord"] + ) + min_lon_combined = min(min_lon_combined, tile_bounds[0]) + min_lat_combined = min(min_lat_combined, tile_bounds[1]) + max_lon_combined = max(max_lon_combined, tile_bounds[2]) + max_lat_combined = max(max_lat_combined, tile_bounds[3]) + + combined_dem_geo_bbox = (min_lon_combined, min_lat_combined, max_lon_combined, max_lat_combined) + logger.debug(f"Combined DEM tiles BBox for requested area: {combined_dem_geo_bbox}") + + + # --- Calculate Zoom to fit the Combined DEM BBox into target pixel size --- + calculated_zoom = None + zoom_calculation_successful = False + # MODIFIED: Check PyProj availability before calculating size. # WHY: calculate_geographic_bbox_size_km requires PyProj. # HOW: Added check. if PYPROJ_AVAILABLE: # type: ignore - map_area_size_km = calculate_geographic_bbox_size_km(area_geo_bbox) + # Use the COMBINED DEM BBox to calculate geographic size + map_area_size_km = calculate_geographic_bbox_size_km(combined_dem_geo_bbox) if map_area_size_km: width_km, height_km = map_area_size_km - logger.debug(f"Calculated geographic size of requested area: {width_km:.2f}km W x {height_km:.2f}km H") - - # MODIFIED: Calculate the appropriate zoom level to fit the area into the target pixel size. - # WHY: To prevent creating excessively large map images for large geographic areas. - # HOW: Use calculate_zoom_level_for_geographic_size based on the area's height. map_bbox_height_meters = height_km * 1000.0 - # Use the center latitude of the requested area BBox for zoom calculation accuracy - center_lat_area_bbox = (area_geo_bbox[1] + area_geo_bbox[3]) / 2.0 + # Use the center latitude of the COMBINED DEM BBox for zoom calculation accuracy + center_lat_combined_bbox = (combined_dem_geo_bbox[1] + combined_dem_geo_bbox[3]) / 2.0 calculated_zoom = calculate_zoom_level_for_geographic_size( - center_lat_area_bbox, + center_lat_combined_bbox, map_bbox_height_meters, - TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW, # Target pixel height (reuse constant from point view) + TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW, # Target pixel height (reuse constant) self.map_service_provider.tile_size # Tile size from the map service ) if calculated_zoom is not None: - logger.info(f"Calculated zoom level {calculated_zoom} to fit Area BBox height ({map_bbox_height_meters:.2f}m) into {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}px.") + logger.info(f"Calculated zoom level {calculated_zoom} to fit COMBINED DEM BBox height ({map_bbox_height_meters:.2f}m) into {TARGET_MAP_PIXEL_DIMENSION_FOR_POINT_VIEW}px.") zoom_calculation_successful = True else: - logger.warning("Could not calculate appropriate zoom level for area. Falling back to default zoom.") + logger.warning("Could not calculate appropriate zoom level for combined DEM area. Falling back to default zoom.") else: - logger.warning("Could not calculate geographic size of requested area BBox. Falling back to default zoom.") + logger.warning("Could not calculate geographic size of combined DEM BBox. Falling back to default zoom.") else: logger.warning("PyProj not available. Cannot calculate geographic size for zoom calculation. Falling back to default zoom.") @@ -558,22 +663,33 @@ class GeoElevationMapViewer: # WHY: Use the calculated zoom if successful, otherwise use the default zoom as a fallback. # HOW: Check zoom_calculation_successful. zoom_to_use = calculated_zoom if zoom_calculation_successful else DEFAULT_MAP_DISPLAY_ZOOM_LEVEL - logger.debug(f"Using zoom level {zoom_to_use} for tile fetching and stitching for area.") + # Clamp zoom to service max zoom + if self.map_service_provider and zoom_to_use > self.map_service_provider.max_zoom: + logger.warning(f"Calculated zoom {zoom_to_use} exceeds service max zoom {self.map_service_provider.max_zoom}. Clamping.") + zoom_to_use = self.map_service_provider.max_zoom + logger.debug(f"Using final zoom level {zoom_to_use} for tile fetching and stitching for area.") - # map_tile_xy_ranges assignment is here - corresponds to line 346 in point view - map_tile_xy_ranges = get_tile_ranges_for_bbox(area_geo_bbox, zoom_to_use) + + # --- Fetch and Stitch Map Tiles for the Combined DEM Area --- + # Use the COMBINED DEM BBox and the calculated zoom to get map tile ranges + map_tile_xy_ranges = get_tile_ranges_for_bbox(combined_dem_geo_bbox, zoom_to_use) if not map_tile_xy_ranges: - logger.warning(f"No map tile ranges found for area BBox {area_geo_bbox} at zoom {zoom_to_use}. Showing placeholder.") + logger.warning(f"No map tile ranges found for combined DEM BBox {combined_dem_geo_bbox} at zoom {zoom_to_use}. Showing placeholder.") self.map_display_window_controller.show_map(None) # MODIFIED: Send error info to GUI queue even if map fails. - # WHY: GUI should update even if map isn't displayed. + # WHY: GUI needs to update even if map isn't displayed. # HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point). - self._send_initial_point_info_to_gui(None, None, "Map Tiles N/A", "Map Tiles N/A") + # MODIFIED: Include DMS fields with error state for consistency with GUI update logic. + self._send_initial_point_info_to_gui(None, None, "Map Tiles N/A", "Map Tiles N/A") # Added DMS handled in send function + # Reset state variables related to the stitched map + self._current_map_geo_bounds_deg = None + self._current_map_render_zoom = None + self._current_stitched_map_pil = None + self._current_stitched_map_pixel_shape = (0, 0) return # Exit after showing placeholder/sending error - # MODIFIED: Pass the chosen zoom_to_use to stitch_map_image. stitched_pil = self.map_tile_fetch_manager.stitch_map_image( zoom_to_use, map_tile_xy_ranges[0], map_tile_xy_ranges[1] ) @@ -581,11 +697,18 @@ class GeoElevationMapViewer: logger.error("Failed to stitch map image for area display.") self.map_display_window_controller.show_map(None) # MODIFIED: Send error info to GUI queue even if stitch fails. - # WHY: GUI should update even if map isn't displayed. + # WHY: GUI needs to update even if map isn't displayed. # HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point). - self._send_initial_point_info_to_gui(None, None, "Map Stitch Failed", "Map Stitch Failed") + # MODIFIED: Include DMS fields with error state for consistency with GUI update logic. + self._send_initial_point_info_to_gui(None, None, "Map Stitch Failed", "Map Stitch Failed") # Added DMS handled in send function + # Reset state variables related to the stitched map + self._current_map_geo_bounds_deg = None + self._current_map_render_zoom = None + self._current_stitched_map_pil = None + self._current_stitched_map_pixel_shape = (0, 0) return # Exit after showing placeholder/sending error + self._current_stitched_map_pil = stitched_pil # MODIFIED: Store the *actual* geographic bounds covered by the stitched tiles for the area view. # WHY: Needed for pixel-to-geo conversions and calculating the displayed area size. @@ -602,13 +725,36 @@ class GeoElevationMapViewer: self._current_map_render_zoom = zoom_to_use self._current_stitched_map_pixel_shape = (stitched_pil.height, stitched_pil.width) - # MODIFIED: Draw the *requested* area bounding box on the map image. - # WHY: To visualize the specific area the user requested to view on the map. - # HOW: Call _draw_area_bounding_box_on_map with the input area_geo_bbox. - map_with_bbox_outline = self._draw_area_bounding_box_on_map( - stitched_pil.copy(), area_geo_bbox # Draw the *requested* area BBox - ) - self.map_display_window_controller.show_map(map_with_bbox_outline) + # --- Drawing Overlays for Area View (Initial Display) --- + map_image_with_overlays = stitched_pil.copy() + + # MODIFIED: Draw the *requested* area bounding box (blue). + # WHY: To visualize the specific area the user requested. + # HOW: Call _draw_area_bounding_box_on_map with the original requested area bbox. + if self._current_requested_area_geo_bbox: + logger.debug("Drawing requested area boundary (blue) on initial map display.") + map_image_with_overlays = self._draw_area_bounding_box_on_map( + map_image_with_overlays, + self._current_requested_area_geo_bbox, + color=AREA_BOUNDARY_COLOR, + thickness=AREA_BOUNDARY_THICKNESS_PX + ) + + # MODIFIED: Draw the DEM tile boundaries and labels (red) for all relevant tiles. + # WHY: To visualize which DEM data is available in the area. + # HOW: Call _draw_dem_tile_boundaries_with_labels_on_map using the stored tile info. + if self._dem_tiles_info_for_current_map: + logger.debug(f"Drawing {len(self._dem_tiles_info_for_current_map)} DEM tile boundaries and labels (red).") + map_image_with_overlays = self._draw_dem_tile_boundaries_with_labels_on_map( + map_image_with_overlays, + self._dem_tiles_info_for_current_map + ) + else: + logger.info("No DEM tiles with HGT data found in the area for boundary drawing.") + + + # Display the final prepared image (scaling is handled by MapDisplayWindow.show_map) + self.map_display_window_controller.show_map(map_image_with_overlays) self._last_user_click_pixel_coords_on_displayed_image = None # MODIFIED: Calculate and send map area size for area view. @@ -624,7 +770,10 @@ class GeoElevationMapViewer: size_km = calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg) if size_km: width_km, height_km = size_km - map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H" + # MODIFIED: Include info that this is the size of the DEM area shown. + # WHY: Clarify what size is being reported in the GUI. + # HOW: Added "(DEM Area)" text. + map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H (DEM Area)" else: map_area_size_str = "Size Calc Failed" logger.warning("calculate_geographic_bbox_size_km returned None for current map bounds.") @@ -633,29 +782,36 @@ class GeoElevationMapViewer: logger.warning("PyProj not available, cannot calculate map area size.") - # Send info for area view (point info is N/A) - self._send_initial_point_info_to_gui(None, None, "N/A (Area View)", map_area_size_str) + # MODIFIED: Send initial point info (N/A) to GUI, including DMS strings. + # WHY: Consistent message format for initial info and clicks. + # HOW: Use _send_initial_point_info_to_gui with None for point coords. + self._send_initial_point_info_to_gui(None, None, "N/A (Area View)", map_area_size_str) # DMS handled in send function except MapCalculationError as e_calc_map_area: logger.error(f"Map calculation error for area display: {e_calc_map_area}") if self.map_display_window_controller: self.map_display_window_controller.show_map(None) # MODIFIED: Send error info to GUI queue on calculation failure for area view. - # WHY: GUI should update even if map isn't displayed. + # WHY: GUI needs to update even if map isn't displayed. # HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point). - self._send_initial_point_info_to_gui(None, None, f"Map Calc Error: {e_calc_map_area}", "Map Calc Error") + # MODIFIED: Include DMS fields with error state for consistency with GUI update logic. + self._send_initial_point_info_to_gui(None, None, f"Map Calc Error: {e_calc_map_area}", "Map Calc Error") # Added DMS handled in send function except Exception as e_disp_map_area_fatal: logger.exception(f"Unexpected error displaying map for area: {e_disp_map_area_fatal}") if self.map_display_window_controller: self.map_display_window_controller.show_map(None) # MODIFIED: Send error info to GUI queue on unexpected fatal error for area view. - # WHY: GUI should update even if map isn't displayed. + # WHY: GUI needs to update even if map isn't displayed. # HOW: Call _send_initial_point_info_to_gui with error details (using N/A for point). - self._send_initial_point_info_to_gui(None, None, f"Fatal Map Error: {type(e_disp_map_area_fatal).__name__}", "Fatal Error") + # MODIFIED: Include DMS fields with error state for consistency with GUI update logic. + self._send_initial_point_info_to_gui(None, None, f"Fatal Map Error: {type(e_disp_map_area_fatal).__name__}", "Fatal Error") # Added DMS handled in send function # MODIFIED: Added a dedicated helper function to send initial point/map info to the GUI. # WHY: Centralizes the logic for sending the first message after map display. # HOW: Created a new method that formats and puts the data into the queue. + # MODIFIED: Updated to include DMS strings in the payload sent to the GUI. + # WHY: The GUI now expects to receive DMS strings directly from the map process for click updates. + # HOW: Calculate DMS strings using map_utils.deg_to_dms_string and add them to the payload dictionary. def _send_initial_point_info_to_gui( self, latitude: Optional[float], @@ -664,10 +820,29 @@ class GeoElevationMapViewer: map_area_size_str: str ) -> None: """Sends initial point/map info (coords, elevation, map size) to the GUI queue.""" + # MODIFIED: Calculate DMS strings for latitude and longitude if available. + # WHY: To send DMS format back to the GUI for display. + # HOW: Use map_utils.deg_to_dms_string. Handle None coords. + lat_dms_str = "N/A" + lon_dms_str = "N/A" + if latitude is not None and math.isfinite(latitude): + # MODIFIED: Use the imported deg_to_dms_string function. + # WHY: Perform the conversion here before sending to GUI. + # HOW: Call the function. + lat_dms_str = map_utils.deg_to_dms_string(latitude, 'lat') + if longitude is not None and math.isfinite(longitude): + # MODIFIED: Use the imported deg_to_dms_string function. + # WHY: Perform the conversion here before sending to GUI. + # HOW: Call the function. + lon_dms_str = map_utils.deg_to_dms_string(longitude, 'lon') + + payload_to_gui = { "type": "map_info_update", # Use a distinct type for initial/map state updates - "latitude": latitude, - "longitude": longitude, + "latitude": latitude, # Send float latitude + "longitude": longitude, # Send float longitude + "latitude_dms_str": lat_dms_str, # Send DMS latitude string + "longitude_dms_str": lon_dms_str, # Send DMS longitude string "elevation_str": elevation_str, "map_area_size_str": map_area_size_str } @@ -686,15 +861,76 @@ class GeoElevationMapViewer: # MODIFIED: Include check for CV2/NumPy availability as well, as some drawing methods use OpenCV. # WHY: Ensure all necessary libraries are present for drawing. # HOW: Added check. + # MODIFIED: Added check for MERCANTILE_LIB_AVAILABLE_DISPLAY. + # WHY: Mercator conversions are needed for drawing. + # HOW: Added check. return bool( - self._current_map_geo_bounds_deg and + self._current_map_geo_bounds_deg is not None and # Check for None self._current_map_render_zoom is not None and - self._current_stitched_map_pixel_shape and - self.map_display_window_controller and + self._current_stitched_map_pixel_shape is not None and # Added check for None + self.map_display_window_controller is not None and # Check for None PIL_IMAGE_LIB_AVAILABLE and ImageDraw is not None and # Check PIL and ImageDraw - CV2_NUMPY_LIBS_AVAILABLE # Check CV2 and NumPy + CV2_NUMPY_LIBS_AVAILABLE and # Check CV2 and NumPy + MERCANTILE_LIB_AVAILABLE_DISPLAY # Mercantile needed for geo to pixel conversion ) + # MODIFIED: Added helper method to convert geo coordinates to pixel on the UN SCALED stitched image. + # WHY: Centralizes the geo-to-pixel logic for the unscaled image, used by multiple drawing methods. + # HOW: Created a new method encapsulating the conversion using mercantile and the stored map context. + # MODIFIED: Added check for _can_perform_drawing_operations at the start. + # WHY: To ensure necessary context is available before attempting conversion. + # HOW: Added an initial check. + def _geo_to_pixel_on_unscaled_map(self, latitude_deg: float, longitude_deg: float) -> Optional[Tuple[int, int]]: + """Converts geographic coordinates to pixel coordinates on the UN SCALED stitched map image.""" + if not self._can_perform_drawing_operations() or self._current_map_geo_bounds_deg is None or self._current_stitched_map_pixel_shape is None: + logger.warning("Map context incomplete for geo_to_pixel_on_unscaled_map conversion.") + return None + + unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape + map_west_lon, map_south_lat, map_east_lon, map_north_lat = self._current_map_geo_bounds_deg + + if unscaled_width <= 0 or unscaled_height <= 0: + logger.warning("Unscaled map dimensions are zero, cannot convert geo to pixel.") + return None + + try: + import mercantile as local_mercantile # Use mercantile directly here + if local_mercantile is None: raise ImportError("mercantile not available locally.") + + + map_ul_merc_x, map_ul_merc_y = local_mercantile.xy(map_west_lon, map_north_lat) # type: ignore + map_lr_merc_x, map_lr_merc_y = local_mercantile.xy(map_east_lon, map_south_lat) # type: ignore + + total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x) + total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y) + + if total_map_width_merc <= 0 or total_map_height_merc <= 0: + logger.warning("Map Mercator extent is zero, cannot convert geo to pixel on unscaled map.") + return None + + target_merc_x, target_merc_y = local_mercantile.xy(longitude_deg, latitude_deg) # type: ignore + + # Relative position within the *unscaled* map's Mercator extent + relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc if total_map_width_merc > 0 else 0.0 + relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Y Mercator increases North, pixel Y downwards + + # Convert to pixel coordinates on the *unscaled* image + pixel_x_on_unscaled = int(round(relative_merc_x_in_map * unscaled_width)) + pixel_y_on_unscaled = int(round(relative_merc_y_in_map * unscaled_height)) + + # Clamp to the boundaries of the unscaled image (allow slight overflow for boundary drawing) + # px_clamped = max(-thickness, min(pixel_x_on_unscaled, unscaled_width + thickness)) # Clamping with thickness is for drawing lines + # py_clamped = max(-thickness, min(pixel_y_on_unscaled, unscaled_height + thickness)) + px_clamped = max(0, min(pixel_x_on_unscaled, unscaled_width -1)) # Simple clamp for point locations + py_clamped = max(0, min(pixel_y_on_unscaled, unscaled_height -1)) + + return (px_clamped, py_clamped) + + except Exception as e_geo_to_px_unscaled: + logger.exception(f"Error during geo_to_pixel_on_unscaled_map conversion for geo ({latitude_deg:.5f},{longitude_deg:.5f}): {e_geo_to_px_unscaled}") + return None + + def _draw_point_marker_on_map( self, pil_image_to_draw_on: ImageType, @@ -709,60 +945,21 @@ class GeoElevationMapViewer: logger.warning("Cannot draw point marker: drawing context/libs/controller or original image not ready.") return pil_image_to_draw_on - # MODIFIED: Use internal mercantile logic to convert geo to pixel on the UN SCALED map image. - # WHY: More direct than going through the MapDisplayWindow's method which is designed for the *scaled* displayed image. - # HOW: Reimplemented the core conversion logic from map_display.py here using the stored unscaled image context. - if self._current_map_geo_bounds_deg and self._current_map_render_zoom is not None and self._current_stitched_map_pixel_shape: - # Reuse the logic from map_display.py but apply it to the unscaled image dimensions - # (height, width) of the UN SCALED stitched map - unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape - map_west_lon, map_south_lat, map_east_lon, map_north_lat = self._current_map_geo_bounds_deg - map_native_zoom = self._current_map_render_zoom # Not strictly needed for this conversion, but part of context + # MODIFIED: Use the new helper method to convert geo to pixel on the UN SCALED map image. + # WHY: Centralizes the geo-to-pixel logic for the unscaled image. + # HOW: Replaced direct mercantile/pixel calculation with a call to _geo_to_pixel_on_unscaled_map. + pixel_coords_on_unscaled = self._geo_to_pixel_on_unscaled_map(latitude_deg, longitude_deg) - try: - import mercantile as local_mercantile # Use mercantile directly here - # MODIFIED: Check mercantile availability locally before use. - # WHY: Safety, although should be available due to class init check. - # HOW: Added check. - if local_mercantile is None: raise ImportError("mercantile not available locally.") + if pixel_coords_on_unscaled: + px_clamped, py_clamped = pixel_coords_on_unscaled - map_ul_merc_x, map_ul_merc_y = local_mercantile.xy(map_west_lon, map_north_lat) # type: ignore - map_lr_merc_x, map_lr_merc_y = local_mercantile.xy(map_east_lon, map_south_lat) # type: ignore + logger.debug(f"Drawing point marker at unscaled pixel ({px_clamped},{py_clamped}) for geo ({latitude_deg:.5f},{longitude_deg:.5f})") - total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x) - # Corrected potential typo and ensured positive value - total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y) - - - # Handle zero dimensions in Mercator space (e.g., invalid geo bounds) - if total_map_width_merc == 0 or total_map_height_merc == 0: - logger.warning("Map Mercator extent is zero, cannot draw point marker.") - return pil_image_to_draw_on # Return original image - - - target_merc_x, target_merc_y = local_mercantile.xy(longitude_deg, latitude_deg) # type: ignore - - # Relative position of the target geo point within the *unscaled* map's Mercator extent - # Need to handle potential division by zero if map width/height is zero (e.g., invalid bounds) - relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc if total_map_width_merc > 0 else 0.0 - relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 - - - # Convert these relative positions to pixel coordinates on the *unscaled* image - pixel_x_on_unscaled = int(round(relative_merc_x_in_map * unscaled_width)) - pixel_y_on_unscaled = int(round(relative_merc_y_in_map * unscaled_height)) - - - # Clamp to the boundaries of the unscaled image - px_clamped = max(0, min(pixel_x_on_unscaled, unscaled_width - 1)) - py_clamped = max(0, min(pixel_y_on_unscaled, unscaled_height - 1)) - - logger.debug(f"Drawing point marker at unscaled pixel ({px_clamped},{py_clamped}) for geo ({latitude_deg:.5f},{longitude_deg:.5f})") - - # MODIFIED: Check for CV2 and NumPy availability before using them. - # WHY: Ensure dependencies are present for drawing with OpenCV. - # HOW: Added check. - if cv2 and np: + # MODIFIED: Check for CV2 and NumPy availability before using them. + # WHY: Ensure dependencies are present for drawing with OpenCV. + # HOW: Added check. + if cv2 and np: + try: # Convert PIL image to OpenCV format (BGR) for drawing # Ensure image is in a mode OpenCV can handle (BGR) if pil_image_to_draw_on.mode != 'RGB': @@ -776,16 +973,14 @@ class GeoElevationMapViewer: cv2.drawMarker(map_cv_bgr, (px_clamped, py_clamped), (0,0,255), cv2.MARKER_CROSS, markerSize=15, thickness=2) # type: ignore # Convert back to PIL format (RGB) return Image.fromarray(cv2.cvtColor(map_cv_bgr, cv2.COLOR_BGR2RGB)) # type: ignore - else: - logger.warning("CV2 or NumPy not available, cannot draw point marker using OpenCV.") - return pil_image_to_draw_on # Return original image - - - except Exception as e_geo_to_px: - logger.exception(f"Error during geo_to_pixel conversion for point marker: {e_geo_to_px}") - return pil_image_to_draw_on # Return original image on error + except Exception as e_draw_click_cv: + logger.exception(f"Error drawing user click marker with OpenCV: {e_draw_click_cv}") + return pil_image_to_draw_on # Return original image on error + else: + logger.warning("CV2 or NumPy not available, cannot draw point marker using OpenCV.") + return pil_image_to_draw_on # Return original image if CV2/NumPy are somehow missing here else: - logger.warning("Current map context incomplete, cannot draw point marker.") + logger.warning(f"Geo-to-pixel conversion failed for ({latitude_deg:.5f},{longitude_deg:.5f}), cannot draw point marker.") return pil_image_to_draw_on # Return original image @@ -804,98 +999,92 @@ class GeoElevationMapViewer: logger.warning("Cannot draw area BBox: drawing context/libs/controller or original image not ready.") return pil_image_to_draw_on - # MODIFIED: Use internal mercantile logic to convert geo to pixel on the UN SCALED map image. - # WHY: More direct and consistent with point marker drawing. - # HOW: Reimplemented the core conversion logic from map_display.py here using the stored unscaled image context. - if self._current_map_geo_bounds_deg and self._current_map_render_zoom is not None and self._current_stitched_map_pixel_shape: - # (height, width) of the UN SCALED stitched map - unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape - map_west_lon, map_south_lat, map_east_lon, map_north_lat = self._current_map_geo_bounds_deg - map_native_zoom = self._current_map_render_zoom # Not strictly needed for this conversion + # MODIFIED: Use the new helper method (_geo_to_pixel_on_unscaled_map) for consistency in converting geo to pixel. + # NOTE: This helper clamps to the unscaled image edge. For drawing lines that might slightly exceed the image, + # we need different clamping. Recalculate pixel coords suitable for line drawing *after* the helper call, + # or modify the helper to take a padding argument. For now, let's recalculate for line drawing. + west, south, east, north = area_geo_bbox + # Corners of the box in geographic degrees + corners_geo = [(west, north), (east, north), (east, south), (west, south)] + pixel_corners: List[Tuple[int,int]] = [] - west, south, east, north = area_geo_bbox - # Corners of the box in geographic degrees - corners_geo = [(west, north), (east, north), (east, south), (west, south)] - pixel_corners: List[Tuple[int,int]] = [] + try: + # Convert all corners to pixel coordinates on the *unscaled* image, suitable for drawing lines + if self._current_map_geo_bounds_deg and self._current_stitched_map_pixel_shape: + unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape + map_west_lon, map_south_lat, map_east_lon, map_north_lat = self._current_map_geo_bounds_deg - try: - import mercantile as local_mercantile # Use mercantile directly here - # MODIFIED: Check mercantile availability locally before use. - # WHY: Safety. - # HOW: Added check. - if local_mercantile is None: raise ImportError("mercantile not available locally.") + map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat) # type: ignore + map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat) # type: ignore - map_ul_merc_x, map_ul_merc_y = local_mercantile.xy(map_west_lon, map_north_lat) # type: ignore - map_lr_merc_x, map_lr_merc_y = local_mercantile.xy(map_east_lon, map_south_lat) # type: ignore + total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x) + total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y) - total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x) - total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y) + if total_map_width_merc <= 0 or total_map_height_merc <= 0: + raise ValueError("Map Mercator extent is zero for drawing lines.") - if total_map_width_merc == 0 or total_map_height_merc == 0: - logger.warning("Map Mercator extent is zero, cannot draw BBox.") - return pil_image_to_draw_on + import mercantile as local_mercantile # Use mercantile directly here + if local_mercantile is None: raise ImportError("mercantile not available locally.") - for lon, lat in corners_geo: + + for lon, lat in corners_geo: target_merc_x, target_merc_y = local_mercantile.xy(lon, lat) # type: ignore - # Handle relative position calculation, ensuring bounds are respected relative_merc_x_in_map = (target_merc_x - map_ul_merc_x) / total_map_width_merc if total_map_width_merc > 0 else 0.0 - relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 + relative_merc_y_in_map = (map_ul_merc_y - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Y Mercator increases North, pixel Y downwards - pixel_x_on_unscaled = int(round(relative_merc_x_in_map * unscaled_width)) - pixel_y_on_unscaled = int(round(relative_merc_y_in_map * unscaled_height)) + pixel_x_on_unscaled_raw = int(round(relative_merc_x_in_map * unscaled_width)) + pixel_y_on_unscaled_raw = int(round(relative_merc_y_in_map * unscaled_height)) - # Clamping is less strict for drawing lines that might slightly exceed image bounds - # to show boundaries clearly, but avoid drawing far off-screen. - # Let's clamp to a reasonable area around the image. - # MODIFIED: Clamping to be slightly outside the image boundaries to avoid clipping the drawn box edges. - # WHY: Standard practice for drawing boundaries. - # HOW: Use thickness value in max/min calculation. - px_clamped = max(-thickness, min(pixel_x_on_unscaled, unscaled_width + thickness)) - py_clamped = max(-thickness, min(pixel_y_on_unscaled, unscaled_height + thickness)) + # Clamp with padding for line drawing + px_clamped_for_line = max(-thickness, min(pixel_x_on_unscaled_raw, unscaled_width + thickness)) + py_clamped_for_line = max(-thickness, min(pixel_y_on_unscaled_raw, unscaled_height + thickness)) + + pixel_corners.append((px_clamped_for_line, py_clamped_for_line)) + + else: + logger.warning("Unscaled map dimensions or geo bounds not available, cannot clamp pixel corners for drawing.") + return pil_image_to_draw_on # Cannot draw if dimensions are unknown - pixel_corners.append((px_clamped, py_clamped)) + except Exception as e_geo_to_px_bbox: + logger.exception(f"Error during geo_to_pixel conversion for BBox drawing: {e_geo_to_px_bbox}") + return pil_image_to_draw_on # Return original image on error - except Exception as e_geo_to_px_bbox: - logger.exception(f"Error during geo_to_pixel conversion for BBox drawing: {e_geo_to_px_bbox}") - return pil_image_to_draw_on # Return original image on error + # MODIFIED: Check ImageDraw availability before using it. + # WHY: Ensure dependency is present. + # HOW: Added check. + if len(pixel_corners) == 4 and ImageDraw is not None: + logger.debug(f"Drawing area BBox with unscaled pixel corners: {pixel_corners}") + # Ensure image is in a mode that supports drawing (RGB or RGBA) + # Converting here ensures drawing is possible if the input image was, e.g., L mode + if pil_image_to_draw_on.mode not in ("RGB", "RGBA"): + pil_image_to_draw_on = pil_image_to_draw_on.convert("RGBA") # Prefer RGBA for drawing + draw = ImageDraw.Draw(pil_image_to_draw_on) - # MODIFIED: Check ImageDraw availability before using it. - # WHY: Ensure dependency is present. - # HOW: Added check. - if len(pixel_corners) == 4 and ImageDraw is not None: - logger.debug(f"Drawing area BBox with unscaled pixel corners: {pixel_corners}") - # Ensure image is in a mode that supports drawing (RGB or RGBA) - if pil_image_to_draw_on.mode not in ("RGB", "RGBA"): - # Convert to RGBA to support drawing with transparency if needed in the future, - # otherwise RGB is usually sufficient for solid colors. - pil_image_to_draw_on = pil_image_to_draw_on.convert("RGBA") # Prefer RGBA for drawing - draw = ImageDraw.Draw(pil_image_to_draw_on) - - # Draw lines connecting the corner points - # Draw a closed polygon outline - # MODIFIED: Use draw.line to draw segments, provides more control and handles points order better. - # WHY: Ensures the rectangle is drawn correctly even if points are not in perfect order for polygon outline. - # HOW: Changed from draw.polygon with outline to four calls to draw.line. - try: - # Draw lines explicitly connecting the corners in sequence - draw.line([pixel_corners[0], pixel_corners[1]], fill=color, width=thickness) # Top edge - draw.line([pixel_corners[1], pixel_corners[2]], fill=color, width=thickness) # Right edge - draw.line([pixel_corners[2], pixel_corners[3]], fill=color, width=thickness) # Bottom edge - draw.line([pixel_corners[3], pixel_corners[0]], fill=color, width=thickness) # Left edge - except Exception as e_draw_lines: - logger.exception(f"Error drawing BBox lines: {e_draw_lines}") + # Draw lines connecting the corner points + # Draw a closed polygon outline + # MODIFIED: Use draw.line to draw segments, provides more control and handles points order better. + # WHY: Ensures the rectangle is drawn correctly even if points are not in perfect order for polygon outline. + # HOW: Changed from draw.polygon with outline to four calls to draw.line. + try: + # Draw lines explicitly connecting the corners in sequence + draw.line([pixel_corners[0], pixel_corners[1]], fill=color, width=thickness) # Top edge + draw.line([pixel_corners[1], pixel_corners[2]], fill=color, width=thickness) # Right edge + draw.line([pixel_corners[2], pixel_corners[3]], fill=color, width=thickness) # Bottom edge + draw.line([pixel_corners[3], pixel_corners[0]], fill=color, width=thickness) # Left edge + except Exception as e_draw_lines: + logger.exception(f"Error drawing BBox lines: {e_draw_lines}") - return pil_image_to_draw_on - else: - logger.warning("Not enough pixel corners calculated for BBox, or ImageDraw missing.") - return pil_image_to_draw_on # Return original image - + return pil_image_to_draw_on else: - logger.warning("Current map context incomplete, cannot draw area BBox.") - return pil_image_to_draw_on # Return original image + logger.warning("Not enough pixel corners calculated for BBox, or ImageDraw missing.") + return pil_image_to_draw_on # Return original image + + # else: No need for this else, handled by initial _can_perform_drawing_operations check + # logger.warning("Current map context incomplete, cannot draw area BBox.") + # return pil_image_to_draw_on # Return original image # MODIFIED: Added a specific drawing function for the DEM tile boundary. @@ -923,6 +1112,248 @@ class GeoElevationMapViewer: thickness=DEM_BOUNDARY_THICKNESS_PX ) + # MODIFIED: Added a new drawing function to draw boundaries and labels for multiple DEM tiles. + # WHY: Implement Feature 1: visualize multiple DEM tiles and their names for area view. + # HOW: Iterates through the list of tile info, draws each boundary and label. + def _draw_dem_tile_boundaries_with_labels_on_map( + self, + pil_image_to_draw_on: ImageType, + dem_tiles_info_list: List[Dict] + ) -> ImageType: + """ + Draws boundaries and names for a list of DEM tiles on the (unscaled) stitched PIL map. + Draws only for tiles marked as available HGT. + """ + # MODIFIED: Enhanced drawing context check. + # WHY: Ensure all necessary conditions are met before attempting drawing. + # HOW: Added check for PIL_IMAGE_LIB_AVAILABLE and ImageDraw is not None explicitly here as well. + if not self._can_perform_drawing_operations() or self._current_stitched_map_pil is None or not PIL_IMAGE_LIB_AVAILABLE or ImageDraw is None: + logger.warning("Cannot draw multiple DEM tile boundaries/labels: drawing context/libs/controller or original image or Pillow/ImageDraw not ready.") + return pil_image_to_draw_on + + if not dem_tiles_info_list: + logger.debug("No DEM tile info provided for drawing multiple boundaries.") + return pil_image_to_draw_on # Nothing to draw + + logger.debug(f"Drawing {len(dem_tiles_info_list)} DEM tile boundaries and labels.") + + # Ensure image is in a mode that supports drawing (RGB or RGBA) + # Converting here ensures drawing is possible if the input image was, e.g., L mode + if pil_image_to_draw_on.mode not in ("RGB", "RGBA"): + pil_image_to_draw_on = pil_image_to_draw_on.convert("RGBA") + draw = ImageDraw.Draw(pil_image_to_draw_on) + + # Attempt to use a font loaded in image_processor for consistency + # Fallback to default PIL font if import failed or font wasn't loaded + font_to_use = _DEFAULT_FONT_FOR_LABELS + if font_to_use is None: + if PIL_IMAGE_LIB_AVAILABLE: # Ensure PIL is available for load_default + font_to_use = ImageDraw.ImageFont.load_default() # type: ignore + logger.debug("Using default PIL font for DEM tile labels.") + else: + logger.error("Pillow (PIL) not available, cannot even load default font for DEM tile labels.") + # Cannot draw text without a font source. Continue without text drawing. + pass # drawing boundaries might still be possible + + # MODIFIED: Calculate font size based on current map zoom. + # WHY: To make labels more readable at different zoom levels. + # HOW: Use a simple linear scaling based on a base zoom and base font size. + current_map_zoom = self._current_map_render_zoom # Use the zoom level the map was rendered at + if current_map_zoom is None: + logger.warning("Current map zoom is None, cannot scale font. Using base size.") + current_map_zoom = DEM_TILE_LABEL_BASE_ZOOM # Default to base zoom for size calc + + # Simple linear scaling: size increases by 1 for each zoom level above base zoom + # Example: at zoom 10, size 12; at zoom 11, size 13; at zoom 9, size 11. + # Ensure minimum sensible font size (e.g., 6) + scaled_font_size = max(6, DEM_TILE_LABEL_BASE_FONT_SIZE + (current_map_zoom - DEM_TILE_LABEL_BASE_ZOOM)) + logger.debug(f"Calculated label font size {scaled_font_size} for zoom {current_map_zoom}.") + + # Update the font instance with the calculated size (if using truetype font) + # If load_default is used, resizing is often not possible or behaves differently. + # Let's re-load the font with the scaled size if it's a truetype font. + # This requires knowing the path of the original font used by image_processor, which is tricky. + # Alternative: Store the font path and size calculation logic from image_processor here. + # Or, maybe simpler, if using load_default fallback, just use the default size. + # Let's assume if _DEFAULT_FONT_FOR_LABELS is not None, it's a truetype font we can resize. + if font_to_use and hasattr(font_to_use, 'font'): # Check if it looks like a truetype font object with a 'font' attribute + try: + # Get the original font object from Pillow's internal structure (might be implementation-dependent) + # Accessing internal `font` attribute is a heuristic for Pillow's FreeTypeFont + original_font_path = font_to_use.font.path # type: ignore + font_index = font_to_use.font.index # type: ignore # Handle TTC files + font_to_use = ImageDraw.ImageFont.truetype(original_font_path, scaled_font_size, index=font_index) # type: ignore + logger.debug(f"Resized truetype font to {scaled_font_size}.") + except Exception as e_resize_font: + logger.warning(f"Could not resize truetype font: {e_resize_font}. Using original size.") + # Keep the font_to_use as it was (original size) + + + for tile_info in dem_tiles_info_list: + # Draw only if HGT data is available for this tile + if not tile_info.get("hgt_available"): + logger.debug(f"Skipping drawing boundary/label for tile {tile_info.get('tile_base_name', '?')}: HGT not available.") + continue # Skip this tile if no HGT + + lat_coord = tile_info.get("latitude_coord") + lon_coord = tile_info.get("longitude_coord") + tile_base_name = tile_info.get("tile_base_name") + + if lat_coord is None or lon_coord is None or tile_base_name is None: + logger.warning(f"Skipping drawing for invalid tile info entry: {tile_info}") + continue + + try: + # Get the precise geographic bounds for this HGT tile + tile_geo_bbox = get_hgt_tile_geographic_bounds(lat_coord, lon_coord) + west, south, east, north = tile_geo_bbox + + # Corners of this specific tile's bbox in geographic degrees + tile_corners_geo = [(west, north), (east, north), (east, south), (west, south)] + tile_pixel_corners_on_unscaled: List[Tuple[int,int]] = [] + + # Convert tile corners to unscaled pixel coordinates, suitable for drawing lines. + # Recalculate pixel coords relative to the unscaled map using mercantile again for line drawing accuracy, + # and clamp with padding. + if self._current_map_geo_bounds_deg and self._current_stitched_map_pixel_shape: + unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape + map_west_lon_stitched, map_south_lat_stitched, map_east_lon_stitched, map_north_lat_stitched = self._current_map_geo_bounds_deg + + map_ul_merc_x_stitched, map_ul_merc_y_stitched = mercantile.xy(map_west_lon_stitched, map_north_lat_stitched) # type: ignore + map_lr_merc_x_stitched, map_lr_merc_y_stitched = mercantile.xy(map_east_lon_stitched, map_south_lat_stitched) # type: ignore + + total_map_width_merc = abs(map_lr_merc_x_stitched - map_ul_merc_x_stitched) + total_map_height_merc = abs(map_ul_merc_y_stitched - map_lr_merc_y_stitched) + + if total_map_width_merc <= 0 or total_map_height_merc <= 0: + raise ValueError("Map Mercator extent is zero for drawing tile boundaries.") + + import mercantile as local_mercantile # Use mercantile directly here + if local_mercantile is None: raise ImportError("mercantile not available locally.") + + + for lon, lat in tile_corners_geo: + target_merc_x, target_merc_y = local_mercantile.xy(lon, lat) # type: ignore + + relative_merc_x_in_map = (target_merc_x - map_ul_merc_x_stitched) / total_map_width_merc if total_map_width_merc > 0 else 0.0 + relative_merc_y_in_map = (map_ul_merc_y_stitched - target_merc_y) / total_map_height_merc if total_map_height_merc > 0 else 0.0 # Y Mercator increases North, pixel Y downwards + + pixel_x_on_unscaled_raw = int(round(relative_merc_x_in_map * unscaled_width)) + pixel_y_on_unscaled_raw = int(round(relative_merc_y_in_map * unscaled_height)) + + # Clamp with padding for line drawing + px_clamped_for_line = max(-DEM_BOUNDARY_THICKNESS_PX, min(pixel_x_on_unscaled_raw, unscaled_width + DEM_BOUNDARY_THICKNESS_PX)) + py_clamped_for_line = max(-DEM_BOUNDARY_THICKNESS_PX, min(pixel_y_on_unscaled_raw, unscaled_height + DEM_BOUNDARY_THICKNESS_PX)) + + tile_pixel_corners_on_unscaled.append((px_clamped_for_line, py_clamped_for_line)) + + else: + logger.warning(f"Unscaled map dimensions or geo bounds not available, cannot clamp pixel corners for tile ({lat_coord},{lon_coord}).") + raise ValueError("Unscaled map dimensions missing for line drawing.") + + + if len(tile_pixel_corners_on_unscaled) == 4: + # Draw the tile boundary (red) + draw.line([tile_pixel_corners_on_unscaled[0], tile_pixel_corners_on_unscaled[1]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Top + draw.line([tile_pixel_corners_on_unscaled[1], tile_pixel_corners_on_unscaled[2]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Right + draw.line([tile_pixel_corners_on_unscaled[2], tile_pixel_corners_on_unscaled[3]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Bottom + draw.line([tile_pixel_corners_on_unscaled[3], tile_pixel_corners_on_unscaled[0]], fill=DEM_BOUNDARY_COLOR, width=DEM_BOUNDARY_THICKNESS_PX) # Left + + # --- Draw Tile Name Label --- + label_text = tile_base_name.upper() + # Find pixel position for label - bottom-right corner area of the tile's pixel box. + # Get the precise unscaled pixel coords for the SE corner using the helper (which clamps to edge) + se_pixel_on_unscaled = self._geo_to_pixel_on_unscaled_map(south, east) + + label_margin = 3 # Small margin from the border + + if font_to_use is not None and se_pixel_on_unscaled and self._current_stitched_map_pixel_shape: # Draw text only if a font is available and position is calculable + try: + unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape + se_px, se_py = se_pixel_on_unscaled + + # Calculate text size using textbbox (requires Pillow >= 8.0) + # Use the bottom-right corner pixel as the anchor point for calculation (not drawing) + try: + # textbbox relative to (0,0) + text_bbox = draw.textbbox((0,0), label_text, font=font_to_use) # type: ignore + text_width = text_bbox[2] - text_bbox[0] + text_height = text_bbox[3] - text_bbox[1] + + # Target bottom-right corner for text drawing relative to the unscaled image pixel + target_text_br_x = se_px - label_margin + target_text_br_y = se_py - label_margin + + # Top-left corner for drawing the text based on target bottom-right and text size + label_text_x = target_text_br_x - text_width + label_text_y = target_text_br_y - text_height + + # Clamp text position to be within the visible area of the unscaled map image + label_text_x = max(0, min(label_text_x, unscaled_width - text_width - 1)) + label_text_y = max(0, min(label_text_y, unscaled_height - text_height - 1)) + + + # Draw background rectangle for the text + bg_padding = 1 # Small padding around text background + bg_coords = [ + label_text_x - bg_padding, + label_text_y - bg_padding, + label_text_x + text_width + bg_padding, + label_text_y + text_height + bg_padding, + ] + draw.rectangle(bg_coords, fill=DEM_TILE_LABEL_BG_COLOR) + + # Draw the text itself + # Use anchor='la' (left, top-of-ascent) and calculated top-left position + draw.text((label_text_x, label_text_y), label_text, fill=DEM_TILE_LABEL_COLOR, font=font_to_use) # type: ignore + + + except AttributeError: + # Fallback for older Pillow versions using textsize + logger.warning("Pillow textbbox not available (Pillow < 8.0). Using textsize fallback for labels.") + text_width, text_height = draw.textsize(label_text, font=font_to_use) # type: ignore + # Rough position calculation based on textsize + if self._current_stitched_map_pixel_shape and se_pixel_on_unscaled: + unscaled_height, unscaled_width = self._current_stitched_map_pixel_shape + se_px, se_py = se_pixel_on_unscaled + label_text_x = se_px - text_width - label_margin + label_text_y = se_py - text_height - label_margin + + # Clamp text position to be within the visible area + label_text_x = max(0, min(label_text_x, unscaled_width - text_width - 1)) + label_text_y = max(0, min(label_text_y, unscaled_height - text_height - 1)) + + # Draw background + bg_padding = 1 + bg_coords = [label_text_x - bg_padding, label_text_y - bg_padding, + label_text_x + text_width + bg_padding, label_text_y + text_height + bg_padding] + draw.rectangle(bg_coords, fill=DEM_TILE_LABEL_BG_COLOR) + + # Draw text using font fallback + draw.text((label_text_x, label_text_y), label_text, fill=DEM_TILE_LABEL_COLOR, font=font_to_use) # type: ignore + else: + logger.warning(f"Could not get SE pixel coords for tile ({lat_coord},{lon_coord}) label positioning (textsize fallback).") + + + except Exception as e_draw_label: + logger.warning(f"Error drawing label '{label_text}' for tile ({lat_coord},{lon_coord}): {e_draw_label}") + else: + logger.debug(f"No font available, skipping drawing label '{label_text}' for tile ({lat_coord},{lon_coord}).") + + + except ValueError as ve: # Catch explicit ValueErrors raised for conversion/drawing issues for a single tile + logger.warning(f"Value error during drawing for tile ({lat_coord},{lon_coord}): {ve}. Skipping this tile.") + continue # Skip this tile and continue with the next + + + except Exception as e_draw_tile: + logger.exception(f"Unexpected error drawing boundary/label for tile ({lat_coord},{lon_coord}): {e_draw_tile}. Skipping this tile.") + continue # Skip this tile and continue with the next + + + # Return the image with all drawn boundaries and labels + return pil_image_to_draw_on + def _draw_user_click_marker_on_map( self, @@ -999,17 +1430,16 @@ class GeoElevationMapViewer: # WHY: Avoids errors if the map wasn't fully loaded or context is missing. # HOW: Added explicit check. Also check Mercantile availability which is critical for pixel-geo conversion. if not self._can_perform_drawing_operations() or not self._current_map_geo_bounds_deg or \ - self._current_map_render_zoom is None or not self._current_stitched_map_pixel_shape or \ + self._current_map_render_zoom is None or self._current_stitched_map_pixel_shape is None or \ not MERCANTILE_LIB_AVAILABLE_DISPLAY: # Check Mercantile specifically logger.warning("Cannot process map click: map context, Mercantile, or display controller not fully loaded.") # MODIFIED: Send partial error info to GUI queue on click if context is missing. - # WHY: GUI should update click info even if elevation cannot be fetched. + # WHY: GUI needs to update click info even if elevation cannot be fetched. # HOW: Send a payload with error status for elevation/size. - error_payload = { - "type": "map_info_update", # Use the same type for click updates - "latitude": None, "longitude": None, - "elevation_str": "Click Error: Context N/A", "map_area_size_str": "Click Error: Context N/A" - } + # 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": "Click Error: Context N/A", "map_area_size_str": "Click Error: Context N/A"} try: self.gui_com_queue.put(error_payload) except Exception as e_put_err: logger.error(f"Failed to put error payload to queue on click: {e_put_err}") return # Exit after sending error @@ -1033,18 +1463,22 @@ class GeoElevationMapViewer: elev_val: Optional[float] = None elev_display_str = "N/A" lat_val: Optional[float] = None - lon_val: Optional[float] = None + # MODIFIED: Renamed lon_val to lon_val_float to distinguish from DMS later if needed (though not used here) + lon_val_float: Optional[float] = None # Keep as float for internal use + + map_area_size_str = "N/A" # Default if geo_coords: - lat_val, lon_val = geo_coords - logger.info(f"Map click (on scaled) converted to Geo: Lat={lat_val:.5f}, Lon={lon_val:.5f}") + lat_val, lon_val_float = geo_coords + logger.info(f"Map click (on scaled) converted to Geo: Lat={lat_val:.5f}, Lon={lon_val_float:.5f}") # Use lon_val_float in log # MODIFIED: Fetch elevation for the clicked point using the child process's ElevationManager. # WHY: ElevationManager is available and configured in this process. # HOW: Call self.elevation_manager.get_elevation. try: if self.elevation_manager: # Ensure manager is not None - elev_val = self.elevation_manager.get_elevation(lat_val, lon_val) + # MODIFIED: Pass lon_val_float to get_elevation + elev_val = self.elevation_manager.get_elevation(lat_val, lon_val_float) if elev_val is None: elev_display_str = "Unavailable" elif isinstance(elev_val, float) and math.isnan(elev_val): elev_display_str = "NoData" else: elev_display_str = f"{elev_val:.2f} m" @@ -1058,6 +1492,8 @@ class GeoElevationMapViewer: elev_display_str = f"Error: {type(e_get_elev_click).__name__}" # Calculate map area size string (same as for initial display) + # For area view, this reports the size of the COMBINED DEM tiles displayed. + # For point view, it reports the size of the map fetch area (often ~1x1 degree buffer). if self._current_map_geo_bounds_deg: # MODIFIED: Check PyProj availability before calculating size. # WHY: calculate_geographic_bbox_size_km requires PyProj. @@ -1066,7 +1502,14 @@ class GeoElevationMapViewer: size_km = calculate_geographic_bbox_size_km(self._current_map_geo_bounds_deg) if size_km: width_km, height_km = size_km - map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H" + # MODIFIED: Add "DEM Area" to the size string for area view, "Map Area" for point view. + # WHY: Clarify what area size is being reported. + # HOW: Conditional string formatting based on whether _current_requested_area_geo_bbox is set (implies area view). + if self._current_requested_area_geo_bbox: + map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H (DEM Area Shown)" + else: # Point view or fallback + map_area_size_str = f"{width_km:.2f} km W x {height_km:.2f} km H (Map Area Shown)" + else: map_area_size_str = "Size Calc Failed" logger.warning("calculate_geographic_bbox_size_km returned None for current map bounds.") @@ -1081,26 +1524,19 @@ class GeoElevationMapViewer: map_area_size_str = "Error: Click conversion failed" # MODIFIED: Send click data + map area size info using the new update message type. - # WHY: Consistent message format for initial info and clicks. - # HOW: Use "type": "map_info_update" and include all relevant fields. - try: - payload_to_gui = { - "type": "map_info_update", - "latitude": lat_val, # Send coordinates even if conversion failed, might be None - "longitude": lon_val, - "elevation_str": elev_display_str, - "map_area_size_str": map_area_size_str - } - self.gui_com_queue.put(payload_to_gui) - logger.debug(f"Sent map_info_update (click) to GUI queue: {payload_to_gui}") - except Exception as e_queue_map_click: - logger.exception(f"Error putting click data onto GUI queue: {e_queue_map_click}") + # WHY: Consistent message format for initial info and clicks. Include DMS strings. + # HOW: Use "type": "map_info_update" and include all relevant fields, including DMS conversion. + # MODIFIED: Use _send_initial_point_info_to_gui helper which now handles DMS conversion and payload structure. + # WHY: Centralize sending logic. + # HOW: Call the helper function with available data. + self._send_initial_point_info_to_gui( + lat_val, lon_val_float, elev_display_str, map_area_size_str # Pass float coords and strings + ) - # Redraw map: get original stitched map, potentially draw DEM boundary, draw new click marker, then show. + + # Redraw map: get original stitched map, draw persistent overlays (DEM boundary for point, Area box + all DEMs for area), draw new click marker, then show. # MapDisplayWindow.show_map() will apply the current_display_scale_factor. - # Need to re-apply DEM boundary drawing if it was originally drawn for this map. - # We need to use the *original* stitched image as the base for redrawing, not the one currently displayed - # by OpenCV which might have older markers. + # Need to re-apply boundary drawing as it's not on the base stitched image. # MODIFIED: Check if original stitched image is available and drawing is possible before redrawing. # WHY: Avoids errors if the original image is None. # HOW: Added check. @@ -1108,17 +1544,40 @@ class GeoElevationMapViewer: # Start with a fresh copy of the original stitched image map_copy_for_drawing = self._current_stitched_map_pil.copy() - # MODIFIED: Re-draw DEM boundary on map image copy before adding click marker. - # WHY: The original stitched image does NOT have the boundary drawn. It must be redrawn for each update - # (initial display, subsequent clicks) on a fresh copy. - # HOW: Check if _dem_tile_geo_bbox_for_current_map is stored (meaning this map view was for a point with DEM data) - # and draw the boundary if so. - if self._dem_tile_geo_bbox_for_current_map: - logger.debug("Redrawing DEM tile boundary on map image copy before adding click marker.") - map_copy_for_drawing = self._draw_dem_tile_boundary_on_map(map_copy_for_drawing, self._dem_tile_geo_bbox_for_current_map) + # MODIFIED: Determine which overlays to redraw based on the current view type (Point vs Area). + # WHY: To correctly redraw the blue area boundary and/or red DEM tile boundaries/labels. + # HOW: Check if _current_requested_area_geo_bbox or _dem_tile_geo_bbox_for_current_map are set. + if self._current_requested_area_geo_bbox: # This is an AREA view + logger.debug("Redrawing overlays for Area View after click.") + # Redraw the requested area boundary (blue) + map_copy_for_drawing = self._draw_area_bounding_box_on_map( + map_copy_for_drawing, + self._current_requested_area_geo_bbox, + color=AREA_BOUNDARY_COLOR, + thickness=AREA_BOUNDARY_THICKNESS_PX + ) + # Redraw the DEM tile boundaries and labels (red) for all relevant tiles + if self._dem_tiles_info_for_current_map: + map_copy_for_drawing = self._draw_dem_tile_boundaries_with_labels_on_map( + map_copy_for_drawing, + self._dem_tiles_info_for_current_map + ) + + elif self._dem_tile_geo_bbox_for_current_map: # This is a POINT view (and DEM was available) + logger.debug("Redrawing DEM tile boundary for Point View after click.") + # Redraw the single DEM tile boundary (red) + map_copy_for_drawing = self._draw_dem_tile_boundary_on_map( + map_copy_for_drawing, + self._dem_tile_geo_bbox_for_current_map + ) + + else: + # Neither area bbox nor single DEM tile bbox is stored. No persistent overlays to redraw. + logger.debug("No persistent overlays (Area box or DEM boundary) stored for redrawing after click.") + pass # map_copy_for_drawing is just the base stitched image - # Draw the latest user click marker on top of the map copy (which might now have the DEM boundary) + # Draw the latest user click marker on top of the map copy (which now has persistent overlays) # _draw_user_click_marker_on_map needs the image to draw on, and uses the stored _last_user_click_pixel_coords_on_displayed_image map_with_latest_click_marker = self._draw_user_click_marker_on_map(map_copy_for_drawing) @@ -1126,7 +1585,7 @@ class GeoElevationMapViewer: if map_with_latest_click_marker: self.map_display_window_controller.show_map(map_with_latest_click_marker) else: - # Fallback if drawing the marker failed, show the map potentially with the DEM boundary but no click marker + # Fallback if drawing the marker failed, show the map potentially with persistent overlays but no click marker logger.warning("Failed to draw user click marker. Showing map without marker.") self.map_display_window_controller.show_map(map_copy_for_drawing) else: @@ -1146,6 +1605,12 @@ class GeoElevationMapViewer: self._current_stitched_map_pixel_shape = (0, 0) # Reset to default tuple self._last_user_click_pixel_coords_on_displayed_image = None self._dem_tile_geo_bbox_for_current_map = None + # MODIFIED: Clear new area view state attributes on shutdown. + # WHY: Clean state. + # HOW: Reset attributes. + self._current_requested_area_geo_bbox = None + self._dem_tiles_info_for_current_map = [] + if self.map_display_window_controller: self.map_display_window_controller.destroy_window() diff --git a/geoelevation/map_viewer/map_utils.py b/geoelevation/map_viewer/map_utils.py index 834550a..5e638ea 100644 --- a/geoelevation/map_viewer/map_utils.py +++ b/geoelevation/map_viewer/map_utils.py @@ -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}") - 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 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 + 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 \ No newline at end of file + 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 \ No newline at end of file