SXXXXXXX_FlightMonitor/flightmonitor/gui/main_window.py
2025-05-15 09:52:24 +02:00

349 lines
15 KiB
Python

import tkinter as tk
from tkinter import ttk
from tkinter import messagebox # Importato per eventuali messaggi di errore
# Valori di default per il bounding box (Europa centrale circa)
DEFAULT_LAT_MIN = 45.0
DEFAULT_LON_MIN = 5.0
DEFAULT_LAT_MAX = 55.0
DEFAULT_LON_MAX = 15.0
DEFAULT_CANVAS_WIDTH = 800
DEFAULT_CANVAS_HEIGHT = 600
class MainWindow:
"""
Main window of the Flight Monitor application.
It handles the layout and basic user interactions.
"""
def __init__(self, root: tk.Tk, controller):
"""
Initializes the main window.
Args:
root (tk.Tk): The root Tkinter object.
controller: The application controller instance.
"""
self.root = root
self.controller = controller
self.root.title("Flight Monitor")
self.root.geometry("1000x750") # Aumentata un po' la dimensione per i nuovi widget
# Main frame
self.main_frame = ttk.Frame(self.root, padding="10")
self.main_frame.pack(fill=tk.BOTH, expand=True)
# --- Control Frame ---
self.control_frame = ttk.LabelFrame(self.main_frame, text="Controls", padding="10")
self.control_frame.pack(side=tk.TOP, fill=tk.X, pady=5)
# Mode selection
self.mode_var = tk.StringVar(value="Live") # Default to Live
self.live_radio = ttk.Radiobutton(
self.control_frame,
text="Live",
variable=self.mode_var,
value="Live",
command=self._on_mode_change
)
self.live_radio.pack(side=tk.LEFT, padx=5)
self.history_radio = ttk.Radiobutton(
self.control_frame,
text="History",
variable=self.mode_var,
value="History",
command=self._on_mode_change
)
self.history_radio.pack(side=tk.LEFT, padx=5)
# Start/Stop buttons
self.start_button = ttk.Button(
self.control_frame,
text="Start Monitoring",
command=self._start_monitoring
)
self.start_button.pack(side=tk.LEFT, padx=5)
self.stop_button = ttk.Button(
self.control_frame,
text="Stop Monitoring",
command=self._stop_monitoring,
state=tk.DISABLED
)
self.stop_button.pack(side=tk.LEFT, padx=5)
# --- Bounding Box Input Frame ---
self.bbox_frame = ttk.LabelFrame(self.main_frame, text="Geographic Area (Bounding Box)", padding="10")
self.bbox_frame.pack(side=tk.TOP, fill=tk.X, pady=5)
# Lat Min
ttk.Label(self.bbox_frame, text="Lat Min:").grid(row=0, column=0, padx=5, pady=2, sticky=tk.W)
self.lat_min_var = tk.StringVar(value=str(DEFAULT_LAT_MIN))
self.lat_min_entry = ttk.Entry(self.bbox_frame, textvariable=self.lat_min_var, width=10)
self.lat_min_entry.grid(row=0, column=1, padx=5, pady=2)
# Lon Min
ttk.Label(self.bbox_frame, text="Lon Min:").grid(row=0, column=2, padx=5, pady=2, sticky=tk.W)
self.lon_min_var = tk.StringVar(value=str(DEFAULT_LON_MIN))
self.lon_min_entry = ttk.Entry(self.bbox_frame, textvariable=self.lon_min_var, width=10)
self.lon_min_entry.grid(row=0, column=3, padx=5, pady=2)
# Lat Max
ttk.Label(self.bbox_frame, text="Lat Max:").grid(row=1, column=0, padx=5, pady=2, sticky=tk.W)
self.lat_max_var = tk.StringVar(value=str(DEFAULT_LAT_MAX))
self.lat_max_entry = ttk.Entry(self.bbox_frame, textvariable=self.lat_max_var, width=10)
self.lat_max_entry.grid(row=1, column=1, padx=5, pady=2)
# Lon Max
ttk.Label(self.bbox_frame, text="Lon Max:").grid(row=1, column=2, padx=5, pady=2, sticky=tk.W)
self.lon_max_var = tk.StringVar(value=str(DEFAULT_LON_MAX))
self.lon_max_entry = ttk.Entry(self.bbox_frame, textvariable=self.lon_max_var, width=10)
self.lon_max_entry.grid(row=1, column=3, padx=5, pady=2)
# --- Output Area (Canvas) ---
self.output_frame = ttk.LabelFrame(self.main_frame, text="Flight Map", padding="10")
self.output_frame.pack(side=tk.TOP, fill=tk.BOTH, expand=True, pady=5)
self.flight_canvas = tk.Canvas(
self.output_frame,
bg="lightgray", # Background color for visibility
width=DEFAULT_CANVAS_WIDTH,
height=DEFAULT_CANVAS_HEIGHT
)
self.flight_canvas.pack(fill=tk.BOTH, expand=True)
# Placeholder text on canvas (can be removed or updated dynamically)
self.flight_canvas.create_text(
DEFAULT_CANVAS_WIDTH / 2,
DEFAULT_CANVAS_HEIGHT / 2,
text="Map Area - Waiting for data...",
tags="placeholder_text"
)
# --- Status Bar ---
self.status_bar_frame = ttk.Frame(self.root, padding=(5, 2))
self.status_bar_frame.pack(side=tk.BOTTOM, fill=tk.X)
self.status_label = ttk.Label(self.status_bar_frame, text="Status: Idle")
self.status_label.pack(side=tk.LEFT)
self._on_mode_change() # Initialize view based on default mode
def _on_mode_change(self):
"""Handles UI changes when the monitoring mode (Live/History) changes."""
selected_mode = self.mode_var.get()
self.update_status(f"Mode changed to {selected_mode}")
if selected_mode == "Live":
self.bbox_frame.pack(side=tk.TOP, fill=tk.X, pady=5) # Show bbox for live
self.flight_canvas.itemconfig("placeholder_text", text="Map Area - Waiting for live data...")
# Enable/disable fields as needed
self._set_bbox_entries_state(tk.NORMAL)
else: # History mode
# self.bbox_frame.pack_forget() # Hide bbox for history (or adapt for history filters)
# For now, let's keep it visible but disabled for History
self._set_bbox_entries_state(tk.DISABLED)
self.flight_canvas.itemconfig("placeholder_text", text="Map Area - Waiting for historical data...")
self.clear_canvas() # Clear canvas on mode change
def _set_bbox_entries_state(self, state):
"""Enable or disable bounding box entry fields."""
self.lat_min_entry.config(state=state)
self.lon_min_entry.config(state=state)
self.lat_max_entry.config(state=state)
self.lon_max_entry.config(state=state)
def _start_monitoring(self):
"""Starts the monitoring process based on the selected mode."""
selected_mode = self.mode_var.get()
self.update_status(f"Starting {selected_mode} monitoring...")
self.start_button.config(state=tk.DISABLED)
self.stop_button.config(state=tk.NORMAL)
self.live_radio.config(state=tk.DISABLED)
self.history_radio.config(state=tk.DISABLED)
self._set_bbox_entries_state(tk.DISABLED) # Disable bbox while running
if selected_mode == "Live":
try:
bounding_box = self.get_bounding_box()
if bounding_box:
self.controller.start_live_monitoring(bounding_box)
else:
# Error already shown by get_bounding_box
self._stop_monitoring() # Revert state
except ValueError as e:
messagebox.showerror("Input Error", str(e))
self._stop_monitoring() # Revert state if there was an error before calling controller
elif selected_mode == "History":
self.controller.start_history_monitoring()
# Placeholder for history:
self.update_status("History mode selected. Displaying sample historical data.")
self.flight_canvas.delete("placeholder_text") # Remove placeholder
# Example: Draw a dummy item for history
self.flight_canvas.create_oval(90, 90, 110, 110, fill="blue", outline="black", tags="flight_dot")
self.flight_canvas.create_text(100, 125, text="HIST_FLIGHT", tags="flight_label")
def _stop_monitoring(self):
"""Stops the monitoring process."""
self.update_status("Monitoring stopped. Status: Idle")
self.start_button.config(state=tk.NORMAL)
self.stop_button.config(state=tk.DISABLED)
self.live_radio.config(state=tk.NORMAL)
self.history_radio.config(state=tk.NORMAL)
self._set_bbox_entries_state(tk.NORMAL if self.mode_var.get() == "Live" else tk.DISABLED)
selected_mode = self.mode_var.get()
if selected_mode == "Live":
self.controller.stop_live_monitoring()
elif selected_mode == "History":
self.controller.stop_history_monitoring()
# self.clear_canvas() # Optionally clear canvas on stop
# self.flight_canvas.create_text(
# DEFAULT_CANVAS_WIDTH / 2,
# DEFAULT_CANVAS_HEIGHT / 2,
# text="Map Area - Monitoring stopped.",
# tags="placeholder_text"
# )
def get_bounding_box(self):
"""
Retrieves and validates the bounding box coordinates from the input fields.
Returns:
dict: A dictionary with 'lat_min', 'lon_min', 'lat_max', 'lon_max' if valid.
None: If validation fails.
"""
try:
lat_min = float(self.lat_min_var.get())
lon_min = float(self.lon_min_var.get())
lat_max = float(self.lat_max_var.get())
lon_max = float(self.lon_max_var.get())
if not (-90 <= lat_min <= 90 and -90 <= lat_max <= 90 and
-180 <= lon_min <= 180 and -180 <= lon_max <= 180):
messagebox.showerror("Input Error", "Invalid latitude or longitude range.")
return None
if lat_min >= lat_max:
messagebox.showerror("Input Error", "Lat Min must be less than Lat Max.")
return None
if lon_min >= lon_max:
messagebox.showerror("Input Error", "Lon Min must be less than Lon Max.")
return None
return {
"lat_min": lat_min, "lon_min": lon_min,
"lat_max": lat_max, "lon_max": lon_max
}
except ValueError:
messagebox.showerror("Input Error", "Bounding box coordinates must be valid numbers.")
return None
def display_flights_on_canvas(self, flights_data, bounding_box):
"""
Displays flight data on the canvas.
Args:
flights_data (list): A list of flight data dictionaries.
Each dictionary should have 'latitude', 'longitude',
and 'callsign' (or 'icao24').
bounding_box (dict): The bounding box used for fetching,
needed for coordinate scaling.
"""
self.clear_canvas()
if not flights_data:
self.flight_canvas.create_text(
self.flight_canvas.winfo_width() / 2,
self.flight_canvas.winfo_height() / 2,
text="No flights found in the selected area.",
tags="placeholder_text"
)
return
canvas_width = self.flight_canvas.winfo_width()
canvas_height = self.flight_canvas.winfo_height()
# If canvas hasn't been drawn yet, its dimensions might be 1. Use defaults.
if canvas_width <= 1 or canvas_height <= 1:
canvas_width = DEFAULT_CANVAS_WIDTH
canvas_height = DEFAULT_CANVAS_HEIGHT
# Schedule a redraw if we used defaults, as winfo_width/height might be updated later
self.root.after(100, lambda: self.display_flights_on_canvas(flights_data, bounding_box))
# return # Optional: wait for actual dimensions
# Unpack bounding box for easier access
lat_min = bounding_box["lat_min"]
lon_min = bounding_box["lon_min"]
lat_max = bounding_box["lat_max"]
lon_max = bounding_box["lon_max"]
# Ensure deltas are not zero to avoid division by zero
delta_lat = lat_max - lat_min
delta_lon = lon_max - lon_min
if delta_lat == 0: delta_lat = 1 # Avoid division by zero
if delta_lon == 0: delta_lon = 1 # Avoid division by zero
for flight in flights_data:
lat = flight.get("latitude")
lon = flight.get("longitude")
callsign = flight.get("callsign", flight.get("icao24", "N/A")) # Use callsign or icao24
if lat is None or lon is None:
# print(f"Skipping flight {callsign} due to missing coordinates.")
continue # Skip if no coordinates
# Basic scaling: map geographic coordinates to canvas coordinates
# X corresponds to longitude, Y corresponds to latitude
# Canvas Y is inverted (0 at top, height at bottom)
# Ensure longitude is within the bounding box to avoid extreme scaling issues if data is outside
# (though API should filter, this is a safeguard for display)
if not (lon_min <= lon <= lon_max and lat_min <= lat <= lat_max):
# print(f"Flight {callsign} ({lat}, {lon}) is outside bbox {bounding_box} - skipping display.")
continue
x_pixel = ((lon - lon_min) / delta_lon) * canvas_width
y_pixel = canvas_height - (((lat - lat_min) / delta_lat) * canvas_height)
# Draw a small circle for the aircraft
radius = 3
self.flight_canvas.create_oval(
x_pixel - radius, y_pixel - radius,
x_pixel + radius, y_pixel + radius,
fill="red", outline="black", tags="flight_dot"
)
# Optionally, display callsign
self.flight_canvas.create_text(
x_pixel, y_pixel - (radius + 5), # Position text above dot
text=callsign,
font=("Arial", 7),
tags="flight_label"
)
self.update_status(f"Displayed {len(flights_data)} flights on map.")
def clear_canvas(self):
"""Clears all items (flights, placeholder) from the canvas."""
self.flight_canvas.delete("flight_dot")
self.flight_canvas.delete("flight_label")
self.flight_canvas.delete("placeholder_text")
def update_status(self, message: str):
"""
Updates the status bar with a message.
Args:
message (str): The message to display.
"""
self.status_label.config(text=f"Status: {message}")
# print(f"Status Update: {message}") # For console logging during development
def show_error_message(self, title: str, message: str):
"""
Displays an error message box.
Args:
title (str): The title of the message box.
message (str): The error message.
"""
messagebox.showerror(title, message)
self.update_status(f"Error: {message[:50]}...") # Update status bar as well