From 2856c5a56099652516423e6bbfbd1d2b0b69a61d Mon Sep 17 00:00:00 2001 From: onur Date: Thu, 16 Jul 2026 13:15:54 +0300 Subject: [PATCH] Initial commit: add Location Plotter --- .github/workflows/build.yml | 30 + .gitignore | 10 + CMakeLists.txt | 84 +++ README.md | 156 +++++ resources/ankasys-logo.png | Bin 0 -> 24036 bytes resources/land-110m.json | 1 + run.sh | 50 ++ setup-ubuntu.sh | 52 ++ src/GlobeWidget.cpp | 814 ++++++++++++++++++++++++ src/GlobeWidget.h | 75 +++ src/LocationParser.cpp | 227 +++++++ src/LocationParser.h | 17 + src/MainWindow.cpp | 1161 +++++++++++++++++++++++++++++++++++ src/MainWindow.h | 106 ++++ src/MapWidget.cpp | 623 +++++++++++++++++++ src/MapWidget.h | 90 +++ src/RinexNavigation.cpp | 339 ++++++++++ src/RinexNavigation.h | 42 ++ src/SatelliteData.h | 11 + src/SerialReader.cpp | 266 ++++++++ src/SerialReader.h | 44 ++ src/TelemetryPoint.h | 54 ++ src/main.cpp | 15 + tests/main_window_tests.cpp | 164 +++++ tests/parser_tests.cpp | 199 ++++++ 25 files changed, 4630 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 resources/ankasys-logo.png create mode 100644 resources/land-110m.json create mode 100755 run.sh create mode 100755 setup-ubuntu.sh create mode 100644 src/GlobeWidget.cpp create mode 100644 src/GlobeWidget.h create mode 100644 src/LocationParser.cpp create mode 100644 src/LocationParser.h create mode 100644 src/MainWindow.cpp create mode 100644 src/MainWindow.h create mode 100644 src/MapWidget.cpp create mode 100644 src/MapWidget.h create mode 100644 src/RinexNavigation.cpp create mode 100644 src/RinexNavigation.h create mode 100644 src/SatelliteData.h create mode 100644 src/SerialReader.cpp create mode 100644 src/SerialReader.h create mode 100644 src/TelemetryPoint.h create mode 100644 src/main.cpp create mode 100644 tests/main_window_tests.cpp create mode 100644 tests/parser_tests.cpp diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c7fc2e0 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,30 @@ +name: Ubuntu build + +on: + push: + pull_request: + +jobs: + build-and-test: + strategy: + matrix: + os: [ubuntu-22.04, ubuntu-24.04] + runs-on: ${{ matrix.os }} + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential cmake qt6-base-dev zlib1g-dev + + - name: Configure + run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: cmake --build build --parallel 2 + + - name: Test + run: ctest --test-dir build --output-on-failure diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ddf6da2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +build/ +build-*/ +.cache/ +.cmake/ +.vscode/ +.idea/ +*.user +*.autosave +*.swp +*~ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..8343a68 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,84 @@ +cmake_minimum_required(VERSION 3.20) + +project(location_plotter VERSION 0.1.0 LANGUAGES CXX) + +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/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) + +qt_add_resources(location_plotter "app_resources" + PREFIX "/" + FILES resources/ankasys-logo.png resources/land-110m.json +) + +if(UNIX AND NOT APPLE) + target_compile_definitions(location_plotter PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1) +endif() + +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) +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/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) +qt_add_resources(location_main_window_tests "test_app_resources" + PREFIX "/" + FILES resources/ankasys-logo.png resources/land-110m.json +) +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/README.md b/README.md new file mode 100644 index 0000000..663bbb2 --- /dev/null +++ b/README.md @@ -0,0 +1,156 @@ +# Location Plotter + +Qt6/C++ GUI that listens to `/dev/ttyUSB2`, 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 +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` + +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. + +## 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 +./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. + +The port selector lists only detected `/dev/ttyUSB*` devices. The default selection is +`/dev/ttyUSB2` and the default baud rate is `115200`. + +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 30 FPS. +- 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. +- 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. +- 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/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 + +# shellcheck disable=SC1091 +source /etc/os-release +if [[ "${ID:-}" != "ubuntu" ]]; then + echo "Bu kurulum betigi Ubuntu icindir (tespit edilen: ${PRETTY_NAME:-bilinmiyor})." >&2 + exit 1 +fi + +current_user="${SUDO_USER:-${USER:-$(id -un)}}" + +if ! dpkg --compare-versions "${VERSION_ID:-0}" ge 22.04; then + echo "Ubuntu 22.04 veya daha yeni bir surum gerekiyor (tespit edilen: ${VERSION_ID:-bilinmiyor})." >&2 + exit 1 +fi + +if ! command -v sudo >/dev/null 2>&1; then + echo "Paketleri kurmak icin sudo komutu gerekiyor." >&2 + exit 1 +fi + +echo "Gerekli Ubuntu paketleri kuruluyor..." +sudo apt-get update +sudo apt-get install -y \ + build-essential \ + cmake \ + qt6-base-dev \ + zlib1g-dev + +if getent group dialout >/dev/null && ! id -nG "$current_user" | tr ' ' '\n' | grep -qx dialout; then + echo "Seri port erisimi icin $current_user kullanicisi dialout grubuna ekleniyor..." + sudo usermod -a -G dialout "$current_user" +fi + +echo "Uygulama derleniyor..." +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --parallel "$(nproc)" + +echo "Testler calistiriliyor..." +ctest --test-dir build --output-on-failure + +echo +echo "Kurulum tamamlandi. Uygulamayi baslatmak icin:" +echo " ./run.sh" diff --git a/src/GlobeWidget.cpp b/src/GlobeWidget.cpp new file mode 100644 index 0000000..35c4596 --- /dev/null +++ b/src/GlobeWidget.cpp @@ -0,0 +1,814 @@ +#include "GlobeWidget.h" + +#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")); + m_satelliteAnimationTimer.setInterval(33); + m_satelliteAnimationTimer.setTimerType(Qt::PreciseTimer); + 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. + m_satelliteMotionStart = m_satellites.isEmpty() ? current : m_satellites; + m_satelliteMotionTarget = next; + 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); + 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(); + 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); + + 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); + if (m_hasReceiver) drawVisibilityCone(painter); + if (m_hasReceiver) drawReceiver(painter); + 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)); + 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")); + } +} + +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 int textureSize = std::clamp( + static_cast(std::ceil(radius * 2.0 * pixelRatio)), 320, 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")); +} + +void GlobeWidget::drawSatellites(QPainter &painter) const +{ + if (!m_hasReceiver) return; + const double orbitRadius = std::min(width(), height()) * 0.255 * m_zoom * 1.47; + struct Item { SatellitePosition satellite; ProjectedPoint projected; }; + QVector items; + 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)}); + } + std::sort(items.begin(), items.end(), [](const Item &a, const Item &b) { + return a.projected.depth < b.projected.depth; + }); + for (const Item &item : items) { + const bool found = m_observedPrns.contains(item.satellite.prn); + const bool inElevationMask = satelliteElevation(item.satellite) >= m_elevationMaskDegrees; + 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); + 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); + } + } +} + +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; + setCursor(Qt::OpenHandCursor); + } +} + +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/src/GlobeWidget.h b/src/GlobeWidget.h new file mode 100644 index 0000000..707594a --- /dev/null +++ b/src/GlobeWidget.h @@ -0,0 +1,75 @@ +#pragma once + +#include "SatelliteData.h" + +#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); + +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; + void drawSatellites(QPainter &painter) const; + double satelliteElevation(const SatellitePosition &satellite) const; + void updateSatelliteAnimation(); + + 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; + 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/src/LocationParser.cpp new file mode 100644 index 0000000..f4520fa --- /dev/null +++ b/src/LocationParser.cpp @@ -0,0 +1,227 @@ +#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 == "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|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; + 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(); + if (value.startsWith(QLatin1Char('G'))) value.remove(0, 1); + bool ok = false; + int number = value.toInt(&ok); + if (ok && zeroBased) ++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"))); + + static const QRegularExpression labeledList( + QStringLiteral(R"((?