# target_simulator/gui/ppi_display.py """ A Tkinter widget that displays a Plan Position Indicator (PPI) using Matplotlib. """ 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 import matplotlib.transforms as transforms import matplotlib.markers as markers from core.models import Target from typing import List class PPIDisplay(ttk.Frame): """A custom 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 self.target_artists = [] self._create_controls() self._create_plot() def _create_controls(self): """Creates the control widgets for the PPI display.""" controls_frame = ttk.Frame(self) controls_frame.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5) ttk.Label(controls_frame, text="Range (NM):").pack(side=tk.LEFT) # Generate range steps steps = list(range(self.max_range, 19, -20)) if 10 not in steps: steps.append(10) self.range_var = tk.IntVar(value=self.max_range) self.range_selector = ttk.Combobox( controls_frame, textvariable=self.range_var, values=steps, state="readonly", width=5 ) self.range_selector.pack(side=tk.LEFT) self.range_selector.bind("<>", self._on_range_selected) def _create_plot(self): """Initializes the Matplotlib polar plot.""" # Dark theme for the plot fig = Figure(figsize=(5, 5), dpi=100, facecolor='#3E3E3E') fig.subplots_adjust(left=0.05, right=0.95, top=0.85, bottom=0.05) self.ax = fig.add_subplot(111, projection='polar', facecolor='#2E2E2E') # Configure polar axes self.ax.set_theta_zero_location('N') # 0 degrees at the top self.ax.set_theta_direction(-1) # Clockwise rotation self.ax.set_rlabel_position(90) self.ax.set_ylim(0, self.max_range) # Set custom theta labels for sector scan view angles = np.arange(0, 360, 30) labels = [] for angle in angles: if angle == 0: labels.append("0°") elif angle < 180: labels.append(f"+{angle}°") elif angle == 180: labels.append("±180°") else: # angle > 180 labels.append(f"-{360 - angle}°") self.ax.set_thetagrids(angles, labels) # Style the plot self.ax.tick_params(axis='x', colors='white') self.ax.tick_params(axis='y', colors='white') 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', y=1.08) # Draw scan sector limits limit_rad = np.deg2rad(self.scan_limit_deg) self.ax.plot([limit_rad, limit_rad], [0, self.max_range], color='yellow', linestyle='--', linewidth=1) self.ax.plot([-limit_rad, -limit_rad], [0, self.max_range], color='yellow', linestyle='--', linewidth=1) # Embed the plot in a Tkinter canvas self.canvas = FigureCanvasTkAgg(fig, master=self) self.canvas.draw() self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True) def _on_range_selected(self, event): """Handles the selection of a new range from the combobox.""" new_range = self.range_var.get() self.ax.set_ylim(0, new_range) # Update heading vector lengths on zoom self.update_targets([]) # Pass empty list to redraw existing ones with new scale self.canvas.draw() def update_targets(self, targets: List[Target]): """ Aggiorna la posizione dei target sulla PPI e mostra l'ID accanto al punto. Tooltip con ID al passaggio del mouse. """ if targets: self.active_targets = [t for t in targets if t.active] # Rimuovi artisti precedenti for artist in self.target_artists: artist.remove() self.target_artists.clear() # Rimuovi eventuali tooltips precedenti if hasattr(self, 'tooltips'): for tip in self.tooltips: tip.remove() self.tooltips = [] vector_len = self.range_var.get() / 25 self._target_dots = [] for target in getattr(self, 'active_targets', []): r1 = target.range_nm th1 = np.deg2rad(target.azimuth_deg) # Punto del target dot, = self.ax.plot(th1, r1, 'o', markersize=5, color='red', picker=5) self.target_artists.append(dot) self._target_dots.append((dot, target)) # Vettore heading x1 = r1 * np.sin(th1) y1 = r1 * np.cos(th1) h_rad = np.deg2rad(target.heading_deg) dx = vector_len * np.sin(h_rad) dy = vector_len * np.cos(h_rad) x2, y2 = x1 + dx, y1 + dy r2 = np.sqrt(x2**2 + y2**2) th2 = np.arctan2(x2, y2) line, = self.ax.plot([th1, th2], [r1, r2], color='red', linewidth=1.2) self.target_artists.append(line) # Gestione tooltip def on_motion(event): # Mostra hint se il mouse è vicino a un punto target if event.inaxes != self.ax: if hasattr(self, '_tooltip_label') and self._tooltip_label: self._tooltip_label.place_forget() self._tooltip_label = None return found = False for dot, target in self._target_dots: cont, _ = dot.contains(event) if cont: # Usa la posizione del mouse relativa al widget Tkinter self._show_tooltip(event.x + 10, event.y + 10, f"ID: {target.target_id}") found = True break if not found and hasattr(self, '_tooltip_label') and self._tooltip_label: self._tooltip_label.place_forget() self._tooltip_label = None self.canvas.mpl_connect('motion_notify_event', on_motion) self._tooltip_label = None self.canvas.draw() def _show_tooltip(self, x, y, text): # Mostra un tooltip Tkinter sopra la canvas, centrato sul punto target if self._tooltip_label: self._tooltip_label.place_forget() self._tooltip_label = tk.Label(self.canvas.get_tk_widget(), text=text, bg='yellow', fg='black', font=('Consolas', 9), relief='solid', borderwidth=1) self._tooltip_label.place(x=x, y=y)