diff --git a/icons/travel-grey.svg b/icons/travel-grey.svg
new file mode 100644
index 00000000..7c8d7cd0
--- /dev/null
+++ b/icons/travel-grey.svg
@@ -0,0 +1,34 @@
+
+
+
diff --git a/icons/travel-white.svg b/icons/travel-white.svg
new file mode 100644
index 00000000..154032ed
--- /dev/null
+++ b/icons/travel-white.svg
@@ -0,0 +1,34 @@
+
+
+
diff --git a/setup.py b/setup.py
index 89ab1399..be12da8d 100644
--- a/setup.py
+++ b/setup.py
@@ -1,17 +1,13 @@
-from setuptools import setup
-from setuptools.extension import Extension
-from Cython.Build import cythonize
+import os
import os.path
-bresenham_path = os.path.join("traffic", "algorithms", "bresenham.pyx")
-extensions = [Extension("traffic.algorithms.bresenham", [bresenham_path])]
+from setuptools import setup
setup(
name="traffic",
version=0.1,
description="A toolbox for manipulating and analysing air traffic data",
entry_points={"console_scripts": ["traffic=traffic.console:main"]},
- ext_modules=cythonize(extensions),
packages=[
"traffic",
"traffic.algorithms",
@@ -23,8 +19,15 @@
"traffic.data.so6",
"traffic.drawing",
"traffic.plugins",
+ "traffic.qtgui",
],
- package_data={"traffic.data.airspaces": ["firs.json"]},
+ package_data={
+ "traffic.data.airspaces": ["firs.json"],
+ "traffic": [
+ os.path.join("..", "icons", f)
+ for f in os.listdir(os.path.join("..", "traffic", "icons"))
+ ],
+ },
install_requires=[
"numpy",
"scipy",
@@ -40,6 +43,7 @@
"paramiko",
"tqdm>=4.26",
"cartotools==1.0",
+ "pyModeS==2.0",
],
dependency_links=[
"https://github.com/xoolive/cartotools.git#whl=cartotools-1.0"
diff --git a/traffic/algorithms/__init__.py b/traffic/algorithms/__init__.py
index 46d9c97f..918703d3 100644
--- a/traffic/algorithms/__init__.py
+++ b/traffic/algorithms/__init__.py
@@ -1,3 +1,2 @@
# flake8: noqa
-from .bresenham import bresenham, bresenham_multiply
from .douglas_peucker import douglas_peucker
diff --git a/traffic/algorithms/bresenham.pyx b/traffic/algorithms/bresenham.pyx
deleted file mode 100644
index 4651c14f..00000000
--- a/traffic/algorithms/bresenham.pyx
+++ /dev/null
@@ -1,113 +0,0 @@
-# cython: embedsignature=False
-cimport cython
-
-@cython.boundscheck(False)
-cdef int grid_increment(long x0, long y0, long x1, long y1, long mult,
- long[:, :] grid):
-
- cdef unsigned nrows, ncols
- cdef long e2, sx, sy, err
- cdef long dx, dy
-
- nrows = grid.shape[0]
- ncols = grid.shape[1]
-
- dx = x1 - x0 if x1 > x0 else x0 - x1
- dy = y1 - y0 if y1 > y0 else y0 - y1
-
- sx = 1 if x0 < x1 else -1
- sy = 1 if y0 < y1 else -1
-
- err = dx - dy
-
- while True:
- # When endpoint is 0, this test occurs before we increment the
- # grid value, so we don't count the last point.
- if x0 == x1 and y0 == y1:
- break
-
- if (0 <= x0 < nrows) and (0 <= y0 < ncols):
- grid[x0, y0] += mult
-
- if x0 == x1 and y0 == y1:
- break
-
- e2 = 2 * err
- if e2 > -dy:
- err -= dy
- x0 += sx
- if e2 < dx:
- err += dx
- y0 += sy
-
- return 0
-
-
-def bresenham(long[:, :] points, long[:, :] grid):
- """bresenham(long[:, :] points, long[:, :] grid)
- Bresenham's algorithm.
-
- - points is a memory view over a 2D numpy array (the trajectory)
- - grid is a memory view over a 2D numpy array (the grid)
-
- The algorithms adds 1 to all the cells the trajectory cross.
-
- >>> import numpy as np
- >>> points = np.array([[0, 0], [1, 11]], dtype=np.int64)
- >>> grid = np.zeros((2, 11), dtype=np.int64)
- >>> bresenham(points, grid)
- >>> grid
- array([[1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0],
- [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]])
-
- See also: http://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm
- """
-
- cdef unsigned k = 0
- cdef int x1 = 0, y1 = 0
-
- with cython.boundscheck(False):
- for k in range(points.shape[0] - 1):
- x1 = points[k+1, 0]
- y1 = points[k+1, 1]
- grid_increment(points[k, 0], points[k, 1], x1, y1, 1, grid)
-
- if 0 <= x1 < grid.shape[0] and 0 <= y1 < grid.shape[1]:
- # Count the last point in the curve.
- grid[x1, y1] += 1
-
-
-def bresenham_multiply(long[:, :] points, long[:] mult, long[:, :] grid):
- """bresenham(long[:, :] points, long[:] mult, long[:, :] grid)
- Bresenham's algorithm.
-
- - points is a memory view over a 2D numpy array (the trajectory)
- - mult is a memory view over a 1D numpy array (vertrate, bearing, etc.)
- - grid is a memory view over a 2D numpy array (the grid)
-
- The algorithms adds 1 to all the cells the trajectory cross.
-
- >>> import numpy as np
- >>> points = np.array([[0, 0], [1, 11]], dtype=np.int64)
- >>> grid = np.zeros((2, 11), dtype=np.int64)
- >>> bresenham(points, grid)
- >>> grid
- array([[1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0],
- [0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1]])
-
- See also: http://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm
- """
-
- cdef unsigned k = 0
- cdef int x1 = 0, y1 = 0
-
- with cython.boundscheck(False):
- for k in range(points.shape[0] - 1):
- x1 = points[k+1, 0]
- y1 = points[k+1, 1]
- grid_increment(points[k, 0], points[k, 1], x1, y1, mult[k], grid)
-
- if 0 <= x1 < grid.shape[0] and 0 <= y1 < grid.shape[1]:
- # Count the last point in the curve.
- grid[x1, y1] += mult[k]
-
diff --git a/traffic/console.py b/traffic/console.py
index 272bbfe6..a6464c6e 100644
--- a/traffic/console.py
+++ b/traffic/console.py
@@ -2,23 +2,21 @@
import argparse
from pathlib import Path
-from .data import airports as data_airports
-from .data import navaids as data_navaids
-from .data import aircraft as data_aircraft
-from .data.adsb.decode import Decoder
-
def get_airports(*args):
+ from .data import airports as data_airports
for airport in data_airports.search(args[0]):
print(airport)
def get_navaids(*args):
+ from .data import navaids as data_navaids
for navaid in data_navaids.search(args[0]):
print(navaid)
def get_aircraft(*args):
+ from .data import aircraft as data_aircraft
if args[0] == "get":
print(data_aircraft[args[1]])
elif args[0] == "operator":
@@ -30,6 +28,7 @@ def get_aircraft(*args):
def decode(*args):
+ from .data.adsb.decode import Decoder
parser = argparse.ArgumentParser()
parser.add_argument("file", help="path to the file to decode", type=Path)
@@ -41,15 +40,26 @@ def decode(*args):
args = parser.parse_args(args)
decoder = Decoder.from_file(args.file, args.reference)
decoder.traffic.to_pickle(
- args.output if args.output is not None else args.file.with_suffix(".pkl")
+ args.output
+ if args.output is not None
+ else args.file.with_suffix(".pkl")
)
+def launch_gui(*args):
+ from traffic.qtgui import layout
+ layout.main()
+
+def config(*args):
+ from . import edit_config
+ edit_config()
cmd = {
"airport": get_airports,
"navaid": get_navaids,
"aircraft": get_aircraft,
"decode": decode,
+ 'config': config,
+ 'gui': launch_gui,
}
diff --git a/traffic/core/traffic.py b/traffic/core/traffic.py
index d272face..4c889ab9 100644
--- a/traffic/core/traffic.py
+++ b/traffic/core/traffic.py
@@ -121,6 +121,9 @@ def _repr_html_(self) -> str:
rep = f"Traffic with {shape} identifiers"
return rep + styler._repr_html_()
+ def subset(self, callsigns: Iterable[str]) -> "Traffic":
+ return Traffic.from_flights(f for f in self if f.callsign in callsigns)
+
# --- Properties ---
@property
@@ -159,7 +162,9 @@ def at(self, time: timelike) -> "Traffic":
if flight.start <= time <= flight.stop
]
return Traffic(
- pd.DataFrame.from_records([s for s in list_flights if s is not None]).assign(
+ pd.DataFrame.from_records(
+ [s for s in list_flights if s is not None]
+ ).assign(
# attribute 'name' refers to the index, i.e. 'timestamp'
timestamp=[s.name for s in list_flights if s is not None]
)
diff --git a/traffic/qtgui/__init__.py b/traffic/qtgui/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/traffic/qtgui/layout.py b/traffic/qtgui/layout.py
new file mode 100644
index 00000000..81a9f3f4
--- /dev/null
+++ b/traffic/qtgui/layout.py
@@ -0,0 +1,649 @@
+# fmt: off
+
+import logging
+import os
+import re
+import sys
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import Optional, Set, cast
+
+from PyQt5 import QtCore, QtGui
+from PyQt5.QtWidgets import (QApplication, QComboBox, QFileDialog, QGridLayout,
+ QHBoxLayout, QInputDialog, QLabel, QLineEdit,
+ QListWidget, QMainWindow, QMessageBox,
+ QPushButton, QSlider, QSystemTrayIcon, QTabWidget,
+ QVBoxLayout, QWidget)
+
+from cartopy.crs import PlateCarree
+
+from .. import config
+from ..core import Traffic, Flight
+from ..data import ModeS_Decoder, airac
+from ..drawing import location
+from .plot import MapCanvas, NavigationToolbar2QT, TimeCanvas
+
+# fmt: on
+
+
+def dont_crash(fn):
+ """
+ Wraps callbacks: a simple information is raised in place of a program crash.
+ """
+
+ def safe_exec(self, *args, **kwargs):
+ try:
+ return fn(self, *args, **kwargs)
+ except Exception as e:
+ logging.exception(e)
+ QMessageBox.information(
+ self, type(e).__name__, " ".join(str(x) for x in e.args)
+ )
+
+ return safe_exec
+
+
+class UpdateTraffic(QtCore.QThread):
+ """
+ UpdateTraffic periodically checks the content of a decoded traffic.
+ """
+
+ def __init__(self, parent: "MainScreen", refresh_time: int) -> None:
+ super().__init__()
+ self.parent = parent
+ self.refresh_time = refresh_time
+
+ def run(self):
+ while True:
+ delta = datetime.now() - self.parent.last_interact
+ # do not overreact, stay minimalist!!
+ if delta.total_seconds() > self.refresh_time:
+ self.parent._tview = self.parent.traffic
+ if self.parent._tview is not None:
+ self.parent.map_plot.default_plot(self.parent._tview)
+ self.parent.map_plot.draw()
+ time.sleep(1)
+
+ def __del__(self):
+ self.thread.quit()
+ while self.thread.isRunning():
+ time.sleep(1)
+
+
+class AiracInitCache(QtCore.QThread):
+ """Initialize cache in background to avoid lag.
+ """
+
+ def __init__(self, parent):
+ super().__init__()
+ self.parent = parent
+
+ def run(self):
+ try:
+ airac.init_cache()
+ self.parent.airac_ready = True
+ except Exception:
+ pass
+
+
+class MainScreen(QMainWindow):
+ """The Main GUI layout and callbacks.
+ """
+
+ def __init__(self) -> None:
+
+ logging.info("Initialize MainScreen")
+ super().__init__(parent=None)
+
+ self._traffic: Optional[Traffic] = None
+ self._tview: Optional[Traffic] = None
+ self.decoder: Optional[ModeS_Decoder] = None
+ self.updateTraffic: Optional[UpdateTraffic] = None
+ self.airac_ready: bool = False
+ self.last_interact: datetime = datetime.now()
+
+ airac_init = AiracInitCache(self)
+ airac_init.start()
+
+ self.setWindowTitle("traffic")
+ self.setGeometry(10, 10, 920, 720)
+
+ self.set_icons()
+ self.set_layout()
+ self.set_design()
+ self.set_callbacks()
+
+ def __del__(self) -> None:
+ if self.updateTraffic is not None:
+ self.updateTraffic.terminate()
+ if self.decoder is not None:
+ self.decoder.stop()
+
+ @property
+ def traffic(self) -> Optional[Traffic]:
+ self.last_interact = datetime.now()
+ if self.decoder is not None:
+ self._traffic = self.decoder.traffic
+ if self._traffic is None:
+ return None
+ self.set_time_range()
+ # self.set_float_columns()
+ # max_alt = 100 * self.altitude_slider.value()
+ # max_time = self.dates[self.time_slider.value()]
+ # self.on_filter(max_alt, max_time)
+ return self._traffic
+
+ def set_callbacks(self) -> None:
+ self.airport_button.clicked.connect(self.on_plot_airport)
+ self.altitude_slider.sliderMoved.connect(self.on_altitude_moved)
+ self.altitude_slider.sliderReleased.connect(self.on_select)
+ self.area_input.textEdited.connect(self.on_area_input)
+ self.area_select.itemSelectionChanged.connect(self.on_area_select)
+ self.extent_button.clicked.connect(self.on_extent_button)
+ self.identifier_input.textEdited.connect(self.on_id_input)
+ self.identifier_select.itemSelectionChanged.connect(self.on_id_change)
+ self.open_dropdown.activated.connect(self.on_open)
+ self.plot_button.clicked.connect(self.on_plot_button)
+ self.projection_dropdown.activated.connect(self.make_map)
+ self.reset_button.clicked.connect(self.on_clear_button)
+ self.time_slider.sliderMoved.connect(self.on_time_moved)
+ self.time_slider.sliderReleased.connect(self.on_select)
+ self.y_selector.itemSelectionChanged.connect(self.on_id_change)
+ self.sec_y_selector.itemSelectionChanged.connect(self.on_id_change)
+
+ def set_design(self) -> None:
+ self.open_dropdown.setMaximumWidth(400)
+ self.projection_dropdown.setMaximumWidth(400)
+ self.altitude_description.setMinimumWidth(100)
+ self.altitude_description.setMaximumWidth(100)
+ self.altitude_slider_info.setMinimumWidth(50)
+ self.altitude_slider_info.setMaximumWidth(50)
+ self.altitude_slider.setMaximumWidth(240)
+ self.area_input_description.setMinimumWidth(100)
+ self.area_input_description.setMaximumWidth(100)
+ self.area_input.setMaximumWidth(290)
+ self.area_select.setMaximumHeight(100)
+ self.area_select.setMaximumWidth(400)
+ self.identifier_description.setMinimumWidth(100)
+ self.identifier_description.setMaximumWidth(100)
+ self.identifier_input.setMaximumWidth(290)
+ self.identifier_select.setMaximumWidth(400)
+ self.time_description.setMinimumWidth(100)
+ self.time_description.setMaximumWidth(100)
+ self.time_slider_info.setMinimumWidth(50)
+ self.time_slider_info.setMaximumWidth(50)
+ self.time_slider.setMaximumWidth(240)
+ self.y_selector.setMaximumHeight(100)
+ self.sec_y_selector.setMaximumHeight(100)
+
+ def set_layout(self) -> None:
+
+ self.plot_tabs = QTabWidget()
+ map_tab = QWidget()
+ map_layout = QVBoxLayout()
+ map_tab.setLayout(map_layout)
+
+ self.map_plot = MapCanvas(parent=self, width=5, height=4)
+ self.map_plot.move(0, 0)
+ self.time_plot = TimeCanvas(parent=self, width=5, height=4)
+ self.time_plot.move(0, 0)
+
+ map_toolbar = NavigationToolbar2QT(self.map_plot, map_tab)
+ map_toolbar.setVisible(False)
+ map_layout.addWidget(map_toolbar)
+ map_layout.addWidget(self.map_plot)
+ map_toolbar.pan()
+
+ time_tab = QWidget()
+ time_layout = QVBoxLayout()
+ time_tab.setLayout(time_layout)
+
+ self.y_selector = QListWidget()
+ self.sec_y_selector = QListWidget()
+ self.y_selector.setSelectionMode(3) # extended selection
+ self.sec_y_selector.setSelectionMode(3) # extended selection
+ selector = QHBoxLayout()
+ selector.addWidget(self.y_selector)
+ selector.addWidget(self.sec_y_selector)
+
+ time_layout.addLayout(selector)
+ time_layout.addWidget(self.time_plot)
+
+ self.plot_tabs.addTab(map_tab, "Map")
+ self.plot_tabs.addTab(time_tab, "Plots")
+
+ plot_column = QVBoxLayout()
+ plot_column.addWidget(self.plot_tabs)
+
+ self.interact_column = QVBoxLayout()
+
+ self.open_options = ["Open file", "dump1090"]
+ if "decoders" in config:
+ self.open_options += list(config["decoders"])
+ self.open_dropdown = QComboBox()
+ for option in self.open_options:
+ self.open_dropdown.addItem(option)
+ self.interact_column.addWidget(self.open_dropdown)
+
+ self.projections = ["EuroPP", "Lambert93", "Mercator"]
+ self.projection_dropdown = QComboBox()
+ more_projs = config.get("projections", "extra", fallback="")
+ if more_projs != "":
+ proj_list = more_projs.split(";")
+ self.projections += list(x.strip() for x in proj_list)
+ for proj in self.projections:
+ self.projection_dropdown.addItem(proj)
+ self.interact_column.addWidget(self.projection_dropdown)
+
+ button_grid = QGridLayout()
+
+ self.extent_button = QPushButton("Extent")
+ button_grid.addWidget(self.extent_button, 0, 0)
+ self.plot_button = QPushButton("Plot")
+ button_grid.addWidget(self.plot_button, 0, 1)
+ self.airport_button = QPushButton("Airport")
+ button_grid.addWidget(self.airport_button, 1, 0)
+ self.reset_button = QPushButton("Reset")
+ button_grid.addWidget(self.reset_button, 1, 1)
+
+ self.interact_column.addLayout(button_grid)
+
+ self.area_input_description = QLabel("Area")
+ self.area_input = QLineEdit()
+ area_input_layout = QHBoxLayout()
+ area_input_layout.addWidget(self.area_input_description)
+ area_input_layout.addWidget(self.area_input)
+ self.interact_column.addLayout(area_input_layout)
+
+ self.area_select = QListWidget()
+ self.interact_column.addWidget(self.area_select)
+
+ self.time_slider = QSlider(QtCore.Qt.Horizontal)
+ self.time_description = QLabel("Date max.")
+ self.time_slider_info = QLabel()
+ time_layout = QHBoxLayout()
+ time_layout.addWidget(self.time_description)
+ time_layout.addWidget(self.time_slider)
+ time_layout.addWidget(self.time_slider_info)
+ self.time_slider.setMinimum(0)
+ self.time_slider.setMaximum(99)
+ self.time_slider.setValue(99)
+ self.time_slider.setEnabled(False)
+ self.interact_column.addLayout(time_layout)
+
+ self.altitude_slider = QSlider(QtCore.Qt.Horizontal)
+ self.altitude_description = QLabel("Altitude max.")
+ self.altitude_slider_info = QLabel("60000")
+ self.altitude_slider.setSingleStep(5)
+ self.altitude_slider.setPageStep(100)
+ self.altitude_slider.setMinimum(0)
+ self.altitude_slider.setMaximum(600)
+ self.altitude_slider.setValue(600)
+ altitude_layout = QHBoxLayout()
+ altitude_layout.addWidget(self.altitude_description)
+ altitude_layout.addWidget(self.altitude_slider)
+ altitude_layout.addWidget(self.altitude_slider_info)
+ self.interact_column.addLayout(altitude_layout)
+
+ self.identifier_description = QLabel("Callsign/ID")
+ self.identifier_input = QLineEdit()
+
+ identifier_layout = QHBoxLayout()
+ identifier_layout.addWidget(self.identifier_description)
+ identifier_layout.addWidget(self.identifier_input)
+ self.interact_column.addLayout(identifier_layout)
+
+ self.identifier_select = QListWidget()
+ self.identifier_select.setSelectionMode(3) # extended selection
+ self.interact_column.addWidget(self.identifier_select)
+
+ mainLayout = QGridLayout()
+ mainLayout.addLayout(plot_column, 0, 0)
+ mainLayout.addLayout(self.interact_column, 0, 1)
+
+ mainWidget = QWidget()
+ mainWidget.setLayout(mainLayout)
+ self.setCentralWidget(mainWidget)
+
+ # -- Callbacks --
+
+ @dont_crash
+ def on_time_moved(self, value: int, *args, **kwargs) -> None:
+ self.last_interact = datetime.now()
+ self.time_slider_info.setText(self.date_options[value])
+
+ @dont_crash
+ def on_altitude_moved(self, value: int, *args, **kwargs) -> None:
+ self.last_interact = datetime.now()
+ self.altitude_slider_info.setText(f"{100*value}")
+
+ @dont_crash
+ def on_select(self, *args, **kwargs) -> None:
+ self.last_interact = datetime.now()
+ if self.traffic is not None:
+ max_alt = 100 * self.altitude_slider.value()
+ max_time = self.dates[self.time_slider.value()]
+ self.on_filter(max_alt, max_time)
+
+ @dont_crash
+ def on_filter(self, max_alt: int, max_time: datetime) -> None:
+
+ assert self._traffic is not None
+
+ west, east, south, north = self.map_plot.ax.get_extent(
+ crs=PlateCarree()
+ )
+
+ self._tview = self._traffic.before(max_time).sort_values("timestamp")
+
+ if self._tview is None:
+ return
+
+ filtered = Traffic.from_flights(
+ Flight(f.data.ffill().bfill()) for f in self._tview
+ )
+ if "altitude" in filtered.data.columns:
+ filtered = filtered.query(
+ f"altitude != altitude or altitude <= {max_alt}"
+ )
+ if "latitude" in self._tview.data.columns:
+ filtered = filtered.query(
+ "latitude != latitude or "
+ f"({west} <= longitude <= {east} and "
+ f"{south} <= latitude <= {north})"
+ )
+
+ self.identifier_select.clear()
+ text = self.identifier_input.text()
+ # cast is necessary because of the @lru_cache on callsigns which hides
+ # the type annotation
+ for callsign in sorted(cast(Set[str], filtered.callsigns)):
+ if re.match(text, callsign, flags=re.IGNORECASE):
+ self.identifier_select.addItem(callsign)
+ self.map_plot.default_plot(self._tview.subset(filtered.callsigns))
+ self.set_float_columns()
+
+ @dont_crash
+ def on_extent_button(self, *args, **kwargs) -> None:
+ self.last_interact = datetime.now()
+ if self.area_select.count() == 0:
+ if len(self.area_input.text()) == 0:
+ self.map_plot.ax.set_global()
+ else:
+ self.map_plot.ax.set_extent(location(self.area_input.text()))
+ else:
+ if self.airac_ready:
+ self.map_plot.ax.set_extent(
+ airac[self.area_select.item(0).text()]
+ )
+
+ self.map_plot.draw()
+
+ if self.traffic is not None:
+ max_alt = 100 * self.altitude_slider.value()
+ max_time = self.dates[self.time_slider.value()]
+ self.on_filter(max_alt, max_time)
+
+ @dont_crash
+ def on_id_change(self, *args, **kwargs) -> None:
+ assert self._tview is not None
+ self.last_interact = datetime.now()
+
+ list_callsigns = list(
+ item.text() for item in self.identifier_select.selectedItems()
+ )
+ selected_y = list(
+ item.text() for item in self.y_selector.selectedItems()
+ )
+ selected_sec_y = list(
+ item.text() for item in self.sec_y_selector.selectedItems()
+ )
+ self.map_plot.plot_callsigns(self._tview, list_callsigns)
+ self.time_plot.create_plot()
+ self.time_plot.plot_callsigns(
+ self._tview,
+ list_callsigns,
+ y=selected_y + selected_sec_y,
+ secondary_y=selected_sec_y,
+ )
+
+ @dont_crash
+ def on_id_input(self, text, *args, **kwargs) -> None:
+ assert self._tview is not None
+ self.last_interact = datetime.now()
+ # segfault prone when interactive (decoder)
+ # selected = list(
+ # item.text() for item in self.identifier_select.selectedItems()
+ # )
+ self.identifier_select.clear()
+ for callsign in sorted(cast(Set[str], self._tview.callsigns)):
+ if re.match(text, callsign, flags=re.IGNORECASE):
+ self.identifier_select.addItem(callsign)
+ # if callsign in selected:
+ # curItem = self.identifier_select.item(
+ # self.identifier_select.count() - 1
+ # )
+ # self.identifier_select.setItemSelected(curItem, True)
+
+ @dont_crash
+ def on_plot_button(self, *args, **kwargs) -> None:
+ assert self._tview is not None
+ self.last_interact = datetime.now()
+ if self.area_select.count() == 0:
+ if len(self.area_input.text()) == 0:
+ self.map_plot.default_plot(self._tview)
+ else:
+ location(self.area_input.text()).plot(
+ self.map_plot.ax, color="grey", linestyle="dashed"
+ )
+ else:
+ if self.airac_ready:
+ selected = self.area_select.selectedItems()
+ if len(selected) == 0:
+ return
+ airspace = airac[selected[0].text()]
+ if airspace is not None:
+ airspace.plot(self.map_plot.ax)
+
+ self.map_plot.draw()
+
+ @dont_crash
+ def on_area_input(self, text: str, *args, **kwargs) -> None:
+ self.last_interact = datetime.now()
+ self.area_select.clear()
+ if len(text) > 0 and self.airac_ready:
+ for airspace_info in airac.parse(text):
+ self.area_select.addItem(airspace_info.name)
+
+ @dont_crash
+ def on_area_select(self, *args, **kwargs) -> None:
+ self.last_interact = datetime.now()
+ selected = self.area_select.selectedItems()
+ if len(selected) == 0:
+ return
+ if self.airac_ready:
+ airspace = airac[selected[0].text()]
+ if airspace is not None:
+ self.map_plot.ax.set_extent(airspace)
+ self.map_plot.draw()
+
+ @dont_crash
+ def on_plot_airport(self, *args, **kwargs) -> None:
+ self.last_interact = datetime.now()
+ if len(self.area_input.text()) == 0:
+ from cartotools.osm import request, tags
+
+ west, east, south, north = self.map_plot.ax.get_extent(
+ crs=PlateCarree()
+ )
+ if abs(east - west) > 1 or abs(north - south) > 1:
+ # that would be a too big request
+ return
+ request((west, south, east, north), **tags.airport).plot(
+ self.map_plot.ax
+ )
+ else:
+ from traffic.data import airports
+
+ airport = airports[self.area_input.text()]
+ if airport is not None:
+ airport.plot(self.map_plot.ax)
+ self.map_plot.draw()
+
+ @dont_crash
+ def on_clear_button(self, *args, **kwargs) -> None:
+ self.last_interact = datetime.now()
+ if self.traffic is not None:
+ assert self._traffic is not None
+ self._tview = self._traffic.sort_values("timestamp")
+ self.altitude_slider.setValue(600)
+ self.make_map(self.projection_dropdown.currentIndex())
+ self.time_plot.create_plot()
+ self.set_float_columns()
+
+ @dont_crash
+ def make_map(self, index_projection: int, *args, **kwargs) -> None:
+ self.last_interact = datetime.now()
+ self.map_plot.create_map(self.projections[index_projection])
+ if self._tview is not None:
+ self.map_plot.default_plot(self._tview)
+
+ @dont_crash
+ def on_open(self, index: int, *args, **kwargs) -> None:
+ if self.decoder is not None and self.updateTraffic is not None:
+ self.updateTraffic.terminate()
+ self.decoder.stop()
+ if index == 0:
+ self.openFile()
+ elif index == 1:
+ self.openDump1090()
+ else:
+ address = config.get("decoders", self.open_options[index])
+ host_port, reference = address.split("/")
+ host, port = host_port.split(":")
+ self.decoder = ModeS_Decoder.from_address(
+ host, int(port), reference
+ )
+ refresh_time = config.getint(
+ "decoders", "refresh_time", fallback=30
+ )
+ self.updateTraffic = UpdateTraffic(self, refresh_time)
+ self.updateTraffic.start()
+
+ # -- Basic setters --
+
+ def set_icons(self) -> None:
+
+ logging.info("Setting options")
+ icon_path = Path(__file__).absolute().parent.parent.parent / "icons"
+
+ if sys.platform == "linux":
+ # icon_mini = QtGui.QIcon(
+ # (icon_path / "travel-mini-white.svg").as_posix()
+ # )
+ icon_full = QtGui.QIcon((icon_path / "travel-white.svg").as_posix())
+ else:
+ # icon_mini = QtGui.QIcon(
+ # (icon_path / "travel-mini-grey.svg").as_posix()
+ # )
+ icon_full = QtGui.QIcon((icon_path / "travel-grey.svg").as_posix())
+
+ self.setWindowIcon(icon_full)
+
+ # self.trayIcon = QSystemTrayIcon(icon_mini, self)
+ # not useful yet...
+ # self.trayIcon.show()
+
+ def set_time_range(self) -> None:
+ assert self._traffic is not None
+ self.time_slider.setEnabled(True)
+ self.dates = [
+ self._traffic.start_time
+ + i * (self._traffic.end_time - self._traffic.start_time) / 99
+ for i in range(100)
+ ]
+
+ tz_now = datetime.now().astimezone().tzinfo
+ if self._traffic.start_time.tzinfo is not None:
+ self.date_options = [
+ t.tz_convert("utc").strftime("%H:%M") for t in self.dates
+ ]
+ else:
+ self.date_options = [
+ t.tz_localize(tz_now).tz_convert("utc").strftime("%H:%M")
+ for t in self.dates
+ ]
+ self.time_slider_info.setText(self.date_options[-1])
+
+ def set_float_columns(self) -> None:
+ assert self._traffic is not None
+ self.y_selector.clear()
+ self.sec_y_selector.clear()
+ for column, dtype in self._traffic.data.dtypes.items():
+ if column not in ("latitude", "longitude"):
+ if dtype in ["float64", "int64"]:
+ self.y_selector.addItem(column)
+ self.sec_y_selector.addItem(column)
+
+ def openDump1090(self) -> None:
+ reference, ok = QInputDialog.getText(
+ self, "dump1090 reference", "Reference airport:"
+ )
+
+ if ok:
+ self.open_dropdown.setItemText(1, f"dump1090 ({reference})")
+ self.decoder = ModeS_Decoder.from_dump1090(reference)
+ refresh_time = config.getint(
+ "decoders", "refresh_time", fallback=30
+ )
+ self.updateTraffic = UpdateTraffic(self, refresh_time)
+ self.updateTraffic.start()
+
+ @dont_crash
+ def openFile(self, *args, **kwargs) -> None:
+ options = {
+ "caption": "Open file",
+ "filter": (
+ "Pandas DataFrame (*.pkl);;"
+ "CSV files (*.csv);;"
+ "Sqlite3 files (*.db)"
+ ),
+ # "filter": "Data files (*.csv *.pkl)",
+ "directory": os.path.expanduser("~"),
+ }
+
+ self.filename = QFileDialog.getOpenFileName(self, **options)[0]
+ if self.filename == "":
+ return
+ self.filename = Path(self.filename)
+ self._traffic = Traffic.from_file(self.filename)
+
+ assert self._traffic is not None
+ self._tview = self._traffic.sort_values("timestamp")
+ assert self._tview is not None
+ self.open_dropdown.setItemText(0, self.filename.name)
+ self.map_plot.default_plot(self._tview)
+
+ self.identifier_select.clear()
+ for callsign in sorted(cast(Set[str], self._tview.callsigns)):
+ self.identifier_select.addItem(callsign)
+
+ self.set_time_range()
+ self.set_float_columns()
+
+
+def main():
+
+ if sys.platform == "win32":
+ # This lets you keep your custom icon in the Windows taskbar
+ import ctypes
+
+ myappid = "org.xoolive.traffic"
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
+
+ app = QApplication(sys.argv)
+ main = MainScreen()
+ main.show()
+
+ return app.exec_()
diff --git a/traffic/qtgui/plot.py b/traffic/qtgui/plot.py
new file mode 100644
index 00000000..0f789511
--- /dev/null
+++ b/traffic/qtgui/plot.py
@@ -0,0 +1,270 @@
+# fmt: off
+
+import logging
+import sys
+from collections import defaultdict
+from threading import Lock
+from typing import Dict, List, Union
+
+import matplotlib.pyplot as plt
+from matplotlib.artist import Artist
+from matplotlib.backends.backend_qt5agg import (FigureCanvasQTAgg,
+ NavigationToolbar2QT)
+from matplotlib.figure import Figure
+from PyQt5.QtWidgets import QSizePolicy
+
+from cartopy.crs import PlateCarree, Projection
+
+from ..core import Traffic
+from ..drawing import * # noqa: F401, F403, type: ignore
+from ..drawing import countries, rivers
+
+# fmt: on
+
+
+class NavigationToolbar(NavigationToolbar2QT):
+ """Emulates a toolbar but do not display it."""
+
+ def set_message(self, msg):
+ pass
+
+
+class TimeCanvas(FigureCanvasQTAgg):
+ """Plotting info with UTC timestamp on the x-axis."""
+
+ def __init__(self, parent=None, width=5, height=4, dpi=100):
+
+ logging.info("Initialize TimeCanvas")
+ self.fig = Figure(figsize=(width, height), dpi=dpi)
+ self.main = parent
+ self.trajectories: Dict[str, List[Artist]] = defaultdict(list)
+
+ self.lock = Lock()
+
+ FigureCanvasQTAgg.__init__(self, self.fig)
+ self.setParent(parent)
+ FigureCanvasQTAgg.setSizePolicy(
+ self, QSizePolicy.Expanding, QSizePolicy.Expanding
+ )
+ FigureCanvasQTAgg.updateGeometry(self)
+
+ self.create_plot()
+
+ def create_plot(self):
+ with plt.style.context("traffic"):
+ self.fig.clear()
+ self.ax = self.fig.add_subplot(111)
+ self.fig.set_tight_layout(True)
+
+ def plot_callsigns(
+ self,
+ traffic: Traffic,
+ callsigns: List[str],
+ y: List[str],
+ secondary_y: List[str],
+ ) -> None:
+
+ if len(y) == 0:
+ y = ["altitude"]
+
+ extra_dict = dict()
+
+ if len(y) > 1:
+ # just to avoid confusion...
+ callsigns = callsigns[:1]
+
+ for key, value in self.trajectories.items():
+ for elt in value:
+ elt.remove()
+ self.trajectories.clear()
+
+ for callsign in callsigns:
+ flight = traffic[callsign]
+ if len(y) == 1:
+ extra_dict["label"] = callsign
+ if flight is not None:
+ try:
+ flight.plot_time(
+ self.ax, y=y, secondary_y=secondary_y, **extra_dict
+ )
+ except Exception: # no numeric data to plot
+ pass
+
+ if len(callsigns) > 1:
+ self.ax.legend()
+
+ for elt in self.ax.get_xticklabels():
+ elt.set_size(12)
+ for elt in self.ax.get_yticklabels():
+ elt.set_size(12)
+ self.ax.set_xlabel("")
+
+ if len(callsigns) > 0:
+ low, up = self.ax.get_ylim()
+ if (up - low) / up < 0.05:
+ self.ax.set_ylim(up - .05 * up, up + .05 * up)
+
+ self.draw()
+
+ def draw(self):
+ with self.lock:
+ if self.fig is None:
+ return
+ super().draw()
+
+
+class MapCanvas(FigureCanvasQTAgg):
+ """Plotting maps."""
+
+ def __init__(self, parent=None, width=5, height=4, dpi=100):
+
+ logging.info("Initialize MapCanvas")
+ self.trajectories = defaultdict(list)
+
+ self.fig = Figure(figsize=(width, height), dpi=dpi)
+ self.main = parent
+ self.lock = Lock()
+
+ FigureCanvasQTAgg.__init__(self, self.fig)
+ self.setParent(parent)
+ FigureCanvasQTAgg.setSizePolicy(
+ self, QSizePolicy.Expanding, QSizePolicy.Expanding
+ )
+ FigureCanvasQTAgg.updateGeometry(self)
+ self.create_map()
+
+ def wheelEvent(self, event):
+ if sys.platform == "darwin": # rather use pinch
+ return
+ self.zoom(event.angleDelta().y() > 0, 0.8)
+
+ def zoom(self, zoom_in, factor):
+ min_x, max_x, min_y, max_y = self.ax.axis()
+ if not zoom_in:
+ factor = 1.0 / factor
+
+ center_x = .5 * (max_x + min_x)
+ delta_x = .5 * (max_x - min_x)
+ center_y = .5 * (max_y + min_y)
+ delta_y = .5 * (max_y - min_y)
+
+ self.ax.axis(
+ (
+ center_x - factor * delta_x,
+ center_x + factor * delta_x,
+ center_y - factor * delta_y,
+ center_y + factor * delta_y,
+ )
+ )
+
+ self.fig.tight_layout()
+ self.lims = self.ax.axis()
+ fmt = ", ".join("{:.5e}".format(t) for t in self.lims)
+ logging.info("Zooming to {}".format(fmt))
+ self.draw()
+
+ def create_map(
+ self, projection: Union[str, Projection] = "EuroPP()" # type: ignore
+ ) -> None:
+ if isinstance(projection, str):
+ if not projection.endswith(")"):
+ projection = projection + "()"
+ projection = eval(projection)
+
+ self.projection = projection
+ self.trajectories.clear()
+
+ with plt.style.context("traffic"):
+
+ self.fig.clear()
+ self.ax = self.fig.add_subplot(111, projection=self.projection)
+ projection_name = projection.__class__.__name__.split(".")[-1]
+
+ self.ax.add_feature(
+ countries(
+ scale="10m"
+ if projection_name not in ["Mercator", "Orthographic"]
+ else "110m"
+ )
+ )
+ if projection_name in ["Lambert93", "GaussKruger", "Amersfoort"]:
+ self.ax.add_feature(rivers())
+
+ self.fig.set_tight_layout(True)
+ self.ax.background_patch.set_visible(False)
+ self.ax.outline_patch.set_visible(False)
+ self.ax.format_coord = lambda x, y: ""
+ self.ax.set_global()
+
+ self.draw()
+
+ def plot_callsigns(self, traffic: Traffic, callsigns: List[str]) -> None:
+ if traffic is None:
+ return
+
+ for key, value in self.trajectories.items():
+ for elt in value:
+ elt.remove()
+ self.trajectories.clear()
+ self.ax.set_prop_cycle(None)
+
+ for c in callsigns:
+ f = traffic[c]
+ if f is not None:
+ try:
+ self.trajectories[c] += f.plot(self.ax)
+ f_at = f.at()
+ if (
+ f_at is not None
+ and hasattr(f_at, "latitude")
+ and f_at.latitude == f_at.latitude
+ ):
+ self.trajectories[c] += f_at.plot(
+ self.ax, s=8, text_kw=dict(s=c)
+ )
+ except TypeError: # NoneType object is not iterable
+ pass
+
+ if len(callsigns) == 0:
+ self.default_plot(traffic)
+
+ self.draw()
+
+ def default_plot(self, traffic: Traffic) -> None:
+ if traffic is None:
+ return
+ # clear all trajectory pieces
+ for key, value in self.trajectories.items():
+ for elt in value:
+ elt.remove()
+ self.trajectories.clear()
+
+ lon_min, lon_max, lat_min, lat_max = self.ax.get_extent(PlateCarree())
+ cur_ats = list(f.at() for f in traffic)
+ cur_flights = list(
+ at
+ for at in cur_ats
+ if at is not None
+ if hasattr(at, "latitude")
+ and at.latitude is not None
+ and lat_min <= at.latitude <= lat_max
+ and lon_min <= at.longitude <= lon_max
+ )
+
+ def params(at):
+ if len(cur_flights) < 10:
+ return dict(s=8, text_kw=dict(s=at.callsign))
+ else:
+ return dict(s=8, text_kw=dict(s=""))
+
+ for at in cur_flights:
+ if at is not None:
+ self.trajectories[at.callsign] += at.plot(self.ax, **params(at))
+
+ self.draw()
+
+ def draw(self):
+ with self.lock:
+ if self.fig is None:
+ return
+ super().draw()