S1005403_RisCC/target_simulator/gui/ppi_display.py
2025-10-08 14:59:45 +02:00

244 lines
10 KiB
Python

# 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
import copy
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
# Use absolute imports
from target_simulator.core.models import Target, Waypoint
from typing import List, Optional
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
self.target_artists = []
self.active_targets: List[Target] = []
self._target_dots = []
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)
# Create a list of valid range steps up to the theoretical max_range
all_steps = [10, 20, 40, 80, 100, 160]
valid_steps = sorted([s for s in all_steps if s <= self.max_range])
if not valid_steps:
valid_steps = [self.max_range]
# Ensure the initial max range is in the list if not a standard step
if self.max_range not in valid_steps:
valid_steps.append(self.max_range)
valid_steps.sort()
# The initial value for the combobox is the max_range passed to the constructor
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("<<ComboboxSelected>>", 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.85, 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}°" if angle == 0 else f"+{angle}°" if angle < 180 else "±180°" if angle == 180 else f"-{360 - 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', y=1.08)
# --- Artists for drawing ---
self._start_plot, = self.ax.plot([], [], 'go', markersize=8)
self._waypoints_plot, = self.ax.plot([], [], 'y+', markersize=10, mew=2, linestyle='None')
self._path_plot, = self.ax.plot([], [], 'r--', linewidth=1.5)
self.preview_artists = [self._start_plot, self._waypoints_plot, self._path_plot]
# --- NEW: Create 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._tooltip_label = None
self.canvas = FigureCanvasTkAgg(fig, master=self)
self.canvas.draw()
self.canvas.get_tk_widget().pack(side=tk.TOP, fill=tk.BOTH, expand=True)
self.canvas.mpl_connect('motion_notify_event', self._on_motion)
# --- NEW: Initial draw of scan lines ---
self._update_scan_lines()
def _update_scan_lines(self):
"""Updates the length of the scan sector lines to match the current range."""
current_range_max = self.ax.get_ylim()[1]
self._scan_line_1.set_ydata([0, current_range_max])
self._scan_line_2.set_ydata([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)
# --- NEW: Update scan lines on zoom change ---
self._update_scan_lines()
self.update_targets(self.active_targets)
self.canvas.draw()
# ... il resto dei metodi rimane invariato ...
def update_targets(self, targets: List[Target]):
# (This method is unchanged)
self.active_targets = [t for t in targets if t.active]
for artist in self.target_artists: artist.remove()
self.target_artists.clear()
self._target_dots.clear()
vector_len = self.range_var.get() / 25
for target in self.active_targets:
r = target.current_range_nm
theta = np.deg2rad(target.current_azimuth_deg)
dot, = self.ax.plot(theta, r, 'o', markersize=5, color='red')
self.target_artists.append(dot)
self._target_dots.append((dot, target))
x1, y1 = r * np.sin(theta), r * np.cos(theta)
h_rad = np.deg2rad(target.current_heading_deg)
dx, dy = vector_len * np.sin(h_rad), vector_len * np.cos(h_rad)
x2, y2 = x1 + dx, y1 + dy
r2, th2 = np.sqrt(x2**2 + y2**2), np.arctan2(x2, y2)
line, = self.ax.plot([theta, th2], [r, r2], color='red', linewidth=1.2)
self.target_artists.append(line)
self.canvas.draw()
def draw_trajectory_preview(self, initial_state: dict, waypoints: List[Waypoint]):
"""Simulates and draws a trajectory preview without auto-zooming."""
self.clear_previews()
# --- NUOVA LOGICA ---
# Assicura che l'asse Y sia sincronizzato con la combobox prima di disegnare.
self._on_range_selected()
if not waypoints:
# Disegna solo il punto di partenza se non ci sono waypoint
start_r, start_theta = initial_state['initial_range_nm'], math.radians(initial_state['initial_azimuth_deg'])
self._start_plot.set_data([start_theta], [start_r])
self.canvas.draw()
return
temp_target = Target(target_id=0, **initial_state, trajectory=copy.deepcopy(waypoints))
path_thetas, path_rs = [], []
wp_thetas, wp_rs = [], []
total_duration = sum(wp.duration_s for wp in waypoints)
sim_time, time_step = 0.0, 0.5
path_thetas.append(math.radians(temp_target.current_azimuth_deg))
path_rs.append(temp_target.current_range_nm)
while sim_time < total_duration:
wp_index_before = temp_target._current_waypoint_index
temp_target.update_state(time_step)
path_thetas.append(math.radians(temp_target.current_azimuth_deg))
path_rs.append(temp_target.current_range_nm)
if temp_target._current_waypoint_index > wp_index_before:
wp_thetas.append(path_thetas[-2])
wp_rs.append(path_rs[-2])
sim_time += time_step
start_r, start_theta = initial_state['initial_range_nm'], math.radians(initial_state['initial_azimuth_deg'])
self._start_plot.set_data([start_theta], [start_r])
self._waypoints_plot.set_data(wp_thetas, wp_rs)
self._path_plot.set_data(path_thetas, path_rs)
# --- LOGICA DI AUTO-ZOOM RIMOSSA ---
# max_r = max(path_rs) if path_rs else start_r
# self.ax.set_ylim(0, max_r * 1.1)
self._update_scan_lines()
self.canvas.draw()
def clear_previews(self):
# (This method is unchanged)
for artist in self.preview_artists: artist.set_data([], [])
self.canvas.draw()
def _on_motion(self, event):
# (This method is unchanged)
if event.inaxes != self.ax:
if 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:
self._show_tooltip(event.x + 10, event.y + 10, f"ID: {target.target_id}"); found = True; break
if not found and self._tooltip_label: self._tooltip_label.place_forget(); self._tooltip_label = None
def _show_tooltip(self, x, y, text):
# (This method is unchanged)
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)
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]
valid_steps = sorted([s for s in steps if s <= self.max_range])
if not valid_steps: valid_steps = [self.max_range]
if self.max_range not in valid_steps:
valid_steps.append(self.max_range)
valid_steps.sort()
self.range_selector['values'] = valid_steps
self.range_var.set(self.max_range) # Set to the new max range
# Update the scan limit lines
limit_rad = np.deg2rad(self.scan_limit_deg)
self._scan_line_1.set_xdata([limit_rad, limit_rad])
self._scan_line_2.set_xdata([-limit_rad, -limit_rad])
# Apply the new range and redraw everything
self._on_range_selected()