# 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. """ import tkinter as tk from tkinter import ttk import math import numpy as np from matplotlib.figure import Figure from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg # Use absolute imports from target_simulator.core.models import Target, Waypoint, ManeuverType, NM_TO_FT from typing import List class PPIDisplay(ttk.Frame): """A custom, reusable widget for the PPI radar display.""" def __init__(self, master, max_range_nm: int = 100, scan_limit_deg: int = 60): super().__init__(master) self.max_range = max_range_nm self.scan_limit_deg = scan_limit_deg # Artists for dynamic target display self.target_artists = [] self.active_targets: List[Target] = [] # Artists for trajectory preview display self.preview_artists = [] self._create_controls() self._create_plot() def _create_controls(self): """Creates the control widgets for the PPI display.""" self.controls_frame = ttk.Frame(self) self.controls_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) 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) 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') self.ax.set_theta_zero_location('N') self.ax.set_theta_direction(-1) self.ax.set_rlabel_position(90) self.ax.set_ylim(0, self.range_var.get()) angles = np.arange(0, 360, 30) labels = [f"{angle}°" for angle in angles] self.ax.set_thetagrids(angles, 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') # --- Artists for drawing previews --- 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] # --- Artists for scan lines --- 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 _update_scan_lines(self): """Updates the length and position of the scan sector lines.""" 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): """Handles the selection of a new range.""" 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): """Clears all preview-related artists from the plot.""" for artist in self.preview_artists: artist.set_data([], []) self.canvas.draw_idle() def update_targets(self, targets: List[Target]): """Updates the display with the current state of active targets.""" self.active_targets = [t for t in targets if t.active] # Clear previous target artists for artist in self.target_artists: artist.remove() self.target_artists.clear() vector_len_nm = self.range_var.get() / 20.0 # Length of heading vector for target in self.active_targets: # Target's polar coordinates r_nm = target.current_range_nm theta_rad = np.deg2rad(target.current_azimuth_deg) # Draw the target dot dot, = self.ax.plot(theta_rad, r_nm, 'o', markersize=6, color='red') self.target_artists.append(dot) # Draw the heading vector heading_rad = np.deg2rad(target.current_heading_deg) # Convert to Cartesian to calculate endpoint, then back to polar x_nm = r_nm * np.sin(theta_rad) y_nm = r_nm * np.cos(theta_rad) dx_nm = vector_len_nm * np.sin(heading_rad) dy_nm = vector_len_nm * np.cos(heading_rad) r2_nm = math.sqrt((x_nm + dx_nm)**2 + (y_nm + dy_nm)**2) theta2_rad = math.atan2(x_nm + dx_nm, y_nm + dy_nm) line, = self.ax.plot([theta_rad, theta2_rad], [r_nm, r2_nm], color='red', linewidth=1.2) self.target_artists.append(line) self.canvas.draw_idle() def draw_trajectory_preview(self, waypoints: List[Waypoint], use_spline: bool): """ Simulates and draws a trajectory preview by leveraging the static path generator. """ self.clear_previews() if not waypoints or waypoints[0].maneuver_type != ManeuverType.FLY_TO_POINT: return # Use the static method to get the path without creating a Target instance path, _ = Target.generate_path_from_waypoints(waypoints, use_spline) if not path: return # --- Draw the main path (splined or polygonal) --- 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) theta_rad = math.atan2(x_ft, y_ft) path_rs.append(r_ft / NM_TO_FT) path_thetas.append(theta_rad) self._path_plot.set_data(path_thetas, path_rs) # --- Draw waypoint markers (only for Fly to Point) --- 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) wp_rs.append(r_nm) wp_thetas.append(theta_rad) self._waypoints_plot.set_data(wp_thetas, wp_rs) # --- Draw the start point --- 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): """ Updates the radar parameters (range, scan limit) of an existing PPI display. """ self.max_range = max_range_nm self.scan_limit_deg = scan_limit_deg # Update the range combobox values 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 the current range is still valid, keep it. Otherwise, reset to max. if current_range in valid_steps: self.range_var.set(current_range) else: self.range_var.set(self.max_range) # Apply the new range and redraw everything self._on_range_selected()