diff --git a/CMakeLists.txt b/CMakeLists.txt index d4cb237..f34527c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,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) @@ -72,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 + $<$:-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 @@ -100,7 +114,7 @@ target_compile_options(location_main_window_tests PRIVATE ) 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) diff --git a/README.md b/README.md index 0ec0406..e4d0304 100644 --- a/README.md +++ b/README.md @@ -232,6 +232,8 @@ 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. diff --git a/resources/bluemarble-2048.png b/resources/bluemarble-2048.png new file mode 100644 index 0000000..2ee1518 Binary files /dev/null and b/resources/bluemarble-2048.png differ diff --git a/src/GlobeWidget.cpp b/src/GlobeWidget.cpp index 6b65e5d..ab82880 100644 --- a/src/GlobeWidget.cpp +++ b/src/GlobeWidget.cpp @@ -1,12 +1,14 @@ #include "GlobeWidget.h" #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -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(std::ceil(radius * 2.0 * pixelRatio)), 280, 650) + : std::clamp(static_cast(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(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((longitude / (2.0 * Pi) + 0.5) * source.width()); + sourceX = (sourceX % source.width() + source.width()) % source.width(); + const int sourceY = std::clamp(static_cast((0.5 - latitude / Pi) + * source.height()), 0, source.height() - 1); + const QRgb sample = reinterpret_cast(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(red * lighting), 0, 255), + std::clamp(static_cast(green * lighting), 0, 255), + std::clamp(static_cast(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(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(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(red), 0, 255), + std::clamp(static_cast(green), 0, 255), + std::clamp(static_cast(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(red * shade), 0, 255), - std::clamp(static_cast(green * shade), 0, 255), - std::clamp(static_cast(blue * shade), 0, 255), 238); + std::clamp(static_cast(red * shade + cityLights * 235.0), 0, 255), + std::clamp(static_cast(green * shade + cityLights * 155.0), 0, 255), + std::clamp(static_cast(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(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); +} diff --git a/src/GlobeWidget.h b/src/GlobeWidget.h index 0164b18..896233b 100644 --- a/src/GlobeWidget.h +++ b/src/GlobeWidget.h @@ -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 m_observedPrns; QString m_status = QStringLiteral("Konum bekleniyor"); bool m_statusError = false; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index d197d49..8896946 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -26,14 +26,15 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include -#include #include #include #include @@ -167,6 +168,7 @@ LocationAnalysis analyzeLocations(const QVector &points) QVector preferredAnalysisPoints(const QVector &points) { QVector corrected; + corrected.reserve(points.size()); for (const TelemetryPoint &point : points) { if (point.source == LocationSource::ImuCorrected) { corrected.push_back(point); @@ -386,6 +388,7 @@ MainWindow::MainWindow(QWidget *parent) , m_rinex(new RinexNavigation(this)) { buildUi(); + buildMenus(); m_locationTimeoutTimer = new QTimer(this); m_locationTimeoutTimer->setSingleShot(true); @@ -395,6 +398,9 @@ 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); @@ -434,11 +440,21 @@ MainWindow::MainWindow(QWidget *parent) }); 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) { @@ -456,6 +472,7 @@ MainWindow::MainWindow(QWidget *parent) rinexRefreshTimer->start(); resize(1480, 900); + loadSettings(); setWindowTitle(QStringLiteral("Canli Konum Haritasi")); } @@ -463,6 +480,7 @@ void MainWindow::closeEvent(QCloseEvent *event) { m_replay->stop(); stopUartLogging(); + saveSettings(); QMainWindow::closeEvent(event); } @@ -628,20 +646,19 @@ bool MainWindow::loadTelemetryLog(const QString &filePath) resetTelemetryState(); const int replayCapacity = static_cast(std::min(10000, lines.size())); m_maxPointsSpin->setValue(std::max(10, replayCapacity)); - int parsedLocations = 0; for (const QByteArray &rawLine : lines) { const QString line = QString::fromUtf8(rawLine).trimmed(); if (line.isEmpty() || line.startsWith(QLatin1Char('#'))) { continue; } - const auto telemetry = LocationParser::parseLine(line); - if (telemetry.has_value() && telemetry->hasLocation()) { - ++parsedLocations; - } handleSerialLine(line); } + // 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); @@ -754,6 +771,14 @@ void MainWindow::refreshSerialPorts() 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); @@ -774,6 +799,110 @@ 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(QStringLiteral("mainSplitter"))) + splitter->restoreState(settings.value(QStringLiteral("window/mainSplitter")).toByteArray()); + if (auto *splitter = findChild(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(QStringLiteral("elevationMaskSpin"))) + elevation->setValue(settings.value(QStringLiteral("globe/elevationMask"), 10.0).toDouble()); + if (auto *tabs = findChild(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(QStringLiteral("mainSplitter"))) + settings.setValue(QStringLiteral("window/mainSplitter"), splitter->saveState()); + if (auto *splitter = findChild(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(QStringLiteral("elevationMaskSpin"))) + settings.setValue(QStringLiteral("globe/elevationMask"), elevation->value()); + if (auto *tabs = findChild(QStringLiteral("mainViewTabs"))) + settings.setValue(QStringLiteral("window/mainTab"), tabs->currentIndex()); + settings.sync(); } void MainWindow::handleSerialLine(const QString &line) @@ -789,6 +918,14 @@ void MainWindow::handleSerialLine(const QString &line) 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 linePrns = LocationParser::parseGpsPrns(line); @@ -1012,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 { @@ -1089,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.")); @@ -1124,6 +1264,11 @@ void MainWindow::buildUi() 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); @@ -1145,6 +1290,7 @@ void MainWindow::buildUi() 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); @@ -1178,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); @@ -1381,7 +1527,11 @@ void MainWindow::buildUi() connect(m_clearButton, &QPushButton::clicked, this, &MainWindow::clearPoints); connect(m_openLogButton, &QPushButton::clicked, this, &MainWindow::openTelemetryLog); connect(m_replayButton, &QPushButton::clicked, this, &MainWindow::openUartReplay); - connect(refreshPortsButton, &QToolButton::clicked, this, &MainWindow::refreshSerialPorts); + connect(m_rinexUpdateButton, &QPushButton::clicked, m_rinex, &RinexNavigation::refresh); + connect(refreshPortsButton, &QToolButton::clicked, this, [this] { + m_portsInitialized = false; + refreshSerialPorts(); + }); connect(m_maxPointsSpin, qOverload(&QSpinBox::valueChanged), this, &MainWindow::trimToMaxPoints); connect(m_autoFollowCheck, &QCheckBox::toggled, m_map, &MapWidget::setAutoFollow); diff --git a/src/MainWindow.h b/src/MainWindow.h index cf6ae2e..6b1061c 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -59,6 +59,9 @@ private slots: private: void buildUi(); + void buildMenus(); + void loadSettings(); + void saveSettings() const; void updateConnectionControls(); void addTelemetryPoint(const ParsedTelemetry &telemetry); void refreshTable(); @@ -89,6 +92,7 @@ private: 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; @@ -113,6 +117,7 @@ private: QPlainTextEdit *m_log = nullptr; QTimer *m_locationTimeoutTimer = nullptr; QTimer *m_uiRefreshTimer = nullptr; + QTimer *m_portRefreshTimer = nullptr; QFile *m_uartLogFile = nullptr; QString m_uartLogPath; @@ -125,4 +130,6 @@ private: DopValues m_lastDop; ParsedTelemetry m_pendingTelemetry; bool m_telemetryRefreshPending = false; + bool m_portsInitialized = false; + QStringList m_lastDetectedPorts; }; diff --git a/src/MapWidget.cpp b/src/MapWidget.cpp index d4ac706..24fea30 100644 --- a/src/MapWidget.cpp +++ b/src/MapWidget.cpp @@ -1,9 +1,11 @@ #include "MapWidget.h" +#include #include #include #include #include +#include #include #include #include @@ -13,6 +15,7 @@ #include #include #include +#include #include #include @@ -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 &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 &points) m_points = points; if (!m_points.isEmpty() && m_autoFollow) { - const TelemetryPoint ¤t = m_points[preferredCurrentPointIndex(m_points)]; - m_centerLat = current.latitude; - m_centerLon = current.longitude; + 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 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(TileSize) * static_cast(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 ¤t = 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(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 missingTiles; + QSet 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 { - painter.fillRect(target, QColor(230, 234, 230)); - painter.setPen(QColor(198, 205, 200)); - painter.drawRect(target); - requestTile(wrappedTileX, tileY, m_zoom); + if (!drawCachedTileOrParent(painter, target, wrappedTileX, tileY, m_zoom)) { + painter.fillRect(target, QColor(230, 234, 230)); + painter.setPen(QColor(198, 205, 200)); + painter.drawRect(target); + } + 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(parent.width()) / scale; + const double sourceHeight = static_cast(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 &visibleTileKeys) +{ + QVector 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(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; +} diff --git a/src/MapWidget.h b/src/MapWidget.h index 5e67a52..b40d5fd 100644 --- a/src/MapWidget.h +++ b/src/MapWidget.h @@ -11,6 +11,7 @@ #include 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 &visibleTileKeys); + void repaintWhenTileRetryIsReady(const QString &key); void setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor); void disableAutoFollowFromUserAction(); + int preferredFocusIndex() const; QVector m_points; QNetworkAccessManager *m_network = nullptr; QLabel *m_attributionLabel = nullptr; QHash m_tileCache; QSet m_pendingTiles; + QHash m_tileReplies; + QHash m_tileFailureCounts; + QHash m_tileRetryAfterMs; + QString m_tileUrlTemplate; double m_centerLat = 39.0; double m_centerLon = 35.0; @@ -90,4 +101,5 @@ private: std::optional m_cepRadiusMeters; bool m_dragging = false; QPoint m_lastMousePos; + double m_wheelZoomAccumulator = 0.0; }; diff --git a/src/RinexNavigation.cpp b/src/RinexNavigation.cpp index c839d5a..fcf862c 100644 --- a/src/RinexNavigation.cpp +++ b/src/RinexNavigation.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -148,7 +149,9 @@ void RinexNavigation::handleDownload(const QByteArray &compressed, const QDate & return; } setLoading(false); - emit statusChanged(QStringLiteral("RINEX okunamadi: %1").arg(error), true); + if (!loadCache()) { + emit statusChanged(QStringLiteral("RINEX okunamadi: %1").arg(error), true); + } return; } @@ -180,7 +183,7 @@ bool RinexNavigation::loadCache() emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX · %2") .arg(positions.size()) .arg(now.toLocalTime().toString( - QStringLiteral("HH:mm:ss t"))), true); + QStringLiteral("HH:mm:ss t"))), false); return true; } diff --git a/tests/main_window_tests.cpp b/tests/main_window_tests.cpp index 908444f..8192967 100644 --- a/tests/main_window_tests.cpp +++ b/tests/main_window_tests.cpp @@ -3,6 +3,7 @@ #include "../src/MapWidget.h" #include +#include #include #include #include @@ -21,6 +22,7 @@ #include #include +#include namespace { @@ -88,6 +90,10 @@ int main(int argc, char **argv) auto *map = window.findChild(); auto *mainViewTabs = window.findChild(QStringLiteral("mainViewTabs")); auto *sideSplitter = window.findChild(QStringLiteral("sideSplitter")); + auto *rinexUpdateButton = window.findChild(QStringLiteral("rinexUpdateButton")); + auto *openLogAction = window.findChild(QStringLiteral("openLogAction")); + auto *toggleSerialAction = window.findChild(QStringLiteral("toggleSerialAction")); + auto *rinexUpdateAction = window.findChild(QStringLiteral("rinexUpdateAction")); assert(table != nullptr); assert(referenceLatInput != nullptr); assert(referenceLonInput != nullptr); @@ -118,6 +124,32 @@ 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()); + + QVector 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); @@ -185,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")); diff --git a/tests/map_widget_tests.cpp b/tests/map_widget_tests.cpp new file mode 100644 index 0000000..11f67ff --- /dev/null +++ b/tests/map_widget_tests.cpp @@ -0,0 +1,73 @@ +#include "../src/MapWidget.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + + QTcpServer tileServer; + assert(tileServer.listen(QHostAddress::LocalHost, 0)); + + int requestCount = 0; + QSet 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 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; +}