From 50a5735140478e1add70414776073ca70d407986 Mon Sep 17 00:00:00 2001 From: onur cayan Date: Fri, 14 Aug 2026 13:12:54 +0300 Subject: [PATCH] Organize Linux and macOS as separate applications --- .github/workflows/build.yml | 29 +- README.md | 307 +-- linux/CMakeLists.txt | 119 ++ linux/README.md | 236 +++ .../resources}/ankasys-logo.png | Bin {resources => linux/resources}/land-110m.json | 0 linux/run.sh | 55 + setup-ubuntu.sh => linux/setup-ubuntu.sh | 0 linux/src/GlobeWidget.cpp | 870 +++++++++ linux/src/GlobeWidget.h | 84 + {src => linux/src}/LocationParser.cpp | 0 {src => linux/src}/LocationParser.h | 0 linux/src/MainWindow.cpp | 1642 +++++++++++++++++ linux/src/MainWindow.h | 128 ++ linux/src/MapWidget.cpp | 727 ++++++++ linux/src/MapWidget.h | 93 + linux/src/RinexNavigation.cpp | 374 ++++ {src => linux/src}/RinexNavigation.h | 0 {src => linux/src}/SatelliteData.h | 0 {src => linux/src}/SerialReader.cpp | 0 {src => linux/src}/SerialReader.h | 0 {src => linux/src}/TelemetryPoint.h | 0 {src => linux/src}/UartReplay.cpp | 0 {src => linux/src}/UartReplay.h | 0 {src => linux/src}/main.cpp | 0 linux/tests/main_window_tests.cpp | 272 +++ {tests => linux/tests}/parser_tests.cpp | 0 {tests => linux/tests}/uart_replay_tests.cpp | 0 CMakeLists.txt => macos/CMakeLists.txt | 11 +- macos/README.md | 242 +++ macos/resources/ankasys-logo.png | Bin 0 -> 24036 bytes .../resources}/bluemarble-2048.png | Bin macos/resources/land-110m.json | 1 + macos/run.sh | 40 + setup-macos.sh => macos/setup-macos.sh | 4 +- {src => macos/src}/GlobeWidget.cpp | 0 {src => macos/src}/GlobeWidget.h | 0 macos/src/LocationParser.cpp | 283 +++ macos/src/LocationParser.h | 17 + {src => macos/src}/MainWindow.cpp | 0 {src => macos/src}/MainWindow.h | 0 {src => macos/src}/MapWidget.cpp | 0 {src => macos/src}/MapWidget.h | 0 {src => macos/src}/RinexNavigation.cpp | 0 macos/src/RinexNavigation.h | 74 + macos/src/SatelliteData.h | 11 + macos/src/SerialReader.cpp | 302 +++ macos/src/SerialReader.h | 44 + macos/src/TelemetryPoint.h | 88 + macos/src/UartReplay.cpp | 207 +++ macos/src/UartReplay.h | 54 + macos/src/main.cpp | 19 + {tests => macos/tests}/main_window_tests.cpp | 0 {tests => macos/tests}/map_widget_tests.cpp | 0 macos/tests/parser_tests.cpp | 317 ++++ macos/tests/uart_replay_tests.cpp | 70 + run.sh | 67 - 57 files changed, 6446 insertions(+), 341 deletions(-) create mode 100644 linux/CMakeLists.txt create mode 100644 linux/README.md rename {resources => linux/resources}/ankasys-logo.png (100%) rename {resources => linux/resources}/land-110m.json (100%) create mode 100755 linux/run.sh rename setup-ubuntu.sh => linux/setup-ubuntu.sh (100%) create mode 100644 linux/src/GlobeWidget.cpp create mode 100644 linux/src/GlobeWidget.h rename {src => linux/src}/LocationParser.cpp (100%) rename {src => linux/src}/LocationParser.h (100%) create mode 100644 linux/src/MainWindow.cpp create mode 100644 linux/src/MainWindow.h create mode 100644 linux/src/MapWidget.cpp create mode 100644 linux/src/MapWidget.h create mode 100644 linux/src/RinexNavigation.cpp rename {src => linux/src}/RinexNavigation.h (100%) rename {src => linux/src}/SatelliteData.h (100%) rename {src => linux/src}/SerialReader.cpp (100%) rename {src => linux/src}/SerialReader.h (100%) rename {src => linux/src}/TelemetryPoint.h (100%) rename {src => linux/src}/UartReplay.cpp (100%) rename {src => linux/src}/UartReplay.h (100%) rename {src => linux/src}/main.cpp (100%) create mode 100644 linux/tests/main_window_tests.cpp rename {tests => linux/tests}/parser_tests.cpp (100%) rename {tests => linux/tests}/uart_replay_tests.cpp (100%) rename CMakeLists.txt => macos/CMakeLists.txt (94%) create mode 100644 macos/README.md create mode 100644 macos/resources/ankasys-logo.png rename {resources => macos/resources}/bluemarble-2048.png (100%) create mode 100644 macos/resources/land-110m.json create mode 100755 macos/run.sh rename setup-macos.sh => macos/setup-macos.sh (91%) rename {src => macos/src}/GlobeWidget.cpp (100%) rename {src => macos/src}/GlobeWidget.h (100%) create mode 100644 macos/src/LocationParser.cpp create mode 100644 macos/src/LocationParser.h rename {src => macos/src}/MainWindow.cpp (100%) rename {src => macos/src}/MainWindow.h (100%) rename {src => macos/src}/MapWidget.cpp (100%) rename {src => macos/src}/MapWidget.h (100%) rename {src => macos/src}/RinexNavigation.cpp (100%) create mode 100644 macos/src/RinexNavigation.h create mode 100644 macos/src/SatelliteData.h create mode 100644 macos/src/SerialReader.cpp create mode 100644 macos/src/SerialReader.h create mode 100644 macos/src/TelemetryPoint.h create mode 100644 macos/src/UartReplay.cpp create mode 100644 macos/src/UartReplay.h create mode 100644 macos/src/main.cpp rename {tests => macos/tests}/main_window_tests.cpp (100%) rename {tests => macos/tests}/map_widget_tests.cpp (100%) create mode 100644 macos/tests/parser_tests.cpp create mode 100644 macos/tests/uart_replay_tests.cpp delete mode 100755 run.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c7fc2e0..761aeee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,11 +1,11 @@ -name: Ubuntu build +name: Platform builds on: push: pull_request: jobs: - build-and-test: + linux: strategy: matrix: os: [ubuntu-22.04, ubuntu-24.04] @@ -21,10 +21,29 @@ jobs: sudo apt-get install -y build-essential cmake qt6-base-dev zlib1g-dev - name: Configure - run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + run: cmake -S linux -B build/linux -DCMAKE_BUILD_TYPE=Release - name: Build - run: cmake --build build --parallel 2 + run: cmake --build build/linux --parallel 2 - name: Test - run: ctest --test-dir build --output-on-failure + run: ctest --test-dir build/linux --output-on-failure + + macos: + runs-on: macos-latest + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install dependencies + run: brew install cmake qt + + - name: Configure + run: cmake -S macos -B build/macos -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$(brew --prefix qt)" + + - name: Build + run: cmake --build build/macos --parallel 2 + + - name: Test + run: ctest --test-dir build/macos --output-on-failure diff --git a/README.md b/README.md index e4d0304..127772c 100644 --- a/README.md +++ b/README.md @@ -1,283 +1,70 @@ # Location Plotter -Qt6/C++ GUI that listens to a USB UART on Linux or macOS, parses normal debug lines, -and plots live locations on a map. - -The application also includes an interactive 3D Earth/orbit view. It downloads the current -stream-derived RINEX 3 navigation file from the BKG/IGS archive at startup and every 15 -minutes, then recomputes GPS satellite positions for current UTC every minute. The last valid -file is cached for offline startup. - -## Parsed fields - -The parser accepts labeled values in the same line or in separate lines: +Bu `main` dali, isletim sistemi surumlerini birbirinden bagimsiz uygulama +agaclari olarak tutar: ```text -lat=41.0082 long=28.9784 hheight=123.45 sats=7 -latitude: 41.0082 longitude: 28.9784 height: 123.45 activeSats: 7 -active_sats=8 -navigation software running with 5 satellites -GDOP : 3.9576744550 -PDOP : 3.2540598660 -HDOP : 2.3631161789 -VDOP : 2.2370935468 -TDOP : 2.2526165852 -tracking PRNs: G03, G08, 14, 21 -SV=G27 -[LOCATION] source=gps lat=40.7357 lon=31.5985 height=812.0 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 -[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 -[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=812.1 tracked=4 used=4 -lattitude : 40.78925034214206846173 -longitude : 29.46277530841727809729 -height : -89.05779508221894502640 +location_plotter/ +├── linux/ # origin/tdk_ekf tabanli Linux surumu +│ ├── src/ +│ ├── tests/ +│ ├── resources/ +│ ├── CMakeLists.txt +│ ├── run.sh +│ └── setup-ubuntu.sh +├── macos/ # origin/macos tabanli macOS surumu +│ ├── src/ +│ ├── tests/ +│ ├── resources/ +│ ├── CMakeLists.txt +│ ├── run.sh +│ └── setup-macos.sh +└── .github/workflows/ # iki surumun CI tanimlari ``` -Accepted location labels: +Windows surumu geldiginde ayni seviyede `windows/` klasoru acilmalidir. +Bu commit Windows kodu veya bos bir Windows iskeleti eklemez. -- Latitude: `lat`, `latitude`, `lattitude` -- Longitude: `long`, `longitude`, `lon`, `lng` -- Height: `hheight`, `height`, `alt`, `altitude` -- Active satellites: `sat`, `sats`, `satellite`, `satellites`, `active_sats`, - `activeSats`, `satellites_active`, `num_sats`, `uydu` -- DOP: `GDOP`, `PDOP`, `HDOP`, `VDOP`, `TDOP` -- GPS identifiers: `G03`, `PRN: 3`, `PRNs: 3, 8, 14`, `SV=G21`, `SVID: 27` -- Location source: `source=gps`, `source=imu_corrected`, `source=trajectory` -- `tracked` is accepted as the active satellite count; `used` remains visible in - the raw UART line and denotes satellites used by the WLS solution. +## Surumler arasindaki farklar -If a satellite count arrives without a location, the active satellite display is updated. -If a location arrives without a satellite count, the latest known satellite count is attached -to that point. -If DOP values arrive before a location, the latest known values stay visible in the DOP -section and are attached to the next plotted point. -If latitude, longitude, and height arrive as separate consecutive lines, they are combined -into a single plotted point. +Linux ve macOS kodlari bilincli olarak ayri tutulur. Ortak duzeltmeler iki +klasore de ayri commitlerle uygulanmalidir; bir klasordeki degisiklik digerini +otomatik olarak etkilemez. -The GPS firmware's level-1 paired protocol is source-aware. Every received -`source=gps` location is added to the blue GPS series and every -`source=imu_corrected` location is added to the red corrected series at the -firmware-configured UART cadence. -Paths are built independently, so alternating UART lines do not create GPS-to-IMU -zigzags. A corrected point newer than one second is also preferred for map -auto-follow, the current-position label and the globe receiver marker. Raw GPS -outliers remain visible in blue for diagnosis without pulling the whole view away. -When corrected points exist, CEP50, average and scatter calculations use only that -corrected series; legacy input without a source keeps the previous behavior. -Synthetic `source=trajectory` points form a third independent green ground-truth -path. The map legend identifies all three series. +- `linux/`, `tdk_ekf` dalindaki Linux uygulamasidir. +- `macos/`, macOS'a ozel menuler, kalici pencere/seri-port ayarlari, otomatik + USB UART yenileme, gelismis harita cizimi ve Blue Marble dunya dokusunu + iceren daha genis GUI varyantidir. +- Seri port adlari farklidir: Linux `/dev/ttyUSB*` ve `/dev/ttyACM*`, macOS + `/dev/cu.usb*` ve `/dev/tty.usb*` yollarini kullanir. +- macOS ozel baud-rate ayari `IOSSIOSPEED` ile yapilir. Linux standart + `termios` baud sabitlerini kullanir. +- macOS uygulamasi `.app` bundle olarak paketlenir; Linux normal executable + uretir ve seri port izni icin `dialout` grubunu kullanir. +- macOS varyantinda ek `map_widget_tests` testi ve yerel Blue Marble kaynagi + vardir; Linux varyanti daha kucuk kaynak/test setini korur. -## UART log replay +Bu ayrim, iki surumun bagimsiz gelistirilmesini kolaylastirir; karsiliginda +parser, RINEX veya replay gibi ortak bir hata duzeltmesi iki klasorde de +uygulanip iki test paketiyle dogrulanmalidir. -Use the `UART Replay` button (or `Dosya > UART Log Replay...`) to feed a recorded -UART `.log`/`.txt` file back through the same parser used by the live serial port. -Choose the raw GPS update interval (default `200 ms`) and sensor/fused update -interval (default `50 ms`) in the replay dialog. Press `Replay Durdur` to stop. +## Calistirma -Recorder timestamps are optional and do not control replay speed. The replayer -removes a leading timestamp when present, identifies `source=gps` as raw GPS and -`source=imu_corrected` or `[FUSION]` as fused/sensor output, then synthesizes the -two update timelines while retaining the original UART line order. Other UART -diagnostic lines are forwarded with the surrounding location update. - -## Sentetik ESKF senaryolarini açma - -GPS deposundan 48 kombinasyonun replay loglarini üretin: +Linux: ```bash -cd /home/onur/Desktop/gps -make -C soc/software/fusion/tests scenario-logs -``` - -Sonra GUI'yi açıp `Senaryo Logu Ac` düğmesinden örneğin -`soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log` dosyasını seçin. -GUI logdaki bütün rotayı otomatik sığdırır. Aynı dosya komut satırından da -doğrudan açılabilir: - -```bash -cd /home/onur/Desktop/location_plotter -cmake -S . -B build -cmake --build build -j -./build/location_plotter --replay \ - /home/onur/Desktop/gps/soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log -``` - -Tek bir özel kombinasyon üretmek için: - -```bash -cd /home/onur/Desktop/gps/soc/software/fusion/tests -make platform_scenario_matrix_test -./platform_scenario_matrix_test --log aircraft 500 19 aircraft.log -``` - -Desteklenen senaryolar `stationary`, `pedestrian`, `car`, `aircraft`; GNSS -periyotları 200/500/1000 ms, IMU periyotları 19/20/200/300 ms'dir. - -## macOS'ta kurulum (MacBook Air, Apple Silicon veya Intel) - -Guncel macOS surumunde once [Homebrew](https://brew.sh) kurulu olmalidir. Ardindan: - -```bash -cd location_plotter -chmod +x setup-macos.sh run.sh -./setup-macos.sh -./run.sh -``` - -Betik Homebrew ile guncel `cmake` ve `qt` paketlerini kurar, yerel mimari icin -`location_plotter.app` paketini derler, gerekli Qt kutuphanelerini uygulama paketine -ekler ve testleri calistirir. - -macOS USB UART aygitlarini genellikle iki adla gosterir: - -```text -/dev/cu.usbserial-XXXXXXXX -/dev/tty.usbserial-XXXXXXXX -``` - -veya bir CDC aygitinda: - -```text -/dev/cu.usbmodemXXXX -/dev/tty.usbmodemXXXX -``` - -Uygulama her iki bicimi de algilar. Mümkünse `cu.usb...` yolunu secin; macOS'ta -disariya baglanti baslatan uygulamalar icin uygun olan aygit budur. UART uygulama -acikken takildiysa port alaninin yanindaki `Yenile` dugmesine basin. Otomatik -algilanmayan bir yol port alanina elle de yazilabilir. - -Istenen port baslatmadan once ortam degiskeniyle de secilebilir: - -```bash -LOCATION_PLOTTER_PORT=/dev/cu.usbserial-XXXXXXXX ./run.sh -``` - -Port hic gorunmuyorsa `ls /dev/cu.usb* /dev/tty.usb*` ile macOS'un aygiti olusturup -olusturmadigini kontrol edin. Hic sonuc yoksa kabloyu/veri baglantisini ve UART -donaniminizin macOS surucusunu kontrol edin. - -## Ubuntu'da kurulum - -Ubuntu 22.04 ve daha yeni surumlerde gerekli paketleri kurmak, uygulamayi derlemek ve -testleri calistirmak icin: - -```bash -git clone location_plotter -cd location_plotter +cd linux ./setup-ubuntu.sh ./run.sh ``` -`setup-ubuntu.sh`, gereken Ubuntu paketlerini `apt` ile kurar, Release build olusturur, -testleri calistirir ve kullaniciyi seri port erisimi icin `dialout` grubuna ekler. -Kurulum sirasinda `sudo` parolasi istenebilir. - -Depo ZIP olarak indirildiyse de klasore girdikten sonra ayni iki betik calistirilabilir. - -## Elle derleme - -Gerekli paketler: - -```bash -sudo apt update -sudo apt install build-essential cmake qt6-base-dev zlib1g-dev -``` - -Derleme ve test: - -```bash -cmake -S . -B build -cmake --build build -ctest --test-dir build --output-on-failure -``` - -Run: +macOS: ```bash +cd macos +./setup-macos.sh ./run.sh ``` -Uygulama Qt tabanli bir masaustu arayuzudur; grafik oturumu olan bir Ubuntu makinede -calistirilmalidir. Harita karolari ve guncel RINEX verisi icin internet erisimi gerekir, -ancak son gecerli RINEX verisi ve harita onbellegiyle cevrimdisi kullanim da desteklenir. - -Port secici Linux'ta `/dev/ttyUSB*` ve `/dev/ttyACM*`; macOS'ta USB ile ilgili -`/dev/cu.*` ve `/dev/tty.*` aygitlarini listeler. Hic aygit algilanmazsa Linux'ta -`/dev/ttyUSB2`, macOS'ta `/dev/cu.usbserial` duzenlenebilir bir varsayilan yol olarak -gosterilir. 50 Hz IMU-duzeltilmis konum akisini kayipsiz tasimak icin varsayilan baud -rate `460800`'dur. - -If the port cannot be opened, make sure the user has serial access: - -```bash -sudo usermod -a -G dialout "$USER" -``` - -Log out and back in after changing group membership. - -If the user is already listed in the `dialout` group but the current shell has not picked it -up yet, `./run.sh` starts the GUI through `sg dialout` so it can access `/dev/ttyUSB2` -without logging out. - -## Notes - -- The map uses OpenStreetMap tiles through Qt Network. If there is no internet connection, - points are still drawn over a fallback grid. -- The `3B Dunya · Uydular` tab appears beside the map. It uses broadcast ephemeris values - from BKG's near-real-time `BRDC00WRD_S` RINEX file to propagate GPS satellites in ECEF. - PRNs parsed from the current navigation solve are green; other RINEX satellites are red. - Drag to rotate the globe and use the mouse wheel to zoom. -- The globe includes a shaded continent layer and a receiver-centred translucent visibility - cone. `Min. elevation` accepts a directly typed decimal value from 0 to 90 degrees - (10 degrees by default, press Enter after typing); - satellites inside the mask get a cyan sight line and halo. Incoming positions update the - receiver marker without resetting a globe orientation chosen by dragging. -- The globe surface uses NASA/GSFC Scientific Visualization Studio's 2048x1024 Blue Marble - mosaic (NASA SVS visualization 2915), projected locally without a runtime network request. -- RINEX download starts at every launch, repeats every 15 minutes, and falls back through the - previous two UTC days if today's stream file is unavailable. Current and next-minute satellite - positions are propagated from the cached ephemeris, then smoothly interpolated at 10 FPS. -- The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status - line shows the system-local time of the last successful download or cache load. -- Complete UART channel snapshots such as `tracked satellites=5 prn_indices=[...]` replace the - previous connected-PRN set, so satellites that were lost no longer remain green. Firmware - channel indices are zero-based and are normalized for display (`0` is GPS PRN `G01`). -- RINEX text is parsed once per download and retained as compact ephemeris records. The static - globe is cached between camera changes, reducing CPU and allocation load during orbit animation. -- The globe uses an embedded Natural Earth coastline mask with terrain-like land colouring, - shallow-water edges, atmospheric shading, and a cached Earth-fixed texture for smooth rotation. -- The main content width and the right-side dashboard/UART height are both adjustable by - dragging their splitter handles. Dashboard cards scroll independently so the UART log can - be enlarged substantially. -- `Max` controls how many recent points stay visible and are included in location - analysis. The default is 10. When the limit is reached, older points are removed - automatically. -- Location analysis shows the average of the plotted samples, the last point's deviation - from that average, mean deviation, RMS scatter, and max deviation in meters. The average - marker can be toggled on the map. -- The map layers panel can hide all telemetry overlays, show only the newest location instead - of the recent path, and independently toggle the average marker. -- GPS points and paths are blue. IMU+ESKF corrected points and their independent - path are red. Synthetic ground-truth trajectory is green; the locations table - shows the parsed source in its `Kaynak` column. -- The application calculates empirical CEP50 as the median horizontal distance of the retained - measurements from their spherical mean, then draws the circle around that measurement center. - This is the radius containing 50 percent of the retained position fixes and is independent of - the optional user reference. -- A user reference can be selected by right-clicking the map. The selected WGS84 coordinates - are transferred to the reference fields with 15 decimal places but do not alter CEP50. -- Latitude and longitude tokens are preserved exactly as received for the table and map - label. Calculations and map projection use IEEE 754 `double` precision. -- The OpenStreetMap Standard raster layer supports zoom levels through 19. At latitude 40.8, - zoom 19 is approximately 0.23 meters per pixel; the marker still uses subpixel coordinates, - but the background tiles cannot add accuracy to the GNSS measurement. -- OpenStreetMap attribution is shown on the map. Tile responses use a 100 MB persistent HTTP - cache in the application's standard cache directory. -- UART log and telemetry views are refreshed in batches so a high-rate serial stream does not - rebuild the complete interface for every received line. The locations table uses a virtual - model and remains usable with a high `Max` value. -- Her basarili UART baglantisi, dosya adinda ve her satirda yerel zaman damgasi bulunan ayri bir - log olusturur. Dosyalar kaynak kodun ana klasorundeki `uart_logs` dizinine yazilir; - `LOCATION_PLOTTER_UART_LOG_DIR` ile farkli bir klasor secilebilir. -- If no new plotted location arrives for 10 seconds while listening, current location - points and analysis values are cleared. +Ayrintili kullanim ve telemetri formati ilgili klasorun `README.md` dosyasinda +yer alir. diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..e1bddd5 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,119 @@ +cmake_minimum_required(VERSION 3.20) + +project(location_plotter VERSION 0.3.0 LANGUAGES CXX) + +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + message(FATAL_ERROR "linux/ yalnizca Linux icin yapilandirilabilir") +endif() + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 REQUIRED COMPONENTS Core Widgets Network) +find_package(ZLIB REQUIRED) + +add_executable(location_plotter + src/LocationParser.cpp + src/LocationParser.h + src/GlobeWidget.cpp + src/GlobeWidget.h + src/MainWindow.cpp + src/MainWindow.h + src/MapWidget.cpp + src/MapWidget.h + src/SerialReader.cpp + src/SerialReader.h + src/UartReplay.cpp + src/UartReplay.h + src/RinexNavigation.cpp + src/RinexNavigation.h + src/SatelliteData.h + src/TelemetryPoint.h + src/main.cpp +) + +target_link_libraries(location_plotter PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB) +target_compile_definitions(location_plotter PRIVATE + LOCATION_PLOTTER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" +) + +qt_add_resources(location_plotter "app_resources" + PREFIX "/" + FILES resources/ankasys-logo.png resources/land-110m.json +) + +target_compile_definitions(location_plotter PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1) + +enable_testing() + +add_executable(location_parser_tests + tests/parser_tests.cpp + src/LocationParser.cpp + src/LocationParser.h + src/RinexNavigation.cpp + src/RinexNavigation.h + src/SatelliteData.h + src/TelemetryPoint.h +) +target_link_libraries(location_parser_tests PRIVATE Qt6::Core Qt6::Network ZLIB::ZLIB) +target_compile_options(location_parser_tests PRIVATE + $<$:-UNDEBUG> +) +add_test(NAME location_parser_tests COMMAND location_parser_tests) + +add_executable(location_main_window_tests + tests/main_window_tests.cpp + src/LocationParser.cpp + src/LocationParser.h + src/MainWindow.cpp + src/MainWindow.h + src/MapWidget.cpp + src/MapWidget.h + src/GlobeWidget.cpp + src/GlobeWidget.h + src/SerialReader.cpp + src/SerialReader.h + src/UartReplay.cpp + src/UartReplay.h + src/RinexNavigation.cpp + src/RinexNavigation.h + src/SatelliteData.h + src/TelemetryPoint.h +) +target_link_libraries(location_main_window_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB) +target_compile_definitions(location_main_window_tests PRIVATE + LOCATION_PLOTTER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" +) +target_compile_options(location_main_window_tests PRIVATE + $<$:-UNDEBUG> +) +qt_add_resources(location_main_window_tests "test_app_resources" + PREFIX "/" + FILES resources/ankasys-logo.png resources/land-110m.json +) +target_compile_definitions(location_main_window_tests PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1) +add_test(NAME location_main_window_tests COMMAND location_main_window_tests) +set_tests_properties(location_main_window_tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_CACHE_HOME=/tmp/location-plotter-test-cache") + +add_executable(location_uart_replay_tests + tests/uart_replay_tests.cpp + src/UartReplay.cpp + src/UartReplay.h + src/LocationParser.cpp + src/LocationParser.h + src/TelemetryPoint.h +) +target_link_libraries(location_uart_replay_tests PRIVATE Qt6::Core) +target_compile_options(location_uart_replay_tests PRIVATE + $<$:-UNDEBUG> +) +add_test(NAME location_uart_replay_tests COMMAND location_uart_replay_tests) + +install(TARGETS location_plotter + BUNDLE DESTINATION . + RUNTIME DESTINATION bin +) diff --git a/linux/README.md b/linux/README.md new file mode 100644 index 0000000..73229e1 --- /dev/null +++ b/linux/README.md @@ -0,0 +1,236 @@ +# Location Plotter + +Qt6/C++ GUI that listens to a USB UART on Linux, parses normal debug lines, +and plots live locations on a map. + +The application also includes an interactive 3D Earth/orbit view. It downloads the current +stream-derived RINEX 3 navigation file from the BKG/IGS archive at startup and every 15 +minutes, then recomputes GPS satellite positions for current UTC every minute. The last valid +file is cached for offline startup. + +## Parsed fields + +The parser accepts labeled values in the same line or in separate lines: + +```text +lat=41.0082 long=28.9784 hheight=123.45 sats=7 +latitude: 41.0082 longitude: 28.9784 height: 123.45 activeSats: 7 +active_sats=8 +navigation software running with 5 satellites +GDOP : 3.9576744550 +PDOP : 3.2540598660 +HDOP : 2.3631161789 +VDOP : 2.2370935468 +TDOP : 2.2526165852 +tracking PRNs: G03, G08, 14, 21 +SV=G27 +[LOCATION] source=gps lat=40.7357 lon=31.5985 height=812.0 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 +[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 +[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=812.1 tracked=4 used=4 +lattitude : 40.78925034214206846173 +longitude : 29.46277530841727809729 +height : -89.05779508221894502640 +``` + +Accepted location labels: + +- Latitude: `lat`, `latitude`, `lattitude` +- Longitude: `long`, `longitude`, `lon`, `lng` +- Height: `hheight`, `height`, `alt`, `altitude` +- Active satellites: `sat`, `sats`, `satellite`, `satellites`, `active_sats`, + `activeSats`, `satellites_active`, `num_sats`, `uydu` +- DOP: `GDOP`, `PDOP`, `HDOP`, `VDOP`, `TDOP` +- GPS identifiers: `G03`, `PRN: 3`, `PRNs: 3, 8, 14`, `SV=G21`, `SVID: 27` +- Location source: `source=gps`, `source=imu_corrected`, `source=trajectory` +- `tracked` is accepted as the active satellite count; `used` remains visible in + the raw UART line and denotes satellites used by the WLS solution. + +If a satellite count arrives without a location, the active satellite display is updated. +If a location arrives without a satellite count, the latest known satellite count is attached +to that point. +If DOP values arrive before a location, the latest known values stay visible in the DOP +section and are attached to the next plotted point. +If latitude, longitude, and height arrive as separate consecutive lines, they are combined +into a single plotted point. + +The GPS firmware's level-1 paired protocol is source-aware. Every received +`source=gps` location is added to the blue GPS series and every +`source=imu_corrected` location is added to the red corrected series at the +firmware-configured UART cadence. +Paths are built independently, so alternating UART lines do not create GPS-to-IMU +zigzags. A corrected point newer than one second is also preferred for map +auto-follow, the current-position label and the globe receiver marker. Raw GPS +outliers remain visible in blue for diagnosis without pulling the whole view away. +When corrected points exist, CEP50, average and scatter calculations use only that +corrected series; legacy input without a source keeps the previous behavior. +Synthetic `source=trajectory` points form a third independent green ground-truth +path. The map legend identifies all three series. + +## UART log replay + +Use the `UART Replay` button (or `Dosya > UART Log Replay...`) to feed a recorded +UART `.log`/`.txt` file back through the same parser used by the live serial port. +Choose the raw GPS update interval (default `200 ms`) and sensor/fused update +interval (default `50 ms`) in the replay dialog. Press `Replay Durdur` to stop. + +Recorder timestamps are optional and do not control replay speed. The replayer +removes a leading timestamp when present, identifies `source=gps` as raw GPS and +`source=imu_corrected` or `[FUSION]` as fused/sensor output, then synthesizes the +two update timelines while retaining the original UART line order. Other UART +diagnostic lines are forwarded with the surrounding location update. + +## Sentetik ESKF senaryolarini açma + +GPS deposundan 48 kombinasyonun replay loglarini üretin: + +```bash +cd /home/onur/Desktop/gps +make -C soc/software/fusion/tests scenario-logs +``` + +Sonra GUI'yi açıp `Senaryo Logu Ac` düğmesinden örneğin +`soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log` dosyasını seçin. +GUI logdaki bütün rotayı otomatik sığdırır. Aynı dosya komut satırından da +doğrudan açılabilir: + +```bash +cd location_plotter/linux +cmake -S . -B build +cmake --build build -j +./build/location_plotter --replay \ + /home/onur/Desktop/gps/soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log +``` + +Tek bir özel kombinasyon üretmek için: + +```bash +cd /home/onur/Desktop/gps/soc/software/fusion/tests +make platform_scenario_matrix_test +./platform_scenario_matrix_test --log aircraft 500 19 aircraft.log +``` + +Desteklenen senaryolar `stationary`, `pedestrian`, `car`, `aircraft`; GNSS +periyotları 200/500/1000 ms, IMU periyotları 19/20/200/300 ms'dir. + +## Ubuntu'da kurulum + +Ubuntu 22.04 ve daha yeni surumlerde gerekli paketleri kurmak, uygulamayi derlemek ve +testleri calistirmak icin: + +```bash +git clone location_plotter +cd location_plotter/linux +./setup-ubuntu.sh +./run.sh +``` + +`setup-ubuntu.sh`, gereken Ubuntu paketlerini `apt` ile kurar, Release build olusturur, +testleri calistirir ve kullaniciyi seri port erisimi icin `dialout` grubuna ekler. +Kurulum sirasinda `sudo` parolasi istenebilir. + +Depo ZIP olarak indirildiyse de klasore girdikten sonra ayni iki betik calistirilabilir. + +## Elle derleme + +Gerekli paketler: + +```bash +sudo apt update +sudo apt install build-essential cmake qt6-base-dev zlib1g-dev +``` + +Derleme ve test: + +```bash +cmake -S . -B build +cmake --build build +ctest --test-dir build --output-on-failure +``` + +Run: + +```bash +./run.sh +``` + +Uygulama Qt tabanli bir masaustu arayuzudur; grafik oturumu olan bir Ubuntu makinede +calistirilmalidir. Harita karolari ve guncel RINEX verisi icin internet erisimi gerekir, +ancak son gecerli RINEX verisi ve harita onbellegiyle cevrimdisi kullanim da desteklenir. + +Port secici `/dev/ttyUSB*` ve `/dev/ttyACM*` aygitlarini listeler. Hic aygit +algilanmazsa `/dev/ttyUSB2` duzenlenebilir bir varsayilan yol olarak gosterilir. +50 Hz IMU-duzeltilmis konum akisini kayipsiz tasimak icin varsayilan baud rate +`460800`'dur. + +If the port cannot be opened, make sure the user has serial access: + +```bash +sudo usermod -a -G dialout "$USER" +``` + +Log out and back in after changing group membership. + +If the user is already listed in the `dialout` group but the current shell has not picked it +up yet, `./run.sh` starts the GUI through `sg dialout` so it can access `/dev/ttyUSB2` +without logging out. + +## Notes + +- The map uses OpenStreetMap tiles through Qt Network. If there is no internet connection, + points are still drawn over a fallback grid. +- The `3B Dunya · Uydular` tab appears beside the map. It uses broadcast ephemeris values + from BKG's near-real-time `BRDC00WRD_S` RINEX file to propagate GPS satellites in ECEF. + PRNs parsed from the current navigation solve are green; other RINEX satellites are red. + Drag to rotate the globe and use the mouse wheel to zoom. +- The globe includes a shaded continent layer and a receiver-centred translucent visibility + cone. `Min. elevation` accepts a directly typed decimal value from 0 to 90 degrees + (10 degrees by default, press Enter after typing); + satellites inside the mask get a cyan sight line and halo. Incoming positions update the + receiver marker without resetting a globe orientation chosen by dragging. +- RINEX download starts at every launch, repeats every 15 minutes, and falls back through the + previous two UTC days if today's stream file is unavailable. Current and next-minute satellite + positions are propagated from the cached ephemeris, then smoothly interpolated at 10 FPS. +- The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status + line shows the system-local time of the last successful download or cache load. +- Complete UART channel snapshots such as `tracked satellites=5 prn_indices=[...]` replace the + previous connected-PRN set, so satellites that were lost no longer remain green. Firmware + channel indices are zero-based and are normalized for display (`0` is GPS PRN `G01`). +- RINEX text is parsed once per download and retained as compact ephemeris records. The static + globe is cached between camera changes, reducing CPU and allocation load during orbit animation. +- The globe uses an embedded Natural Earth coastline mask with terrain-like land colouring, + shallow-water edges, atmospheric shading, and a cached Earth-fixed texture for smooth rotation. +- The main content width and the right-side dashboard/UART height are both adjustable by + dragging their splitter handles. Dashboard cards scroll independently so the UART log can + be enlarged substantially. +- `Max` controls how many recent points stay visible and are included in location + analysis. The default is 10. When the limit is reached, older points are removed + automatically. +- Location analysis shows the average of the plotted samples, the last point's deviation + from that average, mean deviation, RMS scatter, and max deviation in meters. The average + marker can be toggled on the map. +- The map layers panel can hide all telemetry overlays, show only the newest location instead + of the recent path, and independently toggle the average marker. +- GPS points and paths are blue. IMU+ESKF corrected points and their independent + path are red. Synthetic ground-truth trajectory is green; the locations table + shows the parsed source in its `Kaynak` column. +- The application calculates empirical CEP50 as the median horizontal distance of the retained + measurements from their spherical mean, then draws the circle around that measurement center. + This is the radius containing 50 percent of the retained position fixes and is independent of + the optional user reference. +- A user reference can be selected by right-clicking the map. The selected WGS84 coordinates + are transferred to the reference fields with 15 decimal places but do not alter CEP50. +- Latitude and longitude tokens are preserved exactly as received for the table and map + label. Calculations and map projection use IEEE 754 `double` precision. +- The OpenStreetMap Standard raster layer supports zoom levels through 19. At latitude 40.8, + zoom 19 is approximately 0.23 meters per pixel; the marker still uses subpixel coordinates, + but the background tiles cannot add accuracy to the GNSS measurement. +- OpenStreetMap attribution is shown on the map. Tile responses use a 100 MB persistent HTTP + cache in the application's standard cache directory. +- UART log and telemetry views are refreshed in batches so a high-rate serial stream does not + rebuild the complete interface for every received line. The locations table uses a virtual + model and remains usable with a high `Max` value. +- Her basarili UART baglantisi, dosya adinda ve her satirda yerel zaman damgasi bulunan ayri bir + log olusturur. Dosyalar kaynak kodun ana klasorundeki `uart_logs` dizinine yazilir; + `LOCATION_PLOTTER_UART_LOG_DIR` ile farkli bir klasor secilebilir. +- If no new plotted location arrives for 10 seconds while listening, current location + points and analysis values are cleared. diff --git a/resources/ankasys-logo.png b/linux/resources/ankasys-logo.png similarity index 100% rename from resources/ankasys-logo.png rename to linux/resources/ankasys-logo.png diff --git a/resources/land-110m.json b/linux/resources/land-110m.json similarity index 100% rename from resources/land-110m.json rename to linux/resources/land-110m.json diff --git a/linux/run.sh b/linux/run.sh new file mode 100755 index 0000000..0c4de9e --- /dev/null +++ b/linux/run.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +print_qt_install_hint() { + echo + echo "Qt6 bulunamadi. Ubuntu'da gerekli paketleri kurmak icin:" + echo " sudo apt update" + echo " sudo apt install build-essential cmake qt6-base-dev zlib1g-dev" + echo "veya dogrudan: ./setup-ubuntu.sh" +} + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "Bu calistirma betigi Linux icindir." >&2 + exit 1 +fi + +project_dir="$(pwd -P)" + +if [[ -f ./build/CMakeCache.txt ]]; then + cached_source="$(sed -n 's/^CMAKE_HOME_DIRECTORY:INTERNAL=//p' ./build/CMakeCache.txt)" + if [[ -n "$cached_source" && "$cached_source" != "$project_dir" ]]; then + echo "Build klasoru farkli bir proje yoluna ait. 'linux/build' klasorunu silip tekrar deneyin." >&2 + exit 1 + fi +fi + +if ! cmake -S . -B build; then + print_qt_install_hint + exit 1 +fi + +cmake --build build + +missing_libs="$(ldd ./build/location_plotter 2>/dev/null | awk '/not found/ { print $1 }' || true)" +if [[ -n "$missing_libs" ]]; then + echo "Eksik paylasimli kutuphaneler:" + echo "$missing_libs" | sed 's/^/ /' + print_qt_install_hint + exit 1 +fi + +if id -nG | tr ' ' '\n' | grep -qx dialout; then + exec ./build/location_plotter "$@" +fi + +if getent group dialout | tr ':' '\n' | tail -n 1 | tr ',' '\n' | grep -qx "$(id -un)"; then + exec sg dialout -c "$(printf '%q ' ./build/location_plotter "$@")" +fi + +echo "Bu kullanici dialout grubunda degil. Eklemek icin:" +echo " sudo usermod -a -G dialout $(id -un)" +echo "Sonra oturumu kapatip ac veya bu scripti tekrar calistir." +exit 1 diff --git a/setup-ubuntu.sh b/linux/setup-ubuntu.sh similarity index 100% rename from setup-ubuntu.sh rename to linux/setup-ubuntu.sh diff --git a/linux/src/GlobeWidget.cpp b/linux/src/GlobeWidget.cpp new file mode 100644 index 0000000..6b65e5d --- /dev/null +++ b/linux/src/GlobeWidget.cpp @@ -0,0 +1,870 @@ +#include "GlobeWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { +constexpr double Pi = 3.14159265358979323846; +constexpr double InitialLongitudeOffset = 0.50; +constexpr double InitialLatitudeOffset = 0.28; + +struct Vec3 { double x; double y; double z; }; + +Vec3 globeVector(double latitude, double longitude) +{ + const double lat = latitude * Pi / 180.0; + const double lon = longitude * Pi / 180.0; + return {std::cos(lat) * std::cos(lon), std::cos(lat) * std::sin(lon), std::sin(lat)}; +} + +const QVector &fallbackLandPolygons() +{ + // Deliberately simplified Natural-Earth-like coastlines. QPointF is lon/lat. + static const QVector polygons = { + // North America + {{-168, 66}, {-155, 72}, {-138, 70}, {-128, 61}, {-123, 50}, {-124, 42}, + {-117, 32}, {-106, 24}, {-97, 19}, {-90, 19}, {-83, 24}, {-81, 31}, + {-75, 39}, {-66, 45}, {-60, 52}, {-64, 60}, {-78, 63}, {-89, 69}, + {-105, 73}, {-125, 72}, {-145, 75}}, + // Central America + {{-106, 24}, {-97, 18}, {-91, 16}, {-87, 12}, {-83, 9}, {-79, 8}, + {-77, 10}, {-84, 16}, {-91, 20}}, + // South America + {{-81, 12}, {-72, 12}, {-61, 8}, {-50, 3}, {-43, -5}, {-35, -8}, + {-39, -19}, {-48, -29}, {-53, -36}, {-60, -47}, {-67, -55}, + {-74, -50}, {-75, -38}, {-71, -28}, {-70, -18}, {-76, -7}}, + // Greenland + {{-73, 60}, {-52, 59}, {-39, 65}, {-20, 76}, {-28, 83}, {-49, 84}, + {-65, 78}}, + // Europe and Asia + {{-11, 36}, {-10, 44}, {-5, 50}, {4, 54}, {12, 58}, {20, 69}, + {34, 71}, {48, 77}, {72, 76}, {96, 78}, {119, 73}, {142, 71}, + {165, 66}, {178, 58}, {169, 50}, {151, 46}, {141, 38}, {128, 34}, + {121, 24}, {110, 20}, {105, 10}, {98, 7}, {91, 21}, {80, 8}, + {72, 18}, {62, 25}, {53, 27}, {45, 38}, {36, 42}, {28, 40}, + {20, 35}, {12, 36}, {4, 43}}, + // Arabian peninsula + {{35, 31}, {48, 30}, {56, 24}, {51, 15}, {43, 12}, {36, 20}}, + // India and southeast Asia + {{67, 25}, {76, 31}, {88, 25}, {92, 22}, {87, 8}, {80, 6}, + {74, 15}}, + {{95, 22}, {106, 21}, {110, 12}, {105, 1}, {100, 7}}, + // Africa + {{-17, 37}, {-5, 37}, {10, 35}, {25, 31}, {34, 22}, {43, 12}, + {51, 11}, {48, 2}, {42, -12}, {33, -26}, {25, -35}, {17, -34}, + {10, -25}, {2, -9}, {-8, 5}, {-16, 15}}, + // Australia + {{112, -11}, {125, -10}, {138, -14}, {153, -27}, {151, -39}, + {139, -44}, {126, -34}, {114, -35}, {111, -23}}, + // Madagascar + {{47, -13}, {51, -17}, {49, -27}, {44, -25}, {43, -18}}, + // Japan + {{130, 31}, {136, 34}, {142, 43}, {145, 44}, {141, 35}}, + // Great Britain + {{-8, 50}, {-3, 58}, {1, 57}, {2, 51}}, + // New Zealand + {{166, -34}, {174, -41}, {178, -47}, {171, -45}}, + }; + return polygons; +} + +const QVector &landPolygons() +{ + static const QVector polygons = [] { + QFile file(QStringLiteral(":/resources/land-110m.json")); + if (!file.open(QIODevice::ReadOnly)) return fallbackLandPolygons(); + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + const QJsonObject root = document.object(); + const QJsonObject transform = root.value(QStringLiteral("transform")).toObject(); + const QJsonArray scale = transform.value(QStringLiteral("scale")).toArray(); + const QJsonArray translate = transform.value(QStringLiteral("translate")).toArray(); + if (scale.size() != 2 || translate.size() != 2) return fallbackLandPolygons(); + + const double scaleX = scale[0].toDouble(); + const double scaleY = scale[1].toDouble(); + const double translateX = translate[0].toDouble(); + const double translateY = translate[1].toDouble(); + QVector decodedArcs; + const QJsonArray arcs = root.value(QStringLiteral("arcs")).toArray(); + decodedArcs.reserve(arcs.size()); + for (const QJsonValue &arcValue : arcs) { + int x = 0; + int y = 0; + QPolygonF arc; + for (const QJsonValue &pointValue : arcValue.toArray()) { + const QJsonArray point = pointValue.toArray(); + if (point.size() != 2) continue; + x += point[0].toInt(); + y += point[1].toInt(); + arc.push_back(QPointF(x * scaleX + translateX, + y * scaleY + translateY)); + } + decodedArcs.push_back(arc); + } + + QVector result; + const QJsonObject land = root.value(QStringLiteral("objects")).toObject() + .value(QStringLiteral("land")).toObject(); + const QJsonArray geometries = land.value(QStringLiteral("geometries")).toArray(); + for (const QJsonValue &geometryValue : geometries) { + const QJsonObject geometry = geometryValue.toObject(); + const QJsonArray multiPolygon = geometry.value(QStringLiteral("arcs")).toArray(); + for (const QJsonValue &polygonValue : multiPolygon) { + const QJsonArray rings = polygonValue.toArray(); + if (rings.isEmpty()) continue; + const QJsonArray ringArcs = rings[0].toArray(); // outer coastline + QPolygonF polygon; + for (const QJsonValue &arcIndexValue : ringArcs) { + const int reference = arcIndexValue.toInt(); + const int index = reference >= 0 ? reference : ~reference; + if (index < 0 || index >= decodedArcs.size()) continue; + QPolygonF arc = decodedArcs[index]; + if (reference < 0) std::reverse(arc.begin(), arc.end()); + for (int i = polygon.isEmpty() ? 0 : 1; i < arc.size(); ++i) { + polygon.push_back(arc[i]); + } + } + if (polygon.size() >= 3) result.push_back(polygon); + } + } + return result.isEmpty() ? fallbackLandPolygons() : result; + }(); + return polygons; +} + +const QImage &landMask() +{ + // A geographic mask decouples coastline quality from the size of the widget. + // It is generated once and remains fixed in Earth coordinates while rotating. + static const QImage mask = [] { + constexpr int Width = 1440; + constexpr int Height = 720; + QImage image(Width, Height, QImage::Format_Grayscale8); + image.fill(0); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(Qt::NoPen); + painter.setBrush(Qt::white); + for (const QPolygonF &geographic : landPolygons()) { + QPolygonF projected; + projected.reserve(geographic.size()); + double previousLongitude = geographic.first().x(); + double unwrappedLongitude = previousLongitude; + for (const QPointF &point : geographic) { + double longitude = point.x(); + while (longitude - previousLongitude > 180.0) longitude -= 360.0; + while (longitude - previousLongitude < -180.0) longitude += 360.0; + unwrappedLongitude += longitude - previousLongitude; + previousLongitude = longitude; + projected.push_back(QPointF((unwrappedLongitude + 180.0) / 360.0 * Width, + (90.0 - point.y()) / 180.0 * Height)); + } + for (int copy = -1; copy <= 1; ++copy) { + QPolygonF shifted = projected; + shifted.translate(copy * Width, 0.0); + painter.drawPolygon(shifted); + } + } + painter.end(); + return image; + }(); + return mask; +} + +bool maskIsLand(double longitude, double latitude) +{ + const QImage &mask = landMask(); + longitude = std::fmod(longitude + 180.0, 360.0); + if (longitude < 0.0) longitude += 360.0; + const int x = std::clamp(static_cast(longitude / 360.0 * mask.width()), + 0, mask.width() - 1); + const int y = std::clamp(static_cast((90.0 - latitude) / 180.0 * mask.height()), + 0, mask.height() - 1); + return mask.constScanLine(y)[x] != 0; +} + +double smoothStep(double edge0, double edge1, double value) +{ + const double t = std::clamp((value - edge0) / (edge1 - edge0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} +} + +GlobeWidget::GlobeWidget(QWidget *parent) + : QWidget(parent) +{ + setObjectName(QStringLiteral("globeWidget")); + setMinimumSize(420, 360); + setMouseTracking(true); + setCursor(Qt::OpenHandCursor); + setToolTip(QStringLiteral("Dunyayi dondurmek icin surukleyin, yakinlastirmak icin tekerlegi kullanin")); + // GNSS satellites move only a few screen pixels per minute. Ten frames per + // second is visually smooth here and avoids repainting a static globe at 30 FPS. + m_satelliteAnimationTimer.setInterval(100); + m_satelliteAnimationTimer.setTimerType(Qt::CoarseTimer); + connect(&m_satelliteAnimationTimer, &QTimer::timeout, + this, &GlobeWidget::updateSatelliteAnimation); +} + +void GlobeWidget::setSatellites(const QVector &satellites) +{ + m_satellites = satellites; + update(); +} + +void GlobeWidget::setSatelliteMotion(const QVector ¤t, + const QVector &next, + int durationMs) +{ + if (current.isEmpty() || next.isEmpty() || durationMs <= 0) { + setSatellites(current); + m_satelliteAnimationTimer.stop(); + return; + } + + // Continue from the currently rendered points when a fresh ephemeris arrives, + // avoiding even a small visual jump at the update boundary. + const QVector &candidateStart = + m_satellites.isEmpty() ? current : m_satellites; + QHash starts; + starts.reserve(candidateStart.size()); + for (const SatellitePosition &satellite : candidateStart) { + starts.insert(satellite.prn, satellite); + } + + m_satelliteMotionStart.clear(); + m_satelliteMotionTarget.clear(); + m_satelliteMotionStart.reserve(next.size()); + m_satelliteMotionTarget.reserve(next.size()); + for (const SatellitePosition &target : next) { + const auto start = starts.constFind(target.prn); + if (start == starts.cend()) continue; + m_satelliteMotionStart.push_back(start.value()); + m_satelliteMotionTarget.push_back(target); + } + if (m_satelliteMotionStart.isEmpty()) { + setSatellites(current); + m_satelliteAnimationTimer.stop(); + return; + } + m_satelliteMotionDurationMs = durationMs; + m_satelliteMotionClock.restart(); + m_satelliteAnimationTimer.start(); + updateSatelliteAnimation(); +} + +void GlobeWidget::updateSatelliteAnimation() +{ + if (!m_satelliteMotionClock.isValid() || m_satelliteMotionDurationMs <= 0) return; + + const double progress = std::clamp( + static_cast(m_satelliteMotionClock.elapsed()) / m_satelliteMotionDurationMs, + 0.0, 1.0); + QVector interpolated; + interpolated.reserve(m_satelliteMotionStart.size()); + for (qsizetype i = 0; i < m_satelliteMotionStart.size(); ++i) { + const SatellitePosition &start = m_satelliteMotionStart[i]; + const SatellitePosition &target = m_satelliteMotionTarget[i]; + SatellitePosition point = start; + point.x += (target.x - start.x) * progress; + point.y += (target.y - start.y) * progress; + point.z += (target.z - start.z) * progress; + point.healthy = target.healthy; + interpolated.push_back(point); + } + if (!interpolated.isEmpty()) m_satellites = interpolated; + update(); + + if (progress >= 1.0) m_satelliteAnimationTimer.stop(); +} + +void GlobeWidget::setObservedPrns(const QSet &prns) +{ + m_observedPrns = prns; + update(); +} + +void GlobeWidget::setReceiverLocation(double latitude, double longitude) +{ + const bool firstLocation = !m_hasReceiver; + m_hasReceiver = true; + m_receiverLatitude = latitude; + m_receiverLongitude = longitude; + if (firstLocation) { + // Keep the receiver off-centre so its local zenith is visibly directed + // away from Earth. Looking straight down the zenith made the outward + // visibility cone appear to be embedded in the globe. + m_yaw = Pi / 2.0 - longitude * Pi / 180.0 + InitialLongitudeOffset; + m_pitch = std::clamp(-latitude * Pi / 180.0 + InitialLatitudeOffset, + -1.15, 1.15); + } + update(); +} + +void GlobeWidget::clearReceiverLocation() +{ + m_hasReceiver = false; + update(); +} + +void GlobeWidget::setRinexStatus(const QString &status, bool error) +{ + m_status = status; + m_statusError = error; + update(); +} + +void GlobeWidget::setElevationMask(double degrees) +{ + m_elevationMaskDegrees = std::clamp(degrees, 0.0, 90.0); + update(); +} + +GlobeWidget::ProjectedPoint GlobeWidget::project(double x, double y, double z, double radius) const +{ + const double cy = std::cos(m_yaw); + const double sy = std::sin(m_yaw); + const double x1 = cy * x - sy * y; + const double y1 = sy * x + cy * y; + const double cp = std::cos(m_pitch); + const double sp = std::sin(m_pitch); + const double y2 = cp * y1 - sp * z; + const double z2 = sp * y1 + cp * z; + const QPointF center(width() * 0.5, height() * 0.52); + return {{center.x() + x1 * radius, center.y() - z2 * radius}, y2}; +} + +void GlobeWidget::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + + const qreal devicePixelRatio = devicePixelRatioF(); + if (m_staticLayer.isNull() || m_staticLayerSize != size() + || !qFuzzyCompare(m_staticLayerDevicePixelRatio, devicePixelRatio) + || m_staticLayerYaw != m_yaw || m_staticLayerPitch != m_pitch + || m_staticLayerZoom != m_zoom) { + rebuildStaticLayer(); + } + painter.drawPixmap(0, 0, m_staticLayer); + + if (m_hasReceiver) drawVisibilityCone(painter); + if (m_hasReceiver) drawReceiver(painter); + const int visibleCount = drawSatellites(painter); + + painter.setPen(QColor(229, 240, 249)); + QFont titleFont = painter.font(); + titleFont.setPixelSize(18); + titleFont.setBold(true); + painter.setFont(titleFont); + painter.drawText(QRect(20, 16, width() - 40, 28), Qt::AlignLeft | Qt::AlignVCenter, + QStringLiteral("GNSS YORUNGE GORUNUMU")); + QFont smallFont = painter.font(); + smallFont.setPixelSize(11); + smallFont.setBold(false); + painter.setFont(smallFont); + painter.setPen(m_statusError ? QColor(255, 177, 77) : QColor(132, 177, 204)); + painter.drawText(QRect(20, 43, width() - 40, 22), Qt::AlignLeft | Qt::AlignVCenter, m_status); + + const int legendY = height() - 25; + const int legendX = width() / 2 - 125; + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(42, 224, 137)); + painter.drawEllipse(QPointF(legendX, legendY - 3), 4, 4); + painter.setPen(QColor(101, 239, 176)); + painter.drawText(legendX + 9, legendY + 1, QStringLiteral("BULUNDU")); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(255, 79, 95)); + painter.drawEllipse(QPointF(legendX + 78, legendY - 3), 4, 4); + painter.setPen(QColor(255, 132, 143)); + painter.drawText(legendX + 87, legendY + 1, QStringLiteral("BULUNAMADI")); + painter.setPen(QColor(138, 183, 207)); + painter.drawText(legendX + 181, legendY + 1, + QStringLiteral("%1/%2 PRN · ELEV≥%3°: %4") + .arg(m_observedPrns.size()).arg(m_satellites.size()) + .arg(m_elevationMaskDegrees, 0, 'f', 1).arg(visibleCount)); + + if (!m_hasReceiver) { + painter.setPen(QColor(219, 232, 241)); + QFont waitingFont = painter.font(); + waitingFont.setPixelSize(16); + waitingFont.setBold(true); + painter.setFont(waitingFont); + painter.drawText(rect().adjusted(30, 70, -30, -50), Qt::AlignCenter, + QStringLiteral("Ilk gecerli konum bekleniyor")); + } +} + +void GlobeWidget::rebuildStaticLayer() +{ + const qreal devicePixelRatio = devicePixelRatioF(); + const QSize pixelSize( + std::max(1, qRound(width() * devicePixelRatio)), + std::max(1, qRound(height() * devicePixelRatio))); + QPixmap layer(pixelSize); + layer.setDevicePixelRatio(devicePixelRatio); + layer.fill(Qt::transparent); + QPainter painter(&layer); + painter.setRenderHint(QPainter::Antialiasing, true); + + QLinearGradient background(0, 0, width(), height()); + background.setColorAt(0.0, QColor(3, 10, 21)); + background.setColorAt(0.55, QColor(8, 24, 43)); + background.setColorAt(1.0, QColor(2, 8, 17)); + painter.fillRect(rect(), background); + drawStars(painter); + + const double radius = std::min(width(), height()) * 0.255 * m_zoom; + const QPointF center(width() * 0.5, height() * 0.52); + + QRadialGradient atmosphere(center - QPointF(radius * .25, radius * .32), radius * 1.24); + atmosphere.setColorAt(0.66, QColor(19, 113, 177, 0)); + atmosphere.setColorAt(0.82, QColor(47, 169, 235, 48)); + atmosphere.setColorAt(0.92, QColor(51, 178, 239, 20)); + atmosphere.setColorAt(1.0, QColor(35, 126, 205, 0)); + painter.setPen(Qt::NoPen); + painter.setBrush(atmosphere); + painter.drawEllipse(center, radius * 1.25, radius * 1.25); + + QRadialGradient earth(center - QPointF(radius * .36, radius * .42), radius * 1.42); + earth.setColorAt(0.0, QColor(48, 154, 200)); + earth.setColorAt(0.37, QColor(18, 91, 144)); + earth.setColorAt(0.72, QColor(8, 47, 86)); + earth.setColorAt(1.0, QColor(1, 10, 24)); + painter.setBrush(earth); + painter.setPen(QPen(QColor(89, 196, 240, 150), 1.2)); + painter.drawEllipse(center, radius, radius); + + painter.save(); + painter.setClipPath(QPainterPath()); + QPainterPath sphereClip; + sphereClip.addEllipse(center, radius, radius); + painter.setClipPath(sphereClip); + drawContinents(painter); + drawGraticule(painter); + painter.restore(); + + for (int i = 0; i < 3; ++i) drawOrbit(painter, 55.0 + i * 1.8, i * 61.0 + 12.0); + painter.end(); + + m_staticLayer = std::move(layer); + m_staticLayerSize = size(); + m_staticLayerDevicePixelRatio = devicePixelRatio; + m_staticLayerYaw = m_yaw; + m_staticLayerPitch = m_pitch; + m_staticLayerZoom = m_zoom; +} + +void GlobeWidget::drawContinents(QPainter &painter) const +{ + const double radius = std::min(width(), height()) * 0.255 * m_zoom; + const double pixelRatio = m_dragging ? 0.75 : std::min(devicePixelRatioF(), 1.5); + const int textureSize = std::clamp( + static_cast(std::ceil(radius * 2.0 * pixelRatio)), + m_dragging ? 240 : 320, m_dragging ? 480 : 900); + const QPointF center(width() * 0.5, height() * 0.52); + const QRectF target(center.x() - radius, center.y() - radius, + radius * 2.0, radius * 2.0); + if (!m_landTexture.isNull() && m_landTexture.width() == textureSize + && m_landTextureYaw == m_yaw && m_landTexturePitch == m_pitch) { + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.drawImage(target, m_landTexture); + return; + } + QImage land(textureSize, textureSize, QImage::Format_ARGB32); + land.fill(Qt::transparent); + + const double cy = std::cos(m_yaw); + const double sy = std::sin(m_yaw); + const double cp = std::cos(m_pitch); + const double sp = std::sin(m_pitch); + for (int py = 0; py < textureSize; ++py) { + QRgb *scanLine = reinterpret_cast(land.scanLine(py)); + const double screenZ = -((py + 0.5) / textureSize * 2.0 - 1.0); + for (int px = 0; px < textureSize; ++px) { + const double screenX = (px + 0.5) / textureSize * 2.0 - 1.0; + const double radiusSquared = screenX * screenX + screenZ * screenZ; + if (radiusSquared > 1.0) continue; + + const double depth = std::sqrt(1.0 - radiusSquared); + const double yawY = cp * depth + sp * screenZ; + const double worldZ = -sp * depth + cp * screenZ; + const double worldX = cy * screenX + sy * yawY; + const double worldY = -sy * screenX + cy * yawY; + const double latitude = std::asin(std::clamp(worldZ, -1.0, 1.0)) * 180.0 / Pi; + const double longitude = std::atan2(worldY, worldX) * 180.0 / Pi; + const bool onLand = maskIsLand(longitude, latitude); + const bool nearLand = maskIsLand(longitude + 0.55, latitude) + || maskIsLand(longitude - 0.55, latitude) + || maskIsLand(longitude, latitude + 0.55) + || maskIsLand(longitude, latitude - 0.55); + if (!onLand) { + if (nearLand) { + const int alpha = static_cast(65.0 * std::clamp(depth * 1.5, 0.0, 1.0)); + scanLine[px] = qRgba(33, 151, 174, alpha); + } + continue; + } + + const double light = std::clamp(0.30 + 0.70 + * (0.35 - screenX * 0.38 + screenZ * 0.48 + depth * 0.38), 0.18, 1.0); + const double lon = longitude * Pi / 180.0; + const double lat = latitude * Pi / 180.0; + const double broadRelief = 0.50 + 0.24 * std::sin(lon * 5.0 + lat * 2.2) + + 0.16 * std::sin(lon * 13.0 - lat * 7.0) + + 0.10 * std::cos(lon * 29.0 + lat * 17.0); + const double relief = std::clamp(broadRelief, 0.0, 1.0); + const double aridBelt = std::exp(-std::pow((std::abs(latitude) - 25.0) / 13.0, 2.0)); + const double moisture = std::clamp(0.62 - aridBelt * 0.48 + + 0.18 * std::sin(lon * 3.0 - lat * 4.0), 0.0, 1.0); + const double highland = smoothStep(0.67, 0.92, relief); + const double snow = smoothStep(63.0, 78.0, std::abs(latitude)) + * (0.65 + highland * 0.35); + + const double lushRed = 30.0 + relief * 25.0; + const double lushGreen = 104.0 + relief * 34.0; + const double lushBlue = 63.0 + relief * 20.0; + const double dryRed = 151.0 + relief * 24.0; + const double dryGreen = 126.0 + relief * 18.0; + const double dryBlue = 72.0 + relief * 16.0; + double red = dryRed + (lushRed - dryRed) * moisture; + double green = dryGreen + (lushGreen - dryGreen) * moisture; + double blue = dryBlue + (lushBlue - dryBlue) * moisture; + red = red * (1.0 - highland * 0.30) + 104.0 * highland * 0.30; + green = green * (1.0 - highland * 0.30) + 91.0 * highland * 0.30; + blue = blue * (1.0 - highland * 0.30) + 76.0 * highland * 0.30; + red = red * (1.0 - snow) + 224.0 * snow; + green = green * (1.0 - snow) + 231.0 * snow; + blue = blue * (1.0 - snow) + 224.0 * snow; + + const bool coast = !maskIsLand(longitude + 0.55, latitude) + || !maskIsLand(longitude - 0.55, latitude) + || !maskIsLand(longitude, latitude + 0.55) + || !maskIsLand(longitude, latitude - 0.55); + const double shade = light * (0.86 + relief * 0.14); + if (coast) { + red += 28.0; + green += 31.0; + blue += 18.0; + } + scanLine[px] = qRgba( + std::clamp(static_cast(red * shade), 0, 255), + std::clamp(static_cast(green * shade), 0, 255), + std::clamp(static_cast(blue * shade), 0, 255), 238); + } + } + + m_landTexture = land; + m_landTextureYaw = m_yaw; + m_landTexturePitch = m_pitch; + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.drawImage(target, m_landTexture); +} + +void GlobeWidget::drawStars(QPainter &painter) const +{ + painter.setPen(Qt::NoPen); + for (int i = 0; i < 95; ++i) { + const int x = (i * 7919 + 37) % std::max(1, width()); + const int y = (i * 3571 + 71) % std::max(1, height()); + const int alpha = 45 + (i * 29) % 120; + const double size = 0.7 + (i % 4) * 0.35; + painter.setBrush(QColor(183, 218, 240, alpha)); + painter.drawEllipse(QPointF(x, y), size, size); + } +} + +void GlobeWidget::drawGraticule(QPainter &painter) const +{ + const double radius = std::min(width(), height()) * 0.255 * m_zoom; + painter.setPen(QPen(QColor(100, 193, 221, 48), 0.8)); + for (int latitude = -60; latitude <= 60; latitude += 30) { + QPainterPath path; + bool started = false; + for (int longitude = -180; longitude <= 180; longitude += 3) { + const Vec3 v = globeVector(latitude, longitude); + const auto p = project(v.x, v.y, v.z, radius); + if (p.depth >= 0.0) { + if (!started) path.moveTo(p.point); else path.lineTo(p.point); + started = true; + } else started = false; + } + painter.drawPath(path); + } + for (int longitude = -150; longitude < 180; longitude += 30) { + QPainterPath path; + bool started = false; + for (int latitude = -90; latitude <= 90; latitude += 2) { + const Vec3 v = globeVector(latitude, longitude); + const auto p = project(v.x, v.y, v.z, radius); + if (p.depth >= 0.0) { + if (!started) path.moveTo(p.point); else path.lineTo(p.point); + started = true; + } else started = false; + } + painter.drawPath(path); + } +} + +void GlobeWidget::drawOrbit(QPainter &painter, double inclination, double node) const +{ + const double radius = std::min(width(), height()) * 0.255 * m_zoom * 1.47; + const double inc = inclination * Pi / 180.0; + const double asc = node * Pi / 180.0; + QPainterPath back, front; + bool backStarted = false, frontStarted = false; + for (int degree = 0; degree <= 360; degree += 2) { + const double t = degree * Pi / 180.0; + const double ox = std::cos(t); + const double oy = std::sin(t) * std::cos(inc); + const double oz = std::sin(t) * std::sin(inc); + const double x = ox * std::cos(asc) - oy * std::sin(asc); + const double y = ox * std::sin(asc) + oy * std::cos(asc); + const auto p = project(x, y, oz, radius); + QPainterPath &path = p.depth < 0.0 ? back : front; + bool &started = p.depth < 0.0 ? backStarted : frontStarted; + if (!started) path.moveTo(p.point); else path.lineTo(p.point); + started = true; + if (p.depth < 0.0) frontStarted = false; else backStarted = false; + } + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(QColor(86, 130, 160, 44), 0.8, Qt::DashLine)); + painter.drawPath(back); + painter.setPen(QPen(QColor(89, 173, 214, 90), 1.0)); + painter.drawPath(front); +} + +void GlobeWidget::drawVisibilityCone(QPainter &painter) const +{ + if (!m_hasReceiver) return; + + const double earthRadiusPixels = std::min(width(), height()) * 0.255 * m_zoom; + constexpr double physicalOrbitRatio = 4.17; + constexpr double displayOrbitRatio = 1.47; + const Vec3 normal = globeVector(m_receiverLatitude, m_receiverLongitude); + + Vec3 reference = std::abs(normal.z) < 0.85 ? Vec3{0.0, 0.0, 1.0} : Vec3{1.0, 0.0, 0.0}; + Vec3 tangentA { + reference.y * normal.z - reference.z * normal.y, + reference.z * normal.x - reference.x * normal.z, + reference.x * normal.y - reference.y * normal.x + }; + const double tangentLength = std::sqrt(tangentA.x * tangentA.x + + tangentA.y * tangentA.y + + tangentA.z * tangentA.z); + tangentA = {tangentA.x / tangentLength, tangentA.y / tangentLength, + tangentA.z / tangentLength}; + const Vec3 tangentB { + normal.y * tangentA.z - normal.z * tangentA.y, + normal.z * tangentA.x - normal.x * tangentA.z, + normal.x * tangentA.y - normal.y * tangentA.x + }; + + const double elevation = m_elevationMaskDegrees * Pi / 180.0; + const double zenith = Pi / 2.0 - elevation; + const double lineDistance = -std::cos(zenith) + + std::sqrt(physicalOrbitRatio * physicalOrbitRatio + - std::sin(zenith) * std::sin(zenith)); + const auto apex = project(normal.x, normal.y, normal.z, earthRadiusPixels * 1.015); + const auto zenithPoint = project(normal.x, normal.y, normal.z, + earthRadiusPixels * displayOrbitRatio); + + QVector ring; + constexpr int Segments = 48; + ring.reserve(Segments + 1); + for (int segment = 0; segment <= Segments; ++segment) { + const double angle = 2.0 * Pi * segment / Segments; + const Vec3 tangent { + tangentA.x * std::cos(angle) + tangentB.x * std::sin(angle), + tangentA.y * std::cos(angle) + tangentB.y * std::sin(angle), + tangentA.z * std::cos(angle) + tangentB.z * std::sin(angle) + }; + const Vec3 direction { + normal.x * std::cos(zenith) + tangent.x * std::sin(zenith), + normal.y * std::cos(zenith) + tangent.y * std::sin(zenith), + normal.z * std::cos(zenith) + tangent.z * std::sin(zenith) + }; + const Vec3 orbitPoint { + (normal.x + lineDistance * direction.x) / physicalOrbitRatio, + (normal.y + lineDistance * direction.y) / physicalOrbitRatio, + (normal.z + lineDistance * direction.z) / physicalOrbitRatio + }; + ring.push_back(project(orbitPoint.x, orbitPoint.y, orbitPoint.z, + earthRadiusPixels * displayOrbitRatio)); + } + + painter.setPen(Qt::NoPen); + for (int i = 0; i < Segments; ++i) { + const double averageDepth = (apex.depth + ring[i].depth + ring[i + 1].depth) / 3.0; + const int alpha = averageDepth >= 0.0 ? 18 : 7; + painter.setBrush(QColor(48, 207, 218, alpha)); + painter.drawPolygon(QPolygonF{apex.point, ring[i].point, ring[i + 1].point}); + } + + QPainterPath boundary; + boundary.moveTo(ring.first().point); + for (int i = 1; i < ring.size(); ++i) boundary.lineTo(ring[i].point); + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(QColor(76, 222, 228, 105), 1.0, Qt::DashLine)); + painter.drawPath(boundary); + for (int i = 0; i < Segments; i += 8) { + painter.drawLine(apex.point, ring[i].point); + } + painter.setPen(QPen(QColor(119, 239, 241, 165), 1.4)); + painter.drawLine(apex.point, zenithPoint.point); + + const QPointF axis = zenithPoint.point - apex.point; + const double axisLength = std::hypot(axis.x(), axis.y()); + if (axisLength > 1.0) { + const QPointF unit = axis / axisLength; + const QPointF side(-unit.y(), unit.x()); + const QPointF arrowTip = zenithPoint.point; + painter.drawLine(arrowTip, arrowTip - unit * 8.0 + side * 4.0); + painter.drawLine(arrowTip, arrowTip - unit * 8.0 - side * 4.0); + } + if (apex.depth >= -0.05) { + painter.setPen(QColor(119, 239, 241, 190)); + painter.drawText(apex.point + QPointF(12, 18), + QStringLiteral("GORUS KONISI %1°") + .arg(m_elevationMaskDegrees, 0, 'f', 1)); + } +} + +void GlobeWidget::drawReceiver(QPainter &painter) const +{ + const double radius = std::min(width(), height()) * 0.255 * m_zoom; + const Vec3 v = globeVector(m_receiverLatitude, m_receiverLongitude); + const auto p = project(v.x, v.y, v.z, radius * 1.015); + if (p.depth < -0.05) return; + painter.setPen(QPen(QColor(255, 255, 255, 210), 1.5)); + painter.setBrush(QColor(255, 196, 61)); + painter.drawEllipse(p.point, 5.5, 5.5); + painter.setPen(QPen(QColor(255, 196, 61, 110), 1.2)); + painter.setBrush(Qt::NoBrush); + painter.drawEllipse(p.point, 11, 11); + painter.setPen(QColor(255, 224, 147)); + painter.drawText(p.point + QPointF(10, -8), QStringLiteral("ALICI")); +} + +int GlobeWidget::drawSatellites(QPainter &painter) const +{ + if (!m_hasReceiver) return 0; + const double orbitRadius = std::min(width(), height()) * 0.255 * m_zoom * 1.47; + struct Item { + SatellitePosition satellite; + ProjectedPoint projected; + double elevation = -90.0; + }; + QVector items; + items.reserve(m_satellites.size()); + for (const auto &satellite : m_satellites) { + const double length = std::sqrt(satellite.x * satellite.x + satellite.y * satellite.y + + satellite.z * satellite.z); + if (length <= 0.0) continue; + items.push_back({satellite, project(satellite.x / length, satellite.y / length, + satellite.z / length, orbitRadius), + satelliteElevation(satellite)}); + } + std::sort(items.begin(), items.end(), [](const Item &a, const Item &b) { + return a.projected.depth < b.projected.depth; + }); + const double earthRadius = std::min(width(), height()) * 0.255 * m_zoom; + const Vec3 receiver = globeVector(m_receiverLatitude, m_receiverLongitude); + const auto receiverPoint = project(receiver.x, receiver.y, receiver.z, + earthRadius * 1.02); + int visibleCount = 0; + for (const Item &item : items) { + const bool found = m_observedPrns.contains(item.satellite.prn); + const bool inElevationMask = item.elevation >= m_elevationMaskDegrees; + if (inElevationMask) ++visibleCount; + QColor color = found ? QColor(42, 224, 137) : QColor(255, 79, 95); + if (item.projected.depth < 0.0) color.setAlpha(75); + const double markerRadius = found ? 6.5 : 5.0; + if (inElevationMask) { + QColor sightLine(70, 224, 231, item.projected.depth < 0.0 ? 25 : 80); + painter.setPen(QPen(sightLine, 0.8, Qt::DashLine)); + painter.drawLine(receiverPoint.point, item.projected.point); + painter.setPen(QPen(QColor(90, 236, 239, sightLine.alpha()), 1.2)); + painter.setBrush(Qt::NoBrush); + painter.drawEllipse(item.projected.point, markerRadius + 3.0, markerRadius + 3.0); + } + painter.setPen(QPen(QColor(235, 248, 255, color.alpha()), 1.0)); + painter.setBrush(color); + painter.drawEllipse(item.projected.point, markerRadius, markerRadius); + if (item.projected.depth >= -0.05) { + painter.setPen(color.lighter(135)); + painter.drawText(item.projected.point + QPointF(7, -6), item.satellite.prn); + } + } + return visibleCount; +} + +double GlobeWidget::satelliteElevation(const SatellitePosition &satellite) const +{ + if (!m_hasReceiver) return -90.0; + constexpr double EarthRadiusMeters = 6378137.0; + const Vec3 receiverUnit = globeVector(m_receiverLatitude, m_receiverLongitude); + const Vec3 lineOfSight { + satellite.x - receiverUnit.x * EarthRadiusMeters, + satellite.y - receiverUnit.y * EarthRadiusMeters, + satellite.z - receiverUnit.z * EarthRadiusMeters + }; + const double length = std::sqrt(lineOfSight.x * lineOfSight.x + + lineOfSight.y * lineOfSight.y + + lineOfSight.z * lineOfSight.z); + if (length <= 0.0) return -90.0; + const double sineElevation = (lineOfSight.x * receiverUnit.x + + lineOfSight.y * receiverUnit.y + + lineOfSight.z * receiverUnit.z) / length; + return std::asin(std::clamp(sineElevation, -1.0, 1.0)) * 180.0 / Pi; +} + +void GlobeWidget::mousePressEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + m_dragging = true; + m_lastMousePosition = event->pos(); + setCursor(Qt::ClosedHandCursor); + } +} + +void GlobeWidget::mouseMoveEvent(QMouseEvent *event) +{ + if (!m_dragging) return; + const QPoint delta = event->pos() - m_lastMousePosition; + m_lastMousePosition = event->pos(); + m_yaw += delta.x() * 0.008; + m_pitch = std::clamp(m_pitch - delta.y() * 0.008, -1.45, 1.45); + update(); +} + +void GlobeWidget::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + m_dragging = false; + m_staticLayer = QPixmap(); + setCursor(Qt::OpenHandCursor); + update(); + } +} + +void GlobeWidget::wheelEvent(QWheelEvent *event) +{ + m_zoom = std::clamp(m_zoom + event->angleDelta().y() / 1200.0, 0.72, 1.28); + update(); + event->accept(); +} diff --git a/linux/src/GlobeWidget.h b/linux/src/GlobeWidget.h new file mode 100644 index 0000000..0164b18 --- /dev/null +++ b/linux/src/GlobeWidget.h @@ -0,0 +1,84 @@ +#pragma once + +#include "SatelliteData.h" + +#include +#include +#include +#include +#include +#include +#include + +class GlobeWidget : public QWidget { + Q_OBJECT + +public: + explicit GlobeWidget(QWidget *parent = nullptr); + + void setSatellites(const QVector &satellites); + void setSatelliteMotion(const QVector ¤t, + const QVector &next, + int durationMs); + void setObservedPrns(const QSet &prns); + void setReceiverLocation(double latitude, double longitude); + void clearReceiverLocation(); + void setRinexStatus(const QString &status, bool error); + [[nodiscard]] QSet observedPrns() const { return m_observedPrns; } + +public slots: + void setElevationMask(double degrees); + +protected: + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + +private: + struct ProjectedPoint { + QPointF point; + double depth = 0.0; + }; + + ProjectedPoint project(double x, double y, double z, double radius) const; + void drawStars(QPainter &painter) const; + void drawOrbit(QPainter &painter, double inclination, double node) const; + void drawContinents(QPainter &painter) const; + void drawVisibilityCone(QPainter &painter) const; + void drawGraticule(QPainter &painter) const; + void drawReceiver(QPainter &painter) const; + int drawSatellites(QPainter &painter) const; + double satelliteElevation(const SatellitePosition &satellite) const; + void updateSatelliteAnimation(); + void rebuildStaticLayer(); + + QVector m_satellites; + QVector m_satelliteMotionStart; + QVector m_satelliteMotionTarget; + QElapsedTimer m_satelliteMotionClock; + QTimer m_satelliteAnimationTimer; + int m_satelliteMotionDurationMs = 0; + mutable QImage m_landTexture; + mutable double m_landTextureYaw = 0.0; + mutable double m_landTexturePitch = 0.0; + QPixmap m_staticLayer; + QSize m_staticLayerSize; + qreal m_staticLayerDevicePixelRatio = 0.0; + double m_staticLayerYaw = 0.0; + double m_staticLayerPitch = 0.0; + double m_staticLayerZoom = 0.0; + QSet m_observedPrns; + QString m_status = QStringLiteral("Konum bekleniyor"); + bool m_statusError = false; + bool m_hasReceiver = false; + double m_receiverLatitude = 0.0; + double m_receiverLongitude = 0.0; + double m_yaw = -0.55; + double m_pitch = -0.28; + double m_zoom = 1.0; + double m_elevationMaskDegrees = 10.0; + bool m_dragging = false; + QPoint m_lastMousePosition; +}; diff --git a/src/LocationParser.cpp b/linux/src/LocationParser.cpp similarity index 100% rename from src/LocationParser.cpp rename to linux/src/LocationParser.cpp diff --git a/src/LocationParser.h b/linux/src/LocationParser.h similarity index 100% rename from src/LocationParser.h rename to linux/src/LocationParser.h diff --git a/linux/src/MainWindow.cpp b/linux/src/MainWindow.cpp new file mode 100644 index 0000000..d197d49 --- /dev/null +++ b/linux/src/MainWindow.cpp @@ -0,0 +1,1642 @@ +#include "MainWindow.h" + +#include "LocationParser.h" +#include "GlobeWidget.h" +#include "MapWidget.h" +#include "RinexNavigation.h" +#include "SerialReader.h" +#include "UartReplay.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +constexpr int LocationTimeoutMs = 10000; +constexpr int UiRefreshIntervalMs = 100; +constexpr int MaxPendingLogLines = 500; +constexpr double Pi = 3.14159265358979323846; +constexpr double EarthRadiusMeters = 6371008.8; + +struct LocationAnalysis { + bool valid = false; + double averageLat = 0.0; + double averageLon = 0.0; + double lastDeviationMeters = 0.0; + double meanDeviationMeters = 0.0; + double rmsScatterMeters = 0.0; + double maxDeviationMeters = 0.0; +}; + +double degreesToRadians(double degrees) +{ + return degrees * Pi / 180.0; +} + +double radiansToDegrees(double radians) +{ + return radians * 180.0 / Pi; +} + +double distanceMeters(double lat1, double lon1, double lat2, double lon2) +{ + const double phi1 = degreesToRadians(lat1); + const double phi2 = degreesToRadians(lat2); + const double dPhi = degreesToRadians(lat2 - lat1); + const double dLambda = degreesToRadians(lon2 - lon1); + const double sinHalfPhi = std::sin(dPhi / 2.0); + const double sinHalfLambda = std::sin(dLambda / 2.0); + const double a = sinHalfPhi * sinHalfPhi + + std::cos(phi1) * std::cos(phi2) * sinHalfLambda * sinHalfLambda; + const double c = 2.0 * std::atan2(std::sqrt(a), std::sqrt(std::max(0.0, 1.0 - a))); + return EarthRadiusMeters * c; +} + +std::optional parseCoordinateText(QString text, double minimum, double maximum) +{ + text = text.trimmed(); + text.replace(',', '.'); + bool ok = false; + const double value = text.toDouble(&ok); + if (!ok || !std::isfinite(value) || value < minimum || value > maximum) { + return std::nullopt; + } + return value; +} + +double calculateCep50(const QVector &points, + double referenceLatitude, double referenceLongitude) +{ + QVector radialErrors; + radialErrors.reserve(points.size()); + for (const TelemetryPoint &point : points) { + radialErrors.push_back(distanceMeters(referenceLatitude, referenceLongitude, + point.latitude, point.longitude)); + } + std::sort(radialErrors.begin(), radialErrors.end()); + + const int middle = radialErrors.size() / 2; + if (radialErrors.size() % 2 == 1) { + return radialErrors[middle]; + } + return (radialErrors[middle - 1] + radialErrors[middle]) / 2.0; +} + +LocationAnalysis analyzeLocations(const QVector &points) +{ + LocationAnalysis analysis; + if (points.isEmpty()) { + return analysis; + } + + double x = 0.0; + double y = 0.0; + double z = 0.0; + for (const TelemetryPoint &point : points) { + const double lat = degreesToRadians(point.latitude); + const double lon = degreesToRadians(point.longitude); + const double cosLat = std::cos(lat); + x += cosLat * std::cos(lon); + y += cosLat * std::sin(lon); + z += std::sin(lat); + } + + x /= points.size(); + y /= points.size(); + z /= points.size(); + + const double horizontal = std::sqrt(x * x + y * y); + analysis.averageLat = radiansToDegrees(std::atan2(z, horizontal)); + analysis.averageLon = radiansToDegrees(std::atan2(y, x)); + analysis.valid = true; + + double distanceSum = 0.0; + double squaredDistanceSum = 0.0; + for (const TelemetryPoint &point : points) { + const double distance = distanceMeters(point.latitude, point.longitude, + analysis.averageLat, analysis.averageLon); + distanceSum += distance; + squaredDistanceSum += distance * distance; + analysis.maxDeviationMeters = std::max(analysis.maxDeviationMeters, distance); + } + + analysis.meanDeviationMeters = distanceSum / points.size(); + analysis.rmsScatterMeters = std::sqrt(squaredDistanceSum / points.size()); + const TelemetryPoint &last = points.last(); + analysis.lastDeviationMeters = distanceMeters(last.latitude, last.longitude, + analysis.averageLat, analysis.averageLon); + return analysis; +} + +QVector preferredAnalysisPoints(const QVector &points) +{ + QVector corrected; + for (const TelemetryPoint &point : points) { + if (point.source == LocationSource::ImuCorrected) { + corrected.push_back(point); + } + } + return corrected.isEmpty() ? points : corrected; +} + +QString metersText(double meters) +{ + if (!std::isfinite(meters)) { + return QStringLiteral("-"); + } + + const int precision = meters < 10.0 ? 2 : (meters < 100.0 ? 1 : 0); + return QStringLiteral("%1 m").arg(meters, 0, 'f', precision); +} + +QString optionalDoubleText(const std::optional &value, int precision) +{ + if (!value.has_value()) { + return QStringLiteral("-"); + } + return QString::number(*value, 'f', precision); +} + +QString preciseCoordinateText(double value) +{ + return QString::number(value, 'f', 15); +} + +QString coordinateText(double value, const QString &sourceText) +{ + return sourceText.isEmpty() ? preciseCoordinateText(value) : sourceText; +} + +QString optionalIntText(const std::optional &value) +{ + if (!value.has_value()) { + return QStringLiteral("-"); + } + return QString::number(*value); +} + +QString optionalDopText(const std::optional &value) +{ + if (!value.has_value() || !std::isfinite(*value)) { + return QStringLiteral("-"); + } + return QString::number(*value, 'f', 3); +} + +QStringList detectedSerialPorts() +{ + const QDir deviceDirectory(QStringLiteral("/dev")); + QStringList patterns; +#ifdef Q_OS_MACOS + // macOS exposes USB UARTs as paired callout (cu.*) and terminal (tty.*) + // devices. Prefer cu.* because it does not wait for carrier detect. + patterns = { + QStringLiteral("cu.usb*"), QStringLiteral("cu.wchusbserial*"), + QStringLiteral("cu.SLAB_USBtoUART*"), QStringLiteral("cu.*USB*"), + QStringLiteral("tty.usb*"), QStringLiteral("tty.wchusbserial*"), + QStringLiteral("tty.SLAB_USBtoUART*"), QStringLiteral("tty.*USB*")}; +#else + patterns = {QStringLiteral("ttyUSB*"), QStringLiteral("ttyACM*")}; +#endif + + QStringList ports; + for (const QString &pattern : patterns) { + const QStringList names = deviceDirectory.entryList( + {pattern}, QDir::System | QDir::Files, QDir::Name); + for (const QString &name : names) { + const QString path = QStringLiteral("/dev/") + name; + if (!ports.contains(path)) { + ports.push_back(path); + } + } + } + return ports; +} + +QString fallbackSerialPort() +{ +#ifdef Q_OS_MACOS + return QStringLiteral("/dev/cu.usbserial"); +#else + return QStringLiteral("/dev/ttyUSB2"); +#endif +} + +void mergeDopValues(DopValues &target, const DopValues &source) +{ + if (source.gdop.has_value()) { + target.gdop = source.gdop; + } + if (source.pdop.has_value()) { + target.pdop = source.pdop; + } + if (source.hdop.has_value()) { + target.hdop = source.hdop; + } + if (source.vdop.has_value()) { + target.vdop = source.vdop; + } + if (source.tdop.has_value()) { + target.tdop = source.tdop; + } +} + +QString satelliteQualityText(const std::optional &satelliteCount) +{ + if (!satelliteCount.has_value()) { + return QStringLiteral("Uydu bekleniyor"); + } + if (*satelliteCount >= 5) { + return QStringLiteral("Cozum icin yeterli"); + } + if (*satelliteCount >= 4) { + return QStringLiteral("Minimum navigasyon seviyesi"); + } + return QStringLiteral("Konum cozumu icin yetersiz"); +} + +} // namespace + +class TelemetryTableModel final : public QAbstractTableModel { +public: + explicit TelemetryTableModel(QObject *parent = nullptr) + : QAbstractTableModel(parent) + { + } + + void setPoints(const QVector &points) + { + beginResetModel(); + m_points = points; + endResetModel(); + } + + int rowCount(const QModelIndex &parent = QModelIndex()) const override + { + return parent.isValid() ? 0 : m_points.size(); + } + + int columnCount(const QModelIndex &parent = QModelIndex()) const override + { + return parent.isValid() ? 0 : 8; + } + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override + { + if (!index.isValid() || index.row() < 0 || index.row() >= m_points.size()) { + return {}; + } + if (role == Qt::TextAlignmentRole) { + return Qt::AlignCenter; + } + if (role != Qt::DisplayRole) { + return {}; + } + + const TelemetryPoint &point = m_points[index.row()]; + switch (index.column()) { + case 0: + return point.receivedAt.toLocalTime().toString(QStringLiteral("HH:mm:ss.zzz")); + case 1: + if (point.source == LocationSource::ImuCorrected + && point.fusionMode == FusionMode::DeadReckoning) { + return QStringLiteral("IMU corrected (DR)"); + } + if (point.source == LocationSource::ImuCorrected + && point.fusionMode == FusionMode::GpsAided) { + return QStringLiteral("IMU corrected (GPS)"); + } + return locationSourceText(point.source); + case 2: + return coordinateText(point.latitude, point.latitudeText); + case 3: + return coordinateText(point.longitude, point.longitudeText); + case 4: + return optionalDoubleText(point.hheight, 2); + case 5: + return optionalIntText(point.satellites); + case 6: + return optionalDopText(point.dop.pdop); + case 7: + return point.gpsAgeMs.has_value() + ? QStringLiteral("%1 ms").arg(*point.gpsAgeMs) + : QStringLiteral("-"); + default: + return {}; + } + } + + QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const override + { + if (role != Qt::DisplayRole || orientation != Qt::Horizontal) { + return {}; + } + static const QStringList headers = { + QStringLiteral("Saat"), QStringLiteral("Kaynak"), QStringLiteral("Lat"), QStringLiteral("Long"), + QStringLiteral("HHeight"), QStringLiteral("Uydu"), QStringLiteral("PDOP"), + QStringLiteral("GPS yas")}; + return section >= 0 && section < headers.size() ? headers[section] : QVariant(); + } + +private: + QVector m_points; +}; + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent) + , m_serial(new SerialReader(this)) + , m_replay(new UartReplay(this)) + , m_rinex(new RinexNavigation(this)) +{ + buildUi(); + + m_locationTimeoutTimer = new QTimer(this); + m_locationTimeoutTimer->setSingleShot(true); + m_locationTimeoutTimer->setInterval(LocationTimeoutMs); + + m_uiRefreshTimer = new QTimer(this); + m_uiRefreshTimer->setSingleShot(true); + m_uiRefreshTimer->setInterval(UiRefreshIntervalMs); + + connect(m_serial, &SerialReader::lineReceived, this, &MainWindow::handleSerialLine); + connect(m_serial, &SerialReader::statusChanged, this, &MainWindow::handleSerialStatus); + connect(m_serial, &SerialReader::errorOccurred, this, &MainWindow::handleSerialError); + connect(m_replay, &UartReplay::lineReady, this, &MainWindow::handleSerialLine); + connect(m_replay, &UartReplay::replayStarted, this, + [this](int lineCount, int locationCount) { + const QString message = QStringLiteral( + "UART replay basladi: %1 konum, %2 satir") + .arg(locationCount).arg(lineCount); + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[STATUS] ") + message); + updateConnectionControls(); + }); + connect(m_replay, &UartReplay::progressChanged, this, + [this](int processed, int total) { + if (processed == total || processed % 100 == 0) { + m_statusLabel->setText(QStringLiteral("UART replay: %1 / %2 satir") + .arg(processed).arg(total)); + } + }); + connect(m_replay, &UartReplay::replayFinished, this, [this] { + commitPendingTelemetry(); + flushUiUpdates(); + m_map->fitToPoints(); + const QString message = QStringLiteral("UART replay tamamlandi: %1 konum") + .arg(m_points.size()); + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[STATUS] ") + message); + statusBar()->showMessage(message, 8000); + updateConnectionControls(); + }); + connect(m_replay, &UartReplay::errorOccurred, this, [this](const QString &message) { + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[ERROR] ") + message); + statusBar()->showMessage(message, 8000); + updateConnectionControls(); + }); + connect(m_locationTimeoutTimer, &QTimer::timeout, this, &MainWindow::handleLocationTimeout); + connect(m_uiRefreshTimer, &QTimer::timeout, this, &MainWindow::flushUiUpdates); + connect(m_rinex, &RinexNavigation::satelliteMotionUpdated, + m_globe, &GlobeWidget::setSatelliteMotion); + connect(m_rinex, &RinexNavigation::statusChanged, this, + [this](const QString &status, bool error) { + m_globe->setRinexStatus(status, error); + statusBar()->showMessage(status, error ? 8000 : 4000); + }); + connect(m_rinex, &RinexNavigation::loadingChanged, this, [this](bool loading) { + m_refreshRinexButton->setEnabled(!loading); + m_refreshRinexButton->setText( + loading ? QStringLiteral("RINEX indiriliyor...") + : QStringLiteral("RINEX Guncelle")); + }); + connect(m_refreshRinexButton, &QPushButton::clicked, + m_rinex, &RinexNavigation::refresh); + m_rinex->refresh(); + auto *rinexRefreshTimer = new QTimer(this); + rinexRefreshTimer->setInterval(15 * 60 * 1000); + connect(rinexRefreshTimer, &QTimer::timeout, m_rinex, &RinexNavigation::refresh); + rinexRefreshTimer->start(); + + resize(1480, 900); + setWindowTitle(QStringLiteral("Canli Konum Haritasi")); +} + +void MainWindow::closeEvent(QCloseEvent *event) +{ + m_replay->stop(); + stopUartLogging(); + QMainWindow::closeEvent(event); +} + +void MainWindow::toggleSerial() +{ + if (m_replay->isRunning()) { + stopUartReplay(); + } + if (m_serial->isOpen()) { + m_serial->closePort(); + stopUartLogging(); + if (m_locationTimeoutTimer != nullptr) { + m_locationTimeoutTimer->stop(); + } + updateConnectionControls(); + return; + } + + const QString port = m_portCombo->currentText().trimmed(); + const int baudRate = selectedBaudRate(); + if (m_serial->openPort(port, baudRate)) { + startUartLogging(port, baudRate); + updateConnectionControls(); + } +} + +bool MainWindow::startUartReplay(const QString &filePath, int gpsIntervalMs, + int sensorIntervalMs) +{ + if (m_serial->isOpen()) { + m_serial->closePort(); + stopUartLogging(); + } + m_replay->stop(); + resetTelemetryState(); + + if (!m_replay->start(filePath, gpsIntervalMs, sensorIntervalMs)) { + updateConnectionControls(); + return false; + } + + m_maxPointsSpin->setValue(std::clamp(m_replay->locationCount(), 1, 10000)); + statusBar()->showMessage( + QStringLiteral("UART replay: GPS %1 ms, sensor/fused %2 ms") + .arg(gpsIntervalMs).arg(sensorIntervalMs), 8000); + updateConnectionControls(); + return true; +} + +void MainWindow::stopUartReplay() +{ + if (!m_replay->isRunning()) { + return; + } + m_replay->stop(); + commitPendingTelemetry(); + flushUiUpdates(); + const QString message = QStringLiteral("UART replay durduruldu: %1 konum") + .arg(m_points.size()); + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[STATUS] ") + message); + statusBar()->showMessage(message, 5000); + updateConnectionControls(); +} + +bool MainWindow::startUartLogging(const QString &port, int baudRate) +{ + stopUartLogging(); + + QString logDirectory = QString::fromLocal8Bit(qgetenv("LOCATION_PLOTTER_UART_LOG_DIR")); + if (logDirectory.isEmpty()) { + logDirectory = QDir(QStringLiteral(LOCATION_PLOTTER_SOURCE_DIR)) + .filePath(QStringLiteral("uart_logs")); + } else if (QDir::isRelativePath(logDirectory)) { + logDirectory = QDir::current().absoluteFilePath(logDirectory); + } + + if (!QDir().mkpath(logDirectory)) { + const QString message = QStringLiteral("UART log klasoru olusturulamadi: %1") + .arg(logDirectory); + m_log->appendPlainText(QStringLiteral("[ERROR] ") + message); + statusBar()->showMessage(message, 8000); + return false; + } + + const QString fileName = QStringLiteral("uart_%1.log").arg( + QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_HHmmss_zzz"))); + m_uartLogPath = QDir(logDirectory).filePath(fileName); + m_uartLogFile = new QFile(m_uartLogPath, this); + if (!m_uartLogFile->open(QIODevice::WriteOnly | QIODevice::Text)) { + const QString message = QStringLiteral("UART log dosyasi acilamadi: %1") + .arg(m_uartLogFile->errorString()); + m_log->appendPlainText(QStringLiteral("[ERROR] ") + message); + statusBar()->showMessage(message, 8000); + delete m_uartLogFile; + m_uartLogFile = nullptr; + m_uartLogPath.clear(); + return false; + } + + const QString header = QStringLiteral("[%1] [SESSION START] port=%2 baud=%3\n") + .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")), + port) + .arg(baudRate); + m_uartLogFile->write(header.toUtf8()); + m_uartLogFile->flush(); + statusBar()->showMessage(QStringLiteral("UART log kaydi: %1").arg(m_uartLogPath), 8000); + return true; +} + +void MainWindow::stopUartLogging() +{ + if (m_uartLogFile == nullptr) { + return; + } + if (m_uartLogFile->isOpen()) { + const QString footer = QStringLiteral("[%1] [SESSION END]\n").arg( + QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz"))); + m_uartLogFile->write(footer.toUtf8()); + m_uartLogFile->flush(); + m_uartLogFile->close(); + } + delete m_uartLogFile; + m_uartLogFile = nullptr; + m_uartLogPath.clear(); +} + +void MainWindow::appendUartLogLine(const QString &line) +{ + if (m_uartLogFile == nullptr || !m_uartLogFile->isOpen()) { + return; + } + QString cleanLine = line; + cleanLine.replace(QLatin1Char('\r'), QStringLiteral("\\r")); + cleanLine.replace(QLatin1Char('\n'), QStringLiteral("\\n")); + const QString entry = QStringLiteral("[%1] %2\n") + .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")), + cleanLine); + m_uartLogFile->write(entry.toUtf8()); + m_uartLogFile->flush(); +} + +void MainWindow::clearPoints() +{ + if (m_locationTimeoutTimer != nullptr) { + m_locationTimeoutTimer->stop(); + } + resetTelemetryState(); +} + +bool MainWindow::loadTelemetryLog(const QString &filePath) +{ + m_replay->stop(); + updateConnectionControls(); + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + const QString message = QStringLiteral("Log acilamadi: %1").arg(file.errorString()); + m_statusLabel->setText(message); + statusBar()->showMessage(message, 8000); + return false; + } + const QList lines = file.readAll().split('\n'); + resetTelemetryState(); + const int replayCapacity = static_cast(std::min(10000, lines.size())); + m_maxPointsSpin->setValue(std::max(10, replayCapacity)); + int parsedLocations = 0; + for (const QByteArray &rawLine : lines) { + const QString line = QString::fromUtf8(rawLine).trimmed(); + if (line.isEmpty() || line.startsWith(QLatin1Char('#'))) { + continue; + } + const auto telemetry = LocationParser::parseLine(line); + if (telemetry.has_value() && telemetry->hasLocation()) { + ++parsedLocations; + } + handleSerialLine(line); + } + flushUiUpdates(); + m_map->fitToPoints(); + const QString message = QStringLiteral("Replay yuklendi: %1 konum - %2") + .arg(parsedLocations).arg(filePath); + m_statusLabel->setText(message); + statusBar()->showMessage(message, 8000); + return parsedLocations > 0; +} + +void MainWindow::openTelemetryLog() +{ + const QString filePath = QFileDialog::getOpenFileName( + this, QStringLiteral("Sentetik telemetri logunu ac"), QString(), + QStringLiteral("Telemetry logs (*.log *.txt);;All files (*)")); + if (!filePath.isEmpty()) { + loadTelemetryLog(filePath); + } +} + +void MainWindow::openUartReplay() +{ + if (m_replay->isRunning()) { + stopUartReplay(); + return; + } + + QSettings settings; + QDialog dialog(this); + dialog.setWindowTitle(QStringLiteral("UART Log Replay")); + dialog.setMinimumWidth(620); + + auto *layout = new QVBoxLayout(&dialog); + auto *form = new QFormLayout(); + auto *fileRow = new QWidget(&dialog); + auto *fileLayout = new QHBoxLayout(fileRow); + fileLayout->setContentsMargins(0, 0, 0, 0); + auto *pathEdit = new QLineEdit(fileRow); + pathEdit->setObjectName(QStringLiteral("uartReplayPathInput")); + pathEdit->setText(settings.value(QStringLiteral("replay/file")).toString()); + pathEdit->setPlaceholderText(QStringLiteral("UART .log veya .txt dosyasi")); + auto *browseButton = new QPushButton(QStringLiteral("Sec..."), fileRow); + fileLayout->addWidget(pathEdit, 1); + fileLayout->addWidget(browseButton); + form->addRow(QStringLiteral("Kayitli UART verisi"), fileRow); + + auto *gpsInterval = new QSpinBox(&dialog); + gpsInterval->setObjectName(QStringLiteral("uartReplayGpsIntervalInput")); + gpsInterval->setRange(1, 60000); + gpsInterval->setSuffix(QStringLiteral(" ms")); + gpsInterval->setValue(settings.value(QStringLiteral("replay/gpsIntervalMs"), 200).toInt()); + form->addRow(QStringLiteral("Raw GPS guncelleme"), gpsInterval); + + auto *sensorInterval = new QSpinBox(&dialog); + sensorInterval->setObjectName(QStringLiteral("uartReplaySensorIntervalInput")); + sensorInterval->setRange(1, 60000); + sensorInterval->setSuffix(QStringLiteral(" ms")); + sensorInterval->setValue(settings.value( + QStringLiteral("replay/sensorIntervalMs"), 50).toInt()); + form->addRow(QStringLiteral("Sensor / fused guncelleme"), sensorInterval); + layout->addLayout(form); + + auto *hint = new QLabel(QStringLiteral( + "Log zaman damgasi gerektirmez. source=gps kayitlari GPS hizinda; " + "source=imu_corrected ve [FUSION] kayitlari sensor hizinda yeniden uretilir."), + &dialog); + hint->setWordWrap(true); + layout->addWidget(hint); + + auto *buttons = new QDialogButtonBox( + QDialogButtonBox::Cancel | QDialogButtonBox::Ok, &dialog); + buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("Replay Baslat")); + layout->addWidget(buttons); + + connect(browseButton, &QPushButton::clicked, &dialog, [this, pathEdit] { + QString initialPath = pathEdit->text().trimmed(); + if (initialPath.isEmpty()) { + initialPath = QDir(QStringLiteral(LOCATION_PLOTTER_SOURCE_DIR)) + .filePath(QStringLiteral("uart_logs")); + } + const QString filePath = QFileDialog::getOpenFileName( + this, QStringLiteral("UART replay logunu sec"), initialPath, + QStringLiteral("UART logs (*.log *.txt);;All files (*)")); + if (!filePath.isEmpty()) { + pathEdit->setText(filePath); + } + }); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + connect(buttons, &QDialogButtonBox::accepted, &dialog, [&dialog, pathEdit] { + const QFileInfo selectedFile(pathEdit->text().trimmed()); + if (!selectedFile.exists() || !selectedFile.isFile()) { + QMessageBox::warning(&dialog, QStringLiteral("UART Replay"), + QStringLiteral("Gecerli bir UART log dosyasi secin.")); + return; + } + dialog.accept(); + }); + + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const QString filePath = QFileInfo(pathEdit->text().trimmed()).absoluteFilePath(); + settings.setValue(QStringLiteral("replay/file"), filePath); + settings.setValue(QStringLiteral("replay/gpsIntervalMs"), gpsInterval->value()); + settings.setValue(QStringLiteral("replay/sensorIntervalMs"), sensorInterval->value()); + startUartReplay(filePath, gpsInterval->value(), sensorInterval->value()); +} + +void MainWindow::refreshSerialPorts() +{ + const QString previousPort = m_portCombo->currentText().trimmed(); + const QString configuredPort = qEnvironmentVariable("LOCATION_PLOTTER_PORT").trimmed(); + const QStringList detectedPorts = detectedSerialPorts(); + + m_portCombo->clear(); + for (const QString &port : detectedPorts) { + m_portCombo->addItem(port); + } + + QString selectedPort; + if (!configuredPort.isEmpty()) { + selectedPort = configuredPort; + } else if (!previousPort.isEmpty() && detectedPorts.contains(previousPort)) { + selectedPort = previousPort; + } else if (!detectedPorts.isEmpty()) { + selectedPort = detectedPorts.first(); + } else { + selectedPort = previousPort.isEmpty() ? fallbackSerialPort() : previousPort; + } + + if (m_portCombo->findText(selectedPort) < 0) { + m_portCombo->addItem(selectedPort); + } + m_portCombo->setCurrentText(selectedPort); +} + +void MainWindow::handleSerialLine(const QString &line) +{ + appendUartLogLine(line); + static const QRegularExpression solveStartRegex( + QStringLiteral(R"((?:^|\[NAV\]\s*)nav\s+solve\s+(?:start|period)\b)"), + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression trackingSnapshotRegex( + QStringLiteral( + R"(\btracked\s+satellites\s*=\s*\d+\s+(?:prn[_ -]?indices|prns?|svns?)\s*=\s*\[[^\]]*\])"), + QRegularExpression::CaseInsensitiveOption); + const bool solveStarted = solveStartRegex.match(line.trimmed()).hasMatch(); + const bool trackingSnapshot = trackingSnapshotRegex.match(line).hasMatch(); + if (solveStarted) { + m_observedPrns.clear(); + } + const QSet linePrns = LocationParser::parseGpsPrns(line); + if (trackingSnapshot) { + // This line is a complete point-in-time channel snapshot. Replacing + // prevents satellites from older solves remaining green indefinitely. + m_observedPrns = linePrns; + } else if (!linePrns.isEmpty()) { + m_observedPrns.unite(linePrns); + } + if (solveStarted || trackingSnapshot || !linePrns.isEmpty()) { + m_globe->setObservedPrns(m_observedPrns); + } + + const std::optional telemetry = LocationParser::parseLine(line); + const bool solveFinished = line.trimmed().startsWith( + QStringLiteral("NAV solve done"), Qt::CaseInsensitive); + appendLogLine(line, telemetry.has_value() || solveFinished); + + if (!telemetry.has_value()) { + if (solveFinished && m_pendingTelemetry.hasLocation()) { + commitPendingTelemetry(); + } + return; + } + + if (telemetry->satellites.has_value()) { + m_lastSatelliteCount = telemetry->satellites; + } + if (telemetry->dop.hasAnyValue()) { + mergeDopValues(m_lastDop, telemetry->dop); + } + + if (telemetry->hasLocation()) { + m_pendingTelemetry = {}; + addTelemetryPoint(*telemetry); + return; + } + + const bool hasLocationPart = telemetry->latitude.has_value() || telemetry->longitude.has_value() + || telemetry->hheight.has_value(); + if (hasLocationPart) { + mergePendingTelemetry(*telemetry); + if (m_pendingTelemetry.hasLocation() && telemetry->hheight.has_value()) { + commitPendingTelemetry(); + } else { + scheduleTelemetryRefresh(); + } + } else if (m_pendingTelemetry.hasLocation() && solveFinished) { + commitPendingTelemetry(); + } else { + scheduleTelemetryRefresh(); + } +} + +void MainWindow::handleSerialStatus(const QString &status) +{ + flushPendingLogLines(); + m_statusLabel->setText(status); + m_log->appendPlainText(QStringLiteral("[STATUS] ") + status); + statusBar()->showMessage(status, 4000); + updateConnectionControls(); +} + +void MainWindow::handleSerialError(const QString &message) +{ + stopUartLogging(); + flushPendingLogLines(); + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[ERROR] ") + message); + statusBar()->showMessage(message, 8000); + updateConnectionControls(); +} + +void MainWindow::handleLocationTimeout() +{ + resetTelemetryState(); + + const QString message = QStringLiteral("10 sn konum gelmedi, degerler sifirlandi"); + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[STATUS] ") + message); + statusBar()->showMessage(message, 8000); +} + +void MainWindow::trimToMaxPoints() +{ + const int maxPoints = m_maxPointsSpin->value(); + while (m_points.size() > maxPoints) { + m_points.removeFirst(); + } + m_telemetryRefreshPending = false; + refreshTelemetryViews(); +} + +void MainWindow::updateReferenceLocation() +{ + const QString latitudeText = m_referenceLatEdit->text().trimmed(); + const QString longitudeText = m_referenceLonEdit->text().trimmed(); + const std::optional latitude = parseCoordinateText(latitudeText, -90.0, 90.0); + const std::optional longitude = parseCoordinateText(longitudeText, -180.0, 180.0); + + const bool latitudeInvalid = !latitudeText.isEmpty() && !latitude.has_value(); + const bool longitudeInvalid = !longitudeText.isEmpty() && !longitude.has_value(); + m_referenceLatEdit->setStyleSheet(latitudeInvalid + ? QStringLiteral("border: 1px solid #b42318;") : QString()); + m_referenceLonEdit->setStyleSheet(longitudeInvalid + ? QStringLiteral("border: 1px solid #b42318;") : QString()); + + if (latitude.has_value() && longitude.has_value()) { + m_referenceLatitude = latitude; + m_referenceLongitude = longitude; + } else { + m_referenceLatitude.reset(); + m_referenceLongitude.reset(); + } + updateAnalysis(); +} + +void MainWindow::flushUiUpdates() +{ + flushPendingLogLines(); + if (!m_telemetryRefreshPending) { + return; + } + + m_telemetryRefreshPending = false; + refreshTelemetryViews(); +} + +void MainWindow::buildUi() +{ + auto *central = new QWidget(this); + central->setObjectName(QStringLiteral("centralRoot")); + central->setStyleSheet(QStringLiteral(R"( + #centralRoot { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 #08111d, stop:0.5 #101d2b, stop:1 #172737); + } + #brandBar, #controlBar { + background: rgba(18, 31, 45, 248); + border: 1px solid #30465a; + border-radius: 10px; + } + QLabel#appTitle { + color: #f2f7fb; + font-size: 18px; + font-weight: 800; + } + QLabel#appSubtitle { + color: #9fb0c1; + font-size: 11px; + } + QLabel#liveBadge { + color: #08745f; + background: #e4f5ef; + border: 1px solid #b7e4d5; + border-radius: 10px; + padding: 4px 10px; + font-weight: 700; + } + QGroupBox { + background: rgba(20, 34, 48, 248); + border: 1px solid #344a5f; + border-radius: 8px; + margin-top: 10px; + padding: 10px 8px 8px 8px; + font-weight: 700; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 4px; + color: #dce8f2; + } + QLabel { + color: #d5e0e9; + } + QLabel#metricValue { + color: #62b8e8; + font-size: 30px; + font-weight: 800; + } + QLabel#subtleText { + color: #94a8b9; + font-weight: 500; + } + QLabel#dopValue { + color: #eef5fa; + font-weight: 800; + } + QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QPlainTextEdit, QTableView { + color: #e7eef4; + background: #0d1925; + border: 1px solid #3b5267; + border-radius: 6px; + padding: 4px; + selection-background-color: #2d6f9f; + } + QComboBox QAbstractItemView { + color: #e7eef4; + background-color: #132332; + border: 1px solid #4b657c; + selection-color: #ffffff; + selection-background-color: #23658a; + outline: 0; + padding: 4px; + } + QComboBox QAbstractItemView::item { + min-height: 26px; + padding: 3px 8px; + } + QPushButton, QToolButton { + color: #e7eef4; + background: #182b3d; + border: 1px solid #456078; + border-radius: 6px; + padding: 5px 10px; + font-weight: 600; + } + QPushButton:hover, QToolButton:hover { + background: #23445d; + border-color: #62b8e8; + } + QPushButton:pressed, QToolButton:pressed { background: #dbeaf6; } + QCheckBox { spacing: 7px; color: #d5e0e9; } + QCheckBox::indicator { width: 17px; height: 17px; } + QCheckBox::indicator:unchecked { + background: #0d1925; border: 1px solid #6d8294; border-radius: 4px; + } + QCheckBox::indicator:checked { + background: #167b70; border: 1px solid #167b70; border-radius: 4px; + } + QHeaderView::section { + color: #dce8f2; + background: #1d3143; + border: 0; + border-right: 1px solid #d8dee7; + padding: 5px; + font-weight: 700; + } + QTabWidget::pane { + border: 1px solid #344a5f; + background: #081522; + } + QTabBar::tab { + color: #9fb0c1; + background: #101f2d; + border: 1px solid #344a5f; + border-bottom: 0; + padding: 7px 14px; + margin-right: 2px; + } + QTabBar::tab:selected { + color: #f1f7fb; + background: #1d3850; + border-top: 2px solid #57bceb; + } + QSplitter::handle { background: #263c4f; } + QSplitter::handle:hover { background: #57bceb; } + QScrollArea, QScrollArea > QWidget > QWidget { background: transparent; } + QWidget#dashboardPanel { background: #0b1723; } + )")); + auto *rootLayout = new QVBoxLayout(central); + rootLayout->setContentsMargins(10, 10, 10, 10); + rootLayout->setSpacing(8); + + auto *brandBar = new QWidget(central); + brandBar->setObjectName(QStringLiteral("brandBar")); + auto *brandLayout = new QHBoxLayout(brandBar); + brandLayout->setContentsMargins(14, 8, 14, 8); + auto *logoLabel = new QLabel(brandBar); + const QPixmap logo(QStringLiteral(":/resources/ankasys-logo.png")); + logoLabel->setPixmap(logo.scaled(124, 52, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + logoLabel->setFixedSize(132, 54); + logoLabel->setAlignment(Qt::AlignCenter); + logoLabel->setToolTip(QStringLiteral("ANKASYS - Anka Microelectronic Systems")); + auto *titleLayout = new QVBoxLayout(); + titleLayout->setSpacing(1); + auto *titleLabel = new QLabel(QStringLiteral("GNSS Konum Izleme Merkezi"), brandBar); + titleLabel->setObjectName(QStringLiteral("appTitle")); + auto *subtitleLabel = new QLabel(QStringLiteral("Gercek zamanli telemetri, sapma ve CEP50 analizi"), brandBar); + subtitleLabel->setObjectName(QStringLiteral("appSubtitle")); + titleLayout->addWidget(titleLabel); + titleLayout->addWidget(subtitleLabel); + auto *liveBadge = new QLabel(QStringLiteral("● SISTEM HAZIR"), brandBar); + liveBadge->setObjectName(QStringLiteral("liveBadge")); + brandLayout->addWidget(logoLabel); + brandLayout->addLayout(titleLayout); + brandLayout->addStretch(1); + brandLayout->addWidget(liveBadge); + rootLayout->addWidget(brandBar); + + auto *controlBar = new QWidget(central); + controlBar->setObjectName(QStringLiteral("controlBar")); + auto *toolbarLayout = new QHBoxLayout(controlBar); + toolbarLayout->setContentsMargins(12, 7, 12, 7); + toolbarLayout->setSpacing(8); + + m_portCombo = new QComboBox(this); + m_portCombo->setObjectName(QStringLiteral("portSelector")); + m_portCombo->setMinimumWidth(220); + m_portCombo->setEditable(true); + m_portCombo->setToolTip(QStringLiteral( + "USB UART portu. macOS'ta /dev/cu.usb... tercih edilir; /dev/tty.usb... da desteklenir.")); + refreshSerialPorts(); + + auto *refreshPortsButton = new QToolButton(this); + refreshPortsButton->setObjectName(QStringLiteral("refreshPortsButton")); + refreshPortsButton->setText(QStringLiteral("Yenile")); + refreshPortsButton->setToolTip(QStringLiteral("Seri port listesini yenile")); + + m_baudCombo = new QComboBox(this); + for (const int baud : {9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600}) { + m_baudCombo->addItem(QString::number(baud)); + } + m_baudCombo->setCurrentText(QStringLiteral("460800")); + + m_maxPointsSpin = new QSpinBox(this); + m_maxPointsSpin->setObjectName(QStringLiteral("maxPointsInput")); + m_maxPointsSpin->setRange(1, 10000); + m_maxPointsSpin->setValue(10); + m_maxPointsSpin->setSuffix(QStringLiteral(" nokta")); + + m_connectButton = new QPushButton(this); + m_connectButton->setIcon(style()->standardIcon(QStyle::SP_MediaPlay)); + m_clearButton = new QPushButton(QStringLiteral("Temizle"), this); + m_clearButton->setIcon(style()->standardIcon(QStyle::SP_TrashIcon)); + m_openLogButton = new QPushButton(QStringLiteral("Senaryo Logu Ac"), this); + m_openLogButton->setObjectName(QStringLiteral("openLogButton")); + m_openLogButton->setIcon(style()->standardIcon(QStyle::SP_DialogOpenButton)); + m_replayButton = new QPushButton(QStringLiteral("UART Replay"), this); + m_replayButton->setObjectName(QStringLiteral("uartReplayButton")); + m_replayButton->setIcon(style()->standardIcon(QStyle::SP_MediaPlay)); + m_replayButton->setToolTip(QStringLiteral( + "Kayitli UART verisini GPS ve sensor guncelleme hizlariyla yeniden oynat")); + + m_autoFollowCheck = new QCheckBox(QStringLiteral("Kamerayi takip et"), this); + m_autoFollowCheck->setChecked(true); + + auto *zoomInButton = new QToolButton(this); + zoomInButton->setText(QStringLiteral("+")); + zoomInButton->setToolTip(QStringLiteral("Yaklastir")); + auto *zoomOutButton = new QToolButton(this); + zoomOutButton->setText(QStringLiteral("-")); + zoomOutButton->setToolTip(QStringLiteral("Uzaklastir")); + + toolbarLayout->addWidget(new QLabel(QStringLiteral("Port"), this)); + toolbarLayout->addWidget(m_portCombo); + toolbarLayout->addWidget(refreshPortsButton); + toolbarLayout->addWidget(new QLabel(QStringLiteral("Baud"), this)); + toolbarLayout->addWidget(m_baudCombo); + toolbarLayout->addWidget(new QLabel(QStringLiteral("Max"), this)); + toolbarLayout->addWidget(m_maxPointsSpin); + toolbarLayout->addWidget(m_connectButton); + toolbarLayout->addWidget(m_openLogButton); + toolbarLayout->addWidget(m_replayButton); + toolbarLayout->addWidget(m_clearButton); + toolbarLayout->addWidget(m_autoFollowCheck); + toolbarLayout->addWidget(zoomOutButton); + toolbarLayout->addWidget(zoomInButton); + toolbarLayout->addStretch(1); + rootLayout->addWidget(controlBar); + + m_map = new MapWidget(this); + m_globe = new GlobeWidget(this); + auto *viewTabs = new QTabWidget(this); + viewTabs->setObjectName(QStringLiteral("mainViewTabs")); + viewTabs->addTab(m_map, QStringLiteral("2B Harita")); + + auto *globeTab = new QWidget(viewTabs); + auto *globeTabLayout = new QVBoxLayout(globeTab); + globeTabLayout->setContentsMargins(0, 0, 0, 0); + globeTabLayout->setSpacing(6); + auto *rinexToolbar = new QWidget(globeTab); + auto *rinexToolbarLayout = new QHBoxLayout(rinexToolbar); + rinexToolbarLayout->setContentsMargins(10, 6, 10, 0); + rinexToolbarLayout->addWidget(new QLabel( + QStringLiteral("GPS yayin efemerisi · otomatik: 15 dk"), rinexToolbar)); + rinexToolbarLayout->addStretch(1); + m_refreshRinexButton = new QPushButton(QStringLiteral("RINEX Guncelle"), rinexToolbar); + m_refreshRinexButton->setObjectName(QStringLiteral("refreshRinexButton")); + m_refreshRinexButton->setToolTip( + QStringLiteral("Guncel RINEX navigasyon dosyasini simdi indir")); + rinexToolbarLayout->addWidget(m_refreshRinexButton); + globeTabLayout->addWidget(rinexToolbar); + globeTabLayout->addWidget(m_globe, 1); + viewTabs->addTab(globeTab, QStringLiteral("3B Dunya · Uydular")); + + auto *sidePanel = new QWidget(this); + sidePanel->setMinimumWidth(390); + auto *sideOuterLayout = new QVBoxLayout(sidePanel); + sideOuterLayout->setContentsMargins(8, 0, 0, 0); + sideOuterLayout->setSpacing(0); + auto *dashboard = new QWidget(sidePanel); + dashboard->setObjectName(QStringLiteral("dashboardPanel")); + auto *sideLayout = new QVBoxLayout(dashboard); + sideLayout->setContentsMargins(4, 4, 4, 4); + sideLayout->setSpacing(8); + + auto *statusGroup = new QGroupBox(QStringLiteral("Baglanti"), sidePanel); + auto *statusLayout = new QGridLayout(statusGroup); + m_countLabel = new QLabel(QStringLiteral("Konum: 0"), statusGroup); + m_statusLabel = new QLabel(QStringLiteral("Hazir"), statusGroup); + m_statusLabel->setObjectName(QStringLiteral("subtleText")); + m_statusLabel->setWordWrap(true); + statusLayout->addWidget(new QLabel(QStringLiteral("Durum"), statusGroup), 0, 0); + statusLayout->addWidget(m_statusLabel, 0, 1); + statusLayout->addWidget(new QLabel(QStringLiteral("Kayitli nokta"), statusGroup), 1, 0); + statusLayout->addWidget(m_countLabel, 1, 1); + statusLayout->setColumnStretch(1, 1); + sideLayout->addWidget(statusGroup); + + auto *satelliteGroup = new QGroupBox(QStringLiteral("Uydu durumu"), sidePanel); + auto *satelliteLayout = new QVBoxLayout(satelliteGroup); + m_satelliteLabel = new QLabel(QStringLiteral("-"), satelliteGroup); + m_satelliteLabel->setObjectName(QStringLiteral("metricValue")); + m_satelliteLabel->setAlignment(Qt::AlignCenter); + m_satelliteQualityLabel = new QLabel(QStringLiteral("Uydu bekleniyor"), satelliteGroup); + m_satelliteQualityLabel->setObjectName(QStringLiteral("subtleText")); + m_satelliteQualityLabel->setAlignment(Qt::AlignCenter); + m_satelliteQualityLabel->setWordWrap(true); + satelliteLayout->addWidget(m_satelliteLabel); + satelliteLayout->addWidget(m_satelliteQualityLabel); + sideLayout->addWidget(satelliteGroup); + + auto *dopGroup = new QGroupBox(QStringLiteral("DOP degerleri"), sidePanel); + auto *dopLayout = new QGridLayout(dopGroup); + m_gdopLabel = new QLabel(QStringLiteral("-"), dopGroup); + m_hdopLabel = new QLabel(QStringLiteral("-"), dopGroup); + m_vdopLabel = new QLabel(QStringLiteral("-"), dopGroup); + m_tdopLabel = new QLabel(QStringLiteral("-"), dopGroup); + + const auto addDopRow = [dopLayout](int row, const QString &label, QLabel *value) { + auto *nameLabel = new QLabel(label); + nameLabel->setObjectName(QStringLiteral("subtleText")); + value->setObjectName(QStringLiteral("dopValue")); + value->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + value->setTextInteractionFlags(Qt::TextSelectableByMouse); + dopLayout->addWidget(nameLabel, row, 0); + dopLayout->addWidget(value, row, 1); + }; + + addDopRow(0, QStringLiteral("GDOP"), m_gdopLabel); + addDopRow(1, QStringLiteral("HDOP"), m_hdopLabel); + addDopRow(2, QStringLiteral("VDOP"), m_vdopLabel); + addDopRow(3, QStringLiteral("TDOP"), m_tdopLabel); + dopLayout->setColumnStretch(1, 1); + sideLayout->addWidget(dopGroup); + + auto *analysisGroup = new QGroupBox(QStringLiteral("Konum analizi"), sidePanel); + auto *analysisLayout = new QGridLayout(analysisGroup); + auto *layersGroup = new QGroupBox(QStringLiteral("Harita katmanlari"), sidePanel); + auto *layersLayout = new QVBoxLayout(layersGroup); + m_showTelemetryCheck = new QCheckBox(QStringLiteral("Harita isaretlerini goster"), layersGroup); + m_showTelemetryCheck->setObjectName(QStringLiteral("showTelemetryCheck")); + m_showTelemetryCheck->setChecked(true); + m_showRecentPointsCheck = new QCheckBox(QStringLiteral("Son konumlari goster"), layersGroup); + m_showRecentPointsCheck->setObjectName(QStringLiteral("showRecentPointsCheck")); + m_showRecentPointsCheck->setChecked(true); + m_showAverageCheck = new QCheckBox(QStringLiteral("Ortalamayi goster"), layersGroup); + m_showAverageCheck->setObjectName(QStringLiteral("showAverageCheck")); + m_showAverageCheck->setChecked(true); + auto *elevationMaskSpin = new QDoubleSpinBox(layersGroup); + elevationMaskSpin->setObjectName(QStringLiteral("elevationMaskSpin")); + elevationMaskSpin->setRange(0.0, 90.0); + elevationMaskSpin->setDecimals(1); + elevationMaskSpin->setSingleStep(0.5); + elevationMaskSpin->setValue(10.0); + elevationMaskSpin->setKeyboardTracking(false); + elevationMaskSpin->setAccelerated(true); + elevationMaskSpin->setMinimumWidth(86); + elevationMaskSpin->setSuffix(QStringLiteral("°")); + elevationMaskSpin->setToolTip( + QStringLiteral("0-90 arasinda bir deger yazin ve Enter'a basin")); + auto *elevationMaskLayout = new QHBoxLayout(); + elevationMaskLayout->addWidget(new QLabel(QStringLiteral("Min. elevation"), layersGroup)); + elevationMaskLayout->addStretch(1); + elevationMaskLayout->addWidget(elevationMaskSpin); + layersLayout->addWidget(m_showTelemetryCheck); + layersLayout->addWidget(m_showRecentPointsCheck); + layersLayout->addWidget(m_showAverageCheck); + layersLayout->addLayout(elevationMaskLayout); + sideLayout->addWidget(layersGroup); + m_referenceLatEdit = new QLineEdit(analysisGroup); + m_referenceLatEdit->setObjectName(QStringLiteral("referenceLatInput")); + m_referenceLatEdit->setPlaceholderText(QStringLiteral("Orn. 40.789250342142")); + m_referenceLatEdit->setClearButtonEnabled(true); + m_referenceLatEdit->setMaxLength(32); + m_referenceLatEdit->setToolTip(QStringLiteral("Kullanici referansi; CEP50 hesabini etkilemez")); + m_referenceLonEdit = new QLineEdit(analysisGroup); + m_referenceLonEdit->setObjectName(QStringLiteral("referenceLonInput")); + m_referenceLonEdit->setPlaceholderText(QStringLiteral("Orn. 29.462775308417")); + m_referenceLonEdit->setClearButtonEnabled(true); + m_referenceLonEdit->setMaxLength(32); + m_referenceLonEdit->setToolTip(QStringLiteral("Kullanici referansi; CEP50 hesabini etkilemez")); + auto *mapSelectionHint = new QLabel( + QStringLiteral("Sag tik: kullanici referansi. CEP50, olcumlerin %50'sine gore otomatik hesaplanir."), analysisGroup); + mapSelectionHint->setObjectName(QStringLiteral("subtleText")); + mapSelectionHint->setWordWrap(true); + m_cepLabel = new QLabel(QStringLiteral("-"), analysisGroup); + m_cepLabel->setObjectName(QStringLiteral("cepValue")); + m_cepLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + m_cepLabel->setToolTip(QStringLiteral("Radyal konum hatalarinin yuzde 50 yuzdeligi")); + m_meanLocationLabel = new QLabel(QStringLiteral("-"), analysisGroup); + m_meanLocationLabel->setMinimumHeight(36); + m_lastDeviationLabel = new QLabel(QStringLiteral("-"), analysisGroup); + m_meanDeviationLabel = new QLabel(QStringLiteral("-"), analysisGroup); + m_rmsScatterLabel = new QLabel(QStringLiteral("-"), analysisGroup); + m_maxScatterLabel = new QLabel(QStringLiteral("-"), analysisGroup); + + const auto addAnalysisRow = [analysisLayout](int row, const QString &label, QLabel *value) { + auto *nameLabel = new QLabel(label); + value->setTextInteractionFlags(Qt::TextSelectableByMouse); + value->setWordWrap(true); + analysisLayout->addWidget(nameLabel, row, 0); + analysisLayout->addWidget(value, row, 1); + }; + + analysisLayout->addWidget(mapSelectionHint, 0, 0, 1, 2); + analysisLayout->addWidget(new QLabel(QStringLiteral("Referans enlem"), analysisGroup), 1, 0); + analysisLayout->addWidget(m_referenceLatEdit, 1, 1); + analysisLayout->addWidget(new QLabel(QStringLiteral("Referans boylam"), analysisGroup), 2, 0); + analysisLayout->addWidget(m_referenceLonEdit, 2, 1); + analysisLayout->addWidget(new QLabel(QStringLiteral("CEP50"), analysisGroup), 3, 0); + analysisLayout->addWidget(m_cepLabel, 3, 1); + addAnalysisRow(4, QStringLiteral("Ortalama"), m_meanLocationLabel); + addAnalysisRow(5, QStringLiteral("Son sapma"), m_lastDeviationLabel); + addAnalysisRow(6, QStringLiteral("Ort. sapma"), m_meanDeviationLabel); + addAnalysisRow(7, QStringLiteral("RMS sacilim"), m_rmsScatterLabel); + addAnalysisRow(8, QStringLiteral("Maks sapma"), m_maxScatterLabel); + analysisLayout->setColumnStretch(1, 1); + sideLayout->addWidget(analysisGroup); + + auto *telemetryTabs = new QTabWidget(sidePanel); + + m_table = new QTableView(telemetryTabs); + m_tableModel = new TelemetryTableModel(m_table); + m_table->setModel(m_tableModel); + m_table->verticalHeader()->setVisible(false); + m_table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_table->setSelectionBehavior(QAbstractItemView::SelectRows); + m_table->setAlternatingRowColors(true); + + m_log = new QPlainTextEdit(telemetryTabs); + m_log->setReadOnly(true); + m_log->setLineWrapMode(QPlainTextEdit::NoWrap); + m_log->setMaximumBlockCount(500); + m_log->setPlaceholderText(QStringLiteral("Ham seri debug satirlari")); + telemetryTabs->addTab(m_log, QStringLiteral("UART Log")); + telemetryTabs->addTab(m_table, QStringLiteral("Konumlar")); + telemetryTabs->setCurrentWidget(m_log); + telemetryTabs->setMinimumHeight(210); + + auto *dashboardScroll = new QScrollArea(sidePanel); + dashboardScroll->setObjectName(QStringLiteral("dashboardScroll")); + dashboardScroll->setWidgetResizable(true); + dashboardScroll->setFrameShape(QFrame::NoFrame); + dashboardScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + dashboardScroll->setWidget(dashboard); + + auto *sideSplitter = new QSplitter(Qt::Vertical, sidePanel); + sideSplitter->setObjectName(QStringLiteral("sideSplitter")); + sideSplitter->setChildrenCollapsible(false); + sideSplitter->setHandleWidth(7); + sideSplitter->addWidget(dashboardScroll); + sideSplitter->addWidget(telemetryTabs); + sideSplitter->setStretchFactor(0, 2); + sideSplitter->setStretchFactor(1, 3); + sideSplitter->setSizes({380, 410}); + sideOuterLayout->addWidget(sideSplitter); + + auto *splitter = new QSplitter(Qt::Horizontal, central); + splitter->setObjectName(QStringLiteral("mainSplitter")); + splitter->setChildrenCollapsible(false); + splitter->setHandleWidth(7); + splitter->addWidget(viewTabs); + splitter->addWidget(sidePanel); + splitter->setStretchFactor(0, 4); + splitter->setStretchFactor(1, 1); + splitter->setSizes({960, 500}); + rootLayout->addWidget(splitter, 1); + + setCentralWidget(central); + updateConnectionControls(); + updateSummaryLabels(); + updateAnalysis(); + + connect(m_connectButton, &QPushButton::clicked, this, &MainWindow::toggleSerial); + connect(m_clearButton, &QPushButton::clicked, this, &MainWindow::clearPoints); + connect(m_openLogButton, &QPushButton::clicked, this, &MainWindow::openTelemetryLog); + connect(m_replayButton, &QPushButton::clicked, this, &MainWindow::openUartReplay); + connect(refreshPortsButton, &QToolButton::clicked, this, &MainWindow::refreshSerialPorts); + connect(m_maxPointsSpin, qOverload(&QSpinBox::valueChanged), this, + &MainWindow::trimToMaxPoints); + connect(m_autoFollowCheck, &QCheckBox::toggled, m_map, &MapWidget::setAutoFollow); + connect(m_showTelemetryCheck, &QCheckBox::toggled, + m_map, &MapWidget::setTelemetryOverlayEnabled); + connect(m_showTelemetryCheck, &QCheckBox::toggled, this, [this](bool enabled) { + m_showRecentPointsCheck->setEnabled(enabled); + m_showAverageCheck->setEnabled(enabled); + }); + connect(m_showRecentPointsCheck, &QCheckBox::toggled, + m_map, &MapWidget::setRecentPointsEnabled); + connect(m_showAverageCheck, &QCheckBox::toggled, m_map, &MapWidget::setAverageOverlayEnabled); + connect(elevationMaskSpin, qOverload(&QDoubleSpinBox::valueChanged), + m_globe, &GlobeWidget::setElevationMask); + connect(m_referenceLatEdit, &QLineEdit::editingFinished, + this, &MainWindow::updateReferenceLocation); + connect(m_referenceLonEdit, &QLineEdit::editingFinished, + this, &MainWindow::updateReferenceLocation); + connect(m_map, &MapWidget::autoFollowChanged, m_autoFollowCheck, &QCheckBox::setChecked); + connect(m_map, &MapWidget::referenceLocationSelected, this, + [this](double latitude, double longitude) { + m_referenceLatEdit->setText(preciseCoordinateText(latitude)); + m_referenceLonEdit->setText(preciseCoordinateText(longitude)); + updateReferenceLocation(); + statusBar()->showMessage(QStringLiteral("CEP merkezi haritadan secildi"), 4000); + }); + connect(zoomInButton, &QToolButton::clicked, m_map, &MapWidget::zoomIn); + connect(zoomOutButton, &QToolButton::clicked, m_map, &MapWidget::zoomOut); +} + +void MainWindow::updateConnectionControls() +{ + const bool open = m_serial->isOpen(); + const bool replaying = m_replay->isRunning(); + m_connectButton->setText(open ? QStringLiteral("Durdur") : QStringLiteral("Dinle")); + m_connectButton->setIcon(style()->standardIcon(open ? QStyle::SP_MediaStop : QStyle::SP_MediaPlay)); + m_connectButton->setEnabled(!replaying); + m_portCombo->setEnabled(!open && !replaying); + m_baudCombo->setEnabled(!open && !replaying); + m_openLogButton->setEnabled(!replaying); + m_replayButton->setText(replaying ? QStringLiteral("Replay Durdur") + : QStringLiteral("UART Replay")); + m_replayButton->setIcon(style()->standardIcon( + replaying ? QStyle::SP_MediaStop : QStyle::SP_MediaPlay)); +} + +void MainWindow::addTelemetryPoint(const ParsedTelemetry &telemetry) +{ + TelemetryPoint point; + point.source = telemetry.source; + point.fusionMode = telemetry.fusionMode; + point.gpsAgeMs = telemetry.gpsAgeMs; + point.latitude = *telemetry.latitude; + point.longitude = *telemetry.longitude; + point.latitudeText = telemetry.latitudeText; + point.longitudeText = telemetry.longitudeText; + point.hheight = telemetry.hheight; + point.satellites = telemetry.satellites.has_value() ? telemetry.satellites : m_lastSatelliteCount; + point.dop = m_lastDop; + mergeDopValues(point.dop, telemetry.dop); + point.rawLine = telemetry.rawLine; + point.receivedAt = QDateTime::currentDateTimeUtc(); + + constexpr qint64 CorrectedPreferenceWindowMs = 1000; + bool correctedPositionFresh = false; + for (auto existing = m_points.crbegin(); existing != m_points.crend(); ++existing) { + if (existing->source == LocationSource::ImuCorrected) { + correctedPositionFresh = + existing->receivedAt.msecsTo(point.receivedAt) + <= CorrectedPreferenceWindowMs; + break; + } + } + + m_points.push_back(point); + if (point.source == LocationSource::ImuCorrected || !correctedPositionFresh) { + m_globe->setReceiverLocation(point.latitude, point.longitude); + } + if (m_locationTimeoutTimer != nullptr && m_serial->isOpen()) { + m_locationTimeoutTimer->start(); + } + const int maxPoints = m_maxPointsSpin->value(); + while (m_points.size() > maxPoints) { + m_points.removeFirst(); + } + scheduleTelemetryRefresh(); +} + +void MainWindow::refreshTable() +{ + m_tableModel->setPoints(m_points); + if (m_tableModel->rowCount() > 0) { + m_table->scrollToBottom(); + } +} + +void MainWindow::updateSummaryLabels() +{ + m_satelliteLabel->setText( + m_lastSatelliteCount.has_value() + ? QString::number(*m_lastSatelliteCount) + : QStringLiteral("-")); + m_satelliteQualityLabel->setText(satelliteQualityText(m_lastSatelliteCount)); + m_countLabel->setText(QStringLiteral("Konum: %1 / %2") + .arg(m_points.size()) + .arg(m_maxPointsSpin->value())); + updateDopLabels(); +} + +void MainWindow::updateDopLabels() +{ + m_gdopLabel->setText(optionalDopText(m_lastDop.gdop)); + m_hdopLabel->setText(optionalDopText(m_lastDop.hdop)); + m_vdopLabel->setText(optionalDopText(m_lastDop.vdop)); + m_tdopLabel->setText(optionalDopText(m_lastDop.tdop)); +} + +void MainWindow::updateAnalysis() +{ + const QVector analysisPoints = preferredAnalysisPoints(m_points); + const LocationAnalysis analysis = analyzeLocations(analysisPoints); + if (!analysis.valid) { + m_cepLabel->setText(QStringLiteral("-")); + m_meanLocationLabel->setText(QStringLiteral("-")); + m_lastDeviationLabel->setText(QStringLiteral("-")); + m_meanDeviationLabel->setText(QStringLiteral("-")); + m_rmsScatterLabel->setText(QStringLiteral("-")); + m_maxScatterLabel->setText(QStringLiteral("-")); + m_map->clearAverageMarker(); + m_map->clearCepMarker(); + return; + } + + m_meanLocationLabel->setText(QStringLiteral("%1\n%2") + .arg(preciseCoordinateText(analysis.averageLat)) + .arg(preciseCoordinateText(analysis.averageLon))); + m_lastDeviationLabel->setText(metersText(analysis.lastDeviationMeters)); + m_meanDeviationLabel->setText(metersText(analysis.meanDeviationMeters)); + m_rmsScatterLabel->setText(metersText(analysis.rmsScatterMeters)); + m_maxScatterLabel->setText(metersText(analysis.maxDeviationMeters)); + m_map->setAverageMarker(analysis.averageLat, analysis.averageLon, + analysis.rmsScatterMeters); + + const double cep50 = calculateCep50(analysisPoints, analysis.averageLat, + analysis.averageLon); + m_cepLabel->setText(QStringLiteral("%1 (P50, N=%2)") + .arg(metersText(cep50)) + .arg(analysisPoints.size())); + m_map->setCepMarker(analysis.averageLat, analysis.averageLon, cep50); +} + +void MainWindow::appendLogLine(const QString &line, bool parsed) +{ + const QString prefix = parsed ? QStringLiteral("[OK] ") : QStringLiteral("[..] "); + if (m_pendingLogLines.size() >= MaxPendingLogLines) { + m_pendingLogLines.removeFirst(); + } + m_pendingLogLines.push_back(prefix + line); + if (m_uiRefreshTimer != nullptr && !m_uiRefreshTimer->isActive()) { + m_uiRefreshTimer->start(); + } +} + +void MainWindow::mergePendingTelemetry(const ParsedTelemetry &telemetry) +{ + if (telemetry.latitude.has_value() && m_pendingTelemetry.hasLocation()) { + commitPendingTelemetry(); + } + + if (telemetry.fusionMode != FusionMode::Unknown) { + m_pendingTelemetry.fusionMode = telemetry.fusionMode; + } + if (telemetry.gpsAgeMs.has_value()) { + m_pendingTelemetry.gpsAgeMs = telemetry.gpsAgeMs; + } + if (telemetry.latitude.has_value()) { + m_pendingTelemetry.source = telemetry.source; + m_pendingTelemetry.latitude = telemetry.latitude; + m_pendingTelemetry.latitudeText = telemetry.latitudeText; + } + if (telemetry.longitude.has_value()) { + m_pendingTelemetry.longitude = telemetry.longitude; + m_pendingTelemetry.longitudeText = telemetry.longitudeText; + } + if (telemetry.hheight.has_value()) { + m_pendingTelemetry.hheight = telemetry.hheight; + } + if (telemetry.satellites.has_value()) { + m_pendingTelemetry.satellites = telemetry.satellites; + } + if (telemetry.dop.hasAnyValue()) { + mergeDopValues(m_pendingTelemetry.dop, telemetry.dop); + } + + if (m_pendingTelemetry.rawLine.isEmpty()) { + m_pendingTelemetry.rawLine = telemetry.rawLine; + } else if (!telemetry.rawLine.isEmpty()) { + m_pendingTelemetry.rawLine += QStringLiteral(" | ") + telemetry.rawLine; + } +} + +void MainWindow::commitPendingTelemetry() +{ + if (!m_pendingTelemetry.hasLocation()) { + return; + } + + addTelemetryPoint(m_pendingTelemetry); + m_pendingTelemetry = {}; +} + +void MainWindow::resetTelemetryState() +{ + m_telemetryRefreshPending = false; + m_points.clear(); + m_lastSatelliteCount.reset(); + m_observedPrns.clear(); + m_lastDop = {}; + m_pendingTelemetry = {}; + m_map->clearPoints(); + m_globe->clearReceiverLocation(); + m_globe->setObservedPrns({}); + refreshTable(); + updateSummaryLabels(); + updateAnalysis(); +} + +void MainWindow::scheduleTelemetryRefresh() +{ + m_telemetryRefreshPending = true; + if (m_uiRefreshTimer != nullptr && !m_uiRefreshTimer->isActive()) { + m_uiRefreshTimer->start(); + } +} + +void MainWindow::refreshTelemetryViews() +{ + m_map->setPoints(m_points); + refreshTable(); + updateSummaryLabels(); + updateAnalysis(); +} + +void MainWindow::flushPendingLogLines() +{ + if (m_pendingLogLines.isEmpty()) { + return; + } + m_log->appendPlainText(m_pendingLogLines.join('\n')); + m_pendingLogLines.clear(); +} + +int MainWindow::selectedBaudRate() const +{ + bool ok = false; + const int baudRate = m_baudCombo->currentText().toInt(&ok); + return ok ? baudRate : 460800; +} diff --git a/linux/src/MainWindow.h b/linux/src/MainWindow.h new file mode 100644 index 0000000..cf6ae2e --- /dev/null +++ b/linux/src/MainWindow.h @@ -0,0 +1,128 @@ +#pragma once + +#include "TelemetryPoint.h" + +#include +#include +#include +#include + +#include + +class QLabel; +class QCheckBox; +class QComboBox; +class QLineEdit; +class QPlainTextEdit; +class QPushButton; +class QSpinBox; +class QTableView; +class QTimer; +class QCloseEvent; +class QFile; + +class MapWidget; +class GlobeWidget; +class RinexNavigation; +class SerialReader; +class TelemetryTableModel; +class UartReplay; + +class MainWindow : public QMainWindow { + Q_OBJECT + +public: + explicit MainWindow(QWidget *parent = nullptr); + bool loadTelemetryLog(const QString &filePath); + bool startUartReplay(const QString &filePath, int gpsIntervalMs = 200, + int sensorIntervalMs = 50); + +protected: + void closeEvent(QCloseEvent *event) override; + +private slots: + void toggleSerial(); + void clearPoints(); + void handleSerialLine(const QString &line); + void handleSerialStatus(const QString &status); + void handleSerialError(const QString &message); + void handleLocationTimeout(); + void trimToMaxPoints(); + void updateReferenceLocation(); + void flushUiUpdates(); + void openTelemetryLog(); + void openUartReplay(); + void stopUartReplay(); + void refreshSerialPorts(); + bool startUartLogging(const QString &port, int baudRate); + void stopUartLogging(); + +private: + void buildUi(); + void updateConnectionControls(); + void addTelemetryPoint(const ParsedTelemetry &telemetry); + void refreshTable(); + void updateSummaryLabels(); + void updateDopLabels(); + void updateAnalysis(); + void appendLogLine(const QString &line, bool parsed); + void mergePendingTelemetry(const ParsedTelemetry &telemetry); + void commitPendingTelemetry(); + void resetTelemetryState(); + void scheduleTelemetryRefresh(); + void refreshTelemetryViews(); + void flushPendingLogLines(); + void appendUartLogLine(const QString &line); + int selectedBaudRate() const; + + SerialReader *m_serial = nullptr; + UartReplay *m_replay = nullptr; + MapWidget *m_map = nullptr; + GlobeWidget *m_globe = nullptr; + RinexNavigation *m_rinex = nullptr; + QComboBox *m_portCombo = nullptr; + QComboBox *m_baudCombo = nullptr; + QSpinBox *m_maxPointsSpin = nullptr; + QLineEdit *m_referenceLatEdit = nullptr; + QLineEdit *m_referenceLonEdit = nullptr; + QPushButton *m_connectButton = nullptr; + QPushButton *m_clearButton = nullptr; + QPushButton *m_openLogButton = nullptr; + QPushButton *m_replayButton = nullptr; + QPushButton *m_refreshRinexButton = nullptr; + QCheckBox *m_autoFollowCheck = nullptr; + QCheckBox *m_showTelemetryCheck = nullptr; + QCheckBox *m_showRecentPointsCheck = nullptr; + QLabel *m_statusLabel = nullptr; + QLabel *m_satelliteLabel = nullptr; + QLabel *m_satelliteQualityLabel = nullptr; + QLabel *m_gdopLabel = nullptr; + QLabel *m_hdopLabel = nullptr; + QLabel *m_vdopLabel = nullptr; + QLabel *m_tdopLabel = nullptr; + QLabel *m_countLabel = nullptr; + QLabel *m_meanLocationLabel = nullptr; + QLabel *m_lastDeviationLabel = nullptr; + QLabel *m_meanDeviationLabel = nullptr; + QLabel *m_rmsScatterLabel = nullptr; + QLabel *m_maxScatterLabel = nullptr; + QLabel *m_cepLabel = nullptr; + QCheckBox *m_showAverageCheck = nullptr; + QTableView *m_table = nullptr; + TelemetryTableModel *m_tableModel = nullptr; + QPlainTextEdit *m_log = nullptr; + QTimer *m_locationTimeoutTimer = nullptr; + QTimer *m_uiRefreshTimer = nullptr; + QFile *m_uartLogFile = nullptr; + QString m_uartLogPath; + + QVector m_points; + QStringList m_pendingLogLines; + std::optional m_lastSatelliteCount; + QSet m_observedPrns; + std::optional m_referenceLatitude; + std::optional m_referenceLongitude; + DopValues m_lastDop; + ParsedTelemetry m_pendingTelemetry; + bool m_telemetryRefreshPending = false; +}; diff --git a/linux/src/MapWidget.cpp b/linux/src/MapWidget.cpp new file mode 100644 index 0000000..d4ac706 --- /dev/null +++ b/linux/src/MapWidget.cpp @@ -0,0 +1,727 @@ +#include "MapWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +constexpr int TileSize = 256; +constexpr int MinZoom = 2; +constexpr int MaxZoom = 19; +constexpr int MaxMemoryTileCount = 512; +constexpr double Pi = 3.14159265358979323846; +constexpr double WebMercatorRadiusMeters = 6378137.0; +constexpr double MaxMercatorLatitude = 85.05112878; + +QString tileKey(int zoom, int tileX, int tileY) +{ + return QStringLiteral("%1/%2/%3").arg(zoom).arg(tileX).arg(tileY); +} + +double clampLatitude(double latitude) +{ + return std::clamp(latitude, -MaxMercatorLatitude, MaxMercatorLatitude); +} + +int positiveModulo(int value, int modulo) +{ + const int result = value % modulo; + return result < 0 ? result + modulo : result; +} + +double metersPerPixel(double latitude, int zoom) +{ + const double scale = static_cast(TileSize) * static_cast(1 << zoom); + const double latitudeScale = std::cos(latitude * Pi / 180.0); + return latitudeScale * 2.0 * Pi * WebMercatorRadiusMeters / scale; +} + +int preferredCurrentPointIndex(const QVector &points) +{ + constexpr qint64 CorrectedPreferenceWindowMs = 1000; + const int newestIndex = points.size() - 1; + for (int index = points.size() - 1; index >= 0; --index) { + if (points[index].source == LocationSource::ImuCorrected) { + const qint64 ageMs = points[index].receivedAt.msecsTo( + points[newestIndex].receivedAt); + return ageMs <= CorrectedPreferenceWindowMs ? index : newestIndex; + } + } + return newestIndex; +} + +} // namespace + +MapWidget::MapWidget(QWidget *parent) + : QWidget(parent) + , m_network(new QNetworkAccessManager(this)) +{ + setMinimumSize(640, 420); + setMouseTracking(true); + + const QString cacheRoot = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); + if (!cacheRoot.isEmpty()) { + auto *diskCache = new QNetworkDiskCache(m_network); + const QString cacheDirectory = cacheRoot + QStringLiteral("/map-tiles"); + QDir().mkpath(cacheDirectory); + diskCache->setCacheDirectory(cacheDirectory); + diskCache->setMaximumCacheSize(100 * 1024 * 1024); + m_network->setCache(diskCache); + } + + m_attributionLabel = new QLabel(this); + m_attributionLabel->setText( + QStringLiteral("") + + QString::fromUtf8("\xC2\xA9 OpenStreetMap contributors") + + QStringLiteral("")); + m_attributionLabel->setOpenExternalLinks(true); + m_attributionLabel->setStyleSheet(QStringLiteral( + "QLabel { background: rgba(255, 255, 255, 220); color: #26313d; " + "border: 1px solid rgba(120, 130, 140, 90); padding: 3px 6px; }")); + m_attributionLabel->adjustSize(); +} + +void MapWidget::setPoints(const QVector &points) +{ + const bool wasEmpty = m_points.isEmpty(); + m_points = points; + + if (!m_points.isEmpty() && m_autoFollow) { + const TelemetryPoint ¤t = m_points[preferredCurrentPointIndex(m_points)]; + m_centerLat = current.latitude; + m_centerLon = current.longitude; + if (wasEmpty) { + m_zoom = MaxZoom; + } + } + + update(); +} + +void MapWidget::clearPoints() +{ + m_points.clear(); + m_hasAverageMarker = false; + update(); +} + +void MapWidget::fitToPoints() +{ + if (m_points.isEmpty()) { + return; + } + double minimumLatitude = m_points.first().latitude; + double maximumLatitude = minimumLatitude; + double minimumLongitude = m_points.first().longitude; + double maximumLongitude = minimumLongitude; + for (const TelemetryPoint &point : m_points) { + minimumLatitude = std::min(minimumLatitude, point.latitude); + maximumLatitude = std::max(maximumLatitude, point.latitude); + minimumLongitude = std::min(minimumLongitude, point.longitude); + maximumLongitude = std::max(maximumLongitude, point.longitude); + } + m_centerLat = 0.5 * (minimumLatitude + maximumLatitude); + m_centerLon = 0.5 * (minimumLongitude + maximumLongitude); + const double availableWidth = std::max(100.0, width() * 0.82); + const double availableHeight = std::max(100.0, height() * 0.82); + for (int zoom = MaxZoom; zoom >= MinZoom; --zoom) { + const QPointF topLeft = coordinateToWorld(maximumLatitude, minimumLongitude, zoom); + const QPointF bottomRight = coordinateToWorld(minimumLatitude, maximumLongitude, zoom); + if (std::fabs(bottomRight.x() - topLeft.x()) <= availableWidth + && std::fabs(bottomRight.y() - topLeft.y()) <= availableHeight) { + m_zoom = zoom; + break; + } + } + if (m_autoFollow) { + m_autoFollow = false; + emit autoFollowChanged(false); + } + update(); +} + +void MapWidget::setAverageMarker(double latitude, double longitude, double radiusMeters) +{ + m_hasAverageMarker = true; + m_averageLat = latitude; + m_averageLon = longitude; + m_averageRadiusMeters = std::max(0.0, radiusMeters); + update(); +} + +void MapWidget::clearAverageMarker() +{ + if (!m_hasAverageMarker) { + return; + } + + m_hasAverageMarker = false; + update(); +} + +void MapWidget::setCepMarker(double latitude, double longitude, + const std::optional &radiusMeters) +{ + m_hasCepMarker = true; + m_cepLatitude = latitude; + m_cepLongitude = longitude; + if (m_points.isEmpty()) { + m_centerLat = latitude; + m_centerLon = longitude; + m_zoom = MaxZoom; + } + if (radiusMeters.has_value()) { + m_cepRadiusMeters = std::max(0.0, *radiusMeters); + } else { + m_cepRadiusMeters.reset(); + } + update(); +} + +void MapWidget::clearCepMarker() +{ + if (!m_hasCepMarker) { + return; + } + m_hasCepMarker = false; + m_cepRadiusMeters.reset(); + update(); +} + +QPointF MapWidget::centerCoordinate() const +{ + return QPointF(m_centerLat, m_centerLon); +} + +void MapWidget::setAutoFollow(bool enabled) +{ + if (m_autoFollow == enabled) { + return; + } + + m_autoFollow = enabled; + if (m_autoFollow && !m_points.isEmpty()) { + const TelemetryPoint ¤t = m_points[preferredCurrentPointIndex(m_points)]; + m_centerLat = current.latitude; + m_centerLon = current.longitude; + update(); + } + emit autoFollowChanged(m_autoFollow); +} + +void MapWidget::setAverageOverlayEnabled(bool enabled) +{ + if (m_showAverageOverlay == enabled) { + return; + } + + m_showAverageOverlay = enabled; + update(); +} + +void MapWidget::setTelemetryOverlayEnabled(bool enabled) +{ + if (m_showTelemetryOverlay == enabled) { + return; + } + m_showTelemetryOverlay = enabled; + update(); +} + +void MapWidget::setRecentPointsEnabled(bool enabled) +{ + if (m_showRecentPoints == enabled) { + return; + } + m_showRecentPoints = enabled; + update(); +} + +void MapWidget::zoomIn() +{ + setZoomKeepingPoint(m_zoom + 1, QPointF(width() / 2.0, height() / 2.0)); +} + +void MapWidget::zoomOut() +{ + setZoomKeepingPoint(m_zoom - 1, QPointF(width() / 2.0, height() / 2.0)); +} + +void MapWidget::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.fillRect(rect(), QColor(219, 225, 220)); + drawTiles(painter); + drawTelemetry(painter); + drawLegend(painter); + + if (m_points.isEmpty()) { + drawEmptyState(painter); + } +} + +void MapWidget::wheelEvent(QWheelEvent *event) +{ + const int delta = event->angleDelta().y(); + if (delta == 0) { + return; + } + + disableAutoFollowFromUserAction(); + setZoomKeepingPoint(m_zoom + (delta > 0 ? 1 : -1), event->position()); + event->accept(); +} + +void MapWidget::mousePressEvent(QMouseEvent *event) +{ + if (event->button() == Qt::RightButton) { + const QPointF coordinate = screenToCoordinate(event->position()); + double longitude = std::fmod(coordinate.y() + 180.0, 360.0); + if (longitude < 0.0) { + longitude += 360.0; + } + longitude -= 180.0; + emit referenceLocationSelected(coordinate.x(), longitude); + event->accept(); + return; + } + if (event->button() == Qt::LeftButton) { + disableAutoFollowFromUserAction(); + m_dragging = true; + m_lastMousePos = event->pos(); + setCursor(Qt::ClosedHandCursor); + event->accept(); + } +} + +void MapWidget::mouseMoveEvent(QMouseEvent *event) +{ + if (!m_dragging) { + return; + } + + const QPoint delta = event->pos() - m_lastMousePos; + m_lastMousePos = event->pos(); + + const QPointF centerWorld = coordinateToWorld(m_centerLat, m_centerLon, m_zoom); + const QPointF newCenterWorld = centerWorld - QPointF(delta); + const QPointF coordinate = worldToCoordinate(newCenterWorld, m_zoom); + m_centerLat = coordinate.x(); + m_centerLon = coordinate.y(); + update(); + event->accept(); +} + +void MapWidget::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton && m_dragging) { + m_dragging = false; + unsetCursor(); + event->accept(); + } +} + +void MapWidget::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + if (m_attributionLabel != nullptr) { + m_attributionLabel->adjustSize(); + m_attributionLabel->move(width() - m_attributionLabel->width() - 8, + height() - m_attributionLabel->height() - 8); + m_attributionLabel->raise(); + } +} + +QPointF MapWidget::coordinateToWorld(double latitude, double longitude, int zoom) const +{ + latitude = clampLatitude(latitude); + const double sinLat = std::sin(latitude * Pi / 180.0); + const double scale = static_cast(TileSize) * static_cast(1 << zoom); + + const double x = (longitude + 180.0) / 360.0 * scale; + const double y = (0.5 - std::log((1.0 + sinLat) / (1.0 - sinLat)) / (4.0 * Pi)) * scale; + return QPointF(x, y); +} + +QPointF MapWidget::worldToCoordinate(const QPointF &world, int zoom) const +{ + const double scale = static_cast(TileSize) * static_cast(1 << zoom); + const double longitude = world.x() / scale * 360.0 - 180.0; + const double n = Pi - 2.0 * Pi * world.y() / scale; + const double latitude = 180.0 / Pi * std::atan(0.5 * (std::exp(n) - std::exp(-n))); + return QPointF(clampLatitude(latitude), longitude); +} + +QPointF MapWidget::coordinateToScreen(double latitude, double longitude) const +{ + const QPointF world = coordinateToWorld(latitude, longitude, m_zoom); + const QPointF centerWorld = coordinateToWorld(m_centerLat, m_centerLon, m_zoom); + const double worldWidth = static_cast(TileSize) * static_cast(1 << m_zoom); + double deltaX = world.x() - centerWorld.x(); + if (deltaX > worldWidth / 2.0) { + deltaX -= worldWidth; + } else if (deltaX < -worldWidth / 2.0) { + deltaX += worldWidth; + } + return QPointF(width() / 2.0 + deltaX, height() / 2.0 + world.y() - centerWorld.y()); +} + +QPointF MapWidget::screenToCoordinate(const QPointF &screen) const +{ + return worldToCoordinate(topLeftWorld() + screen, m_zoom); +} + +QPointF MapWidget::topLeftWorld() const +{ + return coordinateToWorld(m_centerLat, m_centerLon, m_zoom) + - QPointF(width() / 2.0, height() / 2.0); +} + +void MapWidget::drawTiles(QPainter &painter) +{ + const int tileCount = 1 << m_zoom; + const QPointF topLeft = topLeftWorld(); + const int firstTileX = static_cast(std::floor(topLeft.x() / TileSize)); + const int lastTileX = static_cast(std::floor((topLeft.x() + width()) / TileSize)); + const int firstTileY = static_cast(std::floor(topLeft.y() / TileSize)); + const int lastTileY = static_cast(std::floor((topLeft.y() + height()) / TileSize)); + + painter.setRenderHint(QPainter::SmoothPixmapTransform, false); + + for (int tileY = firstTileY; tileY <= lastTileY; ++tileY) { + if (tileY < 0 || tileY >= tileCount) { + continue; + } + + for (int tileX = firstTileX; tileX <= lastTileX; ++tileX) { + const int wrappedTileX = positiveModulo(tileX, tileCount); + const QString key = tileKey(m_zoom, wrappedTileX, tileY); + const QRectF target( + tileX * TileSize - topLeft.x(), + tileY * TileSize - topLeft.y(), + TileSize, + TileSize); + + const auto tileIt = m_tileCache.constFind(key); + if (tileIt != m_tileCache.constEnd()) { + painter.drawPixmap(target.toRect(), *tileIt); + } else { + painter.fillRect(target, QColor(230, 234, 230)); + painter.setPen(QColor(198, 205, 200)); + painter.drawRect(target); + requestTile(wrappedTileX, tileY, m_zoom); + } + } + } +} + +void MapWidget::drawTelemetry(QPainter &painter) +{ + if (m_points.isEmpty() || !m_showTelemetryOverlay) { + return; + } + + painter.setRenderHint(QPainter::Antialiasing, true); + + drawCepOverlay(painter); + + const auto drawSourcePath = [this, &painter](LocationSource source, const QColor &color) { + QPainterPath path; + bool havePoint = false; + int pointCount = 0; + for (const TelemetryPoint &point : m_points) { + if (point.source != source) { + continue; + } + const QPointF screen = coordinateToScreen(point.latitude, point.longitude); + havePoint ? path.lineTo(screen) : path.moveTo(screen); + havePoint = true; + ++pointCount; + } + if (m_showRecentPoints && pointCount > 1) { + QPen pathPen(color, 3.0); + pathPen.setCapStyle(Qt::RoundCap); + pathPen.setJoinStyle(Qt::RoundJoin); + painter.setPen(pathPen); + painter.drawPath(path); + } + }; + drawSourcePath(LocationSource::Trajectory, QColor(22, 163, 74)); + drawSourcePath(LocationSource::Generic, QColor(210, 105, 30)); + drawSourcePath(LocationSource::Gps, QColor(37, 99, 235)); + drawSourcePath(LocationSource::ImuCorrected, QColor(220, 38, 38)); + + drawAverageOverlay(painter); + drawCepMarker(painter); + + const bool drawNumbers = m_points.size() <= 150; + const int currentPointIndex = preferredCurrentPointIndex(m_points); + const int firstPoint = m_showRecentPoints ? 0 : currentPointIndex; + const int endPoint = m_showRecentPoints ? m_points.size() : currentPointIndex + 1; + for (int i = firstPoint; i < endPoint; ++i) { + const bool isLast = i == currentPointIndex; + const QPointF screen = coordinateToScreen(m_points[i].latitude, m_points[i].longitude); + const double radius = isLast ? 8.0 : 5.0; + + const QColor pointColor = m_points[i].source == LocationSource::ImuCorrected + ? QColor(220, 38, 38) + : (m_points[i].source == LocationSource::Gps + ? QColor(37, 99, 235) + : (m_points[i].source == LocationSource::Trajectory + ? QColor(22, 163, 74) + : (isLast ? QColor(210, 105, 30) : QColor(234, 88, 12)))); + painter.setPen(QPen(Qt::white, isLast ? 3.0 : 2.0)); + painter.setBrush(pointColor); + painter.drawEllipse(screen, radius, radius); + + if (drawNumbers) { + painter.setPen(Qt::white); + painter.setFont(QFont(painter.font().family(), 8, QFont::Bold)); + painter.drawText(QRectF(screen.x() - 13, screen.y() - 9, 26, 18), Qt::AlignCenter, + QString::number(i + 1)); + } + } + + const TelemetryPoint &last = m_points[currentPointIndex]; + const QPointF lastScreen = coordinateToScreen(last.latitude, last.longitude); + QStringList lines; + const QString latitudeText = last.latitudeText.isEmpty() + ? QString::number(last.latitude, 'f', 15) : last.latitudeText; + const QString longitudeText = last.longitudeText.isEmpty() + ? QString::number(last.longitude, 'f', 15) : last.longitudeText; + lines << QStringLiteral("Lat: %1").arg(latitudeText); + lines << QStringLiteral("Long: %1").arg(longitudeText); + lines << QStringLiteral("Kaynak: %1").arg(locationSourceText(last.source)); + if (last.hheight.has_value()) { + lines << QStringLiteral("HHeight: %1").arg(*last.hheight, 0, 'f', 2); + } + if (last.satellites.has_value()) { + lines << QStringLiteral("Aktif uydu: %1").arg(*last.satellites); + } + if (m_cepRadiusMeters.has_value()) { + lines << QStringLiteral("CEP50: %1 m").arg(*m_cepRadiusMeters, 0, 'f', 2); + } + + painter.setFont(QFont(painter.font().family(), 10)); + const QString label = lines.join('\n'); + const QRect textRect = painter.fontMetrics().boundingRect(QRect(0, 0, 300, 150), + Qt::TextWordWrap, label); + QRectF bubble(lastScreen + QPointF(14, -textRect.height() - 12), + QSizeF(textRect.width() + 18, textRect.height() + 14)); + if (bubble.right() > width() - 8) { + bubble.moveRight(width() - 8); + } + if (bubble.top() < 8) { + bubble.moveTop(8); + } + + painter.setPen(QPen(QColor(50, 58, 65, 180), 1.0)); + painter.setBrush(QColor(255, 255, 255, 232)); + painter.drawRoundedRect(bubble, 6, 6); + painter.setPen(QColor(25, 32, 38)); + painter.drawText(bubble.adjusted(9, 7, -9, -7), Qt::AlignLeft | Qt::AlignVCenter, label); +} + +void MapWidget::drawLegend(QPainter &painter) +{ + if (m_points.isEmpty() || !m_showTelemetryOverlay) { + return; + } + const struct { + const char *text; + QColor color; + } entries[] = { + {"Trajectory (truth)", QColor(22, 163, 74)}, + {"GPS (4 satellite)", QColor(37, 99, 235)}, + {"IMU + ESKF corrected", QColor(220, 38, 38)} + }; + const QRectF box(12.0, 12.0, 190.0, 76.0); + painter.setPen(QPen(QColor(50, 58, 65, 180), 1.0)); + painter.setBrush(QColor(255, 255, 255, 232)); + painter.drawRoundedRect(box, 6.0, 6.0); + painter.setFont(QFont(painter.font().family(), 9, QFont::DemiBold)); + for (int index = 0; index < 3; ++index) { + const double y = 27.0 + index * 22.0; + painter.setPen(QPen(entries[index].color, 3.0)); + painter.drawLine(QPointF(23.0, y), QPointF(43.0, y)); + painter.setBrush(entries[index].color); + painter.drawEllipse(QPointF(33.0, y), 4.0, 4.0); + painter.setPen(QColor(25, 32, 38)); + painter.drawText(QRectF(50.0, y - 9.0, 142.0, 18.0), + Qt::AlignLeft | Qt::AlignVCenter, + QString::fromLatin1(entries[index].text)); + } +} + +void MapWidget::drawCepOverlay(QPainter &painter) +{ + if (!m_hasCepMarker || !m_cepRadiusMeters.has_value() || *m_cepRadiusMeters <= 0.0) { + return; + } + + const QPointF screen = coordinateToScreen(m_cepLatitude, m_cepLongitude); + const double resolution = metersPerPixel(m_cepLatitude, m_zoom); + if (!std::isfinite(resolution) || resolution <= 0.0) { + return; + } + + const double radiusPixels = std::min(*m_cepRadiusMeters / resolution, 2500.0); + if (radiusPixels < 1.0) { + return; + } + + painter.setPen(QPen(QColor(8, 111, 104, 210), 2.0)); + painter.setBrush(QColor(13, 148, 136, 42)); + painter.drawEllipse(screen, radiusPixels, radiusPixels); +} + +void MapWidget::drawCepMarker(QPainter &painter) +{ + if (!m_hasCepMarker) { + return; + } + + const QPointF screen = coordinateToScreen(m_cepLatitude, m_cepLongitude); + painter.setPen(QPen(QColor(8, 111, 104), 2.5)); + painter.setBrush(QColor(255, 255, 255, 230)); + painter.drawEllipse(screen, 7.0, 7.0); + painter.drawLine(QPointF(screen.x() - 11.0, screen.y()), + QPointF(screen.x() + 11.0, screen.y())); + painter.drawLine(QPointF(screen.x(), screen.y() - 11.0), + QPointF(screen.x(), screen.y() + 11.0)); + + const bool overlapsLast = !m_points.isEmpty() + && QLineF(screen, coordinateToScreen(m_points.last().latitude, + m_points.last().longitude)).length() < 30.0; + if (!overlapsLast) { + painter.setFont(QFont(painter.font().family(), 9, QFont::Bold)); + painter.setPen(QColor(20, 83, 78)); + painter.drawText(QRectF(screen + QPointF(12.0, -10.0), QSizeF(110.0, 20.0)), + Qt::AlignLeft | Qt::AlignVCenter, QStringLiteral("Bilinen konum")); + } +} + +void MapWidget::drawAverageOverlay(QPainter &painter) +{ + if (!m_showAverageOverlay || !m_hasAverageMarker) { + return; + } + + const QPointF screen = coordinateToScreen(m_averageLat, m_averageLon); + const double resolution = metersPerPixel(m_averageLat, m_zoom); + if (std::isfinite(resolution) && resolution > 0.0 && m_averageRadiusMeters > 0.0) { + const double radiusPixels = std::min(m_averageRadiusMeters / resolution, 2500.0); + if (radiusPixels >= 2.0) { + painter.setPen(QPen(QColor(124, 58, 237, 150), 2.0, Qt::DashLine)); + painter.setBrush(QColor(124, 58, 237, 30)); + painter.drawEllipse(screen, radiusPixels, radiusPixels); + } + } + + painter.setPen(QPen(Qt::white, 3.0)); + painter.setBrush(QColor(124, 58, 237)); + painter.drawEllipse(screen, 9.0, 9.0); + + painter.setPen(QPen(QColor(25, 32, 38), 2.0)); + painter.drawLine(QPointF(screen.x() - 12.0, screen.y()), QPointF(screen.x() + 12.0, screen.y())); + painter.drawLine(QPointF(screen.x(), screen.y() - 12.0), QPointF(screen.x(), screen.y() + 12.0)); + + const bool overlapsLast = !m_points.isEmpty() + && QLineF(screen, coordinateToScreen(m_points.last().latitude, + m_points.last().longitude)).length() < 30.0; + if (!overlapsLast) { + painter.setFont(QFont(painter.font().family(), 9, QFont::Bold)); + const QRectF labelRect(screen + QPointF(12.0, 8.0), QSizeF(72.0, 20.0)); + painter.setPen(QColor(25, 32, 38)); + painter.drawText(labelRect, Qt::AlignLeft | Qt::AlignVCenter, QStringLiteral("Ortalama")); + } +} + +void MapWidget::drawEmptyState(QPainter &painter) +{ + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(QColor(83, 96, 110)); + painter.setFont(QFont(painter.font().family(), 13)); + painter.drawText(rect(), Qt::AlignCenter, + QStringLiteral("Seri porttan lat / long / hheight bekleniyor")); +} + +void MapWidget::requestTile(int tileX, int tileY, int zoom) +{ + const QString key = tileKey(zoom, tileX, tileY); + if (m_tileCache.contains(key) || m_pendingTiles.contains(key)) { + return; + } + + m_pendingTiles.insert(key); + const QUrl url(QStringLiteral("https://tile.openstreetmap.org/%1/%2/%3.png") + .arg(zoom) + .arg(tileX) + .arg(tileY)); + QNetworkRequest request(url); + request.setRawHeader("User-Agent", "RocketLocationPlotter/0.2 (Qt desktop application)"); + QNetworkReply *reply = m_network->get(request); + reply->setProperty("tileKey", key); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + const QString key = reply->property("tileKey").toString(); + m_pendingTiles.remove(key); + + if (reply->error() == QNetworkReply::NoError) { + QPixmap pixmap; + if (pixmap.loadFromData(reply->readAll())) { + if (!m_tileCache.contains(key) && m_tileCache.size() >= MaxMemoryTileCount) { + m_tileCache.erase(m_tileCache.begin()); + } + m_tileCache.insert(key, pixmap); + update(); + } + } + + reply->deleteLater(); + }); +} + +void MapWidget::setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor) +{ + newZoom = std::clamp(newZoom, MinZoom, MaxZoom); + if (newZoom == m_zoom) { + return; + } + + const QPointF coordinateAtAnchor = screenToCoordinate(screenAnchor); + m_zoom = newZoom; + const QPointF anchorWorld = coordinateToWorld(coordinateAtAnchor.x(), coordinateAtAnchor.y(), m_zoom); + const QPointF newCenterWorld = anchorWorld - (screenAnchor - QPointF(width() / 2.0, height() / 2.0)); + const QPointF newCenter = worldToCoordinate(newCenterWorld, m_zoom); + m_centerLat = newCenter.x(); + m_centerLon = newCenter.y(); + update(); +} + +void MapWidget::disableAutoFollowFromUserAction() +{ + if (!m_autoFollow) { + return; + } + + m_autoFollow = false; + emit autoFollowChanged(false); +} diff --git a/linux/src/MapWidget.h b/linux/src/MapWidget.h new file mode 100644 index 0000000..5e67a52 --- /dev/null +++ b/linux/src/MapWidget.h @@ -0,0 +1,93 @@ +#pragma once + +#include "TelemetryPoint.h" + +#include +#include +#include +#include +#include + +#include + +class QNetworkAccessManager; +class QLabel; + +class MapWidget : public QWidget { + Q_OBJECT + +public: + explicit MapWidget(QWidget *parent = nullptr); + + void setPoints(const QVector &points); + void fitToPoints(); + void clearPoints(); + void setAverageMarker(double latitude, double longitude, double radiusMeters); + void clearAverageMarker(); + void setCepMarker(double latitude, double longitude, + const std::optional &radiusMeters); + void clearCepMarker(); + [[nodiscard]] QPointF centerCoordinate() const; + +public slots: + void setAutoFollow(bool enabled); + void setTelemetryOverlayEnabled(bool enabled); + void setAverageOverlayEnabled(bool enabled); + void setRecentPointsEnabled(bool enabled); + void zoomIn(); + void zoomOut(); + +signals: + void autoFollowChanged(bool enabled); + void referenceLocationSelected(double latitude, double longitude); + +protected: + void paintEvent(QPaintEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + +private: + QPointF coordinateToWorld(double latitude, double longitude, int zoom) const; + QPointF worldToCoordinate(const QPointF &world, int zoom) const; + QPointF coordinateToScreen(double latitude, double longitude) const; + QPointF screenToCoordinate(const QPointF &screen) const; + QPointF topLeftWorld() const; + + void drawTiles(QPainter &painter); + void drawTelemetry(QPainter &painter); + void drawLegend(QPainter &painter); + void drawAverageOverlay(QPainter &painter); + void drawCepOverlay(QPainter &painter); + void drawCepMarker(QPainter &painter); + void drawEmptyState(QPainter &painter); + void requestTile(int tileX, int tileY, int zoom); + void setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor); + void disableAutoFollowFromUserAction(); + + QVector m_points; + QNetworkAccessManager *m_network = nullptr; + QLabel *m_attributionLabel = nullptr; + QHash m_tileCache; + QSet m_pendingTiles; + + double m_centerLat = 39.0; + double m_centerLon = 35.0; + int m_zoom = 6; + bool m_autoFollow = true; + bool m_showTelemetryOverlay = true; + bool m_showAverageOverlay = true; + bool m_showRecentPoints = true; + bool m_hasAverageMarker = false; + double m_averageLat = 0.0; + double m_averageLon = 0.0; + double m_averageRadiusMeters = 0.0; + bool m_hasCepMarker = false; + double m_cepLatitude = 0.0; + double m_cepLongitude = 0.0; + std::optional m_cepRadiusMeters; + bool m_dragging = false; + QPoint m_lastMousePos; +}; diff --git a/linux/src/RinexNavigation.cpp b/linux/src/RinexNavigation.cpp new file mode 100644 index 0000000..c839d5a --- /dev/null +++ b/linux/src/RinexNavigation.cpp @@ -0,0 +1,374 @@ +#include "RinexNavigation.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace { + +constexpr double GpsMu = 3.986005e14; +constexpr double EarthRotation = 7.2921151467e-5; +constexpr double GpsWeekSeconds = 604800.0; +constexpr double HalfGpsWeek = GpsWeekSeconds / 2.0; +constexpr double CurrentGpsUtcOffsetSeconds = 18.0; +constexpr int PositionUpdateIntervalMs = 60000; + +double parseField(const QString &line, int field) +{ + QString text = line.mid(4 + field * 19, 19).trimmed(); + text.replace(QChar('D'), QChar('E'), Qt::CaseInsensitive); + bool ok = false; + const double value = text.toDouble(&ok); + return ok ? value : std::numeric_limits::quiet_NaN(); +} + +bool finiteFields(std::initializer_list values) +{ + return std::all_of(values.begin(), values.end(), [](double value) { + return std::isfinite(value); + }); +} + +QByteArray gunzip(const QByteArray &input) +{ + if (input.size() < 2 || static_cast(input[0]) != 0x1f + || static_cast(input[1]) != 0x8b) { + return input; + } + + z_stream stream{}; + stream.next_in = reinterpret_cast(const_cast(input.constData())); + stream.avail_in = static_cast(input.size()); + if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) { + return {}; + } + + QByteArray output; + char buffer[64 * 1024]; + int result = Z_OK; + while (result == Z_OK) { + stream.next_out = reinterpret_cast(buffer); + stream.avail_out = sizeof(buffer); + result = inflate(&stream, Z_NO_FLUSH); + output.append(buffer, static_cast(sizeof(buffer) - stream.avail_out)); + } + inflateEnd(&stream); + return result == Z_STREAM_END ? output : QByteArray{}; +} + +QDateTime gpsEpoch() +{ + return QDateTime(QDate(1980, 1, 6), QTime(0, 0), Qt::UTC); +} + +} // namespace + +RinexNavigation::RinexNavigation(QObject *parent) + : QObject(parent) +{ + m_positionTimer.setInterval(PositionUpdateIntervalMs); + connect(&m_positionTimer, &QTimer::timeout, + this, &RinexNavigation::updateCurrentPositions); + m_positionTimer.start(); +} + +void RinexNavigation::refresh() +{ + if (m_loading) return; + if (m_ephemerides.isEmpty()) { + loadCache(); + } + setLoading(true); + emit statusChanged(QStringLiteral("Guncel RINEX efemerisi indiriliyor..."), false); + requestDay(QDateTime::currentDateTimeUtc().date(), 0); +} + +void RinexNavigation::requestDay(const QDate &date, int fallbackIndex) +{ + const QString year = QString::number(date.year()); + const QString day = QStringLiteral("%1").arg(date.dayOfYear(), 3, 10, QLatin1Char('0')); + // BKG's stream-derived file is updated during the UTC day; the final IGS + // combination is normally published later and is therefore unsuitable here. + const QString file = QStringLiteral("BRDC00WRD_S_") + year + day + + QStringLiteral("0000_01D_MN.rnx.gz"); + const QUrl url(QStringLiteral("https://igs.bkg.bund.de/root_ftp/IGS/BRDC/%1/%2/%3") + .arg(year, day, file)); + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, + QStringLiteral("ANKASYS-LocationPlotter/1.0 (RINEX client)")); + request.setTransferTimeout(15000); + QNetworkReply *reply = m_network.get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply, date, fallbackIndex] { + const bool ok = reply->error() == QNetworkReply::NoError; + const QByteArray data = ok ? reply->readAll() : QByteArray{}; + reply->deleteLater(); + if (!ok || data.isEmpty()) { + if (fallbackIndex < 2) { + requestDay(date.addDays(-1), fallbackIndex + 1); + } else { + setLoading(false); + if (!m_ephemerides.isEmpty()) { + publishCurrentMotion(); + emit statusChanged( + QStringLiteral("RINEX indirilemedi; mevcut efemeris kullaniliyor"), + true); + } else if (!loadCache()) { + emit statusChanged(QStringLiteral("RINEX indirilemedi; internet baglantisini kontrol edin"), true); + } + } + return; + } + handleDownload(data, date, fallbackIndex); + }); +} + +void RinexNavigation::handleDownload(const QByteArray &compressed, const QDate &date, + int fallbackIndex) +{ + const QByteArray rinex = gunzip(compressed); + QString error; + QVector ephemerides = parseEphemerides(rinex, &error); + const QDateTime now = QDateTime::currentDateTimeUtc(); + const QVector positions = propagateEphemerides( + selectEphemerides(ephemerides, now), now); + if (positions.isEmpty()) { + if (fallbackIndex < 2) { + requestDay(date.addDays(-1), fallbackIndex + 1); + return; + } + setLoading(false); + emit statusChanged(QStringLiteral("RINEX okunamadi: %1").arg(error), true); + return; + } + + QFile cache(cachePath()); + QDir().mkpath(QFileInfo(cache).absolutePath()); + if (cache.open(QIODevice::WriteOnly)) cache.write(rinex); + m_ephemerides = std::move(ephemerides); + setLoading(false); + publishCurrentMotion(); + emit statusChanged(QStringLiteral("%1 GPS uydusu · RINEX %2 · %3") + .arg(positions.size()) + .arg(date.toString(QStringLiteral("dd.MM.yyyy"))) + .arg(now.toLocalTime().toString( + QStringLiteral("HH:mm:ss t"))), false); +} + +bool RinexNavigation::loadCache() +{ + QFile cache(cachePath()); + if (!cache.open(QIODevice::ReadOnly)) return false; + QString error; + QVector ephemerides = parseEphemerides(cache.readAll(), &error); + const QDateTime now = QDateTime::currentDateTimeUtc(); + const QVector positions = propagateEphemerides( + selectEphemerides(ephemerides, now), now); + if (positions.isEmpty()) return false; + m_ephemerides = std::move(ephemerides); + publishCurrentMotion(); + emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX · %2") + .arg(positions.size()) + .arg(now.toLocalTime().toString( + QStringLiteral("HH:mm:ss t"))), true); + return true; +} + +QString RinexNavigation::cachePath() const +{ + return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + + QStringLiteral("/latest-navigation.rnx"); +} + +void RinexNavigation::updateCurrentPositions() +{ + if (m_ephemerides.isEmpty()) return; + publishCurrentMotion(); +} + +void RinexNavigation::publishCurrentMotion() +{ + if (m_ephemerides.isEmpty()) return; + + const QDateTime now = QDateTime::currentDateTimeUtc(); + const QVector selected = selectEphemerides(m_ephemerides, now); + const QVector current = propagateEphemerides(selected, now); + if (current.isEmpty()) return; + + const QVector next = propagateEphemerides( + selected, now.addMSecs(PositionUpdateIntervalMs)); + emit satellitesUpdated(current); + if (!next.isEmpty()) { + emit satelliteMotionUpdated(current, next, PositionUpdateIntervalMs); + } +} + +void RinexNavigation::setLoading(bool loading) +{ + if (m_loading == loading) return; + m_loading = loading; + emit loadingChanged(loading); +} + +QVector RinexNavigation::parseEphemerides( + const QByteArray &rinex, QString *error) +{ + const QString text = QString::fromLatin1(rinex); + const QStringList lines = text.split('\n'); + int index = 0; + while (index < lines.size() && !lines[index].contains(QStringLiteral("END OF HEADER"))) ++index; + if (index >= lines.size()) { + if (error) *error = QStringLiteral("END OF HEADER bulunamadi"); + return {}; + } + ++index; + + QVector result; + result.reserve(512); + while (index < lines.size()) { + const QString first = lines[index]; + if (first.size() < 3) { ++index; continue; } + const QChar system = first[0]; + if (!system.isLetter()) { ++index; continue; } + const int blockLines = (system == QLatin1Char('R') || system == QLatin1Char('S')) ? 4 : 8; + if (index + blockLines > lines.size()) break; + if (system != QLatin1Char('G')) { index += blockLines; continue; } + + const QStringList epoch = first.mid(3, 20).simplified().split(' '); + if (epoch.size() < 6) { index += 8; continue; } + bool dateOk = false; + const int year = epoch[0].toInt(&dateOk); + const QDateTime toc(QDate(year, epoch[1].toInt(), epoch[2].toInt()), + QTime(epoch[3].toInt(), epoch[4].toInt(), + static_cast(epoch[5].toDouble())), Qt::UTC); + if (!dateOk || !toc.isValid()) { index += 8; continue; } + + Ephemeris eph; + eph.prn = first.left(3).trimmed().toUpper(); + eph.toc = toc; + eph.crs = parseField(lines[index + 1], 1); + eph.deltaN = parseField(lines[index + 1], 2); + eph.m0 = parseField(lines[index + 1], 3); + eph.cuc = parseField(lines[index + 2], 0); + eph.eccentricity = parseField(lines[index + 2], 1); + eph.cus = parseField(lines[index + 2], 2); + eph.sqrtA = parseField(lines[index + 2], 3); + eph.toe = parseField(lines[index + 3], 0); + eph.cic = parseField(lines[index + 3], 1); + eph.omega0 = parseField(lines[index + 3], 2); + eph.cis = parseField(lines[index + 3], 3); + eph.i0 = parseField(lines[index + 4], 0); + eph.crc = parseField(lines[index + 4], 1); + eph.omega = parseField(lines[index + 4], 2); + eph.omegaDot = parseField(lines[index + 4], 3); + eph.idot = parseField(lines[index + 5], 0); + eph.gpsWeek = static_cast(std::llround(parseField(lines[index + 5], 2))); + const double health = parseField(lines[index + 6], 1); + eph.healthy = !std::isfinite(health) || std::llround(health) == 0; + + if (finiteFields({eph.crs, eph.deltaN, eph.m0, eph.cuc, eph.eccentricity, + eph.cus, eph.sqrtA, eph.toe, eph.cic, eph.omega0, eph.cis, + eph.i0, eph.crc, eph.omega, eph.omegaDot, eph.idot}) + && eph.sqrtA > 4000.0 && eph.gpsWeek > 0) { + result.push_back(eph); + } + index += 8; + } + if (result.isEmpty() && error) *error = QStringLiteral("GPS efemerisi bulunamadi"); + return result; +} + +QVector RinexNavigation::selectEphemerides( + const QVector &ephemerides, const QDateTime &whenUtc) +{ + QHash closest; + QHash closestAge; + // GPS navigation epochs are labelled in GPS system time, not UTC. + const QDateTime whenGps = whenUtc.addSecs( + static_cast(CurrentGpsUtcOffsetSeconds)); + for (const Ephemeris &eph : ephemerides) { + const qint64 age = std::abs(eph.toc.msecsTo(whenGps)); + if (!closestAge.contains(eph.prn) || age < closestAge[eph.prn]) { + closest[eph.prn] = eph; + closestAge[eph.prn] = age; + } + } + + QVector result; + result.reserve(closest.size()); + for (const Ephemeris &eph : closest) result.push_back(eph); + std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) { + return a.prn < b.prn; + }); + return result; +} + +QVector RinexNavigation::propagateEphemerides( + const QVector &ephemerides, const QDateTime &whenUtc) +{ + QVector result; + result.reserve(ephemerides.size()); + for (const Ephemeris &eph : ephemerides) result.push_back(propagate(eph, whenUtc)); + std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) { return a.prn < b.prn; }); + return result; +} + +SatellitePosition RinexNavigation::propagate(const Ephemeris &eph, const QDateTime &whenUtc) +{ + const double gpsSeconds = gpsEpoch().msecsTo(whenUtc) / 1000.0 + + CurrentGpsUtcOffsetSeconds; + const double absoluteToe = eph.gpsWeek * GpsWeekSeconds + eph.toe; + double tk = gpsSeconds - absoluteToe; + while (tk > HalfGpsWeek) tk -= GpsWeekSeconds; + while (tk < -HalfGpsWeek) tk += GpsWeekSeconds; + + const double a = eph.sqrtA * eph.sqrtA; + const double meanMotion = std::sqrt(GpsMu / (a * a * a)) + eph.deltaN; + const double meanAnomaly = eph.m0 + meanMotion * tk; + double eccentricAnomaly = meanAnomaly; + for (int iteration = 0; iteration < 12; ++iteration) { + eccentricAnomaly = meanAnomaly + eph.eccentricity * std::sin(eccentricAnomaly); + } + + const double trueAnomaly = std::atan2( + std::sqrt(1.0 - eph.eccentricity * eph.eccentricity) * std::sin(eccentricAnomaly), + std::cos(eccentricAnomaly) - eph.eccentricity); + const double phi = trueAnomaly + eph.omega; + const double twoPhi = 2.0 * phi; + const double u = phi + eph.cus * std::sin(twoPhi) + eph.cuc * std::cos(twoPhi); + const double radius = a * (1.0 - eph.eccentricity * std::cos(eccentricAnomaly)) + + eph.crs * std::sin(twoPhi) + eph.crc * std::cos(twoPhi); + const double inclination = eph.i0 + eph.idot * tk + + eph.cis * std::sin(twoPhi) + eph.cic * std::cos(twoPhi); + const double orbitalX = radius * std::cos(u); + const double orbitalY = radius * std::sin(u); + const double ascendingNode = eph.omega0 + (eph.omegaDot - EarthRotation) * tk + - EarthRotation * eph.toe; + + SatellitePosition result; + result.prn = eph.prn; + result.x = orbitalX * std::cos(ascendingNode) + - orbitalY * std::cos(inclination) * std::sin(ascendingNode); + result.y = orbitalX * std::sin(ascendingNode) + + orbitalY * std::cos(inclination) * std::cos(ascendingNode); + result.z = orbitalY * std::sin(inclination); + result.healthy = eph.healthy; + return result; +} + +QVector RinexNavigation::parseGpsNavigation( + const QByteArray &rinex, const QDateTime &whenUtc, QString *error) +{ + const QVector ephemerides = parseEphemerides(rinex, error); + return propagateEphemerides(selectEphemerides(ephemerides, whenUtc), whenUtc); +} diff --git a/src/RinexNavigation.h b/linux/src/RinexNavigation.h similarity index 100% rename from src/RinexNavigation.h rename to linux/src/RinexNavigation.h diff --git a/src/SatelliteData.h b/linux/src/SatelliteData.h similarity index 100% rename from src/SatelliteData.h rename to linux/src/SatelliteData.h diff --git a/src/SerialReader.cpp b/linux/src/SerialReader.cpp similarity index 100% rename from src/SerialReader.cpp rename to linux/src/SerialReader.cpp diff --git a/src/SerialReader.h b/linux/src/SerialReader.h similarity index 100% rename from src/SerialReader.h rename to linux/src/SerialReader.h diff --git a/src/TelemetryPoint.h b/linux/src/TelemetryPoint.h similarity index 100% rename from src/TelemetryPoint.h rename to linux/src/TelemetryPoint.h diff --git a/src/UartReplay.cpp b/linux/src/UartReplay.cpp similarity index 100% rename from src/UartReplay.cpp rename to linux/src/UartReplay.cpp diff --git a/src/UartReplay.h b/linux/src/UartReplay.h similarity index 100% rename from src/UartReplay.h rename to linux/src/UartReplay.h diff --git a/src/main.cpp b/linux/src/main.cpp similarity index 100% rename from src/main.cpp rename to linux/src/main.cpp diff --git a/linux/tests/main_window_tests.cpp b/linux/tests/main_window_tests.cpp new file mode 100644 index 0000000..908444f --- /dev/null +++ b/linux/tests/main_window_tests.cpp @@ -0,0 +1,272 @@ +#include "../src/MainWindow.h" +#include "../src/GlobeWidget.h" +#include "../src/MapWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +void sendLine(MainWindow &window, const QString &line) +{ + const bool invoked = QMetaObject::invokeMethod( + &window, "handleSerialLine", Qt::DirectConnection, Q_ARG(QString, line)); + assert(invoked); +} + +void flushUpdates(MainWindow &window) +{ + const bool invoked = QMetaObject::invokeMethod( + &window, "flushUiUpdates", Qt::DirectConnection); + assert(invoked); +} + +} // namespace + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + qputenv("LOCATION_PLOTTER_PORT", "/dev/test-usb-uart"); + MainWindow window; + qunsetenv("LOCATION_PLOTTER_PORT"); + + { + QTemporaryDir uartLogDirectory; + assert(uartLogDirectory.isValid()); + qputenv("LOCATION_PLOTTER_UART_LOG_DIR", uartLogDirectory.path().toUtf8()); + bool loggingStarted = false; + assert(QMetaObject::invokeMethod( + &window, "startUartLogging", Qt::DirectConnection, + Q_RETURN_ARG(bool, loggingStarted), + Q_ARG(QString, QStringLiteral("/dev/test-uart")), Q_ARG(int, 460800))); + assert(loggingStarted); + sendLine(window, QStringLiteral("raw uart test line")); + assert(QMetaObject::invokeMethod(&window, "stopUartLogging", Qt::DirectConnection)); + qunsetenv("LOCATION_PLOTTER_UART_LOG_DIR"); + + const QStringList logFiles = QDir(uartLogDirectory.path()).entryList( + {QStringLiteral("uart_*.log")}, QDir::Files); + assert(logFiles.size() == 1); + QFile logFile(QDir(uartLogDirectory.path()).filePath(logFiles.constFirst())); + assert(logFile.open(QIODevice::ReadOnly | QIODevice::Text)); + const QString contents = QString::fromUtf8(logFile.readAll()); + assert(contents.contains(QStringLiteral("[SESSION START] port=/dev/test-uart baud=460800"))); + assert(contents.contains(QRegularExpression( + QStringLiteral(R"(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] raw uart test line)")))); + assert(contents.contains(QStringLiteral("[SESSION END]"))); + } + + auto *table = window.findChild(); + auto *referenceLatInput = window.findChild(QStringLiteral("referenceLatInput")); + auto *referenceLonInput = window.findChild(QStringLiteral("referenceLonInput")); + auto *cepValue = window.findChild(QStringLiteral("cepValue")); + auto *maxPointsInput = window.findChild(QStringLiteral("maxPointsInput")); + auto *elevationMaskSpin = window.findChild(QStringLiteral("elevationMaskSpin")); + auto *showTelemetry = window.findChild(QStringLiteral("showTelemetryCheck")); + auto *showRecentPoints = window.findChild(QStringLiteral("showRecentPointsCheck")); + auto *showAverage = window.findChild(QStringLiteral("showAverageCheck")); + auto *portSelector = window.findChild(QStringLiteral("portSelector")); + auto *refreshRinexButton = + window.findChild(QStringLiteral("refreshRinexButton")); + auto *map = window.findChild(); + auto *mainViewTabs = window.findChild(QStringLiteral("mainViewTabs")); + auto *sideSplitter = window.findChild(QStringLiteral("sideSplitter")); + assert(table != nullptr); + assert(referenceLatInput != nullptr); + assert(referenceLonInput != nullptr); + assert(cepValue != nullptr); + assert(maxPointsInput != nullptr); + assert(elevationMaskSpin != nullptr); + assert(elevationMaskSpin->value() == 10); + assert(elevationMaskSpin->minimum() == 0); + assert(elevationMaskSpin->maximum() == 90.0); + elevationMaskSpin->setValue(12.5); + assert(elevationMaskSpin->value() == 12.5); + assert(showTelemetry != nullptr); + assert(showRecentPoints != nullptr); + assert(showAverage != nullptr); + assert(portSelector != nullptr); + assert(refreshRinexButton != nullptr); + assert(refreshRinexButton->text() == QStringLiteral("RINEX Guncelle") + || refreshRinexButton->text() == QStringLiteral("RINEX indiriliyor...")); + assert(portSelector->isEditable()); + assert(portSelector->count() >= 1); + assert(portSelector->currentText() == QStringLiteral("/dev/test-usb-uart")); + for (int i = 0; i < portSelector->count(); ++i) { + assert(portSelector->itemText(i).startsWith(QStringLiteral("/dev/"))); + assert(!portSelector->itemText(i).trimmed().isEmpty()); + } + assert(map != nullptr); + assert(mainViewTabs != nullptr); + assert(mainViewTabs->count() == 2); + assert(sideSplitter != nullptr); + assert(sideSplitter->orientation() == Qt::Vertical); + + sendLine(window, QStringLiteral("[TRK] tracked satellites=2 prn_indices=[2,7]")); + assert(map != nullptr); + auto *globe = window.findChild(); + assert(globe != nullptr); + assert(globe->observedPrns() + == QSet({QStringLiteral("G03"), QStringLiteral("G08")})); + sendLine(window, QStringLiteral("[TRK] tracked satellites=1 prn_indices=[13]")); + assert(globe->observedPrns() == QSet({QStringLiteral("G14")})); + + assert(QMetaObject::invokeMethod(map, "referenceLocationSelected", Qt::DirectConnection, + Q_ARG(double, 40.25), Q_ARG(double, 29.5))); + assert(referenceLatInput->text() == QStringLiteral("40.250000000000000")); + assert(referenceLonInput->text() == QStringLiteral("29.500000000000000")); + referenceLatInput->clear(); + referenceLonInput->clear(); + assert(QMetaObject::invokeMethod(&window, "updateReferenceLocation", Qt::DirectConnection)); + showTelemetry->setChecked(false); + assert(!showRecentPoints->isEnabled()); + assert(!showAverage->isEnabled()); + showTelemetry->setChecked(true); + assert(showRecentPoints->isEnabled()); + assert(showAverage->isEnabled()); + + sendLine(window, QStringLiteral("lattitude : 40.78925034214206846173")); + sendLine(window, QStringLiteral("longitude : 29.46277530841727809729")); + assert(table->model()->rowCount() == 0); + + sendLine(window, QStringLiteral("NAV solve done")); + flushUpdates(window); + assert(table->model()->rowCount() == 1); + assert(table->model()->data(table->model()->index(0, 2)).toString() + == QStringLiteral("40.78925034214206846173")); + assert(table->model()->data(table->model()->index(0, 3)).toString() + == QStringLiteral("29.46277530841727809729")); + + sendLine(window, QStringLiteral( + "[LOCATION] source=gps lat=40.1 lon=29.1 height=100 gdop=2 pdop=1.5 " + "hdop=1 vdop=1.2 tdop=0.8 tracked=5 used=5")); + sendLine(window, QStringLiteral( + "[LOCATION] source=imu_corrected lat=40.10001 lon=29.10001 height=100.2 " + "gdop=2 pdop=1.5 hdop=1 vdop=1.2 tdop=0.8 tracked=5 used=5")); + flushUpdates(window); + assert(table->model()->rowCount() == 3); + assert(table->model()->data(table->model()->index(1, 1)).toString() + == QStringLiteral("GPS")); + assert(table->model()->data(table->model()->index(2, 1)).toString() + == QStringLiteral("IMU corrected")); + sendLine(window, QStringLiteral( + "[LOCATION] source=trajectory lat=40.10002 lon=29.10002 height=100 tracked=4 used=4")); + flushUpdates(window); + assert(table->model()->rowCount() == 4); + assert(table->model()->data(table->model()->index(3, 1)).toString() + == QStringLiteral("Trajectory (truth)")); + + QTemporaryFile replayLog; + assert(replayLog.open()); + replayLog.write( + "[LOCATION] source=trajectory lat=40.0 lon=29.0 height=100 tracked=4 used=4\n" + "[LOCATION] source=gps lat=40.0001 lon=29.0001 height=104 tracked=4 used=4\n" + "[LOCATION] source=imu_corrected lat=40.00002 lon=29.00002 height=101 tracked=4 used=4\n"); + replayLog.flush(); + assert(window.loadTelemetryLog(replayLog.fileName())); + assert(table->model()->rowCount() == 3); + assert(table->model()->data(table->model()->index(0, 1)).toString() + == QStringLiteral("Trajectory (truth)")); + + assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection)); + sendLine(window, QStringLiteral( + "lat=40.78925034214206846173 long=29.46277530841727809729")); + flushUpdates(window); + assert(table->model()->rowCount() == 1); + + referenceLatInput->setText(QStringLiteral("40.78925034214206846173")); + referenceLonInput->setText(QStringLiteral("29.46277530841727809729")); + const bool referenceUpdated = QMetaObject::invokeMethod( + &window, "updateReferenceLocation", Qt::DirectConnection); + assert(referenceUpdated); + assert(cepValue->text().startsWith(QStringLiteral("0.00 m"))); + assert(cepValue->text().contains(QStringLiteral("N=1"))); + + const bool cleared = QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection); + assert(cleared); + assert(table->model()->rowCount() == 0); + assert(referenceLatInput->text() == QStringLiteral("40.78925034214206846173")); + assert(referenceLonInput->text() == QStringLiteral("29.46277530841727809729")); + assert(cepValue->text() == QStringLiteral("-")); + + referenceLatInput->setText(QStringLiteral("40.0")); + referenceLonInput->setText(QStringLiteral("29.0")); + assert(QMetaObject::invokeMethod(&window, "updateReferenceLocation", Qt::DirectConnection)); + sendLine(window, QStringLiteral("lat=40.000 long=29.000")); + sendLine(window, QStringLiteral("lat=40.001 long=29.000")); + sendLine(window, QStringLiteral("lat=40.002 long=29.000")); + flushUpdates(window); + assert(cepValue->text().startsWith(QStringLiteral("111 m"))); + assert(cepValue->text().contains(QStringLiteral("N=3"))); + assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection)); + + referenceLatInput->setText(QStringLiteral("40.78925034214206846173")); + referenceLonInput->setText(QStringLiteral("29.46277530841727809729")); + assert(QMetaObject::invokeMethod(&window, "updateReferenceLocation", Qt::DirectConnection)); + + maxPointsInput->setValue(10000); + for (int i = 0; i < 10000; ++i) { + sendLine(window, QStringLiteral("lat=40.78925 long=29.46277 sats=7")); + } + flushUpdates(window); + assert(table->model()->rowCount() == 10000); + assert(cepValue->text().contains(QStringLiteral("N=10000"))); + assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection)); + maxPointsInput->setValue(10); + + { + MapWidget followMap; + TelemetryPoint corrected; + corrected.source = LocationSource::ImuCorrected; + corrected.latitude = 40.1; + corrected.longitude = 29.1; + corrected.receivedAt = QDateTime::currentDateTimeUtc(); + + TelemetryPoint rawGps; + rawGps.source = LocationSource::Gps; + rawGps.latitude = 41.0; + rawGps.longitude = 30.0; + rawGps.receivedAt = corrected.receivedAt.addMSecs(100); + followMap.setPoints({corrected, rawGps}); + assert(followMap.centerCoordinate() == QPointF(40.1, 29.1)); + + rawGps.receivedAt = corrected.receivedAt.addMSecs(1100); + followMap.setPoints({corrected, rawGps}); + assert(followMap.centerCoordinate() == QPointF(41.0, 30.0)); + } + + const QByteArray screenshotPath = qgetenv("LOCATION_PLOTTER_TEST_SCREENSHOT"); + if (!screenshotPath.isEmpty()) { + sendLine(window, QStringLiteral("NAV solve start sats:5")); + sendLine(window, QStringLiteral("tracking PRNs: G03, G08, G14, G21, G27")); + sendLine(window, QStringLiteral( + "lat=40.78925034214206846173 long=29.46277530841727809729 " + "hheight=-89.05779508221894502640 sats=7 PDOP=1.235")); + flushUpdates(window); + mainViewTabs->setCurrentIndex(1); + window.show(); + app.processEvents(); + window.repaint(); + app.processEvents(); + assert(window.grab().save(QString::fromLocal8Bit(screenshotPath), "PNG")); + } + + return 0; +} diff --git a/tests/parser_tests.cpp b/linux/tests/parser_tests.cpp similarity index 100% rename from tests/parser_tests.cpp rename to linux/tests/parser_tests.cpp diff --git a/tests/uart_replay_tests.cpp b/linux/tests/uart_replay_tests.cpp similarity index 100% rename from tests/uart_replay_tests.cpp rename to linux/tests/uart_replay_tests.cpp diff --git a/CMakeLists.txt b/macos/CMakeLists.txt similarity index 94% rename from CMakeLists.txt rename to macos/CMakeLists.txt index f34527c..d18556e 100644 --- a/CMakeLists.txt +++ b/macos/CMakeLists.txt @@ -2,6 +2,10 @@ cmake_minimum_required(VERSION 3.20) project(location_plotter VERSION 0.3.0 LANGUAGES CXX) +if(NOT APPLE) + message(FATAL_ERROR "macos/ yalnizca macOS icin yapilandirilabilir") +endif() + set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) @@ -51,10 +55,6 @@ qt_add_resources(location_plotter "app_resources" FILES resources/ankasys-logo.png resources/land-110m.json resources/bluemarble-2048.png ) -if(UNIX AND NOT APPLE) - target_compile_definitions(location_plotter PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1) -endif() - enable_testing() add_executable(location_parser_tests @@ -116,9 +116,6 @@ qt_add_resources(location_main_window_tests "test_app_resources" PREFIX "/" FILES resources/ankasys-logo.png resources/land-110m.json resources/bluemarble-2048.png ) -if(UNIX AND NOT APPLE) - target_compile_definitions(location_main_window_tests PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1) -endif() add_test(NAME location_main_window_tests COMMAND location_main_window_tests) set_tests_properties(location_main_window_tests PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_CACHE_HOME=/tmp/location-plotter-test-cache") diff --git a/macos/README.md b/macos/README.md new file mode 100644 index 0000000..f73589f --- /dev/null +++ b/macos/README.md @@ -0,0 +1,242 @@ +# Location Plotter + +Qt6/C++ GUI that listens to a USB UART on macOS, parses normal debug lines, +and plots live locations on a map. + +The application also includes an interactive 3D Earth/orbit view. It downloads the current +stream-derived RINEX 3 navigation file from the BKG/IGS archive at startup and every 15 +minutes, then recomputes GPS satellite positions for current UTC every minute. The last valid +file is cached for offline startup. + +## Parsed fields + +The parser accepts labeled values in the same line or in separate lines: + +```text +lat=41.0082 long=28.9784 hheight=123.45 sats=7 +latitude: 41.0082 longitude: 28.9784 height: 123.45 activeSats: 7 +active_sats=8 +navigation software running with 5 satellites +GDOP : 3.9576744550 +PDOP : 3.2540598660 +HDOP : 2.3631161789 +VDOP : 2.2370935468 +TDOP : 2.2526165852 +tracking PRNs: G03, G08, 14, 21 +SV=G27 +[LOCATION] source=gps lat=40.7357 lon=31.5985 height=812.0 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 +[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 +[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=812.1 tracked=4 used=4 +lattitude : 40.78925034214206846173 +longitude : 29.46277530841727809729 +height : -89.05779508221894502640 +``` + +Accepted location labels: + +- Latitude: `lat`, `latitude`, `lattitude` +- Longitude: `long`, `longitude`, `lon`, `lng` +- Height: `hheight`, `height`, `alt`, `altitude` +- Active satellites: `sat`, `sats`, `satellite`, `satellites`, `active_sats`, + `activeSats`, `satellites_active`, `num_sats`, `uydu` +- DOP: `GDOP`, `PDOP`, `HDOP`, `VDOP`, `TDOP` +- GPS identifiers: `G03`, `PRN: 3`, `PRNs: 3, 8, 14`, `SV=G21`, `SVID: 27` +- Location source: `source=gps`, `source=imu_corrected`, `source=trajectory` +- `tracked` is accepted as the active satellite count; `used` remains visible in + the raw UART line and denotes satellites used by the WLS solution. + +If a satellite count arrives without a location, the active satellite display is updated. +If a location arrives without a satellite count, the latest known satellite count is attached +to that point. +If DOP values arrive before a location, the latest known values stay visible in the DOP +section and are attached to the next plotted point. +If latitude, longitude, and height arrive as separate consecutive lines, they are combined +into a single plotted point. + +The GPS firmware's level-1 paired protocol is source-aware. Every received +`source=gps` location is added to the blue GPS series and every +`source=imu_corrected` location is added to the red corrected series at the +firmware-configured UART cadence. +Paths are built independently, so alternating UART lines do not create GPS-to-IMU +zigzags. A corrected point newer than one second is also preferred for map +auto-follow, the current-position label and the globe receiver marker. Raw GPS +outliers remain visible in blue for diagnosis without pulling the whole view away. +When corrected points exist, CEP50, average and scatter calculations use only that +corrected series; legacy input without a source keeps the previous behavior. +Synthetic `source=trajectory` points form a third independent green ground-truth +path. The map legend identifies all three series. + +## UART log replay + +Use the `UART Replay` button (or `Dosya > UART Log Replay...`) to feed a recorded +UART `.log`/`.txt` file back through the same parser used by the live serial port. +Choose the raw GPS update interval (default `200 ms`) and sensor/fused update +interval (default `50 ms`) in the replay dialog. Press `Replay Durdur` to stop. + +Recorder timestamps are optional and do not control replay speed. The replayer +removes a leading timestamp when present, identifies `source=gps` as raw GPS and +`source=imu_corrected` or `[FUSION]` as fused/sensor output, then synthesizes the +two update timelines while retaining the original UART line order. Other UART +diagnostic lines are forwarded with the surrounding location update. + +## Sentetik ESKF senaryolarini açma + +GPS deposundan 48 kombinasyonun replay loglarini üretin: + +```bash +cd /home/onur/Desktop/gps +make -C soc/software/fusion/tests scenario-logs +``` + +Sonra GUI'yi açıp `Senaryo Logu Ac` düğmesinden örneğin +`soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log` dosyasını seçin. +GUI logdaki bütün rotayı otomatik sığdırır. Aynı dosya komut satırından da +doğrudan açılabilir: + +```bash +cd location_plotter/macos +cmake -S . -B build +cmake --build build -j +./build/location_plotter --replay \ + /home/onur/Desktop/gps/soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log +``` + +Tek bir özel kombinasyon üretmek için: + +```bash +cd /home/onur/Desktop/gps/soc/software/fusion/tests +make platform_scenario_matrix_test +./platform_scenario_matrix_test --log aircraft 500 19 aircraft.log +``` + +Desteklenen senaryolar `stationary`, `pedestrian`, `car`, `aircraft`; GNSS +periyotları 200/500/1000 ms, IMU periyotları 19/20/200/300 ms'dir. + +## macOS'ta kurulum (MacBook Air, Apple Silicon veya Intel) + +Guncel macOS surumunde once [Homebrew](https://brew.sh) kurulu olmalidir. Ardindan: + +```bash +cd location_plotter/macos +chmod +x setup-macos.sh run.sh +./setup-macos.sh +./run.sh +``` + +Betik Homebrew ile guncel `cmake` ve `qt` paketlerini kurar, yerel mimari icin +`location_plotter.app` paketini derler, gerekli Qt kutuphanelerini uygulama paketine +ekler ve testleri calistirir. + +macOS USB UART aygitlarini genellikle iki adla gosterir: + +```text +/dev/cu.usbserial-XXXXXXXX +/dev/tty.usbserial-XXXXXXXX +``` + +veya bir CDC aygitinda: + +```text +/dev/cu.usbmodemXXXX +/dev/tty.usbmodemXXXX +``` + +Uygulama her iki bicimi de algilar. Mümkünse `cu.usb...` yolunu secin; macOS'ta +disariya baglanti baslatan uygulamalar icin uygun olan aygit budur. UART uygulama +acikken takildiysa port alaninin yanindaki `Yenile` dugmesine basin. Otomatik +algilanmayan bir yol port alanina elle de yazilabilir. + +Istenen port baslatmadan once ortam degiskeniyle de secilebilir: + +```bash +LOCATION_PLOTTER_PORT=/dev/cu.usbserial-XXXXXXXX ./run.sh +``` + +Port hic gorunmuyorsa `ls /dev/cu.usb* /dev/tty.usb*` ile macOS'un aygiti olusturup +olusturmadigini kontrol edin. Hic sonuc yoksa kabloyu/veri baglantisini ve UART +donaniminizin macOS surucusunu kontrol edin. + +## Elle derleme + +Homebrew ile `cmake` ve `qt` kurulduktan sonra derleme ve test: + +```bash +cmake -S . -B build -DCMAKE_PREFIX_PATH="$(brew --prefix qt)" +cmake --build build +ctest --test-dir build --output-on-failure +``` + +Run: + +```bash +./run.sh +``` + +Uygulama Qt tabanli bir macOS masaustu arayuzudur. Harita karolari ve guncel +RINEX verisi icin internet erisimi gerekir; son gecerli RINEX verisi ve harita +onbellegiyle cevrimdisi kullanim da desteklenir. Port secici USB ile ilgili +`/dev/cu.*` ve `/dev/tty.*` aygitlarini listeler. Hic aygit algilanmazsa +`/dev/cu.usbserial` duzenlenebilir bir varsayilan yol olarak gosterilir. + +## Notes + +- The map uses OpenStreetMap tiles through Qt Network. If there is no internet connection, + points are still drawn over a fallback grid. +- The `3B Dunya · Uydular` tab appears beside the map. It uses broadcast ephemeris values + from BKG's near-real-time `BRDC00WRD_S` RINEX file to propagate GPS satellites in ECEF. + PRNs parsed from the current navigation solve are green; other RINEX satellites are red. + Drag to rotate the globe and use the mouse wheel to zoom. +- The globe includes a shaded continent layer and a receiver-centred translucent visibility + cone. `Min. elevation` accepts a directly typed decimal value from 0 to 90 degrees + (10 degrees by default, press Enter after typing); + satellites inside the mask get a cyan sight line and halo. Incoming positions update the + receiver marker without resetting a globe orientation chosen by dragging. +- The globe surface uses NASA/GSFC Scientific Visualization Studio's 2048x1024 Blue Marble + mosaic (NASA SVS visualization 2915), projected locally without a runtime network request. +- RINEX download starts at every launch, repeats every 15 minutes, and falls back through the + previous two UTC days if today's stream file is unavailable. Current and next-minute satellite + positions are propagated from the cached ephemeris, then smoothly interpolated at 10 FPS. +- The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status + line shows the system-local time of the last successful download or cache load. +- Complete UART channel snapshots such as `tracked satellites=5 prn_indices=[...]` replace the + previous connected-PRN set, so satellites that were lost no longer remain green. Firmware + channel indices are zero-based and are normalized for display (`0` is GPS PRN `G01`). +- RINEX text is parsed once per download and retained as compact ephemeris records. The static + globe is cached between camera changes, reducing CPU and allocation load during orbit animation. +- The globe uses an embedded Natural Earth coastline mask with terrain-like land colouring, + shallow-water edges, atmospheric shading, and a cached Earth-fixed texture for smooth rotation. +- The main content width and the right-side dashboard/UART height are both adjustable by + dragging their splitter handles. Dashboard cards scroll independently so the UART log can + be enlarged substantially. +- `Max` controls how many recent points stay visible and are included in location + analysis. The default is 10. When the limit is reached, older points are removed + automatically. +- Location analysis shows the average of the plotted samples, the last point's deviation + from that average, mean deviation, RMS scatter, and max deviation in meters. The average + marker can be toggled on the map. +- The map layers panel can hide all telemetry overlays, show only the newest location instead + of the recent path, and independently toggle the average marker. +- GPS points and paths are blue. IMU+ESKF corrected points and their independent + path are red. Synthetic ground-truth trajectory is green; the locations table + shows the parsed source in its `Kaynak` column. +- The application calculates empirical CEP50 as the median horizontal distance of the retained + measurements from their spherical mean, then draws the circle around that measurement center. + This is the radius containing 50 percent of the retained position fixes and is independent of + the optional user reference. +- A user reference can be selected by right-clicking the map. The selected WGS84 coordinates + are transferred to the reference fields with 15 decimal places but do not alter CEP50. +- Latitude and longitude tokens are preserved exactly as received for the table and map + label. Calculations and map projection use IEEE 754 `double` precision. +- The OpenStreetMap Standard raster layer supports zoom levels through 19. At latitude 40.8, + zoom 19 is approximately 0.23 meters per pixel; the marker still uses subpixel coordinates, + but the background tiles cannot add accuracy to the GNSS measurement. +- OpenStreetMap attribution is shown on the map. Tile responses use a 100 MB persistent HTTP + cache in the application's standard cache directory. +- UART log and telemetry views are refreshed in batches so a high-rate serial stream does not + rebuild the complete interface for every received line. The locations table uses a virtual + model and remains usable with a high `Max` value. +- Her basarili UART baglantisi, dosya adinda ve her satirda yerel zaman damgasi bulunan ayri bir + log olusturur. Dosyalar kaynak kodun ana klasorundeki `uart_logs` dizinine yazilir; + `LOCATION_PLOTTER_UART_LOG_DIR` ile farkli bir klasor secilebilir. +- If no new plotted location arrives for 10 seconds while listening, current location + points and analysis values are cleared. diff --git a/macos/resources/ankasys-logo.png b/macos/resources/ankasys-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..75bbd3737d240e1a673e64987c87de94cfd3c375 GIT binary patch literal 24036 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 v_*;j*`I~&2 + exit 1 +fi + +project_dir="$(pwd -P)" + +if [[ -f ./build/CMakeCache.txt ]]; then + cached_source="$(sed -n 's/^CMAKE_HOME_DIRECTORY:INTERNAL=//p' ./build/CMakeCache.txt)" + if [[ -n "$cached_source" && "$cached_source" != "$project_dir" ]]; then + echo "Build klasoru farkli bir proje yoluna ait. 'macos/build' klasorunu silip tekrar deneyin." >&2 + exit 1 + fi +fi + +cmake_args=(-S . -B build) +if command -v brew >/dev/null 2>&1; then + cmake_args+=("-DCMAKE_PREFIX_PATH=$(brew --prefix qt)") +fi + +if ! cmake "${cmake_args[@]}"; then + print_qt_install_hint + exit 1 +fi + +cmake --build build + +exec ./build/location_plotter.app/Contents/MacOS/location_plotter "$@" diff --git a/setup-macos.sh b/macos/setup-macos.sh similarity index 91% rename from setup-macos.sh rename to macos/setup-macos.sh index 0a4651f..c5c4bf5 100755 --- a/setup-macos.sh +++ b/macos/setup-macos.sh @@ -22,8 +22,8 @@ parallel_jobs="$(sysctl -n hw.logicalcpu 2>/dev/null || echo 4)" if [[ -f build/CMakeCache.txt ]]; then cached_system="$(sed -n 's/^CMAKE_SYSTEM_NAME:INTERNAL=//p' build/CMakeCache.txt)" if [[ -n "$cached_system" && "$cached_system" != "Darwin" ]]; then - echo "Baska bir sisteme ait build klasoru yeniden olusturuluyor." - rm -rf build + echo "Build klasoru macOS disinda bir sisteme ait. 'macos/build' klasorunu silip tekrar deneyin." >&2 + exit 1 fi fi diff --git a/src/GlobeWidget.cpp b/macos/src/GlobeWidget.cpp similarity index 100% rename from src/GlobeWidget.cpp rename to macos/src/GlobeWidget.cpp diff --git a/src/GlobeWidget.h b/macos/src/GlobeWidget.h similarity index 100% rename from src/GlobeWidget.h rename to macos/src/GlobeWidget.h diff --git a/macos/src/LocationParser.cpp b/macos/src/LocationParser.cpp new file mode 100644 index 0000000..6e99605 --- /dev/null +++ b/macos/src/LocationParser.cpp @@ -0,0 +1,283 @@ +#include "LocationParser.h" + +#include + +#include + +namespace { + +double toDouble(QString value, bool *ok) +{ + value.replace(',', '.'); + return value.toDouble(ok); +} + +QString normalizedCoordinateText(QString value) +{ + value.replace(',', '.'); + if (value.startsWith('+')) { + value.remove(0, 1); + } + return value; +} + +QString normalizeKey(QString key) +{ + key = key.toLower(); + key.remove('_'); + key.remove('-'); + key.remove(' '); + + if (key == "lat" || key == "latitude" || key == "lattitude") { + return "latitude"; + } + + if (key == "long" || key == "longitude" || key == "lon" || key == "lng") { + return "longitude"; + } + + if (key == "hheight" || key == "height" || key == "alt" || key == "altitude") { + return "hheight"; + } + + if (key == "sat" || key == "sats" || key == "satellite" || key == "satellites" + || key == "activesat" || key == "activesats" || key == "activesatellite" + || key == "activesatellites" || key == "numsat" || key == "numsats" + || key == "numbersat" || key == "numbersats" || key == "satactive" + || key == "satsactive" || key == "satelliteactive" || key == "satellitesactive" + || key == "uydu" || key == "uydular") { + return "satellites"; + } + + if (key == "tracked") { + return "satellites"; + } + + if (key == "gdop" || key == "pdop" || key == "hdop" || key == "vdop" || key == "tdop") { + return key; + } + + return {}; +} + +bool isValidLatitude(double latitude) +{ + return std::isfinite(latitude) && latitude >= -90.0 && latitude <= 90.0; +} + +bool isValidLongitude(double longitude) +{ + return std::isfinite(longitude) && longitude >= -180.0 && longitude <= 180.0; +} + +} // namespace + +std::optional LocationParser::parseLine(const QString &line) +{ + const QString trimmed = line.trimmed(); + if (trimmed.isEmpty()) { + return std::nullopt; + } + + static const QRegularExpression labeledValueRegex( + QStringLiteral(R"((^|[^A-Za-z0-9_])(?lat(?:t?itude)?|long(?:itude)?|lon|lng|hheight|height|alt(?:itude)?|active[_ -]?sat(?:ellite)?s?|num(?:ber)?[_ -]?sat(?:ellite)?s?|sat(?:ellite)?s?[_ -]?active|sat(?:ellite)?s?|sats|tracked|uydu(?:lar)?|[gphvt][_ -]?dop)(?:\s*(?:=|:|->)?\s*)(?[+-]?(?:\d+(?:[\.,]\d+)?|[\.,]\d+)(?:[eE][+-]?\d+)?))"), + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression satelliteCountPhraseRegex( + QStringLiteral(R"((^|[^A-Za-z0-9_\.])(?\d+)\s+(?:active\s+)?(?:sat(?:ellite)?s?|uydu(?:lar)?)(?=$|[\s,.;)\]]))"), + QRegularExpression::CaseInsensitiveOption); + + ParsedTelemetry telemetry; + telemetry.rawLine = trimmed; + if (trimmed.startsWith(QStringLiteral("[FUSION]"), Qt::CaseInsensitive)) { + telemetry.source = LocationSource::ImuCorrected; + } + static const QRegularExpression sourceRegex( + QStringLiteral(R"((?:^|\s)source\s*=\s*(?gps|imu[_-]?corrected|trajectory|ground[_-]?truth)(?=$|\s))"), + QRegularExpression::CaseInsensitiveOption); + const QRegularExpressionMatch sourceMatch = sourceRegex.match(trimmed); + if (sourceMatch.hasMatch()) { + const QString source = sourceMatch.captured(QStringLiteral("source")); + if (source.startsWith(QStringLiteral("imu"), Qt::CaseInsensitive)) { + telemetry.source = LocationSource::ImuCorrected; + } else if (source.startsWith(QStringLiteral("trajectory"), Qt::CaseInsensitive) + || source.startsWith(QStringLiteral("ground"), Qt::CaseInsensitive)) { + telemetry.source = LocationSource::Trajectory; + } else { + telemetry.source = LocationSource::Gps; + } + } + static const QRegularExpression fusionModeRegex( + QStringLiteral(R"((?:^|\s)mode\s*=\s*(?gps[_-]?aided|dead[_-]?reckoning)(?=$|\s))"), + QRegularExpression::CaseInsensitiveOption); + const QRegularExpressionMatch fusionModeMatch = fusionModeRegex.match(trimmed); + if (fusionModeMatch.hasMatch()) { + telemetry.fusionMode = fusionModeMatch.captured(QStringLiteral("mode")).startsWith( + QStringLiteral("dead"), Qt::CaseInsensitive) + ? FusionMode::DeadReckoning : FusionMode::GpsAided; + } + static const QRegularExpression gpsAgeRegex( + QStringLiteral(R"((?:^|\s)gps[_-]?age[_-]?ms\s*=\s*(?\d+)(?=$|\s))"), + QRegularExpression::CaseInsensitiveOption); + const QRegularExpressionMatch gpsAgeMatch = gpsAgeRegex.match(trimmed); + if (gpsAgeMatch.hasMatch()) { + bool ok = false; + const qint64 ageMs = gpsAgeMatch.captured(QStringLiteral("age")).toLongLong(&ok); + if (ok) { + telemetry.gpsAgeMs = ageMs; + } + } + bool hadCoordinateField = false; + bool hadLatitudeField = false; + bool hadLongitudeField = false; + + auto matches = labeledValueRegex.globalMatch(trimmed); + while (matches.hasNext()) { + const QRegularExpressionMatch match = matches.next(); + const QString field = normalizeKey(match.captured(QStringLiteral("key"))); + if (field.isEmpty()) { + continue; + } + + bool ok = false; + const double value = toDouble(match.captured(QStringLiteral("value")), &ok); + if (!ok || !std::isfinite(value)) { + continue; + } + + if (field == "latitude") { + hadCoordinateField = true; + hadLatitudeField = true; + telemetry.latitude = value; + telemetry.latitudeText = normalizedCoordinateText( + match.captured(QStringLiteral("value"))); + } else if (field == "longitude") { + hadCoordinateField = true; + hadLongitudeField = true; + telemetry.longitude = value; + telemetry.longitudeText = normalizedCoordinateText( + match.captured(QStringLiteral("value"))); + } else if (field == "hheight") { + telemetry.hheight = value; + } else if (field == "satellites") { + const int count = static_cast(std::llround(value)); + if (count >= 0) { + telemetry.satellites = count; + } + } else if (field == "gdop") { + if (value >= 0.0) { + telemetry.dop.gdop = value; + } + } else if (field == "pdop") { + if (value >= 0.0) { + telemetry.dop.pdop = value; + } + } else if (field == "hdop") { + if (value >= 0.0) { + telemetry.dop.hdop = value; + } + } else if (field == "vdop") { + if (value >= 0.0) { + telemetry.dop.vdop = value; + } + } else if (field == "tdop") { + if (value >= 0.0) { + telemetry.dop.tdop = value; + } + } + } + + auto satellitePhraseMatches = satelliteCountPhraseRegex.globalMatch(trimmed); + while (satellitePhraseMatches.hasNext()) { + const QRegularExpressionMatch match = satellitePhraseMatches.next(); + bool ok = false; + const int count = match.captured(QStringLiteral("value")).toInt(&ok); + if (ok && count >= 0) { + telemetry.satellites = count; + } + } + + if (telemetry.latitude.has_value() && !isValidLatitude(*telemetry.latitude)) { + telemetry.latitude.reset(); + telemetry.latitudeText.clear(); + } + if (telemetry.longitude.has_value() && !isValidLongitude(*telemetry.longitude)) { + telemetry.longitude.reset(); + telemetry.longitudeText.clear(); + } + + if (hadLatitudeField && hadLongitudeField + && (!telemetry.hasLocation() + || !isValidCoordinate(*telemetry.latitude, *telemetry.longitude))) { + telemetry.latitude.reset(); + telemetry.longitude.reset(); + telemetry.latitudeText.clear(); + telemetry.longitudeText.clear(); + telemetry.hheight.reset(); + } else if (hadCoordinateField && !telemetry.latitude.has_value() + && !telemetry.longitude.has_value()) { + telemetry.hheight.reset(); + } + + if (!telemetry.hasAnyValue()) { + return std::nullopt; + } + + return telemetry; +} + +bool LocationParser::isValidCoordinate(double latitude, double longitude) +{ + return std::isfinite(latitude) && std::isfinite(longitude) + && latitude >= -90.0 && latitude <= 90.0 + && longitude >= -180.0 && longitude <= 180.0; +} + +QSet LocationParser::parseGpsPrns(const QString &line) +{ + QSet result; + const auto addPrn = [&result](QString value, bool zeroBased = false) { + value = value.trimmed().toUpper(); + const bool explicitGpsPrn = value.startsWith(QLatin1Char('G')); + if (explicitGpsPrn) value.remove(0, 1); + bool ok = false; + int number = value.toInt(&ok); + if (ok && zeroBased && !explicitGpsPrn) ++number; + if (ok && number >= 1 && number <= 63) { + result.insert(QStringLiteral("G%1").arg(number, 2, 10, QLatin1Char('0'))); + } + }; + + static const QRegularExpression gpsToken( + QStringLiteral(R"((?:^|[^A-Za-z0-9])G(?\d{1,2})(?=$|[^0-9]))"), + QRegularExpression::CaseInsensitiveOption); + auto gpsMatches = gpsToken.globalMatch(line); + while (gpsMatches.hasNext()) addPrn(gpsMatches.next().captured(QStringLiteral("prn"))); + + // Current firmware versions called their zero-based hardware channel values + // "prns" in complete tracking snapshots. Keep accepting those messages while + // newer firmware uses the unambiguous "prn_indices" label. + static const QRegularExpression legacyTrackingIndices( + QStringLiteral(R"(\btracked\s+satellites\s*=\s*\d+\s+prns?\s*=)"), + QRegularExpression::CaseInsensitiveOption); + const bool legacyZeroBasedPrns = legacyTrackingIndices.match(line).hasMatch(); + + static const QRegularExpression labeledList( + QStringLiteral(R"((?