diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c7fc2e0..761aeee 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,11 +1,11 @@ -name: Ubuntu build +name: Platform builds on: push: pull_request: jobs: - build-and-test: + linux: strategy: matrix: os: [ubuntu-22.04, ubuntu-24.04] @@ -21,10 +21,29 @@ jobs: sudo apt-get install -y build-essential cmake qt6-base-dev zlib1g-dev - name: Configure - run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release + run: cmake -S linux -B build/linux -DCMAKE_BUILD_TYPE=Release - name: Build - run: cmake --build build --parallel 2 + run: cmake --build build/linux --parallel 2 - name: Test - run: ctest --test-dir build --output-on-failure + run: ctest --test-dir build/linux --output-on-failure + + macos: + runs-on: macos-latest + + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Install dependencies + run: brew install cmake qt + + - name: Configure + run: cmake -S macos -B build/macos -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="$(brew --prefix qt)" + + - name: Build + run: cmake --build build/macos --parallel 2 + + - name: Test + run: ctest --test-dir build/macos --output-on-failure diff --git a/README.md b/README.md index e4d0304..127772c 100644 --- a/README.md +++ b/README.md @@ -1,283 +1,70 @@ # Location Plotter -Qt6/C++ GUI that listens to a USB UART on Linux or macOS, parses normal debug lines, -and plots live locations on a map. - -The application also includes an interactive 3D Earth/orbit view. It downloads the current -stream-derived RINEX 3 navigation file from the BKG/IGS archive at startup and every 15 -minutes, then recomputes GPS satellite positions for current UTC every minute. The last valid -file is cached for offline startup. - -## Parsed fields - -The parser accepts labeled values in the same line or in separate lines: +Bu `main` dali, isletim sistemi surumlerini birbirinden bagimsiz uygulama +agaclari olarak tutar: ```text -lat=41.0082 long=28.9784 hheight=123.45 sats=7 -latitude: 41.0082 longitude: 28.9784 height: 123.45 activeSats: 7 -active_sats=8 -navigation software running with 5 satellites -GDOP : 3.9576744550 -PDOP : 3.2540598660 -HDOP : 2.3631161789 -VDOP : 2.2370935468 -TDOP : 2.2526165852 -tracking PRNs: G03, G08, 14, 21 -SV=G27 -[LOCATION] source=gps lat=40.7357 lon=31.5985 height=812.0 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 -[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 -[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=812.1 tracked=4 used=4 -lattitude : 40.78925034214206846173 -longitude : 29.46277530841727809729 -height : -89.05779508221894502640 +location_plotter/ +├── linux/ # origin/tdk_ekf tabanli Linux surumu +│ ├── src/ +│ ├── tests/ +│ ├── resources/ +│ ├── CMakeLists.txt +│ ├── run.sh +│ └── setup-ubuntu.sh +├── macos/ # origin/macos tabanli macOS surumu +│ ├── src/ +│ ├── tests/ +│ ├── resources/ +│ ├── CMakeLists.txt +│ ├── run.sh +│ └── setup-macos.sh +└── .github/workflows/ # iki surumun CI tanimlari ``` -Accepted location labels: +Windows surumu geldiginde ayni seviyede `windows/` klasoru acilmalidir. +Bu commit Windows kodu veya bos bir Windows iskeleti eklemez. -- Latitude: `lat`, `latitude`, `lattitude` -- Longitude: `long`, `longitude`, `lon`, `lng` -- Height: `hheight`, `height`, `alt`, `altitude` -- Active satellites: `sat`, `sats`, `satellite`, `satellites`, `active_sats`, - `activeSats`, `satellites_active`, `num_sats`, `uydu` -- DOP: `GDOP`, `PDOP`, `HDOP`, `VDOP`, `TDOP` -- GPS identifiers: `G03`, `PRN: 3`, `PRNs: 3, 8, 14`, `SV=G21`, `SVID: 27` -- Location source: `source=gps`, `source=imu_corrected`, `source=trajectory` -- `tracked` is accepted as the active satellite count; `used` remains visible in - the raw UART line and denotes satellites used by the WLS solution. +## Surumler arasindaki farklar -If a satellite count arrives without a location, the active satellite display is updated. -If a location arrives without a satellite count, the latest known satellite count is attached -to that point. -If DOP values arrive before a location, the latest known values stay visible in the DOP -section and are attached to the next plotted point. -If latitude, longitude, and height arrive as separate consecutive lines, they are combined -into a single plotted point. +Linux ve macOS kodlari bilincli olarak ayri tutulur. Ortak duzeltmeler iki +klasore de ayri commitlerle uygulanmalidir; bir klasordeki degisiklik digerini +otomatik olarak etkilemez. -The GPS firmware's level-1 paired protocol is source-aware. Every received -`source=gps` location is added to the blue GPS series and every -`source=imu_corrected` location is added to the red corrected series at the -firmware-configured UART cadence. -Paths are built independently, so alternating UART lines do not create GPS-to-IMU -zigzags. A corrected point newer than one second is also preferred for map -auto-follow, the current-position label and the globe receiver marker. Raw GPS -outliers remain visible in blue for diagnosis without pulling the whole view away. -When corrected points exist, CEP50, average and scatter calculations use only that -corrected series; legacy input without a source keeps the previous behavior. -Synthetic `source=trajectory` points form a third independent green ground-truth -path. The map legend identifies all three series. +- `linux/`, `tdk_ekf` dalindaki Linux uygulamasidir. +- `macos/`, macOS'a ozel menuler, kalici pencere/seri-port ayarlari, otomatik + USB UART yenileme, gelismis harita cizimi ve Blue Marble dunya dokusunu + iceren daha genis GUI varyantidir. +- Seri port adlari farklidir: Linux `/dev/ttyUSB*` ve `/dev/ttyACM*`, macOS + `/dev/cu.usb*` ve `/dev/tty.usb*` yollarini kullanir. +- macOS ozel baud-rate ayari `IOSSIOSPEED` ile yapilir. Linux standart + `termios` baud sabitlerini kullanir. +- macOS uygulamasi `.app` bundle olarak paketlenir; Linux normal executable + uretir ve seri port izni icin `dialout` grubunu kullanir. +- macOS varyantinda ek `map_widget_tests` testi ve yerel Blue Marble kaynagi + vardir; Linux varyanti daha kucuk kaynak/test setini korur. -## UART log replay +Bu ayrim, iki surumun bagimsiz gelistirilmesini kolaylastirir; karsiliginda +parser, RINEX veya replay gibi ortak bir hata duzeltmesi iki klasorde de +uygulanip iki test paketiyle dogrulanmalidir. -Use the `UART Replay` button (or `Dosya > UART Log Replay...`) to feed a recorded -UART `.log`/`.txt` file back through the same parser used by the live serial port. -Choose the raw GPS update interval (default `200 ms`) and sensor/fused update -interval (default `50 ms`) in the replay dialog. Press `Replay Durdur` to stop. +## Calistirma -Recorder timestamps are optional and do not control replay speed. The replayer -removes a leading timestamp when present, identifies `source=gps` as raw GPS and -`source=imu_corrected` or `[FUSION]` as fused/sensor output, then synthesizes the -two update timelines while retaining the original UART line order. Other UART -diagnostic lines are forwarded with the surrounding location update. - -## Sentetik ESKF senaryolarini açma - -GPS deposundan 48 kombinasyonun replay loglarini üretin: +Linux: ```bash -cd /home/onur/Desktop/gps -make -C soc/software/fusion/tests scenario-logs -``` - -Sonra GUI'yi açıp `Senaryo Logu Ac` düğmesinden örneğin -`soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log` dosyasını seçin. -GUI logdaki bütün rotayı otomatik sığdırır. Aynı dosya komut satırından da -doğrudan açılabilir: - -```bash -cd /home/onur/Desktop/location_plotter -cmake -S . -B build -cmake --build build -j -./build/location_plotter --replay \ - /home/onur/Desktop/gps/soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log -``` - -Tek bir özel kombinasyon üretmek için: - -```bash -cd /home/onur/Desktop/gps/soc/software/fusion/tests -make platform_scenario_matrix_test -./platform_scenario_matrix_test --log aircraft 500 19 aircraft.log -``` - -Desteklenen senaryolar `stationary`, `pedestrian`, `car`, `aircraft`; GNSS -periyotları 200/500/1000 ms, IMU periyotları 19/20/200/300 ms'dir. - -## macOS'ta kurulum (MacBook Air, Apple Silicon veya Intel) - -Guncel macOS surumunde once [Homebrew](https://brew.sh) kurulu olmalidir. Ardindan: - -```bash -cd location_plotter -chmod +x setup-macos.sh run.sh -./setup-macos.sh -./run.sh -``` - -Betik Homebrew ile guncel `cmake` ve `qt` paketlerini kurar, yerel mimari icin -`location_plotter.app` paketini derler, gerekli Qt kutuphanelerini uygulama paketine -ekler ve testleri calistirir. - -macOS USB UART aygitlarini genellikle iki adla gosterir: - -```text -/dev/cu.usbserial-XXXXXXXX -/dev/tty.usbserial-XXXXXXXX -``` - -veya bir CDC aygitinda: - -```text -/dev/cu.usbmodemXXXX -/dev/tty.usbmodemXXXX -``` - -Uygulama her iki bicimi de algilar. Mümkünse `cu.usb...` yolunu secin; macOS'ta -disariya baglanti baslatan uygulamalar icin uygun olan aygit budur. UART uygulama -acikken takildiysa port alaninin yanindaki `Yenile` dugmesine basin. Otomatik -algilanmayan bir yol port alanina elle de yazilabilir. - -Istenen port baslatmadan once ortam degiskeniyle de secilebilir: - -```bash -LOCATION_PLOTTER_PORT=/dev/cu.usbserial-XXXXXXXX ./run.sh -``` - -Port hic gorunmuyorsa `ls /dev/cu.usb* /dev/tty.usb*` ile macOS'un aygiti olusturup -olusturmadigini kontrol edin. Hic sonuc yoksa kabloyu/veri baglantisini ve UART -donaniminizin macOS surucusunu kontrol edin. - -## Ubuntu'da kurulum - -Ubuntu 22.04 ve daha yeni surumlerde gerekli paketleri kurmak, uygulamayi derlemek ve -testleri calistirmak icin: - -```bash -git clone location_plotter -cd location_plotter +cd linux ./setup-ubuntu.sh ./run.sh ``` -`setup-ubuntu.sh`, gereken Ubuntu paketlerini `apt` ile kurar, Release build olusturur, -testleri calistirir ve kullaniciyi seri port erisimi icin `dialout` grubuna ekler. -Kurulum sirasinda `sudo` parolasi istenebilir. - -Depo ZIP olarak indirildiyse de klasore girdikten sonra ayni iki betik calistirilabilir. - -## Elle derleme - -Gerekli paketler: - -```bash -sudo apt update -sudo apt install build-essential cmake qt6-base-dev zlib1g-dev -``` - -Derleme ve test: - -```bash -cmake -S . -B build -cmake --build build -ctest --test-dir build --output-on-failure -``` - -Run: +macOS: ```bash +cd macos +./setup-macos.sh ./run.sh ``` -Uygulama Qt tabanli bir masaustu arayuzudur; grafik oturumu olan bir Ubuntu makinede -calistirilmalidir. Harita karolari ve guncel RINEX verisi icin internet erisimi gerekir, -ancak son gecerli RINEX verisi ve harita onbellegiyle cevrimdisi kullanim da desteklenir. - -Port secici Linux'ta `/dev/ttyUSB*` ve `/dev/ttyACM*`; macOS'ta USB ile ilgili -`/dev/cu.*` ve `/dev/tty.*` aygitlarini listeler. Hic aygit algilanmazsa Linux'ta -`/dev/ttyUSB2`, macOS'ta `/dev/cu.usbserial` duzenlenebilir bir varsayilan yol olarak -gosterilir. 50 Hz IMU-duzeltilmis konum akisini kayipsiz tasimak icin varsayilan baud -rate `460800`'dur. - -If the port cannot be opened, make sure the user has serial access: - -```bash -sudo usermod -a -G dialout "$USER" -``` - -Log out and back in after changing group membership. - -If the user is already listed in the `dialout` group but the current shell has not picked it -up yet, `./run.sh` starts the GUI through `sg dialout` so it can access `/dev/ttyUSB2` -without logging out. - -## Notes - -- The map uses OpenStreetMap tiles through Qt Network. If there is no internet connection, - points are still drawn over a fallback grid. -- The `3B Dunya · Uydular` tab appears beside the map. It uses broadcast ephemeris values - from BKG's near-real-time `BRDC00WRD_S` RINEX file to propagate GPS satellites in ECEF. - PRNs parsed from the current navigation solve are green; other RINEX satellites are red. - Drag to rotate the globe and use the mouse wheel to zoom. -- The globe includes a shaded continent layer and a receiver-centred translucent visibility - cone. `Min. elevation` accepts a directly typed decimal value from 0 to 90 degrees - (10 degrees by default, press Enter after typing); - satellites inside the mask get a cyan sight line and halo. Incoming positions update the - receiver marker without resetting a globe orientation chosen by dragging. -- The globe surface uses NASA/GSFC Scientific Visualization Studio's 2048x1024 Blue Marble - mosaic (NASA SVS visualization 2915), projected locally without a runtime network request. -- RINEX download starts at every launch, repeats every 15 minutes, and falls back through the - previous two UTC days if today's stream file is unavailable. Current and next-minute satellite - positions are propagated from the cached ephemeris, then smoothly interpolated at 10 FPS. -- The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status - line shows the system-local time of the last successful download or cache load. -- Complete UART channel snapshots such as `tracked satellites=5 prn_indices=[...]` replace the - previous connected-PRN set, so satellites that were lost no longer remain green. Firmware - channel indices are zero-based and are normalized for display (`0` is GPS PRN `G01`). -- RINEX text is parsed once per download and retained as compact ephemeris records. The static - globe is cached between camera changes, reducing CPU and allocation load during orbit animation. -- The globe uses an embedded Natural Earth coastline mask with terrain-like land colouring, - shallow-water edges, atmospheric shading, and a cached Earth-fixed texture for smooth rotation. -- The main content width and the right-side dashboard/UART height are both adjustable by - dragging their splitter handles. Dashboard cards scroll independently so the UART log can - be enlarged substantially. -- `Max` controls how many recent points stay visible and are included in location - analysis. The default is 10. When the limit is reached, older points are removed - automatically. -- Location analysis shows the average of the plotted samples, the last point's deviation - from that average, mean deviation, RMS scatter, and max deviation in meters. The average - marker can be toggled on the map. -- The map layers panel can hide all telemetry overlays, show only the newest location instead - of the recent path, and independently toggle the average marker. -- GPS points and paths are blue. IMU+ESKF corrected points and their independent - path are red. Synthetic ground-truth trajectory is green; the locations table - shows the parsed source in its `Kaynak` column. -- The application calculates empirical CEP50 as the median horizontal distance of the retained - measurements from their spherical mean, then draws the circle around that measurement center. - This is the radius containing 50 percent of the retained position fixes and is independent of - the optional user reference. -- A user reference can be selected by right-clicking the map. The selected WGS84 coordinates - are transferred to the reference fields with 15 decimal places but do not alter CEP50. -- Latitude and longitude tokens are preserved exactly as received for the table and map - label. Calculations and map projection use IEEE 754 `double` precision. -- The OpenStreetMap Standard raster layer supports zoom levels through 19. At latitude 40.8, - zoom 19 is approximately 0.23 meters per pixel; the marker still uses subpixel coordinates, - but the background tiles cannot add accuracy to the GNSS measurement. -- OpenStreetMap attribution is shown on the map. Tile responses use a 100 MB persistent HTTP - cache in the application's standard cache directory. -- UART log and telemetry views are refreshed in batches so a high-rate serial stream does not - rebuild the complete interface for every received line. The locations table uses a virtual - model and remains usable with a high `Max` value. -- Her basarili UART baglantisi, dosya adinda ve her satirda yerel zaman damgasi bulunan ayri bir - log olusturur. Dosyalar kaynak kodun ana klasorundeki `uart_logs` dizinine yazilir; - `LOCATION_PLOTTER_UART_LOG_DIR` ile farkli bir klasor secilebilir. -- If no new plotted location arrives for 10 seconds while listening, current location - points and analysis values are cleared. +Ayrintili kullanim ve telemetri formati ilgili klasorun `README.md` dosyasinda +yer alir. diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..e1bddd5 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,119 @@ +cmake_minimum_required(VERSION 3.20) + +project(location_plotter VERSION 0.3.0 LANGUAGES CXX) + +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + message(FATAL_ERROR "linux/ yalnizca Linux icin yapilandirilabilir") +endif() + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 REQUIRED COMPONENTS Core Widgets Network) +find_package(ZLIB REQUIRED) + +add_executable(location_plotter + src/LocationParser.cpp + src/LocationParser.h + src/GlobeWidget.cpp + src/GlobeWidget.h + src/MainWindow.cpp + src/MainWindow.h + src/MapWidget.cpp + src/MapWidget.h + src/SerialReader.cpp + src/SerialReader.h + src/UartReplay.cpp + src/UartReplay.h + src/RinexNavigation.cpp + src/RinexNavigation.h + src/SatelliteData.h + src/TelemetryPoint.h + src/main.cpp +) + +target_link_libraries(location_plotter PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB) +target_compile_definitions(location_plotter PRIVATE + LOCATION_PLOTTER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" +) + +qt_add_resources(location_plotter "app_resources" + PREFIX "/" + FILES resources/ankasys-logo.png resources/land-110m.json +) + +target_compile_definitions(location_plotter PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1) + +enable_testing() + +add_executable(location_parser_tests + tests/parser_tests.cpp + src/LocationParser.cpp + src/LocationParser.h + src/RinexNavigation.cpp + src/RinexNavigation.h + src/SatelliteData.h + src/TelemetryPoint.h +) +target_link_libraries(location_parser_tests PRIVATE Qt6::Core Qt6::Network ZLIB::ZLIB) +target_compile_options(location_parser_tests PRIVATE + $<$:-UNDEBUG> +) +add_test(NAME location_parser_tests COMMAND location_parser_tests) + +add_executable(location_main_window_tests + tests/main_window_tests.cpp + src/LocationParser.cpp + src/LocationParser.h + src/MainWindow.cpp + src/MainWindow.h + src/MapWidget.cpp + src/MapWidget.h + src/GlobeWidget.cpp + src/GlobeWidget.h + src/SerialReader.cpp + src/SerialReader.h + src/UartReplay.cpp + src/UartReplay.h + src/RinexNavigation.cpp + src/RinexNavigation.h + src/SatelliteData.h + src/TelemetryPoint.h +) +target_link_libraries(location_main_window_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB) +target_compile_definitions(location_main_window_tests PRIVATE + LOCATION_PLOTTER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}" +) +target_compile_options(location_main_window_tests PRIVATE + $<$:-UNDEBUG> +) +qt_add_resources(location_main_window_tests "test_app_resources" + PREFIX "/" + FILES resources/ankasys-logo.png resources/land-110m.json +) +target_compile_definitions(location_main_window_tests PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1) +add_test(NAME location_main_window_tests COMMAND location_main_window_tests) +set_tests_properties(location_main_window_tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_CACHE_HOME=/tmp/location-plotter-test-cache") + +add_executable(location_uart_replay_tests + tests/uart_replay_tests.cpp + src/UartReplay.cpp + src/UartReplay.h + src/LocationParser.cpp + src/LocationParser.h + src/TelemetryPoint.h +) +target_link_libraries(location_uart_replay_tests PRIVATE Qt6::Core) +target_compile_options(location_uart_replay_tests PRIVATE + $<$:-UNDEBUG> +) +add_test(NAME location_uart_replay_tests COMMAND location_uart_replay_tests) + +install(TARGETS location_plotter + BUNDLE DESTINATION . + RUNTIME DESTINATION bin +) diff --git a/linux/README.md b/linux/README.md new file mode 100644 index 0000000..73229e1 --- /dev/null +++ b/linux/README.md @@ -0,0 +1,236 @@ +# Location Plotter + +Qt6/C++ GUI that listens to a USB UART on Linux, parses normal debug lines, +and plots live locations on a map. + +The application also includes an interactive 3D Earth/orbit view. It downloads the current +stream-derived RINEX 3 navigation file from the BKG/IGS archive at startup and every 15 +minutes, then recomputes GPS satellite positions for current UTC every minute. The last valid +file is cached for offline startup. + +## Parsed fields + +The parser accepts labeled values in the same line or in separate lines: + +```text +lat=41.0082 long=28.9784 hheight=123.45 sats=7 +latitude: 41.0082 longitude: 28.9784 height: 123.45 activeSats: 7 +active_sats=8 +navigation software running with 5 satellites +GDOP : 3.9576744550 +PDOP : 3.2540598660 +HDOP : 2.3631161789 +VDOP : 2.2370935468 +TDOP : 2.2526165852 +tracking PRNs: G03, G08, 14, 21 +SV=G27 +[LOCATION] source=gps lat=40.7357 lon=31.5985 height=812.0 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 +[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 +[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=812.1 tracked=4 used=4 +lattitude : 40.78925034214206846173 +longitude : 29.46277530841727809729 +height : -89.05779508221894502640 +``` + +Accepted location labels: + +- Latitude: `lat`, `latitude`, `lattitude` +- Longitude: `long`, `longitude`, `lon`, `lng` +- Height: `hheight`, `height`, `alt`, `altitude` +- Active satellites: `sat`, `sats`, `satellite`, `satellites`, `active_sats`, + `activeSats`, `satellites_active`, `num_sats`, `uydu` +- DOP: `GDOP`, `PDOP`, `HDOP`, `VDOP`, `TDOP` +- GPS identifiers: `G03`, `PRN: 3`, `PRNs: 3, 8, 14`, `SV=G21`, `SVID: 27` +- Location source: `source=gps`, `source=imu_corrected`, `source=trajectory` +- `tracked` is accepted as the active satellite count; `used` remains visible in + the raw UART line and denotes satellites used by the WLS solution. + +If a satellite count arrives without a location, the active satellite display is updated. +If a location arrives without a satellite count, the latest known satellite count is attached +to that point. +If DOP values arrive before a location, the latest known values stay visible in the DOP +section and are attached to the next plotted point. +If latitude, longitude, and height arrive as separate consecutive lines, they are combined +into a single plotted point. + +The GPS firmware's level-1 paired protocol is source-aware. Every received +`source=gps` location is added to the blue GPS series and every +`source=imu_corrected` location is added to the red corrected series at the +firmware-configured UART cadence. +Paths are built independently, so alternating UART lines do not create GPS-to-IMU +zigzags. A corrected point newer than one second is also preferred for map +auto-follow, the current-position label and the globe receiver marker. Raw GPS +outliers remain visible in blue for diagnosis without pulling the whole view away. +When corrected points exist, CEP50, average and scatter calculations use only that +corrected series; legacy input without a source keeps the previous behavior. +Synthetic `source=trajectory` points form a third independent green ground-truth +path. The map legend identifies all three series. + +## UART log replay + +Use the `UART Replay` button (or `Dosya > UART Log Replay...`) to feed a recorded +UART `.log`/`.txt` file back through the same parser used by the live serial port. +Choose the raw GPS update interval (default `200 ms`) and sensor/fused update +interval (default `50 ms`) in the replay dialog. Press `Replay Durdur` to stop. + +Recorder timestamps are optional and do not control replay speed. The replayer +removes a leading timestamp when present, identifies `source=gps` as raw GPS and +`source=imu_corrected` or `[FUSION]` as fused/sensor output, then synthesizes the +two update timelines while retaining the original UART line order. Other UART +diagnostic lines are forwarded with the surrounding location update. + +## Sentetik ESKF senaryolarini açma + +GPS deposundan 48 kombinasyonun replay loglarini üretin: + +```bash +cd /home/onur/Desktop/gps +make -C soc/software/fusion/tests scenario-logs +``` + +Sonra GUI'yi açıp `Senaryo Logu Ac` düğmesinden örneğin +`soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log` dosyasını seçin. +GUI logdaki bütün rotayı otomatik sığdırır. Aynı dosya komut satırından da +doğrudan açılabilir: + +```bash +cd location_plotter/linux +cmake -S . -B build +cmake --build build -j +./build/location_plotter --replay \ + /home/onur/Desktop/gps/soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log +``` + +Tek bir özel kombinasyon üretmek için: + +```bash +cd /home/onur/Desktop/gps/soc/software/fusion/tests +make platform_scenario_matrix_test +./platform_scenario_matrix_test --log aircraft 500 19 aircraft.log +``` + +Desteklenen senaryolar `stationary`, `pedestrian`, `car`, `aircraft`; GNSS +periyotları 200/500/1000 ms, IMU periyotları 19/20/200/300 ms'dir. + +## Ubuntu'da kurulum + +Ubuntu 22.04 ve daha yeni surumlerde gerekli paketleri kurmak, uygulamayi derlemek ve +testleri calistirmak icin: + +```bash +git clone location_plotter +cd location_plotter/linux +./setup-ubuntu.sh +./run.sh +``` + +`setup-ubuntu.sh`, gereken Ubuntu paketlerini `apt` ile kurar, Release build olusturur, +testleri calistirir ve kullaniciyi seri port erisimi icin `dialout` grubuna ekler. +Kurulum sirasinda `sudo` parolasi istenebilir. + +Depo ZIP olarak indirildiyse de klasore girdikten sonra ayni iki betik calistirilabilir. + +## Elle derleme + +Gerekli paketler: + +```bash +sudo apt update +sudo apt install build-essential cmake qt6-base-dev zlib1g-dev +``` + +Derleme ve test: + +```bash +cmake -S . -B build +cmake --build build +ctest --test-dir build --output-on-failure +``` + +Run: + +```bash +./run.sh +``` + +Uygulama Qt tabanli bir masaustu arayuzudur; grafik oturumu olan bir Ubuntu makinede +calistirilmalidir. Harita karolari ve guncel RINEX verisi icin internet erisimi gerekir, +ancak son gecerli RINEX verisi ve harita onbellegiyle cevrimdisi kullanim da desteklenir. + +Port secici `/dev/ttyUSB*` ve `/dev/ttyACM*` aygitlarini listeler. Hic aygit +algilanmazsa `/dev/ttyUSB2` duzenlenebilir bir varsayilan yol olarak gosterilir. +50 Hz IMU-duzeltilmis konum akisini kayipsiz tasimak icin varsayilan baud rate +`460800`'dur. + +If the port cannot be opened, make sure the user has serial access: + +```bash +sudo usermod -a -G dialout "$USER" +``` + +Log out and back in after changing group membership. + +If the user is already listed in the `dialout` group but the current shell has not picked it +up yet, `./run.sh` starts the GUI through `sg dialout` so it can access `/dev/ttyUSB2` +without logging out. + +## Notes + +- The map uses OpenStreetMap tiles through Qt Network. If there is no internet connection, + points are still drawn over a fallback grid. +- The `3B Dunya · Uydular` tab appears beside the map. It uses broadcast ephemeris values + from BKG's near-real-time `BRDC00WRD_S` RINEX file to propagate GPS satellites in ECEF. + PRNs parsed from the current navigation solve are green; other RINEX satellites are red. + Drag to rotate the globe and use the mouse wheel to zoom. +- The globe includes a shaded continent layer and a receiver-centred translucent visibility + cone. `Min. elevation` accepts a directly typed decimal value from 0 to 90 degrees + (10 degrees by default, press Enter after typing); + satellites inside the mask get a cyan sight line and halo. Incoming positions update the + receiver marker without resetting a globe orientation chosen by dragging. +- RINEX download starts at every launch, repeats every 15 minutes, and falls back through the + previous two UTC days if today's stream file is unavailable. Current and next-minute satellite + positions are propagated from the cached ephemeris, then smoothly interpolated at 10 FPS. +- The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status + line shows the system-local time of the last successful download or cache load. +- Complete UART channel snapshots such as `tracked satellites=5 prn_indices=[...]` replace the + previous connected-PRN set, so satellites that were lost no longer remain green. Firmware + channel indices are zero-based and are normalized for display (`0` is GPS PRN `G01`). +- RINEX text is parsed once per download and retained as compact ephemeris records. The static + globe is cached between camera changes, reducing CPU and allocation load during orbit animation. +- The globe uses an embedded Natural Earth coastline mask with terrain-like land colouring, + shallow-water edges, atmospheric shading, and a cached Earth-fixed texture for smooth rotation. +- The main content width and the right-side dashboard/UART height are both adjustable by + dragging their splitter handles. Dashboard cards scroll independently so the UART log can + be enlarged substantially. +- `Max` controls how many recent points stay visible and are included in location + analysis. The default is 10. When the limit is reached, older points are removed + automatically. +- Location analysis shows the average of the plotted samples, the last point's deviation + from that average, mean deviation, RMS scatter, and max deviation in meters. The average + marker can be toggled on the map. +- The map layers panel can hide all telemetry overlays, show only the newest location instead + of the recent path, and independently toggle the average marker. +- GPS points and paths are blue. IMU+ESKF corrected points and their independent + path are red. Synthetic ground-truth trajectory is green; the locations table + shows the parsed source in its `Kaynak` column. +- The application calculates empirical CEP50 as the median horizontal distance of the retained + measurements from their spherical mean, then draws the circle around that measurement center. + This is the radius containing 50 percent of the retained position fixes and is independent of + the optional user reference. +- A user reference can be selected by right-clicking the map. The selected WGS84 coordinates + are transferred to the reference fields with 15 decimal places but do not alter CEP50. +- Latitude and longitude tokens are preserved exactly as received for the table and map + label. Calculations and map projection use IEEE 754 `double` precision. +- The OpenStreetMap Standard raster layer supports zoom levels through 19. At latitude 40.8, + zoom 19 is approximately 0.23 meters per pixel; the marker still uses subpixel coordinates, + but the background tiles cannot add accuracy to the GNSS measurement. +- OpenStreetMap attribution is shown on the map. Tile responses use a 100 MB persistent HTTP + cache in the application's standard cache directory. +- UART log and telemetry views are refreshed in batches so a high-rate serial stream does not + rebuild the complete interface for every received line. The locations table uses a virtual + model and remains usable with a high `Max` value. +- Her basarili UART baglantisi, dosya adinda ve her satirda yerel zaman damgasi bulunan ayri bir + log olusturur. Dosyalar kaynak kodun ana klasorundeki `uart_logs` dizinine yazilir; + `LOCATION_PLOTTER_UART_LOG_DIR` ile farkli bir klasor secilebilir. +- If no new plotted location arrives for 10 seconds while listening, current location + points and analysis values are cleared. diff --git a/resources/ankasys-logo.png b/linux/resources/ankasys-logo.png similarity index 100% rename from resources/ankasys-logo.png rename to linux/resources/ankasys-logo.png diff --git a/resources/land-110m.json b/linux/resources/land-110m.json similarity index 100% rename from resources/land-110m.json rename to linux/resources/land-110m.json diff --git a/linux/run.sh b/linux/run.sh new file mode 100755 index 0000000..0c4de9e --- /dev/null +++ b/linux/run.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +print_qt_install_hint() { + echo + echo "Qt6 bulunamadi. Ubuntu'da gerekli paketleri kurmak icin:" + echo " sudo apt update" + echo " sudo apt install build-essential cmake qt6-base-dev zlib1g-dev" + echo "veya dogrudan: ./setup-ubuntu.sh" +} + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "Bu calistirma betigi Linux icindir." >&2 + exit 1 +fi + +project_dir="$(pwd -P)" + +if [[ -f ./build/CMakeCache.txt ]]; then + cached_source="$(sed -n 's/^CMAKE_HOME_DIRECTORY:INTERNAL=//p' ./build/CMakeCache.txt)" + if [[ -n "$cached_source" && "$cached_source" != "$project_dir" ]]; then + echo "Build klasoru farkli bir proje yoluna ait. 'linux/build' klasorunu silip tekrar deneyin." >&2 + exit 1 + fi +fi + +if ! cmake -S . -B build; then + print_qt_install_hint + exit 1 +fi + +cmake --build build + +missing_libs="$(ldd ./build/location_plotter 2>/dev/null | awk '/not found/ { print $1 }' || true)" +if [[ -n "$missing_libs" ]]; then + echo "Eksik paylasimli kutuphaneler:" + echo "$missing_libs" | sed 's/^/ /' + print_qt_install_hint + exit 1 +fi + +if id -nG | tr ' ' '\n' | grep -qx dialout; then + exec ./build/location_plotter "$@" +fi + +if getent group dialout | tr ':' '\n' | tail -n 1 | tr ',' '\n' | grep -qx "$(id -un)"; then + exec sg dialout -c "$(printf '%q ' ./build/location_plotter "$@")" +fi + +echo "Bu kullanici dialout grubunda degil. Eklemek icin:" +echo " sudo usermod -a -G dialout $(id -un)" +echo "Sonra oturumu kapatip ac veya bu scripti tekrar calistir." +exit 1 diff --git a/setup-ubuntu.sh b/linux/setup-ubuntu.sh similarity index 100% rename from setup-ubuntu.sh rename to linux/setup-ubuntu.sh diff --git a/linux/src/GlobeWidget.cpp b/linux/src/GlobeWidget.cpp new file mode 100644 index 0000000..6b65e5d --- /dev/null +++ b/linux/src/GlobeWidget.cpp @@ -0,0 +1,870 @@ +#include "GlobeWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { +constexpr double Pi = 3.14159265358979323846; +constexpr double InitialLongitudeOffset = 0.50; +constexpr double InitialLatitudeOffset = 0.28; + +struct Vec3 { double x; double y; double z; }; + +Vec3 globeVector(double latitude, double longitude) +{ + const double lat = latitude * Pi / 180.0; + const double lon = longitude * Pi / 180.0; + return {std::cos(lat) * std::cos(lon), std::cos(lat) * std::sin(lon), std::sin(lat)}; +} + +const QVector &fallbackLandPolygons() +{ + // Deliberately simplified Natural-Earth-like coastlines. QPointF is lon/lat. + static const QVector polygons = { + // North America + {{-168, 66}, {-155, 72}, {-138, 70}, {-128, 61}, {-123, 50}, {-124, 42}, + {-117, 32}, {-106, 24}, {-97, 19}, {-90, 19}, {-83, 24}, {-81, 31}, + {-75, 39}, {-66, 45}, {-60, 52}, {-64, 60}, {-78, 63}, {-89, 69}, + {-105, 73}, {-125, 72}, {-145, 75}}, + // Central America + {{-106, 24}, {-97, 18}, {-91, 16}, {-87, 12}, {-83, 9}, {-79, 8}, + {-77, 10}, {-84, 16}, {-91, 20}}, + // South America + {{-81, 12}, {-72, 12}, {-61, 8}, {-50, 3}, {-43, -5}, {-35, -8}, + {-39, -19}, {-48, -29}, {-53, -36}, {-60, -47}, {-67, -55}, + {-74, -50}, {-75, -38}, {-71, -28}, {-70, -18}, {-76, -7}}, + // Greenland + {{-73, 60}, {-52, 59}, {-39, 65}, {-20, 76}, {-28, 83}, {-49, 84}, + {-65, 78}}, + // Europe and Asia + {{-11, 36}, {-10, 44}, {-5, 50}, {4, 54}, {12, 58}, {20, 69}, + {34, 71}, {48, 77}, {72, 76}, {96, 78}, {119, 73}, {142, 71}, + {165, 66}, {178, 58}, {169, 50}, {151, 46}, {141, 38}, {128, 34}, + {121, 24}, {110, 20}, {105, 10}, {98, 7}, {91, 21}, {80, 8}, + {72, 18}, {62, 25}, {53, 27}, {45, 38}, {36, 42}, {28, 40}, + {20, 35}, {12, 36}, {4, 43}}, + // Arabian peninsula + {{35, 31}, {48, 30}, {56, 24}, {51, 15}, {43, 12}, {36, 20}}, + // India and southeast Asia + {{67, 25}, {76, 31}, {88, 25}, {92, 22}, {87, 8}, {80, 6}, + {74, 15}}, + {{95, 22}, {106, 21}, {110, 12}, {105, 1}, {100, 7}}, + // Africa + {{-17, 37}, {-5, 37}, {10, 35}, {25, 31}, {34, 22}, {43, 12}, + {51, 11}, {48, 2}, {42, -12}, {33, -26}, {25, -35}, {17, -34}, + {10, -25}, {2, -9}, {-8, 5}, {-16, 15}}, + // Australia + {{112, -11}, {125, -10}, {138, -14}, {153, -27}, {151, -39}, + {139, -44}, {126, -34}, {114, -35}, {111, -23}}, + // Madagascar + {{47, -13}, {51, -17}, {49, -27}, {44, -25}, {43, -18}}, + // Japan + {{130, 31}, {136, 34}, {142, 43}, {145, 44}, {141, 35}}, + // Great Britain + {{-8, 50}, {-3, 58}, {1, 57}, {2, 51}}, + // New Zealand + {{166, -34}, {174, -41}, {178, -47}, {171, -45}}, + }; + return polygons; +} + +const QVector &landPolygons() +{ + static const QVector polygons = [] { + QFile file(QStringLiteral(":/resources/land-110m.json")); + if (!file.open(QIODevice::ReadOnly)) return fallbackLandPolygons(); + const QJsonDocument document = QJsonDocument::fromJson(file.readAll()); + const QJsonObject root = document.object(); + const QJsonObject transform = root.value(QStringLiteral("transform")).toObject(); + const QJsonArray scale = transform.value(QStringLiteral("scale")).toArray(); + const QJsonArray translate = transform.value(QStringLiteral("translate")).toArray(); + if (scale.size() != 2 || translate.size() != 2) return fallbackLandPolygons(); + + const double scaleX = scale[0].toDouble(); + const double scaleY = scale[1].toDouble(); + const double translateX = translate[0].toDouble(); + const double translateY = translate[1].toDouble(); + QVector decodedArcs; + const QJsonArray arcs = root.value(QStringLiteral("arcs")).toArray(); + decodedArcs.reserve(arcs.size()); + for (const QJsonValue &arcValue : arcs) { + int x = 0; + int y = 0; + QPolygonF arc; + for (const QJsonValue &pointValue : arcValue.toArray()) { + const QJsonArray point = pointValue.toArray(); + if (point.size() != 2) continue; + x += point[0].toInt(); + y += point[1].toInt(); + arc.push_back(QPointF(x * scaleX + translateX, + y * scaleY + translateY)); + } + decodedArcs.push_back(arc); + } + + QVector result; + const QJsonObject land = root.value(QStringLiteral("objects")).toObject() + .value(QStringLiteral("land")).toObject(); + const QJsonArray geometries = land.value(QStringLiteral("geometries")).toArray(); + for (const QJsonValue &geometryValue : geometries) { + const QJsonObject geometry = geometryValue.toObject(); + const QJsonArray multiPolygon = geometry.value(QStringLiteral("arcs")).toArray(); + for (const QJsonValue &polygonValue : multiPolygon) { + const QJsonArray rings = polygonValue.toArray(); + if (rings.isEmpty()) continue; + const QJsonArray ringArcs = rings[0].toArray(); // outer coastline + QPolygonF polygon; + for (const QJsonValue &arcIndexValue : ringArcs) { + const int reference = arcIndexValue.toInt(); + const int index = reference >= 0 ? reference : ~reference; + if (index < 0 || index >= decodedArcs.size()) continue; + QPolygonF arc = decodedArcs[index]; + if (reference < 0) std::reverse(arc.begin(), arc.end()); + for (int i = polygon.isEmpty() ? 0 : 1; i < arc.size(); ++i) { + polygon.push_back(arc[i]); + } + } + if (polygon.size() >= 3) result.push_back(polygon); + } + } + return result.isEmpty() ? fallbackLandPolygons() : result; + }(); + return polygons; +} + +const QImage &landMask() +{ + // A geographic mask decouples coastline quality from the size of the widget. + // It is generated once and remains fixed in Earth coordinates while rotating. + static const QImage mask = [] { + constexpr int Width = 1440; + constexpr int Height = 720; + QImage image(Width, Height, QImage::Format_Grayscale8); + image.fill(0); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(Qt::NoPen); + painter.setBrush(Qt::white); + for (const QPolygonF &geographic : landPolygons()) { + QPolygonF projected; + projected.reserve(geographic.size()); + double previousLongitude = geographic.first().x(); + double unwrappedLongitude = previousLongitude; + for (const QPointF &point : geographic) { + double longitude = point.x(); + while (longitude - previousLongitude > 180.0) longitude -= 360.0; + while (longitude - previousLongitude < -180.0) longitude += 360.0; + unwrappedLongitude += longitude - previousLongitude; + previousLongitude = longitude; + projected.push_back(QPointF((unwrappedLongitude + 180.0) / 360.0 * Width, + (90.0 - point.y()) / 180.0 * Height)); + } + for (int copy = -1; copy <= 1; ++copy) { + QPolygonF shifted = projected; + shifted.translate(copy * Width, 0.0); + painter.drawPolygon(shifted); + } + } + painter.end(); + return image; + }(); + return mask; +} + +bool maskIsLand(double longitude, double latitude) +{ + const QImage &mask = landMask(); + longitude = std::fmod(longitude + 180.0, 360.0); + if (longitude < 0.0) longitude += 360.0; + const int x = std::clamp(static_cast(longitude / 360.0 * mask.width()), + 0, mask.width() - 1); + const int y = std::clamp(static_cast((90.0 - latitude) / 180.0 * mask.height()), + 0, mask.height() - 1); + return mask.constScanLine(y)[x] != 0; +} + +double smoothStep(double edge0, double edge1, double value) +{ + const double t = std::clamp((value - edge0) / (edge1 - edge0), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} +} + +GlobeWidget::GlobeWidget(QWidget *parent) + : QWidget(parent) +{ + setObjectName(QStringLiteral("globeWidget")); + setMinimumSize(420, 360); + setMouseTracking(true); + setCursor(Qt::OpenHandCursor); + setToolTip(QStringLiteral("Dunyayi dondurmek icin surukleyin, yakinlastirmak icin tekerlegi kullanin")); + // GNSS satellites move only a few screen pixels per minute. Ten frames per + // second is visually smooth here and avoids repainting a static globe at 30 FPS. + m_satelliteAnimationTimer.setInterval(100); + m_satelliteAnimationTimer.setTimerType(Qt::CoarseTimer); + connect(&m_satelliteAnimationTimer, &QTimer::timeout, + this, &GlobeWidget::updateSatelliteAnimation); +} + +void GlobeWidget::setSatellites(const QVector &satellites) +{ + m_satellites = satellites; + update(); +} + +void GlobeWidget::setSatelliteMotion(const QVector ¤t, + const QVector &next, + int durationMs) +{ + if (current.isEmpty() || next.isEmpty() || durationMs <= 0) { + setSatellites(current); + m_satelliteAnimationTimer.stop(); + return; + } + + // Continue from the currently rendered points when a fresh ephemeris arrives, + // avoiding even a small visual jump at the update boundary. + const QVector &candidateStart = + m_satellites.isEmpty() ? current : m_satellites; + QHash starts; + starts.reserve(candidateStart.size()); + for (const SatellitePosition &satellite : candidateStart) { + starts.insert(satellite.prn, satellite); + } + + m_satelliteMotionStart.clear(); + m_satelliteMotionTarget.clear(); + m_satelliteMotionStart.reserve(next.size()); + m_satelliteMotionTarget.reserve(next.size()); + for (const SatellitePosition &target : next) { + const auto start = starts.constFind(target.prn); + if (start == starts.cend()) continue; + m_satelliteMotionStart.push_back(start.value()); + m_satelliteMotionTarget.push_back(target); + } + if (m_satelliteMotionStart.isEmpty()) { + setSatellites(current); + m_satelliteAnimationTimer.stop(); + return; + } + m_satelliteMotionDurationMs = durationMs; + m_satelliteMotionClock.restart(); + m_satelliteAnimationTimer.start(); + updateSatelliteAnimation(); +} + +void GlobeWidget::updateSatelliteAnimation() +{ + if (!m_satelliteMotionClock.isValid() || m_satelliteMotionDurationMs <= 0) return; + + const double progress = std::clamp( + static_cast(m_satelliteMotionClock.elapsed()) / m_satelliteMotionDurationMs, + 0.0, 1.0); + QVector interpolated; + interpolated.reserve(m_satelliteMotionStart.size()); + for (qsizetype i = 0; i < m_satelliteMotionStart.size(); ++i) { + const SatellitePosition &start = m_satelliteMotionStart[i]; + const SatellitePosition &target = m_satelliteMotionTarget[i]; + SatellitePosition point = start; + point.x += (target.x - start.x) * progress; + point.y += (target.y - start.y) * progress; + point.z += (target.z - start.z) * progress; + point.healthy = target.healthy; + interpolated.push_back(point); + } + if (!interpolated.isEmpty()) m_satellites = interpolated; + update(); + + if (progress >= 1.0) m_satelliteAnimationTimer.stop(); +} + +void GlobeWidget::setObservedPrns(const QSet &prns) +{ + m_observedPrns = prns; + update(); +} + +void GlobeWidget::setReceiverLocation(double latitude, double longitude) +{ + const bool firstLocation = !m_hasReceiver; + m_hasReceiver = true; + m_receiverLatitude = latitude; + m_receiverLongitude = longitude; + if (firstLocation) { + // Keep the receiver off-centre so its local zenith is visibly directed + // away from Earth. Looking straight down the zenith made the outward + // visibility cone appear to be embedded in the globe. + m_yaw = Pi / 2.0 - longitude * Pi / 180.0 + InitialLongitudeOffset; + m_pitch = std::clamp(-latitude * Pi / 180.0 + InitialLatitudeOffset, + -1.15, 1.15); + } + update(); +} + +void GlobeWidget::clearReceiverLocation() +{ + m_hasReceiver = false; + update(); +} + +void GlobeWidget::setRinexStatus(const QString &status, bool error) +{ + m_status = status; + m_statusError = error; + update(); +} + +void GlobeWidget::setElevationMask(double degrees) +{ + m_elevationMaskDegrees = std::clamp(degrees, 0.0, 90.0); + update(); +} + +GlobeWidget::ProjectedPoint GlobeWidget::project(double x, double y, double z, double radius) const +{ + const double cy = std::cos(m_yaw); + const double sy = std::sin(m_yaw); + const double x1 = cy * x - sy * y; + const double y1 = sy * x + cy * y; + const double cp = std::cos(m_pitch); + const double sp = std::sin(m_pitch); + const double y2 = cp * y1 - sp * z; + const double z2 = sp * y1 + cp * z; + const QPointF center(width() * 0.5, height() * 0.52); + return {{center.x() + x1 * radius, center.y() - z2 * radius}, y2}; +} + +void GlobeWidget::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + + const qreal devicePixelRatio = devicePixelRatioF(); + if (m_staticLayer.isNull() || m_staticLayerSize != size() + || !qFuzzyCompare(m_staticLayerDevicePixelRatio, devicePixelRatio) + || m_staticLayerYaw != m_yaw || m_staticLayerPitch != m_pitch + || m_staticLayerZoom != m_zoom) { + rebuildStaticLayer(); + } + painter.drawPixmap(0, 0, m_staticLayer); + + if (m_hasReceiver) drawVisibilityCone(painter); + if (m_hasReceiver) drawReceiver(painter); + const int visibleCount = drawSatellites(painter); + + painter.setPen(QColor(229, 240, 249)); + QFont titleFont = painter.font(); + titleFont.setPixelSize(18); + titleFont.setBold(true); + painter.setFont(titleFont); + painter.drawText(QRect(20, 16, width() - 40, 28), Qt::AlignLeft | Qt::AlignVCenter, + QStringLiteral("GNSS YORUNGE GORUNUMU")); + QFont smallFont = painter.font(); + smallFont.setPixelSize(11); + smallFont.setBold(false); + painter.setFont(smallFont); + painter.setPen(m_statusError ? QColor(255, 177, 77) : QColor(132, 177, 204)); + painter.drawText(QRect(20, 43, width() - 40, 22), Qt::AlignLeft | Qt::AlignVCenter, m_status); + + const int legendY = height() - 25; + const int legendX = width() / 2 - 125; + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(42, 224, 137)); + painter.drawEllipse(QPointF(legendX, legendY - 3), 4, 4); + painter.setPen(QColor(101, 239, 176)); + painter.drawText(legendX + 9, legendY + 1, QStringLiteral("BULUNDU")); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(255, 79, 95)); + painter.drawEllipse(QPointF(legendX + 78, legendY - 3), 4, 4); + painter.setPen(QColor(255, 132, 143)); + painter.drawText(legendX + 87, legendY + 1, QStringLiteral("BULUNAMADI")); + painter.setPen(QColor(138, 183, 207)); + painter.drawText(legendX + 181, legendY + 1, + QStringLiteral("%1/%2 PRN · ELEV≥%3°: %4") + .arg(m_observedPrns.size()).arg(m_satellites.size()) + .arg(m_elevationMaskDegrees, 0, 'f', 1).arg(visibleCount)); + + if (!m_hasReceiver) { + painter.setPen(QColor(219, 232, 241)); + QFont waitingFont = painter.font(); + waitingFont.setPixelSize(16); + waitingFont.setBold(true); + painter.setFont(waitingFont); + painter.drawText(rect().adjusted(30, 70, -30, -50), Qt::AlignCenter, + QStringLiteral("Ilk gecerli konum bekleniyor")); + } +} + +void GlobeWidget::rebuildStaticLayer() +{ + const qreal devicePixelRatio = devicePixelRatioF(); + const QSize pixelSize( + std::max(1, qRound(width() * devicePixelRatio)), + std::max(1, qRound(height() * devicePixelRatio))); + QPixmap layer(pixelSize); + layer.setDevicePixelRatio(devicePixelRatio); + layer.fill(Qt::transparent); + QPainter painter(&layer); + painter.setRenderHint(QPainter::Antialiasing, true); + + QLinearGradient background(0, 0, width(), height()); + background.setColorAt(0.0, QColor(3, 10, 21)); + background.setColorAt(0.55, QColor(8, 24, 43)); + background.setColorAt(1.0, QColor(2, 8, 17)); + painter.fillRect(rect(), background); + drawStars(painter); + + const double radius = std::min(width(), height()) * 0.255 * m_zoom; + const QPointF center(width() * 0.5, height() * 0.52); + + QRadialGradient atmosphere(center - QPointF(radius * .25, radius * .32), radius * 1.24); + atmosphere.setColorAt(0.66, QColor(19, 113, 177, 0)); + atmosphere.setColorAt(0.82, QColor(47, 169, 235, 48)); + atmosphere.setColorAt(0.92, QColor(51, 178, 239, 20)); + atmosphere.setColorAt(1.0, QColor(35, 126, 205, 0)); + painter.setPen(Qt::NoPen); + painter.setBrush(atmosphere); + painter.drawEllipse(center, radius * 1.25, radius * 1.25); + + QRadialGradient earth(center - QPointF(radius * .36, radius * .42), radius * 1.42); + earth.setColorAt(0.0, QColor(48, 154, 200)); + earth.setColorAt(0.37, QColor(18, 91, 144)); + earth.setColorAt(0.72, QColor(8, 47, 86)); + earth.setColorAt(1.0, QColor(1, 10, 24)); + painter.setBrush(earth); + painter.setPen(QPen(QColor(89, 196, 240, 150), 1.2)); + painter.drawEllipse(center, radius, radius); + + painter.save(); + painter.setClipPath(QPainterPath()); + QPainterPath sphereClip; + sphereClip.addEllipse(center, radius, radius); + painter.setClipPath(sphereClip); + drawContinents(painter); + drawGraticule(painter); + painter.restore(); + + for (int i = 0; i < 3; ++i) drawOrbit(painter, 55.0 + i * 1.8, i * 61.0 + 12.0); + painter.end(); + + m_staticLayer = std::move(layer); + m_staticLayerSize = size(); + m_staticLayerDevicePixelRatio = devicePixelRatio; + m_staticLayerYaw = m_yaw; + m_staticLayerPitch = m_pitch; + m_staticLayerZoom = m_zoom; +} + +void GlobeWidget::drawContinents(QPainter &painter) const +{ + const double radius = std::min(width(), height()) * 0.255 * m_zoom; + const double pixelRatio = m_dragging ? 0.75 : std::min(devicePixelRatioF(), 1.5); + const int textureSize = std::clamp( + static_cast(std::ceil(radius * 2.0 * pixelRatio)), + m_dragging ? 240 : 320, m_dragging ? 480 : 900); + const QPointF center(width() * 0.5, height() * 0.52); + const QRectF target(center.x() - radius, center.y() - radius, + radius * 2.0, radius * 2.0); + if (!m_landTexture.isNull() && m_landTexture.width() == textureSize + && m_landTextureYaw == m_yaw && m_landTexturePitch == m_pitch) { + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.drawImage(target, m_landTexture); + return; + } + QImage land(textureSize, textureSize, QImage::Format_ARGB32); + land.fill(Qt::transparent); + + const double cy = std::cos(m_yaw); + const double sy = std::sin(m_yaw); + const double cp = std::cos(m_pitch); + const double sp = std::sin(m_pitch); + for (int py = 0; py < textureSize; ++py) { + QRgb *scanLine = reinterpret_cast(land.scanLine(py)); + const double screenZ = -((py + 0.5) / textureSize * 2.0 - 1.0); + for (int px = 0; px < textureSize; ++px) { + const double screenX = (px + 0.5) / textureSize * 2.0 - 1.0; + const double radiusSquared = screenX * screenX + screenZ * screenZ; + if (radiusSquared > 1.0) continue; + + const double depth = std::sqrt(1.0 - radiusSquared); + const double yawY = cp * depth + sp * screenZ; + const double worldZ = -sp * depth + cp * screenZ; + const double worldX = cy * screenX + sy * yawY; + const double worldY = -sy * screenX + cy * yawY; + const double latitude = std::asin(std::clamp(worldZ, -1.0, 1.0)) * 180.0 / Pi; + const double longitude = std::atan2(worldY, worldX) * 180.0 / Pi; + const bool onLand = maskIsLand(longitude, latitude); + const bool nearLand = maskIsLand(longitude + 0.55, latitude) + || maskIsLand(longitude - 0.55, latitude) + || maskIsLand(longitude, latitude + 0.55) + || maskIsLand(longitude, latitude - 0.55); + if (!onLand) { + if (nearLand) { + const int alpha = static_cast(65.0 * std::clamp(depth * 1.5, 0.0, 1.0)); + scanLine[px] = qRgba(33, 151, 174, alpha); + } + continue; + } + + const double light = std::clamp(0.30 + 0.70 + * (0.35 - screenX * 0.38 + screenZ * 0.48 + depth * 0.38), 0.18, 1.0); + const double lon = longitude * Pi / 180.0; + const double lat = latitude * Pi / 180.0; + const double broadRelief = 0.50 + 0.24 * std::sin(lon * 5.0 + lat * 2.2) + + 0.16 * std::sin(lon * 13.0 - lat * 7.0) + + 0.10 * std::cos(lon * 29.0 + lat * 17.0); + const double relief = std::clamp(broadRelief, 0.0, 1.0); + const double aridBelt = std::exp(-std::pow((std::abs(latitude) - 25.0) / 13.0, 2.0)); + const double moisture = std::clamp(0.62 - aridBelt * 0.48 + + 0.18 * std::sin(lon * 3.0 - lat * 4.0), 0.0, 1.0); + const double highland = smoothStep(0.67, 0.92, relief); + const double snow = smoothStep(63.0, 78.0, std::abs(latitude)) + * (0.65 + highland * 0.35); + + const double lushRed = 30.0 + relief * 25.0; + const double lushGreen = 104.0 + relief * 34.0; + const double lushBlue = 63.0 + relief * 20.0; + const double dryRed = 151.0 + relief * 24.0; + const double dryGreen = 126.0 + relief * 18.0; + const double dryBlue = 72.0 + relief * 16.0; + double red = dryRed + (lushRed - dryRed) * moisture; + double green = dryGreen + (lushGreen - dryGreen) * moisture; + double blue = dryBlue + (lushBlue - dryBlue) * moisture; + red = red * (1.0 - highland * 0.30) + 104.0 * highland * 0.30; + green = green * (1.0 - highland * 0.30) + 91.0 * highland * 0.30; + blue = blue * (1.0 - highland * 0.30) + 76.0 * highland * 0.30; + red = red * (1.0 - snow) + 224.0 * snow; + green = green * (1.0 - snow) + 231.0 * snow; + blue = blue * (1.0 - snow) + 224.0 * snow; + + const bool coast = !maskIsLand(longitude + 0.55, latitude) + || !maskIsLand(longitude - 0.55, latitude) + || !maskIsLand(longitude, latitude + 0.55) + || !maskIsLand(longitude, latitude - 0.55); + const double shade = light * (0.86 + relief * 0.14); + if (coast) { + red += 28.0; + green += 31.0; + blue += 18.0; + } + scanLine[px] = qRgba( + std::clamp(static_cast(red * shade), 0, 255), + std::clamp(static_cast(green * shade), 0, 255), + std::clamp(static_cast(blue * shade), 0, 255), 238); + } + } + + m_landTexture = land; + m_landTextureYaw = m_yaw; + m_landTexturePitch = m_pitch; + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.drawImage(target, m_landTexture); +} + +void GlobeWidget::drawStars(QPainter &painter) const +{ + painter.setPen(Qt::NoPen); + for (int i = 0; i < 95; ++i) { + const int x = (i * 7919 + 37) % std::max(1, width()); + const int y = (i * 3571 + 71) % std::max(1, height()); + const int alpha = 45 + (i * 29) % 120; + const double size = 0.7 + (i % 4) * 0.35; + painter.setBrush(QColor(183, 218, 240, alpha)); + painter.drawEllipse(QPointF(x, y), size, size); + } +} + +void GlobeWidget::drawGraticule(QPainter &painter) const +{ + const double radius = std::min(width(), height()) * 0.255 * m_zoom; + painter.setPen(QPen(QColor(100, 193, 221, 48), 0.8)); + for (int latitude = -60; latitude <= 60; latitude += 30) { + QPainterPath path; + bool started = false; + for (int longitude = -180; longitude <= 180; longitude += 3) { + const Vec3 v = globeVector(latitude, longitude); + const auto p = project(v.x, v.y, v.z, radius); + if (p.depth >= 0.0) { + if (!started) path.moveTo(p.point); else path.lineTo(p.point); + started = true; + } else started = false; + } + painter.drawPath(path); + } + for (int longitude = -150; longitude < 180; longitude += 30) { + QPainterPath path; + bool started = false; + for (int latitude = -90; latitude <= 90; latitude += 2) { + const Vec3 v = globeVector(latitude, longitude); + const auto p = project(v.x, v.y, v.z, radius); + if (p.depth >= 0.0) { + if (!started) path.moveTo(p.point); else path.lineTo(p.point); + started = true; + } else started = false; + } + painter.drawPath(path); + } +} + +void GlobeWidget::drawOrbit(QPainter &painter, double inclination, double node) const +{ + const double radius = std::min(width(), height()) * 0.255 * m_zoom * 1.47; + const double inc = inclination * Pi / 180.0; + const double asc = node * Pi / 180.0; + QPainterPath back, front; + bool backStarted = false, frontStarted = false; + for (int degree = 0; degree <= 360; degree += 2) { + const double t = degree * Pi / 180.0; + const double ox = std::cos(t); + const double oy = std::sin(t) * std::cos(inc); + const double oz = std::sin(t) * std::sin(inc); + const double x = ox * std::cos(asc) - oy * std::sin(asc); + const double y = ox * std::sin(asc) + oy * std::cos(asc); + const auto p = project(x, y, oz, radius); + QPainterPath &path = p.depth < 0.0 ? back : front; + bool &started = p.depth < 0.0 ? backStarted : frontStarted; + if (!started) path.moveTo(p.point); else path.lineTo(p.point); + started = true; + if (p.depth < 0.0) frontStarted = false; else backStarted = false; + } + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(QColor(86, 130, 160, 44), 0.8, Qt::DashLine)); + painter.drawPath(back); + painter.setPen(QPen(QColor(89, 173, 214, 90), 1.0)); + painter.drawPath(front); +} + +void GlobeWidget::drawVisibilityCone(QPainter &painter) const +{ + if (!m_hasReceiver) return; + + const double earthRadiusPixels = std::min(width(), height()) * 0.255 * m_zoom; + constexpr double physicalOrbitRatio = 4.17; + constexpr double displayOrbitRatio = 1.47; + const Vec3 normal = globeVector(m_receiverLatitude, m_receiverLongitude); + + Vec3 reference = std::abs(normal.z) < 0.85 ? Vec3{0.0, 0.0, 1.0} : Vec3{1.0, 0.0, 0.0}; + Vec3 tangentA { + reference.y * normal.z - reference.z * normal.y, + reference.z * normal.x - reference.x * normal.z, + reference.x * normal.y - reference.y * normal.x + }; + const double tangentLength = std::sqrt(tangentA.x * tangentA.x + + tangentA.y * tangentA.y + + tangentA.z * tangentA.z); + tangentA = {tangentA.x / tangentLength, tangentA.y / tangentLength, + tangentA.z / tangentLength}; + const Vec3 tangentB { + normal.y * tangentA.z - normal.z * tangentA.y, + normal.z * tangentA.x - normal.x * tangentA.z, + normal.x * tangentA.y - normal.y * tangentA.x + }; + + const double elevation = m_elevationMaskDegrees * Pi / 180.0; + const double zenith = Pi / 2.0 - elevation; + const double lineDistance = -std::cos(zenith) + + std::sqrt(physicalOrbitRatio * physicalOrbitRatio + - std::sin(zenith) * std::sin(zenith)); + const auto apex = project(normal.x, normal.y, normal.z, earthRadiusPixels * 1.015); + const auto zenithPoint = project(normal.x, normal.y, normal.z, + earthRadiusPixels * displayOrbitRatio); + + QVector ring; + constexpr int Segments = 48; + ring.reserve(Segments + 1); + for (int segment = 0; segment <= Segments; ++segment) { + const double angle = 2.0 * Pi * segment / Segments; + const Vec3 tangent { + tangentA.x * std::cos(angle) + tangentB.x * std::sin(angle), + tangentA.y * std::cos(angle) + tangentB.y * std::sin(angle), + tangentA.z * std::cos(angle) + tangentB.z * std::sin(angle) + }; + const Vec3 direction { + normal.x * std::cos(zenith) + tangent.x * std::sin(zenith), + normal.y * std::cos(zenith) + tangent.y * std::sin(zenith), + normal.z * std::cos(zenith) + tangent.z * std::sin(zenith) + }; + const Vec3 orbitPoint { + (normal.x + lineDistance * direction.x) / physicalOrbitRatio, + (normal.y + lineDistance * direction.y) / physicalOrbitRatio, + (normal.z + lineDistance * direction.z) / physicalOrbitRatio + }; + ring.push_back(project(orbitPoint.x, orbitPoint.y, orbitPoint.z, + earthRadiusPixels * displayOrbitRatio)); + } + + painter.setPen(Qt::NoPen); + for (int i = 0; i < Segments; ++i) { + const double averageDepth = (apex.depth + ring[i].depth + ring[i + 1].depth) / 3.0; + const int alpha = averageDepth >= 0.0 ? 18 : 7; + painter.setBrush(QColor(48, 207, 218, alpha)); + painter.drawPolygon(QPolygonF{apex.point, ring[i].point, ring[i + 1].point}); + } + + QPainterPath boundary; + boundary.moveTo(ring.first().point); + for (int i = 1; i < ring.size(); ++i) boundary.lineTo(ring[i].point); + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(QColor(76, 222, 228, 105), 1.0, Qt::DashLine)); + painter.drawPath(boundary); + for (int i = 0; i < Segments; i += 8) { + painter.drawLine(apex.point, ring[i].point); + } + painter.setPen(QPen(QColor(119, 239, 241, 165), 1.4)); + painter.drawLine(apex.point, zenithPoint.point); + + const QPointF axis = zenithPoint.point - apex.point; + const double axisLength = std::hypot(axis.x(), axis.y()); + if (axisLength > 1.0) { + const QPointF unit = axis / axisLength; + const QPointF side(-unit.y(), unit.x()); + const QPointF arrowTip = zenithPoint.point; + painter.drawLine(arrowTip, arrowTip - unit * 8.0 + side * 4.0); + painter.drawLine(arrowTip, arrowTip - unit * 8.0 - side * 4.0); + } + if (apex.depth >= -0.05) { + painter.setPen(QColor(119, 239, 241, 190)); + painter.drawText(apex.point + QPointF(12, 18), + QStringLiteral("GORUS KONISI %1°") + .arg(m_elevationMaskDegrees, 0, 'f', 1)); + } +} + +void GlobeWidget::drawReceiver(QPainter &painter) const +{ + const double radius = std::min(width(), height()) * 0.255 * m_zoom; + const Vec3 v = globeVector(m_receiverLatitude, m_receiverLongitude); + const auto p = project(v.x, v.y, v.z, radius * 1.015); + if (p.depth < -0.05) return; + painter.setPen(QPen(QColor(255, 255, 255, 210), 1.5)); + painter.setBrush(QColor(255, 196, 61)); + painter.drawEllipse(p.point, 5.5, 5.5); + painter.setPen(QPen(QColor(255, 196, 61, 110), 1.2)); + painter.setBrush(Qt::NoBrush); + painter.drawEllipse(p.point, 11, 11); + painter.setPen(QColor(255, 224, 147)); + painter.drawText(p.point + QPointF(10, -8), QStringLiteral("ALICI")); +} + +int GlobeWidget::drawSatellites(QPainter &painter) const +{ + if (!m_hasReceiver) return 0; + const double orbitRadius = std::min(width(), height()) * 0.255 * m_zoom * 1.47; + struct Item { + SatellitePosition satellite; + ProjectedPoint projected; + double elevation = -90.0; + }; + QVector items; + items.reserve(m_satellites.size()); + for (const auto &satellite : m_satellites) { + const double length = std::sqrt(satellite.x * satellite.x + satellite.y * satellite.y + + satellite.z * satellite.z); + if (length <= 0.0) continue; + items.push_back({satellite, project(satellite.x / length, satellite.y / length, + satellite.z / length, orbitRadius), + satelliteElevation(satellite)}); + } + std::sort(items.begin(), items.end(), [](const Item &a, const Item &b) { + return a.projected.depth < b.projected.depth; + }); + const double earthRadius = std::min(width(), height()) * 0.255 * m_zoom; + const Vec3 receiver = globeVector(m_receiverLatitude, m_receiverLongitude); + const auto receiverPoint = project(receiver.x, receiver.y, receiver.z, + earthRadius * 1.02); + int visibleCount = 0; + for (const Item &item : items) { + const bool found = m_observedPrns.contains(item.satellite.prn); + const bool inElevationMask = item.elevation >= m_elevationMaskDegrees; + if (inElevationMask) ++visibleCount; + QColor color = found ? QColor(42, 224, 137) : QColor(255, 79, 95); + if (item.projected.depth < 0.0) color.setAlpha(75); + const double markerRadius = found ? 6.5 : 5.0; + if (inElevationMask) { + QColor sightLine(70, 224, 231, item.projected.depth < 0.0 ? 25 : 80); + painter.setPen(QPen(sightLine, 0.8, Qt::DashLine)); + painter.drawLine(receiverPoint.point, item.projected.point); + painter.setPen(QPen(QColor(90, 236, 239, sightLine.alpha()), 1.2)); + painter.setBrush(Qt::NoBrush); + painter.drawEllipse(item.projected.point, markerRadius + 3.0, markerRadius + 3.0); + } + painter.setPen(QPen(QColor(235, 248, 255, color.alpha()), 1.0)); + painter.setBrush(color); + painter.drawEllipse(item.projected.point, markerRadius, markerRadius); + if (item.projected.depth >= -0.05) { + painter.setPen(color.lighter(135)); + painter.drawText(item.projected.point + QPointF(7, -6), item.satellite.prn); + } + } + return visibleCount; +} + +double GlobeWidget::satelliteElevation(const SatellitePosition &satellite) const +{ + if (!m_hasReceiver) return -90.0; + constexpr double EarthRadiusMeters = 6378137.0; + const Vec3 receiverUnit = globeVector(m_receiverLatitude, m_receiverLongitude); + const Vec3 lineOfSight { + satellite.x - receiverUnit.x * EarthRadiusMeters, + satellite.y - receiverUnit.y * EarthRadiusMeters, + satellite.z - receiverUnit.z * EarthRadiusMeters + }; + const double length = std::sqrt(lineOfSight.x * lineOfSight.x + + lineOfSight.y * lineOfSight.y + + lineOfSight.z * lineOfSight.z); + if (length <= 0.0) return -90.0; + const double sineElevation = (lineOfSight.x * receiverUnit.x + + lineOfSight.y * receiverUnit.y + + lineOfSight.z * receiverUnit.z) / length; + return std::asin(std::clamp(sineElevation, -1.0, 1.0)) * 180.0 / Pi; +} + +void GlobeWidget::mousePressEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + m_dragging = true; + m_lastMousePosition = event->pos(); + setCursor(Qt::ClosedHandCursor); + } +} + +void GlobeWidget::mouseMoveEvent(QMouseEvent *event) +{ + if (!m_dragging) return; + const QPoint delta = event->pos() - m_lastMousePosition; + m_lastMousePosition = event->pos(); + m_yaw += delta.x() * 0.008; + m_pitch = std::clamp(m_pitch - delta.y() * 0.008, -1.45, 1.45); + update(); +} + +void GlobeWidget::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + m_dragging = false; + m_staticLayer = QPixmap(); + setCursor(Qt::OpenHandCursor); + update(); + } +} + +void GlobeWidget::wheelEvent(QWheelEvent *event) +{ + m_zoom = std::clamp(m_zoom + event->angleDelta().y() / 1200.0, 0.72, 1.28); + update(); + event->accept(); +} diff --git a/linux/src/GlobeWidget.h b/linux/src/GlobeWidget.h new file mode 100644 index 0000000..0164b18 --- /dev/null +++ b/linux/src/GlobeWidget.h @@ -0,0 +1,84 @@ +#pragma once + +#include "SatelliteData.h" + +#include +#include +#include +#include +#include +#include +#include + +class GlobeWidget : public QWidget { + Q_OBJECT + +public: + explicit GlobeWidget(QWidget *parent = nullptr); + + void setSatellites(const QVector &satellites); + void setSatelliteMotion(const QVector ¤t, + const QVector &next, + int durationMs); + void setObservedPrns(const QSet &prns); + void setReceiverLocation(double latitude, double longitude); + void clearReceiverLocation(); + void setRinexStatus(const QString &status, bool error); + [[nodiscard]] QSet observedPrns() const { return m_observedPrns; } + +public slots: + void setElevationMask(double degrees); + +protected: + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + +private: + struct ProjectedPoint { + QPointF point; + double depth = 0.0; + }; + + ProjectedPoint project(double x, double y, double z, double radius) const; + void drawStars(QPainter &painter) const; + void drawOrbit(QPainter &painter, double inclination, double node) const; + void drawContinents(QPainter &painter) const; + void drawVisibilityCone(QPainter &painter) const; + void drawGraticule(QPainter &painter) const; + void drawReceiver(QPainter &painter) const; + int drawSatellites(QPainter &painter) const; + double satelliteElevation(const SatellitePosition &satellite) const; + void updateSatelliteAnimation(); + void rebuildStaticLayer(); + + QVector m_satellites; + QVector m_satelliteMotionStart; + QVector m_satelliteMotionTarget; + QElapsedTimer m_satelliteMotionClock; + QTimer m_satelliteAnimationTimer; + int m_satelliteMotionDurationMs = 0; + mutable QImage m_landTexture; + mutable double m_landTextureYaw = 0.0; + mutable double m_landTexturePitch = 0.0; + QPixmap m_staticLayer; + QSize m_staticLayerSize; + qreal m_staticLayerDevicePixelRatio = 0.0; + double m_staticLayerYaw = 0.0; + double m_staticLayerPitch = 0.0; + double m_staticLayerZoom = 0.0; + QSet m_observedPrns; + QString m_status = QStringLiteral("Konum bekleniyor"); + bool m_statusError = false; + bool m_hasReceiver = false; + double m_receiverLatitude = 0.0; + double m_receiverLongitude = 0.0; + double m_yaw = -0.55; + double m_pitch = -0.28; + double m_zoom = 1.0; + double m_elevationMaskDegrees = 10.0; + bool m_dragging = false; + QPoint m_lastMousePosition; +}; diff --git a/src/LocationParser.cpp b/linux/src/LocationParser.cpp similarity index 100% rename from src/LocationParser.cpp rename to linux/src/LocationParser.cpp diff --git a/src/LocationParser.h b/linux/src/LocationParser.h similarity index 100% rename from src/LocationParser.h rename to linux/src/LocationParser.h diff --git a/linux/src/MainWindow.cpp b/linux/src/MainWindow.cpp new file mode 100644 index 0000000..d197d49 --- /dev/null +++ b/linux/src/MainWindow.cpp @@ -0,0 +1,1642 @@ +#include "MainWindow.h" + +#include "LocationParser.h" +#include "GlobeWidget.h" +#include "MapWidget.h" +#include "RinexNavigation.h" +#include "SerialReader.h" +#include "UartReplay.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +constexpr int LocationTimeoutMs = 10000; +constexpr int UiRefreshIntervalMs = 100; +constexpr int MaxPendingLogLines = 500; +constexpr double Pi = 3.14159265358979323846; +constexpr double EarthRadiusMeters = 6371008.8; + +struct LocationAnalysis { + bool valid = false; + double averageLat = 0.0; + double averageLon = 0.0; + double lastDeviationMeters = 0.0; + double meanDeviationMeters = 0.0; + double rmsScatterMeters = 0.0; + double maxDeviationMeters = 0.0; +}; + +double degreesToRadians(double degrees) +{ + return degrees * Pi / 180.0; +} + +double radiansToDegrees(double radians) +{ + return radians * 180.0 / Pi; +} + +double distanceMeters(double lat1, double lon1, double lat2, double lon2) +{ + const double phi1 = degreesToRadians(lat1); + const double phi2 = degreesToRadians(lat2); + const double dPhi = degreesToRadians(lat2 - lat1); + const double dLambda = degreesToRadians(lon2 - lon1); + const double sinHalfPhi = std::sin(dPhi / 2.0); + const double sinHalfLambda = std::sin(dLambda / 2.0); + const double a = sinHalfPhi * sinHalfPhi + + std::cos(phi1) * std::cos(phi2) * sinHalfLambda * sinHalfLambda; + const double c = 2.0 * std::atan2(std::sqrt(a), std::sqrt(std::max(0.0, 1.0 - a))); + return EarthRadiusMeters * c; +} + +std::optional parseCoordinateText(QString text, double minimum, double maximum) +{ + text = text.trimmed(); + text.replace(',', '.'); + bool ok = false; + const double value = text.toDouble(&ok); + if (!ok || !std::isfinite(value) || value < minimum || value > maximum) { + return std::nullopt; + } + return value; +} + +double calculateCep50(const QVector &points, + double referenceLatitude, double referenceLongitude) +{ + QVector radialErrors; + radialErrors.reserve(points.size()); + for (const TelemetryPoint &point : points) { + radialErrors.push_back(distanceMeters(referenceLatitude, referenceLongitude, + point.latitude, point.longitude)); + } + std::sort(radialErrors.begin(), radialErrors.end()); + + const int middle = radialErrors.size() / 2; + if (radialErrors.size() % 2 == 1) { + return radialErrors[middle]; + } + return (radialErrors[middle - 1] + radialErrors[middle]) / 2.0; +} + +LocationAnalysis analyzeLocations(const QVector &points) +{ + LocationAnalysis analysis; + if (points.isEmpty()) { + return analysis; + } + + double x = 0.0; + double y = 0.0; + double z = 0.0; + for (const TelemetryPoint &point : points) { + const double lat = degreesToRadians(point.latitude); + const double lon = degreesToRadians(point.longitude); + const double cosLat = std::cos(lat); + x += cosLat * std::cos(lon); + y += cosLat * std::sin(lon); + z += std::sin(lat); + } + + x /= points.size(); + y /= points.size(); + z /= points.size(); + + const double horizontal = std::sqrt(x * x + y * y); + analysis.averageLat = radiansToDegrees(std::atan2(z, horizontal)); + analysis.averageLon = radiansToDegrees(std::atan2(y, x)); + analysis.valid = true; + + double distanceSum = 0.0; + double squaredDistanceSum = 0.0; + for (const TelemetryPoint &point : points) { + const double distance = distanceMeters(point.latitude, point.longitude, + analysis.averageLat, analysis.averageLon); + distanceSum += distance; + squaredDistanceSum += distance * distance; + analysis.maxDeviationMeters = std::max(analysis.maxDeviationMeters, distance); + } + + analysis.meanDeviationMeters = distanceSum / points.size(); + analysis.rmsScatterMeters = std::sqrt(squaredDistanceSum / points.size()); + const TelemetryPoint &last = points.last(); + analysis.lastDeviationMeters = distanceMeters(last.latitude, last.longitude, + analysis.averageLat, analysis.averageLon); + return analysis; +} + +QVector preferredAnalysisPoints(const QVector &points) +{ + QVector corrected; + for (const TelemetryPoint &point : points) { + if (point.source == LocationSource::ImuCorrected) { + corrected.push_back(point); + } + } + return corrected.isEmpty() ? points : corrected; +} + +QString metersText(double meters) +{ + if (!std::isfinite(meters)) { + return QStringLiteral("-"); + } + + const int precision = meters < 10.0 ? 2 : (meters < 100.0 ? 1 : 0); + return QStringLiteral("%1 m").arg(meters, 0, 'f', precision); +} + +QString optionalDoubleText(const std::optional &value, int precision) +{ + if (!value.has_value()) { + return QStringLiteral("-"); + } + return QString::number(*value, 'f', precision); +} + +QString preciseCoordinateText(double value) +{ + return QString::number(value, 'f', 15); +} + +QString coordinateText(double value, const QString &sourceText) +{ + return sourceText.isEmpty() ? preciseCoordinateText(value) : sourceText; +} + +QString optionalIntText(const std::optional &value) +{ + if (!value.has_value()) { + return QStringLiteral("-"); + } + return QString::number(*value); +} + +QString optionalDopText(const std::optional &value) +{ + if (!value.has_value() || !std::isfinite(*value)) { + return QStringLiteral("-"); + } + return QString::number(*value, 'f', 3); +} + +QStringList detectedSerialPorts() +{ + const QDir deviceDirectory(QStringLiteral("/dev")); + QStringList patterns; +#ifdef Q_OS_MACOS + // macOS exposes USB UARTs as paired callout (cu.*) and terminal (tty.*) + // devices. Prefer cu.* because it does not wait for carrier detect. + patterns = { + QStringLiteral("cu.usb*"), QStringLiteral("cu.wchusbserial*"), + QStringLiteral("cu.SLAB_USBtoUART*"), QStringLiteral("cu.*USB*"), + QStringLiteral("tty.usb*"), QStringLiteral("tty.wchusbserial*"), + QStringLiteral("tty.SLAB_USBtoUART*"), QStringLiteral("tty.*USB*")}; +#else + patterns = {QStringLiteral("ttyUSB*"), QStringLiteral("ttyACM*")}; +#endif + + QStringList ports; + for (const QString &pattern : patterns) { + const QStringList names = deviceDirectory.entryList( + {pattern}, QDir::System | QDir::Files, QDir::Name); + for (const QString &name : names) { + const QString path = QStringLiteral("/dev/") + name; + if (!ports.contains(path)) { + ports.push_back(path); + } + } + } + return ports; +} + +QString fallbackSerialPort() +{ +#ifdef Q_OS_MACOS + return QStringLiteral("/dev/cu.usbserial"); +#else + return QStringLiteral("/dev/ttyUSB2"); +#endif +} + +void mergeDopValues(DopValues &target, const DopValues &source) +{ + if (source.gdop.has_value()) { + target.gdop = source.gdop; + } + if (source.pdop.has_value()) { + target.pdop = source.pdop; + } + if (source.hdop.has_value()) { + target.hdop = source.hdop; + } + if (source.vdop.has_value()) { + target.vdop = source.vdop; + } + if (source.tdop.has_value()) { + target.tdop = source.tdop; + } +} + +QString satelliteQualityText(const std::optional &satelliteCount) +{ + if (!satelliteCount.has_value()) { + return QStringLiteral("Uydu bekleniyor"); + } + if (*satelliteCount >= 5) { + return QStringLiteral("Cozum icin yeterli"); + } + if (*satelliteCount >= 4) { + return QStringLiteral("Minimum navigasyon seviyesi"); + } + return QStringLiteral("Konum cozumu icin yetersiz"); +} + +} // namespace + +class TelemetryTableModel final : public QAbstractTableModel { +public: + explicit TelemetryTableModel(QObject *parent = nullptr) + : QAbstractTableModel(parent) + { + } + + void setPoints(const QVector &points) + { + beginResetModel(); + m_points = points; + endResetModel(); + } + + int rowCount(const QModelIndex &parent = QModelIndex()) const override + { + return parent.isValid() ? 0 : m_points.size(); + } + + int columnCount(const QModelIndex &parent = QModelIndex()) const override + { + return parent.isValid() ? 0 : 8; + } + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override + { + if (!index.isValid() || index.row() < 0 || index.row() >= m_points.size()) { + return {}; + } + if (role == Qt::TextAlignmentRole) { + return Qt::AlignCenter; + } + if (role != Qt::DisplayRole) { + return {}; + } + + const TelemetryPoint &point = m_points[index.row()]; + switch (index.column()) { + case 0: + return point.receivedAt.toLocalTime().toString(QStringLiteral("HH:mm:ss.zzz")); + case 1: + if (point.source == LocationSource::ImuCorrected + && point.fusionMode == FusionMode::DeadReckoning) { + return QStringLiteral("IMU corrected (DR)"); + } + if (point.source == LocationSource::ImuCorrected + && point.fusionMode == FusionMode::GpsAided) { + return QStringLiteral("IMU corrected (GPS)"); + } + return locationSourceText(point.source); + case 2: + return coordinateText(point.latitude, point.latitudeText); + case 3: + return coordinateText(point.longitude, point.longitudeText); + case 4: + return optionalDoubleText(point.hheight, 2); + case 5: + return optionalIntText(point.satellites); + case 6: + return optionalDopText(point.dop.pdop); + case 7: + return point.gpsAgeMs.has_value() + ? QStringLiteral("%1 ms").arg(*point.gpsAgeMs) + : QStringLiteral("-"); + default: + return {}; + } + } + + QVariant headerData(int section, Qt::Orientation orientation, + int role = Qt::DisplayRole) const override + { + if (role != Qt::DisplayRole || orientation != Qt::Horizontal) { + return {}; + } + static const QStringList headers = { + QStringLiteral("Saat"), QStringLiteral("Kaynak"), QStringLiteral("Lat"), QStringLiteral("Long"), + QStringLiteral("HHeight"), QStringLiteral("Uydu"), QStringLiteral("PDOP"), + QStringLiteral("GPS yas")}; + return section >= 0 && section < headers.size() ? headers[section] : QVariant(); + } + +private: + QVector m_points; +}; + +MainWindow::MainWindow(QWidget *parent) + : QMainWindow(parent) + , m_serial(new SerialReader(this)) + , m_replay(new UartReplay(this)) + , m_rinex(new RinexNavigation(this)) +{ + buildUi(); + + m_locationTimeoutTimer = new QTimer(this); + m_locationTimeoutTimer->setSingleShot(true); + m_locationTimeoutTimer->setInterval(LocationTimeoutMs); + + m_uiRefreshTimer = new QTimer(this); + m_uiRefreshTimer->setSingleShot(true); + m_uiRefreshTimer->setInterval(UiRefreshIntervalMs); + + connect(m_serial, &SerialReader::lineReceived, this, &MainWindow::handleSerialLine); + connect(m_serial, &SerialReader::statusChanged, this, &MainWindow::handleSerialStatus); + connect(m_serial, &SerialReader::errorOccurred, this, &MainWindow::handleSerialError); + connect(m_replay, &UartReplay::lineReady, this, &MainWindow::handleSerialLine); + connect(m_replay, &UartReplay::replayStarted, this, + [this](int lineCount, int locationCount) { + const QString message = QStringLiteral( + "UART replay basladi: %1 konum, %2 satir") + .arg(locationCount).arg(lineCount); + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[STATUS] ") + message); + updateConnectionControls(); + }); + connect(m_replay, &UartReplay::progressChanged, this, + [this](int processed, int total) { + if (processed == total || processed % 100 == 0) { + m_statusLabel->setText(QStringLiteral("UART replay: %1 / %2 satir") + .arg(processed).arg(total)); + } + }); + connect(m_replay, &UartReplay::replayFinished, this, [this] { + commitPendingTelemetry(); + flushUiUpdates(); + m_map->fitToPoints(); + const QString message = QStringLiteral("UART replay tamamlandi: %1 konum") + .arg(m_points.size()); + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[STATUS] ") + message); + statusBar()->showMessage(message, 8000); + updateConnectionControls(); + }); + connect(m_replay, &UartReplay::errorOccurred, this, [this](const QString &message) { + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[ERROR] ") + message); + statusBar()->showMessage(message, 8000); + updateConnectionControls(); + }); + connect(m_locationTimeoutTimer, &QTimer::timeout, this, &MainWindow::handleLocationTimeout); + connect(m_uiRefreshTimer, &QTimer::timeout, this, &MainWindow::flushUiUpdates); + connect(m_rinex, &RinexNavigation::satelliteMotionUpdated, + m_globe, &GlobeWidget::setSatelliteMotion); + connect(m_rinex, &RinexNavigation::statusChanged, this, + [this](const QString &status, bool error) { + m_globe->setRinexStatus(status, error); + statusBar()->showMessage(status, error ? 8000 : 4000); + }); + connect(m_rinex, &RinexNavigation::loadingChanged, this, [this](bool loading) { + m_refreshRinexButton->setEnabled(!loading); + m_refreshRinexButton->setText( + loading ? QStringLiteral("RINEX indiriliyor...") + : QStringLiteral("RINEX Guncelle")); + }); + connect(m_refreshRinexButton, &QPushButton::clicked, + m_rinex, &RinexNavigation::refresh); + m_rinex->refresh(); + auto *rinexRefreshTimer = new QTimer(this); + rinexRefreshTimer->setInterval(15 * 60 * 1000); + connect(rinexRefreshTimer, &QTimer::timeout, m_rinex, &RinexNavigation::refresh); + rinexRefreshTimer->start(); + + resize(1480, 900); + setWindowTitle(QStringLiteral("Canli Konum Haritasi")); +} + +void MainWindow::closeEvent(QCloseEvent *event) +{ + m_replay->stop(); + stopUartLogging(); + QMainWindow::closeEvent(event); +} + +void MainWindow::toggleSerial() +{ + if (m_replay->isRunning()) { + stopUartReplay(); + } + if (m_serial->isOpen()) { + m_serial->closePort(); + stopUartLogging(); + if (m_locationTimeoutTimer != nullptr) { + m_locationTimeoutTimer->stop(); + } + updateConnectionControls(); + return; + } + + const QString port = m_portCombo->currentText().trimmed(); + const int baudRate = selectedBaudRate(); + if (m_serial->openPort(port, baudRate)) { + startUartLogging(port, baudRate); + updateConnectionControls(); + } +} + +bool MainWindow::startUartReplay(const QString &filePath, int gpsIntervalMs, + int sensorIntervalMs) +{ + if (m_serial->isOpen()) { + m_serial->closePort(); + stopUartLogging(); + } + m_replay->stop(); + resetTelemetryState(); + + if (!m_replay->start(filePath, gpsIntervalMs, sensorIntervalMs)) { + updateConnectionControls(); + return false; + } + + m_maxPointsSpin->setValue(std::clamp(m_replay->locationCount(), 1, 10000)); + statusBar()->showMessage( + QStringLiteral("UART replay: GPS %1 ms, sensor/fused %2 ms") + .arg(gpsIntervalMs).arg(sensorIntervalMs), 8000); + updateConnectionControls(); + return true; +} + +void MainWindow::stopUartReplay() +{ + if (!m_replay->isRunning()) { + return; + } + m_replay->stop(); + commitPendingTelemetry(); + flushUiUpdates(); + const QString message = QStringLiteral("UART replay durduruldu: %1 konum") + .arg(m_points.size()); + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[STATUS] ") + message); + statusBar()->showMessage(message, 5000); + updateConnectionControls(); +} + +bool MainWindow::startUartLogging(const QString &port, int baudRate) +{ + stopUartLogging(); + + QString logDirectory = QString::fromLocal8Bit(qgetenv("LOCATION_PLOTTER_UART_LOG_DIR")); + if (logDirectory.isEmpty()) { + logDirectory = QDir(QStringLiteral(LOCATION_PLOTTER_SOURCE_DIR)) + .filePath(QStringLiteral("uart_logs")); + } else if (QDir::isRelativePath(logDirectory)) { + logDirectory = QDir::current().absoluteFilePath(logDirectory); + } + + if (!QDir().mkpath(logDirectory)) { + const QString message = QStringLiteral("UART log klasoru olusturulamadi: %1") + .arg(logDirectory); + m_log->appendPlainText(QStringLiteral("[ERROR] ") + message); + statusBar()->showMessage(message, 8000); + return false; + } + + const QString fileName = QStringLiteral("uart_%1.log").arg( + QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_HHmmss_zzz"))); + m_uartLogPath = QDir(logDirectory).filePath(fileName); + m_uartLogFile = new QFile(m_uartLogPath, this); + if (!m_uartLogFile->open(QIODevice::WriteOnly | QIODevice::Text)) { + const QString message = QStringLiteral("UART log dosyasi acilamadi: %1") + .arg(m_uartLogFile->errorString()); + m_log->appendPlainText(QStringLiteral("[ERROR] ") + message); + statusBar()->showMessage(message, 8000); + delete m_uartLogFile; + m_uartLogFile = nullptr; + m_uartLogPath.clear(); + return false; + } + + const QString header = QStringLiteral("[%1] [SESSION START] port=%2 baud=%3\n") + .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")), + port) + .arg(baudRate); + m_uartLogFile->write(header.toUtf8()); + m_uartLogFile->flush(); + statusBar()->showMessage(QStringLiteral("UART log kaydi: %1").arg(m_uartLogPath), 8000); + return true; +} + +void MainWindow::stopUartLogging() +{ + if (m_uartLogFile == nullptr) { + return; + } + if (m_uartLogFile->isOpen()) { + const QString footer = QStringLiteral("[%1] [SESSION END]\n").arg( + QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz"))); + m_uartLogFile->write(footer.toUtf8()); + m_uartLogFile->flush(); + m_uartLogFile->close(); + } + delete m_uartLogFile; + m_uartLogFile = nullptr; + m_uartLogPath.clear(); +} + +void MainWindow::appendUartLogLine(const QString &line) +{ + if (m_uartLogFile == nullptr || !m_uartLogFile->isOpen()) { + return; + } + QString cleanLine = line; + cleanLine.replace(QLatin1Char('\r'), QStringLiteral("\\r")); + cleanLine.replace(QLatin1Char('\n'), QStringLiteral("\\n")); + const QString entry = QStringLiteral("[%1] %2\n") + .arg(QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss.zzz")), + cleanLine); + m_uartLogFile->write(entry.toUtf8()); + m_uartLogFile->flush(); +} + +void MainWindow::clearPoints() +{ + if (m_locationTimeoutTimer != nullptr) { + m_locationTimeoutTimer->stop(); + } + resetTelemetryState(); +} + +bool MainWindow::loadTelemetryLog(const QString &filePath) +{ + m_replay->stop(); + updateConnectionControls(); + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + const QString message = QStringLiteral("Log acilamadi: %1").arg(file.errorString()); + m_statusLabel->setText(message); + statusBar()->showMessage(message, 8000); + return false; + } + const QList lines = file.readAll().split('\n'); + resetTelemetryState(); + const int replayCapacity = static_cast(std::min(10000, lines.size())); + m_maxPointsSpin->setValue(std::max(10, replayCapacity)); + int parsedLocations = 0; + for (const QByteArray &rawLine : lines) { + const QString line = QString::fromUtf8(rawLine).trimmed(); + if (line.isEmpty() || line.startsWith(QLatin1Char('#'))) { + continue; + } + const auto telemetry = LocationParser::parseLine(line); + if (telemetry.has_value() && telemetry->hasLocation()) { + ++parsedLocations; + } + handleSerialLine(line); + } + flushUiUpdates(); + m_map->fitToPoints(); + const QString message = QStringLiteral("Replay yuklendi: %1 konum - %2") + .arg(parsedLocations).arg(filePath); + m_statusLabel->setText(message); + statusBar()->showMessage(message, 8000); + return parsedLocations > 0; +} + +void MainWindow::openTelemetryLog() +{ + const QString filePath = QFileDialog::getOpenFileName( + this, QStringLiteral("Sentetik telemetri logunu ac"), QString(), + QStringLiteral("Telemetry logs (*.log *.txt);;All files (*)")); + if (!filePath.isEmpty()) { + loadTelemetryLog(filePath); + } +} + +void MainWindow::openUartReplay() +{ + if (m_replay->isRunning()) { + stopUartReplay(); + return; + } + + QSettings settings; + QDialog dialog(this); + dialog.setWindowTitle(QStringLiteral("UART Log Replay")); + dialog.setMinimumWidth(620); + + auto *layout = new QVBoxLayout(&dialog); + auto *form = new QFormLayout(); + auto *fileRow = new QWidget(&dialog); + auto *fileLayout = new QHBoxLayout(fileRow); + fileLayout->setContentsMargins(0, 0, 0, 0); + auto *pathEdit = new QLineEdit(fileRow); + pathEdit->setObjectName(QStringLiteral("uartReplayPathInput")); + pathEdit->setText(settings.value(QStringLiteral("replay/file")).toString()); + pathEdit->setPlaceholderText(QStringLiteral("UART .log veya .txt dosyasi")); + auto *browseButton = new QPushButton(QStringLiteral("Sec..."), fileRow); + fileLayout->addWidget(pathEdit, 1); + fileLayout->addWidget(browseButton); + form->addRow(QStringLiteral("Kayitli UART verisi"), fileRow); + + auto *gpsInterval = new QSpinBox(&dialog); + gpsInterval->setObjectName(QStringLiteral("uartReplayGpsIntervalInput")); + gpsInterval->setRange(1, 60000); + gpsInterval->setSuffix(QStringLiteral(" ms")); + gpsInterval->setValue(settings.value(QStringLiteral("replay/gpsIntervalMs"), 200).toInt()); + form->addRow(QStringLiteral("Raw GPS guncelleme"), gpsInterval); + + auto *sensorInterval = new QSpinBox(&dialog); + sensorInterval->setObjectName(QStringLiteral("uartReplaySensorIntervalInput")); + sensorInterval->setRange(1, 60000); + sensorInterval->setSuffix(QStringLiteral(" ms")); + sensorInterval->setValue(settings.value( + QStringLiteral("replay/sensorIntervalMs"), 50).toInt()); + form->addRow(QStringLiteral("Sensor / fused guncelleme"), sensorInterval); + layout->addLayout(form); + + auto *hint = new QLabel(QStringLiteral( + "Log zaman damgasi gerektirmez. source=gps kayitlari GPS hizinda; " + "source=imu_corrected ve [FUSION] kayitlari sensor hizinda yeniden uretilir."), + &dialog); + hint->setWordWrap(true); + layout->addWidget(hint); + + auto *buttons = new QDialogButtonBox( + QDialogButtonBox::Cancel | QDialogButtonBox::Ok, &dialog); + buttons->button(QDialogButtonBox::Ok)->setText(QStringLiteral("Replay Baslat")); + layout->addWidget(buttons); + + connect(browseButton, &QPushButton::clicked, &dialog, [this, pathEdit] { + QString initialPath = pathEdit->text().trimmed(); + if (initialPath.isEmpty()) { + initialPath = QDir(QStringLiteral(LOCATION_PLOTTER_SOURCE_DIR)) + .filePath(QStringLiteral("uart_logs")); + } + const QString filePath = QFileDialog::getOpenFileName( + this, QStringLiteral("UART replay logunu sec"), initialPath, + QStringLiteral("UART logs (*.log *.txt);;All files (*)")); + if (!filePath.isEmpty()) { + pathEdit->setText(filePath); + } + }); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + connect(buttons, &QDialogButtonBox::accepted, &dialog, [&dialog, pathEdit] { + const QFileInfo selectedFile(pathEdit->text().trimmed()); + if (!selectedFile.exists() || !selectedFile.isFile()) { + QMessageBox::warning(&dialog, QStringLiteral("UART Replay"), + QStringLiteral("Gecerli bir UART log dosyasi secin.")); + return; + } + dialog.accept(); + }); + + if (dialog.exec() != QDialog::Accepted) { + return; + } + + const QString filePath = QFileInfo(pathEdit->text().trimmed()).absoluteFilePath(); + settings.setValue(QStringLiteral("replay/file"), filePath); + settings.setValue(QStringLiteral("replay/gpsIntervalMs"), gpsInterval->value()); + settings.setValue(QStringLiteral("replay/sensorIntervalMs"), sensorInterval->value()); + startUartReplay(filePath, gpsInterval->value(), sensorInterval->value()); +} + +void MainWindow::refreshSerialPorts() +{ + const QString previousPort = m_portCombo->currentText().trimmed(); + const QString configuredPort = qEnvironmentVariable("LOCATION_PLOTTER_PORT").trimmed(); + const QStringList detectedPorts = detectedSerialPorts(); + + m_portCombo->clear(); + for (const QString &port : detectedPorts) { + m_portCombo->addItem(port); + } + + QString selectedPort; + if (!configuredPort.isEmpty()) { + selectedPort = configuredPort; + } else if (!previousPort.isEmpty() && detectedPorts.contains(previousPort)) { + selectedPort = previousPort; + } else if (!detectedPorts.isEmpty()) { + selectedPort = detectedPorts.first(); + } else { + selectedPort = previousPort.isEmpty() ? fallbackSerialPort() : previousPort; + } + + if (m_portCombo->findText(selectedPort) < 0) { + m_portCombo->addItem(selectedPort); + } + m_portCombo->setCurrentText(selectedPort); +} + +void MainWindow::handleSerialLine(const QString &line) +{ + appendUartLogLine(line); + static const QRegularExpression solveStartRegex( + QStringLiteral(R"((?:^|\[NAV\]\s*)nav\s+solve\s+(?:start|period)\b)"), + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression trackingSnapshotRegex( + QStringLiteral( + R"(\btracked\s+satellites\s*=\s*\d+\s+(?:prn[_ -]?indices|prns?|svns?)\s*=\s*\[[^\]]*\])"), + QRegularExpression::CaseInsensitiveOption); + const bool solveStarted = solveStartRegex.match(line.trimmed()).hasMatch(); + const bool trackingSnapshot = trackingSnapshotRegex.match(line).hasMatch(); + if (solveStarted) { + m_observedPrns.clear(); + } + const QSet linePrns = LocationParser::parseGpsPrns(line); + if (trackingSnapshot) { + // This line is a complete point-in-time channel snapshot. Replacing + // prevents satellites from older solves remaining green indefinitely. + m_observedPrns = linePrns; + } else if (!linePrns.isEmpty()) { + m_observedPrns.unite(linePrns); + } + if (solveStarted || trackingSnapshot || !linePrns.isEmpty()) { + m_globe->setObservedPrns(m_observedPrns); + } + + const std::optional telemetry = LocationParser::parseLine(line); + const bool solveFinished = line.trimmed().startsWith( + QStringLiteral("NAV solve done"), Qt::CaseInsensitive); + appendLogLine(line, telemetry.has_value() || solveFinished); + + if (!telemetry.has_value()) { + if (solveFinished && m_pendingTelemetry.hasLocation()) { + commitPendingTelemetry(); + } + return; + } + + if (telemetry->satellites.has_value()) { + m_lastSatelliteCount = telemetry->satellites; + } + if (telemetry->dop.hasAnyValue()) { + mergeDopValues(m_lastDop, telemetry->dop); + } + + if (telemetry->hasLocation()) { + m_pendingTelemetry = {}; + addTelemetryPoint(*telemetry); + return; + } + + const bool hasLocationPart = telemetry->latitude.has_value() || telemetry->longitude.has_value() + || telemetry->hheight.has_value(); + if (hasLocationPart) { + mergePendingTelemetry(*telemetry); + if (m_pendingTelemetry.hasLocation() && telemetry->hheight.has_value()) { + commitPendingTelemetry(); + } else { + scheduleTelemetryRefresh(); + } + } else if (m_pendingTelemetry.hasLocation() && solveFinished) { + commitPendingTelemetry(); + } else { + scheduleTelemetryRefresh(); + } +} + +void MainWindow::handleSerialStatus(const QString &status) +{ + flushPendingLogLines(); + m_statusLabel->setText(status); + m_log->appendPlainText(QStringLiteral("[STATUS] ") + status); + statusBar()->showMessage(status, 4000); + updateConnectionControls(); +} + +void MainWindow::handleSerialError(const QString &message) +{ + stopUartLogging(); + flushPendingLogLines(); + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[ERROR] ") + message); + statusBar()->showMessage(message, 8000); + updateConnectionControls(); +} + +void MainWindow::handleLocationTimeout() +{ + resetTelemetryState(); + + const QString message = QStringLiteral("10 sn konum gelmedi, degerler sifirlandi"); + m_statusLabel->setText(message); + m_log->appendPlainText(QStringLiteral("[STATUS] ") + message); + statusBar()->showMessage(message, 8000); +} + +void MainWindow::trimToMaxPoints() +{ + const int maxPoints = m_maxPointsSpin->value(); + while (m_points.size() > maxPoints) { + m_points.removeFirst(); + } + m_telemetryRefreshPending = false; + refreshTelemetryViews(); +} + +void MainWindow::updateReferenceLocation() +{ + const QString latitudeText = m_referenceLatEdit->text().trimmed(); + const QString longitudeText = m_referenceLonEdit->text().trimmed(); + const std::optional latitude = parseCoordinateText(latitudeText, -90.0, 90.0); + const std::optional longitude = parseCoordinateText(longitudeText, -180.0, 180.0); + + const bool latitudeInvalid = !latitudeText.isEmpty() && !latitude.has_value(); + const bool longitudeInvalid = !longitudeText.isEmpty() && !longitude.has_value(); + m_referenceLatEdit->setStyleSheet(latitudeInvalid + ? QStringLiteral("border: 1px solid #b42318;") : QString()); + m_referenceLonEdit->setStyleSheet(longitudeInvalid + ? QStringLiteral("border: 1px solid #b42318;") : QString()); + + if (latitude.has_value() && longitude.has_value()) { + m_referenceLatitude = latitude; + m_referenceLongitude = longitude; + } else { + m_referenceLatitude.reset(); + m_referenceLongitude.reset(); + } + updateAnalysis(); +} + +void MainWindow::flushUiUpdates() +{ + flushPendingLogLines(); + if (!m_telemetryRefreshPending) { + return; + } + + m_telemetryRefreshPending = false; + refreshTelemetryViews(); +} + +void MainWindow::buildUi() +{ + auto *central = new QWidget(this); + central->setObjectName(QStringLiteral("centralRoot")); + central->setStyleSheet(QStringLiteral(R"( + #centralRoot { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, + stop:0 #08111d, stop:0.5 #101d2b, stop:1 #172737); + } + #brandBar, #controlBar { + background: rgba(18, 31, 45, 248); + border: 1px solid #30465a; + border-radius: 10px; + } + QLabel#appTitle { + color: #f2f7fb; + font-size: 18px; + font-weight: 800; + } + QLabel#appSubtitle { + color: #9fb0c1; + font-size: 11px; + } + QLabel#liveBadge { + color: #08745f; + background: #e4f5ef; + border: 1px solid #b7e4d5; + border-radius: 10px; + padding: 4px 10px; + font-weight: 700; + } + QGroupBox { + background: rgba(20, 34, 48, 248); + border: 1px solid #344a5f; + border-radius: 8px; + margin-top: 10px; + padding: 10px 8px 8px 8px; + font-weight: 700; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 4px; + color: #dce8f2; + } + QLabel { + color: #d5e0e9; + } + QLabel#metricValue { + color: #62b8e8; + font-size: 30px; + font-weight: 800; + } + QLabel#subtleText { + color: #94a8b9; + font-weight: 500; + } + QLabel#dopValue { + color: #eef5fa; + font-weight: 800; + } + QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox, QPlainTextEdit, QTableView { + color: #e7eef4; + background: #0d1925; + border: 1px solid #3b5267; + border-radius: 6px; + padding: 4px; + selection-background-color: #2d6f9f; + } + QComboBox QAbstractItemView { + color: #e7eef4; + background-color: #132332; + border: 1px solid #4b657c; + selection-color: #ffffff; + selection-background-color: #23658a; + outline: 0; + padding: 4px; + } + QComboBox QAbstractItemView::item { + min-height: 26px; + padding: 3px 8px; + } + QPushButton, QToolButton { + color: #e7eef4; + background: #182b3d; + border: 1px solid #456078; + border-radius: 6px; + padding: 5px 10px; + font-weight: 600; + } + QPushButton:hover, QToolButton:hover { + background: #23445d; + border-color: #62b8e8; + } + QPushButton:pressed, QToolButton:pressed { background: #dbeaf6; } + QCheckBox { spacing: 7px; color: #d5e0e9; } + QCheckBox::indicator { width: 17px; height: 17px; } + QCheckBox::indicator:unchecked { + background: #0d1925; border: 1px solid #6d8294; border-radius: 4px; + } + QCheckBox::indicator:checked { + background: #167b70; border: 1px solid #167b70; border-radius: 4px; + } + QHeaderView::section { + color: #dce8f2; + background: #1d3143; + border: 0; + border-right: 1px solid #d8dee7; + padding: 5px; + font-weight: 700; + } + QTabWidget::pane { + border: 1px solid #344a5f; + background: #081522; + } + QTabBar::tab { + color: #9fb0c1; + background: #101f2d; + border: 1px solid #344a5f; + border-bottom: 0; + padding: 7px 14px; + margin-right: 2px; + } + QTabBar::tab:selected { + color: #f1f7fb; + background: #1d3850; + border-top: 2px solid #57bceb; + } + QSplitter::handle { background: #263c4f; } + QSplitter::handle:hover { background: #57bceb; } + QScrollArea, QScrollArea > QWidget > QWidget { background: transparent; } + QWidget#dashboardPanel { background: #0b1723; } + )")); + auto *rootLayout = new QVBoxLayout(central); + rootLayout->setContentsMargins(10, 10, 10, 10); + rootLayout->setSpacing(8); + + auto *brandBar = new QWidget(central); + brandBar->setObjectName(QStringLiteral("brandBar")); + auto *brandLayout = new QHBoxLayout(brandBar); + brandLayout->setContentsMargins(14, 8, 14, 8); + auto *logoLabel = new QLabel(brandBar); + const QPixmap logo(QStringLiteral(":/resources/ankasys-logo.png")); + logoLabel->setPixmap(logo.scaled(124, 52, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + logoLabel->setFixedSize(132, 54); + logoLabel->setAlignment(Qt::AlignCenter); + logoLabel->setToolTip(QStringLiteral("ANKASYS - Anka Microelectronic Systems")); + auto *titleLayout = new QVBoxLayout(); + titleLayout->setSpacing(1); + auto *titleLabel = new QLabel(QStringLiteral("GNSS Konum Izleme Merkezi"), brandBar); + titleLabel->setObjectName(QStringLiteral("appTitle")); + auto *subtitleLabel = new QLabel(QStringLiteral("Gercek zamanli telemetri, sapma ve CEP50 analizi"), brandBar); + subtitleLabel->setObjectName(QStringLiteral("appSubtitle")); + titleLayout->addWidget(titleLabel); + titleLayout->addWidget(subtitleLabel); + auto *liveBadge = new QLabel(QStringLiteral("● SISTEM HAZIR"), brandBar); + liveBadge->setObjectName(QStringLiteral("liveBadge")); + brandLayout->addWidget(logoLabel); + brandLayout->addLayout(titleLayout); + brandLayout->addStretch(1); + brandLayout->addWidget(liveBadge); + rootLayout->addWidget(brandBar); + + auto *controlBar = new QWidget(central); + controlBar->setObjectName(QStringLiteral("controlBar")); + auto *toolbarLayout = new QHBoxLayout(controlBar); + toolbarLayout->setContentsMargins(12, 7, 12, 7); + toolbarLayout->setSpacing(8); + + m_portCombo = new QComboBox(this); + m_portCombo->setObjectName(QStringLiteral("portSelector")); + m_portCombo->setMinimumWidth(220); + m_portCombo->setEditable(true); + m_portCombo->setToolTip(QStringLiteral( + "USB UART portu. macOS'ta /dev/cu.usb... tercih edilir; /dev/tty.usb... da desteklenir.")); + refreshSerialPorts(); + + auto *refreshPortsButton = new QToolButton(this); + refreshPortsButton->setObjectName(QStringLiteral("refreshPortsButton")); + refreshPortsButton->setText(QStringLiteral("Yenile")); + refreshPortsButton->setToolTip(QStringLiteral("Seri port listesini yenile")); + + m_baudCombo = new QComboBox(this); + for (const int baud : {9600, 19200, 38400, 57600, 115200, 230400, 460800, 921600}) { + m_baudCombo->addItem(QString::number(baud)); + } + m_baudCombo->setCurrentText(QStringLiteral("460800")); + + m_maxPointsSpin = new QSpinBox(this); + m_maxPointsSpin->setObjectName(QStringLiteral("maxPointsInput")); + m_maxPointsSpin->setRange(1, 10000); + m_maxPointsSpin->setValue(10); + m_maxPointsSpin->setSuffix(QStringLiteral(" nokta")); + + m_connectButton = new QPushButton(this); + m_connectButton->setIcon(style()->standardIcon(QStyle::SP_MediaPlay)); + m_clearButton = new QPushButton(QStringLiteral("Temizle"), this); + m_clearButton->setIcon(style()->standardIcon(QStyle::SP_TrashIcon)); + m_openLogButton = new QPushButton(QStringLiteral("Senaryo Logu Ac"), this); + m_openLogButton->setObjectName(QStringLiteral("openLogButton")); + m_openLogButton->setIcon(style()->standardIcon(QStyle::SP_DialogOpenButton)); + m_replayButton = new QPushButton(QStringLiteral("UART Replay"), this); + m_replayButton->setObjectName(QStringLiteral("uartReplayButton")); + m_replayButton->setIcon(style()->standardIcon(QStyle::SP_MediaPlay)); + m_replayButton->setToolTip(QStringLiteral( + "Kayitli UART verisini GPS ve sensor guncelleme hizlariyla yeniden oynat")); + + m_autoFollowCheck = new QCheckBox(QStringLiteral("Kamerayi takip et"), this); + m_autoFollowCheck->setChecked(true); + + auto *zoomInButton = new QToolButton(this); + zoomInButton->setText(QStringLiteral("+")); + zoomInButton->setToolTip(QStringLiteral("Yaklastir")); + auto *zoomOutButton = new QToolButton(this); + zoomOutButton->setText(QStringLiteral("-")); + zoomOutButton->setToolTip(QStringLiteral("Uzaklastir")); + + toolbarLayout->addWidget(new QLabel(QStringLiteral("Port"), this)); + toolbarLayout->addWidget(m_portCombo); + toolbarLayout->addWidget(refreshPortsButton); + toolbarLayout->addWidget(new QLabel(QStringLiteral("Baud"), this)); + toolbarLayout->addWidget(m_baudCombo); + toolbarLayout->addWidget(new QLabel(QStringLiteral("Max"), this)); + toolbarLayout->addWidget(m_maxPointsSpin); + toolbarLayout->addWidget(m_connectButton); + toolbarLayout->addWidget(m_openLogButton); + toolbarLayout->addWidget(m_replayButton); + toolbarLayout->addWidget(m_clearButton); + toolbarLayout->addWidget(m_autoFollowCheck); + toolbarLayout->addWidget(zoomOutButton); + toolbarLayout->addWidget(zoomInButton); + toolbarLayout->addStretch(1); + rootLayout->addWidget(controlBar); + + m_map = new MapWidget(this); + m_globe = new GlobeWidget(this); + auto *viewTabs = new QTabWidget(this); + viewTabs->setObjectName(QStringLiteral("mainViewTabs")); + viewTabs->addTab(m_map, QStringLiteral("2B Harita")); + + auto *globeTab = new QWidget(viewTabs); + auto *globeTabLayout = new QVBoxLayout(globeTab); + globeTabLayout->setContentsMargins(0, 0, 0, 0); + globeTabLayout->setSpacing(6); + auto *rinexToolbar = new QWidget(globeTab); + auto *rinexToolbarLayout = new QHBoxLayout(rinexToolbar); + rinexToolbarLayout->setContentsMargins(10, 6, 10, 0); + rinexToolbarLayout->addWidget(new QLabel( + QStringLiteral("GPS yayin efemerisi · otomatik: 15 dk"), rinexToolbar)); + rinexToolbarLayout->addStretch(1); + m_refreshRinexButton = new QPushButton(QStringLiteral("RINEX Guncelle"), rinexToolbar); + m_refreshRinexButton->setObjectName(QStringLiteral("refreshRinexButton")); + m_refreshRinexButton->setToolTip( + QStringLiteral("Guncel RINEX navigasyon dosyasini simdi indir")); + rinexToolbarLayout->addWidget(m_refreshRinexButton); + globeTabLayout->addWidget(rinexToolbar); + globeTabLayout->addWidget(m_globe, 1); + viewTabs->addTab(globeTab, QStringLiteral("3B Dunya · Uydular")); + + auto *sidePanel = new QWidget(this); + sidePanel->setMinimumWidth(390); + auto *sideOuterLayout = new QVBoxLayout(sidePanel); + sideOuterLayout->setContentsMargins(8, 0, 0, 0); + sideOuterLayout->setSpacing(0); + auto *dashboard = new QWidget(sidePanel); + dashboard->setObjectName(QStringLiteral("dashboardPanel")); + auto *sideLayout = new QVBoxLayout(dashboard); + sideLayout->setContentsMargins(4, 4, 4, 4); + sideLayout->setSpacing(8); + + auto *statusGroup = new QGroupBox(QStringLiteral("Baglanti"), sidePanel); + auto *statusLayout = new QGridLayout(statusGroup); + m_countLabel = new QLabel(QStringLiteral("Konum: 0"), statusGroup); + m_statusLabel = new QLabel(QStringLiteral("Hazir"), statusGroup); + m_statusLabel->setObjectName(QStringLiteral("subtleText")); + m_statusLabel->setWordWrap(true); + statusLayout->addWidget(new QLabel(QStringLiteral("Durum"), statusGroup), 0, 0); + statusLayout->addWidget(m_statusLabel, 0, 1); + statusLayout->addWidget(new QLabel(QStringLiteral("Kayitli nokta"), statusGroup), 1, 0); + statusLayout->addWidget(m_countLabel, 1, 1); + statusLayout->setColumnStretch(1, 1); + sideLayout->addWidget(statusGroup); + + auto *satelliteGroup = new QGroupBox(QStringLiteral("Uydu durumu"), sidePanel); + auto *satelliteLayout = new QVBoxLayout(satelliteGroup); + m_satelliteLabel = new QLabel(QStringLiteral("-"), satelliteGroup); + m_satelliteLabel->setObjectName(QStringLiteral("metricValue")); + m_satelliteLabel->setAlignment(Qt::AlignCenter); + m_satelliteQualityLabel = new QLabel(QStringLiteral("Uydu bekleniyor"), satelliteGroup); + m_satelliteQualityLabel->setObjectName(QStringLiteral("subtleText")); + m_satelliteQualityLabel->setAlignment(Qt::AlignCenter); + m_satelliteQualityLabel->setWordWrap(true); + satelliteLayout->addWidget(m_satelliteLabel); + satelliteLayout->addWidget(m_satelliteQualityLabel); + sideLayout->addWidget(satelliteGroup); + + auto *dopGroup = new QGroupBox(QStringLiteral("DOP degerleri"), sidePanel); + auto *dopLayout = new QGridLayout(dopGroup); + m_gdopLabel = new QLabel(QStringLiteral("-"), dopGroup); + m_hdopLabel = new QLabel(QStringLiteral("-"), dopGroup); + m_vdopLabel = new QLabel(QStringLiteral("-"), dopGroup); + m_tdopLabel = new QLabel(QStringLiteral("-"), dopGroup); + + const auto addDopRow = [dopLayout](int row, const QString &label, QLabel *value) { + auto *nameLabel = new QLabel(label); + nameLabel->setObjectName(QStringLiteral("subtleText")); + value->setObjectName(QStringLiteral("dopValue")); + value->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + value->setTextInteractionFlags(Qt::TextSelectableByMouse); + dopLayout->addWidget(nameLabel, row, 0); + dopLayout->addWidget(value, row, 1); + }; + + addDopRow(0, QStringLiteral("GDOP"), m_gdopLabel); + addDopRow(1, QStringLiteral("HDOP"), m_hdopLabel); + addDopRow(2, QStringLiteral("VDOP"), m_vdopLabel); + addDopRow(3, QStringLiteral("TDOP"), m_tdopLabel); + dopLayout->setColumnStretch(1, 1); + sideLayout->addWidget(dopGroup); + + auto *analysisGroup = new QGroupBox(QStringLiteral("Konum analizi"), sidePanel); + auto *analysisLayout = new QGridLayout(analysisGroup); + auto *layersGroup = new QGroupBox(QStringLiteral("Harita katmanlari"), sidePanel); + auto *layersLayout = new QVBoxLayout(layersGroup); + m_showTelemetryCheck = new QCheckBox(QStringLiteral("Harita isaretlerini goster"), layersGroup); + m_showTelemetryCheck->setObjectName(QStringLiteral("showTelemetryCheck")); + m_showTelemetryCheck->setChecked(true); + m_showRecentPointsCheck = new QCheckBox(QStringLiteral("Son konumlari goster"), layersGroup); + m_showRecentPointsCheck->setObjectName(QStringLiteral("showRecentPointsCheck")); + m_showRecentPointsCheck->setChecked(true); + m_showAverageCheck = new QCheckBox(QStringLiteral("Ortalamayi goster"), layersGroup); + m_showAverageCheck->setObjectName(QStringLiteral("showAverageCheck")); + m_showAverageCheck->setChecked(true); + auto *elevationMaskSpin = new QDoubleSpinBox(layersGroup); + elevationMaskSpin->setObjectName(QStringLiteral("elevationMaskSpin")); + elevationMaskSpin->setRange(0.0, 90.0); + elevationMaskSpin->setDecimals(1); + elevationMaskSpin->setSingleStep(0.5); + elevationMaskSpin->setValue(10.0); + elevationMaskSpin->setKeyboardTracking(false); + elevationMaskSpin->setAccelerated(true); + elevationMaskSpin->setMinimumWidth(86); + elevationMaskSpin->setSuffix(QStringLiteral("°")); + elevationMaskSpin->setToolTip( + QStringLiteral("0-90 arasinda bir deger yazin ve Enter'a basin")); + auto *elevationMaskLayout = new QHBoxLayout(); + elevationMaskLayout->addWidget(new QLabel(QStringLiteral("Min. elevation"), layersGroup)); + elevationMaskLayout->addStretch(1); + elevationMaskLayout->addWidget(elevationMaskSpin); + layersLayout->addWidget(m_showTelemetryCheck); + layersLayout->addWidget(m_showRecentPointsCheck); + layersLayout->addWidget(m_showAverageCheck); + layersLayout->addLayout(elevationMaskLayout); + sideLayout->addWidget(layersGroup); + m_referenceLatEdit = new QLineEdit(analysisGroup); + m_referenceLatEdit->setObjectName(QStringLiteral("referenceLatInput")); + m_referenceLatEdit->setPlaceholderText(QStringLiteral("Orn. 40.789250342142")); + m_referenceLatEdit->setClearButtonEnabled(true); + m_referenceLatEdit->setMaxLength(32); + m_referenceLatEdit->setToolTip(QStringLiteral("Kullanici referansi; CEP50 hesabini etkilemez")); + m_referenceLonEdit = new QLineEdit(analysisGroup); + m_referenceLonEdit->setObjectName(QStringLiteral("referenceLonInput")); + m_referenceLonEdit->setPlaceholderText(QStringLiteral("Orn. 29.462775308417")); + m_referenceLonEdit->setClearButtonEnabled(true); + m_referenceLonEdit->setMaxLength(32); + m_referenceLonEdit->setToolTip(QStringLiteral("Kullanici referansi; CEP50 hesabini etkilemez")); + auto *mapSelectionHint = new QLabel( + QStringLiteral("Sag tik: kullanici referansi. CEP50, olcumlerin %50'sine gore otomatik hesaplanir."), analysisGroup); + mapSelectionHint->setObjectName(QStringLiteral("subtleText")); + mapSelectionHint->setWordWrap(true); + m_cepLabel = new QLabel(QStringLiteral("-"), analysisGroup); + m_cepLabel->setObjectName(QStringLiteral("cepValue")); + m_cepLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + m_cepLabel->setToolTip(QStringLiteral("Radyal konum hatalarinin yuzde 50 yuzdeligi")); + m_meanLocationLabel = new QLabel(QStringLiteral("-"), analysisGroup); + m_meanLocationLabel->setMinimumHeight(36); + m_lastDeviationLabel = new QLabel(QStringLiteral("-"), analysisGroup); + m_meanDeviationLabel = new QLabel(QStringLiteral("-"), analysisGroup); + m_rmsScatterLabel = new QLabel(QStringLiteral("-"), analysisGroup); + m_maxScatterLabel = new QLabel(QStringLiteral("-"), analysisGroup); + + const auto addAnalysisRow = [analysisLayout](int row, const QString &label, QLabel *value) { + auto *nameLabel = new QLabel(label); + value->setTextInteractionFlags(Qt::TextSelectableByMouse); + value->setWordWrap(true); + analysisLayout->addWidget(nameLabel, row, 0); + analysisLayout->addWidget(value, row, 1); + }; + + analysisLayout->addWidget(mapSelectionHint, 0, 0, 1, 2); + analysisLayout->addWidget(new QLabel(QStringLiteral("Referans enlem"), analysisGroup), 1, 0); + analysisLayout->addWidget(m_referenceLatEdit, 1, 1); + analysisLayout->addWidget(new QLabel(QStringLiteral("Referans boylam"), analysisGroup), 2, 0); + analysisLayout->addWidget(m_referenceLonEdit, 2, 1); + analysisLayout->addWidget(new QLabel(QStringLiteral("CEP50"), analysisGroup), 3, 0); + analysisLayout->addWidget(m_cepLabel, 3, 1); + addAnalysisRow(4, QStringLiteral("Ortalama"), m_meanLocationLabel); + addAnalysisRow(5, QStringLiteral("Son sapma"), m_lastDeviationLabel); + addAnalysisRow(6, QStringLiteral("Ort. sapma"), m_meanDeviationLabel); + addAnalysisRow(7, QStringLiteral("RMS sacilim"), m_rmsScatterLabel); + addAnalysisRow(8, QStringLiteral("Maks sapma"), m_maxScatterLabel); + analysisLayout->setColumnStretch(1, 1); + sideLayout->addWidget(analysisGroup); + + auto *telemetryTabs = new QTabWidget(sidePanel); + + m_table = new QTableView(telemetryTabs); + m_tableModel = new TelemetryTableModel(m_table); + m_table->setModel(m_tableModel); + m_table->verticalHeader()->setVisible(false); + m_table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_table->setSelectionBehavior(QAbstractItemView::SelectRows); + m_table->setAlternatingRowColors(true); + + m_log = new QPlainTextEdit(telemetryTabs); + m_log->setReadOnly(true); + m_log->setLineWrapMode(QPlainTextEdit::NoWrap); + m_log->setMaximumBlockCount(500); + m_log->setPlaceholderText(QStringLiteral("Ham seri debug satirlari")); + telemetryTabs->addTab(m_log, QStringLiteral("UART Log")); + telemetryTabs->addTab(m_table, QStringLiteral("Konumlar")); + telemetryTabs->setCurrentWidget(m_log); + telemetryTabs->setMinimumHeight(210); + + auto *dashboardScroll = new QScrollArea(sidePanel); + dashboardScroll->setObjectName(QStringLiteral("dashboardScroll")); + dashboardScroll->setWidgetResizable(true); + dashboardScroll->setFrameShape(QFrame::NoFrame); + dashboardScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + dashboardScroll->setWidget(dashboard); + + auto *sideSplitter = new QSplitter(Qt::Vertical, sidePanel); + sideSplitter->setObjectName(QStringLiteral("sideSplitter")); + sideSplitter->setChildrenCollapsible(false); + sideSplitter->setHandleWidth(7); + sideSplitter->addWidget(dashboardScroll); + sideSplitter->addWidget(telemetryTabs); + sideSplitter->setStretchFactor(0, 2); + sideSplitter->setStretchFactor(1, 3); + sideSplitter->setSizes({380, 410}); + sideOuterLayout->addWidget(sideSplitter); + + auto *splitter = new QSplitter(Qt::Horizontal, central); + splitter->setObjectName(QStringLiteral("mainSplitter")); + splitter->setChildrenCollapsible(false); + splitter->setHandleWidth(7); + splitter->addWidget(viewTabs); + splitter->addWidget(sidePanel); + splitter->setStretchFactor(0, 4); + splitter->setStretchFactor(1, 1); + splitter->setSizes({960, 500}); + rootLayout->addWidget(splitter, 1); + + setCentralWidget(central); + updateConnectionControls(); + updateSummaryLabels(); + updateAnalysis(); + + connect(m_connectButton, &QPushButton::clicked, this, &MainWindow::toggleSerial); + connect(m_clearButton, &QPushButton::clicked, this, &MainWindow::clearPoints); + connect(m_openLogButton, &QPushButton::clicked, this, &MainWindow::openTelemetryLog); + connect(m_replayButton, &QPushButton::clicked, this, &MainWindow::openUartReplay); + connect(refreshPortsButton, &QToolButton::clicked, this, &MainWindow::refreshSerialPorts); + connect(m_maxPointsSpin, qOverload(&QSpinBox::valueChanged), this, + &MainWindow::trimToMaxPoints); + connect(m_autoFollowCheck, &QCheckBox::toggled, m_map, &MapWidget::setAutoFollow); + connect(m_showTelemetryCheck, &QCheckBox::toggled, + m_map, &MapWidget::setTelemetryOverlayEnabled); + connect(m_showTelemetryCheck, &QCheckBox::toggled, this, [this](bool enabled) { + m_showRecentPointsCheck->setEnabled(enabled); + m_showAverageCheck->setEnabled(enabled); + }); + connect(m_showRecentPointsCheck, &QCheckBox::toggled, + m_map, &MapWidget::setRecentPointsEnabled); + connect(m_showAverageCheck, &QCheckBox::toggled, m_map, &MapWidget::setAverageOverlayEnabled); + connect(elevationMaskSpin, qOverload(&QDoubleSpinBox::valueChanged), + m_globe, &GlobeWidget::setElevationMask); + connect(m_referenceLatEdit, &QLineEdit::editingFinished, + this, &MainWindow::updateReferenceLocation); + connect(m_referenceLonEdit, &QLineEdit::editingFinished, + this, &MainWindow::updateReferenceLocation); + connect(m_map, &MapWidget::autoFollowChanged, m_autoFollowCheck, &QCheckBox::setChecked); + connect(m_map, &MapWidget::referenceLocationSelected, this, + [this](double latitude, double longitude) { + m_referenceLatEdit->setText(preciseCoordinateText(latitude)); + m_referenceLonEdit->setText(preciseCoordinateText(longitude)); + updateReferenceLocation(); + statusBar()->showMessage(QStringLiteral("CEP merkezi haritadan secildi"), 4000); + }); + connect(zoomInButton, &QToolButton::clicked, m_map, &MapWidget::zoomIn); + connect(zoomOutButton, &QToolButton::clicked, m_map, &MapWidget::zoomOut); +} + +void MainWindow::updateConnectionControls() +{ + const bool open = m_serial->isOpen(); + const bool replaying = m_replay->isRunning(); + m_connectButton->setText(open ? QStringLiteral("Durdur") : QStringLiteral("Dinle")); + m_connectButton->setIcon(style()->standardIcon(open ? QStyle::SP_MediaStop : QStyle::SP_MediaPlay)); + m_connectButton->setEnabled(!replaying); + m_portCombo->setEnabled(!open && !replaying); + m_baudCombo->setEnabled(!open && !replaying); + m_openLogButton->setEnabled(!replaying); + m_replayButton->setText(replaying ? QStringLiteral("Replay Durdur") + : QStringLiteral("UART Replay")); + m_replayButton->setIcon(style()->standardIcon( + replaying ? QStyle::SP_MediaStop : QStyle::SP_MediaPlay)); +} + +void MainWindow::addTelemetryPoint(const ParsedTelemetry &telemetry) +{ + TelemetryPoint point; + point.source = telemetry.source; + point.fusionMode = telemetry.fusionMode; + point.gpsAgeMs = telemetry.gpsAgeMs; + point.latitude = *telemetry.latitude; + point.longitude = *telemetry.longitude; + point.latitudeText = telemetry.latitudeText; + point.longitudeText = telemetry.longitudeText; + point.hheight = telemetry.hheight; + point.satellites = telemetry.satellites.has_value() ? telemetry.satellites : m_lastSatelliteCount; + point.dop = m_lastDop; + mergeDopValues(point.dop, telemetry.dop); + point.rawLine = telemetry.rawLine; + point.receivedAt = QDateTime::currentDateTimeUtc(); + + constexpr qint64 CorrectedPreferenceWindowMs = 1000; + bool correctedPositionFresh = false; + for (auto existing = m_points.crbegin(); existing != m_points.crend(); ++existing) { + if (existing->source == LocationSource::ImuCorrected) { + correctedPositionFresh = + existing->receivedAt.msecsTo(point.receivedAt) + <= CorrectedPreferenceWindowMs; + break; + } + } + + m_points.push_back(point); + if (point.source == LocationSource::ImuCorrected || !correctedPositionFresh) { + m_globe->setReceiverLocation(point.latitude, point.longitude); + } + if (m_locationTimeoutTimer != nullptr && m_serial->isOpen()) { + m_locationTimeoutTimer->start(); + } + const int maxPoints = m_maxPointsSpin->value(); + while (m_points.size() > maxPoints) { + m_points.removeFirst(); + } + scheduleTelemetryRefresh(); +} + +void MainWindow::refreshTable() +{ + m_tableModel->setPoints(m_points); + if (m_tableModel->rowCount() > 0) { + m_table->scrollToBottom(); + } +} + +void MainWindow::updateSummaryLabels() +{ + m_satelliteLabel->setText( + m_lastSatelliteCount.has_value() + ? QString::number(*m_lastSatelliteCount) + : QStringLiteral("-")); + m_satelliteQualityLabel->setText(satelliteQualityText(m_lastSatelliteCount)); + m_countLabel->setText(QStringLiteral("Konum: %1 / %2") + .arg(m_points.size()) + .arg(m_maxPointsSpin->value())); + updateDopLabels(); +} + +void MainWindow::updateDopLabels() +{ + m_gdopLabel->setText(optionalDopText(m_lastDop.gdop)); + m_hdopLabel->setText(optionalDopText(m_lastDop.hdop)); + m_vdopLabel->setText(optionalDopText(m_lastDop.vdop)); + m_tdopLabel->setText(optionalDopText(m_lastDop.tdop)); +} + +void MainWindow::updateAnalysis() +{ + const QVector analysisPoints = preferredAnalysisPoints(m_points); + const LocationAnalysis analysis = analyzeLocations(analysisPoints); + if (!analysis.valid) { + m_cepLabel->setText(QStringLiteral("-")); + m_meanLocationLabel->setText(QStringLiteral("-")); + m_lastDeviationLabel->setText(QStringLiteral("-")); + m_meanDeviationLabel->setText(QStringLiteral("-")); + m_rmsScatterLabel->setText(QStringLiteral("-")); + m_maxScatterLabel->setText(QStringLiteral("-")); + m_map->clearAverageMarker(); + m_map->clearCepMarker(); + return; + } + + m_meanLocationLabel->setText(QStringLiteral("%1\n%2") + .arg(preciseCoordinateText(analysis.averageLat)) + .arg(preciseCoordinateText(analysis.averageLon))); + m_lastDeviationLabel->setText(metersText(analysis.lastDeviationMeters)); + m_meanDeviationLabel->setText(metersText(analysis.meanDeviationMeters)); + m_rmsScatterLabel->setText(metersText(analysis.rmsScatterMeters)); + m_maxScatterLabel->setText(metersText(analysis.maxDeviationMeters)); + m_map->setAverageMarker(analysis.averageLat, analysis.averageLon, + analysis.rmsScatterMeters); + + const double cep50 = calculateCep50(analysisPoints, analysis.averageLat, + analysis.averageLon); + m_cepLabel->setText(QStringLiteral("%1 (P50, N=%2)") + .arg(metersText(cep50)) + .arg(analysisPoints.size())); + m_map->setCepMarker(analysis.averageLat, analysis.averageLon, cep50); +} + +void MainWindow::appendLogLine(const QString &line, bool parsed) +{ + const QString prefix = parsed ? QStringLiteral("[OK] ") : QStringLiteral("[..] "); + if (m_pendingLogLines.size() >= MaxPendingLogLines) { + m_pendingLogLines.removeFirst(); + } + m_pendingLogLines.push_back(prefix + line); + if (m_uiRefreshTimer != nullptr && !m_uiRefreshTimer->isActive()) { + m_uiRefreshTimer->start(); + } +} + +void MainWindow::mergePendingTelemetry(const ParsedTelemetry &telemetry) +{ + if (telemetry.latitude.has_value() && m_pendingTelemetry.hasLocation()) { + commitPendingTelemetry(); + } + + if (telemetry.fusionMode != FusionMode::Unknown) { + m_pendingTelemetry.fusionMode = telemetry.fusionMode; + } + if (telemetry.gpsAgeMs.has_value()) { + m_pendingTelemetry.gpsAgeMs = telemetry.gpsAgeMs; + } + if (telemetry.latitude.has_value()) { + m_pendingTelemetry.source = telemetry.source; + m_pendingTelemetry.latitude = telemetry.latitude; + m_pendingTelemetry.latitudeText = telemetry.latitudeText; + } + if (telemetry.longitude.has_value()) { + m_pendingTelemetry.longitude = telemetry.longitude; + m_pendingTelemetry.longitudeText = telemetry.longitudeText; + } + if (telemetry.hheight.has_value()) { + m_pendingTelemetry.hheight = telemetry.hheight; + } + if (telemetry.satellites.has_value()) { + m_pendingTelemetry.satellites = telemetry.satellites; + } + if (telemetry.dop.hasAnyValue()) { + mergeDopValues(m_pendingTelemetry.dop, telemetry.dop); + } + + if (m_pendingTelemetry.rawLine.isEmpty()) { + m_pendingTelemetry.rawLine = telemetry.rawLine; + } else if (!telemetry.rawLine.isEmpty()) { + m_pendingTelemetry.rawLine += QStringLiteral(" | ") + telemetry.rawLine; + } +} + +void MainWindow::commitPendingTelemetry() +{ + if (!m_pendingTelemetry.hasLocation()) { + return; + } + + addTelemetryPoint(m_pendingTelemetry); + m_pendingTelemetry = {}; +} + +void MainWindow::resetTelemetryState() +{ + m_telemetryRefreshPending = false; + m_points.clear(); + m_lastSatelliteCount.reset(); + m_observedPrns.clear(); + m_lastDop = {}; + m_pendingTelemetry = {}; + m_map->clearPoints(); + m_globe->clearReceiverLocation(); + m_globe->setObservedPrns({}); + refreshTable(); + updateSummaryLabels(); + updateAnalysis(); +} + +void MainWindow::scheduleTelemetryRefresh() +{ + m_telemetryRefreshPending = true; + if (m_uiRefreshTimer != nullptr && !m_uiRefreshTimer->isActive()) { + m_uiRefreshTimer->start(); + } +} + +void MainWindow::refreshTelemetryViews() +{ + m_map->setPoints(m_points); + refreshTable(); + updateSummaryLabels(); + updateAnalysis(); +} + +void MainWindow::flushPendingLogLines() +{ + if (m_pendingLogLines.isEmpty()) { + return; + } + m_log->appendPlainText(m_pendingLogLines.join('\n')); + m_pendingLogLines.clear(); +} + +int MainWindow::selectedBaudRate() const +{ + bool ok = false; + const int baudRate = m_baudCombo->currentText().toInt(&ok); + return ok ? baudRate : 460800; +} diff --git a/linux/src/MainWindow.h b/linux/src/MainWindow.h new file mode 100644 index 0000000..cf6ae2e --- /dev/null +++ b/linux/src/MainWindow.h @@ -0,0 +1,128 @@ +#pragma once + +#include "TelemetryPoint.h" + +#include +#include +#include +#include + +#include + +class QLabel; +class QCheckBox; +class QComboBox; +class QLineEdit; +class QPlainTextEdit; +class QPushButton; +class QSpinBox; +class QTableView; +class QTimer; +class QCloseEvent; +class QFile; + +class MapWidget; +class GlobeWidget; +class RinexNavigation; +class SerialReader; +class TelemetryTableModel; +class UartReplay; + +class MainWindow : public QMainWindow { + Q_OBJECT + +public: + explicit MainWindow(QWidget *parent = nullptr); + bool loadTelemetryLog(const QString &filePath); + bool startUartReplay(const QString &filePath, int gpsIntervalMs = 200, + int sensorIntervalMs = 50); + +protected: + void closeEvent(QCloseEvent *event) override; + +private slots: + void toggleSerial(); + void clearPoints(); + void handleSerialLine(const QString &line); + void handleSerialStatus(const QString &status); + void handleSerialError(const QString &message); + void handleLocationTimeout(); + void trimToMaxPoints(); + void updateReferenceLocation(); + void flushUiUpdates(); + void openTelemetryLog(); + void openUartReplay(); + void stopUartReplay(); + void refreshSerialPorts(); + bool startUartLogging(const QString &port, int baudRate); + void stopUartLogging(); + +private: + void buildUi(); + void updateConnectionControls(); + void addTelemetryPoint(const ParsedTelemetry &telemetry); + void refreshTable(); + void updateSummaryLabels(); + void updateDopLabels(); + void updateAnalysis(); + void appendLogLine(const QString &line, bool parsed); + void mergePendingTelemetry(const ParsedTelemetry &telemetry); + void commitPendingTelemetry(); + void resetTelemetryState(); + void scheduleTelemetryRefresh(); + void refreshTelemetryViews(); + void flushPendingLogLines(); + void appendUartLogLine(const QString &line); + int selectedBaudRate() const; + + SerialReader *m_serial = nullptr; + UartReplay *m_replay = nullptr; + MapWidget *m_map = nullptr; + GlobeWidget *m_globe = nullptr; + RinexNavigation *m_rinex = nullptr; + QComboBox *m_portCombo = nullptr; + QComboBox *m_baudCombo = nullptr; + QSpinBox *m_maxPointsSpin = nullptr; + QLineEdit *m_referenceLatEdit = nullptr; + QLineEdit *m_referenceLonEdit = nullptr; + QPushButton *m_connectButton = nullptr; + QPushButton *m_clearButton = nullptr; + QPushButton *m_openLogButton = nullptr; + QPushButton *m_replayButton = nullptr; + QPushButton *m_refreshRinexButton = nullptr; + QCheckBox *m_autoFollowCheck = nullptr; + QCheckBox *m_showTelemetryCheck = nullptr; + QCheckBox *m_showRecentPointsCheck = nullptr; + QLabel *m_statusLabel = nullptr; + QLabel *m_satelliteLabel = nullptr; + QLabel *m_satelliteQualityLabel = nullptr; + QLabel *m_gdopLabel = nullptr; + QLabel *m_hdopLabel = nullptr; + QLabel *m_vdopLabel = nullptr; + QLabel *m_tdopLabel = nullptr; + QLabel *m_countLabel = nullptr; + QLabel *m_meanLocationLabel = nullptr; + QLabel *m_lastDeviationLabel = nullptr; + QLabel *m_meanDeviationLabel = nullptr; + QLabel *m_rmsScatterLabel = nullptr; + QLabel *m_maxScatterLabel = nullptr; + QLabel *m_cepLabel = nullptr; + QCheckBox *m_showAverageCheck = nullptr; + QTableView *m_table = nullptr; + TelemetryTableModel *m_tableModel = nullptr; + QPlainTextEdit *m_log = nullptr; + QTimer *m_locationTimeoutTimer = nullptr; + QTimer *m_uiRefreshTimer = nullptr; + QFile *m_uartLogFile = nullptr; + QString m_uartLogPath; + + QVector m_points; + QStringList m_pendingLogLines; + std::optional m_lastSatelliteCount; + QSet m_observedPrns; + std::optional m_referenceLatitude; + std::optional m_referenceLongitude; + DopValues m_lastDop; + ParsedTelemetry m_pendingTelemetry; + bool m_telemetryRefreshPending = false; +}; diff --git a/linux/src/MapWidget.cpp b/linux/src/MapWidget.cpp new file mode 100644 index 0000000..d4ac706 --- /dev/null +++ b/linux/src/MapWidget.cpp @@ -0,0 +1,727 @@ +#include "MapWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { + +constexpr int TileSize = 256; +constexpr int MinZoom = 2; +constexpr int MaxZoom = 19; +constexpr int MaxMemoryTileCount = 512; +constexpr double Pi = 3.14159265358979323846; +constexpr double WebMercatorRadiusMeters = 6378137.0; +constexpr double MaxMercatorLatitude = 85.05112878; + +QString tileKey(int zoom, int tileX, int tileY) +{ + return QStringLiteral("%1/%2/%3").arg(zoom).arg(tileX).arg(tileY); +} + +double clampLatitude(double latitude) +{ + return std::clamp(latitude, -MaxMercatorLatitude, MaxMercatorLatitude); +} + +int positiveModulo(int value, int modulo) +{ + const int result = value % modulo; + return result < 0 ? result + modulo : result; +} + +double metersPerPixel(double latitude, int zoom) +{ + const double scale = static_cast(TileSize) * static_cast(1 << zoom); + const double latitudeScale = std::cos(latitude * Pi / 180.0); + return latitudeScale * 2.0 * Pi * WebMercatorRadiusMeters / scale; +} + +int preferredCurrentPointIndex(const QVector &points) +{ + constexpr qint64 CorrectedPreferenceWindowMs = 1000; + const int newestIndex = points.size() - 1; + for (int index = points.size() - 1; index >= 0; --index) { + if (points[index].source == LocationSource::ImuCorrected) { + const qint64 ageMs = points[index].receivedAt.msecsTo( + points[newestIndex].receivedAt); + return ageMs <= CorrectedPreferenceWindowMs ? index : newestIndex; + } + } + return newestIndex; +} + +} // namespace + +MapWidget::MapWidget(QWidget *parent) + : QWidget(parent) + , m_network(new QNetworkAccessManager(this)) +{ + setMinimumSize(640, 420); + setMouseTracking(true); + + const QString cacheRoot = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); + if (!cacheRoot.isEmpty()) { + auto *diskCache = new QNetworkDiskCache(m_network); + const QString cacheDirectory = cacheRoot + QStringLiteral("/map-tiles"); + QDir().mkpath(cacheDirectory); + diskCache->setCacheDirectory(cacheDirectory); + diskCache->setMaximumCacheSize(100 * 1024 * 1024); + m_network->setCache(diskCache); + } + + m_attributionLabel = new QLabel(this); + m_attributionLabel->setText( + QStringLiteral("") + + QString::fromUtf8("\xC2\xA9 OpenStreetMap contributors") + + QStringLiteral("")); + m_attributionLabel->setOpenExternalLinks(true); + m_attributionLabel->setStyleSheet(QStringLiteral( + "QLabel { background: rgba(255, 255, 255, 220); color: #26313d; " + "border: 1px solid rgba(120, 130, 140, 90); padding: 3px 6px; }")); + m_attributionLabel->adjustSize(); +} + +void MapWidget::setPoints(const QVector &points) +{ + const bool wasEmpty = m_points.isEmpty(); + m_points = points; + + if (!m_points.isEmpty() && m_autoFollow) { + const TelemetryPoint ¤t = m_points[preferredCurrentPointIndex(m_points)]; + m_centerLat = current.latitude; + m_centerLon = current.longitude; + if (wasEmpty) { + m_zoom = MaxZoom; + } + } + + update(); +} + +void MapWidget::clearPoints() +{ + m_points.clear(); + m_hasAverageMarker = false; + update(); +} + +void MapWidget::fitToPoints() +{ + if (m_points.isEmpty()) { + return; + } + double minimumLatitude = m_points.first().latitude; + double maximumLatitude = minimumLatitude; + double minimumLongitude = m_points.first().longitude; + double maximumLongitude = minimumLongitude; + for (const TelemetryPoint &point : m_points) { + minimumLatitude = std::min(minimumLatitude, point.latitude); + maximumLatitude = std::max(maximumLatitude, point.latitude); + minimumLongitude = std::min(minimumLongitude, point.longitude); + maximumLongitude = std::max(maximumLongitude, point.longitude); + } + m_centerLat = 0.5 * (minimumLatitude + maximumLatitude); + m_centerLon = 0.5 * (minimumLongitude + maximumLongitude); + const double availableWidth = std::max(100.0, width() * 0.82); + const double availableHeight = std::max(100.0, height() * 0.82); + for (int zoom = MaxZoom; zoom >= MinZoom; --zoom) { + const QPointF topLeft = coordinateToWorld(maximumLatitude, minimumLongitude, zoom); + const QPointF bottomRight = coordinateToWorld(minimumLatitude, maximumLongitude, zoom); + if (std::fabs(bottomRight.x() - topLeft.x()) <= availableWidth + && std::fabs(bottomRight.y() - topLeft.y()) <= availableHeight) { + m_zoom = zoom; + break; + } + } + if (m_autoFollow) { + m_autoFollow = false; + emit autoFollowChanged(false); + } + update(); +} + +void MapWidget::setAverageMarker(double latitude, double longitude, double radiusMeters) +{ + m_hasAverageMarker = true; + m_averageLat = latitude; + m_averageLon = longitude; + m_averageRadiusMeters = std::max(0.0, radiusMeters); + update(); +} + +void MapWidget::clearAverageMarker() +{ + if (!m_hasAverageMarker) { + return; + } + + m_hasAverageMarker = false; + update(); +} + +void MapWidget::setCepMarker(double latitude, double longitude, + const std::optional &radiusMeters) +{ + m_hasCepMarker = true; + m_cepLatitude = latitude; + m_cepLongitude = longitude; + if (m_points.isEmpty()) { + m_centerLat = latitude; + m_centerLon = longitude; + m_zoom = MaxZoom; + } + if (radiusMeters.has_value()) { + m_cepRadiusMeters = std::max(0.0, *radiusMeters); + } else { + m_cepRadiusMeters.reset(); + } + update(); +} + +void MapWidget::clearCepMarker() +{ + if (!m_hasCepMarker) { + return; + } + m_hasCepMarker = false; + m_cepRadiusMeters.reset(); + update(); +} + +QPointF MapWidget::centerCoordinate() const +{ + return QPointF(m_centerLat, m_centerLon); +} + +void MapWidget::setAutoFollow(bool enabled) +{ + if (m_autoFollow == enabled) { + return; + } + + m_autoFollow = enabled; + if (m_autoFollow && !m_points.isEmpty()) { + const TelemetryPoint ¤t = m_points[preferredCurrentPointIndex(m_points)]; + m_centerLat = current.latitude; + m_centerLon = current.longitude; + update(); + } + emit autoFollowChanged(m_autoFollow); +} + +void MapWidget::setAverageOverlayEnabled(bool enabled) +{ + if (m_showAverageOverlay == enabled) { + return; + } + + m_showAverageOverlay = enabled; + update(); +} + +void MapWidget::setTelemetryOverlayEnabled(bool enabled) +{ + if (m_showTelemetryOverlay == enabled) { + return; + } + m_showTelemetryOverlay = enabled; + update(); +} + +void MapWidget::setRecentPointsEnabled(bool enabled) +{ + if (m_showRecentPoints == enabled) { + return; + } + m_showRecentPoints = enabled; + update(); +} + +void MapWidget::zoomIn() +{ + setZoomKeepingPoint(m_zoom + 1, QPointF(width() / 2.0, height() / 2.0)); +} + +void MapWidget::zoomOut() +{ + setZoomKeepingPoint(m_zoom - 1, QPointF(width() / 2.0, height() / 2.0)); +} + +void MapWidget::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.fillRect(rect(), QColor(219, 225, 220)); + drawTiles(painter); + drawTelemetry(painter); + drawLegend(painter); + + if (m_points.isEmpty()) { + drawEmptyState(painter); + } +} + +void MapWidget::wheelEvent(QWheelEvent *event) +{ + const int delta = event->angleDelta().y(); + if (delta == 0) { + return; + } + + disableAutoFollowFromUserAction(); + setZoomKeepingPoint(m_zoom + (delta > 0 ? 1 : -1), event->position()); + event->accept(); +} + +void MapWidget::mousePressEvent(QMouseEvent *event) +{ + if (event->button() == Qt::RightButton) { + const QPointF coordinate = screenToCoordinate(event->position()); + double longitude = std::fmod(coordinate.y() + 180.0, 360.0); + if (longitude < 0.0) { + longitude += 360.0; + } + longitude -= 180.0; + emit referenceLocationSelected(coordinate.x(), longitude); + event->accept(); + return; + } + if (event->button() == Qt::LeftButton) { + disableAutoFollowFromUserAction(); + m_dragging = true; + m_lastMousePos = event->pos(); + setCursor(Qt::ClosedHandCursor); + event->accept(); + } +} + +void MapWidget::mouseMoveEvent(QMouseEvent *event) +{ + if (!m_dragging) { + return; + } + + const QPoint delta = event->pos() - m_lastMousePos; + m_lastMousePos = event->pos(); + + const QPointF centerWorld = coordinateToWorld(m_centerLat, m_centerLon, m_zoom); + const QPointF newCenterWorld = centerWorld - QPointF(delta); + const QPointF coordinate = worldToCoordinate(newCenterWorld, m_zoom); + m_centerLat = coordinate.x(); + m_centerLon = coordinate.y(); + update(); + event->accept(); +} + +void MapWidget::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton && m_dragging) { + m_dragging = false; + unsetCursor(); + event->accept(); + } +} + +void MapWidget::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + if (m_attributionLabel != nullptr) { + m_attributionLabel->adjustSize(); + m_attributionLabel->move(width() - m_attributionLabel->width() - 8, + height() - m_attributionLabel->height() - 8); + m_attributionLabel->raise(); + } +} + +QPointF MapWidget::coordinateToWorld(double latitude, double longitude, int zoom) const +{ + latitude = clampLatitude(latitude); + const double sinLat = std::sin(latitude * Pi / 180.0); + const double scale = static_cast(TileSize) * static_cast(1 << zoom); + + const double x = (longitude + 180.0) / 360.0 * scale; + const double y = (0.5 - std::log((1.0 + sinLat) / (1.0 - sinLat)) / (4.0 * Pi)) * scale; + return QPointF(x, y); +} + +QPointF MapWidget::worldToCoordinate(const QPointF &world, int zoom) const +{ + const double scale = static_cast(TileSize) * static_cast(1 << zoom); + const double longitude = world.x() / scale * 360.0 - 180.0; + const double n = Pi - 2.0 * Pi * world.y() / scale; + const double latitude = 180.0 / Pi * std::atan(0.5 * (std::exp(n) - std::exp(-n))); + return QPointF(clampLatitude(latitude), longitude); +} + +QPointF MapWidget::coordinateToScreen(double latitude, double longitude) const +{ + const QPointF world = coordinateToWorld(latitude, longitude, m_zoom); + const QPointF centerWorld = coordinateToWorld(m_centerLat, m_centerLon, m_zoom); + const double worldWidth = static_cast(TileSize) * static_cast(1 << m_zoom); + double deltaX = world.x() - centerWorld.x(); + if (deltaX > worldWidth / 2.0) { + deltaX -= worldWidth; + } else if (deltaX < -worldWidth / 2.0) { + deltaX += worldWidth; + } + return QPointF(width() / 2.0 + deltaX, height() / 2.0 + world.y() - centerWorld.y()); +} + +QPointF MapWidget::screenToCoordinate(const QPointF &screen) const +{ + return worldToCoordinate(topLeftWorld() + screen, m_zoom); +} + +QPointF MapWidget::topLeftWorld() const +{ + return coordinateToWorld(m_centerLat, m_centerLon, m_zoom) + - QPointF(width() / 2.0, height() / 2.0); +} + +void MapWidget::drawTiles(QPainter &painter) +{ + const int tileCount = 1 << m_zoom; + const QPointF topLeft = topLeftWorld(); + const int firstTileX = static_cast(std::floor(topLeft.x() / TileSize)); + const int lastTileX = static_cast(std::floor((topLeft.x() + width()) / TileSize)); + const int firstTileY = static_cast(std::floor(topLeft.y() / TileSize)); + const int lastTileY = static_cast(std::floor((topLeft.y() + height()) / TileSize)); + + painter.setRenderHint(QPainter::SmoothPixmapTransform, false); + + for (int tileY = firstTileY; tileY <= lastTileY; ++tileY) { + if (tileY < 0 || tileY >= tileCount) { + continue; + } + + for (int tileX = firstTileX; tileX <= lastTileX; ++tileX) { + const int wrappedTileX = positiveModulo(tileX, tileCount); + const QString key = tileKey(m_zoom, wrappedTileX, tileY); + const QRectF target( + tileX * TileSize - topLeft.x(), + tileY * TileSize - topLeft.y(), + TileSize, + TileSize); + + const auto tileIt = m_tileCache.constFind(key); + if (tileIt != m_tileCache.constEnd()) { + painter.drawPixmap(target.toRect(), *tileIt); + } else { + painter.fillRect(target, QColor(230, 234, 230)); + painter.setPen(QColor(198, 205, 200)); + painter.drawRect(target); + requestTile(wrappedTileX, tileY, m_zoom); + } + } + } +} + +void MapWidget::drawTelemetry(QPainter &painter) +{ + if (m_points.isEmpty() || !m_showTelemetryOverlay) { + return; + } + + painter.setRenderHint(QPainter::Antialiasing, true); + + drawCepOverlay(painter); + + const auto drawSourcePath = [this, &painter](LocationSource source, const QColor &color) { + QPainterPath path; + bool havePoint = false; + int pointCount = 0; + for (const TelemetryPoint &point : m_points) { + if (point.source != source) { + continue; + } + const QPointF screen = coordinateToScreen(point.latitude, point.longitude); + havePoint ? path.lineTo(screen) : path.moveTo(screen); + havePoint = true; + ++pointCount; + } + if (m_showRecentPoints && pointCount > 1) { + QPen pathPen(color, 3.0); + pathPen.setCapStyle(Qt::RoundCap); + pathPen.setJoinStyle(Qt::RoundJoin); + painter.setPen(pathPen); + painter.drawPath(path); + } + }; + drawSourcePath(LocationSource::Trajectory, QColor(22, 163, 74)); + drawSourcePath(LocationSource::Generic, QColor(210, 105, 30)); + drawSourcePath(LocationSource::Gps, QColor(37, 99, 235)); + drawSourcePath(LocationSource::ImuCorrected, QColor(220, 38, 38)); + + drawAverageOverlay(painter); + drawCepMarker(painter); + + const bool drawNumbers = m_points.size() <= 150; + const int currentPointIndex = preferredCurrentPointIndex(m_points); + const int firstPoint = m_showRecentPoints ? 0 : currentPointIndex; + const int endPoint = m_showRecentPoints ? m_points.size() : currentPointIndex + 1; + for (int i = firstPoint; i < endPoint; ++i) { + const bool isLast = i == currentPointIndex; + const QPointF screen = coordinateToScreen(m_points[i].latitude, m_points[i].longitude); + const double radius = isLast ? 8.0 : 5.0; + + const QColor pointColor = m_points[i].source == LocationSource::ImuCorrected + ? QColor(220, 38, 38) + : (m_points[i].source == LocationSource::Gps + ? QColor(37, 99, 235) + : (m_points[i].source == LocationSource::Trajectory + ? QColor(22, 163, 74) + : (isLast ? QColor(210, 105, 30) : QColor(234, 88, 12)))); + painter.setPen(QPen(Qt::white, isLast ? 3.0 : 2.0)); + painter.setBrush(pointColor); + painter.drawEllipse(screen, radius, radius); + + if (drawNumbers) { + painter.setPen(Qt::white); + painter.setFont(QFont(painter.font().family(), 8, QFont::Bold)); + painter.drawText(QRectF(screen.x() - 13, screen.y() - 9, 26, 18), Qt::AlignCenter, + QString::number(i + 1)); + } + } + + const TelemetryPoint &last = m_points[currentPointIndex]; + const QPointF lastScreen = coordinateToScreen(last.latitude, last.longitude); + QStringList lines; + const QString latitudeText = last.latitudeText.isEmpty() + ? QString::number(last.latitude, 'f', 15) : last.latitudeText; + const QString longitudeText = last.longitudeText.isEmpty() + ? QString::number(last.longitude, 'f', 15) : last.longitudeText; + lines << QStringLiteral("Lat: %1").arg(latitudeText); + lines << QStringLiteral("Long: %1").arg(longitudeText); + lines << QStringLiteral("Kaynak: %1").arg(locationSourceText(last.source)); + if (last.hheight.has_value()) { + lines << QStringLiteral("HHeight: %1").arg(*last.hheight, 0, 'f', 2); + } + if (last.satellites.has_value()) { + lines << QStringLiteral("Aktif uydu: %1").arg(*last.satellites); + } + if (m_cepRadiusMeters.has_value()) { + lines << QStringLiteral("CEP50: %1 m").arg(*m_cepRadiusMeters, 0, 'f', 2); + } + + painter.setFont(QFont(painter.font().family(), 10)); + const QString label = lines.join('\n'); + const QRect textRect = painter.fontMetrics().boundingRect(QRect(0, 0, 300, 150), + Qt::TextWordWrap, label); + QRectF bubble(lastScreen + QPointF(14, -textRect.height() - 12), + QSizeF(textRect.width() + 18, textRect.height() + 14)); + if (bubble.right() > width() - 8) { + bubble.moveRight(width() - 8); + } + if (bubble.top() < 8) { + bubble.moveTop(8); + } + + painter.setPen(QPen(QColor(50, 58, 65, 180), 1.0)); + painter.setBrush(QColor(255, 255, 255, 232)); + painter.drawRoundedRect(bubble, 6, 6); + painter.setPen(QColor(25, 32, 38)); + painter.drawText(bubble.adjusted(9, 7, -9, -7), Qt::AlignLeft | Qt::AlignVCenter, label); +} + +void MapWidget::drawLegend(QPainter &painter) +{ + if (m_points.isEmpty() || !m_showTelemetryOverlay) { + return; + } + const struct { + const char *text; + QColor color; + } entries[] = { + {"Trajectory (truth)", QColor(22, 163, 74)}, + {"GPS (4 satellite)", QColor(37, 99, 235)}, + {"IMU + ESKF corrected", QColor(220, 38, 38)} + }; + const QRectF box(12.0, 12.0, 190.0, 76.0); + painter.setPen(QPen(QColor(50, 58, 65, 180), 1.0)); + painter.setBrush(QColor(255, 255, 255, 232)); + painter.drawRoundedRect(box, 6.0, 6.0); + painter.setFont(QFont(painter.font().family(), 9, QFont::DemiBold)); + for (int index = 0; index < 3; ++index) { + const double y = 27.0 + index * 22.0; + painter.setPen(QPen(entries[index].color, 3.0)); + painter.drawLine(QPointF(23.0, y), QPointF(43.0, y)); + painter.setBrush(entries[index].color); + painter.drawEllipse(QPointF(33.0, y), 4.0, 4.0); + painter.setPen(QColor(25, 32, 38)); + painter.drawText(QRectF(50.0, y - 9.0, 142.0, 18.0), + Qt::AlignLeft | Qt::AlignVCenter, + QString::fromLatin1(entries[index].text)); + } +} + +void MapWidget::drawCepOverlay(QPainter &painter) +{ + if (!m_hasCepMarker || !m_cepRadiusMeters.has_value() || *m_cepRadiusMeters <= 0.0) { + return; + } + + const QPointF screen = coordinateToScreen(m_cepLatitude, m_cepLongitude); + const double resolution = metersPerPixel(m_cepLatitude, m_zoom); + if (!std::isfinite(resolution) || resolution <= 0.0) { + return; + } + + const double radiusPixels = std::min(*m_cepRadiusMeters / resolution, 2500.0); + if (radiusPixels < 1.0) { + return; + } + + painter.setPen(QPen(QColor(8, 111, 104, 210), 2.0)); + painter.setBrush(QColor(13, 148, 136, 42)); + painter.drawEllipse(screen, radiusPixels, radiusPixels); +} + +void MapWidget::drawCepMarker(QPainter &painter) +{ + if (!m_hasCepMarker) { + return; + } + + const QPointF screen = coordinateToScreen(m_cepLatitude, m_cepLongitude); + painter.setPen(QPen(QColor(8, 111, 104), 2.5)); + painter.setBrush(QColor(255, 255, 255, 230)); + painter.drawEllipse(screen, 7.0, 7.0); + painter.drawLine(QPointF(screen.x() - 11.0, screen.y()), + QPointF(screen.x() + 11.0, screen.y())); + painter.drawLine(QPointF(screen.x(), screen.y() - 11.0), + QPointF(screen.x(), screen.y() + 11.0)); + + const bool overlapsLast = !m_points.isEmpty() + && QLineF(screen, coordinateToScreen(m_points.last().latitude, + m_points.last().longitude)).length() < 30.0; + if (!overlapsLast) { + painter.setFont(QFont(painter.font().family(), 9, QFont::Bold)); + painter.setPen(QColor(20, 83, 78)); + painter.drawText(QRectF(screen + QPointF(12.0, -10.0), QSizeF(110.0, 20.0)), + Qt::AlignLeft | Qt::AlignVCenter, QStringLiteral("Bilinen konum")); + } +} + +void MapWidget::drawAverageOverlay(QPainter &painter) +{ + if (!m_showAverageOverlay || !m_hasAverageMarker) { + return; + } + + const QPointF screen = coordinateToScreen(m_averageLat, m_averageLon); + const double resolution = metersPerPixel(m_averageLat, m_zoom); + if (std::isfinite(resolution) && resolution > 0.0 && m_averageRadiusMeters > 0.0) { + const double radiusPixels = std::min(m_averageRadiusMeters / resolution, 2500.0); + if (radiusPixels >= 2.0) { + painter.setPen(QPen(QColor(124, 58, 237, 150), 2.0, Qt::DashLine)); + painter.setBrush(QColor(124, 58, 237, 30)); + painter.drawEllipse(screen, radiusPixels, radiusPixels); + } + } + + painter.setPen(QPen(Qt::white, 3.0)); + painter.setBrush(QColor(124, 58, 237)); + painter.drawEllipse(screen, 9.0, 9.0); + + painter.setPen(QPen(QColor(25, 32, 38), 2.0)); + painter.drawLine(QPointF(screen.x() - 12.0, screen.y()), QPointF(screen.x() + 12.0, screen.y())); + painter.drawLine(QPointF(screen.x(), screen.y() - 12.0), QPointF(screen.x(), screen.y() + 12.0)); + + const bool overlapsLast = !m_points.isEmpty() + && QLineF(screen, coordinateToScreen(m_points.last().latitude, + m_points.last().longitude)).length() < 30.0; + if (!overlapsLast) { + painter.setFont(QFont(painter.font().family(), 9, QFont::Bold)); + const QRectF labelRect(screen + QPointF(12.0, 8.0), QSizeF(72.0, 20.0)); + painter.setPen(QColor(25, 32, 38)); + painter.drawText(labelRect, Qt::AlignLeft | Qt::AlignVCenter, QStringLiteral("Ortalama")); + } +} + +void MapWidget::drawEmptyState(QPainter &painter) +{ + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(QColor(83, 96, 110)); + painter.setFont(QFont(painter.font().family(), 13)); + painter.drawText(rect(), Qt::AlignCenter, + QStringLiteral("Seri porttan lat / long / hheight bekleniyor")); +} + +void MapWidget::requestTile(int tileX, int tileY, int zoom) +{ + const QString key = tileKey(zoom, tileX, tileY); + if (m_tileCache.contains(key) || m_pendingTiles.contains(key)) { + return; + } + + m_pendingTiles.insert(key); + const QUrl url(QStringLiteral("https://tile.openstreetmap.org/%1/%2/%3.png") + .arg(zoom) + .arg(tileX) + .arg(tileY)); + QNetworkRequest request(url); + request.setRawHeader("User-Agent", "RocketLocationPlotter/0.2 (Qt desktop application)"); + QNetworkReply *reply = m_network->get(request); + reply->setProperty("tileKey", key); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { + const QString key = reply->property("tileKey").toString(); + m_pendingTiles.remove(key); + + if (reply->error() == QNetworkReply::NoError) { + QPixmap pixmap; + if (pixmap.loadFromData(reply->readAll())) { + if (!m_tileCache.contains(key) && m_tileCache.size() >= MaxMemoryTileCount) { + m_tileCache.erase(m_tileCache.begin()); + } + m_tileCache.insert(key, pixmap); + update(); + } + } + + reply->deleteLater(); + }); +} + +void MapWidget::setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor) +{ + newZoom = std::clamp(newZoom, MinZoom, MaxZoom); + if (newZoom == m_zoom) { + return; + } + + const QPointF coordinateAtAnchor = screenToCoordinate(screenAnchor); + m_zoom = newZoom; + const QPointF anchorWorld = coordinateToWorld(coordinateAtAnchor.x(), coordinateAtAnchor.y(), m_zoom); + const QPointF newCenterWorld = anchorWorld - (screenAnchor - QPointF(width() / 2.0, height() / 2.0)); + const QPointF newCenter = worldToCoordinate(newCenterWorld, m_zoom); + m_centerLat = newCenter.x(); + m_centerLon = newCenter.y(); + update(); +} + +void MapWidget::disableAutoFollowFromUserAction() +{ + if (!m_autoFollow) { + return; + } + + m_autoFollow = false; + emit autoFollowChanged(false); +} diff --git a/linux/src/MapWidget.h b/linux/src/MapWidget.h new file mode 100644 index 0000000..5e67a52 --- /dev/null +++ b/linux/src/MapWidget.h @@ -0,0 +1,93 @@ +#pragma once + +#include "TelemetryPoint.h" + +#include +#include +#include +#include +#include + +#include + +class QNetworkAccessManager; +class QLabel; + +class MapWidget : public QWidget { + Q_OBJECT + +public: + explicit MapWidget(QWidget *parent = nullptr); + + void setPoints(const QVector &points); + void fitToPoints(); + void clearPoints(); + void setAverageMarker(double latitude, double longitude, double radiusMeters); + void clearAverageMarker(); + void setCepMarker(double latitude, double longitude, + const std::optional &radiusMeters); + void clearCepMarker(); + [[nodiscard]] QPointF centerCoordinate() const; + +public slots: + void setAutoFollow(bool enabled); + void setTelemetryOverlayEnabled(bool enabled); + void setAverageOverlayEnabled(bool enabled); + void setRecentPointsEnabled(bool enabled); + void zoomIn(); + void zoomOut(); + +signals: + void autoFollowChanged(bool enabled); + void referenceLocationSelected(double latitude, double longitude); + +protected: + void paintEvent(QPaintEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + +private: + QPointF coordinateToWorld(double latitude, double longitude, int zoom) const; + QPointF worldToCoordinate(const QPointF &world, int zoom) const; + QPointF coordinateToScreen(double latitude, double longitude) const; + QPointF screenToCoordinate(const QPointF &screen) const; + QPointF topLeftWorld() const; + + void drawTiles(QPainter &painter); + void drawTelemetry(QPainter &painter); + void drawLegend(QPainter &painter); + void drawAverageOverlay(QPainter &painter); + void drawCepOverlay(QPainter &painter); + void drawCepMarker(QPainter &painter); + void drawEmptyState(QPainter &painter); + void requestTile(int tileX, int tileY, int zoom); + void setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor); + void disableAutoFollowFromUserAction(); + + QVector m_points; + QNetworkAccessManager *m_network = nullptr; + QLabel *m_attributionLabel = nullptr; + QHash m_tileCache; + QSet m_pendingTiles; + + double m_centerLat = 39.0; + double m_centerLon = 35.0; + int m_zoom = 6; + bool m_autoFollow = true; + bool m_showTelemetryOverlay = true; + bool m_showAverageOverlay = true; + bool m_showRecentPoints = true; + bool m_hasAverageMarker = false; + double m_averageLat = 0.0; + double m_averageLon = 0.0; + double m_averageRadiusMeters = 0.0; + bool m_hasCepMarker = false; + double m_cepLatitude = 0.0; + double m_cepLongitude = 0.0; + std::optional m_cepRadiusMeters; + bool m_dragging = false; + QPoint m_lastMousePos; +}; diff --git a/linux/src/RinexNavigation.cpp b/linux/src/RinexNavigation.cpp new file mode 100644 index 0000000..c839d5a --- /dev/null +++ b/linux/src/RinexNavigation.cpp @@ -0,0 +1,374 @@ +#include "RinexNavigation.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include +#include + +namespace { + +constexpr double GpsMu = 3.986005e14; +constexpr double EarthRotation = 7.2921151467e-5; +constexpr double GpsWeekSeconds = 604800.0; +constexpr double HalfGpsWeek = GpsWeekSeconds / 2.0; +constexpr double CurrentGpsUtcOffsetSeconds = 18.0; +constexpr int PositionUpdateIntervalMs = 60000; + +double parseField(const QString &line, int field) +{ + QString text = line.mid(4 + field * 19, 19).trimmed(); + text.replace(QChar('D'), QChar('E'), Qt::CaseInsensitive); + bool ok = false; + const double value = text.toDouble(&ok); + return ok ? value : std::numeric_limits::quiet_NaN(); +} + +bool finiteFields(std::initializer_list values) +{ + return std::all_of(values.begin(), values.end(), [](double value) { + return std::isfinite(value); + }); +} + +QByteArray gunzip(const QByteArray &input) +{ + if (input.size() < 2 || static_cast(input[0]) != 0x1f + || static_cast(input[1]) != 0x8b) { + return input; + } + + z_stream stream{}; + stream.next_in = reinterpret_cast(const_cast(input.constData())); + stream.avail_in = static_cast(input.size()); + if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) { + return {}; + } + + QByteArray output; + char buffer[64 * 1024]; + int result = Z_OK; + while (result == Z_OK) { + stream.next_out = reinterpret_cast(buffer); + stream.avail_out = sizeof(buffer); + result = inflate(&stream, Z_NO_FLUSH); + output.append(buffer, static_cast(sizeof(buffer) - stream.avail_out)); + } + inflateEnd(&stream); + return result == Z_STREAM_END ? output : QByteArray{}; +} + +QDateTime gpsEpoch() +{ + return QDateTime(QDate(1980, 1, 6), QTime(0, 0), Qt::UTC); +} + +} // namespace + +RinexNavigation::RinexNavigation(QObject *parent) + : QObject(parent) +{ + m_positionTimer.setInterval(PositionUpdateIntervalMs); + connect(&m_positionTimer, &QTimer::timeout, + this, &RinexNavigation::updateCurrentPositions); + m_positionTimer.start(); +} + +void RinexNavigation::refresh() +{ + if (m_loading) return; + if (m_ephemerides.isEmpty()) { + loadCache(); + } + setLoading(true); + emit statusChanged(QStringLiteral("Guncel RINEX efemerisi indiriliyor..."), false); + requestDay(QDateTime::currentDateTimeUtc().date(), 0); +} + +void RinexNavigation::requestDay(const QDate &date, int fallbackIndex) +{ + const QString year = QString::number(date.year()); + const QString day = QStringLiteral("%1").arg(date.dayOfYear(), 3, 10, QLatin1Char('0')); + // BKG's stream-derived file is updated during the UTC day; the final IGS + // combination is normally published later and is therefore unsuitable here. + const QString file = QStringLiteral("BRDC00WRD_S_") + year + day + + QStringLiteral("0000_01D_MN.rnx.gz"); + const QUrl url(QStringLiteral("https://igs.bkg.bund.de/root_ftp/IGS/BRDC/%1/%2/%3") + .arg(year, day, file)); + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, + QStringLiteral("ANKASYS-LocationPlotter/1.0 (RINEX client)")); + request.setTransferTimeout(15000); + QNetworkReply *reply = m_network.get(request); + connect(reply, &QNetworkReply::finished, this, [this, reply, date, fallbackIndex] { + const bool ok = reply->error() == QNetworkReply::NoError; + const QByteArray data = ok ? reply->readAll() : QByteArray{}; + reply->deleteLater(); + if (!ok || data.isEmpty()) { + if (fallbackIndex < 2) { + requestDay(date.addDays(-1), fallbackIndex + 1); + } else { + setLoading(false); + if (!m_ephemerides.isEmpty()) { + publishCurrentMotion(); + emit statusChanged( + QStringLiteral("RINEX indirilemedi; mevcut efemeris kullaniliyor"), + true); + } else if (!loadCache()) { + emit statusChanged(QStringLiteral("RINEX indirilemedi; internet baglantisini kontrol edin"), true); + } + } + return; + } + handleDownload(data, date, fallbackIndex); + }); +} + +void RinexNavigation::handleDownload(const QByteArray &compressed, const QDate &date, + int fallbackIndex) +{ + const QByteArray rinex = gunzip(compressed); + QString error; + QVector ephemerides = parseEphemerides(rinex, &error); + const QDateTime now = QDateTime::currentDateTimeUtc(); + const QVector positions = propagateEphemerides( + selectEphemerides(ephemerides, now), now); + if (positions.isEmpty()) { + if (fallbackIndex < 2) { + requestDay(date.addDays(-1), fallbackIndex + 1); + return; + } + setLoading(false); + emit statusChanged(QStringLiteral("RINEX okunamadi: %1").arg(error), true); + return; + } + + QFile cache(cachePath()); + QDir().mkpath(QFileInfo(cache).absolutePath()); + if (cache.open(QIODevice::WriteOnly)) cache.write(rinex); + m_ephemerides = std::move(ephemerides); + setLoading(false); + publishCurrentMotion(); + emit statusChanged(QStringLiteral("%1 GPS uydusu · RINEX %2 · %3") + .arg(positions.size()) + .arg(date.toString(QStringLiteral("dd.MM.yyyy"))) + .arg(now.toLocalTime().toString( + QStringLiteral("HH:mm:ss t"))), false); +} + +bool RinexNavigation::loadCache() +{ + QFile cache(cachePath()); + if (!cache.open(QIODevice::ReadOnly)) return false; + QString error; + QVector ephemerides = parseEphemerides(cache.readAll(), &error); + const QDateTime now = QDateTime::currentDateTimeUtc(); + const QVector positions = propagateEphemerides( + selectEphemerides(ephemerides, now), now); + if (positions.isEmpty()) return false; + m_ephemerides = std::move(ephemerides); + publishCurrentMotion(); + emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX · %2") + .arg(positions.size()) + .arg(now.toLocalTime().toString( + QStringLiteral("HH:mm:ss t"))), true); + return true; +} + +QString RinexNavigation::cachePath() const +{ + return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + + QStringLiteral("/latest-navigation.rnx"); +} + +void RinexNavigation::updateCurrentPositions() +{ + if (m_ephemerides.isEmpty()) return; + publishCurrentMotion(); +} + +void RinexNavigation::publishCurrentMotion() +{ + if (m_ephemerides.isEmpty()) return; + + const QDateTime now = QDateTime::currentDateTimeUtc(); + const QVector selected = selectEphemerides(m_ephemerides, now); + const QVector current = propagateEphemerides(selected, now); + if (current.isEmpty()) return; + + const QVector next = propagateEphemerides( + selected, now.addMSecs(PositionUpdateIntervalMs)); + emit satellitesUpdated(current); + if (!next.isEmpty()) { + emit satelliteMotionUpdated(current, next, PositionUpdateIntervalMs); + } +} + +void RinexNavigation::setLoading(bool loading) +{ + if (m_loading == loading) return; + m_loading = loading; + emit loadingChanged(loading); +} + +QVector RinexNavigation::parseEphemerides( + const QByteArray &rinex, QString *error) +{ + const QString text = QString::fromLatin1(rinex); + const QStringList lines = text.split('\n'); + int index = 0; + while (index < lines.size() && !lines[index].contains(QStringLiteral("END OF HEADER"))) ++index; + if (index >= lines.size()) { + if (error) *error = QStringLiteral("END OF HEADER bulunamadi"); + return {}; + } + ++index; + + QVector result; + result.reserve(512); + while (index < lines.size()) { + const QString first = lines[index]; + if (first.size() < 3) { ++index; continue; } + const QChar system = first[0]; + if (!system.isLetter()) { ++index; continue; } + const int blockLines = (system == QLatin1Char('R') || system == QLatin1Char('S')) ? 4 : 8; + if (index + blockLines > lines.size()) break; + if (system != QLatin1Char('G')) { index += blockLines; continue; } + + const QStringList epoch = first.mid(3, 20).simplified().split(' '); + if (epoch.size() < 6) { index += 8; continue; } + bool dateOk = false; + const int year = epoch[0].toInt(&dateOk); + const QDateTime toc(QDate(year, epoch[1].toInt(), epoch[2].toInt()), + QTime(epoch[3].toInt(), epoch[4].toInt(), + static_cast(epoch[5].toDouble())), Qt::UTC); + if (!dateOk || !toc.isValid()) { index += 8; continue; } + + Ephemeris eph; + eph.prn = first.left(3).trimmed().toUpper(); + eph.toc = toc; + eph.crs = parseField(lines[index + 1], 1); + eph.deltaN = parseField(lines[index + 1], 2); + eph.m0 = parseField(lines[index + 1], 3); + eph.cuc = parseField(lines[index + 2], 0); + eph.eccentricity = parseField(lines[index + 2], 1); + eph.cus = parseField(lines[index + 2], 2); + eph.sqrtA = parseField(lines[index + 2], 3); + eph.toe = parseField(lines[index + 3], 0); + eph.cic = parseField(lines[index + 3], 1); + eph.omega0 = parseField(lines[index + 3], 2); + eph.cis = parseField(lines[index + 3], 3); + eph.i0 = parseField(lines[index + 4], 0); + eph.crc = parseField(lines[index + 4], 1); + eph.omega = parseField(lines[index + 4], 2); + eph.omegaDot = parseField(lines[index + 4], 3); + eph.idot = parseField(lines[index + 5], 0); + eph.gpsWeek = static_cast(std::llround(parseField(lines[index + 5], 2))); + const double health = parseField(lines[index + 6], 1); + eph.healthy = !std::isfinite(health) || std::llround(health) == 0; + + if (finiteFields({eph.crs, eph.deltaN, eph.m0, eph.cuc, eph.eccentricity, + eph.cus, eph.sqrtA, eph.toe, eph.cic, eph.omega0, eph.cis, + eph.i0, eph.crc, eph.omega, eph.omegaDot, eph.idot}) + && eph.sqrtA > 4000.0 && eph.gpsWeek > 0) { + result.push_back(eph); + } + index += 8; + } + if (result.isEmpty() && error) *error = QStringLiteral("GPS efemerisi bulunamadi"); + return result; +} + +QVector RinexNavigation::selectEphemerides( + const QVector &ephemerides, const QDateTime &whenUtc) +{ + QHash closest; + QHash closestAge; + // GPS navigation epochs are labelled in GPS system time, not UTC. + const QDateTime whenGps = whenUtc.addSecs( + static_cast(CurrentGpsUtcOffsetSeconds)); + for (const Ephemeris &eph : ephemerides) { + const qint64 age = std::abs(eph.toc.msecsTo(whenGps)); + if (!closestAge.contains(eph.prn) || age < closestAge[eph.prn]) { + closest[eph.prn] = eph; + closestAge[eph.prn] = age; + } + } + + QVector result; + result.reserve(closest.size()); + for (const Ephemeris &eph : closest) result.push_back(eph); + std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) { + return a.prn < b.prn; + }); + return result; +} + +QVector RinexNavigation::propagateEphemerides( + const QVector &ephemerides, const QDateTime &whenUtc) +{ + QVector result; + result.reserve(ephemerides.size()); + for (const Ephemeris &eph : ephemerides) result.push_back(propagate(eph, whenUtc)); + std::sort(result.begin(), result.end(), [](const auto &a, const auto &b) { return a.prn < b.prn; }); + return result; +} + +SatellitePosition RinexNavigation::propagate(const Ephemeris &eph, const QDateTime &whenUtc) +{ + const double gpsSeconds = gpsEpoch().msecsTo(whenUtc) / 1000.0 + + CurrentGpsUtcOffsetSeconds; + const double absoluteToe = eph.gpsWeek * GpsWeekSeconds + eph.toe; + double tk = gpsSeconds - absoluteToe; + while (tk > HalfGpsWeek) tk -= GpsWeekSeconds; + while (tk < -HalfGpsWeek) tk += GpsWeekSeconds; + + const double a = eph.sqrtA * eph.sqrtA; + const double meanMotion = std::sqrt(GpsMu / (a * a * a)) + eph.deltaN; + const double meanAnomaly = eph.m0 + meanMotion * tk; + double eccentricAnomaly = meanAnomaly; + for (int iteration = 0; iteration < 12; ++iteration) { + eccentricAnomaly = meanAnomaly + eph.eccentricity * std::sin(eccentricAnomaly); + } + + const double trueAnomaly = std::atan2( + std::sqrt(1.0 - eph.eccentricity * eph.eccentricity) * std::sin(eccentricAnomaly), + std::cos(eccentricAnomaly) - eph.eccentricity); + const double phi = trueAnomaly + eph.omega; + const double twoPhi = 2.0 * phi; + const double u = phi + eph.cus * std::sin(twoPhi) + eph.cuc * std::cos(twoPhi); + const double radius = a * (1.0 - eph.eccentricity * std::cos(eccentricAnomaly)) + + eph.crs * std::sin(twoPhi) + eph.crc * std::cos(twoPhi); + const double inclination = eph.i0 + eph.idot * tk + + eph.cis * std::sin(twoPhi) + eph.cic * std::cos(twoPhi); + const double orbitalX = radius * std::cos(u); + const double orbitalY = radius * std::sin(u); + const double ascendingNode = eph.omega0 + (eph.omegaDot - EarthRotation) * tk + - EarthRotation * eph.toe; + + SatellitePosition result; + result.prn = eph.prn; + result.x = orbitalX * std::cos(ascendingNode) + - orbitalY * std::cos(inclination) * std::sin(ascendingNode); + result.y = orbitalX * std::sin(ascendingNode) + + orbitalY * std::cos(inclination) * std::cos(ascendingNode); + result.z = orbitalY * std::sin(inclination); + result.healthy = eph.healthy; + return result; +} + +QVector RinexNavigation::parseGpsNavigation( + const QByteArray &rinex, const QDateTime &whenUtc, QString *error) +{ + const QVector ephemerides = parseEphemerides(rinex, error); + return propagateEphemerides(selectEphemerides(ephemerides, whenUtc), whenUtc); +} diff --git a/src/RinexNavigation.h b/linux/src/RinexNavigation.h similarity index 100% rename from src/RinexNavigation.h rename to linux/src/RinexNavigation.h diff --git a/src/SatelliteData.h b/linux/src/SatelliteData.h similarity index 100% rename from src/SatelliteData.h rename to linux/src/SatelliteData.h diff --git a/src/SerialReader.cpp b/linux/src/SerialReader.cpp similarity index 100% rename from src/SerialReader.cpp rename to linux/src/SerialReader.cpp diff --git a/src/SerialReader.h b/linux/src/SerialReader.h similarity index 100% rename from src/SerialReader.h rename to linux/src/SerialReader.h diff --git a/src/TelemetryPoint.h b/linux/src/TelemetryPoint.h similarity index 100% rename from src/TelemetryPoint.h rename to linux/src/TelemetryPoint.h diff --git a/src/UartReplay.cpp b/linux/src/UartReplay.cpp similarity index 100% rename from src/UartReplay.cpp rename to linux/src/UartReplay.cpp diff --git a/src/UartReplay.h b/linux/src/UartReplay.h similarity index 100% rename from src/UartReplay.h rename to linux/src/UartReplay.h diff --git a/src/main.cpp b/linux/src/main.cpp similarity index 100% rename from src/main.cpp rename to linux/src/main.cpp diff --git a/linux/tests/main_window_tests.cpp b/linux/tests/main_window_tests.cpp new file mode 100644 index 0000000..908444f --- /dev/null +++ b/linux/tests/main_window_tests.cpp @@ -0,0 +1,272 @@ +#include "../src/MainWindow.h" +#include "../src/GlobeWidget.h" +#include "../src/MapWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +void sendLine(MainWindow &window, const QString &line) +{ + const bool invoked = QMetaObject::invokeMethod( + &window, "handleSerialLine", Qt::DirectConnection, Q_ARG(QString, line)); + assert(invoked); +} + +void flushUpdates(MainWindow &window) +{ + const bool invoked = QMetaObject::invokeMethod( + &window, "flushUiUpdates", Qt::DirectConnection); + assert(invoked); +} + +} // namespace + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + qputenv("LOCATION_PLOTTER_PORT", "/dev/test-usb-uart"); + MainWindow window; + qunsetenv("LOCATION_PLOTTER_PORT"); + + { + QTemporaryDir uartLogDirectory; + assert(uartLogDirectory.isValid()); + qputenv("LOCATION_PLOTTER_UART_LOG_DIR", uartLogDirectory.path().toUtf8()); + bool loggingStarted = false; + assert(QMetaObject::invokeMethod( + &window, "startUartLogging", Qt::DirectConnection, + Q_RETURN_ARG(bool, loggingStarted), + Q_ARG(QString, QStringLiteral("/dev/test-uart")), Q_ARG(int, 460800))); + assert(loggingStarted); + sendLine(window, QStringLiteral("raw uart test line")); + assert(QMetaObject::invokeMethod(&window, "stopUartLogging", Qt::DirectConnection)); + qunsetenv("LOCATION_PLOTTER_UART_LOG_DIR"); + + const QStringList logFiles = QDir(uartLogDirectory.path()).entryList( + {QStringLiteral("uart_*.log")}, QDir::Files); + assert(logFiles.size() == 1); + QFile logFile(QDir(uartLogDirectory.path()).filePath(logFiles.constFirst())); + assert(logFile.open(QIODevice::ReadOnly | QIODevice::Text)); + const QString contents = QString::fromUtf8(logFile.readAll()); + assert(contents.contains(QStringLiteral("[SESSION START] port=/dev/test-uart baud=460800"))); + assert(contents.contains(QRegularExpression( + QStringLiteral(R"(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}\] raw uart test line)")))); + assert(contents.contains(QStringLiteral("[SESSION END]"))); + } + + auto *table = window.findChild(); + auto *referenceLatInput = window.findChild(QStringLiteral("referenceLatInput")); + auto *referenceLonInput = window.findChild(QStringLiteral("referenceLonInput")); + auto *cepValue = window.findChild(QStringLiteral("cepValue")); + auto *maxPointsInput = window.findChild(QStringLiteral("maxPointsInput")); + auto *elevationMaskSpin = window.findChild(QStringLiteral("elevationMaskSpin")); + auto *showTelemetry = window.findChild(QStringLiteral("showTelemetryCheck")); + auto *showRecentPoints = window.findChild(QStringLiteral("showRecentPointsCheck")); + auto *showAverage = window.findChild(QStringLiteral("showAverageCheck")); + auto *portSelector = window.findChild(QStringLiteral("portSelector")); + auto *refreshRinexButton = + window.findChild(QStringLiteral("refreshRinexButton")); + auto *map = window.findChild(); + auto *mainViewTabs = window.findChild(QStringLiteral("mainViewTabs")); + auto *sideSplitter = window.findChild(QStringLiteral("sideSplitter")); + assert(table != nullptr); + assert(referenceLatInput != nullptr); + assert(referenceLonInput != nullptr); + assert(cepValue != nullptr); + assert(maxPointsInput != nullptr); + assert(elevationMaskSpin != nullptr); + assert(elevationMaskSpin->value() == 10); + assert(elevationMaskSpin->minimum() == 0); + assert(elevationMaskSpin->maximum() == 90.0); + elevationMaskSpin->setValue(12.5); + assert(elevationMaskSpin->value() == 12.5); + assert(showTelemetry != nullptr); + assert(showRecentPoints != nullptr); + assert(showAverage != nullptr); + assert(portSelector != nullptr); + assert(refreshRinexButton != nullptr); + assert(refreshRinexButton->text() == QStringLiteral("RINEX Guncelle") + || refreshRinexButton->text() == QStringLiteral("RINEX indiriliyor...")); + assert(portSelector->isEditable()); + assert(portSelector->count() >= 1); + assert(portSelector->currentText() == QStringLiteral("/dev/test-usb-uart")); + for (int i = 0; i < portSelector->count(); ++i) { + assert(portSelector->itemText(i).startsWith(QStringLiteral("/dev/"))); + assert(!portSelector->itemText(i).trimmed().isEmpty()); + } + assert(map != nullptr); + assert(mainViewTabs != nullptr); + assert(mainViewTabs->count() == 2); + assert(sideSplitter != nullptr); + assert(sideSplitter->orientation() == Qt::Vertical); + + sendLine(window, QStringLiteral("[TRK] tracked satellites=2 prn_indices=[2,7]")); + assert(map != nullptr); + auto *globe = window.findChild(); + assert(globe != nullptr); + assert(globe->observedPrns() + == QSet({QStringLiteral("G03"), QStringLiteral("G08")})); + sendLine(window, QStringLiteral("[TRK] tracked satellites=1 prn_indices=[13]")); + assert(globe->observedPrns() == QSet({QStringLiteral("G14")})); + + assert(QMetaObject::invokeMethod(map, "referenceLocationSelected", Qt::DirectConnection, + Q_ARG(double, 40.25), Q_ARG(double, 29.5))); + assert(referenceLatInput->text() == QStringLiteral("40.250000000000000")); + assert(referenceLonInput->text() == QStringLiteral("29.500000000000000")); + referenceLatInput->clear(); + referenceLonInput->clear(); + assert(QMetaObject::invokeMethod(&window, "updateReferenceLocation", Qt::DirectConnection)); + showTelemetry->setChecked(false); + assert(!showRecentPoints->isEnabled()); + assert(!showAverage->isEnabled()); + showTelemetry->setChecked(true); + assert(showRecentPoints->isEnabled()); + assert(showAverage->isEnabled()); + + sendLine(window, QStringLiteral("lattitude : 40.78925034214206846173")); + sendLine(window, QStringLiteral("longitude : 29.46277530841727809729")); + assert(table->model()->rowCount() == 0); + + sendLine(window, QStringLiteral("NAV solve done")); + flushUpdates(window); + assert(table->model()->rowCount() == 1); + assert(table->model()->data(table->model()->index(0, 2)).toString() + == QStringLiteral("40.78925034214206846173")); + assert(table->model()->data(table->model()->index(0, 3)).toString() + == QStringLiteral("29.46277530841727809729")); + + sendLine(window, QStringLiteral( + "[LOCATION] source=gps lat=40.1 lon=29.1 height=100 gdop=2 pdop=1.5 " + "hdop=1 vdop=1.2 tdop=0.8 tracked=5 used=5")); + sendLine(window, QStringLiteral( + "[LOCATION] source=imu_corrected lat=40.10001 lon=29.10001 height=100.2 " + "gdop=2 pdop=1.5 hdop=1 vdop=1.2 tdop=0.8 tracked=5 used=5")); + flushUpdates(window); + assert(table->model()->rowCount() == 3); + assert(table->model()->data(table->model()->index(1, 1)).toString() + == QStringLiteral("GPS")); + assert(table->model()->data(table->model()->index(2, 1)).toString() + == QStringLiteral("IMU corrected")); + sendLine(window, QStringLiteral( + "[LOCATION] source=trajectory lat=40.10002 lon=29.10002 height=100 tracked=4 used=4")); + flushUpdates(window); + assert(table->model()->rowCount() == 4); + assert(table->model()->data(table->model()->index(3, 1)).toString() + == QStringLiteral("Trajectory (truth)")); + + QTemporaryFile replayLog; + assert(replayLog.open()); + replayLog.write( + "[LOCATION] source=trajectory lat=40.0 lon=29.0 height=100 tracked=4 used=4\n" + "[LOCATION] source=gps lat=40.0001 lon=29.0001 height=104 tracked=4 used=4\n" + "[LOCATION] source=imu_corrected lat=40.00002 lon=29.00002 height=101 tracked=4 used=4\n"); + replayLog.flush(); + assert(window.loadTelemetryLog(replayLog.fileName())); + assert(table->model()->rowCount() == 3); + assert(table->model()->data(table->model()->index(0, 1)).toString() + == QStringLiteral("Trajectory (truth)")); + + assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection)); + sendLine(window, QStringLiteral( + "lat=40.78925034214206846173 long=29.46277530841727809729")); + flushUpdates(window); + assert(table->model()->rowCount() == 1); + + referenceLatInput->setText(QStringLiteral("40.78925034214206846173")); + referenceLonInput->setText(QStringLiteral("29.46277530841727809729")); + const bool referenceUpdated = QMetaObject::invokeMethod( + &window, "updateReferenceLocation", Qt::DirectConnection); + assert(referenceUpdated); + assert(cepValue->text().startsWith(QStringLiteral("0.00 m"))); + assert(cepValue->text().contains(QStringLiteral("N=1"))); + + const bool cleared = QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection); + assert(cleared); + assert(table->model()->rowCount() == 0); + assert(referenceLatInput->text() == QStringLiteral("40.78925034214206846173")); + assert(referenceLonInput->text() == QStringLiteral("29.46277530841727809729")); + assert(cepValue->text() == QStringLiteral("-")); + + referenceLatInput->setText(QStringLiteral("40.0")); + referenceLonInput->setText(QStringLiteral("29.0")); + assert(QMetaObject::invokeMethod(&window, "updateReferenceLocation", Qt::DirectConnection)); + sendLine(window, QStringLiteral("lat=40.000 long=29.000")); + sendLine(window, QStringLiteral("lat=40.001 long=29.000")); + sendLine(window, QStringLiteral("lat=40.002 long=29.000")); + flushUpdates(window); + assert(cepValue->text().startsWith(QStringLiteral("111 m"))); + assert(cepValue->text().contains(QStringLiteral("N=3"))); + assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection)); + + referenceLatInput->setText(QStringLiteral("40.78925034214206846173")); + referenceLonInput->setText(QStringLiteral("29.46277530841727809729")); + assert(QMetaObject::invokeMethod(&window, "updateReferenceLocation", Qt::DirectConnection)); + + maxPointsInput->setValue(10000); + for (int i = 0; i < 10000; ++i) { + sendLine(window, QStringLiteral("lat=40.78925 long=29.46277 sats=7")); + } + flushUpdates(window); + assert(table->model()->rowCount() == 10000); + assert(cepValue->text().contains(QStringLiteral("N=10000"))); + assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection)); + maxPointsInput->setValue(10); + + { + MapWidget followMap; + TelemetryPoint corrected; + corrected.source = LocationSource::ImuCorrected; + corrected.latitude = 40.1; + corrected.longitude = 29.1; + corrected.receivedAt = QDateTime::currentDateTimeUtc(); + + TelemetryPoint rawGps; + rawGps.source = LocationSource::Gps; + rawGps.latitude = 41.0; + rawGps.longitude = 30.0; + rawGps.receivedAt = corrected.receivedAt.addMSecs(100); + followMap.setPoints({corrected, rawGps}); + assert(followMap.centerCoordinate() == QPointF(40.1, 29.1)); + + rawGps.receivedAt = corrected.receivedAt.addMSecs(1100); + followMap.setPoints({corrected, rawGps}); + assert(followMap.centerCoordinate() == QPointF(41.0, 30.0)); + } + + const QByteArray screenshotPath = qgetenv("LOCATION_PLOTTER_TEST_SCREENSHOT"); + if (!screenshotPath.isEmpty()) { + sendLine(window, QStringLiteral("NAV solve start sats:5")); + sendLine(window, QStringLiteral("tracking PRNs: G03, G08, G14, G21, G27")); + sendLine(window, QStringLiteral( + "lat=40.78925034214206846173 long=29.46277530841727809729 " + "hheight=-89.05779508221894502640 sats=7 PDOP=1.235")); + flushUpdates(window); + mainViewTabs->setCurrentIndex(1); + window.show(); + app.processEvents(); + window.repaint(); + app.processEvents(); + assert(window.grab().save(QString::fromLocal8Bit(screenshotPath), "PNG")); + } + + return 0; +} diff --git a/tests/parser_tests.cpp b/linux/tests/parser_tests.cpp similarity index 100% rename from tests/parser_tests.cpp rename to linux/tests/parser_tests.cpp diff --git a/tests/uart_replay_tests.cpp b/linux/tests/uart_replay_tests.cpp similarity index 100% rename from tests/uart_replay_tests.cpp rename to linux/tests/uart_replay_tests.cpp diff --git a/CMakeLists.txt b/macos/CMakeLists.txt similarity index 94% rename from CMakeLists.txt rename to macos/CMakeLists.txt index f34527c..d18556e 100644 --- a/CMakeLists.txt +++ b/macos/CMakeLists.txt @@ -2,6 +2,10 @@ cmake_minimum_required(VERSION 3.20) project(location_plotter VERSION 0.3.0 LANGUAGES CXX) +if(NOT APPLE) + message(FATAL_ERROR "macos/ yalnizca macOS icin yapilandirilabilir") +endif() + set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) @@ -51,10 +55,6 @@ qt_add_resources(location_plotter "app_resources" FILES resources/ankasys-logo.png resources/land-110m.json resources/bluemarble-2048.png ) -if(UNIX AND NOT APPLE) - target_compile_definitions(location_plotter PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1) -endif() - enable_testing() add_executable(location_parser_tests @@ -116,9 +116,6 @@ qt_add_resources(location_main_window_tests "test_app_resources" PREFIX "/" FILES resources/ankasys-logo.png resources/land-110m.json resources/bluemarble-2048.png ) -if(UNIX AND NOT APPLE) - target_compile_definitions(location_main_window_tests PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1) -endif() add_test(NAME location_main_window_tests COMMAND location_main_window_tests) set_tests_properties(location_main_window_tests PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_CACHE_HOME=/tmp/location-plotter-test-cache") diff --git a/macos/README.md b/macos/README.md new file mode 100644 index 0000000..f73589f --- /dev/null +++ b/macos/README.md @@ -0,0 +1,242 @@ +# Location Plotter + +Qt6/C++ GUI that listens to a USB UART on macOS, parses normal debug lines, +and plots live locations on a map. + +The application also includes an interactive 3D Earth/orbit view. It downloads the current +stream-derived RINEX 3 navigation file from the BKG/IGS archive at startup and every 15 +minutes, then recomputes GPS satellite positions for current UTC every minute. The last valid +file is cached for offline startup. + +## Parsed fields + +The parser accepts labeled values in the same line or in separate lines: + +```text +lat=41.0082 long=28.9784 hheight=123.45 sats=7 +latitude: 41.0082 longitude: 28.9784 height: 123.45 activeSats: 7 +active_sats=8 +navigation software running with 5 satellites +GDOP : 3.9576744550 +PDOP : 3.2540598660 +HDOP : 2.3631161789 +VDOP : 2.2370935468 +TDOP : 2.2526165852 +tracking PRNs: G03, G08, 14, 21 +SV=G27 +[LOCATION] source=gps lat=40.7357 lon=31.5985 height=812.0 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 +[LOCATION] source=imu_corrected lat=40.7356 lon=31.5985 height=812.7 gdop=4.7 pdop=4.1 hdop=2.7 vdop=3.0 tdop=2.3 tracked=6 used=5 +[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=812.1 tracked=4 used=4 +lattitude : 40.78925034214206846173 +longitude : 29.46277530841727809729 +height : -89.05779508221894502640 +``` + +Accepted location labels: + +- Latitude: `lat`, `latitude`, `lattitude` +- Longitude: `long`, `longitude`, `lon`, `lng` +- Height: `hheight`, `height`, `alt`, `altitude` +- Active satellites: `sat`, `sats`, `satellite`, `satellites`, `active_sats`, + `activeSats`, `satellites_active`, `num_sats`, `uydu` +- DOP: `GDOP`, `PDOP`, `HDOP`, `VDOP`, `TDOP` +- GPS identifiers: `G03`, `PRN: 3`, `PRNs: 3, 8, 14`, `SV=G21`, `SVID: 27` +- Location source: `source=gps`, `source=imu_corrected`, `source=trajectory` +- `tracked` is accepted as the active satellite count; `used` remains visible in + the raw UART line and denotes satellites used by the WLS solution. + +If a satellite count arrives without a location, the active satellite display is updated. +If a location arrives without a satellite count, the latest known satellite count is attached +to that point. +If DOP values arrive before a location, the latest known values stay visible in the DOP +section and are attached to the next plotted point. +If latitude, longitude, and height arrive as separate consecutive lines, they are combined +into a single plotted point. + +The GPS firmware's level-1 paired protocol is source-aware. Every received +`source=gps` location is added to the blue GPS series and every +`source=imu_corrected` location is added to the red corrected series at the +firmware-configured UART cadence. +Paths are built independently, so alternating UART lines do not create GPS-to-IMU +zigzags. A corrected point newer than one second is also preferred for map +auto-follow, the current-position label and the globe receiver marker. Raw GPS +outliers remain visible in blue for diagnosis without pulling the whole view away. +When corrected points exist, CEP50, average and scatter calculations use only that +corrected series; legacy input without a source keeps the previous behavior. +Synthetic `source=trajectory` points form a third independent green ground-truth +path. The map legend identifies all three series. + +## UART log replay + +Use the `UART Replay` button (or `Dosya > UART Log Replay...`) to feed a recorded +UART `.log`/`.txt` file back through the same parser used by the live serial port. +Choose the raw GPS update interval (default `200 ms`) and sensor/fused update +interval (default `50 ms`) in the replay dialog. Press `Replay Durdur` to stop. + +Recorder timestamps are optional and do not control replay speed. The replayer +removes a leading timestamp when present, identifies `source=gps` as raw GPS and +`source=imu_corrected` or `[FUSION]` as fused/sensor output, then synthesizes the +two update timelines while retaining the original UART line order. Other UART +diagnostic lines are forwarded with the surrounding location update. + +## Sentetik ESKF senaryolarini açma + +GPS deposundan 48 kombinasyonun replay loglarini üretin: + +```bash +cd /home/onur/Desktop/gps +make -C soc/software/fusion/tests scenario-logs +``` + +Sonra GUI'yi açıp `Senaryo Logu Ac` düğmesinden örneğin +`soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log` dosyasını seçin. +GUI logdaki bütün rotayı otomatik sığdırır. Aynı dosya komut satırından da +doğrudan açılabilir: + +```bash +cd location_plotter/macos +cmake -S . -B build +cmake --build build -j +./build/location_plotter --replay \ + /home/onur/Desktop/gps/soc/software/fusion/tests/output/scenarios/car_gps200_imu20.log +``` + +Tek bir özel kombinasyon üretmek için: + +```bash +cd /home/onur/Desktop/gps/soc/software/fusion/tests +make platform_scenario_matrix_test +./platform_scenario_matrix_test --log aircraft 500 19 aircraft.log +``` + +Desteklenen senaryolar `stationary`, `pedestrian`, `car`, `aircraft`; GNSS +periyotları 200/500/1000 ms, IMU periyotları 19/20/200/300 ms'dir. + +## macOS'ta kurulum (MacBook Air, Apple Silicon veya Intel) + +Guncel macOS surumunde once [Homebrew](https://brew.sh) kurulu olmalidir. Ardindan: + +```bash +cd location_plotter/macos +chmod +x setup-macos.sh run.sh +./setup-macos.sh +./run.sh +``` + +Betik Homebrew ile guncel `cmake` ve `qt` paketlerini kurar, yerel mimari icin +`location_plotter.app` paketini derler, gerekli Qt kutuphanelerini uygulama paketine +ekler ve testleri calistirir. + +macOS USB UART aygitlarini genellikle iki adla gosterir: + +```text +/dev/cu.usbserial-XXXXXXXX +/dev/tty.usbserial-XXXXXXXX +``` + +veya bir CDC aygitinda: + +```text +/dev/cu.usbmodemXXXX +/dev/tty.usbmodemXXXX +``` + +Uygulama her iki bicimi de algilar. Mümkünse `cu.usb...` yolunu secin; macOS'ta +disariya baglanti baslatan uygulamalar icin uygun olan aygit budur. UART uygulama +acikken takildiysa port alaninin yanindaki `Yenile` dugmesine basin. Otomatik +algilanmayan bir yol port alanina elle de yazilabilir. + +Istenen port baslatmadan once ortam degiskeniyle de secilebilir: + +```bash +LOCATION_PLOTTER_PORT=/dev/cu.usbserial-XXXXXXXX ./run.sh +``` + +Port hic gorunmuyorsa `ls /dev/cu.usb* /dev/tty.usb*` ile macOS'un aygiti olusturup +olusturmadigini kontrol edin. Hic sonuc yoksa kabloyu/veri baglantisini ve UART +donaniminizin macOS surucusunu kontrol edin. + +## Elle derleme + +Homebrew ile `cmake` ve `qt` kurulduktan sonra derleme ve test: + +```bash +cmake -S . -B build -DCMAKE_PREFIX_PATH="$(brew --prefix qt)" +cmake --build build +ctest --test-dir build --output-on-failure +``` + +Run: + +```bash +./run.sh +``` + +Uygulama Qt tabanli bir macOS masaustu arayuzudur. Harita karolari ve guncel +RINEX verisi icin internet erisimi gerekir; son gecerli RINEX verisi ve harita +onbellegiyle cevrimdisi kullanim da desteklenir. Port secici USB ile ilgili +`/dev/cu.*` ve `/dev/tty.*` aygitlarini listeler. Hic aygit algilanmazsa +`/dev/cu.usbserial` duzenlenebilir bir varsayilan yol olarak gosterilir. + +## Notes + +- The map uses OpenStreetMap tiles through Qt Network. If there is no internet connection, + points are still drawn over a fallback grid. +- The `3B Dunya · Uydular` tab appears beside the map. It uses broadcast ephemeris values + from BKG's near-real-time `BRDC00WRD_S` RINEX file to propagate GPS satellites in ECEF. + PRNs parsed from the current navigation solve are green; other RINEX satellites are red. + Drag to rotate the globe and use the mouse wheel to zoom. +- The globe includes a shaded continent layer and a receiver-centred translucent visibility + cone. `Min. elevation` accepts a directly typed decimal value from 0 to 90 degrees + (10 degrees by default, press Enter after typing); + satellites inside the mask get a cyan sight line and halo. Incoming positions update the + receiver marker without resetting a globe orientation chosen by dragging. +- The globe surface uses NASA/GSFC Scientific Visualization Studio's 2048x1024 Blue Marble + mosaic (NASA SVS visualization 2915), projected locally without a runtime network request. +- RINEX download starts at every launch, repeats every 15 minutes, and falls back through the + previous two UTC days if today's stream file is unavailable. Current and next-minute satellite + positions are propagated from the cached ephemeris, then smoothly interpolated at 10 FPS. +- The 3D Earth tab includes a `RINEX Guncelle` button for an immediate manual refresh. Its status + line shows the system-local time of the last successful download or cache load. +- Complete UART channel snapshots such as `tracked satellites=5 prn_indices=[...]` replace the + previous connected-PRN set, so satellites that were lost no longer remain green. Firmware + channel indices are zero-based and are normalized for display (`0` is GPS PRN `G01`). +- RINEX text is parsed once per download and retained as compact ephemeris records. The static + globe is cached between camera changes, reducing CPU and allocation load during orbit animation. +- The globe uses an embedded Natural Earth coastline mask with terrain-like land colouring, + shallow-water edges, atmospheric shading, and a cached Earth-fixed texture for smooth rotation. +- The main content width and the right-side dashboard/UART height are both adjustable by + dragging their splitter handles. Dashboard cards scroll independently so the UART log can + be enlarged substantially. +- `Max` controls how many recent points stay visible and are included in location + analysis. The default is 10. When the limit is reached, older points are removed + automatically. +- Location analysis shows the average of the plotted samples, the last point's deviation + from that average, mean deviation, RMS scatter, and max deviation in meters. The average + marker can be toggled on the map. +- The map layers panel can hide all telemetry overlays, show only the newest location instead + of the recent path, and independently toggle the average marker. +- GPS points and paths are blue. IMU+ESKF corrected points and their independent + path are red. Synthetic ground-truth trajectory is green; the locations table + shows the parsed source in its `Kaynak` column. +- The application calculates empirical CEP50 as the median horizontal distance of the retained + measurements from their spherical mean, then draws the circle around that measurement center. + This is the radius containing 50 percent of the retained position fixes and is independent of + the optional user reference. +- A user reference can be selected by right-clicking the map. The selected WGS84 coordinates + are transferred to the reference fields with 15 decimal places but do not alter CEP50. +- Latitude and longitude tokens are preserved exactly as received for the table and map + label. Calculations and map projection use IEEE 754 `double` precision. +- The OpenStreetMap Standard raster layer supports zoom levels through 19. At latitude 40.8, + zoom 19 is approximately 0.23 meters per pixel; the marker still uses subpixel coordinates, + but the background tiles cannot add accuracy to the GNSS measurement. +- OpenStreetMap attribution is shown on the map. Tile responses use a 100 MB persistent HTTP + cache in the application's standard cache directory. +- UART log and telemetry views are refreshed in batches so a high-rate serial stream does not + rebuild the complete interface for every received line. The locations table uses a virtual + model and remains usable with a high `Max` value. +- Her basarili UART baglantisi, dosya adinda ve her satirda yerel zaman damgasi bulunan ayri bir + log olusturur. Dosyalar kaynak kodun ana klasorundeki `uart_logs` dizinine yazilir; + `LOCATION_PLOTTER_UART_LOG_DIR` ile farkli bir klasor secilebilir. +- If no new plotted location arrives for 10 seconds while listening, current location + points and analysis values are cleared. diff --git a/macos/resources/ankasys-logo.png b/macos/resources/ankasys-logo.png new file mode 100644 index 0000000..75bbd37 Binary files /dev/null and b/macos/resources/ankasys-logo.png differ diff --git a/resources/bluemarble-2048.png b/macos/resources/bluemarble-2048.png similarity index 100% rename from resources/bluemarble-2048.png rename to macos/resources/bluemarble-2048.png diff --git a/macos/resources/land-110m.json b/macos/resources/land-110m.json new file mode 100644 index 0000000..fbd897c --- /dev/null +++ b/macos/resources/land-110m.json @@ -0,0 +1 @@ +{"type":"Topology","objects":{"land":{"type":"GeometryCollection","geometries":[{"type":"MultiPolygon","arcs":[[[0]],[[1]],[[2]],[[3]],[[4]],[[5]],[[6]],[[7]],[[8]],[[9]],[[10]],[[11]],[[12]],[[13]],[[14]],[[15]],[[16,18]],[[19]],[[20]],[[21]],[[22]],[[23]],[[24]],[[25]],[[26]],[[27]],[[28]],[[29]],[[30]],[[31]],[[32]],[[33]],[[34]],[[35]],[[36]],[[37]],[[38]],[[39]],[[40]],[[41]],[[42]],[[43]],[[44]],[[45]],[[46]],[[47]],[[48]],[[49]],[[50]],[[51]],[[52]],[[53]],[[54]],[[55]],[[56]],[[57]],[[58]],[[59]],[[60]],[[61]],[[62]],[[63]],[[64]],[[65]],[[66]],[[67]],[[68]],[[69]],[[70]],[[71]],[[72]],[[73]],[[74]],[[75]],[[76]],[[77]],[[78]],[[79]],[[80]],[[81]],[[82]],[[83]],[[84]],[[85]],[[86]],[[87]],[[88]],[[89]],[[90]],[[91]],[[92,115],[114]],[[94]],[[95]],[[96]],[[97]],[[98]],[[99]],[[100]],[[101]],[[102]],[[103]],[[104]],[[105]],[[106]],[[107]],[[108]],[[109]],[[110]],[[111]],[[112]],[[113]],[[116]],[[117]],[[118]],[[119]],[[120]],[[121]],[[122]],[[123]],[[124]],[[125]],[[126]],[[127]],[[128]],[[129]]]}]}},"arcs":[[[33452,3290],[-82,-301],[-81,-266],[-582,81],[-621,-35],[-348,197],[0,23],[-152,174],[625,-23],[599,-58],[207,243],[147,208],[288,-243]],[[5775,3611],[-533,-81],[-364,208],[-163,209],[-11,35],[-180,162],[169,220],[517,-93],[277,-185],[212,-209],[76,-266]],[[37457,4468],[342,-255],[120,-359],[33,-254],[11,-301],[-430,-186],[-452,-150],[-522,-139],[-582,-116],[-658,35],[-365,197],[49,243],[593,162],[239,197],[174,254],[126,220],[168,209],[180,243],[141,0],[414,127],[419,-127]],[[16330,7154],[359,-93],[332,104],[-158,-208],[-261,-151],[-386,47],[-278,208],[60,197],[332,-104]],[[15122,7165],[425,-231],[-164,23],[-359,58],[-381,162],[202,127],[277,-139]],[[22505,8080],[305,-81],[304,69],[163,-335],[-217,46],[-337,-23],[-343,23],[-376,-35],[-283,116],[-146,243],[174,104],[353,-81],[403,-46]],[[30985,8657],[33,-266],[-49,-231],[-76,-220],[-326,-81],[-311,-116],[-364,11],[136,232],[-327,-81],[-310,-81],[-212,174],[-16,243],[305,231],[190,70],[321,-23],[82,301],[16,219],[-6,475],[158,278],[256,93],[147,-220],[65,-220],[120,-267],[92,-254],[76,-267]],[[0,529],[16,-5],[245,344],[501,-185],[32,21],[78,49],[94,61],[81,52],[41,26],[41,-1],[29,-10],[402,-246],[352,246],[63,34],[816,104],[265,-138],[130,-71],[419,-196],[789,-151],[625,-185],[1072,-139],[800,162],[1181,-116],[669,-185],[734,174],[773,162],[60,278],[-1094,23],[-898,139],[-234,231],[-745,128],[49,266],[103,243],[104,220],[-55,243],[-462,162],[-212,209],[-430,185],[675,-35],[642,93],[402,-197],[495,173],[457,220],[223,197],[-98,243],[-359,162],[-408,174],[-571,35],[-500,81],[-539,58],[-180,220],[-359,185],[-217,208],[-87,672],[136,-58],[250,-185],[457,58],[441,81],[228,-255],[441,58],[370,127],[348,162],[315,197],[419,58],[-11,220],[-97,220],[81,208],[359,104],[163,-196],[425,115],[321,151],[397,12],[375,57],[376,139],[299,128],[337,127],[218,-35],[190,-46],[414,81],[370,-104],[381,11],[364,81],[375,-57],[414,-58],[386,23],[403,-12],[413,-11],[381,23],[283,174],[337,92],[349,-127],[331,104],[300,208],[179,-185],[98,-208],[180,-197],[288,174],[332,-220],[375,-70],[321,-162],[392,35],[354,104],[418,-23],[376,-81],[381,-104],[147,254],[-180,197],[-136,209],[-359,46],[-158,220],[-60,220],[-98,440],[213,-81],[364,-35],[359,35],[327,-93],[283,-174],[119,-208],[376,-35],[359,81],[381,116],[342,70],[283,-139],[370,46],[239,451],[224,-266],[321,-104],[348,58],[228,-232],[365,-23],[337,-69],[332,-128],[218,220],[108,209],[278,-232],[381,58],[283,-127],[190,-197],[370,58],[288,127],[283,151],[337,81],[392,69],[354,81],[272,127],[163,186],[65,254],[-32,244],[-87,231],[-98,232],[-87,231],[-71,209],[-16,231],[27,232],[130,220],[109,243],[44,231],[-55,255],[-32,232],[136,266],[152,173],[180,220],[190,186],[223,173],[109,255],[152,162],[174,151],[267,34],[174,186],[196,115],[228,70],[202,150],[157,186],[218,69],[163,-151],[-103,-196],[-283,-174],[-120,-127],[-206,92],[-229,-58],[-190,-139],[-202,-150],[-136,-174],[-38,-231],[17,-220],[130,-197],[-190,-139],[-261,-46],[-153,-197],[-163,-185],[-174,-255],[-44,-220],[98,-243],[147,-185],[229,-139],[212,-185],[114,-232],[60,-220],[82,-232],[130,-196],[82,-220],[38,-544],[81,-220],[22,-232],[87,-231],[-38,-313],[-152,-243],[-163,-197],[-370,-81],[-125,-208],[-169,-197],[-419,-220],[-370,-93],[-348,-127],[-376,-128],[-223,-243],[-446,-23],[-489,23],[-441,-46],[-468,0],[87,-232],[424,-104],[311,-162],[174,-208],[-310,-185],[-479,58],[-397,-151],[-17,-243],[-11,-232],[327,-196],[60,-220],[353,-220],[588,-93],[500,-162],[398,-185],[506,-186],[690,-92],[681,-162],[473,-174],[517,-197],[272,-278],[136,-220],[337,209],[457,173],[484,186],[577,150],[495,162],[691,12],[680,-81],[560,-139],[180,255],[386,173],[702,12],[550,127],[522,128],[577,81],[614,104],[430,150],[-196,209],[-119,208],[0,220],[-539,-23],[-571,-93],[-544,0],[-77,220],[39,440],[125,128],[397,138],[468,139],[337,174],[337,174],[251,231],[380,104],[376,81],[190,47],[430,23],[408,81],[343,116],[337,139],[305,139],[386,185],[245,197],[261,173],[82,232],[-294,139],[98,243],[185,185],[288,116],[305,139],[283,185],[217,232],[136,277],[202,163],[331,-35],[136,-197],[332,-23],[11,220],[142,231],[299,-58],[71,-220],[331,-34],[360,104],[348,69],[315,-34],[120,-243],[305,196],[283,105],[315,81],[310,81],[283,139],[310,92],[240,128],[168,208],[207,-151],[288,81],[202,-277],[157,-209],[316,116],[125,232],[283,162],[365,-35],[108,-220],[229,220],[299,69],[326,23],[294,-11],[310,-70],[300,-34],[130,-197],[180,-174],[304,104],[327,24],[315,0],[310,11],[278,81],[294,70],[245,162],[261,104],[283,58],[212,162],[152,324],[158,197],[288,-93],[109,-208],[239,-139],[289,46],[196,-208],[206,-151],[283,139],[98,255],[250,104],[289,197],[272,81],[326,116],[218,127],[228,139],[218,127],[261,-69],[250,208],[180,162],[261,-11],[229,139],[54,208],[234,162],[228,116],[278,93],[256,46],[244,-35],[262,-58],[223,-162],[27,-254],[245,-197],[168,-162],[332,-70],[185,-162],[229,-162],[266,-35],[223,116],[240,243],[261,-127],[272,-70],[261,-69],[272,-46],[277,0],[229,-614],[-11,-150],[-33,-267],[-266,-150],[-218,-220],[38,-232],[310,12],[-38,-232],[-141,-220],[-131,-243],[212,-185],[321,-58],[321,104],[153,232],[92,220],[153,185],[174,174],[70,208],[147,289],[174,58],[316,24],[277,69],[283,93],[136,231],[82,220],[190,220],[272,151],[234,115],[153,197],[157,104],[202,93],[277,-58],[250,58],[272,69],[305,-34],[201,162],[142,393],[103,-162],[131,-278],[234,-115],[266,-47],[267,70],[283,-46],[261,-12],[174,58],[234,-35],[212,-127],[250,81],[300,0],[255,81],[289,-81],[185,197],[141,196],[191,163],[348,439],[179,-81],[212,-162],[185,-208],[354,-359],[272,-12],[256,0],[299,70],[299,81],[229,162],[190,174],[310,23],[207,127],[218,-116],[141,-185],[196,-185],[305,23],[190,-150],[332,-151],[348,-58],[288,47],[218,185],[185,185],[250,46],[251,-81],[288,-58],[261,93],[250,0],[245,-58],[256,-58],[250,104],[299,93],[283,23],[316,0],[255,58],[251,46],[76,290],[11,243],[174,-162],[49,-266],[92,-244],[115,-196],[234,-105],[315,35],[365,12],[250,35],[364,0],[262,11],[364,-23],[310,-46],[196,-186],[-54,-220],[179,-173],[299,-139],[310,-151],[360,-104],[375,-92],[283,-93],[315,-12],[180,197],[245,-162],[212,-185],[245,-139],[337,-58],[321,-69],[136,-232],[316,-139],[212,-208],[310,-93],[321,12],[299,-35],[332,12],[332,-47],[310,-81],[288,-139],[289,-116],[195,-173],[-32,-232],[-147,-208],[-125,-266],[-98,-209],[-131,-243],[-364,-93],[-163,-208],[-360,-127],[-125,-232],[-190,-220],[-201,-185],[-115,-243],[-70,-220],[-28,-266],[6,-220],[158,-232],[60,-220],[130,-208],[517,-81],[109,-255],[-501,-93],[-424,-127],[-528,-23],[-234,-336],[-49,-278],[-119,-220],[-147,-220],[370,-196],[141,-244],[239,-219],[338,-197],[386,-186],[419,-185],[636,-185],[142,-289],[800,-128],[53,-45],[208,-175],[767,151],[636,-186],[-99520,-142]],[[31180,18764],[361,-355],[389,-147],[-125,-296],[-264,-29],[-141,208],[-92,-239],[-238,-183],[-301,67],[-202,177],[-291,86],[-350,330],[-283,317],[-383,662],[229,-124],[390,-395],[369,-212],[143,271],[90,405],[256,244],[198,-70],[106,-274],[139,-443]],[[33736,20389],[222,-266],[-83,-207],[-375,-177],[-125,207],[-236,-266],[-139,266],[333,354],[236,-148],[167,237]],[[69522,21210],[-427,-38],[-7,314],[41,244],[19,121],[179,-186],[263,-74],[9,-112],[-77,-269]],[[90387,26479],[269,-204],[151,81],[217,113],[166,-39],[20,-702],[-95,-203],[-29,-476],[-97,162],[-193,-412],[-57,32],[-171,19],[-171,505],[-38,390],[-160,515],[7,271],[181,-52]],[[98060,26404],[63,-244],[198,239],[80,-249],[0,-249],[-103,-274],[-182,-435],[-142,-238],[103,-284],[-214,-7],[-238,-223],[-75,-387],[-157,-597],[-219,-264],[-138,-169],[-256,13],[-180,194],[-302,42],[-46,217],[149,438],[349,583],[179,111],[200,225],[238,310],[167,306],[123,441],[106,149],[41,330],[195,273],[61,-251]],[[98502,29218],[202,-622],[5,403],[126,-161],[41,-447],[224,-192],[188,-48],[158,226],[141,-69],[-67,-524],[-85,-345],[-212,12],[-74,-179],[26,-254],[-41,-110],[-105,-319],[-138,-404],[-214,-236],[-48,155],[-116,85],[160,486],[-91,326],[-299,236],[8,214],[201,206],[47,455],[-13,382],[-113,396],[8,104],[-133,244],[-218,523],[-117,418],[104,46],[151,-328],[216,-153],[78,-526]],[[96421,37487],[-105,-142],[-153,160],[-199,266],[-179,313],[-184,416],[-38,201],[119,-9],[156,-201],[122,-200],[89,-166],[228,-366],[144,-272]],[[99547,40335],[96,-171],[-46,-308],[-172,-81],[-153,73],[-27,260],[107,203],[126,-74],[69,98]],[[0,40798],[99822,-145],[-177,-124],[-36,220],[139,121],[88,33],[-99836,184]],[[0,41087],[0,-289]],[[0,41087],[57,27],[-34,-284],[-23,-32]],[[96623,40851],[-92,-78],[-93,259],[10,158],[175,-339]],[[96418,41756],[45,-476],[-75,74],[-58,-32],[-39,163],[-6,453],[133,-182]],[[63904,42571],[45,-711],[72,-276],[-28,-284],[-49,-174],[-94,347],[-53,-175],[53,-438],[-24,-250],[-77,-137],[-18,-500],[-109,-689],[-137,-814],[-172,-1120],[-106,-821],[-125,-685],[-226,-140],[-243,-250],[-160,151],[-220,211],[-77,312],[-18,524],[-98,471],[-26,425],[50,426],[128,102],[1,197],[133,447],[25,377],[-65,280],[-52,372],[-23,544],[97,331],[38,375],[138,22],[155,121],[103,107],[122,7],[158,337],[229,364],[83,297],[-38,253],[118,-71],[153,410],[6,356],[92,264],[96,-254],[74,-251],[69,-390]],[[89877,42448],[100,-464],[179,223],[92,-250],[133,-231],[-29,-262],[60,-506],[42,-295],[70,-72],[75,-505],[-27,-307],[90,-400],[301,-309],[197,-281],[186,-257],[-37,-143],[159,-371],[108,-639],[111,130],[113,-256],[68,91],[48,-626],[197,-363],[129,-226],[217,-478],[78,-475],[7,-337],[-19,-365],[132,-502],[-16,-523],[-48,-274],[-75,-527],[6,-339],[-55,-423],[-123,-538],[-205,-290],[-102,-458],[-93,-292],[-82,-510],[-107,-294],[-70,-442],[-36,-407],[14,-187],[-159,-205],[-311,-22],[-257,-242],[-127,-229],[-168,-254],[-230,262],[-170,104],[43,308],[-152,-112],[-243,-428],[-240,160],[-158,94],[-159,42],[-269,171],[-179,364],[-52,449],[-64,298],[-137,240],[-267,71],[91,287],[-67,438],[-136,-408],[-247,-109],[146,327],[42,341],[107,289],[-22,438],[-226,-504],[-174,-202],[-106,-470],[-217,243],[9,313],[-174,429],[-147,221],[52,137],[-356,358],[-195,17],[-267,287],[-498,-56],[-359,-211],[-317,-197],[-265,39],[-294,-303],[-241,-137],[-53,-309],[-103,-240],[-236,-15],[-174,-52],[-246,107],[-199,-64],[-191,-27],[-165,-315],[-81,26],[-140,-167],[-133,-187],[-203,23],[-186,0],[-295,377],[-149,113],[6,338],[138,81],[47,134],[-10,212],[34,411],[-31,350],[-147,598],[-45,337],[12,336],[-111,385],[-7,174],[-123,235],[-35,463],[-158,467],[-39,252],[122,-255],[-93,548],[137,-171],[83,-229],[-5,303],[-138,465],[-26,186],[-65,177],[31,341],[56,146],[38,295],[-29,346],[114,425],[21,-450],[118,406],[225,198],[136,252],[212,217],[126,46],[77,-73],[219,220],[168,66],[42,129],[74,54],[153,-14],[292,173],[151,262],[71,316],[163,300],[13,236],[7,321],[194,502],[117,-510],[119,118],[-99,279],[87,287],[122,-128],[34,449],[152,291],[67,233],[140,101],[4,165],[122,-69],[5,148],[122,85],[134,80],[205,-271],[155,-350],[173,-4],[177,-56],[-59,325],[133,473],[126,155],[-44,147],[121,338],[168,208],[142,-70],[234,111],[-5,302],[-204,195],[148,86],[184,-147],[148,-242],[234,-151],[79,60],[172,-182],[162,169],[105,-51],[65,113],[127,-292],[-74,-316],[-105,-239],[-96,-20],[32,-236],[-81,-295],[-99,-291],[20,-166],[221,-327],[214,-189],[143,-204],[201,-350],[78,1],[145,-151],[43,-183],[265,-200],[183,202],[55,317],[56,262],[34,324],[85,470],[-39,286],[20,171],[-32,339],[37,445],[53,120],[-43,197],[67,313],[52,325],[7,168],[104,222],[78,-289],[19,-371],[70,-71],[11,-249],[101,-300],[21,-335],[-10,-214]],[[95032,44386],[78,-203],[-194,4],[-106,363],[166,-142],[56,-22]],[[83531,44530],[-117,-11],[-368,414],[259,116],[146,-180],[97,-180],[-17,-159]],[[94680,44747],[-108,-14],[-170,60],[-58,91],[17,235],[183,-93],[91,-124],[45,-155]],[[94910,44908],[-42,-109],[-206,512],[-57,353],[94,0],[100,-473],[111,-283]],[[84565,44589],[-238,-130],[-33,71],[25,201],[119,360],[275,235],[32,139],[239,133],[194,20],[87,74],[105,-74],[-102,-160],[-289,-258],[-233,-170],[-181,-441]],[[82749,45797],[100,-158],[172,48],[69,-251],[-321,-119],[-193,-79],[-149,5],[95,340],[153,5],[74,209]],[[84139,45797],[-41,-328],[-417,-168],[-370,73],[0,216],[220,123],[174,-177],[185,45],[249,216]],[[94409,45654],[12,-119],[-218,251],[-152,212],[-104,197],[41,60],[128,-142],[228,-272],[65,-187]],[[93760,46238],[-56,-33],[-121,134],[-114,243],[14,99],[166,-250],[111,-193]],[[80172,46575],[533,-59],[61,244],[515,-284],[101,-383],[417,-108],[341,-351],[-317,-225],[-306,238],[-251,-16],[-288,44],[-260,106],[-322,225],[-204,59],[-116,-74],[-506,243],[-48,254],[-255,44],[191,564],[337,-35],[224,-231],[115,-45],[38,-210]],[[87423,46908],[-143,-402],[-27,445],[49,212],[58,200],[63,-173],[0,-282]],[[93299,46550],[-78,-59],[-120,227],[-122,375],[-59,450],[38,57],[30,-175],[84,-134],[135,-375],[131,-200],[-39,-166]],[[92217,47343],[-146,-48],[-44,-166],[-152,-144],[-142,-138],[-148,1],[-228,171],[-158,165],[23,183],[249,-86],[152,46],[42,283],[40,15],[27,-314],[158,45],[78,202],[155,211],[-30,348],[166,11],[56,-97],[-5,-327],[-93,-361]],[[85346,48536],[-104,-196],[-192,108],[-54,254],[281,29],[69,-195]],[[86241,48752],[101,-452],[-234,244],[-232,49],[-157,-39],[-192,21],[65,325],[344,24],[305,-172]],[[92538,47921],[-87,-157],[-52,348],[-65,229],[-126,193],[-158,252],[-200,174],[77,143],[150,-166],[94,-130],[117,-142],[111,-248],[106,-189],[33,-307]],[[87261,49899],[78,-955],[287,-354],[232,627],[319,356],[247,1],[238,-206],[206,-212],[298,-113],[482,-407],[513,-338],[192,-302],[154,-297],[43,-349],[462,-365],[68,-313],[-256,-64],[62,-393],[248,-388],[180,-627],[159,20],[-11,-262],[215,-100],[-84,-111],[295,-249],[-30,-171],[-184,-41],[-69,153],[-238,66],[-281,89],[-216,377],[-158,325],[-144,517],[-362,259],[-235,-169],[-170,-195],[35,-436],[-218,-203],[-155,99],[-288,25],[-247,485],[-282,118],[-69,-168],[-352,-18],[118,481],[175,164],[-72,642],[-134,496],[-538,500],[-229,50],[-417,546],[-82,-287],[-107,-52],[-63,216],[-1,257],[-212,290],[299,213],[198,-11],[-23,156],[-407,1],[-110,352],[-248,109],[-117,293],[374,143],[142,192],[446,-242],[44,-220]],[[84788,51419],[-223,-587],[-209,-113],[-267,115],[-463,-29],[-243,-85],[-39,-447],[248,-526],[150,268],[518,201],[-22,-272],[-121,86],[-121,-347],[-245,-229],[263,-757],[-50,-203],[249,-682],[-2,-388],[-148,-173],[-109,207],[134,484],[-273,-229],[-69,164],[36,228],[-200,346],[21,576],[-186,-179],[24,-689],[11,-846],[-176,-85],[-119,173],[79,544],[-43,570],[-117,4],[-86,405],[115,387],[40,469],[139,891],[58,243],[237,439],[217,-174],[350,-82],[319,25],[275,429],[48,-132]],[[85746,51249],[-15,-517],[-143,58],[-42,-359],[114,-312],[-78,-71],[-112,374],[-82,755],[56,472],[92,215],[20,-322],[164,-52],[26,-241]],[[79393,47122],[-308,-12],[-234,494],[-356,482],[-119,358],[-210,481],[-138,443],[-212,827],[-244,493],[-81,508],[-103,461],[-250,372],[-145,506],[-209,330],[-290,652],[-24,300],[178,-24],[430,-114],[246,-577],[215,-401],[153,-246],[263,-635],[283,-9],[233,-405],[161,-495],[211,-270],[-111,-482],[159,-205],[100,-15],[47,-412],[97,-330],[204,-52],[135,-374],[-70,-735],[-11,-914]],[[82742,51659],[312,-546],[-329,-70],[-93,-403],[12,-535],[-267,-404],[-7,-589],[-107,-903],[-41,210],[-316,-266],[-110,361],[-198,34],[-139,189],[-330,-212],[-101,285],[-182,-32],[-229,68],[-43,793],[-138,164],[-134,505],[-38,517],[32,548],[165,392],[204,-202],[214,110],[56,500],[119,112],[333,128],[199,467],[137,374],[110,221],[236,323],[214,411],[140,462],[112,2],[143,-299],[13,-257],[183,-165],[231,-177],[-20,-232],[-186,-29],[50,-289],[-205,-201],[-158,-533],[204,-560],[-48,-272]],[[85104,55551],[28,-392],[16,-332],[-94,-540],[-102,602],[-130,-300],[89,-435],[-79,-277],[-327,343],[-78,428],[84,280],[-176,280],[-87,-245],[-131,23],[-205,-330],[-46,173],[109,498],[175,166],[151,223],[98,-268],[212,162],[45,264],[196,15],[-16,457],[225,-280],[23,-297],[20,-218]],[[72560,54241],[-242,-135],[-132,470],[-49,849],[126,959],[192,-328],[129,-416],[134,-616],[-42,-615],[-116,-168]],[[33073,56553],[-232,-65],[-50,53],[81,163],[-6,233],[160,77],[58,-21],[-11,-440]],[[84439,56653],[-100,-195],[-87,-373],[-87,-175],[-171,409],[57,158],[70,165],[30,367],[153,35],[-44,-398],[205,570],[-26,-563]],[[82917,56084],[-369,-561],[136,414],[200,364],[167,409],[146,587],[49,-482],[-183,-325],[-146,-406]],[[83856,57606],[166,-183],[177,1],[-5,-247],[-129,-251],[-176,-178],[-10,275],[20,301],[-43,282]],[[84861,57766],[78,-660],[-214,157],[5,-199],[68,-364],[-132,-133],[-11,416],[-84,31],[-43,357],[163,-47],[-4,224],[-169,451],[266,-13],[77,-220]],[[83757,58301],[-74,-510],[-119,295],[-142,450],[238,-22],[97,-213]],[[83700,61512],[171,-168],[85,153],[26,-150],[-46,-245],[95,-423],[-73,-491],[-164,-196],[-43,-476],[62,-471],[147,-65],[123,70],[347,-328],[-27,-321],[91,-142],[-29,-272],[-216,290],[-103,310],[-71,-217],[-177,354],[-253,-87],[-138,130],[14,244],[87,151],[-83,136],[-36,-213],[-137,340],[-41,257],[-11,566],[112,-195],[29,925],[90,535],[169,-1]],[[31780,61349],[-71,-149],[-209,4],[-163,-21],[-16,253],[40,86],[227,-3],[142,-52],[50,-118]],[[28638,61137],[-84,-99],[-156,95],[-159,215],[34,135],[116,41],[64,-20],[187,-53],[147,-142],[46,-161],[-195,-11]],[[29839,62320],[241,-93],[34,101],[217,-3],[165,-152],[73,15],[50,-209],[152,11],[-9,-176],[124,-21],[136,-217],[-103,-240],[-132,128],[-127,-25],[-92,28],[-50,-107],[-106,-37],[-43,144],[-92,-85],[-111,-405],[-71,94],[-14,170],[-185,100],[-131,-41],[-169,43],[-130,-110],[-149,184],[24,190],[256,-82],[210,-47],[100,131],[-127,256],[2,226],[-175,92],[62,163],[170,-26]],[[80649,61615],[-240,-284],[-228,183],[-8,509],[137,267],[304,166],[159,-14],[62,-226],[-122,-260],[-64,-341]],[[6794,61855],[-41,-99],[-69,84],[8,165],[-46,216],[14,65],[48,97],[-19,116],[16,55],[21,-11],[107,-100],[49,-51],[45,-79],[71,-207],[-7,-33],[-108,-126],[-89,-92]],[[6645,62777],[-94,-43],[-47,125],[-32,48],[-3,37],[27,50],[99,-56],[73,-90],[-23,-71]],[[6456,63091],[-9,-63],[-149,17],[21,72],[137,-26]],[[6207,63177],[-15,-34],[-19,8],[-97,21],[-35,133],[-11,24],[74,82],[23,-38],[80,-196]],[[5737,63567],[-33,-58],[-93,107],[14,43],[43,58],[64,-12],[5,-138]],[[27867,64030],[110,-216],[260,66],[98,-138],[235,-366],[173,-267],[92,8],[165,-120],[-20,-167],[205,-24],[210,-242],[-33,-138],[-185,-75],[-187,-29],[-191,46],[-398,-57],[186,329],[-113,154],[-179,39],[-96,171],[-66,336],[-157,-23],[-259,159],[-83,124],[-362,91],[-97,115],[104,148],[-273,30],[-199,-307],[-115,-8],[-40,-144],[-138,-65],[-118,56],[146,183],[60,213],[126,131],[142,116],[210,56],[67,65],[240,-42],[219,-7],[261,-201]],[[28462,64617],[-68,-29],[-70,340],[-104,171],[60,375],[84,-23],[97,-491],[1,-343]],[[83659,64045],[-119,-485],[-146,499],[-32,438],[163,581],[223,447],[127,-176],[-49,-357],[-167,-947]],[[28383,66284],[-303,-95],[-19,219],[130,47],[184,-18],[8,-153]],[[28611,66290],[-48,-420],[-51,75],[4,309],[-124,234],[-1,67],[220,-265]],[[87399,70756],[35,-203],[-156,-357],[-114,189],[-143,-137],[-73,-346],[-181,168],[2,281],[154,352],[158,-68],[114,248],[204,-127]],[[59604,71655],[-188,-251],[21,-111],[8,-48],[-285,-240],[-136,77],[-64,237],[132,22],[19,3],[40,143],[200,-8],[253,176]],[[56583,71675],[152,-199],[216,34],[207,-42],[-7,-103],[151,71],[-35,-175],[-400,-50],[3,98],[-339,115],[52,251]],[[54311,73167],[-100,-465],[41,-183],[-58,-303],[-213,222],[-141,64],[-387,300],[38,304],[325,-54],[284,64],[211,51]],[[52558,74927],[166,-419],[-39,-782],[-126,38],[-113,-197],[-105,156],[-11,713],[-64,338],[153,-30],[139,183]],[[89159,72524],[-104,-472],[48,-296],[-145,-416],[-355,-278],[-488,-36],[-396,-675],[-186,227],[-12,442],[-483,-130],[-329,-279],[-325,-11],[282,-435],[-186,-1004],[-179,-248],[-135,229],[69,533],[-176,172],[-113,405],[263,182],[145,371],[280,306],[203,403],[553,177],[297,-121],[291,1050],[185,-282],[408,591],[158,229],[174,723],[-47,664],[117,374],[295,108],[152,-819],[-9,-479],[-256,-595],[4,-610]],[[52655,75484],[-92,-456],[-126,120],[-64,398],[56,219],[179,226],[47,-507]],[[89974,76679],[195,-126],[197,250],[62,-663],[-412,-162],[-244,-587],[-436,404],[-152,-646],[-308,-9],[-39,587],[138,455],[296,33],[81,817],[83,460],[326,-615],[213,-198]],[[32315,78082],[202,-79],[257,16],[-137,-242],[-102,-38],[-353,250],[-69,198],[105,183],[97,-288]],[[32831,79592],[-135,-11],[-360,186],[-258,279],[96,49],[365,-148],[284,-247],[8,-108]],[[15692,79240],[-140,-82],[-456,269],[-84,209],[-248,207],[-50,168],[-286,107],[-107,321],[24,137],[291,-129],[171,-89],[261,-63],[94,-204],[138,-280],[277,-244],[115,-327]],[[34407,80527],[-184,-517],[181,199],[187,-126],[-98,-206],[247,-162],[128,144],[277,-182],[-86,-433],[194,101],[36,-313],[86,-367],[-117,-520],[-125,-22],[-183,111],[60,484],[-77,75],[-322,-513],[-166,21],[196,277],[-267,144],[-298,-35],[-539,18],[-43,175],[173,208],[-121,160],[234,356],[287,941],[172,336],[241,204],[129,-26],[-54,-160],[-148,-372]],[[13136,82508],[267,47],[-84,-671],[242,-475],[-111,1],[-167,270],[-103,272],[-140,184],[-51,260],[16,188],[131,-76]],[[89901,80562],[280,-1046],[-411,195],[-171,-854],[271,-605],[-8,-413],[-211,356],[-182,-457],[-51,496],[31,575],[-32,638],[64,446],[13,790],[-163,581],[24,808],[257,271],[-110,274],[123,83],[73,-391],[96,-569],[-7,-581],[114,-597]],[[48114,81456],[-493,-349],[-393,89],[225,617],[-145,601],[378,463],[210,276],[233,24],[298,-365],[-149,-406],[46,-422],[-210,-528]],[[53524,83435],[-166,-478],[-291,333],[-39,246],[408,195],[88,-296]],[[7498,84325],[-277,-225],[-142,152],[-43,277],[252,210],[148,90],[185,-40],[117,-183],[-240,-281]],[[49165,85222],[-297,-639],[283,81],[304,-3],[-72,-481],[-250,-530],[287,-38],[270,-759],[190,-95],[171,-673],[79,-233],[337,-113],[-34,-378],[-142,-173],[111,-305],[-250,-310],[-371,6],[-473,-163],[-130,116],[-183,-276],[-257,67],[-195,-226],[-148,118],[407,621],[249,127],[-436,99],[-79,235],[291,183],[-152,319],[52,387],[414,-54],[40,343],[-190,372],[-337,104],[-66,160],[101,264],[-92,163],[-149,-279],[-17,569],[-140,301],[101,611],[216,480],[222,-47],[335,49]],[[4006,85976],[-171,-92],[-182,110],[-168,161],[274,101],[220,-54],[27,-226]],[[27981,87304],[-108,-310],[-123,50],[-73,176],[13,41],[107,177],[114,-13],[70,-121]],[[27250,87631],[-325,-326],[-196,13],[-61,160],[207,273],[381,-6],[-6,-114]],[[2297,88264],[171,-113],[173,61],[225,-156],[276,-79],[-23,-64],[-211,-125],[-211,128],[-106,107],[-245,-34],[-66,52],[17,223]],[[26344,89371],[51,-259],[143,91],[161,-155],[304,-203],[318,-184],[25,-281],[204,46],[199,-196],[-247,-186],[-432,142],[-156,266],[-275,-314],[-396,-306],[-95,346],[-377,-57],[242,292],[35,465],[95,542],[201,-49]],[[45969,89843],[-64,-382],[314,-403],[-361,-451],[-801,-405],[-240,-107],[-365,87],[-775,187],[273,261],[-605,289],[492,114],[-12,174],[-583,137],[188,385],[421,87],[433,-400],[422,321],[349,-167],[453,315],[461,-42]],[[28926,90253],[-312,-30],[-69,289],[118,331],[255,82],[217,-163],[3,-253],[-32,-82],[-180,-174]],[[0,91325],[681,-451],[728,-588],[-24,-367],[187,-147],[-64,429],[754,-88],[544,-553],[-276,-257],[-455,-61],[-7,-578],[-111,-122],[-260,17],[-212,206],[-369,172],[-62,257],[-283,96],[-315,-76],[-151,207],[60,219],[-333,-140],[126,-278],[-158,-251]],[[0,88971],[0,2354]],[[23431,91410],[-173,-207],[-374,179],[-226,-65],[-380,266],[245,183],[194,256],[295,-168],[166,-106],[84,-112],[169,-226]],[[0,92833],[99999,-404],[-305,-30],[-49,187],[-99645,247]],[[0,92833],[36,24],[235,-1],[402,-169],[-24,-81],[-286,-141],[-363,-36],[0,404]],[[24848,91640],[-1,-604],[371,463],[332,-380],[-83,-438],[269,-399],[290,427],[202,510],[16,648],[394,-45],[411,-87],[373,-293],[17,-293],[-207,-315],[196,-316],[-36,-288],[-544,-413],[-386,-91],[-287,178],[-83,-297],[-268,-498],[-81,-258],[-322,-400],[-397,-39],[-220,-250],[-18,-384],[-323,-74],[-340,-479],[-301,-665],[-108,-466],[-15,-686],[408,-99],[125,-553],[130,-448],[388,117],[517,-256],[277,-225],[199,-279],[348,-162],[294,-249],[459,-34],[302,-58],[-45,-511],[86,-594],[201,-661],[414,-561],[214,192],[150,607],[-145,934],[-196,311],[445,276],[314,415],[154,411],[-22,395],[-189,502],[-338,445],[328,619],[-121,535],[-93,922],[194,137],[476,-161],[286,-57],[230,155],[258,-200],[342,-343],[85,-229],[495,-45],[-8,-496],[92,-747],[254,-92],[201,-348],[402,328],[266,652],[184,274],[216,-527],[362,-754],[307,-709],[-112,-371],[370,-333],[250,-338],[442,-152],[179,-189],[110,-500],[216,-78],[112,-223],[20,-664],[-202,-222],[-199,-207],[-458,-210],[-349,-486],[-470,-96],[-594,125],[-417,4],[-287,-41],[-233,-424],[-354,-262],[-401,-782],[-320,-545],[236,97],[446,776],[583,493],[416,59],[245,-290],[-262,-397],[88,-637],[91,-446],[361,-295],[459,86],[278,664],[19,-429],[180,-214],[-344,-387],[-615,-351],[-276,-239],[-310,-426],[-211,44],[-11,500],[483,488],[-445,-19],[-309,-72],[48,-194],[-296,-286],[-286,-204],[-293,-175],[-159,-386],[-35,-98],[-3,-313],[92,-313],[115,-15],[-29,216],[83,-131],[-22,-169],[-188,-96],[-133,12],[-205,-104],[-121,-29],[-162,-29],[-231,-171],[408,111],[82,-112],[-389,-177],[-177,-1],[8,72],[-84,-164],[82,-27],[-60,-424],[-203,-455],[-20,152],[-61,31],[-91,147],[57,-318],[66,-106],[8,-222],[-89,-230],[-157,-472],[-25,24],[86,402],[-142,226],[-33,490],[-53,-255],[59,-375],[-175,88],[183,-186],[12,-562],[79,-41],[29,-204],[39,-591],[-176,-439],[-288,-175],[-182,-346],[-139,-38],[-141,-217],[-39,-199],[-305,-383],[-157,-281],[-131,-351],[-43,-419],[50,-411],[92,-505],[124,-418],[1,-256],[132,-685],[-9,-398],[-12,-230],[-69,-361],[-83,-74],[-137,71],[-44,259],[-106,136],[-147,508],[-129,452],[-42,231],[57,393],[-77,325],[-217,494],[-108,91],[-281,-269],[-49,30],[-135,276],[-174,146],[-314,-75],[-247,66],[-212,-41],[-118,-83],[54,-166],[-5,-240],[59,-117],[-53,-77],[-103,87],[-104,-112],[-202,18],[-207,313],[-242,-74],[-202,137],[-173,-42],[-234,-138],[-253,-438],[-276,-255],[-152,-282],[-63,-266],[-3,-407],[14,-284],[52,-201],[1,-1],[-1,-1],[-107,-516],[-49,-426],[-20,-791],[-27,-289],[48,-322],[86,-288],[56,-458],[184,-440],[65,-337],[109,-291],[295,-157],[114,-247],[244,165],[212,60],[208,106],[175,101],[176,241],[67,345],[22,496],[48,173],[188,155],[294,137],[246,-21],[169,50],[66,-125],[-9,-285],[-149,-351],[-66,-360],[51,-103],[-42,-255],[-69,-461],[-71,152],[-58,-10],[1,-87],[53,-3],[-5,-160],[-45,-256],[24,-91],[-29,-212],[18,-56],[-32,-299],[-55,-156],[-50,-19],[-55,-205],[90,-107],[24,88],[82,-75],[29,-23],[61,104],[79,8],[26,-48],[43,29],[129,-53],[128,16],[90,65],[32,66],[89,-31],[66,-40],[73,14],[55,51],[127,-82],[44,-13],[85,-110],[80,-132],[101,-91],[73,-162],[-24,-57],[-14,-132],[29,-216],[-64,-202],[-30,-237],[-9,-261],[15,-152],[7,-266],[-43,-58],[-26,-253],[19,-156],[-56,-151],[12,-159],[43,-97],[70,-321],[108,-238],[130,-252],[100,-212],[-6,-125],[111,-27],[26,48],[77,-145],[136,42],[119,150],[168,119],[95,176],[153,-34],[-10,-58],[155,-21],[124,-102],[90,-177],[105,-164],[143,-18],[209,412],[114,63],[3,195],[51,500],[159,274],[175,11],[22,123],[218,-49],[218,298],[109,132],[134,285],[98,-36],[73,-156],[-54,-199],[-8,-139],[-163,-69],[91,-268],[-3,-309],[-123,-343],[105,-469],[120,38],[62,427],[-86,208],[-14,447],[346,241],[-38,278],[97,186],[100,-415],[195,-9],[180,-330],[11,-195],[249,-6],[297,61],[159,-264],[213,-73],[155,184],[4,149],[344,35],[333,9],[-236,-175],[95,-279],[222,-44],[210,-291],[45,-473],[144,13],[109,-139],[183,-217],[171,-385],[8,-304],[105,-14],[149,-289],[109,-205],[333,-119],[30,107],[225,43],[298,-159],[95,-65],[204,-140],[294,-499],[46,-242],[95,28],[69,-327],[155,-1033],[149,-97],[7,-408],[-208,-487],[86,-178],[491,-92],[10,-593],[211,388],[349,-212],[462,-361],[135,-346],[-45,-327],[323,182],[540,-313],[415,23],[411,-489],[355,-662],[214,-170],[237,-24],[101,-186],[94,-752],[46,-358],[-110,-977],[-142,-385],[-391,-822],[-177,-668],[-206,-513],[-69,-11],[-78,-435],[20,-1107],[-77,-910],[-30,-390],[-88,-233],[-49,-790],[-282,-771],[-47,-610],[-225,-256],[-65,-355],[-302,2],[-437,-227],[-195,-263],[-311,-173],[-327,-470],[-235,-586],[-41,-441],[46,-326],[-51,-597],[-63,-289],[-195,-325],[-308,-1040],[-244,-468],[-189,-277],[-127,-562],[-183,-337],[-121,-372],[-313,-328],[-205,118],[-151,-63],[-256,253],[-189,-19],[-169,327],[-19,-308],[353,-506],[-38,-408],[173,-257],[-14,-289],[-267,-757],[-412,-317],[-557,-123],[-305,59],[59,-352],[-57,-442],[51,-298],[-167,-208],[-284,-82],[-267,216],[-108,-155],[39,-587],[188,-178],[152,186],[82,-307],[-255,-183],[-223,-367],[-41,-595],[-66,-316],[-262,-2],[-218,-302],[-80,-443],[274,-433],[265,-119],[-96,-531],[-328,-333],[-180,-692],[-254,-234],[-113,-276],[89,-614],[185,-342],[-117,30],[-247,4],[-134,-145],[-250,-213],[-45,-552],[-118,-14],[-313,192],[-318,412],[-346,338],[-87,374],[79,346],[-140,393],[-36,1007],[119,568],[293,457],[-422,172],[265,522],[94,982],[309,-208],[145,1224],[-186,157],[-87,-738],[-175,83],[87,845],[95,1095],[127,404],[-79,576],[-23,666],[117,19],[170,954],[192,945],[118,881],[-64,885],[83,487],[-34,730],[163,721],[50,1143],[89,1227],[87,1321],[-20,967],[-58,832],[-279,340],[-24,242],[-551,593],[-498,646],[-214,365],[-115,488],[46,170],[-236,775],[-274,1090],[-262,1177],[-114,269],[-87,435],[-216,386],[-198,239],[90,264],[-134,563],[86,414],[221,373],[148,442],[-60,258],[-106,-275],[-166,259],[56,167],[-47,536],[97,89],[52,368],[105,381],[-20,241],[153,126],[190,236],[-37,183],[103,44],[-12,296],[65,214],[138,40],[117,371],[106,310],[-102,141],[52,343],[-62,540],[59,155],[-44,500],[-112,315],[-93,170],[-59,319],[68,158],[-70,40],[-52,195],[-138,165],[-122,-38],[-56,-205],[-112,-149],[-61,-20],[-27,-123],[132,-321],[-75,-76],[-40,-87],[-130,-30],[-48,353],[-36,-101],[-92,35],[-56,238],[-114,39],[-72,69],[-119,-1],[-8,-128],[-32,89],[-151,131],[-56,124],[32,103],[-11,130],[-77,142],[-109,116],[-95,76],[-19,173],[-73,105],[18,-172],[-55,-141],[-64,164],[-89,58],[-38,120],[2,179],[36,187],[-78,83],[64,114],[-96,186],[-130,238],[-61,200],[-117,185],[-140,267],[31,92],[46,-89],[21,41],[-48,185],[-84,52],[-31,-140],[-161,9],[-100,57],[-115,117],[-154,37],[-79,127],[-142,103],[-174,11],[-127,117],[-149,244],[-314,636],[-144,192],[-226,154],[-156,-43],[-223,-223],[-140,-58],[-196,156],[-208,112],[-260,271],[-208,83],[-314,275],[-233,282],[-70,158],[-155,35],[-284,187],[-116,270],[-299,335],[-139,373],[-66,288],[93,57],[-29,169],[64,153],[1,204],[-93,266],[-25,235],[-94,298],[-244,587],[-280,462],[-135,368],[-238,241],[-51,145],[42,365],[-142,137],[-164,288],[-69,412],[-149,48],[-162,311],[-130,288],[-12,184],[-149,446],[-99,452],[5,227],[-201,235],[-93,-26],[-159,163],[-44,-240],[46,-284],[27,-444],[95,-243],[206,-407],[46,-139],[42,-42],[37,-203],[49,8],[56,-381],[85,-150],[59,-210],[174,-300],[92,-550],[83,-259],[77,-277],[15,-311],[134,-20],[112,-268],[100,-264],[-6,-106],[-117,-217],[-49,3],[-74,359],[-182,337],[-200,286],[-142,150],[9,432],[-42,320],[-132,183],[-191,264],[-37,-76],[-70,154],[-171,143],[-164,343],[20,44],[115,-33],[103,221],[10,266],[-214,422],[-163,163],[-102,369],[-103,388],[-129,472],[-113,531],[-46,302],[-180,340],[-130,71],[-30,169],[-156,30],[-100,159],[-258,59],[-70,95],[-34,324],[-270,594],[-231,821],[10,137],[-123,195],[-215,495],[-38,482],[-148,323],[61,489],[-10,507],[-89,453],[109,557],[67,1072],[-50,792],[-88,506],[-80,274],[33,115],[402,-200],[148,-558],[68,156],[-44,485],[-94,484],[-38,1],[-537,581],[-199,255],[-503,245],[-155,523],[40,362],[-356,252],[-48,476],[-336,429],[-6,304],[-153,223],[-245,188],[-78,515],[-358,478],[-150,558],[-267,38],[-441,15],[-326,170],[-574,613],[-266,112],[-486,211],[-385,-50],[-546,271],[-330,252],[-309,-125],[58,-411],[-154,-38],[-321,-123],[-245,-199],[-307,-126],[-40,348],[125,580],[295,182],[-76,148],[-354,-329],[-190,-394],[-400,-420],[203,-287],[-262,-424],[-299,-247],[-278,-181],[-69,-261],[-434,-305],[-87,-278],[-325,-252],[-191,45],[-259,-165],[-282,-201],[-231,-197],[-477,-169],[-43,99],[304,276],[271,182],[296,324],[345,66],[137,243],[385,353],[62,119],[205,208],[48,448],[141,349],[-320,-179],[-90,102],[-150,-215],[-181,300],[-75,-212],[-104,294],[-278,-236],[-170,0],[-24,352],[50,217],[-179,210],[-361,-113],[-235,277],[-190,142],[-1,334],[-214,252],[108,340],[226,330],[99,303],[225,43],[191,-94],[224,285],[201,-51],[212,183],[-52,270],[-155,106],[205,228],[-170,-7],[-295,-128],[-85,-131],[-219,131],[-392,-67],[-407,142],[-117,238],[-351,343],[390,247],[620,289],[228,0],[-38,-295],[586,22],[-225,366],[-342,226],[-197,295],[-267,252],[-381,187],[155,309],[493,19],[350,270],[66,287],[284,281],[271,68],[526,262],[256,-40],[427,315],[421,-124],[201,-266],[123,114],[469,-35],[-16,-136],[425,-101],[283,59],[585,-186],[534,-56],[214,-77],[370,96],[421,-177],[302,-83],[518,-142],[438,-284],[289,-55],[244,247],[336,184],[413,-72],[416,259],[455,148],[191,-245],[207,138],[62,278],[192,-63],[470,-530],[369,401],[38,-448],[341,96],[105,173],[337,-34],[424,-248],[650,-217],[383,-100],[272,38],[375,-300],[-391,-293],[502,-127],[750,70],[236,103],[296,-354],[302,299],[-283,251],[179,202],[338,27],[223,59],[224,-141],[279,-321],[310,47],[491,-266],[431,94],[405,-14],[-32,367],[247,103],[431,-200],[-2,-559],[177,471],[223,-16],[126,594],[-298,364],[-324,239],[22,653],[329,429],[366,-95],[281,-261],[378,-666],[-247,-290],[517,-120]],[[18287,93781],[-139,-277],[618,179],[386,-298],[314,302],[254,-194],[227,-580],[140,244],[-197,606],[244,86],[276,-94],[311,-239],[175,-575],[86,-417],[466,-293],[502,-279],[-31,-260],[-456,-48],[178,-227],[-94,-217],[-503,93],[-478,160],[-322,-36],[-522,-201],[-824,-103],[-374,-41],[-151,279],[-379,161],[-246,-66],[-343,468],[185,62],[429,101],[392,-26],[362,103],[-537,138],[-594,-47],[-394,12],[-146,217],[644,237],[-428,-9],[-485,156],[233,443],[193,235],[744,359],[284,-114]],[[20972,93958],[-244,-390],[-434,413],[95,83],[372,24],[211,-130]],[[28794,93770],[25,-163],[-296,17],[-299,13],[-304,-80],[-80,36],[-306,313],[12,213],[133,39],[636,-63],[479,-325]],[[25955,93803],[219,-369],[256,477],[704,242],[477,-611],[-42,-387],[550,172],[263,235],[616,-299],[383,-282],[36,-258],[515,134],[290,-376],[670,-234],[242,-238],[263,-553],[-510,-275],[654,-386],[441,-130],[400,-543],[437,-39],[-87,-414],[-487,-687],[-342,253],[-437,568],[-359,-74],[-35,-338],[292,-344],[377,-272],[114,-157],[181,-584],[-96,-425],[-350,160],[-697,473],[393,-509],[289,-357],[45,-206],[-753,236],[-596,343],[-337,287],[97,167],[-414,304],[-405,286],[5,-171],[-803,-94],[-235,203],[183,435],[522,10],[571,76],[-92,211],[96,294],[360,576],[-77,261],[-107,203],[-425,286],[-563,201],[178,150],[-294,367],[-245,34],[-219,201],[-149,-175],[-503,-76],[-1011,132],[-588,174],[-450,89],[-231,207],[290,270],[-394,2],[-88,599],[213,528],[286,241],[717,158],[-204,-382]],[[22123,94208],[331,-124],[496,75],[72,-172],[-259,-283],[420,-254],[-50,-532],[-455,-229],[-268,50],[-192,225],[-690,456],[5,189],[567,-73],[-306,386],[329,286]],[[89889,93835],[-421,-4],[-569,66],[-49,31],[263,234],[348,54],[394,-226],[34,-155]],[[24112,93575],[-298,-442],[-317,22],[-173,519],[4,294],[145,251],[276,161],[579,-20],[530,-144],[-415,-526],[-331,-115]],[[16539,92764],[-731,-294],[-147,259],[-641,312],[93,193],[218,489],[241,388],[-272,362],[939,93],[397,-123],[709,-33],[270,-171],[298,-249],[-349,-149],[-681,-415],[-344,-414],[0,-248]],[[91869,94941],[-321,-234],[-444,53],[-516,233],[66,192],[518,-89],[697,-155]],[[23996,94879],[-151,-229],[-403,44],[-337,155],[148,266],[399,159],[243,-208],[101,-187]],[[90301,95224],[-219,-439],[-1023,16],[-461,-139],[-550,384],[149,406],[366,111],[734,-26],[1004,-313]],[[22639,95907],[212,-273],[9,-303],[-127,-440],[-458,-60],[-298,94],[5,345],[-455,-46],[-18,457],[299,-18],[419,201],[390,-34],[22,77]],[[19941,95601],[109,-210],[247,99],[291,-26],[49,-289],[-169,-281],[-940,-91],[-701,-256],[-423,-14],[-35,193],[577,261],[-1255,-70],[-389,106],[379,577],[262,165],[782,-199],[493,-350],[485,-45],[-397,565],[255,215],[286,-68],[94,-282]],[[65981,92363],[-164,-52],[-907,77],[-74,262],[-503,158],[-40,320],[284,126],[-10,323],[551,503],[-255,73],[665,518],[-75,268],[621,312],[917,380],[925,110],[475,220],[541,76],[193,-233],[-187,-184],[-984,-293],[-848,-282],[-863,-562],[-414,-577],[-435,-568],[56,-491],[531,-484]],[[23699,96131],[308,-190],[547,1],[240,-194],[-64,-222],[319,-134],[177,-140],[374,-26],[406,-50],[441,128],[566,51],[451,-42],[298,-223],[62,-244],[-174,-157],[-414,-127],[-355,72],[-797,-91],[-570,-11],[-449,73],[-738,190],[-96,325],[-34,293],[-279,258],[-574,72],[-322,183],[104,242],[573,-37]],[[17722,96454],[-38,-454],[-214,-205],[-259,-29],[-517,-252],[-444,-91],[-377,128],[472,442],[570,383],[426,-9],[381,87]],[[63641,74970],[141,-419],[130,-28],[85,-159],[-228,-47],[-49,-459],[-47,-207],[-102,-138],[7,-293],[88,-436],[263,-123],[193,-296],[395,-102],[434,156],[27,139],[-52,417],[40,618],[-216,200],[71,405],[-184,34],[61,498],[262,-145],[244,189],[-202,355],[-80,338],[-224,-151],[-28,-433],[-87,383],[-15,144],[68,246],[-53,206],[-322,202],[-125,530],[-154,150],[-9,192],[270,-56],[11,432],[236,96],[243,-88],[50,576],[-50,365],[-278,-28],[-236,144],[-321,-260],[-259,-124],[-127,-350],[-269,-97],[-276,-610],[252,-561],[-27,-398],[303,-696],[146,-311]],[[0,88971],[99997,-3],[-357,-260],[-360,44],[250,-315],[166,-487],[128,-159],[32,-244],[-71,-157],[-518,129],[-777,-445],[-247,-69],[-425,-415],[-403,-362],[-102,-269],[-397,409],[-724,-464],[-126,220],[-268,-254],[-371,81],[-90,-388],[-333,-572],[10,-239],[316,-132],[-37,-860],[-258,-22],[-119,-494],[116,-255],[-486,-301],[-96,-675],[-415,-144],[-83,-600],[-400,-551],[-103,407],[-119,862],[-155,1313],[134,819],[234,353],[15,276],[431,132],[496,744],[479,608],[499,471],[223,833],[-337,-50],[-167,-487],[-705,-648],[-227,726],[-717,-201],[-696,-990],[230,-362],[-620,-154],[-430,-61],[20,427],[-431,90],[-344,-291],[-850,102],[-913,-175],[-900,-1153],[-1065,-1394],[438,-74],[136,-370],[270,-132],[178,296],[305,-39],[401,-650],[9,-502],[-217,-591],[-23,-705],[-126,-945],[-418,-855],[-94,-409],[-377,-688],[-374,-682],[-179,-349],[-370,-346],[-175,-8],[-175,287],[-373,-432],[-43,-197],[-106,36],[-120,-201],[-83,-201],[10,-424],[-143,-130],[-50,-105],[-104,-174],[-185,-97],[-121,-159],[-9,-256],[-32,-65],[111,-96],[157,-259],[240,-697],[68,-383],[3,-681],[-105,-325],[-252,-113],[-222,-245],[-250,-51],[-31,322],[52,443],[-123,615],[206,99],[-190,506],[-135,113],[-34,-112],[-81,-49],[-10,112],[-72,54],[-75,94],[77,260],[65,69],[-25,108],[71,319],[-18,97],[-163,64],[-131,158],[-388,-171],[-204,-277],[-300,-161],[148,274],[-58,230],[220,397],[-147,310],[-242,-209],[-314,-411],[-171,-381],[-272,-29],[-142,-275],[147,-400],[227,-97],[9,-265],[220,-172],[311,421],[247,-230],[179,-15],[46,-310],[-394,-165],[-130,-319],[-270,-296],[-142,-414],[299,-324],[109,-582],[169,-541],[189,-454],[-5,-439],[-174,-161],[66,-315],[164,-184],[-43,-481],[-71,-468],[-155,-53],[-203,-640],[-225,-775],[-258,-705],[-382,-545],[-386,-498],[-313,-68],[-170,-262],[-96,192],[-157,-294],[-388,-296],[-294,-90],[-95,-624],[-154,-35],[-73,429],[66,228],[-373,190],[-131,-97],[-371,-505],[-231,-558],[-61,-410],[212,-623],[260,-772],[252,-365],[169,-475],[127,-1093],[-37,-1039],[-232,-389],[-318,-381],[-227,-492],[-346,-550],[-101,378],[78,401],[-206,335],[-233,87],[-112,307],[-141,611],[-249,271],[-238,-11],[41,464],[-245,-3],[-22,-650],[-150,-863],[-90,-522],[19,-428],[181,-18],[113,-539],[50,-512],[155,-338],[168,-69],[144,-306],[64,-56],[164,-356],[116,-396],[16,-398],[-29,-269],[27,-203],[20,-349],[98,-163],[109,-523],[-5,-199],[-197,-40],[-263,438],[-329,469],[-32,301],[-161,395],[-38,489],[-100,322],[30,431],[-61,250],[-110,227],[-47,292],[-148,334],[-135,280],[-45,-347],[-53,328],[30,369],[82,566],[-27,439],[86,452],[-94,350],[23,644],[-113,306],[-90,707],[-50,746],[-121,490],[-183,-297],[-315,-421],[-156,53],[-172,138],[96,732],[-58,554],[-218,681],[34,213],[-163,76],[-197,481],[-79,309],[-16,301],[-53,284],[-116,344],[-256,23],[25,-243],[-87,-329],[-118,120],[-41,-108],[-78,65],[-108,53],[-39,-216],[-189,7],[-343,-122],[16,-445],[-148,-349],[-400,-398],[-311,-695],[-209,-373],[-276,-386],[-1,-272],[-138,-146],[-250,-212],[-130,-31],[-84,-450],[58,-769],[15,-490],[-118,-561],[-1,-1004],[-144,-29],[-126,-450],[84,-195],[-253,-167],[-93,-402],[-112,-170],[-263,552],[-128,827],[-107,596],[-97,279],[-148,568],[-69,739],[-48,369],[-253,811],[-115,1145],[-83,756],[1,716],[-54,553],[-404,-353],[-196,70],[-362,716],[133,214],[-82,232],[-326,501],[-203,150],[-83,425],[-215,449],[-512,-111],[-451,-11],[-391,-83],[-523,179],[-302,136],[-314,76],[-118,725],[-133,105],[-214,-106],[-280,-286],[-339,196],[-281,454],[-267,168],[-186,561],[-205,788],[-149,-96],[-177,196],[-103,-231],[-165,29],[58,-261],[-25,-135],[89,-445],[109,-510],[137,-135],[47,-207],[190,-248],[16,-244],[-27,-197],[35,-199],[80,-165],[37,-194],[41,-145],[-18,430],[75,310],[76,64],[84,-186],[5,-345],[-61,-348],[53,-226],[49,29],[11,-162],[217,93],[230,-15],[168,-18],[190,400],[207,379],[176,364],[80,201],[35,-51],[-26,-244],[-37,-108],[38,-466],[125,-404],[155,-214],[204,-78],[164,-107],[125,-339],[75,-196],[100,-75],[-1,-132],[-101,-352],[-44,-166],[-117,-189],[-104,-404],[-126,31],[-58,-141],[-44,-300],[34,-395],[-26,-72],[-128,2],[-174,-221],[-27,-288],[-63,-125],[-173,5],[-109,-149],[1,-239],[-134,-164],[-153,56],[-186,-199],[-128,-33],[-201,-159],[-54,-263],[-6,-201],[-277,-249],[-444,-276],[-249,-417],[-122,-32],[-83,34],[-163,-245],[-177,-113],[-233,-31],[-70,-34],[-61,-156],[-73,-43],[-42,-150],[-138,13],[-89,-80],[-192,30],[-72,345],[8,323],[-46,174],[-54,437],[-80,243],[56,29],[-29,270],[34,114],[-12,257],[-36,253],[-84,177],[-22,236],[-143,212],[-148,495],[-79,482],[-192,406],[-124,97],[-184,563],[-32,411],[12,350],[-159,655],[-130,231],[-150,122],[-92,339],[15,133],[-77,307],[-81,131],[-108,440],[-170,476],[-141,406],[-139,-3],[44,325],[12,206],[34,236],[-9,86],[-78,-238],[-60,-446],[-75,-308],[-65,-103],[-93,191],[-125,263],[-198,847],[-29,-53],[115,-624],[171,-594],[210,-920],[102,-321],[90,-334],[249,-654],[-55,-103],[9,-384],[323,-530],[49,-121],[90,-580],[-61,-107],[40,-608],[102,-706],[106,-145],[152,-219],[161,-683],[77,-543],[152,-288],[379,-558],[154,-336],[151,-341],[87,-203],[136,-178],[66,-183],[-9,-245],[-158,-142],[119,-161],[91,-109],[54,-244],[125,-248],[138,-2],[262,151],[302,70],[245,184],[138,39],[99,108],[158,20],[89,12],[128,88],[147,59],[132,202],[105,2],[6,-163],[-25,-344],[1,-310],[-59,-214],[-78,-639],[-134,-659],[-172,-755],[-238,-866],[-237,-661],[-327,-806],[-278,-479],[-415,-586],[-259,-450],[-304,-715],[-64,-312],[-63,-140],[-195,-236],[-68,-246],[-104,-44],[-40,-416],[-89,-238],[-54,-393],[-112,-195],[-128,-728],[16,-335],[178,-216],[8,-153],[-76,-357],[16,-180],[-18,-282],[97,-370],[115,-583],[101,-129],[45,-265],[-11,-588],[34,-519],[11,-923],[49,-290],[-83,-422],[-108,-410],[-177,-366],[-254,-225],[-313,-287],[-313,-634],[-107,-108],[-194,-420],[-115,-136],[-23,-421],[132,-448],[54,-346],[4,-177],[49,29],[-8,-579],[-45,-274],[65,-102],[-41,-246],[-116,-210],[-229,-199],[-334,-320],[-122,-219],[24,-248],[71,-40],[-24,-311],[-70,-430],[-32,-491],[-72,-267],[-190,-298],[-54,-86],[-118,-300],[-77,-303],[-158,-424],[-314,-609],[-196,-355],[-209,-269],[-291,-229],[-141,-31],[-36,-164],[-169,88],[-138,-113],[-301,114],[-168,-72],[-115,31],[-286,-233],[-238,-94],[-171,-223],[-127,-14],[-117,210],[-94,11],[-120,264],[-13,-82],[-37,159],[2,346],[-90,396],[89,108],[-7,453],[-182,553],[-139,501],[-1,1],[-199,768],[-207,446],[-108,432],[-62,575],[-68,428],[-93,910],[-7,707],[-35,322],[-108,243],[-144,489],[-146,708],[-60,371],[-226,577],[-17,453],[-26,372],[38,519],[96,541],[15,254],[90,532],[66,243],[159,386],[90,263],[29,438],[-15,335],[-83,211],[-74,358],[-68,355],[15,122],[85,235],[-84,570],[-57,396],[-139,374],[26,115],[-39,183],[-74,444],[-228,626],[-285,596],[-184,488],[-169,610],[9,196],[61,189],[67,430],[56,438],[-52,90],[96,663],[40,467],[-108,390],[-127,100],[-56,265],[-71,85],[3,163],[-289,-213],[-105,32],[-107,-133],[-222,13],[-149,370],[-91,427],[-197,390],[-209,-8],[-245,1],[-229,-69],[-224,-126],[-436,-346],[-154,-203],[-250,-171],[-248,168],[-126,-7],[-194,116],[-178,-7],[-329,-103],[-193,-170],[-275,-217],[-54,15],[-73,-5],[-286,282],[-252,450],[-237,323],[-187,381],[-75,44],[-200,238],[-144,316],[-49,216],[-34,437],[-122,349],[-108,232],[-71,76],[-69,118],[-32,261],[-41,130],[-80,97],[-149,247],[-117,39],[-63,166],[1,90],[-84,125],[-18,127],[-46,453],[36,262],[-115,460],[-138,210],[122,112],[134,415],[66,304],[-24,318],[78,291],[34,557],[-30,583],[-34,294],[28,295],[-72,281],[-146,255],[12,249],[13,274],[106,161],[91,308],[-18,200],[96,417],[155,376],[93,95],[74,344],[6,315],[100,365],[185,216],[177,603],[144,235],[259,66],[219,403],[139,158],[232,493],[-70,735],[106,508],[37,312],[179,399],[278,270],[206,244],[186,612],[87,362],[205,-2],[167,-251],[264,41],[288,-131],[121,-6],[267,323],[300,102],[175,244],[268,180],[471,105],[459,48],[140,-87],[262,232],[297,5],[113,-137],[190,35],[302,239],[195,-71],[-9,-299],[236,217],[20,-113],[-139,-289],[-2,-274],[96,-147],[-36,-511],[-183,-297],[53,-322],[143,-10],[70,-281],[106,-92],[326,-204],[117,51],[232,-98],[368,-264],[130,-526],[250,-114],[391,-248],[296,-293],[136,153],[133,272],[-65,452],[87,288],[200,277],[192,80],[375,-121],[95,-264],[104,-2],[88,-101],[276,-69],[68,-196],[369,10],[268,-156],[275,-175],[129,-92],[214,188],[114,169],[245,49],[198,-75],[75,-293],[65,193],[222,-140],[217,-33],[137,149],[80,194],[-19,34],[74,276],[56,446],[40,149],[8,6],[99,482],[138,416],[5,21],[-26,452],[68,243],[-102,268],[105,222],[-169,-51],[-233,136],[-191,-340],[-421,-66],[-225,317],[-300,20],[-64,-245],[-192,-71],[-268,315],[-303,-10],[-165,587],[-203,328],[135,459],[-176,283],[308,565],[428,23],[117,449],[529,-78],[334,383],[324,167],[459,13],[485,-416],[399,-229],[323,91],[239,-53],[328,309],[42,252],[-70,403],[-160,218],[-154,68],[-102,181],[-354,499],[-317,223],[-240,347],[202,95],[231,494],[-156,234],[410,241],[-8,129],[-249,-95],[-222,-48],[-185,-191],[-260,-31],[-239,-220],[16,-368],[136,-142],[284,35],[-55,-210],[-304,-103],[-377,-342],[-154,121],[61,277],[-304,173],[50,113],[265,197],[-80,135],[-432,149],[-19,221],[-257,-73],[-103,-325],[-215,-437],[6,-152],[-135,-128],[-84,56],[-78,-713],[-144,-245],[-101,-422],[89,-337],[33,-228],[243,-190],[-51,-145],[-330,-33],[-118,-182],[-232,-319],[-87,275],[3,122],[-169,17],[-145,56],[-336,-154],[192,-332],[-141,-96],[-154,0],[-147,304],[-52,-130],[62,-353],[139,-277],[-105,-130],[155,-272],[137,-171],[4,-334],[-257,157],[82,-302],[-176,-62],[105,-521],[-184,-7],[-228,257],[-104,472],[-49,393],[-108,272],[-143,337],[-18,168],[-48,41],[-5,130],[-154,199],[-24,281],[23,403],[38,184],[-46,93],[-59,46],[-78,192],[-120,118],[-261,218],[-161,213],[-254,176],[-233,435],[56,44],[-127,248],[-5,200],[-179,93],[-85,-255],[-82,198],[6,205],[10,9],[62,54],[-221,86],[-226,-210],[15,-293],[-34,-168],[91,-301],[261,-298],[140,-488],[309,-476],[217,3],[68,-130],[-78,-118],[249,-213],[204,-179],[238,-308],[29,-111],[-52,-211],[-154,276],[-242,97],[-116,-382],[200,-219],[-33,-309],[-116,-35],[-148,-506],[-116,-46],[1,181],[57,317],[60,126],[-108,342],[-85,298],[-115,74],[-82,255],[-179,107],[-120,238],[-206,38],[-217,267],[-254,384],[-189,341],[-86,584],[-138,68],[-226,195],[-128,-80],[-161,-274],[-115,-43],[-252,-334],[-548,160],[-404,-192],[-32,-355],[15,-344],[-263,-393],[-356,-125],[-25,-199],[-171,-327],[-107,-481],[108,-338],[-160,-263],[-60,-384],[-210,-118],[-197,-455],[-352,-8],[-265,11],[-174,-209],[-106,-223],[-136,49],[-103,199],[-79,340],[-259,92],[-112,-153],[-146,83],[-143,-65],[42,462],[-26,363],[-124,55],[-67,224],[22,386],[111,215],[20,239],[58,355],[-6,250],[-56,212],[-12,200],[14,420],[-114,257],[393,426],[340,-107],[373,4],[296,-101],[230,31],[449,-19],[144,354],[53,1177],[-287,620],[-205,299],[-424,228],[-28,430],[360,129],[466,-152],[-88,669],[263,-254],[646,461],[84,484],[243,119],[222,117],[143,162],[244,870],[380,247],[231,-17],[54,125],[232,32],[52,-130],[188,291],[-63,222],[-13,335],[-113,328],[-8,604],[46,159],[80,178],[244,36],[98,163],[223,167],[-9,-304],[-82,-192],[33,-166],[151,-89],[-68,-223],[-83,64],[-200,-425],[76,-288],[4,-228],[281,-138],[-3,-210],[283,111],[156,162],[313,-233],[132,-189],[189,174],[434,273],[350,200],[277,-100],[21,-144],[268,-7],[63,260],[383,191],[-59,497],[10,445],[136,371],[262,202],[221,-442],[223,12],[53,453],[32,349],[-102,-75],[-176,210],[-24,340],[351,164],[350,86],[301,-97],[287,17],[316,327],[-291,280],[-504,-47],[-489,-216],[-452,-125],[-161,322],[-269,195],[62,581],[-135,534],[133,344],[252,371],[635,640],[185,124],[-28,250],[-387,279],[-478,-167],[-269,-413],[43,-361],[-441,-475],[-537,-509],[-202,-832],[198,-416],[265,-328],[-255,-666],[-289,-138],[-106,-992],[-157,-554],[-337,57],[-158,-468],[-321,-27],[-89,558],[-232,671],[-211,835],[-187,363],[-548,-684],[-370,-138],[-385,301],[-99,635],[-88,1364],[256,380],[733,496],[549,609],[508,824],[668,1141],[465,444],[763,741],[610,259],[457,-31],[423,489],[506,-26],[499,118],[869,-433],[-358,-158],[305,-371],[286,206],[456,-358],[761,-140],[1050,-668],[213,-281],[18,-393],[-308,-311],[-454,-157],[-1240,449],[-204,-75],[453,-433],[36,-878],[358,-180],[217,-153],[36,286],[-174,263],[183,215],[672,-368],[234,144],[-187,433],[647,578],[256,-34],[260,-206],[161,406],[-231,352],[136,353],[-204,367],[777,-190],[158,-331],[-351,-73],[2,-328],[218,-203],[429,128],[68,377],[581,282],[969,507],[209,-29],[-273,-359],[344,-61],[199,202],[521,16],[412,245],[317,-356],[315,391],[-291,343],[145,195],[820,-179],[385,-185],[1006,-675],[186,309],[-282,313],[-8,125],[-335,58],[92,280],[-149,461],[-8,189],[512,535],[182,537],[207,116],[735,-156],[58,-328],[-263,-479],[173,-189],[89,-413],[-63,-809],[307,-362],[-120,-395],[-544,-839],[318,-87],[110,213],[306,151],[74,293],[240,281],[-162,336],[130,390],[-304,49],[-67,328],[222,594],[-361,481],[497,398],[-64,421],[139,13],[145,-328],[-109,-570],[297,-108],[-127,426],[465,233],[577,31],[513,-337],[-247,492],[-28,630],[484,119],[668,-26],[602,77],[-226,309],[321,388],[319,16],[540,293],[734,79],[93,162],[729,55],[227,-133],[624,314],[510,-10],[77,255],[265,252],[656,242],[476,-191],[-378,-146],[629,-90],[75,-292],[254,143],[812,-7],[626,-289],[223,-221],[-69,-307],[-307,-175],[-730,-328],[-209,-175],[345,-83],[410,-149],[250,112],[142,-379],[122,153],[444,93],[892,-97],[67,-276],[1162,-88],[15,451],[590,-103],[443,3],[449,-312],[128,-378],[-165,-247],[349,-465],[437,-240],[268,620],[446,-266],[473,159],[538,-182],[204,166],[455,-83],[-201,549],[367,256],[2509,-384],[236,-351],[727,-451],[1122,112],[553,-98],[231,-244],[-33,-432],[342,-168],[372,121],[492,15],[525,-116],[526,66],[484,-526],[344,189],[-224,378],[123,263],[886,-166],[578,36],[799,-282],[-99610,-258]],[[23933,96380],[-126,-17],[-521,38],[-74,165],[559,-9],[195,-109],[-33,-68]],[[19392,96485],[-518,-170],[-411,191],[224,188],[406,60],[392,-92],[-93,-177]],[[56867,96577],[-620,-241],[-490,137],[191,152],[-167,189],[575,119],[110,-222],[401,-134]],[[19538,97019],[-339,-115],[-461,1],[5,84],[285,177],[149,-27],[361,-120]],[[23380,96697],[-411,-122],[-226,138],[-119,221],[-22,245],[360,-24],[162,-39],[332,-205],[-76,-214]],[[22205,96856],[108,-247],[-453,66],[-457,192],[-619,21],[268,176],[-335,142],[-21,227],[546,-81],[751,-215],[212,-281]],[[79187,96845],[-1566,-228],[507,776],[229,66],[208,-38],[704,-336],[-82,-240]],[[55069,97669],[915,-440],[-699,-233],[-155,-435],[-243,-111],[-132,-490],[-335,-23],[-598,361],[252,210],[-416,170],[-541,499],[-216,463],[757,212],[152,-207],[396,8],[105,202],[408,20],[350,-206]],[[57068,98086],[545,-207],[-412,-318],[-806,-70],[-819,98],[-50,163],[-398,11],[-304,271],[858,165],[403,-142],[281,177],[702,-148]],[[64204,98169],[-373,-78],[-250,-45],[-39,-97],[-324,-98],[-301,140],[158,185],[-618,18],[542,107],[422,8],[57,-160],[159,142],[262,97],[412,-129],[-107,-90]],[[77760,97184],[-606,-73],[-773,170],[-462,226],[-213,423],[-379,117],[722,404],[600,133],[540,-297],[640,-572],[-69,-531]],[[25828,97644],[334,-190],[-381,-176],[-513,-445],[-492,-42],[-575,76],[-299,240],[4,215],[220,157],[-508,-4],[-306,196],[-176,268],[193,262],[192,180],[285,42],[-122,135],[646,30],[355,-315],[468,-127],[455,-112],[220,-390]],[[30972,99681],[742,-47],[597,-75],[508,-161],[-12,-157],[-678,-257],[-672,-119],[-251,-133],[605,3],[-656,-358],[-452,-167],[-476,-483],[-573,-98],[-177,-120],[-841,-64],[383,-74],[-192,-105],[230,-292],[-264,-202],[-429,-167],[-132,-232],[-388,-176],[39,-134],[475,23],[6,-144],[-742,-355],[-726,163],[-816,-91],[-414,71],[-525,31],[-35,284],[514,133],[-137,427],[170,41],[742,-255],[-379,379],[-450,113],[225,229],[492,141],[79,206],[-392,231],[-118,304],[759,-26],[220,-64],[433,216],[-625,68],[-972,-38],[-491,201],[-232,239],[-324,173],[-61,202],[413,112],[324,19],[545,96],[409,220],[344,-30],[300,-166],[211,319],[367,95],[498,65],[849,24],[148,-63],[802,100],[601,-38],[602,-37]],[[42472,99925],[1737,-469],[-513,-227],[-1062,-26],[-1496,-58],[140,-105],[984,65],[836,-204],[540,181],[231,-212],[-305,-344],[707,220],[1348,229],[833,-114],[156,-253],[-1132,-420],[-157,-136],[-888,-102],[643,-28],[-324,-431],[-224,-383],[9,-658],[333,-386],[-434,-24],[-457,-187],[513,-313],[65,-502],[-297,-55],[360,-508],[-617,-42],[322,-241],[-91,-208],[-391,-91],[-388,-2],[348,-400],[4,-263],[-549,244],[-143,-158],[375,-148],[364,-361],[105,-476],[-495,-114],[-214,228],[-344,340],[95,-401],[-322,-311],[732,-25],[383,-32],[-745,-515],[-755,-466],[-813,-204],[-306,-2],[-288,-228],[-386,-624],[-597,-414],[-192,-24],[-370,-145],[-399,-138],[-238,-365],[-4,-415],[-141,-388],[-453,-472],[112,-462],[-125,-488],[-142,-577],[-391,-36],[-410,482],[-556,3],[-269,324],[-186,577],[-481,735],[-141,385],[-38,530],[-384,546],[100,435],[-186,208],[275,691],[418,220],[110,247],[58,461],[-318,-209],[-151,-88],[-249,-84],[-341,193],[-19,401],[109,314],[258,9],[567,-157],[-478,375],[-249,202],[-276,-83],[-232,147],[310,550],[-169,220],[-220,409],[-335,626],[-353,230],[3,247],[-745,346],[-590,43],[-743,-24],[-677,-44],[-323,188],[-482,372],[729,186],[559,31],[-1188,154],[-627,241],[39,229],[1051,285],[1018,284],[107,214],[-750,213],[243,235],[961,413],[404,63],[-115,265],[658,156],[854,93],[853,5],[303,-184],[737,325],[663,-221],[390,-46],[577,-192],[-660,318],[38,253],[932,353],[975,-27],[354,218],[982,57],[2219,-74]]],"bbox":[-180,-85.60903777459777,180,83.64513000000002],"transform":{"scale":[0.0036000360003600037,0.0016925586033320111],"translate":[-180,-85.60903777459777]}} diff --git a/macos/run.sh b/macos/run.sh new file mode 100755 index 0000000..c60f844 --- /dev/null +++ b/macos/run.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +print_qt_install_hint() { + echo + echo "Qt6 bulunamadi. macOS'ta gerekli paketleri kurmak icin:" + echo " brew install cmake qt" + echo "veya dogrudan: ./setup-macos.sh" +} + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Bu calistirma betigi macOS icindir." >&2 + exit 1 +fi + +project_dir="$(pwd -P)" + +if [[ -f ./build/CMakeCache.txt ]]; then + cached_source="$(sed -n 's/^CMAKE_HOME_DIRECTORY:INTERNAL=//p' ./build/CMakeCache.txt)" + if [[ -n "$cached_source" && "$cached_source" != "$project_dir" ]]; then + echo "Build klasoru farkli bir proje yoluna ait. 'macos/build' klasorunu silip tekrar deneyin." >&2 + exit 1 + fi +fi + +cmake_args=(-S . -B build) +if command -v brew >/dev/null 2>&1; then + cmake_args+=("-DCMAKE_PREFIX_PATH=$(brew --prefix qt)") +fi + +if ! cmake "${cmake_args[@]}"; then + print_qt_install_hint + exit 1 +fi + +cmake --build build + +exec ./build/location_plotter.app/Contents/MacOS/location_plotter "$@" diff --git a/setup-macos.sh b/macos/setup-macos.sh similarity index 91% rename from setup-macos.sh rename to macos/setup-macos.sh index 0a4651f..c5c4bf5 100755 --- a/setup-macos.sh +++ b/macos/setup-macos.sh @@ -22,8 +22,8 @@ parallel_jobs="$(sysctl -n hw.logicalcpu 2>/dev/null || echo 4)" if [[ -f build/CMakeCache.txt ]]; then cached_system="$(sed -n 's/^CMAKE_SYSTEM_NAME:INTERNAL=//p' build/CMakeCache.txt)" if [[ -n "$cached_system" && "$cached_system" != "Darwin" ]]; then - echo "Baska bir sisteme ait build klasoru yeniden olusturuluyor." - rm -rf build + echo "Build klasoru macOS disinda bir sisteme ait. 'macos/build' klasorunu silip tekrar deneyin." >&2 + exit 1 fi fi diff --git a/src/GlobeWidget.cpp b/macos/src/GlobeWidget.cpp similarity index 100% rename from src/GlobeWidget.cpp rename to macos/src/GlobeWidget.cpp diff --git a/src/GlobeWidget.h b/macos/src/GlobeWidget.h similarity index 100% rename from src/GlobeWidget.h rename to macos/src/GlobeWidget.h diff --git a/macos/src/LocationParser.cpp b/macos/src/LocationParser.cpp new file mode 100644 index 0000000..6e99605 --- /dev/null +++ b/macos/src/LocationParser.cpp @@ -0,0 +1,283 @@ +#include "LocationParser.h" + +#include + +#include + +namespace { + +double toDouble(QString value, bool *ok) +{ + value.replace(',', '.'); + return value.toDouble(ok); +} + +QString normalizedCoordinateText(QString value) +{ + value.replace(',', '.'); + if (value.startsWith('+')) { + value.remove(0, 1); + } + return value; +} + +QString normalizeKey(QString key) +{ + key = key.toLower(); + key.remove('_'); + key.remove('-'); + key.remove(' '); + + if (key == "lat" || key == "latitude" || key == "lattitude") { + return "latitude"; + } + + if (key == "long" || key == "longitude" || key == "lon" || key == "lng") { + return "longitude"; + } + + if (key == "hheight" || key == "height" || key == "alt" || key == "altitude") { + return "hheight"; + } + + if (key == "sat" || key == "sats" || key == "satellite" || key == "satellites" + || key == "activesat" || key == "activesats" || key == "activesatellite" + || key == "activesatellites" || key == "numsat" || key == "numsats" + || key == "numbersat" || key == "numbersats" || key == "satactive" + || key == "satsactive" || key == "satelliteactive" || key == "satellitesactive" + || key == "uydu" || key == "uydular") { + return "satellites"; + } + + if (key == "tracked") { + return "satellites"; + } + + if (key == "gdop" || key == "pdop" || key == "hdop" || key == "vdop" || key == "tdop") { + return key; + } + + return {}; +} + +bool isValidLatitude(double latitude) +{ + return std::isfinite(latitude) && latitude >= -90.0 && latitude <= 90.0; +} + +bool isValidLongitude(double longitude) +{ + return std::isfinite(longitude) && longitude >= -180.0 && longitude <= 180.0; +} + +} // namespace + +std::optional LocationParser::parseLine(const QString &line) +{ + const QString trimmed = line.trimmed(); + if (trimmed.isEmpty()) { + return std::nullopt; + } + + static const QRegularExpression labeledValueRegex( + QStringLiteral(R"((^|[^A-Za-z0-9_])(?lat(?:t?itude)?|long(?:itude)?|lon|lng|hheight|height|alt(?:itude)?|active[_ -]?sat(?:ellite)?s?|num(?:ber)?[_ -]?sat(?:ellite)?s?|sat(?:ellite)?s?[_ -]?active|sat(?:ellite)?s?|sats|tracked|uydu(?:lar)?|[gphvt][_ -]?dop)(?:\s*(?:=|:|->)?\s*)(?[+-]?(?:\d+(?:[\.,]\d+)?|[\.,]\d+)(?:[eE][+-]?\d+)?))"), + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression satelliteCountPhraseRegex( + QStringLiteral(R"((^|[^A-Za-z0-9_\.])(?\d+)\s+(?:active\s+)?(?:sat(?:ellite)?s?|uydu(?:lar)?)(?=$|[\s,.;)\]]))"), + QRegularExpression::CaseInsensitiveOption); + + ParsedTelemetry telemetry; + telemetry.rawLine = trimmed; + if (trimmed.startsWith(QStringLiteral("[FUSION]"), Qt::CaseInsensitive)) { + telemetry.source = LocationSource::ImuCorrected; + } + static const QRegularExpression sourceRegex( + QStringLiteral(R"((?:^|\s)source\s*=\s*(?gps|imu[_-]?corrected|trajectory|ground[_-]?truth)(?=$|\s))"), + QRegularExpression::CaseInsensitiveOption); + const QRegularExpressionMatch sourceMatch = sourceRegex.match(trimmed); + if (sourceMatch.hasMatch()) { + const QString source = sourceMatch.captured(QStringLiteral("source")); + if (source.startsWith(QStringLiteral("imu"), Qt::CaseInsensitive)) { + telemetry.source = LocationSource::ImuCorrected; + } else if (source.startsWith(QStringLiteral("trajectory"), Qt::CaseInsensitive) + || source.startsWith(QStringLiteral("ground"), Qt::CaseInsensitive)) { + telemetry.source = LocationSource::Trajectory; + } else { + telemetry.source = LocationSource::Gps; + } + } + static const QRegularExpression fusionModeRegex( + QStringLiteral(R"((?:^|\s)mode\s*=\s*(?gps[_-]?aided|dead[_-]?reckoning)(?=$|\s))"), + QRegularExpression::CaseInsensitiveOption); + const QRegularExpressionMatch fusionModeMatch = fusionModeRegex.match(trimmed); + if (fusionModeMatch.hasMatch()) { + telemetry.fusionMode = fusionModeMatch.captured(QStringLiteral("mode")).startsWith( + QStringLiteral("dead"), Qt::CaseInsensitive) + ? FusionMode::DeadReckoning : FusionMode::GpsAided; + } + static const QRegularExpression gpsAgeRegex( + QStringLiteral(R"((?:^|\s)gps[_-]?age[_-]?ms\s*=\s*(?\d+)(?=$|\s))"), + QRegularExpression::CaseInsensitiveOption); + const QRegularExpressionMatch gpsAgeMatch = gpsAgeRegex.match(trimmed); + if (gpsAgeMatch.hasMatch()) { + bool ok = false; + const qint64 ageMs = gpsAgeMatch.captured(QStringLiteral("age")).toLongLong(&ok); + if (ok) { + telemetry.gpsAgeMs = ageMs; + } + } + bool hadCoordinateField = false; + bool hadLatitudeField = false; + bool hadLongitudeField = false; + + auto matches = labeledValueRegex.globalMatch(trimmed); + while (matches.hasNext()) { + const QRegularExpressionMatch match = matches.next(); + const QString field = normalizeKey(match.captured(QStringLiteral("key"))); + if (field.isEmpty()) { + continue; + } + + bool ok = false; + const double value = toDouble(match.captured(QStringLiteral("value")), &ok); + if (!ok || !std::isfinite(value)) { + continue; + } + + if (field == "latitude") { + hadCoordinateField = true; + hadLatitudeField = true; + telemetry.latitude = value; + telemetry.latitudeText = normalizedCoordinateText( + match.captured(QStringLiteral("value"))); + } else if (field == "longitude") { + hadCoordinateField = true; + hadLongitudeField = true; + telemetry.longitude = value; + telemetry.longitudeText = normalizedCoordinateText( + match.captured(QStringLiteral("value"))); + } else if (field == "hheight") { + telemetry.hheight = value; + } else if (field == "satellites") { + const int count = static_cast(std::llround(value)); + if (count >= 0) { + telemetry.satellites = count; + } + } else if (field == "gdop") { + if (value >= 0.0) { + telemetry.dop.gdop = value; + } + } else if (field == "pdop") { + if (value >= 0.0) { + telemetry.dop.pdop = value; + } + } else if (field == "hdop") { + if (value >= 0.0) { + telemetry.dop.hdop = value; + } + } else if (field == "vdop") { + if (value >= 0.0) { + telemetry.dop.vdop = value; + } + } else if (field == "tdop") { + if (value >= 0.0) { + telemetry.dop.tdop = value; + } + } + } + + auto satellitePhraseMatches = satelliteCountPhraseRegex.globalMatch(trimmed); + while (satellitePhraseMatches.hasNext()) { + const QRegularExpressionMatch match = satellitePhraseMatches.next(); + bool ok = false; + const int count = match.captured(QStringLiteral("value")).toInt(&ok); + if (ok && count >= 0) { + telemetry.satellites = count; + } + } + + if (telemetry.latitude.has_value() && !isValidLatitude(*telemetry.latitude)) { + telemetry.latitude.reset(); + telemetry.latitudeText.clear(); + } + if (telemetry.longitude.has_value() && !isValidLongitude(*telemetry.longitude)) { + telemetry.longitude.reset(); + telemetry.longitudeText.clear(); + } + + if (hadLatitudeField && hadLongitudeField + && (!telemetry.hasLocation() + || !isValidCoordinate(*telemetry.latitude, *telemetry.longitude))) { + telemetry.latitude.reset(); + telemetry.longitude.reset(); + telemetry.latitudeText.clear(); + telemetry.longitudeText.clear(); + telemetry.hheight.reset(); + } else if (hadCoordinateField && !telemetry.latitude.has_value() + && !telemetry.longitude.has_value()) { + telemetry.hheight.reset(); + } + + if (!telemetry.hasAnyValue()) { + return std::nullopt; + } + + return telemetry; +} + +bool LocationParser::isValidCoordinate(double latitude, double longitude) +{ + return std::isfinite(latitude) && std::isfinite(longitude) + && latitude >= -90.0 && latitude <= 90.0 + && longitude >= -180.0 && longitude <= 180.0; +} + +QSet LocationParser::parseGpsPrns(const QString &line) +{ + QSet result; + const auto addPrn = [&result](QString value, bool zeroBased = false) { + value = value.trimmed().toUpper(); + const bool explicitGpsPrn = value.startsWith(QLatin1Char('G')); + if (explicitGpsPrn) value.remove(0, 1); + bool ok = false; + int number = value.toInt(&ok); + if (ok && zeroBased && !explicitGpsPrn) ++number; + if (ok && number >= 1 && number <= 63) { + result.insert(QStringLiteral("G%1").arg(number, 2, 10, QLatin1Char('0'))); + } + }; + + static const QRegularExpression gpsToken( + QStringLiteral(R"((?:^|[^A-Za-z0-9])G(?\d{1,2})(?=$|[^0-9]))"), + QRegularExpression::CaseInsensitiveOption); + auto gpsMatches = gpsToken.globalMatch(line); + while (gpsMatches.hasNext()) addPrn(gpsMatches.next().captured(QStringLiteral("prn"))); + + // Current firmware versions called their zero-based hardware channel values + // "prns" in complete tracking snapshots. Keep accepting those messages while + // newer firmware uses the unambiguous "prn_indices" label. + static const QRegularExpression legacyTrackingIndices( + QStringLiteral(R"(\btracked\s+satellites\s*=\s*\d+\s+prns?\s*=)"), + QRegularExpression::CaseInsensitiveOption); + const bool legacyZeroBasedPrns = legacyTrackingIndices.match(line).hasMatch(); + + static const QRegularExpression labeledList( + QStringLiteral(R"((?