9 Commits

Author SHA1 Message Date
Onur Cayan c8d6314e48 Add source-aware UART log replay
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
2026-08-12 13:54:06 +03:00
Onur Cayan f7fa2b3e4d Fix incomplete map tile updates
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
2026-08-10 00:51:00 +03:00
onur 745c7d91af Normalize zero-based firmware satellite indices
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
2026-07-24 12:06:12 +03:00
onur db96fd4da2 Verify satellite propagation is timezone invariant 2026-07-24 11:58:18 +03:00
onur 140b0e35b5 Show RINEX status time in local timezone 2026-07-24 11:51:12 +03:00
onur 73227452c3 Merge GUI updates into macOS branch
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
# Conflicts:
#	src/GlobeWidget.cpp
#	src/GlobeWidget.h
#	src/MainWindow.h
#	src/MapWidget.cpp
#	src/MapWidget.h
#	src/RinexNavigation.cpp
2026-07-24 00:14:47 +03:00
onur f012b7a52a Lock map tracking to corrected IMU output
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
2026-07-18 18:06:02 +03:00
onur 7a883b801f Add native macOS settings and device integration
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
2026-07-18 16:51:50 +03:00
onur e648eca324 Add polished macOS location plotter experience
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled
2026-07-18 15:20:37 +03:00
18 changed files with 1657 additions and 112 deletions
+1
View File
@@ -8,3 +8,4 @@ build-*/
*.autosave
*.swp
*~
uart_logs/
+40 -2
View File
@@ -22,6 +22,8 @@ add_executable(location_plotter
src/MapWidget.h
src/SerialReader.cpp
src/SerialReader.h
src/UartReplay.cpp
src/UartReplay.h
src/RinexNavigation.cpp
src/RinexNavigation.h
src/SatelliteData.h
@@ -30,6 +32,9 @@ add_executable(location_plotter
)
target_link_libraries(location_plotter PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB)
target_compile_definitions(location_plotter PRIVATE
LOCATION_PLOTTER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
)
if(APPLE)
set_target_properties(location_plotter PROPERTIES
@@ -43,7 +48,7 @@ endif()
qt_add_resources(location_plotter "app_resources"
PREFIX "/"
FILES resources/ankasys-logo.png resources/land-110m.json
FILES resources/ankasys-logo.png resources/land-110m.json resources/bluemarble-2048.png
)
if(UNIX AND NOT APPLE)
@@ -67,6 +72,20 @@ target_compile_options(location_parser_tests PRIVATE
)
add_test(NAME location_parser_tests COMMAND location_parser_tests)
add_executable(location_map_widget_tests
tests/map_widget_tests.cpp
src/MapWidget.cpp
src/MapWidget.h
src/TelemetryPoint.h
)
target_link_libraries(location_map_widget_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network)
target_compile_options(location_map_widget_tests PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-UNDEBUG>
)
add_test(NAME location_map_widget_tests COMMAND location_map_widget_tests)
set_tests_properties(location_map_widget_tests PROPERTIES
ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_CACHE_HOME=/tmp/location-plotter-map-test-cache")
add_executable(location_main_window_tests
tests/main_window_tests.cpp
src/LocationParser.cpp
@@ -79,18 +98,23 @@ add_executable(location_main_window_tests
src/GlobeWidget.h
src/SerialReader.cpp
src/SerialReader.h
src/UartReplay.cpp
src/UartReplay.h
src/RinexNavigation.cpp
src/RinexNavigation.h
src/SatelliteData.h
src/TelemetryPoint.h
)
target_link_libraries(location_main_window_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB)
target_compile_definitions(location_main_window_tests PRIVATE
LOCATION_PLOTTER_SOURCE_DIR="${CMAKE_CURRENT_SOURCE_DIR}"
)
target_compile_options(location_main_window_tests PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-UNDEBUG>
)
qt_add_resources(location_main_window_tests "test_app_resources"
PREFIX "/"
FILES resources/ankasys-logo.png resources/land-110m.json
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)
@@ -99,6 +123,20 @@ 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
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-UNDEBUG>
)
add_test(NAME location_uart_replay_tests COMMAND location_uart_replay_tests)
install(TARGETS location_plotter
BUNDLE DESTINATION .
RUNTIME DESTINATION bin
+22 -3
View File
@@ -66,6 +66,19 @@ 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:
@@ -219,13 +232,16 @@ without logging out.
(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 UTC time of the last successful download or cache load.
- Complete UART channel snapshots such as `tracked satellites=5 prns=[...]` replace the previous
connected-PRN set, so satellites that were lost no longer remain green.
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,
@@ -260,5 +276,8 @@ without logging out.
- UART log and telemetry views are refreshed in batches so a high-rate serial stream does not
rebuild the complete interface for every received line. The locations table uses a virtual
model and remains usable with a high `Max` value.
- Her basarili UART baglantisi, dosya adinda ve her satirda yerel zaman damgasi bulunan ayri bir
log olusturur. Dosyalar kaynak kodun ana klasorundeki `uart_logs` dizinine yazilir;
`LOCATION_PLOTTER_UART_LOG_DIR` ile farkli bir klasor secilebilir.
- If no new plotted location arrives for 10 seconds while listening, current location
points and analysis values are cleared.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+288 -22
View File
@@ -1,12 +1,14 @@
#include "GlobeWidget.h"
#include <QLinearGradient>
#include <QDateTime>
#include <QHash>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMouseEvent>
#include <QNativeGestureEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPolygonF>
@@ -185,6 +187,15 @@ const QImage &landMask()
return mask;
}
const QImage &blueMarbleTexture()
{
static const QImage texture = [] {
QImage image(QStringLiteral(":/resources/bluemarble-2048.png"));
return image.convertToFormat(QImage::Format_RGB32);
}();
return texture;
}
bool maskIsLand(double longitude, double latitude)
{
const QImage &mask = landMask();
@@ -354,14 +365,18 @@ void GlobeWidget::paintEvent(QPaintEvent *)
painter.setRenderHint(QPainter::Antialiasing, true);
const qreal devicePixelRatio = devicePixelRatioF();
const qint64 lightingMinute =
QDateTime::currentDateTimeUtc().toSecsSinceEpoch() / 60;
if (m_staticLayer.isNull() || m_staticLayerSize != size()
|| !qFuzzyCompare(m_staticLayerDevicePixelRatio, devicePixelRatio)
|| m_staticLayerYaw != m_yaw || m_staticLayerPitch != m_pitch
|| m_staticLayerZoom != m_zoom) {
|| m_staticLayerZoom != m_zoom
|| m_staticLayerLightingMinute != lightingMinute) {
rebuildStaticLayer();
}
painter.drawPixmap(0, 0, m_staticLayer);
drawSatelliteCoverage(painter);
if (m_hasReceiver) drawVisibilityCone(painter);
if (m_hasReceiver) drawReceiver(painter);
const int visibleCount = drawSatellites(painter);
@@ -454,10 +469,26 @@ void GlobeWidget::rebuildStaticLayer()
QPainterPath sphereClip;
sphereClip.addEllipse(center, radius, radius);
painter.setClipPath(sphereClip);
drawContinents(painter);
drawBlueMarble(painter);
drawGraticule(painter);
painter.restore();
// A subtle limb shadow and ocean highlight add depth without hiding the
// geographic texture or telemetry overlays.
painter.save();
QPainterPath lightingClip;
lightingClip.addEllipse(center, radius, radius);
painter.setClipPath(lightingClip);
QRadialGradient limb(center - QPointF(radius * .30, radius * .34), radius * 1.30);
limb.setColorAt(0.0, QColor(255, 255, 255, 18));
limb.setColorAt(0.50, QColor(70, 175, 225, 5));
limb.setColorAt(0.82, QColor(0, 8, 20, 35));
limb.setColorAt(1.0, QColor(0, 3, 12, 155));
painter.setPen(Qt::NoPen);
painter.setBrush(limb);
painter.drawEllipse(center, radius, radius);
painter.restore();
for (int i = 0; i < 3; ++i) drawOrbit(painter, 55.0 + i * 1.8, i * 61.0 + 12.0);
painter.end();
@@ -467,6 +498,107 @@ void GlobeWidget::rebuildStaticLayer()
m_staticLayerYaw = m_yaw;
m_staticLayerPitch = m_pitch;
m_staticLayerZoom = m_zoom;
m_staticLayerLightingMinute =
QDateTime::currentDateTimeUtc().toSecsSinceEpoch() / 60;
}
void GlobeWidget::drawBlueMarble(QPainter &painter) const
{
const QImage &source = blueMarbleTexture();
if (source.isNull()) {
drawContinents(painter);
return;
}
const double radius = std::min(width(), height()) * 0.255 * m_zoom;
// Interactive rotation favors latency; mouse release immediately causes
// a full Retina-quality rebuild. Satellite animation frames reuse the
// cached texture and therefore do not repeat this projection work.
const double pixelRatio = m_dragging ? std::min(devicePixelRatioF(), 1.0)
: std::min(devicePixelRatioF(), 2.0);
const int textureSize = m_dragging
? std::clamp(static_cast<int>(std::ceil(radius * 2.0 * pixelRatio)), 280, 650)
: std::clamp(static_cast<int>(std::ceil(radius * 2.0 * pixelRatio)), 420, 1200);
const QPointF center(width() * 0.5, height() * 0.52);
const QRectF target(center.x() - radius, center.y() - radius,
radius * 2.0, radius * 2.0);
const QDateTime nowUtc = QDateTime::currentDateTimeUtc();
const qint64 lightingMinute = nowUtc.toSecsSinceEpoch() / 60;
if (!m_landTexture.isNull() && m_landTexture.width() == textureSize
&& m_landTextureYaw == m_yaw && m_landTexturePitch == m_pitch
&& m_landTextureLightingMinute == lightingMinute) {
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
painter.drawImage(target, m_landTexture);
return;
}
QImage globe(textureSize, textureSize, QImage::Format_ARGB32_Premultiplied);
globe.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);
const double utcHours = nowUtc.time().msecsSinceStartOfDay() / 3600000.0;
const double declination = 23.44 * std::sin(
2.0 * Pi * (284.0 + nowUtc.date().dayOfYear()) / 365.25);
const Vec3 sun = globeVector(declination, (12.0 - utcHours) * 15.0);
const Vec3 camera {sy * cp, cy * cp, -sp};
const double halfLength = std::sqrt(std::pow(sun.x + camera.x, 2.0)
+ std::pow(sun.y + camera.y, 2.0) + std::pow(sun.z + camera.z, 2.0));
const Vec3 halfVector {(sun.x + camera.x) / halfLength,
(sun.y + camera.y) / halfLength,
(sun.z + camera.z) / halfLength};
for (int py = 0; py < textureSize; ++py) {
QRgb *destination = reinterpret_cast<QRgb *>(globe.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));
const double longitude = std::atan2(worldY, worldX);
int sourceX = static_cast<int>((longitude / (2.0 * Pi) + 0.5) * source.width());
sourceX = (sourceX % source.width() + source.width()) % source.width();
const int sourceY = std::clamp(static_cast<int>((0.5 - latitude / Pi)
* source.height()), 0, source.height() - 1);
const QRgb sample = reinterpret_cast<const QRgb *>(source.constScanLine(sourceY))[sourceX];
double red = qRed(sample);
double green = qGreen(sample);
double blue = qBlue(sample);
const double solarDot = worldX * sun.x + worldY * sun.y + worldZ * sun.z;
const double daylight = smoothStep(-0.10, 0.07, solarDot);
const double diffuse = 0.16 + 0.84 * std::max(0.0, solarDot);
const bool ocean = blue > green * 1.18 && blue > red * 1.35;
if (ocean) {
const double halfDot = std::max(0.0, worldX * halfVector.x
+ worldY * halfVector.y + worldZ * halfVector.z);
const double specular = std::pow(halfDot, 80.0) * daylight * 115.0;
red += specular;
green += specular;
blue += specular * 0.92;
}
const double limb = smoothStep(0.02, 0.34, depth);
const double lighting = (0.08 + diffuse * 0.92) * (0.45 + limb * 0.55);
destination[px] = qRgba(
std::clamp(static_cast<int>(red * lighting), 0, 255),
std::clamp(static_cast<int>(green * lighting), 0, 255),
std::clamp(static_cast<int>(blue * lighting), 0, 255), 255);
}
}
m_landTexture = globe;
m_landTextureYaw = m_yaw;
m_landTexturePitch = m_pitch;
m_landTextureLightingMinute = lightingMinute;
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
painter.drawImage(target, m_landTexture);
}
void GlobeWidget::drawContinents(QPainter &painter) const
@@ -479,8 +611,11 @@ void GlobeWidget::drawContinents(QPainter &painter) const
const QPointF center(width() * 0.5, height() * 0.52);
const QRectF target(center.x() - radius, center.y() - radius,
radius * 2.0, radius * 2.0);
const QDateTime nowUtc = QDateTime::currentDateTimeUtc();
const qint64 lightingMinute = nowUtc.toSecsSinceEpoch() / 60;
if (!m_landTexture.isNull() && m_landTexture.width() == textureSize
&& m_landTextureYaw == m_yaw && m_landTexturePitch == m_pitch) {
&& m_landTextureYaw == m_yaw && m_landTexturePitch == m_pitch
&& m_landTextureLightingMinute == lightingMinute) {
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
painter.drawImage(target, m_landTexture);
return;
@@ -492,6 +627,18 @@ void GlobeWidget::drawContinents(QPainter &painter) const
const double sy = std::sin(m_yaw);
const double cp = std::cos(m_pitch);
const double sp = std::sin(m_pitch);
const double utcHours = nowUtc.time().msecsSinceStartOfDay() / 3600000.0;
const double solarDeclination = 23.44 * std::sin(
2.0 * Pi * (284.0 + nowUtc.date().dayOfYear()) / 365.25);
const double subsolarLongitude = (12.0 - utcHours) * 15.0;
const Vec3 sun = globeVector(solarDeclination, subsolarLongitude);
const Vec3 camera {sy * cp, cy * cp, -sp};
const double halfLength = std::sqrt(
std::pow(sun.x + camera.x, 2.0) + std::pow(sun.y + camera.y, 2.0)
+ std::pow(sun.z + camera.z, 2.0));
const Vec3 halfVector {(sun.x + camera.x) / halfLength,
(sun.y + camera.y) / halfLength,
(sun.z + camera.z) / halfLength};
for (int py = 0; py < textureSize; ++py) {
QRgb *scanLine = reinterpret_cast<QRgb *>(land.scanLine(py));
const double screenZ = -((py + 0.5) / textureSize * 2.0 - 1.0);
@@ -512,16 +659,37 @@ void GlobeWidget::drawContinents(QPainter &painter) const
|| maskIsLand(longitude - 0.55, latitude)
|| maskIsLand(longitude, latitude + 0.55)
|| maskIsLand(longitude, latitude - 0.55);
const double cloudNoise = 0.50
+ 0.24 * std::sin(longitude * Pi / 22.0 + latitude * Pi / 19.0)
+ 0.18 * std::sin(longitude * Pi / 7.0 - latitude * Pi / 13.0)
+ 0.12 * std::cos(longitude * Pi / 3.7 + latitude * Pi / 8.0);
const double cloud = smoothStep(0.72, 0.94, cloudNoise)
* smoothStep(0.03, 0.25, depth);
const double solarDot = worldX * sun.x + worldY * sun.y + worldZ * sun.z;
const double daylight = smoothStep(-0.11, 0.08, solarDot);
const double diffuse = 0.12 + 0.88 * std::max(0.0, solarDot);
if (!onLand) {
if (nearLand) {
const int alpha = static_cast<int>(65.0 * std::clamp(depth * 1.5, 0.0, 1.0));
scanLine[px] = qRgba(33, 151, 174, alpha);
}
const double oceanPattern = 0.5 + 0.25 * std::sin(longitude * Pi / 31.0)
+ 0.18 * std::cos(latitude * Pi / 17.0 + longitude * Pi / 47.0);
double red = nearLand ? 18.0 : 3.0 + oceanPattern * 5.0;
double green = nearLand ? 104.0 : 32.0 + oceanPattern * 16.0;
double blue = nearLand ? 132.0 : 72.0 + oceanPattern * 26.0;
const double halfDot = std::max(0.0, worldX * halfVector.x
+ worldY * halfVector.y + worldZ * halfVector.z);
const double specular = std::pow(halfDot, 72.0) * daylight * 150.0;
const double shade = 0.18 + diffuse * 0.82;
red = red * shade + specular;
green = green * shade + specular;
blue = blue * shade + specular * 0.92;
red = red * (1.0 - cloud * 0.58) + 226.0 * cloud * 0.58;
green = green * (1.0 - cloud * 0.58) + 237.0 * cloud * 0.58;
blue = blue * (1.0 - cloud * 0.58) + 243.0 * cloud * 0.58;
scanLine[px] = qRgba(std::clamp(static_cast<int>(red), 0, 255),
std::clamp(static_cast<int>(green), 0, 255),
std::clamp(static_cast<int>(blue), 0, 255), 248);
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)
@@ -550,31 +718,110 @@ void GlobeWidget::drawContinents(QPainter &painter) const
red = red * (1.0 - snow) + 224.0 * snow;
green = green * (1.0 - snow) + 231.0 * snow;
blue = blue * (1.0 - snow) + 224.0 * snow;
red = red * (1.0 - cloud * 0.62) + 232.0 * cloud * 0.62;
green = green * (1.0 - cloud * 0.62) + 241.0 * cloud * 0.62;
blue = blue * (1.0 - cloud * 0.62) + 244.0 * cloud * 0.62;
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);
const double shade = (0.15 + diffuse * 0.85) * (0.86 + relief * 0.14);
if (coast) {
red += 28.0;
green += 31.0;
blue += 18.0;
}
const double settlementNoise = 0.5 + 0.28 * std::sin(lon * 41.0 + lat * 19.0)
+ 0.22 * std::cos(lon * 73.0 - lat * 31.0);
const double cityLights = smoothStep(0.82, 0.98, settlementNoise)
* (1.0 - daylight) * smoothStep(68.0, 42.0, std::abs(latitude));
scanLine[px] = qRgba(
std::clamp(static_cast<int>(red * shade), 0, 255),
std::clamp(static_cast<int>(green * shade), 0, 255),
std::clamp(static_cast<int>(blue * shade), 0, 255), 238);
std::clamp(static_cast<int>(red * shade + cityLights * 235.0), 0, 255),
std::clamp(static_cast<int>(green * shade + cityLights * 155.0), 0, 255),
std::clamp(static_cast<int>(blue * shade + cityLights * 52.0), 0, 255), 248);
}
}
m_landTexture = land;
m_landTextureYaw = m_yaw;
m_landTexturePitch = m_pitch;
m_landTextureLightingMinute = lightingMinute;
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
painter.drawImage(target, m_landTexture);
}
void GlobeWidget::drawSatelliteCoverage(QPainter &painter) const
{
if (m_satellites.isEmpty()) return;
constexpr double EarthRadiusMeters = 6378137.0;
const double earthRadiusPixels = std::min(width(), height()) * 0.255 * m_zoom;
const double elevation = m_elevationMaskDegrees * Pi / 180.0;
for (const SatellitePosition &satellite : m_satellites) {
const double orbitRadius = std::sqrt(satellite.x * satellite.x
+ satellite.y * satellite.y
+ satellite.z * satellite.z);
if (orbitRadius <= EarthRadiusMeters) continue;
const Vec3 normal {satellite.x / orbitRadius, satellite.y / orbitRadius,
satellite.z / orbitRadius};
const double footprintAngle = std::acos(std::clamp(
EarthRadiusMeters / orbitRadius * std::cos(elevation), -1.0, 1.0)) - elevation;
Vec3 reference = std::abs(normal.z) < 0.88
? 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);
if (tangentLength <= 0.0) continue;
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 bool tracked = m_observedPrns.contains(satellite.prn);
QColor coverageColor = tracked ? QColor(69, 232, 157, 78)
: QColor(83, 188, 230, 25);
painter.setPen(QPen(coverageColor, tracked ? 1.15 : 0.65, Qt::DashLine));
painter.setBrush(Qt::NoBrush);
QPainterPath visibleSegments;
bool drawing = false;
constexpr int Segments = 72;
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 surface {
normal.x * std::cos(footprintAngle) + tangent.x * std::sin(footprintAngle),
normal.y * std::cos(footprintAngle) + tangent.y * std::sin(footprintAngle),
normal.z * std::cos(footprintAngle) + tangent.z * std::sin(footprintAngle)
};
const ProjectedPoint point = project(surface.x, surface.y, surface.z,
earthRadiusPixels * 1.006);
if (point.depth >= -0.015) {
if (!drawing) visibleSegments.moveTo(point.point);
else visibleSegments.lineTo(point.point);
drawing = true;
} else {
drawing = false;
}
}
painter.drawPath(visibleSegments);
}
}
void GlobeWidget::drawStars(QPainter &painter) const
{
painter.setPen(Qt::NoPen);
@@ -591,6 +838,10 @@ void GlobeWidget::drawStars(QPainter &painter) const
void GlobeWidget::drawGraticule(QPainter &painter) const
{
const double radius = std::min(width(), height()) * 0.255 * m_zoom;
// Paths are open grid lines. Inheriting the radial Earth brush makes
// QPainter implicitly close and fill them, which produced the black
// rectangular/polygonal artifacts on the globe.
painter.setBrush(Qt::NoBrush);
painter.setPen(QPen(QColor(100, 193, 221, 48), 0.8));
for (int latitude = -60; latitude <= 60; latitude += 30) {
QPainterPath path;
@@ -707,14 +958,10 @@ void GlobeWidget::drawVisibilityCone(QPainter &painter) const
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});
}
// Do not fill the projected cone with a triangle fan. When part of the
// ring is behind the globe that fan self-intersects and produces dark
// rectangular/polygonal artifacts over the Earth texture. The boundary,
// spokes and sight lines communicate the same mask without obscuring it.
QPainterPath boundary;
boundary.moveTo(ring.first().point);
for (int i = 1; i < ring.size(); ++i) boundary.lineTo(ring[i].point);
@@ -864,7 +1111,26 @@ void GlobeWidget::mouseReleaseEvent(QMouseEvent *event)
void GlobeWidget::wheelEvent(QWheelEvent *event)
{
m_zoom = std::clamp(m_zoom + event->angleDelta().y() / 1200.0, 0.72, 1.28);
const QPoint pixelDelta = event->pixelDelta();
const double delta = !pixelDelta.isNull()
? pixelDelta.y() / 420.0
: event->angleDelta().y() / 1200.0;
if (qFuzzyIsNull(delta)) return;
m_zoom = std::clamp(m_zoom + delta, 0.62, 1.42);
update();
event->accept();
}
bool GlobeWidget::event(QEvent *event)
{
if (event->type() == QEvent::NativeGesture) {
auto *gesture = static_cast<QNativeGestureEvent *>(event);
if (gesture->gestureType() == Qt::ZoomNativeGesture) {
m_zoom = std::clamp(m_zoom + gesture->value() * 0.85, 0.62, 1.42);
update();
gesture->accept();
return true;
}
}
return QWidget::event(event);
}
+5
View File
@@ -30,6 +30,7 @@ public slots:
void setElevationMask(double degrees);
protected:
bool event(QEvent *event) override;
void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
@@ -46,6 +47,8 @@ private:
void drawStars(QPainter &painter) const;
void drawOrbit(QPainter &painter, double inclination, double node) const;
void drawContinents(QPainter &painter) const;
void drawBlueMarble(QPainter &painter) const;
void drawSatelliteCoverage(QPainter &painter) const;
void drawVisibilityCone(QPainter &painter) const;
void drawGraticule(QPainter &painter) const;
void drawReceiver(QPainter &painter) const;
@@ -63,12 +66,14 @@ private:
mutable QImage m_landTexture;
mutable double m_landTextureYaw = 0.0;
mutable double m_landTexturePitch = 0.0;
mutable qint64 m_landTextureLightingMinute = -1;
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;
qint64 m_staticLayerLightingMinute = -1;
QSet<QString> m_observedPrns;
QString m_status = QStringLiteral("Konum bekleniyor");
bool m_statusError = false;
+25 -8
View File
@@ -88,6 +88,9 @@ std::optional<ParsedTelemetry> LocationParser::parseLine(const QString &line)
ParsedTelemetry telemetry;
telemetry.rawLine = trimmed;
if (trimmed.startsWith(QStringLiteral("[FUSION]"), Qt::CaseInsensitive)) {
telemetry.source = LocationSource::ImuCorrected;
}
static const QRegularExpression sourceRegex(
QStringLiteral(R"((?:^|\s)source\s*=\s*(?<source>gps|imu[_-]?corrected|trajectory|ground[_-]?truth)(?=$|\s))"),
QRegularExpression::CaseInsensitiveOption);
@@ -234,10 +237,11 @@ QSet<QString> LocationParser::parseGpsPrns(const QString &line)
QSet<QString> result;
const auto addPrn = [&result](QString value, bool zeroBased = false) {
value = value.trimmed().toUpper();
if (value.startsWith(QLatin1Char('G'))) value.remove(0, 1);
const bool explicitGpsPrn = value.startsWith(QLatin1Char('G'));
if (explicitGpsPrn) value.remove(0, 1);
bool ok = false;
int number = value.toInt(&ok);
if (ok && zeroBased) ++number;
if (ok && zeroBased && !explicitGpsPrn) ++number;
if (ok && number >= 1 && number <= 63) {
result.insert(QStringLiteral("G%1").arg(number, 2, 10, QLatin1Char('0')));
}
@@ -249,18 +253,31 @@ QSet<QString> LocationParser::parseGpsPrns(const QString &line)
auto gpsMatches = gpsToken.globalMatch(line);
while (gpsMatches.hasNext()) addPrn(gpsMatches.next().captured(QStringLiteral("prn")));
static const QRegularExpression labeledList(
QStringLiteral(R"((?<label>\bPRNS?\b|\bSVS?\b|\bSVNS?\b|\bSVIDS?\b)\s*(?:=|:|->)?\s*[\[(]?\s*(?<list>(?:G?\d{1,2})(?:\s*[,;/| ]\s*G?\d{1,2})*)\s*[\])]?)"),
// 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);
static const QRegularExpression number(QStringLiteral(R"(G?(\d{1,2}))"),
const bool legacyZeroBasedPrns = legacyTrackingIndices.match(line).hasMatch();
static const QRegularExpression labeledList(
QStringLiteral(R"((?<label>\bPRN[_ -]?INDICES\b|\bPRNS?\b|\bSVS?\b|\bSVNS?\b|\bSVIDS?\b)\s*(?:=|:|->)?\s*[\[(]?\s*(?<list>(?:G?\d{1,2})(?:\s*[,;/| ]\s*G?\d{1,2})*)\s*[\])]?)"),
QRegularExpression::CaseInsensitiveOption);
static const QRegularExpression number(QStringLiteral(R"(G?\d{1,2})"),
QRegularExpression::CaseInsensitiveOption);
auto listMatches = labeledList.globalMatch(line);
while (listMatches.hasNext()) {
const QRegularExpressionMatch listMatch = listMatches.next();
const bool zeroBased = listMatch.captured(QStringLiteral("label")).startsWith(
QStringLiteral("SVN"), Qt::CaseInsensitive);
QString label = listMatch.captured(QStringLiteral("label")).toUpper();
label.remove(QLatin1Char('_'));
label.remove(QLatin1Char('-'));
label.remove(QLatin1Char(' '));
const bool zeroBased = label.startsWith(QStringLiteral("SVN"))
|| label == QStringLiteral("PRNINDICES")
|| (legacyZeroBasedPrns && label.startsWith(QStringLiteral("PRN")));
auto values = number.globalMatch(listMatch.captured(QStringLiteral("list")));
while (values.hasNext()) addPrn(values.next().captured(1), zeroBased);
while (values.hasNext()) addPrn(values.next().captured(0), zeroBased);
}
return result;
}
+442 -13
View File
@@ -5,26 +5,34 @@
#include "MapWidget.h"
#include "RinexNavigation.h"
#include "SerialReader.h"
#include "UartReplay.h"
#include <QAbstractItemView>
#include <QAbstractTableModel>
#include <QCheckBox>
#include <QCloseEvent>
#include <QComboBox>
#include <QDateTime>
#include <QDir>
#include <QDialog>
#include <QDialogButtonBox>
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QFile>
#include <QFileDialog>
#include <QFileInfo>
#include <QGridLayout>
#include <QGroupBox>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QMenuBar>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QScrollArea>
#include <QPushButton>
#include <QRegularExpression>
#include <QSettings>
#include <QSpinBox>
#include <QSplitter>
#include <QStatusBar>
@@ -160,6 +168,7 @@ LocationAnalysis analyzeLocations(const QVector<TelemetryPoint> &points)
QVector<TelemetryPoint> preferredAnalysisPoints(const QVector<TelemetryPoint> &points)
{
QVector<TelemetryPoint> corrected;
corrected.reserve(points.size());
for (const TelemetryPoint &point : points) {
if (point.source == LocationSource::ImuCorrected) {
corrected.push_back(point);
@@ -375,9 +384,11 @@ private:
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent)
, m_serial(new SerialReader(this))
, m_replay(new UartReplay(this))
, m_rinex(new RinexNavigation(this))
{
buildUi();
buildMenus();
m_locationTimeoutTimer = new QTimer(this);
m_locationTimeoutTimer->setSingleShot(true);
@@ -387,16 +398,63 @@ MainWindow::MainWindow(QWidget *parent)
m_uiRefreshTimer->setSingleShot(true);
m_uiRefreshTimer->setInterval(UiRefreshIntervalMs);
m_portRefreshTimer = new QTimer(this);
m_portRefreshTimer->setInterval(2000);
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_portRefreshTimer, &QTimer::timeout, this, [this] {
if (!m_serial->isOpen()) refreshSerialPorts();
});
m_portRefreshTimer->start();
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);
if (m_rinexUpdateButton != nullptr) {
m_rinexUpdateButton->setEnabled(!m_rinex->isLoading());
m_rinexUpdateButton->setText(m_rinex->isLoading()
? QStringLiteral("RINEX indiriliyor...")
: QStringLiteral("RINEX Guncelle"));
}
statusBar()->showMessage(status, error ? 8000 : 4000);
});
connect(m_rinex, &RinexNavigation::loadingChanged, this, [this](bool loading) {
@@ -414,13 +472,26 @@ MainWindow::MainWindow(QWidget *parent)
rinexRefreshTimer->start();
resize(1480, 900);
loadSettings();
setWindowTitle(QStringLiteral("Canli Konum Haritasi"));
}
void MainWindow::closeEvent(QCloseEvent *event)
{
m_replay->stop();
stopUartLogging();
saveSettings();
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();
}
@@ -428,11 +499,130 @@ void MainWindow::toggleSerial()
return;
}
if (m_serial->openPort(m_portCombo->currentText().trimmed(), selectedBaudRate())) {
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) {
@@ -443,6 +633,8 @@ void MainWindow::clearPoints()
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());
@@ -454,20 +646,19 @@ bool MainWindow::loadTelemetryLog(const QString &filePath)
resetTelemetryState();
const int replayCapacity = static_cast<int>(std::min<qsizetype>(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);
}
// Replay files do not always include a final "NAV solve done" marker. If
// the final latitude/longitude pair is complete, retain it as a fix.
commitPendingTelemetry();
flushUiUpdates();
m_map->fitToPoints();
const int parsedLocations = m_points.size();
const QString message = QStringLiteral("Replay yuklendi: %1 konum - %2")
.arg(parsedLocations).arg(filePath);
m_statusLabel->setText(message);
@@ -485,12 +676,109 @@ void MainWindow::openTelemetryLog()
}
}
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();
if (m_portsInitialized && detectedPorts == m_lastDetectedPorts) {
return;
}
const bool deviceAdded = m_portsInitialized
&& detectedPorts.size() > m_lastDetectedPorts.size();
m_lastDetectedPorts = detectedPorts;
m_portsInitialized = true;
m_portCombo->clear();
for (const QString &port : detectedPorts) {
m_portCombo->addItem(port);
@@ -511,20 +799,133 @@ void MainWindow::refreshSerialPorts()
m_portCombo->addItem(selectedPort);
}
m_portCombo->setCurrentText(selectedPort);
if (deviceAdded) {
statusBar()->showMessage(QStringLiteral("Yeni USB UART aygiti algilandi: %1")
.arg(selectedPort), 4000);
}
}
void MainWindow::buildMenus()
{
auto *fileMenu = menuBar()->addMenu(QStringLiteral("Dosya"));
QAction *openAction = fileMenu->addAction(QStringLiteral("Senaryo Logu Ac..."));
openAction->setObjectName(QStringLiteral("openLogAction"));
openAction->setShortcut(QKeySequence::Open);
connect(openAction, &QAction::triggered, this, &MainWindow::openTelemetryLog);
QAction *replayAction = fileMenu->addAction(QStringLiteral("UART Log Replay..."));
replayAction->setObjectName(QStringLiteral("uartReplayAction"));
replayAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+O")));
connect(replayAction, &QAction::triggered, this, &MainWindow::openUartReplay);
fileMenu->addSeparator();
QAction *quitAction = fileMenu->addAction(QStringLiteral("Cikis"));
quitAction->setShortcut(QKeySequence::Quit);
connect(quitAction, &QAction::triggered, this, &QWidget::close);
auto *connectionMenu = menuBar()->addMenu(QStringLiteral("Baglanti"));
QAction *listenAction = connectionMenu->addAction(QStringLiteral("Seri Baglantiyi Ac / Kapat"));
listenAction->setObjectName(QStringLiteral("toggleSerialAction"));
listenAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+K")));
connect(listenAction, &QAction::triggered, this, &MainWindow::toggleSerial);
QAction *portsAction = connectionMenu->addAction(QStringLiteral("Portlari Yenile"));
portsAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+R")));
connect(portsAction, &QAction::triggered, this, [this] {
m_portsInitialized = false;
refreshSerialPorts();
});
QAction *rinexAction = connectionMenu->addAction(QStringLiteral("RINEX Guncelle"));
rinexAction->setObjectName(QStringLiteral("rinexUpdateAction"));
rinexAction->setShortcut(QKeySequence::Refresh);
connect(rinexAction, &QAction::triggered, m_rinex, &RinexNavigation::refresh);
auto *viewMenu = menuBar()->addMenu(QStringLiteral("Gorunum"));
QAction *fitAction = viewMenu->addAction(QStringLiteral("Rotayi Haritaya Sigdir"));
fitAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+0")));
connect(fitAction, &QAction::triggered, m_map, &MapWidget::fitToPoints);
QAction *zoomInAction = viewMenu->addAction(QStringLiteral("Yaklastir"));
zoomInAction->setShortcut(QKeySequence::ZoomIn);
connect(zoomInAction, &QAction::triggered, m_map, &MapWidget::zoomIn);
QAction *zoomOutAction = viewMenu->addAction(QStringLiteral("Uzaklastir"));
zoomOutAction->setShortcut(QKeySequence::ZoomOut);
connect(zoomOutAction, &QAction::triggered, m_map, &MapWidget::zoomOut);
auto *helpMenu = menuBar()->addMenu(QStringLiteral("Yardim"));
QAction *aboutAction = helpMenu->addAction(QStringLiteral("Location Plotter Hakkinda"));
connect(aboutAction, &QAction::triggered, this, [this] {
QMessageBox::about(this, QStringLiteral("Location Plotter"),
QStringLiteral("GNSS telemetri, konum analizi ve RINEX uydu gorunumu\n"
"macOS surumu 0.2.0"));
});
}
void MainWindow::loadSettings()
{
QSettings settings;
restoreGeometry(settings.value(QStringLiteral("window/geometry")).toByteArray());
if (auto *splitter = findChild<QSplitter *>(QStringLiteral("mainSplitter")))
splitter->restoreState(settings.value(QStringLiteral("window/mainSplitter")).toByteArray());
if (auto *splitter = findChild<QSplitter *>(QStringLiteral("sideSplitter")))
splitter->restoreState(settings.value(QStringLiteral("window/sideSplitter")).toByteArray());
const QString port = settings.value(QStringLiteral("serial/port")).toString();
if (!port.isEmpty()) {
if (m_portCombo->findText(port) < 0) m_portCombo->addItem(port);
m_portCombo->setCurrentText(port);
}
m_baudCombo->setCurrentText(settings.value(QStringLiteral("serial/baud"), 115200).toString());
m_maxPointsSpin->setValue(settings.value(QStringLiteral("telemetry/maxPoints"), 10).toInt());
m_autoFollowCheck->setChecked(settings.value(QStringLiteral("map/autoFollow"), true).toBool());
m_showTelemetryCheck->setChecked(settings.value(QStringLiteral("map/showTelemetry"), true).toBool());
m_showRecentPointsCheck->setChecked(settings.value(QStringLiteral("map/showRecent"), true).toBool());
m_showAverageCheck->setChecked(settings.value(QStringLiteral("map/showAverage"), true).toBool());
if (auto *elevation = findChild<QDoubleSpinBox *>(QStringLiteral("elevationMaskSpin")))
elevation->setValue(settings.value(QStringLiteral("globe/elevationMask"), 10.0).toDouble());
if (auto *tabs = findChild<QTabWidget *>(QStringLiteral("mainViewTabs")))
tabs->setCurrentIndex(settings.value(QStringLiteral("window/mainTab"), 0).toInt());
}
void MainWindow::saveSettings() const
{
QSettings settings;
settings.setValue(QStringLiteral("window/geometry"), saveGeometry());
if (auto *splitter = findChild<QSplitter *>(QStringLiteral("mainSplitter")))
settings.setValue(QStringLiteral("window/mainSplitter"), splitter->saveState());
if (auto *splitter = findChild<QSplitter *>(QStringLiteral("sideSplitter")))
settings.setValue(QStringLiteral("window/sideSplitter"), splitter->saveState());
settings.setValue(QStringLiteral("serial/port"), m_portCombo->currentText().trimmed());
settings.setValue(QStringLiteral("serial/baud"), selectedBaudRate());
settings.setValue(QStringLiteral("telemetry/maxPoints"), m_maxPointsSpin->value());
settings.setValue(QStringLiteral("map/autoFollow"), m_autoFollowCheck->isChecked());
settings.setValue(QStringLiteral("map/showTelemetry"), m_showTelemetryCheck->isChecked());
settings.setValue(QStringLiteral("map/showRecent"), m_showRecentPointsCheck->isChecked());
settings.setValue(QStringLiteral("map/showAverage"), m_showAverageCheck->isChecked());
if (auto *elevation = findChild<QDoubleSpinBox *>(QStringLiteral("elevationMaskSpin")))
settings.setValue(QStringLiteral("globe/elevationMask"), elevation->value());
if (auto *tabs = findChild<QTabWidget *>(QStringLiteral("mainViewTabs")))
settings.setValue(QStringLiteral("window/mainTab"), tabs->currentIndex());
settings.sync();
}
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+(?:prns?|svns?)\s*=\s*\[[^\]]*\])"),
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) {
// Never combine a coordinate fragment left by the previous navigation
// period with fields from the new one. A complete pending fix is still
// useful; an incomplete one must be discarded at the period boundary.
if (m_pendingTelemetry.hasLocation()) {
commitPendingTelemetry();
} else {
m_pendingTelemetry = {};
}
m_observedPrns.clear();
}
const QSet<QString> linePrns = LocationParser::parseGpsPrns(line);
@@ -591,6 +992,7 @@ void MainWindow::handleSerialStatus(const QString &status)
void MainWindow::handleSerialError(const QString &message)
{
stopUartLogging();
flushPendingLogLines();
m_statusLabel->setText(message);
m_log->appendPlainText(QStringLiteral("[ERROR] ") + message);
@@ -747,7 +1149,10 @@ void MainWindow::buildUi()
background: #23445d;
border-color: #62b8e8;
}
QPushButton:pressed, QToolButton:pressed { background: #dbeaf6; }
QPushButton:pressed, QToolButton:pressed {
color: #102333;
background: #dbeaf6;
}
QCheckBox { spacing: 7px; color: #d5e0e9; }
QCheckBox::indicator { width: 17px; height: 17px; }
QCheckBox::indicator:unchecked {
@@ -824,7 +1229,7 @@ void MainWindow::buildUi()
m_portCombo = new QComboBox(this);
m_portCombo->setObjectName(QStringLiteral("portSelector"));
m_portCombo->setMinimumWidth(220);
m_portCombo->setMinimumWidth(170);
m_portCombo->setEditable(true);
m_portCombo->setToolTip(QStringLiteral(
"USB UART portu. macOS'ta /dev/cu.usb... tercih edilir; /dev/tty.usb... da desteklenir."));
@@ -854,6 +1259,16 @@ void MainWindow::buildUi()
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_rinexUpdateButton = new QPushButton(QStringLiteral("RINEX Guncelle"), this);
m_rinexUpdateButton->setObjectName(QStringLiteral("rinexUpdateButton"));
m_rinexUpdateButton->setIcon(style()->standardIcon(QStyle::SP_BrowserReload));
m_rinexUpdateButton->setToolTip(QStringLiteral(
"BKG/IGS arsivinden guncel GPS RINEX navigasyon verisini yeniden indir"));
m_autoFollowCheck = new QCheckBox(QStringLiteral("Kamerayi takip et"), this);
m_autoFollowCheck->setChecked(true);
@@ -874,6 +1289,8 @@ void MainWindow::buildUi()
toolbarLayout->addWidget(m_maxPointsSpin);
toolbarLayout->addWidget(m_connectButton);
toolbarLayout->addWidget(m_openLogButton);
toolbarLayout->addWidget(m_replayButton);
toolbarLayout->addWidget(m_rinexUpdateButton);
toolbarLayout->addWidget(m_clearButton);
toolbarLayout->addWidget(m_autoFollowCheck);
toolbarLayout->addWidget(zoomOutButton);
@@ -907,7 +1324,7 @@ void MainWindow::buildUi()
viewTabs->addTab(globeTab, QStringLiteral("3B Dunya · Uydular"));
auto *sidePanel = new QWidget(this);
sidePanel->setMinimumWidth(390);
sidePanel->setMinimumWidth(330);
auto *sideOuterLayout = new QVBoxLayout(sidePanel);
sideOuterLayout->setContentsMargins(8, 0, 0, 0);
sideOuterLayout->setSpacing(0);
@@ -1109,7 +1526,12 @@ void MainWindow::buildUi()
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(refreshPortsButton, &QToolButton::clicked, this, &MainWindow::refreshSerialPorts);
connect(m_replayButton, &QPushButton::clicked, this, &MainWindow::openUartReplay);
connect(m_rinexUpdateButton, &QPushButton::clicked, m_rinex, &RinexNavigation::refresh);
connect(refreshPortsButton, &QToolButton::clicked, this, [this] {
m_portsInitialized = false;
refreshSerialPorts();
});
connect(m_maxPointsSpin, qOverload<int>(&QSpinBox::valueChanged), this,
&MainWindow::trimToMaxPoints);
connect(m_autoFollowCheck, &QCheckBox::toggled, m_map, &MapWidget::setAutoFollow);
@@ -1143,10 +1565,17 @@ void MainWindow::buildUi()
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_portCombo->setEnabled(!open);
m_baudCombo->setEnabled(!open);
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)
+24
View File
@@ -18,12 +18,15 @@ 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
@@ -31,6 +34,11 @@ class MainWindow : public QMainWindow {
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();
@@ -43,10 +51,17 @@ private slots:
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 buildMenus();
void loadSettings();
void saveSettings() const;
void updateConnectionControls();
void addTelemetryPoint(const ParsedTelemetry &telemetry);
void refreshTable();
@@ -60,9 +75,11 @@ private:
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;
@@ -74,6 +91,8 @@ private:
QPushButton *m_connectButton = nullptr;
QPushButton *m_clearButton = nullptr;
QPushButton *m_openLogButton = nullptr;
QPushButton *m_replayButton = nullptr;
QPushButton *m_rinexUpdateButton = nullptr;
QPushButton *m_refreshRinexButton = nullptr;
QCheckBox *m_autoFollowCheck = nullptr;
QCheckBox *m_showTelemetryCheck = nullptr;
@@ -98,6 +117,9 @@ private:
QPlainTextEdit *m_log = nullptr;
QTimer *m_locationTimeoutTimer = nullptr;
QTimer *m_uiRefreshTimer = nullptr;
QTimer *m_portRefreshTimer = nullptr;
QFile *m_uartLogFile = nullptr;
QString m_uartLogPath;
QVector<TelemetryPoint> m_points;
QStringList m_pendingLogLines;
@@ -108,4 +130,6 @@ private:
DopValues m_lastDop;
ParsedTelemetry m_pendingTelemetry;
bool m_telemetryRefreshPending = false;
bool m_portsInitialized = false;
QStringList m_lastDetectedPorts;
};
+261 -54
View File
@@ -1,9 +1,11 @@
#include "MapWidget.h"
#include <QDateTime>
#include <QDir>
#include <QLabel>
#include <QLineF>
#include <QMouseEvent>
#include <QNativeGestureEvent>
#include <QNetworkAccessManager>
#include <QNetworkDiskCache>
#include <QNetworkRequest>
@@ -13,6 +15,7 @@
#include <QResizeEvent>
#include <QStandardPaths>
#include <QStringList>
#include <QTimer>
#include <QUrl>
#include <QWheelEvent>
@@ -25,6 +28,8 @@ constexpr int TileSize = 256;
constexpr int MinZoom = 2;
constexpr int MaxZoom = 19;
constexpr int MaxMemoryTileCount = 512;
constexpr int InitialTileRetryDelayMs = 250;
constexpr int MaximumTileRetryDelayMs = 8000;
constexpr double Pi = 3.14159265358979323846;
constexpr double WebMercatorRadiusMeters = 6378137.0;
constexpr double MaxMercatorLatitude = 85.05112878;
@@ -34,6 +39,14 @@ QString tileKey(int zoom, int tileX, int tileY)
return QStringLiteral("%1/%2/%3").arg(zoom).arg(tileX).arg(tileY);
}
QUrl tileUrl(QString urlTemplate, int zoom, int tileX, int tileY)
{
urlTemplate.replace(QStringLiteral("{z}"), QString::number(zoom));
urlTemplate.replace(QStringLiteral("{x}"), QString::number(tileX));
urlTemplate.replace(QStringLiteral("{y}"), QString::number(tileY));
return QUrl(urlTemplate);
}
double clampLatitude(double latitude)
{
return std::clamp(latitude, -MaxMercatorLatitude, MaxMercatorLatitude);
@@ -52,27 +65,16 @@ double metersPerPixel(double latitude, int zoom)
return latitudeScale * 2.0 * Pi * WebMercatorRadiusMeters / scale;
}
int preferredCurrentPointIndex(const QVector<TelemetryPoint> &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))
, m_tileUrlTemplate(qEnvironmentVariable(
"LOCATION_PLOTTER_TILE_URL_TEMPLATE",
QStringLiteral("https://tile.openstreetmap.org/{z}/{x}/{y}.png")))
{
setMinimumSize(640, 420);
setMinimumSize(480, 320);
setMouseTracking(true);
const QString cacheRoot = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
@@ -104,9 +106,9 @@ void MapWidget::setPoints(const QVector<TelemetryPoint> &points)
m_points = points;
if (!m_points.isEmpty() && m_autoFollow) {
const TelemetryPoint &current = m_points[preferredCurrentPointIndex(m_points)];
m_centerLat = current.latitude;
m_centerLon = current.longitude;
const TelemetryPoint &focus = m_points[preferredFocusIndex()];
m_centerLat = focus.latitude;
m_centerLon = focus.longitude;
if (wasEmpty) {
m_zoom = MaxZoom;
}
@@ -129,23 +131,55 @@ void MapWidget::fitToPoints()
}
double minimumLatitude = m_points.first().latitude;
double maximumLatitude = minimumLatitude;
double minimumLongitude = m_points.first().longitude;
double maximumLongitude = minimumLongitude;
QVector<double> longitudes;
longitudes.reserve(m_points.size());
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);
double normalizedLongitude = std::fmod(point.longitude + 360.0, 360.0);
if (normalizedLongitude < 0.0) {
normalizedLongitude += 360.0;
}
longitudes.push_back(normalizedLongitude);
}
std::sort(longitudes.begin(), longitudes.end());
// Fit the shortest circular longitude interval. A plain min/max makes
// points around +180/-180 look almost a full world apart.
double arcStart = longitudes.first();
double longitudeSpan = 0.0;
if (longitudes.size() > 1) {
double largestGap = -1.0;
int largestGapIndex = 0;
for (int i = 0; i < longitudes.size(); ++i) {
const double current = longitudes[i];
const double next = i + 1 < longitudes.size()
? longitudes[i + 1] : longitudes.first() + 360.0;
const double gap = next - current;
if (gap > largestGap) {
largestGap = gap;
largestGapIndex = i;
}
}
arcStart = largestGapIndex + 1 < longitudes.size()
? longitudes[largestGapIndex + 1] : longitudes.first();
longitudeSpan = 360.0 - largestGap;
}
m_centerLat = 0.5 * (minimumLatitude + maximumLatitude);
m_centerLon = 0.5 * (minimumLongitude + maximumLongitude);
m_centerLon = std::fmod(arcStart + longitudeSpan * 0.5 + 180.0, 360.0);
if (m_centerLon < 0.0) {
m_centerLon += 360.0;
}
m_centerLon -= 180.0;
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) {
const double worldWidth = static_cast<double>(TileSize) * static_cast<double>(1 << zoom);
const double longitudeWidth = longitudeSpan / 360.0 * worldWidth;
const double top = coordinateToWorld(maximumLatitude, m_centerLon, zoom).y();
const double bottom = coordinateToWorld(minimumLatitude, m_centerLon, zoom).y();
if (longitudeWidth <= availableWidth
&& std::fabs(bottom - top) <= availableHeight) {
m_zoom = zoom;
break;
}
@@ -218,9 +252,9 @@ void MapWidget::setAutoFollow(bool enabled)
m_autoFollow = enabled;
if (m_autoFollow && !m_points.isEmpty()) {
const TelemetryPoint &current = m_points[preferredCurrentPointIndex(m_points)];
m_centerLat = current.latitude;
m_centerLon = current.longitude;
const TelemetryPoint &focus = m_points[preferredFocusIndex()];
m_centerLat = focus.latitude;
m_centerLon = focus.longitude;
update();
}
emit autoFollowChanged(m_autoFollow);
@@ -279,16 +313,52 @@ void MapWidget::paintEvent(QPaintEvent *)
void MapWidget::wheelEvent(QWheelEvent *event)
{
const int delta = event->angleDelta().y();
if (delta == 0) {
const QPoint pixelDelta = event->pixelDelta();
const QPoint angleDelta = event->angleDelta();
const double delta = !pixelDelta.isNull()
? pixelDelta.y() / 36.0
: angleDelta.y() / 120.0;
if (qFuzzyIsNull(delta)) {
return;
}
disableAutoFollowFromUserAction();
setZoomKeepingPoint(m_zoom + (delta > 0 ? 1 : -1), event->position());
m_wheelZoomAccumulator += delta;
while (std::abs(m_wheelZoomAccumulator) >= 1.0) {
const int direction = m_wheelZoomAccumulator > 0.0 ? 1 : -1;
setZoomKeepingPoint(m_zoom + direction, event->position());
m_wheelZoomAccumulator -= direction;
}
if (event->phase() == Qt::ScrollEnd) {
m_wheelZoomAccumulator = 0.0;
}
event->accept();
}
bool MapWidget::event(QEvent *event)
{
if (event->type() == QEvent::NativeGesture) {
auto *gesture = static_cast<QNativeGestureEvent *>(event);
if (gesture->gestureType() == Qt::ZoomNativeGesture) {
disableAutoFollowFromUserAction();
// macOS reports pinch magnification as small deltas. Accumulate
// them so map-tile zoom remains stable instead of flickering.
m_wheelZoomAccumulator += gesture->value() * 5.0;
while (std::abs(m_wheelZoomAccumulator) >= 1.0) {
const int direction = m_wheelZoomAccumulator > 0.0 ? 1 : -1;
setZoomKeepingPoint(m_zoom + direction, gesture->position());
m_wheelZoomAccumulator -= direction;
}
gesture->accept();
return true;
}
if (gesture->gestureType() == Qt::EndNativeGesture) {
m_wheelZoomAccumulator = 0.0;
}
}
return QWidget::event(event);
}
void MapWidget::mousePressEvent(QMouseEvent *event)
{
if (event->button() == Qt::RightButton) {
@@ -405,6 +475,15 @@ void MapWidget::drawTiles(QPainter &painter)
painter.setRenderHint(QPainter::SmoothPixmapTransform, false);
struct MissingTile {
int x;
int y;
double distanceFromCenter;
};
QVector<MissingTile> missingTiles;
QSet<QString> visibleTileKeys;
const QPointF viewportCenter = topLeft + QPointF(width() / 2.0, height() / 2.0);
for (int tileY = firstTileY; tileY <= lastTileY; ++tileY) {
if (tileY < 0 || tileY >= tileCount) {
continue;
@@ -413,6 +492,7 @@ void MapWidget::drawTiles(QPainter &painter)
for (int tileX = firstTileX; tileX <= lastTileX; ++tileX) {
const int wrappedTileX = positiveModulo(tileX, tileCount);
const QString key = tileKey(m_zoom, wrappedTileX, tileY);
visibleTileKeys.insert(key);
const QRectF target(
tileX * TileSize - topLeft.x(),
tileY * TileSize - topLeft.y(),
@@ -421,15 +501,57 @@ void MapWidget::drawTiles(QPainter &painter)
const auto tileIt = m_tileCache.constFind(key);
if (tileIt != m_tileCache.constEnd()) {
painter.drawPixmap(target.toRect(), *tileIt);
painter.drawPixmap(target, *tileIt, QRectF(tileIt->rect()));
} else {
if (!drawCachedTileOrParent(painter, target, wrappedTileX, tileY, m_zoom)) {
painter.fillRect(target, QColor(230, 234, 230));
painter.setPen(QColor(198, 205, 200));
painter.drawRect(target);
requestTile(wrappedTileX, tileY, m_zoom);
}
const QPointF tileCenter((tileX + 0.5) * TileSize,
(tileY + 0.5) * TileSize);
const QPointF offset = tileCenter - viewportCenter;
missingTiles.push_back({wrappedTileX, tileY,
offset.x() * offset.x() + offset.y() * offset.y()});
}
}
}
// A fast pan or auto-follow jump can otherwise leave old requests ahead of
// the tiles that are visible now. Cancel those requests and enqueue the
// centre tiles first so the viewport fills in promptly.
cancelInvisibleTileRequests(visibleTileKeys);
std::sort(missingTiles.begin(), missingTiles.end(),
[](const MissingTile &left, const MissingTile &right) {
return left.distanceFromCenter < right.distanceFromCenter;
});
for (const MissingTile &tile : missingTiles) {
requestTile(tile.x, tile.y, m_zoom);
}
}
bool MapWidget::drawCachedTileOrParent(QPainter &painter, const QRectF &target,
int tileX, int tileY, int zoom) const
{
for (int parentZoom = zoom - 1; parentZoom >= MinZoom; --parentZoom) {
const int scale = 1 << (zoom - parentZoom);
const int parentX = tileX / scale;
const int parentY = tileY / scale;
const auto parentIt = m_tileCache.constFind(tileKey(parentZoom, parentX, parentY));
if (parentIt == m_tileCache.constEnd()) {
continue;
}
const QPixmap &parent = *parentIt;
const double sourceWidth = static_cast<double>(parent.width()) / scale;
const double sourceHeight = static_cast<double>(parent.height()) / scale;
const QRectF source((tileX % scale) * sourceWidth,
(tileY % scale) * sourceHeight,
sourceWidth, sourceHeight);
painter.drawPixmap(target, parent, source);
return true;
}
return false;
}
void MapWidget::drawTelemetry(QPainter &painter)
@@ -472,11 +594,10 @@ void MapWidget::drawTelemetry(QPainter &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 int focusIndex = preferredFocusIndex();
for (int i = 0; i < m_points.size(); ++i) {
if (!m_showRecentPoints && i != focusIndex) continue;
const bool isLast = i == focusIndex;
const QPointF screen = coordinateToScreen(m_points[i].latitude, m_points[i].longitude);
const double radius = isLast ? 8.0 : 5.0;
@@ -499,7 +620,7 @@ void MapWidget::drawTelemetry(QPainter &painter)
}
}
const TelemetryPoint &last = m_points[currentPointIndex];
const TelemetryPoint &last = m_points[focusIndex];
const QPointF lastScreen = coordinateToScreen(last.latitude, last.longitude);
QStringList lines;
const QString latitudeText = last.latitudeText.isEmpty()
@@ -607,9 +728,10 @@ void MapWidget::drawCepMarker(QPainter &painter)
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;
const int focusIndex = preferredFocusIndex();
const bool overlapsLast = focusIndex >= 0
&& QLineF(screen, coordinateToScreen(m_points[focusIndex].latitude,
m_points[focusIndex].longitude)).length() < 30.0;
if (!overlapsLast) {
painter.setFont(QFont(painter.font().family(), 9, QFont::Bold));
painter.setPen(QColor(20, 83, 78));
@@ -643,9 +765,10 @@ void MapWidget::drawAverageOverlay(QPainter &painter)
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;
const int focusIndex = preferredFocusIndex();
const bool overlapsLast = focusIndex >= 0
&& QLineF(screen, coordinateToScreen(m_points[focusIndex].latitude,
m_points[focusIndex].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));
@@ -670,20 +793,27 @@ void MapWidget::requestTile(int tileX, int tileY, int zoom)
return;
}
const qint64 now = QDateTime::currentMSecsSinceEpoch();
if (m_tileRetryAfterMs.value(key, 0) > now) {
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)");
QNetworkRequest request(tileUrl(m_tileUrlTemplate, zoom, tileX, tileY));
request.setRawHeader("User-Agent", "RocketLocationPlotter/0.3 (Qt desktop application)");
request.setTransferTimeout(15000);
QNetworkReply *reply = m_network->get(request);
reply->setProperty("tileKey", key);
m_tileReplies.insert(key, reply);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QString key = reply->property("tileKey").toString();
m_pendingTiles.remove(key);
if (m_tileReplies.value(key) == reply) {
m_tileReplies.remove(key);
}
bool loaded = false;
if (reply->error() == QNetworkReply::NoError) {
QPixmap pixmap;
if (pixmap.loadFromData(reply->readAll())) {
@@ -691,14 +821,77 @@ void MapWidget::requestTile(int tileX, int tileY, int zoom)
m_tileCache.erase(m_tileCache.begin());
}
m_tileCache.insert(key, pixmap);
update();
m_tileFailureCounts.remove(key);
m_tileRetryAfterMs.remove(key);
loaded = true;
}
}
const bool cancelled = reply->property("tileCancelled").toBool();
if (!loaded && !cancelled) {
const int failures = m_tileFailureCounts.value(key, 0) + 1;
m_tileFailureCounts.insert(key, failures);
const int shift = std::min(failures - 1, 5);
const int retryDelay = std::min(MaximumTileRetryDelayMs,
InitialTileRetryDelayMs * (1 << shift));
m_tileRetryAfterMs.insert(key,
QDateTime::currentMSecsSinceEpoch() + retryDelay);
QTimer::singleShot(retryDelay, this, [this, key]() {
repaintWhenTileRetryIsReady(key);
});
}
if (loaded) {
update();
}
reply->deleteLater();
});
}
void MapWidget::cancelInvisibleTileRequests(const QSet<QString> &visibleTileKeys)
{
QVector<QNetworkReply *> obsoleteReplies;
for (auto it = m_tileReplies.cbegin(); it != m_tileReplies.cend(); ++it) {
if (!visibleTileKeys.contains(it.key())) {
obsoleteReplies.push_back(it.value());
}
}
for (QNetworkReply *reply : obsoleteReplies) {
if (reply == nullptr || reply->isFinished()) {
continue;
}
reply->setProperty("tileCancelled", true);
reply->abort();
}
for (auto it = m_tileRetryAfterMs.begin(); it != m_tileRetryAfterMs.end();) {
if (visibleTileKeys.contains(it.key())) {
++it;
continue;
}
m_tileFailureCounts.remove(it.key());
it = m_tileRetryAfterMs.erase(it);
}
}
void MapWidget::repaintWhenTileRetryIsReady(const QString &key)
{
if (!m_tileRetryAfterMs.contains(key)) {
return;
}
const qint64 remaining = m_tileRetryAfterMs.value(key, 0)
- QDateTime::currentMSecsSinceEpoch();
if (remaining > 0) {
QTimer::singleShot(static_cast<int>(remaining), this, [this, key]() {
repaintWhenTileRetryIsReady(key);
});
return;
}
update();
}
void MapWidget::setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor)
{
newZoom = std::clamp(newZoom, MinZoom, MaxZoom);
@@ -725,3 +918,17 @@ void MapWidget::disableAutoFollowFromUserAction()
m_autoFollow = false;
emit autoFollowChanged(false);
}
int MapWidget::preferredFocusIndex() const
{
constexpr qint64 CorrectedPreferenceWindowMs = 1000;
const int newestIndex = m_points.size() - 1;
for (int index = m_points.size() - 1; index >= 0; --index) {
if (m_points[index].source == LocationSource::ImuCorrected) {
const qint64 ageMs = m_points[index].receivedAt.msecsTo(
m_points[newestIndex].receivedAt);
return ageMs <= CorrectedPreferenceWindowMs ? index : newestIndex;
}
}
return newestIndex;
}
+12
View File
@@ -11,6 +11,7 @@
#include <optional>
class QNetworkAccessManager;
class QNetworkReply;
class QLabel;
class MapWidget : public QWidget {
@@ -42,6 +43,7 @@ signals:
void referenceLocationSelected(double latitude, double longitude);
protected:
bool event(QEvent *event) override;
void paintEvent(QPaintEvent *event) override;
void wheelEvent(QWheelEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
@@ -63,15 +65,24 @@ private:
void drawCepOverlay(QPainter &painter);
void drawCepMarker(QPainter &painter);
void drawEmptyState(QPainter &painter);
bool drawCachedTileOrParent(QPainter &painter, const QRectF &target,
int tileX, int tileY, int zoom) const;
void requestTile(int tileX, int tileY, int zoom);
void cancelInvisibleTileRequests(const QSet<QString> &visibleTileKeys);
void repaintWhenTileRetryIsReady(const QString &key);
void setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor);
void disableAutoFollowFromUserAction();
int preferredFocusIndex() const;
QVector<TelemetryPoint> m_points;
QNetworkAccessManager *m_network = nullptr;
QLabel *m_attributionLabel = nullptr;
QHash<QString, QPixmap> m_tileCache;
QSet<QString> m_pendingTiles;
QHash<QString, QNetworkReply *> m_tileReplies;
QHash<QString, int> m_tileFailureCounts;
QHash<QString, qint64> m_tileRetryAfterMs;
QString m_tileUrlTemplate;
double m_centerLat = 39.0;
double m_centerLon = 35.0;
@@ -90,4 +101,5 @@ private:
std::optional<double> m_cepRadiusMeters;
bool m_dragging = false;
QPoint m_lastMousePos;
double m_wheelZoomAccumulator = 0.0;
};
+9 -4
View File
@@ -6,6 +6,7 @@
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QStandardPaths>
#include <QTimeZone>
#include <QUrl>
#include <zlib.h>
@@ -148,7 +149,9 @@ void RinexNavigation::handleDownload(const QByteArray &compressed, const QDate &
return;
}
setLoading(false);
if (!loadCache()) {
emit statusChanged(QStringLiteral("RINEX okunamadi: %1").arg(error), true);
}
return;
}
@@ -158,10 +161,11 @@ void RinexNavigation::handleDownload(const QByteArray &compressed, const QDate &
m_ephemerides = std::move(ephemerides);
setLoading(false);
publishCurrentMotion();
emit statusChanged(QStringLiteral("%1 GPS uydusu · RINEX %2 · %3 UTC")
emit statusChanged(QStringLiteral("%1 GPS uydusu · RINEX %2 · %3")
.arg(positions.size())
.arg(date.toString(QStringLiteral("dd.MM.yyyy")))
.arg(now.time().toString(QStringLiteral("HH:mm:ss"))), false);
.arg(now.toLocalTime().toString(
QStringLiteral("HH:mm:ss t"))), false);
}
bool RinexNavigation::loadCache()
@@ -176,9 +180,10 @@ bool RinexNavigation::loadCache()
if (positions.isEmpty()) return false;
m_ephemerides = std::move(ephemerides);
publishCurrentMotion();
emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX · %2 UTC")
emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX · %2")
.arg(positions.size())
.arg(now.time().toString(QStringLiteral("HH:mm:ss"))), true);
.arg(now.toLocalTime().toString(
QStringLiteral("HH:mm:ss t"))), false);
return true;
}
+207
View File
@@ -0,0 +1,207 @@
#include "UartReplay.h"
#include "LocationParser.h"
#include <QFile>
#include <QRegularExpression>
#include <QTimer>
#include <algorithm>
#include <limits>
UartReplay::UartReplay(QObject *parent)
: QObject(parent)
, m_timer(new QTimer(this))
{
m_timer->setSingleShot(true);
connect(m_timer, &QTimer::timeout, this, &UartReplay::emitNext);
}
bool UartReplay::isRunning() const
{
return m_running;
}
int UartReplay::locationCount() const
{
return m_locationCount;
}
bool UartReplay::start(const QString &filePath, int gpsIntervalMs, int sensorIntervalMs)
{
stop();
if (gpsIntervalMs < 1 || sensorIntervalMs < 1) {
emit errorOccurred(QStringLiteral("Replay araliklari en az 1 ms olmalidir"));
return false;
}
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
emit errorOccurred(QStringLiteral("UART replay logu acilamadi: %1")
.arg(file.errorString()));
return false;
}
qint64 nextGpsMs = 0;
qint64 nextSensorMs = 0;
qint64 lastDueMs = 0;
bool pendingLatitude = false;
bool pendingLongitude = false;
LocationSource pendingSource = LocationSource::Generic;
const QList<QByteArray> rawLines = file.readAll().split('\n');
m_lines.reserve(rawLines.size());
for (const QByteArray &rawLine : rawLines) {
const QString line = removeRecorderTimestamp(QString::fromUtf8(rawLine));
if (line.isEmpty() || line.startsWith(QLatin1Char('#'))
|| line.startsWith(QStringLiteral("[SESSION START]"))
|| line.startsWith(QStringLiteral("[SESSION END]"))) {
continue;
}
Stream stream = classifyLine(line);
const std::optional<ParsedTelemetry> telemetry = LocationParser::parseLine(line);
if (telemetry.has_value() && telemetry->hasLocation()) {
pendingLatitude = false;
pendingLongitude = false;
pendingSource = LocationSource::Generic;
} else if (telemetry.has_value()) {
if (telemetry->latitude.has_value()) {
if (pendingLatitude) {
pendingLongitude = false;
}
pendingLatitude = true;
pendingSource = telemetry->source;
}
if (telemetry->longitude.has_value()) {
pendingLongitude = true;
if (telemetry->source != LocationSource::Generic) {
pendingSource = telemetry->source;
}
}
if (pendingLatitude && pendingLongitude) {
stream = pendingSource == LocationSource::ImuCorrected
|| pendingSource == LocationSource::Trajectory
? Stream::Sensor : Stream::Gps;
pendingLatitude = false;
pendingLongitude = false;
pendingSource = LocationSource::Generic;
}
}
qint64 dueMs = lastDueMs;
if (stream == Stream::Gps) {
dueMs = std::max(lastDueMs, nextGpsMs);
nextGpsMs = dueMs + gpsIntervalMs;
++m_locationCount;
} else if (stream == Stream::Sensor) {
dueMs = std::max(lastDueMs, nextSensorMs);
nextSensorMs = dueMs + sensorIntervalMs;
++m_locationCount;
}
ReplayLine replayLine;
replayLine.text = line;
replayLine.delayBeforeMs = static_cast<int>(std::min<qint64>(
dueMs - lastDueMs, std::numeric_limits<int>::max()));
m_lines.push_back(std::move(replayLine));
lastDueMs = dueMs;
}
if (m_locationCount == 0) {
m_lines.clear();
emit errorOccurred(QStringLiteral(
"UART replay logunda GPS veya fused konum kaydi bulunamadi"));
return false;
}
m_running = true;
emit replayStarted(m_lines.size(), m_locationCount);
m_timer->start(0);
return true;
}
void UartReplay::stop()
{
if (!m_running && m_lines.isEmpty()) {
return;
}
m_timer->stop();
m_running = false;
m_lines.clear();
m_index = 0;
m_locationCount = 0;
}
void UartReplay::emitNext()
{
if (!m_running) {
return;
}
constexpr int MaxImmediateLines = 250;
int emitted = 0;
while (m_index < m_lines.size() && emitted < MaxImmediateLines) {
const ReplayLine &line = m_lines.at(m_index);
if (line.delayBeforeMs > 0) {
if (emitted > 0) {
emit progressChanged(m_index, m_lines.size());
}
const int delay = line.delayBeforeMs;
m_lines[m_index].delayBeforeMs = 0;
m_timer->start(delay);
return;
}
emit lineReady(line.text);
++m_index;
++emitted;
}
emit progressChanged(m_index, m_lines.size());
if (m_index >= m_lines.size()) {
finish();
} else {
m_timer->start(0);
}
}
QString UartReplay::removeRecorderTimestamp(const QString &line)
{
QString result = line.trimmed();
static const QRegularExpression timestampPrefix(
QStringLiteral(R"(^\[\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[\.,]\d+)?\]\s*)"));
result.remove(timestampPrefix);
return result.trimmed();
}
UartReplay::Stream UartReplay::classifyLine(const QString &line)
{
const std::optional<ParsedTelemetry> telemetry = LocationParser::parseLine(line);
if (!telemetry.has_value() || !telemetry->hasLocation()) {
return Stream::Diagnostic;
}
switch (telemetry->source) {
case LocationSource::Gps:
return Stream::Gps;
case LocationSource::ImuCorrected:
case LocationSource::Trajectory:
return Stream::Sensor;
case LocationSource::Generic:
default:
// Older firmware used a [FUSION] record without an explicit source.
return line.startsWith(QStringLiteral("[FUSION]"), Qt::CaseInsensitive)
? Stream::Sensor : Stream::Gps;
}
}
void UartReplay::finish()
{
m_timer->stop();
m_running = false;
m_lines.clear();
m_index = 0;
m_locationCount = 0;
emit replayFinished();
}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <QObject>
#include <QString>
#include <QVector>
class QTimer;
class UartReplay final : public QObject {
Q_OBJECT
public:
explicit UartReplay(QObject *parent = nullptr);
[[nodiscard]] bool isRunning() const;
[[nodiscard]] int locationCount() const;
public slots:
bool start(const QString &filePath, int gpsIntervalMs = 200,
int sensorIntervalMs = 50);
void stop();
signals:
void lineReady(const QString &line);
void replayStarted(int lineCount, int locationCount);
void progressChanged(int linesProcessed, int lineCount);
void replayFinished();
void errorOccurred(const QString &message);
private slots:
void emitNext();
private:
enum class Stream {
Diagnostic,
Gps,
Sensor
};
struct ReplayLine {
QString text;
int delayBeforeMs = 0;
};
static QString removeRecorderTimestamp(const QString &line);
static Stream classifyLine(const QString &line);
void finish();
QTimer *m_timer = nullptr;
QVector<ReplayLine> m_lines;
int m_index = 0;
int m_locationCount = 0;
bool m_running = false;
};
+82 -2
View File
@@ -3,20 +3,26 @@
#include "../src/MapWidget.h"
#include <QApplication>
#include <QAction>
#include <QCheckBox>
#include <QComboBox>
#include <QDoubleSpinBox>
#include <QDir>
#include <QFile>
#include <QLabel>
#include <QLineEdit>
#include <QMetaObject>
#include <QSpinBox>
#include <QPushButton>
#include <QRegularExpression>
#include <QTableView>
#include <QTabWidget>
#include <QSplitter>
#include <QTemporaryFile>
#include <QTemporaryDir>
#include <cassert>
#include <cmath>
namespace {
@@ -43,6 +49,32 @@ int main(int argc, char **argv)
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<QTableView *>();
auto *referenceLatInput = window.findChild<QLineEdit *>(QStringLiteral("referenceLatInput"));
auto *referenceLonInput = window.findChild<QLineEdit *>(QStringLiteral("referenceLonInput"));
@@ -58,6 +90,10 @@ int main(int argc, char **argv)
auto *map = window.findChild<MapWidget *>();
auto *mainViewTabs = window.findChild<QTabWidget *>(QStringLiteral("mainViewTabs"));
auto *sideSplitter = window.findChild<QSplitter *>(QStringLiteral("sideSplitter"));
auto *rinexUpdateButton = window.findChild<QPushButton *>(QStringLiteral("rinexUpdateButton"));
auto *openLogAction = window.findChild<QAction *>(QStringLiteral("openLogAction"));
auto *toggleSerialAction = window.findChild<QAction *>(QStringLiteral("toggleSerialAction"));
auto *rinexUpdateAction = window.findChild<QAction *>(QStringLiteral("rinexUpdateAction"));
assert(table != nullptr);
assert(referenceLatInput != nullptr);
assert(referenceLonInput != nullptr);
@@ -88,14 +124,40 @@ int main(int argc, char **argv)
assert(mainViewTabs->count() == 2);
assert(sideSplitter != nullptr);
assert(sideSplitter->orientation() == Qt::Vertical);
assert(rinexUpdateButton != nullptr);
assert(!rinexUpdateButton->toolTip().isEmpty());
assert(openLogAction != nullptr && !openLogAction->shortcut().isEmpty());
assert(toggleSerialAction != nullptr && !toggleSerialAction->shortcut().isEmpty());
assert(rinexUpdateAction != nullptr && !rinexUpdateAction->shortcut().isEmpty());
sendLine(window, QStringLiteral("[TRK] tracked satellites=2 prns=[3,8]"));
QVector<TelemetryPoint> followPoints;
TelemetryPoint gpsPoint;
gpsPoint.source = LocationSource::Gps;
gpsPoint.latitude = 40.0;
gpsPoint.longitude = 29.0;
followPoints.push_back(gpsPoint);
TelemetryPoint correctedPoint = gpsPoint;
correctedPoint.source = LocationSource::ImuCorrected;
correctedPoint.latitude = 40.25;
correctedPoint.longitude = 29.5;
followPoints.push_back(correctedPoint);
TelemetryPoint trajectoryPoint = gpsPoint;
trajectoryPoint.source = LocationSource::Trajectory;
trajectoryPoint.latitude = 41.0;
trajectoryPoint.longitude = 30.0;
followPoints.push_back(trajectoryPoint);
map->setPoints(followPoints);
assert(std::abs(map->centerCoordinate().x() - 40.25) < 0.000001);
assert(std::abs(map->centerCoordinate().y() - 29.5) < 0.000001);
map->clearPoints();
sendLine(window, QStringLiteral("[TRK] tracked satellites=2 prn_indices=[2,7]"));
assert(map != nullptr);
auto *globe = window.findChild<GlobeWidget *>();
assert(globe != nullptr);
assert(globe->observedPrns()
== QSet<QString>({QStringLiteral("G03"), QStringLiteral("G08")}));
sendLine(window, QStringLiteral("[TRK] tracked satellites=1 prns=[14]"));
sendLine(window, QStringLiteral("[TRK] tracked satellites=1 prn_indices=[13]"));
assert(globe->observedPrns() == QSet<QString>({QStringLiteral("G14")}));
assert(QMetaObject::invokeMethod(map, "referenceLocationSelected", Qt::DirectConnection,
@@ -155,6 +217,24 @@ int main(int argc, char **argv)
assert(table->model()->data(table->model()->index(0, 1)).toString()
== QStringLiteral("Trajectory (truth)"));
QTemporaryFile splitReplayLog;
assert(splitReplayLog.open());
splitReplayLog.write("latitude: 40.25\nlongitude: 29.50\n");
splitReplayLog.flush();
assert(window.loadTelemetryLog(splitReplayLog.fileName()));
assert(table->model()->rowCount() == 1);
// A partial fix from an earlier solve must not be completed using a field
// from the following solve.
assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection));
sendLine(window, QStringLiteral("NAV solve start"));
sendLine(window, QStringLiteral("latitude: 40.0"));
sendLine(window, QStringLiteral("NAV solve start"));
sendLine(window, QStringLiteral("longitude: 29.0"));
sendLine(window, QStringLiteral("NAV solve done"));
flushUpdates(window);
assert(table->model()->rowCount() == 0);
assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection));
sendLine(window, QStringLiteral(
"lat=40.78925034214206846173 long=29.46277530841727809729"));
+73
View File
@@ -0,0 +1,73 @@
#include "../src/MapWidget.h"
#include <QApplication>
#include <QElapsedTimer>
#include <QHostAddress>
#include <QSet>
#include <QTcpServer>
#include <QTcpSocket>
#include <QThread>
#include <cassert>
int main(int argc, char **argv)
{
QApplication app(argc, argv);
QTcpServer tileServer;
assert(tileServer.listen(QHostAddress::LocalHost, 0));
int requestCount = 0;
QSet<QByteArray> requestedPaths;
QObject::connect(&tileServer, &QTcpServer::newConnection, [&]() {
while (tileServer.hasPendingConnections()) {
QTcpSocket *socket = tileServer.nextPendingConnection();
const auto respond = [&, socket]() {
if (!socket->canReadLine()) {
return;
}
const QByteArray requestLine = socket->readLine().trimmed();
const QList<QByteArray> fields = requestLine.split(' ');
if (fields.size() >= 2) {
++requestCount;
requestedPaths.insert(fields[1]);
}
socket->readAll();
socket->write("HTTP/1.1 503 Service Unavailable\r\n"
"Content-Length: 0\r\n"
"Connection: close\r\n\r\n");
socket->disconnectFromHost();
};
QObject::connect(socket, &QTcpSocket::readyRead, socket, respond);
QObject::connect(socket, &QTcpSocket::disconnected,
socket, &QObject::deleteLater);
respond();
}
});
const QByteArray tileUrl = QByteArray("http://127.0.0.1:")
+ QByteArray::number(tileServer.serverPort()) + "/{z}/{x}/{y}.png";
qputenv("LOCATION_PLOTTER_TILE_URL_TEMPLATE", tileUrl);
{
MapWidget map;
map.resize(640, 480);
map.show();
QElapsedTimer timer;
timer.start();
while (timer.elapsed() < 1200) {
app.processEvents(QEventLoop::AllEvents, 50);
QThread::msleep(5);
}
// Every URL fails. Seeing any URL more than once proves that a failed
// tile schedules a new paint/request instead of remaining a blank
// placeholder forever.
assert(!requestedPaths.isEmpty());
assert(requestCount > requestedPaths.size());
}
qunsetenv("LOCATION_PLOTTER_TILE_URL_TEMPLATE");
return 0;
}
+38
View File
@@ -186,6 +186,25 @@ int main(int argc, char **argv)
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G32")}));
}
{
const QSet<QString> prns = LocationParser::parseGpsPrns(
QStringLiteral("[TRK] tracked satellites=3 prn_indices=[0,7,31]"));
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G08"),
QStringLiteral("G32")}));
}
{
const QSet<QString> prns = LocationParser::parseGpsPrns(
QStringLiteral("[TRK] tracked satellites=2 prns=[0,7]"));
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G08")}));
}
{
const QSet<QString> prns = LocationParser::parseGpsPrns(
QStringLiteral("[TRK] tracked satellites=2 prn_indices=[0,G08]"));
assert(prns == QSet<QString>({QStringLiteral("G01"), QStringLiteral("G08")}));
}
{
const auto parsed = LocationParser::parseLine(QStringLiteral(
"[OK] [NAV] DOP GDOP=7.0907891622 PDOP=5.6333862175 "
@@ -231,6 +250,14 @@ int main(int argc, char **argv)
assert(parsed->satellites == 5);
}
{
const auto parsed = LocationParser::parseLine(QStringLiteral(
"[FUSION] state=moving lat=40.7357 lon=31.5986 height=811"));
assert(parsed.has_value());
assert(parsed->hasLocation());
assert(parsed->source == LocationSource::ImuCorrected);
}
{
const auto parsed = LocationParser::parseLine(QStringLiteral(
"[LOCATION] source=trajectory lat=40.7358 lon=31.5987 height=810 tracked=4 used=4"));
@@ -257,6 +284,17 @@ int main(int argc, char **argv)
assert(closeTo(satellites[0].x, semiMajorAxis * std::cos(node), 0.1));
assert(closeTo(satellites[0].y, semiMajorAxis * std::sin(node), 0.1));
assert(closeTo(satellites[0].z, 0.0, 0.1));
// A local wall-clock representation must not move the satellites.
// 11:48 at UTC+03 and 08:48 UTC represent the same physical instant.
const auto localTimeSatellites = RinexNavigation::parseGpsNavigation(
syntheticGpsNavigation(gpsLabel, gpsWeek, toe),
matchingUtc.toOffsetFromUtc(3 * 60 * 60), &error);
assert(error.isEmpty());
assert(localTimeSatellites.size() == satellites.size());
assert(closeTo(localTimeSatellites[0].x, satellites[0].x, 0.001));
assert(closeTo(localTimeSatellites[0].y, satellites[0].y, 0.001));
assert(closeTo(localTimeSatellites[0].z, satellites[0].z, 0.001));
}
const QString rinexPath = qEnvironmentVariable("LOCATION_PLOTTER_TEST_RINEX");
+70
View File
@@ -0,0 +1,70 @@
#include "../src/UartReplay.h"
#include <QCoreApplication>
#include <QElapsedTimer>
#include <QEventLoop>
#include <QTemporaryFile>
#include <QTimer>
#include <cassert>
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
QTemporaryFile log;
assert(log.open());
log.write(
"[2026-08-12 10:00:00.000] [SESSION START] port=/dev/test baud=460800\n"
"[2026-08-12 10:00:00.001] [LOCATION] source=gps lat=40.0 lon=29.0\n"
"diagnostic line without timestamp\n"
"[LOCATION] source=imu_corrected lat=40.1 lon=29.1\n"
"[FUSION] lat=40.2 lon=29.2 height=10\n"
"[LOCATION] source=gps lat=40.3 lon=29.3\n"
"[2026-08-12 10:00:00.999] [SESSION END]\n");
log.flush();
UartReplay replay;
QStringList received;
QVector<qint64> receivedAt;
QElapsedTimer elapsed;
QEventLoop loop;
QObject::connect(&replay, &UartReplay::lineReady, &loop, [&](const QString &line) {
received.push_back(line);
receivedAt.push_back(elapsed.elapsed());
});
QObject::connect(&replay, &UartReplay::replayFinished, &loop, &QEventLoop::quit);
QTimer::singleShot(2000, &loop, &QEventLoop::quit);
elapsed.start();
assert(replay.start(log.fileName(), 20, 10));
assert(replay.locationCount() == 4);
loop.exec();
assert(!replay.isRunning());
assert(received.size() == 5);
assert(received.first().startsWith(QStringLiteral("[LOCATION] source=gps")));
assert(received.at(1) == QStringLiteral("diagnostic line without timestamp"));
assert(received.at(3).startsWith(QStringLiteral("[FUSION]")));
assert(receivedAt.last() >= 15);
QTemporaryFile splitLog;
assert(splitLog.open());
splitLog.write("latitude: 40.25\nlongitude: 29.50\n");
splitLog.flush();
received.clear();
receivedAt.clear();
elapsed.restart();
assert(replay.start(splitLog.fileName(), 1, 1));
assert(replay.locationCount() == 1);
loop.exec();
assert(received.size() == 2);
QTemporaryFile invalidLog;
assert(invalidLog.open());
invalidLog.write("only diagnostics\n");
invalidLog.flush();
assert(!replay.start(invalidLog.fileName(), 200, 50));
return 0;
}