# target_simulator/gui/ppi_display.py """ A reusable Tkinter widget that displays a Plan Position Indicator (PPI) using Matplotlib, capable of showing both live targets and trajectory previews, and comparing simulated vs. real-time data. """ import tkinter as tk from tkinter import ttk import math import numpy as np import collections from matplotlib.figure import Figure from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from typing import List, Dict, Union # Use absolute imports from target_simulator.core.models import Target, Waypoint, ManeuverType, NM_TO_FT class PPIDisplay(ttk.Frame): """ A custom widget for the PPI radar display, capable of showing simulated data, real-time feedback, and trajectory previews. """ TRAIL_LENGTH = 100 # Number of historical points to show in a target's trail def __init__(self, master, max_range_nm: int = 100, scan_limit_deg: int = 60, trail_length: int = None): super().__init__(master) self.max_range = max_range_nm self.scan_limit_deg = scan_limit_deg # Artists for dynamic target display self.sim_target_artists = [] self.real_target_artists = [] self.sim_trail_artists = [] self.real_trail_artists = [] self.target_label_artists = [] # Data store for target trails. Use a bounded deque per-target whose # max length is configurable (via constructor). The widget no longer # supports an unbounded "full trail" mode — use a sufficiently large # trail_length if you need a longer history. self.trail_length = trail_length or self.TRAIL_LENGTH self._trails = { "simulated": collections.defaultdict(lambda: collections.deque(maxlen=self.trail_length)), "real": collections.defaultdict(lambda: collections.deque(maxlen=self.trail_length)), } # Artists for trajectory preview self.preview_artists = [] # --- UI Visibility Controls --- self.show_sim_points_var = tk.BooleanVar(value=True) self.show_real_points_var = tk.BooleanVar(value=True) self.show_sim_trail_var = tk.BooleanVar(value=False) self.show_real_trail_var = tk.BooleanVar(value=True) self._create_controls() self._create_plot() def _create_controls(self): """Creates the control widgets for the PPI display.""" top_frame = ttk.Frame(self) top_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) self.controls_frame = ttk.Frame(top_frame) self.controls_frame.pack(side=tk.LEFT, fill=tk.X, expand=True) ttk.Label(self.controls_frame, text="Range (NM):").pack(side=tk.LEFT) all_steps = [10, 20, 40, 80, 100, 160, 240, 320] valid_steps = sorted([s for s in all_steps if s <= self.max_range]) if not valid_steps or self.max_range not in valid_steps: valid_steps.append(self.max_range) valid_steps.sort() self.range_var = tk.IntVar(value=self.max_range) self.range_selector = ttk.Combobox( self.controls_frame, textvariable=self.range_var, values=valid_steps, state="readonly", width=5 ) self.range_selector.pack(side=tk.LEFT, padx=5) self.range_selector.bind("<>", self._on_range_selected) # NOTE: Connection controls were removed from the PPI display to # keep this widget purely visual. Connection state is still # exposed via the API below so MainView or other windows can # register callbacks or push state changes here. self._connect_callback = None self._is_connected = False # --- Display Options --- options_frame = ttk.LabelFrame(top_frame, text="Display Options") options_frame.pack(side=tk.RIGHT, padx=(10, 0)) cb_sim_points = ttk.Checkbutton(options_frame, text="Sim Points", variable=self.show_sim_points_var, command=lambda: self.canvas.draw_idle()) cb_sim_points.grid(row=0, column=0, sticky='w', padx=5) cb_real_points = ttk.Checkbutton(options_frame, text="Real Points", variable=self.show_real_points_var, command=lambda: self.canvas.draw_idle()) cb_real_points.grid(row=0, column=1, sticky='w', padx=5) cb_sim_trail = ttk.Checkbutton(options_frame, text="Sim Trail", variable=self.show_sim_trail_var, command=lambda: self.canvas.draw_idle()) cb_sim_trail.grid(row=1, column=0, sticky='w', padx=5) cb_real_trail = ttk.Checkbutton(options_frame, text="Real Trail", variable=self.show_real_trail_var, command=lambda: self.canvas.draw_idle()) cb_real_trail.grid(row=1, column=1, sticky='w', padx=5) # --- Legend --- legend_frame = ttk.Frame(top_frame) legend_frame.pack(side=tk.RIGHT, padx=(10, 5)) # Small colored swatches for simulated/real sim_sw = tk.Canvas(legend_frame, width=16, height=12, highlightthickness=0) sim_sw.create_rectangle(0, 0, 16, 12, fill='green', outline='black') sim_sw.pack(side=tk.LEFT, padx=(0, 4)) ttk.Label(legend_frame, text="Simulated").pack(side=tk.LEFT, padx=(0, 8)) real_sw = tk.Canvas(legend_frame, width=16, height=12, highlightthickness=0) real_sw.create_rectangle(0, 0, 16, 12, fill='red', outline='black') real_sw.pack(side=tk.LEFT, padx=(2, 4)) ttk.Label(legend_frame, text="Real").pack(side=tk.LEFT) def _create_plot(self): """Initializes the Matplotlib polar plot.""" fig = Figure(figsize=(5, 5), dpi=100, facecolor="#3E3E3E") fig.subplots_adjust(left=0.05, right=0.95, top=0.9, bottom=0.05) self.ax = fig.add_subplot(111, projection="polar", facecolor="#2E2E2E") # Set zero at North (top) and theta direction to counter-clockwise (standard) self.ax.set_theta_zero_location("N") self.ax.set_theta_direction(1) # POSITIVO = ANTI-ORARIO (CCW) self.ax.set_rlabel_position(90) self.ax.set_ylim(0, self.range_var.get()) # Set angular grid labels to be [-180, 180] with North at 0 # and positive azimuths increasing counter-clockwise. # Using a direct mapping: angles 0..180 -> same, 181..359 -> angle-360 (negative) angles_deg = np.arange(0, 360, 30) labels = [] for angle in angles_deg: display_angle = angle if angle <= 180 else angle - 360 # Show integer degrees with sign where appropriate if display_angle == int(display_angle): labels.append(f'{int(display_angle)}°') else: labels.append(f'{display_angle:.0f}°') self.ax.set_thetagrids(angles_deg, labels) self.ax.tick_params(axis="x", colors="white", labelsize=8) self.ax.tick_params(axis="y", colors="white", labelsize=8) self.ax.grid(color="white", linestyle="--", linewidth=0.5, alpha=0.5) self.ax.spines["polar"].set_color("white") self.ax.set_title("PPI Display", color="white") # ... (il resto del metodo è corretto) ... (self._path_plot,) = self.ax.plot([], [], "g--", linewidth=1.5, label="Path") (self._start_plot,) = self.ax.plot([], [], "go", markersize=8, label="Start") (self._waypoints_plot,) = self.ax.plot([], [], "y+", markersize=10, mew=2, label="Waypoints") self.preview_artists = [self._path_plot, self._start_plot, self._waypoints_plot] limit_rad = np.deg2rad(self.scan_limit_deg) (self._scan_line_1,) = self.ax.plot([limit_rad, limit_rad], [0, self.max_range], color="yellow", linestyle="--", linewidth=1) (self._scan_line_2,) = self.ax.plot([-limit_rad, -limit_rad], [0, self.max_range], color="yellow", linestyle="--", linewidth=1) self.canvas = FigureCanvasTkAgg(fig, master=self) self.canvas.draw() self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True) self._update_scan_lines() def _init_trails(self): """(Removed) kept for historical reasons but no longer used.""" # This method is intentionally left as a no-op. Trails are initialized # during construction and are always bounded by `trail_length`. return def _on_full_trail_toggled(self): """Toggle between bounded trail and full trail while preserving data. When switching modes we transfer existing points into the new structure to avoid losing history unexpectedly. """ # Full-trail toggling has been removed. Keep the method present so # callers won't fail if they still reference it, but do nothing. return def update_targets(self, targets_data: Union[List[Target], Dict[str, List[Target]]]): """ Updates the display with the current state of targets. This method is backward compatible. It can accept: 1. A simple List[Target] (for previews and simple display). 2. A Dict{'simulated': [...], 'real': [...]} for comparative display. """ sim_targets: List[Target] = [] real_targets: List[Target] = [] if isinstance(targets_data, list): sim_targets = [t for t in targets_data if t.active] elif isinstance(targets_data, dict): sim_targets = [t for t in targets_data.get("simulated", []) if t.active] real_targets = [t for t in targets_data.get("real", []) if t.active] # --- Clear all previous dynamic artists --- for artist_list in [self.sim_target_artists, self.real_target_artists, self.sim_trail_artists, self.real_trail_artists, self.target_label_artists]: for artist in artist_list: artist.remove() artist_list.clear() # --- Update trail history --- for target in sim_targets: # Invert azimuth sign for plotting so numeric azimuth values # coming from the model (server convention) match the visual # placement: server positive -> right side (clockwise). pos = (np.deg2rad(-target.current_azimuth_deg), target.current_range_nm) self._trails["simulated"][target.target_id].append(pos) for target in real_targets: pos = (np.deg2rad(-target.current_azimuth_deg), target.current_range_nm) self._trails["real"][target.target_id].append(pos) # --- Redraw artists based on visibility settings --- if self.show_sim_points_var.get(): self._draw_target_visuals(sim_targets, 'green', self.sim_target_artists) if self.show_real_points_var.get(): self._draw_target_visuals(real_targets, 'red', self.real_target_artists) if self.show_sim_trail_var.get(): self._draw_trails(self._trails["simulated"], 'limegreen', self.sim_trail_artists) if self.show_real_trail_var.get(): self._draw_trails(self._trails["real"], 'tomato', self.real_trail_artists) self.canvas.draw_idle() # --- Connection state API (PPI remains visual only) --- def set_connect_callback(self, cb): """Register a callback. Kept for backward compatibility. Note: the PPI no longer exposes a connect button. This method only stores the callback so higher-level UI can reuse the same handler if desired. """ self._connect_callback = cb def update_connect_state(self, is_connected: bool): """Update the internal connection state. Does not alter any widgets. MainView is responsible for showing connect/disconnect controls. """ try: self._is_connected = bool(is_connected) except Exception: pass def _draw_target_visuals(self, targets: List[Target], color: str, artist_list: List): """Helper to draw dots and vectors for a list of targets.""" vector_len_nm = self.range_var.get() / 20.0 for target in targets: r_nm = target.current_range_nm theta_rad = np.deg2rad(-target.current_azimuth_deg) (dot,) = self.ax.plot(theta_rad, r_nm, "o", markersize=6, color=color) artist_list.append(dot) # Convert the navigational heading (0=North, clockwise) to a mathematical # angle (0=East, counter-clockwise) for use with sin/cos. # Formula: math_angle = (450 - nav_heading) % 360 heading_math_rad = np.deg2rad((450 - target.current_heading_deg) % 360) # Calculate the end point of the vector in Cartesian space # Start point in cartesian x_nm = r_nm * math.cos(theta_rad) y_nm = r_nm * math.sin(theta_rad) # Displacement vector based on heading dx_nm = vector_len_nm * math.cos(heading_math_rad) dy_nm = vector_len_nm * math.sin(heading_math_rad) # End point in cartesian end_x_nm = x_nm + dx_nm end_y_nm = y_nm + dy_nm # Convert end point back to polar for plotting r2_nm = math.sqrt(end_x_nm**2 + end_y_nm**2) theta2_rad = math.atan2(end_y_nm, end_x_nm) (line,) = self.ax.plot([theta_rad, theta2_rad], [r_nm, r2_nm], color=color, linewidth=1.2) artist_list.append(line) label_r = r_nm + (vector_len_nm * 0.5) txt = self.ax.text(theta_rad, label_r, str(target.target_id), color="white", fontsize=8, ha="center", va="bottom") self.target_label_artists.append(txt) def _draw_trails(self, trail_data: Dict, color: str, artist_list: List): """Helper to draw historical trails for targets.""" for target_id in trail_data: trail = trail_data[target_id] if len(trail) > 1: thetas, rs = zip(*trail) (line,) = self.ax.plot(thetas, rs, color=color, linestyle='-', linewidth=0.8, alpha=0.7) artist_list.append(line) def clear_trails(self): """Clears the historical data for all target trails.""" self._trails["simulated"].clear() self._trails["real"].clear() self.update_targets({}) # Trigger a redraw to clear visuals # --- Other methods remain unchanged --- def _update_scan_lines(self): current_range_max = self.ax.get_ylim()[1] limit_rad = np.deg2rad(self.scan_limit_deg) self._scan_line_1.set_data([limit_rad, limit_rad], [0, current_range_max]) self._scan_line_2.set_data([-limit_rad, -limit_rad], [0, current_range_max]) def _on_range_selected(self, event=None): new_range = self.range_var.get() self.ax.set_ylim(0, new_range) self._update_scan_lines() self.canvas.draw_idle() def clear_previews(self): for artist in self.preview_artists: artist.set_data([], []) self.canvas.draw_idle() def draw_trajectory_preview(self, waypoints: List[Waypoint], use_spline: bool): self.clear_previews() self.clear_trails() if not waypoints or waypoints[0].maneuver_type != ManeuverType.FLY_TO_POINT: return path, _ = Target.generate_path_from_waypoints(waypoints, use_spline) if not path: return path_thetas, path_rs = [], [] for point in path: _time, x_ft, y_ft, _z_ft = point r_ft = math.sqrt(x_ft**2 + y_ft**2) # Compute azimuth from Cartesian coords (x East, y North). theta_rad = math.atan2(y_ft, x_ft) # Invert sign for plotting so path uses the same visual convention # as the rest of the PPI (server azimuths are not inverted). path_rs.append(r_ft / NM_TO_FT) path_thetas.append(-theta_rad) self._path_plot.set_data(path_thetas, path_rs) wp_thetas, wp_rs = [], [] for wp in waypoints: if wp.maneuver_type == ManeuverType.FLY_TO_POINT: r_nm = wp.target_range_nm or 0.0 theta_rad = math.radians(wp.target_azimuth_deg or 0.0) # invert for plotting wp_rs.append(r_nm) wp_thetas.append(-theta_rad) self._waypoints_plot.set_data(wp_thetas, wp_rs) start_wp = waypoints[0] start_r = start_wp.target_range_nm or 0.0 start_theta = math.radians(start_wp.target_azimuth_deg or 0.0) self._start_plot.set_data([-start_theta], [start_r]) self.canvas.draw_idle() def reconfigure_radar(self, max_range_nm: int, scan_limit_deg: int): self.max_range = max_range_nm self.scan_limit_deg = scan_limit_deg steps = [10, 20, 40, 80, 100, 160, 240, 320] valid_steps = sorted([s for s in steps if s <= self.max_range]) if not valid_steps or self.max_range not in valid_steps: valid_steps.append(self.max_range) valid_steps.sort() current_range = self.range_var.get() self.range_selector["values"] = valid_steps if current_range in valid_steps: self.range_var.set(current_range) else: self.range_var.set(self.max_range) self._on_range_selected()