diff --git a/CMakeLists.txt b/CMakeLists.txt index ed9a55a..a84f02b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,7 +43,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) @@ -84,7 +84,7 @@ add_executable(location_main_window_tests target_link_libraries(location_main_window_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network ZLIB::ZLIB) qt_add_resources(location_main_window_tests "test_app_resources" PREFIX "/" - FILES resources/ankasys-logo.png resources/land-110m.json + 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 6e490b2..58ec7d6 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,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 30 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 35c4596..7a5b025 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 @@ -184,6 +186,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(); @@ -369,11 +380,28 @@ void GlobeWidget::paintEvent(QPaintEvent *) 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); + drawSatelliteCoverage(painter); if (m_hasReceiver) drawVisibilityCone(painter); if (m_hasReceiver) drawReceiver(painter); drawSatellites(painter); @@ -391,6 +419,9 @@ void GlobeWidget::paintEvent(QPaintEvent *) painter.setFont(smallFont); painter.setPen(m_statusError ? QColor(255, 177, 77) : QColor(132, 177, 204)); painter.drawText(QRect(20, 43, width() - 40, 22), Qt::AlignLeft | Qt::AlignVCenter, m_status); + painter.setPen(QColor(105, 143, 166, 145)); + painter.drawText(QRect(20, 43, width() - 40, 22), Qt::AlignRight | Qt::AlignVCenter, + QStringLiteral("Earth: NASA/GSFC Blue Marble")); const int legendY = height() - 25; const int legendX = width() / 2 - 125; @@ -425,6 +456,100 @@ void GlobeWidget::paintEvent(QPaintEvent *) } } +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; + const double pixelRatio = std::min(devicePixelRatioF(), 2.0); + const int textureSize = 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 { const double radius = std::min(width(), height()) * 0.255 * m_zoom; @@ -434,8 +559,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; @@ -447,6 +575,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); @@ -467,16 +607,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) @@ -505,31 +666,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); @@ -546,6 +786,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; @@ -662,14 +906,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); @@ -808,7 +1048,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 707594a..aad6c14 100644 --- a/src/GlobeWidget.h +++ b/src/GlobeWidget.h @@ -28,6 +28,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; @@ -44,6 +45,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; @@ -60,6 +63,7 @@ private: mutable QImage m_landTexture; mutable double m_landTextureYaw = 0.0; mutable double m_landTexturePitch = 0.0; + mutable qint64 m_landTextureLightingMinute = -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 0abe336..2816f0c 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -384,6 +384,12 @@ MainWindow::MainWindow(QWidget *parent) 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); }); m_rinex->refresh(); @@ -433,20 +439,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); @@ -499,6 +504,14 @@ void MainWindow::handleSerialLine(const QString &line) QRegularExpression::CaseInsensitiveOption); const bool solveStarted = solveStartRegex.match(line.trimmed()).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); @@ -717,7 +730,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 { @@ -794,7 +810,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.")); @@ -824,6 +840,11 @@ 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_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); @@ -844,6 +865,7 @@ void MainWindow::buildUi() toolbarLayout->addWidget(m_maxPointsSpin); toolbarLayout->addWidget(m_connectButton); toolbarLayout->addWidget(m_openLogButton); + toolbarLayout->addWidget(m_rinexUpdateButton); toolbarLayout->addWidget(m_clearButton); toolbarLayout->addWidget(m_autoFollowCheck); toolbarLayout->addWidget(zoomOutButton); @@ -859,7 +881,7 @@ void MainWindow::buildUi() viewTabs->addTab(m_globe, 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); @@ -1061,6 +1083,7 @@ 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(m_rinexUpdateButton, &QPushButton::clicked, m_rinex, &RinexNavigation::refresh); connect(refreshPortsButton, &QToolButton::clicked, this, &MainWindow::refreshSerialPorts); connect(m_maxPointsSpin, qOverload(&QSpinBox::valueChanged), this, &MainWindow::trimToMaxPoints); diff --git a/src/MainWindow.h b/src/MainWindow.h index 3f6ee15..f3021e8 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -74,6 +74,7 @@ private: QPushButton *m_connectButton = nullptr; QPushButton *m_clearButton = nullptr; QPushButton *m_openLogButton = nullptr; + QPushButton *m_rinexUpdateButton = nullptr; QCheckBox *m_autoFollowCheck = nullptr; QCheckBox *m_showTelemetryCheck = nullptr; QCheckBox *m_showRecentPointsCheck = nullptr; diff --git a/src/MapWidget.cpp b/src/MapWidget.cpp index 8df3fb4..e305f85 100644 --- a/src/MapWidget.cpp +++ b/src/MapWidget.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -58,7 +59,7 @@ MapWidget::MapWidget(QWidget *parent) : QWidget(parent) , m_network(new QNetworkAccessManager(this)) { - setMinimumSize(640, 420); + setMinimumSize(480, 320); setMouseTracking(true); const QString cacheRoot = QStandardPaths::writableLocation(QStandardPaths::CacheLocation); @@ -115,23 +116,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; } @@ -260,16 +293,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) { diff --git a/src/MapWidget.h b/src/MapWidget.h index 213ba1a..9f42459 100644 --- a/src/MapWidget.h +++ b/src/MapWidget.h @@ -41,6 +41,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; @@ -89,4 +90,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 a823dcc..2fa7993 100644 --- a/src/RinexNavigation.cpp +++ b/src/RinexNavigation.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -90,7 +91,8 @@ QByteArray gunzip(const QByteArray &input) QDateTime gpsEpoch() { - return QDateTime(QDate(1980, 1, 6), QTime(0, 0), Qt::UTC); + return QDateTime(QDate(1980, 1, 6), QTime(0, 0), + QTimeZone::fromSecondsAheadOfUtc(0)); } SatellitePosition propagate(const Ephemeris &eph, const QDateTime &whenUtc) @@ -205,7 +207,9 @@ void RinexNavigation::handleDownload(const QByteArray &compressed, const QDate & return; } m_loading = false; - emit statusChanged(QStringLiteral("RINEX okunamadi: %1").arg(error), true); + if (!loadCache()) { + emit statusChanged(QStringLiteral("RINEX okunamadi: %1").arg(error), true); + } return; } @@ -224,12 +228,13 @@ bool RinexNavigation::loadCache() QFile cache(cachePath()); if (!cache.open(QIODevice::ReadOnly)) return false; QString error; - m_latestRinex = cache.readAll(); + const QByteArray cachedRinex = cache.readAll(); const QVector positions = parseGpsNavigation( - m_latestRinex, QDateTime::currentDateTimeUtc(), &error); + cachedRinex, QDateTime::currentDateTimeUtc(), &error); if (positions.isEmpty()) return false; + m_latestRinex = cachedRinex; publishCurrentMotion(); - emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX").arg(positions.size()), true); + emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX").arg(positions.size()), false); return true; } @@ -291,7 +296,8 @@ QVector RinexNavigation::parseGpsNavigation( const int year = epoch[0].toInt(&dateOk); const QDateTime toc(QDate(year, epoch[1].toInt(), epoch[2].toInt()), QTime(epoch[3].toInt(), epoch[4].toInt(), - static_cast(epoch[5].toDouble())), Qt::UTC); + static_cast(epoch[5].toDouble())), + QTimeZone::fromSecondsAheadOfUtc(0)); if (!dateOk || !toc.isValid()) { index += 8; continue; } Ephemeris eph; diff --git a/tests/main_window_tests.cpp b/tests/main_window_tests.cpp index bb40f56..04326d9 100644 --- a/tests/main_window_tests.cpp +++ b/tests/main_window_tests.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -54,6 +55,7 @@ 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")); assert(table != nullptr); assert(referenceLatInput != nullptr); assert(referenceLonInput != nullptr); @@ -81,6 +83,8 @@ 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(QMetaObject::invokeMethod(map, "referenceLocationSelected", Qt::DirectConnection, Q_ARG(double, 40.25), Q_ARG(double, 29.5))); @@ -139,6 +143,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"));