""" Simulation controls widget. Cosa fa: fornisce controlli per avviare/fermare la simulazione, mostra stato ownship e targets. Principali: SimulationControls Ingressi/Uscite: interagisce con MainView tramite callback e variabili Tk (side-effect). """ import tkinter as tk from tkinter import ttk from typing import Dict, Any, List import math from target_simulator.core.models import FPS_TO_KNOTS, Target class SimulationControls(ttk.LabelFrame): """ Encapsulates the Live Simulation Engine controls, ownship status, and active targets display. """ def __init__(self, parent, main_view): super().__init__(parent, text="Live Simulation Engine") self.main_view = main_view # --- Variables for UI Binding --- self.time_multiplier_var = getattr( main_view, "time_multiplier_var", tk.StringVar(value="1x") ) self.update_time = getattr(main_view, "update_time", tk.DoubleVar(value=1.0)) self.sim_slider_var = getattr( main_view, "sim_slider_var", tk.DoubleVar(value=0.0) ) # Ownship display variables self.lat_var = tk.StringVar(value="-") self.lon_var = tk.StringVar(value="-") self.alt_var = tk.StringVar(value="-") self.hdg_var = tk.StringVar(value="-") self.gnd_speed_var = tk.StringVar(value="-") self.vert_speed_var = tk.StringVar(value="-") # --- Widget Layout --- self.grid_columnconfigure(0, weight=1) # Main controls frame controls_frame = ttk.Frame(self) controls_frame.grid(row=0, column=0, sticky="ew", padx=5, pady=5) controls_frame.grid_columnconfigure(3, weight=1) self.start_button = ttk.Button( controls_frame, text="Start Live", command=self._start ) self.start_button.grid(row=0, column=0, sticky="w") self.stop_button = ttk.Button( controls_frame, text="Stop Live", command=self._stop, state=tk.DISABLED ) self.stop_button.grid(row=0, column=1, sticky="w", padx=5) ttk.Frame(controls_frame).grid(row=0, column=2, sticky="ew", padx=10) # Spacer ttk.Label(controls_frame, text="Speed:").grid( row=0, column=4, sticky="e", padx=(10, 2) ) self.multiplier_combo = ttk.Label( controls_frame, text="1x" ) # Placeholder, could be a combo self.multiplier_combo.grid(row=0, column=5, sticky="w") ttk.Label(controls_frame, text="Update (s):").grid( row=0, column=6, sticky="e", padx=(10, 2) ) self.update_time_entry = ttk.Entry( controls_frame, textvariable=self.update_time, width=5 ) self.update_time_entry.grid(row=0, column=7, sticky="w") self.reset_button = ttk.Button( controls_frame, text="Reset Sim", command=getattr(main_view, "_on_reset_simulation", lambda: None), ) self.reset_button.grid(row=0, column=8, sticky="e", padx=10) self.reset_radar_button = ttk.Button( controls_frame, text="Reset Radar", command=getattr(main_view, "_reset_radar_state", lambda: None), ) self.reset_radar_button.grid(row=0, column=9, sticky="e") # Progress slider row progress_frame = ttk.Frame(self) progress_frame.grid(row=1, column=0, sticky="ew", padx=5, pady=(6, 2)) progress_frame.grid_columnconfigure(0, weight=1) self.sim_slider = ttk.Scale( progress_frame, orient=tk.HORIZONTAL, variable=self.sim_slider_var, from_=0.0, to=1.0, ) self.sim_slider.grid(row=0, column=0, sticky="ew", padx=(4, 8)) self.sim_slider.bind( "", lambda e: setattr(main_view, "_slider_is_dragging", True) ) self.sim_slider.bind( "", lambda e: ( setattr(main_view, "_slider_is_dragging", False), getattr(main_view, "_on_seek", lambda: None)(), ), ) labels_frame = ttk.Frame(progress_frame) labels_frame.grid(row=0, column=1, sticky="e") self.sim_elapsed_label = ttk.Label( labels_frame, text="0.0s", width=8, anchor=tk.E ) self.sim_elapsed_label.pack(side=tk.LEFT) ttk.Label(labels_frame, text="/").pack(side=tk.LEFT, padx=2) self.sim_total_label = ttk.Label( labels_frame, text="0.0s", width=8, anchor=tk.W ) self.sim_total_label.pack(side=tk.LEFT) # --- Ownship State Display --- ownship_frame = ttk.LabelFrame(self, text="Ownship State", padding=10) ownship_frame.grid(row=2, column=0, sticky="ew", padx=5, pady=8) ownship_frame.grid_columnconfigure(1, weight=1) ownship_frame.grid_columnconfigure(3, weight=1) ownship_frame.grid_columnconfigure(5, weight=1) ttk.Label(ownship_frame, text="Latitude:").grid(row=0, column=0, sticky="w") ttk.Label(ownship_frame, textvariable=self.lat_var, anchor="w").grid( row=0, column=1, sticky="ew", padx=5 ) ttk.Label(ownship_frame, text="Longitude:").grid(row=1, column=0, sticky="w") ttk.Label(ownship_frame, textvariable=self.lon_var, anchor="w").grid( row=1, column=1, sticky="ew", padx=5 ) ttk.Label(ownship_frame, text="Altitude:").grid( row=0, column=2, sticky="w", padx=(10, 0) ) ttk.Label(ownship_frame, textvariable=self.alt_var, anchor="w").grid( row=0, column=3, sticky="ew", padx=5 ) ttk.Label(ownship_frame, text="Heading:").grid( row=1, column=2, sticky="w", padx=(10, 0) ) ttk.Label(ownship_frame, textvariable=self.hdg_var, anchor="w").grid( row=1, column=3, sticky="ew", padx=5 ) ttk.Label(ownship_frame, text="Ground Speed:").grid( row=0, column=4, sticky="w", padx=(10, 0) ) ttk.Label(ownship_frame, textvariable=self.gnd_speed_var, anchor="w").grid( row=0, column=5, sticky="ew", padx=5 ) ttk.Label(ownship_frame, text="Vertical Speed:").grid( row=1, column=4, sticky="w", padx=(10, 0) ) ttk.Label(ownship_frame, textvariable=self.vert_speed_var, anchor="w").grid( row=1, column=5, sticky="ew", padx=5 ) # --- Active Targets Table --- targets_frame = ttk.LabelFrame(self, text="Active Targets", padding=10) targets_frame.grid(row=3, column=0, sticky="nsew", padx=5, pady=8) self.grid_rowconfigure(3, weight=1) # Allow this frame to expand vertically targets_frame.grid_columnconfigure(0, weight=1) targets_frame.grid_rowconfigure(0, weight=1) # Scrollbar scrollbar = ttk.Scrollbar(targets_frame, orient=tk.VERTICAL) # Treeview columns = ( "id", "lat", "lon", "alt", "hdg", "gnd_speed", "vert_speed", ) self.targets_tree = ttk.Treeview( targets_frame, columns=columns, show="headings", yscrollcommand=scrollbar.set, ) scrollbar.config(command=self.targets_tree.yview) # Define headings self.targets_tree.heading("id", text="ID") self.targets_tree.heading("lat", text="Latitude") self.targets_tree.heading("lon", text="Longitude") self.targets_tree.heading("alt", text="Altitude (ft)") self.targets_tree.heading("hdg", text="Heading (°)") self.targets_tree.heading("gnd_speed", text="Speed (kn)") self.targets_tree.heading("vert_speed", text="Vert. Speed (ft/s)") # Define column properties self.targets_tree.column("id", width=30, anchor=tk.CENTER, stretch=False) self.targets_tree.column("lat", width=100, anchor=tk.W) self.targets_tree.column("lon", width=100, anchor=tk.W) self.targets_tree.column("alt", width=80, anchor=tk.E) self.targets_tree.column("hdg", width=80, anchor=tk.E) self.targets_tree.column("gnd_speed", width=80, anchor=tk.E) self.targets_tree.column("vert_speed", width=100, anchor=tk.E) # Layout Treeview and Scrollbar self.targets_tree.grid(row=0, column=0, sticky="nsew") scrollbar.grid(row=0, column=1, sticky="ns") # --- Non-modal notice area --- self.notice_var = tk.StringVar(value="") self.notice_frame = ttk.Frame(self) self.notice_frame.grid(row=4, column=0, sticky="ew", padx=5, pady=(5, 0)) self.notice_frame.grid_remove() # Hidden by default notice_label = tk.Label( self.notice_frame, textvariable=self.notice_var, bg="#fff3cd", fg="#6a4b00", anchor="w", relief=tk.SOLID, bd=1, padx=6, pady=2, ) notice_label.pack(side=tk.LEFT, fill=tk.X, expand=True) ttk.Button(self.notice_frame, text="Dismiss", command=self.hide_notice).pack( side=tk.RIGHT, padx=(6, 0) ) def update_ownship_display(self, state: Dict[str, Any]): """Updates the labels in the Ownship State frame.""" if not state: self.lat_var.set("-") self.lon_var.set("-") self.alt_var.set("-") self.hdg_var.set("-") self.gnd_speed_var.set("-") self.vert_speed_var.set("-") return # Latitude / Longitude lat = state.get("latitude", 0.0) lon = state.get("longitude", 0.0) self.lat_var.set(f"{abs(lat):.5f}° {'N' if lat >= 0 else 'S'}") self.lon_var.set(f"{abs(lon):.5f}° {'E' if lon >= 0 else 'W'}") # Altitude alt_ft = state.get("altitude_ft", 0.0) self.alt_var.set(f"{alt_ft:.1f} ft") # Heading hdg_deg = state.get("heading_deg", 0.0) self.hdg_var.set(f"{hdg_deg:.2f}°") # Ground Speed vx_fps, vy_fps = state.get("velocity_xy_fps", (0.0, 0.0)) gnd_speed_kn = (vx_fps**2 + vy_fps**2) ** 0.5 * FPS_TO_KNOTS self.gnd_speed_var.set(f"{gnd_speed_kn:.1f} kn") # Vertical Speed vz_fps = state.get("velocity_z_fps", 0.0) self.vert_speed_var.set(f"{vz_fps:+.1f} ft/s") def update_targets_table( self, targets: List[Target], ownship_state: Dict[str, Any] ): """Clears and repopulates the active targets table with geographic data.""" for item in self.targets_tree.get_children(): self.targets_tree.delete(item) # Get ownship data needed for conversion own_lat = ownship_state.get("latitude") own_lon = ownship_state.get("longitude") own_pos_xy_ft = ownship_state.get("position_xy_ft") for target in sorted(targets, key=lambda t: t.target_id): if not target.active: continue lat_str = "N/A" lon_str = "N/A" # Calculate geographic position if possible if own_lat is not None and own_lon is not None and own_pos_xy_ft: target_x_ft = getattr(target, "_pos_x_ft", 0.0) target_y_ft = getattr(target, "_pos_y_ft", 0.0) own_x_ft, own_y_ft = own_pos_xy_ft # Delta from ownship's current position in meters delta_east_m = (target_x_ft - own_x_ft) * 0.3048 delta_north_m = (target_y_ft - own_y_ft) * 0.3048 # Equirectangular approximation for lat/lon calculation earth_radius_m = 6378137.0 dlat = (delta_north_m / earth_radius_m) * (180.0 / math.pi) dlon = ( delta_east_m / (earth_radius_m * math.cos(math.radians(own_lat))) ) * (180.0 / math.pi) target_lat = own_lat + dlat target_lon = own_lon + dlon lat_str = f"{abs(target_lat):.5f}° {'N' if target_lat >= 0 else 'S'}" lon_str = f"{abs(target_lon):.5f}° {'E' if target_lon >= 0 else 'W'}" alt_str = f"{target.current_altitude_ft:.1f}" hdg_str = f"{target.current_heading_deg:.2f}" # Use the now-correct velocity values from the Target object gnd_speed_kn = target.current_velocity_fps * FPS_TO_KNOTS gnd_speed_str = f"{gnd_speed_kn:.1f}" vert_speed_fps = target.current_vertical_velocity_fps vert_speed_str = f"{vert_speed_fps:+.1f}" values = ( target.target_id, lat_str, lon_str, alt_str, hdg_str, gnd_speed_str, vert_speed_str, ) self.targets_tree.insert("", tk.END, values=values) def show_notice(self, message: str): self.notice_var.set(message) self.notice_frame.grid() def hide_notice(self): self.notice_var.set("") self.notice_frame.grid_remove() def _start(self): """Trigger the main view's start-simulation callback if available. This method is a thin adapter wired to the Start Live button and does not perform simulation logic itself; it delegates to MainView. """ if hasattr(self.main_view, "_on_start_simulation"): self.main_view._on_start_simulation() def _stop(self): """Trigger the main view's stop-simulation callback if available. This method is wired to the Stop Live button and simply delegates the request to MainView so the central controller manages engine state. """ if hasattr(self.main_view, "_on_stop_simulation"): self.main_view._on_stop_simulation()