Initial commit: add Location Plotter
This commit is contained in:
@@ -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
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
build/
|
||||||
|
build-*/
|
||||||
|
.cache/
|
||||||
|
.cmake/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.user
|
||||||
|
*.autosave
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
@@ -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")
|
||||||
@@ -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 <depo-adresi> 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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,50 @@
|
|||||||
|
#!/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"
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
rm -rf ./build
|
||||||
|
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
|
||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
if [[ ! -r /etc/os-release ]]; then
|
||||||
|
echo "Ubuntu surumu tespit edilemedi." >&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"
|
||||||
@@ -0,0 +1,814 @@
|
|||||||
|
#include "GlobeWidget.h"
|
||||||
|
|
||||||
|
#include <QLinearGradient>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPainterPath>
|
||||||
|
#include <QPolygonF>
|
||||||
|
#include <QRadialGradient>
|
||||||
|
#include <QWheelEvent>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
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<QPolygonF> &fallbackLandPolygons()
|
||||||
|
{
|
||||||
|
// Deliberately simplified Natural-Earth-like coastlines. QPointF is lon/lat.
|
||||||
|
static const QVector<QPolygonF> 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<QPolygonF> &landPolygons()
|
||||||
|
{
|
||||||
|
static const QVector<QPolygonF> 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<QPolygonF> 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<QPolygonF> 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<int>(longitude / 360.0 * mask.width()),
|
||||||
|
0, mask.width() - 1);
|
||||||
|
const int y = std::clamp(static_cast<int>((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<SatellitePosition> &satellites)
|
||||||
|
{
|
||||||
|
m_satellites = satellites;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void GlobeWidget::setSatelliteMotion(const QVector<SatellitePosition> ¤t,
|
||||||
|
const QVector<SatellitePosition> &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<double>(m_satelliteMotionClock.elapsed()) / m_satelliteMotionDurationMs,
|
||||||
|
0.0, 1.0);
|
||||||
|
QHash<QString, SatellitePosition> targets;
|
||||||
|
targets.reserve(m_satelliteMotionTarget.size());
|
||||||
|
for (const SatellitePosition &satellite : m_satelliteMotionTarget) {
|
||||||
|
targets.insert(satellite.prn, satellite);
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<SatellitePosition> 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<QString> &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<int>(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<QRgb *>(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<int>(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<int>(red * shade), 0, 255),
|
||||||
|
std::clamp(static_cast<int>(green * shade), 0, 255),
|
||||||
|
std::clamp(static_cast<int>(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<ProjectedPoint> 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<Item> 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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "SatelliteData.h"
|
||||||
|
|
||||||
|
#include <QElapsedTimer>
|
||||||
|
#include <QImage>
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
class GlobeWidget : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit GlobeWidget(QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
void setSatellites(const QVector<SatellitePosition> &satellites);
|
||||||
|
void setSatelliteMotion(const QVector<SatellitePosition> ¤t,
|
||||||
|
const QVector<SatellitePosition> &next,
|
||||||
|
int durationMs);
|
||||||
|
void setObservedPrns(const QSet<QString> &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<SatellitePosition> m_satellites;
|
||||||
|
QVector<SatellitePosition> m_satelliteMotionStart;
|
||||||
|
QVector<SatellitePosition> 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<QString> 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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
#include "LocationParser.h"
|
||||||
|
|
||||||
|
#include <QRegularExpression>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
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<ParsedTelemetry> 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_])(?<key>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*)(?<value>[+-]?(?:\d+(?:[\.,]\d+)?|[\.,]\d+)(?:[eE][+-]?\d+)?))"),
|
||||||
|
QRegularExpression::CaseInsensitiveOption);
|
||||||
|
static const QRegularExpression satelliteCountPhraseRegex(
|
||||||
|
QStringLiteral(R"((^|[^A-Za-z0-9_\.])(?<value>\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<int>(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<QString> LocationParser::parseGpsPrns(const QString &line)
|
||||||
|
{
|
||||||
|
QSet<QString> 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(?<prn>\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"((?<label>\bPRNS?\b|\bSVS?\b|\bSVNS?\b|\bSVIDS?\b)\s*(?:=|:|->)?\s*[\[(]?\s*(?<list>(?:G?\d{1,2})(?:\s*[,;/| ]\s*G?\d{1,2})*)\s*[\])]?)"),
|
||||||
|
QRegularExpression::CaseInsensitiveOption);
|
||||||
|
static const QRegularExpression number(QStringLiteral(R"(G?(\d{1,2}))"),
|
||||||
|
QRegularExpression::CaseInsensitiveOption);
|
||||||
|
auto listMatches = labeledList.globalMatch(line);
|
||||||
|
while (listMatches.hasNext()) {
|
||||||
|
const QRegularExpressionMatch listMatch = listMatches.next();
|
||||||
|
const bool zeroBased = listMatch.captured(QStringLiteral("label")).startsWith(
|
||||||
|
QStringLiteral("SVN"), Qt::CaseInsensitive);
|
||||||
|
auto values = number.globalMatch(listMatch.captured(QStringLiteral("list")));
|
||||||
|
while (values.hasNext()) addPrn(values.next().captured(1), zeroBased);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "TelemetryPoint.h"
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
#include <QSet>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
class LocationParser {
|
||||||
|
public:
|
||||||
|
static std::optional<ParsedTelemetry> parseLine(const QString &line);
|
||||||
|
static QSet<QString> parseGpsPrns(const QString &line);
|
||||||
|
|
||||||
|
private:
|
||||||
|
static bool isValidCoordinate(double latitude, double longitude);
|
||||||
|
};
|
||||||
+1161
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "TelemetryPoint.h"
|
||||||
|
|
||||||
|
#include <QMainWindow>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QVector>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
class QLabel;
|
||||||
|
class QCheckBox;
|
||||||
|
class QComboBox;
|
||||||
|
class QLineEdit;
|
||||||
|
class QPlainTextEdit;
|
||||||
|
class QPushButton;
|
||||||
|
class QSpinBox;
|
||||||
|
class QTableView;
|
||||||
|
class QTimer;
|
||||||
|
|
||||||
|
class MapWidget;
|
||||||
|
class GlobeWidget;
|
||||||
|
class RinexNavigation;
|
||||||
|
class SerialReader;
|
||||||
|
class TelemetryTableModel;
|
||||||
|
|
||||||
|
class MainWindow : public QMainWindow {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit MainWindow(QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
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();
|
||||||
|
int selectedBaudRate() const;
|
||||||
|
|
||||||
|
SerialReader *m_serial = 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;
|
||||||
|
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;
|
||||||
|
|
||||||
|
QVector<TelemetryPoint> m_points;
|
||||||
|
QStringList m_pendingLogLines;
|
||||||
|
std::optional<int> m_lastSatelliteCount;
|
||||||
|
QSet<QString> m_observedPrns;
|
||||||
|
std::optional<double> m_referenceLatitude;
|
||||||
|
std::optional<double> m_referenceLongitude;
|
||||||
|
DopValues m_lastDop;
|
||||||
|
ParsedTelemetry m_pendingTelemetry;
|
||||||
|
bool m_telemetryRefreshPending = false;
|
||||||
|
};
|
||||||
@@ -0,0 +1,623 @@
|
|||||||
|
#include "MapWidget.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineF>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QNetworkDiskCache>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPainterPath>
|
||||||
|
#include <QResizeEvent>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QWheelEvent>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
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<double>(TileSize) * static_cast<double>(1 << zoom);
|
||||||
|
const double latitudeScale = std::cos(latitude * Pi / 180.0);
|
||||||
|
return latitudeScale * 2.0 * Pi * WebMercatorRadiusMeters / scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // 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("<a style=\"color:#174b7a;text-decoration:none\" "
|
||||||
|
"href=\"https://www.openstreetmap.org/copyright\">")
|
||||||
|
+ QString::fromUtf8("\xC2\xA9 OpenStreetMap contributors")
|
||||||
|
+ QStringLiteral("</a>"));
|
||||||
|
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<TelemetryPoint> &points)
|
||||||
|
{
|
||||||
|
const bool wasEmpty = m_points.isEmpty();
|
||||||
|
m_points = points;
|
||||||
|
|
||||||
|
if (!m_points.isEmpty() && m_autoFollow) {
|
||||||
|
const TelemetryPoint &last = m_points.last();
|
||||||
|
m_centerLat = last.latitude;
|
||||||
|
m_centerLon = last.longitude;
|
||||||
|
if (wasEmpty) {
|
||||||
|
m_zoom = MaxZoom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MapWidget::clearPoints()
|
||||||
|
{
|
||||||
|
m_points.clear();
|
||||||
|
m_hasAverageMarker = 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<double> &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();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MapWidget::setAutoFollow(bool enabled)
|
||||||
|
{
|
||||||
|
if (m_autoFollow == enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_autoFollow = enabled;
|
||||||
|
if (m_autoFollow && !m_points.isEmpty()) {
|
||||||
|
const TelemetryPoint &last = m_points.last();
|
||||||
|
m_centerLat = last.latitude;
|
||||||
|
m_centerLon = last.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);
|
||||||
|
|
||||||
|
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<double>(TileSize) * static_cast<double>(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<double>(TileSize) * static_cast<double>(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<double>(TileSize) * static_cast<double>(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<int>(std::floor(topLeft.x() / TileSize));
|
||||||
|
const int lastTileX = static_cast<int>(std::floor((topLeft.x() + width()) / TileSize));
|
||||||
|
const int firstTileY = static_cast<int>(std::floor(topLeft.y() / TileSize));
|
||||||
|
const int lastTileY = static_cast<int>(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);
|
||||||
|
|
||||||
|
QPainterPath path;
|
||||||
|
for (int i = 0; i < m_points.size(); ++i) {
|
||||||
|
const QPointF screen = coordinateToScreen(m_points[i].latitude, m_points[i].longitude);
|
||||||
|
if (i == 0) {
|
||||||
|
path.moveTo(screen);
|
||||||
|
} else {
|
||||||
|
path.lineTo(screen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_showRecentPoints && m_points.size() > 1) {
|
||||||
|
QPen pathPen(QColor(210, 105, 30), 3.0);
|
||||||
|
pathPen.setCapStyle(Qt::RoundCap);
|
||||||
|
pathPen.setJoinStyle(Qt::RoundJoin);
|
||||||
|
painter.setPen(pathPen);
|
||||||
|
painter.drawPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawAverageOverlay(painter);
|
||||||
|
drawCepMarker(painter);
|
||||||
|
|
||||||
|
const bool drawNumbers = m_points.size() <= 150;
|
||||||
|
const int firstPoint = m_showRecentPoints ? 0 : m_points.size() - 1;
|
||||||
|
for (int i = firstPoint; i < m_points.size(); ++i) {
|
||||||
|
const bool isLast = i == m_points.size() - 1;
|
||||||
|
const QPointF screen = coordinateToScreen(m_points[i].latitude, m_points[i].longitude);
|
||||||
|
const double radius = isLast ? 8.0 : 5.0;
|
||||||
|
|
||||||
|
painter.setPen(QPen(Qt::white, isLast ? 3.0 : 2.0));
|
||||||
|
painter.setBrush(isLast ? QColor(31, 150, 82) : QColor(37, 99, 235));
|
||||||
|
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.last();
|
||||||
|
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);
|
||||||
|
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::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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "TelemetryPoint.h"
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QPoint>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
class QNetworkAccessManager;
|
||||||
|
class QLabel;
|
||||||
|
|
||||||
|
class MapWidget : public QWidget {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit MapWidget(QWidget *parent = nullptr);
|
||||||
|
|
||||||
|
void setPoints(const QVector<TelemetryPoint> &points);
|
||||||
|
void clearPoints();
|
||||||
|
void setAverageMarker(double latitude, double longitude, double radiusMeters);
|
||||||
|
void clearAverageMarker();
|
||||||
|
void setCepMarker(double latitude, double longitude,
|
||||||
|
const std::optional<double> &radiusMeters);
|
||||||
|
void clearCepMarker();
|
||||||
|
|
||||||
|
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 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<TelemetryPoint> m_points;
|
||||||
|
QNetworkAccessManager *m_network = nullptr;
|
||||||
|
QLabel *m_attributionLabel = nullptr;
|
||||||
|
QHash<QString, QPixmap> m_tileCache;
|
||||||
|
QSet<QString> 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<double> m_cepRadiusMeters;
|
||||||
|
bool m_dragging = false;
|
||||||
|
QPoint m_lastMousePos;
|
||||||
|
};
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
#include "RinexNavigation.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
#include <zlib.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
struct Ephemeris {
|
||||||
|
QString prn;
|
||||||
|
QDateTime toc;
|
||||||
|
double crs = 0.0;
|
||||||
|
double deltaN = 0.0;
|
||||||
|
double m0 = 0.0;
|
||||||
|
double cuc = 0.0;
|
||||||
|
double eccentricity = 0.0;
|
||||||
|
double cus = 0.0;
|
||||||
|
double sqrtA = 0.0;
|
||||||
|
double toe = 0.0;
|
||||||
|
double cic = 0.0;
|
||||||
|
double omega0 = 0.0;
|
||||||
|
double cis = 0.0;
|
||||||
|
double i0 = 0.0;
|
||||||
|
double crc = 0.0;
|
||||||
|
double omega = 0.0;
|
||||||
|
double omegaDot = 0.0;
|
||||||
|
double idot = 0.0;
|
||||||
|
int gpsWeek = 0;
|
||||||
|
bool healthy = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<double>::quiet_NaN();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool finiteFields(std::initializer_list<double> 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<unsigned char>(input[0]) != 0x1f
|
||||||
|
|| static_cast<unsigned char>(input[1]) != 0x8b) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
z_stream stream{};
|
||||||
|
stream.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(input.constData()));
|
||||||
|
stream.avail_in = static_cast<uInt>(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<Bytef *>(buffer);
|
||||||
|
stream.avail_out = sizeof(buffer);
|
||||||
|
result = inflate(&stream, Z_NO_FLUSH);
|
||||||
|
output.append(buffer, static_cast<int>(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
SatellitePosition 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // 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_latestRinex.isEmpty()) {
|
||||||
|
loadCache();
|
||||||
|
}
|
||||||
|
m_loading = 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 {
|
||||||
|
m_loading = false;
|
||||||
|
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;
|
||||||
|
const QVector<SatellitePosition> positions = parseGpsNavigation(
|
||||||
|
rinex, QDateTime::currentDateTimeUtc(), &error);
|
||||||
|
if (positions.isEmpty()) {
|
||||||
|
if (fallbackIndex < 2) {
|
||||||
|
requestDay(date.addDays(-1), fallbackIndex + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
m_loading = 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_latestRinex = rinex;
|
||||||
|
m_loading = false;
|
||||||
|
publishCurrentMotion();
|
||||||
|
emit statusChanged(QStringLiteral("%1 GPS uydusu · RINEX %2 (UTC)")
|
||||||
|
.arg(positions.size()).arg(date.toString(QStringLiteral("dd.MM.yyyy"))), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RinexNavigation::loadCache()
|
||||||
|
{
|
||||||
|
QFile cache(cachePath());
|
||||||
|
if (!cache.open(QIODevice::ReadOnly)) return false;
|
||||||
|
QString error;
|
||||||
|
m_latestRinex = cache.readAll();
|
||||||
|
const QVector<SatellitePosition> positions = parseGpsNavigation(
|
||||||
|
m_latestRinex, QDateTime::currentDateTimeUtc(), &error);
|
||||||
|
if (positions.isEmpty()) return false;
|
||||||
|
publishCurrentMotion();
|
||||||
|
emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX").arg(positions.size()), true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString RinexNavigation::cachePath() const
|
||||||
|
{
|
||||||
|
return QStandardPaths::writableLocation(QStandardPaths::CacheLocation)
|
||||||
|
+ QStringLiteral("/latest-navigation.rnx");
|
||||||
|
}
|
||||||
|
|
||||||
|
void RinexNavigation::updateCurrentPositions()
|
||||||
|
{
|
||||||
|
if (m_latestRinex.isEmpty()) return;
|
||||||
|
publishCurrentMotion();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RinexNavigation::publishCurrentMotion()
|
||||||
|
{
|
||||||
|
if (m_latestRinex.isEmpty()) return;
|
||||||
|
|
||||||
|
const QDateTime now = QDateTime::currentDateTimeUtc();
|
||||||
|
const QVector<SatellitePosition> current = parseGpsNavigation(m_latestRinex, now);
|
||||||
|
if (current.isEmpty()) return;
|
||||||
|
|
||||||
|
const QVector<SatellitePosition> next = parseGpsNavigation(
|
||||||
|
m_latestRinex, now.addMSecs(PositionUpdateIntervalMs));
|
||||||
|
emit satellitesUpdated(current);
|
||||||
|
if (!next.isEmpty()) {
|
||||||
|
emit satelliteMotionUpdated(current, next, PositionUpdateIntervalMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<SatellitePosition> RinexNavigation::parseGpsNavigation(
|
||||||
|
const QByteArray &rinex, const QDateTime &whenUtc, 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;
|
||||||
|
|
||||||
|
QHash<QString, Ephemeris> closest;
|
||||||
|
QHash<QString, qint64> closestAge;
|
||||||
|
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<int>(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<int>(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) {
|
||||||
|
const qint64 age = std::abs(toc.secsTo(whenUtc));
|
||||||
|
if (!closestAge.contains(eph.prn) || age < closestAge[eph.prn]) {
|
||||||
|
closest[eph.prn] = eph;
|
||||||
|
closestAge[eph.prn] = age;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index += 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<SatellitePosition> result;
|
||||||
|
result.reserve(closest.size());
|
||||||
|
for (const Ephemeris &eph : closest) result.push_back(propagate(eph, whenUtc));
|
||||||
|
std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) { return a.prn < b.prn; });
|
||||||
|
if (result.isEmpty() && error) *error = QStringLiteral("GPS efemerisi bulunamadi");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "SatelliteData.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QVector>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
class RinexNavigation : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit RinexNavigation(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
void refresh();
|
||||||
|
[[nodiscard]] bool isLoading() const { return m_loading; }
|
||||||
|
|
||||||
|
static QVector<SatellitePosition> parseGpsNavigation(
|
||||||
|
const QByteArray &rinex, const QDateTime &whenUtc, QString *error = nullptr);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void satellitesUpdated(const QVector<SatellitePosition> &satellites);
|
||||||
|
void satelliteMotionUpdated(const QVector<SatellitePosition> ¤t,
|
||||||
|
const QVector<SatellitePosition> &next,
|
||||||
|
int durationMs);
|
||||||
|
void statusChanged(const QString &status, bool error);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void requestDay(const QDate &date, int fallbackIndex);
|
||||||
|
void handleDownload(const QByteArray &compressed, const QDate &date, int fallbackIndex);
|
||||||
|
bool loadCache();
|
||||||
|
QString cachePath() const;
|
||||||
|
void updateCurrentPositions();
|
||||||
|
void publishCurrentMotion();
|
||||||
|
|
||||||
|
QNetworkAccessManager m_network;
|
||||||
|
QTimer m_positionTimer;
|
||||||
|
QByteArray m_latestRinex;
|
||||||
|
bool m_loading = false;
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
struct SatellitePosition {
|
||||||
|
QString prn;
|
||||||
|
double x = 0.0; // Earth-centred, Earth-fixed metres
|
||||||
|
double y = 0.0;
|
||||||
|
double z = 0.0;
|
||||||
|
bool healthy = true;
|
||||||
|
};
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
#include "SerialReader.h"
|
||||||
|
|
||||||
|
#include <QSocketNotifier>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
#include <cerrno>
|
||||||
|
#include <cstring>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <sys/ioctl.h>
|
||||||
|
#include <termios.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr qsizetype MaxBytesPerReadCycle = 64 * 1024;
|
||||||
|
|
||||||
|
speed_t baudToSpeed(int baudRate)
|
||||||
|
{
|
||||||
|
switch (baudRate) {
|
||||||
|
case 1200:
|
||||||
|
return B1200;
|
||||||
|
case 2400:
|
||||||
|
return B2400;
|
||||||
|
case 4800:
|
||||||
|
return B4800;
|
||||||
|
case 9600:
|
||||||
|
return B9600;
|
||||||
|
case 19200:
|
||||||
|
return B19200;
|
||||||
|
case 38400:
|
||||||
|
return B38400;
|
||||||
|
case 57600:
|
||||||
|
return B57600;
|
||||||
|
case 115200:
|
||||||
|
return B115200;
|
||||||
|
case 230400:
|
||||||
|
return B230400;
|
||||||
|
case 460800:
|
||||||
|
return B460800;
|
||||||
|
case 921600:
|
||||||
|
return B921600;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
SerialReader::SerialReader(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
m_pollTimer = new QTimer(this);
|
||||||
|
m_pollTimer->setInterval(20);
|
||||||
|
connect(m_pollTimer, &QTimer::timeout, this, &SerialReader::handleReadyRead);
|
||||||
|
|
||||||
|
m_partialLineTimer = new QTimer(this);
|
||||||
|
m_partialLineTimer->setSingleShot(true);
|
||||||
|
m_partialLineTimer->setInterval(250);
|
||||||
|
connect(m_partialLineTimer, &QTimer::timeout, this, &SerialReader::flushPartialLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
SerialReader::~SerialReader()
|
||||||
|
{
|
||||||
|
closePort();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SerialReader::isOpen() const
|
||||||
|
{
|
||||||
|
return m_fd >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SerialReader::portName() const
|
||||||
|
{
|
||||||
|
return m_portName;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SerialReader::openPort(const QString &path, int baudRate)
|
||||||
|
{
|
||||||
|
closePort();
|
||||||
|
|
||||||
|
const QByteArray localPath = path.toLocal8Bit();
|
||||||
|
m_fd = ::open(localPath.constData(), O_RDWR | O_NOCTTY | O_NONBLOCK);
|
||||||
|
if (m_fd < 0) {
|
||||||
|
emitSystemError(QStringLiteral("Port acilamadi"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_portName = path;
|
||||||
|
if (!configurePort(baudRate)) {
|
||||||
|
closePort();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setModemControlLines();
|
||||||
|
|
||||||
|
m_notifier = new QSocketNotifier(m_fd, QSocketNotifier::Read, this);
|
||||||
|
connect(m_notifier, &QSocketNotifier::activated, this, &SerialReader::handleReadyRead);
|
||||||
|
m_pollTimer->start();
|
||||||
|
|
||||||
|
emit statusChanged(QStringLiteral("%1 dinleniyor (%2 baud)").arg(path).arg(baudRate));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SerialReader::closePort()
|
||||||
|
{
|
||||||
|
if (m_notifier != nullptr) {
|
||||||
|
m_notifier->setEnabled(false);
|
||||||
|
m_notifier->deleteLater();
|
||||||
|
m_notifier = nullptr;
|
||||||
|
}
|
||||||
|
m_pollTimer->stop();
|
||||||
|
m_partialLineTimer->stop();
|
||||||
|
|
||||||
|
if (m_fd >= 0) {
|
||||||
|
::close(m_fd);
|
||||||
|
m_fd = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_buffer.clear();
|
||||||
|
if (!m_portName.isEmpty()) {
|
||||||
|
emit statusChanged(QStringLiteral("%1 kapatildi").arg(m_portName));
|
||||||
|
}
|
||||||
|
m_portName.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SerialReader::handleReadyRead()
|
||||||
|
{
|
||||||
|
if (m_fd < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
char data[4096];
|
||||||
|
qsizetype bytesProcessed = 0;
|
||||||
|
while (true) {
|
||||||
|
const ssize_t bytesRead = ::read(m_fd, data, sizeof(data));
|
||||||
|
if (bytesRead > 0) {
|
||||||
|
bytesProcessed += static_cast<qsizetype>(bytesRead);
|
||||||
|
m_buffer.append(data, static_cast<qsizetype>(bytesRead));
|
||||||
|
processBuffer();
|
||||||
|
|
||||||
|
if (m_buffer.size() > 64 * 1024) {
|
||||||
|
m_buffer.clear();
|
||||||
|
emit errorOccurred(QStringLiteral("Satir sonu gelmeyen seri veri tamponu temizlendi"));
|
||||||
|
}
|
||||||
|
if (bytesProcessed >= MaxBytesPerReadCycle) {
|
||||||
|
QTimer::singleShot(0, this, &SerialReader::handleReadyRead);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytesRead == 0 || errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitSystemError(QStringLiteral("Seri port okuma hatasi"));
|
||||||
|
closePort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SerialReader::flushPartialLine()
|
||||||
|
{
|
||||||
|
if (m_buffer.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray line = m_buffer;
|
||||||
|
m_buffer.clear();
|
||||||
|
while (line.endsWith('\r') || line.endsWith('\n')) {
|
||||||
|
line.chop(1);
|
||||||
|
}
|
||||||
|
if (!line.isEmpty()) {
|
||||||
|
emit lineReceived(QString::fromUtf8(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SerialReader::configurePort(int baudRate)
|
||||||
|
{
|
||||||
|
const speed_t speed = baudToSpeed(baudRate);
|
||||||
|
if (speed == 0) {
|
||||||
|
emit errorOccurred(QStringLiteral("Desteklenmeyen baud rate: %1").arg(baudRate));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
termios options {};
|
||||||
|
if (::tcgetattr(m_fd, &options) != 0) {
|
||||||
|
emitSystemError(QStringLiteral("Port ayarlari okunamadi"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
::cfmakeraw(&options);
|
||||||
|
::cfsetispeed(&options, speed);
|
||||||
|
::cfsetospeed(&options, speed);
|
||||||
|
|
||||||
|
options.c_cflag |= CLOCAL | CREAD;
|
||||||
|
options.c_cflag &= ~HUPCL;
|
||||||
|
options.c_cflag &= ~PARENB;
|
||||||
|
options.c_cflag &= ~CSTOPB;
|
||||||
|
options.c_cflag &= ~CSIZE;
|
||||||
|
options.c_cflag |= CS8;
|
||||||
|
options.c_cc[VMIN] = 0;
|
||||||
|
options.c_cc[VTIME] = 0;
|
||||||
|
|
||||||
|
if (::tcsetattr(m_fd, TCSANOW, &options) != 0) {
|
||||||
|
emitSystemError(QStringLiteral("Port ayarlari uygulanamadi"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
::tcflush(m_fd, TCIFLUSH);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SerialReader::processBuffer()
|
||||||
|
{
|
||||||
|
bool emittedLine = false;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
qsizetype delimiterIndex = -1;
|
||||||
|
for (qsizetype i = 0; i < m_buffer.size(); ++i) {
|
||||||
|
if (m_buffer.at(i) == '\n' || m_buffer.at(i) == '\r') {
|
||||||
|
delimiterIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delimiterIndex < 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray line = m_buffer.left(delimiterIndex);
|
||||||
|
qsizetype removeCount = delimiterIndex + 1;
|
||||||
|
while (removeCount < m_buffer.size()
|
||||||
|
&& (m_buffer.at(removeCount) == '\n' || m_buffer.at(removeCount) == '\r')) {
|
||||||
|
++removeCount;
|
||||||
|
}
|
||||||
|
m_buffer.remove(0, removeCount);
|
||||||
|
|
||||||
|
if (!line.isEmpty()) {
|
||||||
|
emittedLine = true;
|
||||||
|
emit lineReceived(QString::fromUtf8(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!m_buffer.isEmpty()) {
|
||||||
|
m_partialLineTimer->start();
|
||||||
|
} else if (emittedLine) {
|
||||||
|
m_partialLineTimer->stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SerialReader::setModemControlLines()
|
||||||
|
{
|
||||||
|
int status = 0;
|
||||||
|
if (::ioctl(m_fd, TIOCMGET, &status) != 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
status |= TIOCM_DTR;
|
||||||
|
status |= TIOCM_RTS;
|
||||||
|
::ioctl(m_fd, TIOCMSET, &status);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SerialReader::emitSystemError(const QString &prefix)
|
||||||
|
{
|
||||||
|
emit errorOccurred(QStringLiteral("%1: %2").arg(prefix, QString::fromLocal8Bit(std::strerror(errno))));
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
class QSocketNotifier;
|
||||||
|
class QTimer;
|
||||||
|
|
||||||
|
class SerialReader : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit SerialReader(QObject *parent = nullptr);
|
||||||
|
~SerialReader() override;
|
||||||
|
|
||||||
|
[[nodiscard]] bool isOpen() const;
|
||||||
|
[[nodiscard]] QString portName() const;
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
bool openPort(const QString &path, int baudRate);
|
||||||
|
void closePort();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void lineReceived(const QString &line);
|
||||||
|
void statusChanged(const QString &status);
|
||||||
|
void errorOccurred(const QString &message);
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void handleReadyRead();
|
||||||
|
void flushPartialLine();
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool configurePort(int baudRate);
|
||||||
|
void processBuffer();
|
||||||
|
void setModemControlLines();
|
||||||
|
void emitSystemError(const QString &prefix);
|
||||||
|
|
||||||
|
int m_fd = -1;
|
||||||
|
QSocketNotifier *m_notifier = nullptr;
|
||||||
|
QTimer *m_pollTimer = nullptr;
|
||||||
|
QTimer *m_partialLineTimer = nullptr;
|
||||||
|
QByteArray m_buffer;
|
||||||
|
QString m_portName;
|
||||||
|
};
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include <optional>
|
||||||
|
|
||||||
|
struct DopValues {
|
||||||
|
std::optional<double> gdop;
|
||||||
|
std::optional<double> pdop;
|
||||||
|
std::optional<double> hdop;
|
||||||
|
std::optional<double> vdop;
|
||||||
|
std::optional<double> tdop;
|
||||||
|
|
||||||
|
[[nodiscard]] bool hasAnyValue() const
|
||||||
|
{
|
||||||
|
return gdop.has_value() || pdop.has_value() || hdop.has_value()
|
||||||
|
|| vdop.has_value() || tdop.has_value();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
struct TelemetryPoint {
|
||||||
|
double latitude = 0.0;
|
||||||
|
double longitude = 0.0;
|
||||||
|
QString latitudeText;
|
||||||
|
QString longitudeText;
|
||||||
|
std::optional<double> hheight;
|
||||||
|
std::optional<int> satellites;
|
||||||
|
DopValues dop;
|
||||||
|
QDateTime receivedAt = QDateTime::currentDateTimeUtc();
|
||||||
|
QString rawLine;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ParsedTelemetry {
|
||||||
|
std::optional<double> latitude;
|
||||||
|
std::optional<double> longitude;
|
||||||
|
QString latitudeText;
|
||||||
|
QString longitudeText;
|
||||||
|
std::optional<double> hheight;
|
||||||
|
std::optional<int> satellites;
|
||||||
|
DopValues dop;
|
||||||
|
QString rawLine;
|
||||||
|
|
||||||
|
[[nodiscard]] bool hasLocation() const
|
||||||
|
{
|
||||||
|
return latitude.has_value() && longitude.has_value();
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] bool hasAnyValue() const
|
||||||
|
{
|
||||||
|
return latitude.has_value() || longitude.has_value() || hheight.has_value()
|
||||||
|
|| satellites.has_value() || dop.hasAnyValue();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#include "MainWindow.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
|
||||||
|
int main(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
QApplication app(argc, argv);
|
||||||
|
QApplication::setApplicationName(QStringLiteral("location_plotter"));
|
||||||
|
QApplication::setOrganizationName(QStringLiteral("rocket"));
|
||||||
|
|
||||||
|
MainWindow window;
|
||||||
|
window.show();
|
||||||
|
|
||||||
|
return QApplication::exec();
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
#include "../src/MainWindow.h"
|
||||||
|
#include "../src/MapWidget.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QDoubleSpinBox>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QMetaObject>
|
||||||
|
#include <QSpinBox>
|
||||||
|
#include <QTableView>
|
||||||
|
#include <QTabWidget>
|
||||||
|
#include <QSplitter>
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
|
||||||
|
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);
|
||||||
|
MainWindow window;
|
||||||
|
|
||||||
|
auto *table = window.findChild<QTableView *>();
|
||||||
|
auto *referenceLatInput = window.findChild<QLineEdit *>(QStringLiteral("referenceLatInput"));
|
||||||
|
auto *referenceLonInput = window.findChild<QLineEdit *>(QStringLiteral("referenceLonInput"));
|
||||||
|
auto *cepValue = window.findChild<QLabel *>(QStringLiteral("cepValue"));
|
||||||
|
auto *maxPointsInput = window.findChild<QSpinBox *>(QStringLiteral("maxPointsInput"));
|
||||||
|
auto *elevationMaskSpin = window.findChild<QDoubleSpinBox *>(QStringLiteral("elevationMaskSpin"));
|
||||||
|
auto *showTelemetry = window.findChild<QCheckBox *>(QStringLiteral("showTelemetryCheck"));
|
||||||
|
auto *showRecentPoints = window.findChild<QCheckBox *>(QStringLiteral("showRecentPointsCheck"));
|
||||||
|
auto *showAverage = window.findChild<QCheckBox *>(QStringLiteral("showAverageCheck"));
|
||||||
|
auto *portSelector = window.findChild<QComboBox *>(QStringLiteral("portSelector"));
|
||||||
|
auto *map = window.findChild<MapWidget *>();
|
||||||
|
auto *mainViewTabs = window.findChild<QTabWidget *>(QStringLiteral("mainViewTabs"));
|
||||||
|
auto *sideSplitter = window.findChild<QSplitter *>(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(!portSelector->isEditable());
|
||||||
|
assert(portSelector->count() >= 1);
|
||||||
|
for (int i = 0; i < portSelector->count(); ++i) {
|
||||||
|
assert(portSelector->itemText(i).startsWith(QStringLiteral("/dev/ttyUSB")));
|
||||||
|
assert(!portSelector->itemText(i).trimmed().isEmpty());
|
||||||
|
}
|
||||||
|
assert(map != nullptr);
|
||||||
|
assert(mainViewTabs != nullptr);
|
||||||
|
assert(mainViewTabs->count() == 2);
|
||||||
|
assert(sideSplitter != nullptr);
|
||||||
|
assert(sideSplitter->orientation() == Qt::Vertical);
|
||||||
|
|
||||||
|
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, 1)).toString()
|
||||||
|
== QStringLiteral("40.78925034214206846173"));
|
||||||
|
assert(table->model()->data(table->model()->index(0, 2)).toString()
|
||||||
|
== QStringLiteral("29.46277530841727809729"));
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
#include "../src/LocationParser.h"
|
||||||
|
#include "../src/RinexNavigation.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QFile>
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
bool closeTo(double left, double right)
|
||||||
|
{
|
||||||
|
return std::fabs(left - right) < 0.000001;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
QCoreApplication app(argc, argv);
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto parsed = LocationParser::parseLine(
|
||||||
|
QStringLiteral("DBG lat=41.0082000 long=28.9784000 hheight=123.45 sats=7"));
|
||||||
|
assert(parsed.has_value());
|
||||||
|
assert(parsed->hasLocation());
|
||||||
|
assert(closeTo(*parsed->latitude, 41.0082));
|
||||||
|
assert(closeTo(*parsed->longitude, 28.9784));
|
||||||
|
assert(closeTo(*parsed->hheight, 123.45));
|
||||||
|
assert(parsed->satellites == 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto parsed = LocationParser::parseLine(
|
||||||
|
QStringLiteral("latitude: -12,5 longitude: 33,25 height: 99 activeSats: 4"));
|
||||||
|
assert(parsed.has_value());
|
||||||
|
assert(parsed->hasLocation());
|
||||||
|
assert(closeTo(*parsed->latitude, -12.5));
|
||||||
|
assert(closeTo(*parsed->longitude, 33.25));
|
||||||
|
assert(parsed->latitudeText == QStringLiteral("-12.5"));
|
||||||
|
assert(parsed->longitudeText == QStringLiteral("33.25"));
|
||||||
|
assert(closeTo(*parsed->hheight, 99.0));
|
||||||
|
assert(parsed->satellites == 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto parsed = LocationParser::parseLine(QStringLiteral("satellites_active=11"));
|
||||||
|
assert(parsed.has_value());
|
||||||
|
assert(!parsed->hasLocation());
|
||||||
|
assert(parsed->satellites == 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto parsed = LocationParser::parseLine(
|
||||||
|
QStringLiteral("lat=200 long=28 hheight=100 active_sats=8"));
|
||||||
|
assert(parsed.has_value());
|
||||||
|
assert(!parsed->hasLocation());
|
||||||
|
assert(parsed->latitudeText.isEmpty());
|
||||||
|
assert(parsed->longitudeText.isEmpty());
|
||||||
|
assert(!parsed->hheight.has_value());
|
||||||
|
assert(parsed->satellites == 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto parsed = LocationParser::parseLine(
|
||||||
|
QStringLiteral("lattitude : 40.78925034214206846173"));
|
||||||
|
assert(parsed.has_value());
|
||||||
|
assert(!parsed->hasLocation());
|
||||||
|
assert(closeTo(*parsed->latitude, 40.78925034214206846173));
|
||||||
|
assert(parsed->latitudeText == QStringLiteral("40.78925034214206846173"));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto parsed = LocationParser::parseLine(
|
||||||
|
QStringLiteral("longitude : 29.46277530841727809729"));
|
||||||
|
assert(parsed.has_value());
|
||||||
|
assert(!parsed->hasLocation());
|
||||||
|
assert(closeTo(*parsed->longitude, 29.46277530841727809729));
|
||||||
|
assert(parsed->longitudeText == QStringLiteral("29.46277530841727809729"));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto parsed = LocationParser::parseLine(
|
||||||
|
QStringLiteral("height : -89.05779508221894502640 "));
|
||||||
|
assert(parsed.has_value());
|
||||||
|
assert(!parsed->hasLocation());
|
||||||
|
assert(closeTo(*parsed->hheight, -89.05779508221894502640));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto parsed = LocationParser::parseLine(
|
||||||
|
QStringLiteral("NAV solve start sats:4 mode:periodic abs_span_ms:2.176"));
|
||||||
|
assert(parsed.has_value());
|
||||||
|
assert(!parsed->hasLocation());
|
||||||
|
assert(parsed->satellites == 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto parsed = LocationParser::parseLine(
|
||||||
|
QStringLiteral("[NAV] navigation software running with 5 satellites"));
|
||||||
|
assert(parsed.has_value());
|
||||||
|
assert(!parsed->hasLocation());
|
||||||
|
assert(parsed->satellites == 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto parsed = LocationParser::parseLine(QStringLiteral("GDOP : 3.9576744550"));
|
||||||
|
assert(parsed.has_value());
|
||||||
|
assert(!parsed->hasLocation());
|
||||||
|
assert(closeTo(*parsed->dop.gdop, 3.9576744550));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto parsed = LocationParser::parseLine(
|
||||||
|
QStringLiteral("PDOP : 3.2540598660 HDOP : 2.3631161789 VDOP : 2.2370935468 TDOP : 2.2526165852"));
|
||||||
|
assert(parsed.has_value());
|
||||||
|
assert(!parsed->hasLocation());
|
||||||
|
assert(closeTo(*parsed->dop.pdop, 3.2540598660));
|
||||||
|
assert(closeTo(*parsed->dop.hdop, 2.3631161789));
|
||||||
|
assert(closeTo(*parsed->dop.vdop, 2.2370935468));
|
||||||
|
assert(closeTo(*parsed->dop.tdop, 2.2526165852));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto parsed = LocationParser::parseLine(QStringLiteral("plain debug noise"));
|
||||||
|
assert(!parsed.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const QSet<QString> prns = LocationParser::parseGpsPrns(
|
||||||
|
QStringLiteral("tracking PRNs: 3, G08, 14; SV=G21 SVID: 27"));
|
||||||
|
assert(prns.contains(QStringLiteral("G03")));
|
||||||
|
assert(prns.contains(QStringLiteral("G08")));
|
||||||
|
assert(prns.contains(QStringLiteral("G14")));
|
||||||
|
assert(prns.contains(QStringLiteral("G21")));
|
||||||
|
assert(prns.contains(QStringLiteral("G27")));
|
||||||
|
assert(prns.size() == 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const QString line = QStringLiteral(
|
||||||
|
"[OK] [TRK] tracked satellites=5 svn=[2,3,5,8,30]");
|
||||||
|
const auto parsed = LocationParser::parseLine(line);
|
||||||
|
assert(parsed.has_value());
|
||||||
|
assert(parsed->satellites == 5);
|
||||||
|
const QSet<QString> prns = LocationParser::parseGpsPrns(line);
|
||||||
|
assert(prns == QSet<QString>({QStringLiteral("G03"), QStringLiteral("G04"),
|
||||||
|
QStringLiteral("G06"), QStringLiteral("G09"),
|
||||||
|
QStringLiteral("G31")}));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const QSet<QString> prns = LocationParser::parseGpsPrns(
|
||||||
|
QStringLiteral("[TRK] tracked satellites=2 svn=[0,31]"));
|
||||||
|
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G32")}));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto parsed = LocationParser::parseLine(QStringLiteral(
|
||||||
|
"[OK] [NAV] DOP GDOP=7.0907891622 PDOP=5.6333862175 "
|
||||||
|
"HDOP=2.9819528958 VDOP=4.7794348204 TDOP=4.3063035967"));
|
||||||
|
assert(parsed.has_value());
|
||||||
|
assert(closeTo(*parsed->dop.gdop, 7.0907891622));
|
||||||
|
assert(closeTo(*parsed->dop.pdop, 5.6333862175));
|
||||||
|
assert(closeTo(*parsed->dop.hdop, 2.9819528958));
|
||||||
|
assert(closeTo(*parsed->dop.vdop, 4.7794348204));
|
||||||
|
assert(closeTo(*parsed->dop.tdop, 4.3063035967));
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const auto parsed = LocationParser::parseLine(QStringLiteral(
|
||||||
|
"[OK] [NAV] position lat=40.77831900791127850425 "
|
||||||
|
"lon=29.43579105837637044374 height=198.80186179187148809433"));
|
||||||
|
assert(parsed.has_value());
|
||||||
|
assert(parsed->hasLocation());
|
||||||
|
assert(closeTo(*parsed->latitude, 40.77831900791127850425));
|
||||||
|
assert(closeTo(*parsed->longitude, 29.43579105837637044374));
|
||||||
|
assert(closeTo(*parsed->hheight, 198.80186179187148809433));
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString rinexPath = qEnvironmentVariable("LOCATION_PLOTTER_TEST_RINEX");
|
||||||
|
if (!rinexPath.isEmpty()) {
|
||||||
|
QFile file(rinexPath);
|
||||||
|
assert(file.open(QIODevice::ReadOnly));
|
||||||
|
QString error;
|
||||||
|
const auto satellites = RinexNavigation::parseGpsNavigation(
|
||||||
|
file.readAll(), QDateTime::currentDateTimeUtc(), &error);
|
||||||
|
assert(satellites.size() >= 20);
|
||||||
|
for (const auto &satellite : satellites) {
|
||||||
|
assert(satellite.prn.startsWith(QStringLiteral("G")));
|
||||||
|
assert(std::isfinite(satellite.x));
|
||||||
|
assert(std::isfinite(satellite.y));
|
||||||
|
assert(std::isfinite(satellite.z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user