diff --git a/.gitignore b/.gitignore index ddf6da2..1dddccb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build-*/ *.autosave *.swp *~ +uart_logs/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 8343a68..d4cb237 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.20) -project(location_plotter VERSION 0.1.0 LANGUAGES CXX) +project(location_plotter VERSION 0.3.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -22,6 +22,8 @@ add_executable(location_plotter src/MapWidget.h src/SerialReader.cpp src/SerialReader.h + src/UartReplay.cpp + src/UartReplay.h src/RinexNavigation.cpp src/RinexNavigation.h src/SatelliteData.h @@ -30,6 +32,19 @@ add_executable(location_plotter ) 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}" +) + +if(APPLE) + set_target_properties(location_plotter PROPERTIES + MACOSX_BUNDLE TRUE + MACOSX_BUNDLE_BUNDLE_NAME "Location Plotter" + MACOSX_BUNDLE_GUI_IDENTIFIER "com.ankasys.location-plotter" + MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}" + ) +endif() qt_add_resources(location_plotter "app_resources" PREFIX "/" @@ -52,6 +67,9 @@ add_executable(location_parser_tests 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 @@ -66,12 +84,20 @@ add_executable(location_main_window_tests 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 @@ -82,3 +108,22 @@ 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") + +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/README.md b/README.md index 663bbb2..0ec0406 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Location Plotter -Qt6/C++ GUI that listens to `/dev/ttyUSB2`, parses normal debug lines, and plots live -locations on a map. +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 @@ -24,6 +24,9 @@ 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 @@ -38,6 +41,9 @@ Accepted location labels: `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 @@ -47,6 +53,109 @@ 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 /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 @@ -92,8 +201,11 @@ Uygulama Qt tabanli bir masaustu arayuzudur; grafik oturumu olan bir Ubuntu maki calistirilmalidir. Harita karolari ve guncel RINEX verisi icin internet erisimi gerekir, ancak son gecerli RINEX verisi ve harita onbellegiyle cevrimdisi kullanim da desteklenir. -The port selector lists only detected `/dev/ttyUSB*` devices. The default selection is -`/dev/ttyUSB2` and the default baud rate is `115200`. +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: @@ -122,7 +234,14 @@ without logging out. 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 30 FPS. + 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 @@ -136,6 +255,9 @@ without logging out. 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 @@ -152,5 +274,8 @@ without logging out. - 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/run.sh b/run.sh index 72b648e..8113a5c 100755 --- a/run.sh +++ b/run.sh @@ -5,29 +5,46 @@ 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" + if [[ "$(uname -s)" == "Darwin" ]]; then + echo "Qt6 bulunamadi. macOS'ta gerekli paketleri kurmak icin:" + echo " brew install cmake qt" + echo "veya doğrudan: ./setup-macos.sh" + else + echo "Qt6 bulunamadi. Ubuntu'da gerekli paketleri kurmak icin:" + echo " sudo apt update" + echo " sudo apt install build-essential cmake qt6-base-dev" + 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, yeniden olusturuluyor:" - echo " $cached_source" + cached_system="$(sed -n 's/^CMAKE_SYSTEM_NAME:INTERNAL=//p' ./build/CMakeCache.txt)" + host_system="$(uname -s)" + if [[ ( -n "$cached_source" && "$cached_source" != "$project_dir" ) \ + || ( -n "$cached_system" && "$cached_system" != "$host_system" ) ]]; then + echo "Build klasoru farkli bir proje yolu veya sisteme ait, yeniden olusturuluyor." rm -rf ./build fi fi -if ! cmake -S . -B build; then +cmake_args=(-S . -B build) +if [[ "$(uname -s)" == "Darwin" ]] && 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 +if [[ "$(uname -s)" == "Darwin" ]]; then + exec ./build/location_plotter.app/Contents/MacOS/location_plotter "$@" +fi + missing_libs="$(ldd ./build/location_plotter 2>/dev/null | awk '/not found/ { print $1 }' || true)" if [[ -n "$missing_libs" ]]; then echo "Eksik paylasimli kutuphaneler:" diff --git a/setup-macos.sh b/setup-macos.sh new file mode 100755 index 0000000..0a4651f --- /dev/null +++ b/setup-macos.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Bu kurulum betigi macOS icindir." >&2 + exit 1 +fi + +if ! command -v brew >/dev/null 2>&1; then + echo "Homebrew bulunamadi. Once https://brew.sh adresindeki Homebrew kurulumunu tamamlayin." >&2 + exit 1 +fi + +echo "Gerekli macOS paketleri kuruluyor..." +brew install cmake qt + +qt_prefix="$(brew --prefix qt)" +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 + fi +fi + +echo "Uygulama derleniyor..." +cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$qt_prefix" +cmake --build build --parallel "$parallel_jobs" + +echo "Testler calistiriliyor..." +ctest --test-dir build --output-on-failure + +echo "macOS uygulama paketi hazirlaniyor..." +"$qt_prefix/bin/macdeployqt" build/location_plotter.app -always-overwrite + +echo +echo "Kurulum tamamlandi. Uygulamayi baslatmak icin:" +echo " ./run.sh" +echo +echo "USB UART icin port listesinden /dev/cu.usb... aygitini secin." +echo "/dev/tty.usb... gorunuyorsa o yol da desteklenir." diff --git a/src/GlobeWidget.cpp b/src/GlobeWidget.cpp index 35c4596..6b65e5d 100644 --- a/src/GlobeWidget.cpp +++ b/src/GlobeWidget.cpp @@ -15,6 +15,7 @@ #include #include +#include namespace { constexpr double Pi = 3.14159265358979323846; @@ -211,8 +212,10 @@ GlobeWidget::GlobeWidget(QWidget *parent) setMouseTracking(true); setCursor(Qt::OpenHandCursor); setToolTip(QStringLiteral("Dunyayi dondurmek icin surukleyin, yakinlastirmak icin tekerlegi kullanin")); - m_satelliteAnimationTimer.setInterval(33); - m_satelliteAnimationTimer.setTimerType(Qt::PreciseTimer); + // 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); } @@ -235,8 +238,29 @@ void GlobeWidget::setSatelliteMotion(const QVector ¤t, // Continue from the currently rendered points when a fresh ephemeris arrives, // avoiding even a small visual jump at the update boundary. - m_satelliteMotionStart = m_satellites.isEmpty() ? current : m_satellites; - m_satelliteMotionTarget = next; + 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(); @@ -250,18 +274,11 @@ void GlobeWidget::updateSatelliteAnimation() const double progress = std::clamp( static_cast(m_satelliteMotionClock.elapsed()) / m_satelliteMotionDurationMs, 0.0, 1.0); - QHash targets; - targets.reserve(m_satelliteMotionTarget.size()); - for (const SatellitePosition &satellite : m_satelliteMotionTarget) { - targets.insert(satellite.prn, satellite); - } - QVector interpolated; interpolated.reserve(m_satelliteMotionStart.size()); - for (const SatellitePosition &start : m_satelliteMotionStart) { - const auto targetIt = targets.constFind(start.prn); - if (targetIt == targets.cend()) continue; - const SatellitePosition &target = targetIt.value(); + 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; @@ -336,6 +353,74 @@ 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)); @@ -374,63 +459,23 @@ void GlobeWidget::paintEvent(QPaintEvent *) painter.restore(); for (int i = 0; i < 3; ++i) drawOrbit(painter, 55.0 + i * 1.8, i * 61.0 + 12.0); - if (m_hasReceiver) drawVisibilityCone(painter); - if (m_hasReceiver) drawReceiver(painter); - drawSatellites(painter); + painter.end(); - 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)); - int visibleCount = 0; - for (const auto &satellite : m_satellites) { - if (satelliteElevation(satellite) >= m_elevationMaskDegrees) ++visibleCount; - } - 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")); - } + 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 = std::min(devicePixelRatioF(), 1.5); + 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)), 320, 900); + 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); @@ -715,33 +760,41 @@ void GlobeWidget::drawReceiver(QPainter &painter) const painter.drawText(p.point + QPointF(10, -8), QStringLiteral("ALICI")); } -void GlobeWidget::drawSatellites(QPainter &painter) const +int GlobeWidget::drawSatellites(QPainter &painter) const { - if (!m_hasReceiver) return; + if (!m_hasReceiver) return 0; const double orbitRadius = std::min(width(), height()) * 0.255 * m_zoom * 1.47; - struct Item { SatellitePosition satellite; ProjectedPoint projected; }; + 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)}); + 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 = satelliteElevation(item.satellite) >= m_elevationMaskDegrees; + 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) { - 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); 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); @@ -757,6 +810,7 @@ void GlobeWidget::drawSatellites(QPainter &painter) const painter.drawText(item.projected.point + QPointF(7, -6), item.satellite.prn); } } + return visibleCount; } double GlobeWidget::satelliteElevation(const SatellitePosition &satellite) const @@ -802,7 +856,9 @@ void GlobeWidget::mouseReleaseEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { m_dragging = false; + m_staticLayer = QPixmap(); setCursor(Qt::OpenHandCursor); + update(); } } diff --git a/src/GlobeWidget.h b/src/GlobeWidget.h index 707594a..0164b18 100644 --- a/src/GlobeWidget.h +++ b/src/GlobeWidget.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -23,6 +24,7 @@ public: 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); @@ -47,9 +49,10 @@ private: void drawVisibilityCone(QPainter &painter) const; void drawGraticule(QPainter &painter) const; void drawReceiver(QPainter &painter) const; - void drawSatellites(QPainter &painter) const; + int drawSatellites(QPainter &painter) const; double satelliteElevation(const SatellitePosition &satellite) const; void updateSatelliteAnimation(); + void rebuildStaticLayer(); QVector m_satellites; QVector m_satelliteMotionStart; @@ -60,6 +63,12 @@ private: 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; diff --git a/src/LocationParser.cpp b/src/LocationParser.cpp index f4520fa..6e99605 100644 --- a/src/LocationParser.cpp +++ b/src/LocationParser.cpp @@ -49,6 +49,10 @@ QString normalizeKey(QString key) return "satellites"; } + if (key == "tracked") { + return "satellites"; + } + if (key == "gdop" || key == "pdop" || key == "hdop" || key == "vdop" || key == "tdop") { return key; } @@ -76,7 +80,7 @@ std::optional LocationParser::parseLine(const QString &line) } 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|uydu(?:lar)?|[gphvt][_ -]?dop)(?:\s*(?:=|:|->)?\s*)(?[+-]?(?:\d+(?:[\.,]\d+)?|[\.,]\d+)(?:[eE][+-]?\d+)?))"), + 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,.;)\]]))"), @@ -84,6 +88,44 @@ std::optional LocationParser::parseLine(const QString &line) 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; @@ -195,10 +237,11 @@ QSet LocationParser::parseGpsPrns(const QString &line) QSet result; const auto addPrn = [&result](QString value, bool zeroBased = false) { value = value.trimmed().toUpper(); - if (value.startsWith(QLatin1Char('G'))) value.remove(0, 1); + const bool explicitGpsPrn = value.startsWith(QLatin1Char('G')); + if (explicitGpsPrn) value.remove(0, 1); bool ok = false; int number = value.toInt(&ok); - if (ok && zeroBased) ++number; + if (ok && zeroBased && !explicitGpsPrn) ++number; if (ok && number >= 1 && number <= 63) { result.insert(QStringLiteral("G%1").arg(number, 2, 10, QLatin1Char('0'))); } @@ -210,18 +253,31 @@ QSet LocationParser::parseGpsPrns(const QString &line) auto gpsMatches = gpsToken.globalMatch(line); while (gpsMatches.hasNext()) addPrn(gpsMatches.next().captured(QStringLiteral("prn"))); - static const QRegularExpression labeledList( - QStringLiteral(R"((?