Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8d6314e48 | |||
| f7fa2b3e4d | |||
| 745c7d91af | |||
| db96fd4da2 | |||
| 140b0e35b5 | |||
| 73227452c3 | |||
| f012b7a52a | |||
| 7a883b801f | |||
| e648eca324 |
+16
-2
@@ -48,7 +48,7 @@ endif()
|
|||||||
|
|
||||||
qt_add_resources(location_plotter "app_resources"
|
qt_add_resources(location_plotter "app_resources"
|
||||||
PREFIX "/"
|
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)
|
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_test(NAME location_parser_tests COMMAND location_parser_tests)
|
||||||
|
|
||||||
|
add_executable(location_map_widget_tests
|
||||||
|
tests/map_widget_tests.cpp
|
||||||
|
src/MapWidget.cpp
|
||||||
|
src/MapWidget.h
|
||||||
|
src/TelemetryPoint.h
|
||||||
|
)
|
||||||
|
target_link_libraries(location_map_widget_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network)
|
||||||
|
target_compile_options(location_map_widget_tests PRIVATE
|
||||||
|
$<$<CXX_COMPILER_ID:GNU,Clang,AppleClang>:-UNDEBUG>
|
||||||
|
)
|
||||||
|
add_test(NAME location_map_widget_tests COMMAND location_map_widget_tests)
|
||||||
|
set_tests_properties(location_map_widget_tests PROPERTIES
|
||||||
|
ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_CACHE_HOME=/tmp/location-plotter-map-test-cache")
|
||||||
|
|
||||||
add_executable(location_main_window_tests
|
add_executable(location_main_window_tests
|
||||||
tests/main_window_tests.cpp
|
tests/main_window_tests.cpp
|
||||||
src/LocationParser.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"
|
qt_add_resources(location_main_window_tests "test_app_resources"
|
||||||
PREFIX "/"
|
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)
|
if(UNIX AND NOT APPLE)
|
||||||
target_compile_definitions(location_main_window_tests PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1)
|
target_compile_definitions(location_main_window_tests PRIVATE LOCATION_PLOTTER_LINUX_SERIAL=1)
|
||||||
|
|||||||
@@ -232,6 +232,8 @@ without logging out.
|
|||||||
(10 degrees by default, press Enter after typing);
|
(10 degrees by default, press Enter after typing);
|
||||||
satellites inside the mask get a cyan sight line and halo. Incoming positions update the
|
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.
|
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
|
- 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
|
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.
|
positions are propagated from the cached ephemeris, then smoothly interpolated at 10 FPS.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
+288
-22
@@ -1,12 +1,14 @@
|
|||||||
#include "GlobeWidget.h"
|
#include "GlobeWidget.h"
|
||||||
|
|
||||||
#include <QLinearGradient>
|
#include <QLinearGradient>
|
||||||
|
#include <QDateTime>
|
||||||
#include <QHash>
|
#include <QHash>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QJsonArray>
|
#include <QJsonArray>
|
||||||
#include <QJsonDocument>
|
#include <QJsonDocument>
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QMouseEvent>
|
#include <QMouseEvent>
|
||||||
|
#include <QNativeGestureEvent>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
#include <QPainterPath>
|
#include <QPainterPath>
|
||||||
#include <QPolygonF>
|
#include <QPolygonF>
|
||||||
@@ -185,6 +187,15 @@ const QImage &landMask()
|
|||||||
return mask;
|
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)
|
bool maskIsLand(double longitude, double latitude)
|
||||||
{
|
{
|
||||||
const QImage &mask = landMask();
|
const QImage &mask = landMask();
|
||||||
@@ -354,14 +365,18 @@ void GlobeWidget::paintEvent(QPaintEvent *)
|
|||||||
painter.setRenderHint(QPainter::Antialiasing, true);
|
painter.setRenderHint(QPainter::Antialiasing, true);
|
||||||
|
|
||||||
const qreal devicePixelRatio = devicePixelRatioF();
|
const qreal devicePixelRatio = devicePixelRatioF();
|
||||||
|
const qint64 lightingMinute =
|
||||||
|
QDateTime::currentDateTimeUtc().toSecsSinceEpoch() / 60;
|
||||||
if (m_staticLayer.isNull() || m_staticLayerSize != size()
|
if (m_staticLayer.isNull() || m_staticLayerSize != size()
|
||||||
|| !qFuzzyCompare(m_staticLayerDevicePixelRatio, devicePixelRatio)
|
|| !qFuzzyCompare(m_staticLayerDevicePixelRatio, devicePixelRatio)
|
||||||
|| m_staticLayerYaw != m_yaw || m_staticLayerPitch != m_pitch
|
|| m_staticLayerYaw != m_yaw || m_staticLayerPitch != m_pitch
|
||||||
|| m_staticLayerZoom != m_zoom) {
|
|| m_staticLayerZoom != m_zoom
|
||||||
|
|| m_staticLayerLightingMinute != lightingMinute) {
|
||||||
rebuildStaticLayer();
|
rebuildStaticLayer();
|
||||||
}
|
}
|
||||||
painter.drawPixmap(0, 0, m_staticLayer);
|
painter.drawPixmap(0, 0, m_staticLayer);
|
||||||
|
|
||||||
|
drawSatelliteCoverage(painter);
|
||||||
if (m_hasReceiver) drawVisibilityCone(painter);
|
if (m_hasReceiver) drawVisibilityCone(painter);
|
||||||
if (m_hasReceiver) drawReceiver(painter);
|
if (m_hasReceiver) drawReceiver(painter);
|
||||||
const int visibleCount = drawSatellites(painter);
|
const int visibleCount = drawSatellites(painter);
|
||||||
@@ -454,10 +469,26 @@ void GlobeWidget::rebuildStaticLayer()
|
|||||||
QPainterPath sphereClip;
|
QPainterPath sphereClip;
|
||||||
sphereClip.addEllipse(center, radius, radius);
|
sphereClip.addEllipse(center, radius, radius);
|
||||||
painter.setClipPath(sphereClip);
|
painter.setClipPath(sphereClip);
|
||||||
drawContinents(painter);
|
drawBlueMarble(painter);
|
||||||
drawGraticule(painter);
|
drawGraticule(painter);
|
||||||
painter.restore();
|
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);
|
for (int i = 0; i < 3; ++i) drawOrbit(painter, 55.0 + i * 1.8, i * 61.0 + 12.0);
|
||||||
painter.end();
|
painter.end();
|
||||||
|
|
||||||
@@ -467,6 +498,107 @@ void GlobeWidget::rebuildStaticLayer()
|
|||||||
m_staticLayerYaw = m_yaw;
|
m_staticLayerYaw = m_yaw;
|
||||||
m_staticLayerPitch = m_pitch;
|
m_staticLayerPitch = m_pitch;
|
||||||
m_staticLayerZoom = m_zoom;
|
m_staticLayerZoom = m_zoom;
|
||||||
|
m_staticLayerLightingMinute =
|
||||||
|
QDateTime::currentDateTimeUtc().toSecsSinceEpoch() / 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
void GlobeWidget::drawBlueMarble(QPainter &painter) const
|
||||||
|
{
|
||||||
|
const QImage &source = blueMarbleTexture();
|
||||||
|
if (source.isNull()) {
|
||||||
|
drawContinents(painter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const double radius = std::min(width(), height()) * 0.255 * m_zoom;
|
||||||
|
// Interactive rotation favors latency; mouse release immediately causes
|
||||||
|
// a full Retina-quality rebuild. Satellite animation frames reuse the
|
||||||
|
// cached texture and therefore do not repeat this projection work.
|
||||||
|
const double pixelRatio = m_dragging ? std::min(devicePixelRatioF(), 1.0)
|
||||||
|
: std::min(devicePixelRatioF(), 2.0);
|
||||||
|
const int textureSize = m_dragging
|
||||||
|
? std::clamp(static_cast<int>(std::ceil(radius * 2.0 * pixelRatio)), 280, 650)
|
||||||
|
: std::clamp(static_cast<int>(std::ceil(radius * 2.0 * pixelRatio)), 420, 1200);
|
||||||
|
const QPointF center(width() * 0.5, height() * 0.52);
|
||||||
|
const QRectF target(center.x() - radius, center.y() - radius,
|
||||||
|
radius * 2.0, radius * 2.0);
|
||||||
|
const QDateTime nowUtc = QDateTime::currentDateTimeUtc();
|
||||||
|
const qint64 lightingMinute = nowUtc.toSecsSinceEpoch() / 60;
|
||||||
|
if (!m_landTexture.isNull() && m_landTexture.width() == textureSize
|
||||||
|
&& m_landTextureYaw == m_yaw && m_landTexturePitch == m_pitch
|
||||||
|
&& m_landTextureLightingMinute == lightingMinute) {
|
||||||
|
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||||
|
painter.drawImage(target, m_landTexture);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QImage globe(textureSize, textureSize, QImage::Format_ARGB32_Premultiplied);
|
||||||
|
globe.fill(Qt::transparent);
|
||||||
|
const double cy = std::cos(m_yaw);
|
||||||
|
const double sy = std::sin(m_yaw);
|
||||||
|
const double cp = std::cos(m_pitch);
|
||||||
|
const double sp = std::sin(m_pitch);
|
||||||
|
const double utcHours = nowUtc.time().msecsSinceStartOfDay() / 3600000.0;
|
||||||
|
const double declination = 23.44 * std::sin(
|
||||||
|
2.0 * Pi * (284.0 + nowUtc.date().dayOfYear()) / 365.25);
|
||||||
|
const Vec3 sun = globeVector(declination, (12.0 - utcHours) * 15.0);
|
||||||
|
const Vec3 camera {sy * cp, cy * cp, -sp};
|
||||||
|
const double halfLength = std::sqrt(std::pow(sun.x + camera.x, 2.0)
|
||||||
|
+ std::pow(sun.y + camera.y, 2.0) + std::pow(sun.z + camera.z, 2.0));
|
||||||
|
const Vec3 halfVector {(sun.x + camera.x) / halfLength,
|
||||||
|
(sun.y + camera.y) / halfLength,
|
||||||
|
(sun.z + camera.z) / halfLength};
|
||||||
|
|
||||||
|
for (int py = 0; py < textureSize; ++py) {
|
||||||
|
QRgb *destination = reinterpret_cast<QRgb *>(globe.scanLine(py));
|
||||||
|
const double screenZ = -((py + 0.5) / textureSize * 2.0 - 1.0);
|
||||||
|
for (int px = 0; px < textureSize; ++px) {
|
||||||
|
const double screenX = (px + 0.5) / textureSize * 2.0 - 1.0;
|
||||||
|
const double radiusSquared = screenX * screenX + screenZ * screenZ;
|
||||||
|
if (radiusSquared > 1.0) continue;
|
||||||
|
const double depth = std::sqrt(1.0 - radiusSquared);
|
||||||
|
const double yawY = cp * depth + sp * screenZ;
|
||||||
|
const double worldZ = -sp * depth + cp * screenZ;
|
||||||
|
const double worldX = cy * screenX + sy * yawY;
|
||||||
|
const double worldY = -sy * screenX + cy * yawY;
|
||||||
|
const double latitude = std::asin(std::clamp(worldZ, -1.0, 1.0));
|
||||||
|
const double longitude = std::atan2(worldY, worldX);
|
||||||
|
int sourceX = static_cast<int>((longitude / (2.0 * Pi) + 0.5) * source.width());
|
||||||
|
sourceX = (sourceX % source.width() + source.width()) % source.width();
|
||||||
|
const int sourceY = std::clamp(static_cast<int>((0.5 - latitude / Pi)
|
||||||
|
* source.height()), 0, source.height() - 1);
|
||||||
|
const QRgb sample = reinterpret_cast<const QRgb *>(source.constScanLine(sourceY))[sourceX];
|
||||||
|
|
||||||
|
double red = qRed(sample);
|
||||||
|
double green = qGreen(sample);
|
||||||
|
double blue = qBlue(sample);
|
||||||
|
const double solarDot = worldX * sun.x + worldY * sun.y + worldZ * sun.z;
|
||||||
|
const double daylight = smoothStep(-0.10, 0.07, solarDot);
|
||||||
|
const double diffuse = 0.16 + 0.84 * std::max(0.0, solarDot);
|
||||||
|
const bool ocean = blue > green * 1.18 && blue > red * 1.35;
|
||||||
|
if (ocean) {
|
||||||
|
const double halfDot = std::max(0.0, worldX * halfVector.x
|
||||||
|
+ worldY * halfVector.y + worldZ * halfVector.z);
|
||||||
|
const double specular = std::pow(halfDot, 80.0) * daylight * 115.0;
|
||||||
|
red += specular;
|
||||||
|
green += specular;
|
||||||
|
blue += specular * 0.92;
|
||||||
|
}
|
||||||
|
const double limb = smoothStep(0.02, 0.34, depth);
|
||||||
|
const double lighting = (0.08 + diffuse * 0.92) * (0.45 + limb * 0.55);
|
||||||
|
destination[px] = qRgba(
|
||||||
|
std::clamp(static_cast<int>(red * lighting), 0, 255),
|
||||||
|
std::clamp(static_cast<int>(green * lighting), 0, 255),
|
||||||
|
std::clamp(static_cast<int>(blue * lighting), 0, 255), 255);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m_landTexture = globe;
|
||||||
|
m_landTextureYaw = m_yaw;
|
||||||
|
m_landTexturePitch = m_pitch;
|
||||||
|
m_landTextureLightingMinute = lightingMinute;
|
||||||
|
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||||
|
painter.drawImage(target, m_landTexture);
|
||||||
}
|
}
|
||||||
|
|
||||||
void GlobeWidget::drawContinents(QPainter &painter) const
|
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 QPointF center(width() * 0.5, height() * 0.52);
|
||||||
const QRectF target(center.x() - radius, center.y() - radius,
|
const QRectF target(center.x() - radius, center.y() - radius,
|
||||||
radius * 2.0, radius * 2.0);
|
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
|
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.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||||
painter.drawImage(target, m_landTexture);
|
painter.drawImage(target, m_landTexture);
|
||||||
return;
|
return;
|
||||||
@@ -492,6 +627,18 @@ void GlobeWidget::drawContinents(QPainter &painter) const
|
|||||||
const double sy = std::sin(m_yaw);
|
const double sy = std::sin(m_yaw);
|
||||||
const double cp = std::cos(m_pitch);
|
const double cp = std::cos(m_pitch);
|
||||||
const double sp = std::sin(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) {
|
for (int py = 0; py < textureSize; ++py) {
|
||||||
QRgb *scanLine = reinterpret_cast<QRgb *>(land.scanLine(py));
|
QRgb *scanLine = reinterpret_cast<QRgb *>(land.scanLine(py));
|
||||||
const double screenZ = -((py + 0.5) / textureSize * 2.0 - 1.0);
|
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 - 0.55, latitude)
|
||||||
|| maskIsLand(longitude, latitude + 0.55)
|
|| maskIsLand(longitude, latitude + 0.55)
|
||||||
|| 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 (!onLand) {
|
||||||
if (nearLand) {
|
const double oceanPattern = 0.5 + 0.25 * std::sin(longitude * Pi / 31.0)
|
||||||
const int alpha = static_cast<int>(65.0 * std::clamp(depth * 1.5, 0.0, 1.0));
|
+ 0.18 * std::cos(latitude * Pi / 17.0 + longitude * Pi / 47.0);
|
||||||
scanLine[px] = qRgba(33, 151, 174, alpha);
|
double red = nearLand ? 18.0 : 3.0 + oceanPattern * 5.0;
|
||||||
}
|
double green = nearLand ? 104.0 : 32.0 + oceanPattern * 16.0;
|
||||||
|
double blue = nearLand ? 132.0 : 72.0 + oceanPattern * 26.0;
|
||||||
|
const double halfDot = std::max(0.0, worldX * halfVector.x
|
||||||
|
+ worldY * halfVector.y + worldZ * halfVector.z);
|
||||||
|
const double specular = std::pow(halfDot, 72.0) * daylight * 150.0;
|
||||||
|
const double shade = 0.18 + diffuse * 0.82;
|
||||||
|
red = red * shade + specular;
|
||||||
|
green = green * shade + specular;
|
||||||
|
blue = blue * shade + specular * 0.92;
|
||||||
|
red = red * (1.0 - cloud * 0.58) + 226.0 * cloud * 0.58;
|
||||||
|
green = green * (1.0 - cloud * 0.58) + 237.0 * cloud * 0.58;
|
||||||
|
blue = blue * (1.0 - cloud * 0.58) + 243.0 * cloud * 0.58;
|
||||||
|
scanLine[px] = qRgba(std::clamp(static_cast<int>(red), 0, 255),
|
||||||
|
std::clamp(static_cast<int>(green), 0, 255),
|
||||||
|
std::clamp(static_cast<int>(blue), 0, 255), 248);
|
||||||
continue;
|
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 lon = longitude * Pi / 180.0;
|
||||||
const double lat = latitude * 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)
|
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;
|
red = red * (1.0 - snow) + 224.0 * snow;
|
||||||
green = green * (1.0 - snow) + 231.0 * snow;
|
green = green * (1.0 - snow) + 231.0 * snow;
|
||||||
blue = blue * (1.0 - snow) + 224.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)
|
const bool coast = !maskIsLand(longitude + 0.55, latitude)
|
||||||
|| !maskIsLand(longitude - 0.55, latitude)
|
|| !maskIsLand(longitude - 0.55, latitude)
|
||||||
|| !maskIsLand(longitude, latitude + 0.55)
|
|| !maskIsLand(longitude, latitude + 0.55)
|
||||||
|| !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) {
|
if (coast) {
|
||||||
red += 28.0;
|
red += 28.0;
|
||||||
green += 31.0;
|
green += 31.0;
|
||||||
blue += 18.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(
|
scanLine[px] = qRgba(
|
||||||
std::clamp(static_cast<int>(red * shade), 0, 255),
|
std::clamp(static_cast<int>(red * shade + cityLights * 235.0), 0, 255),
|
||||||
std::clamp(static_cast<int>(green * shade), 0, 255),
|
std::clamp(static_cast<int>(green * shade + cityLights * 155.0), 0, 255),
|
||||||
std::clamp(static_cast<int>(blue * shade), 0, 255), 238);
|
std::clamp(static_cast<int>(blue * shade + cityLights * 52.0), 0, 255), 248);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
m_landTexture = land;
|
m_landTexture = land;
|
||||||
m_landTextureYaw = m_yaw;
|
m_landTextureYaw = m_yaw;
|
||||||
m_landTexturePitch = m_pitch;
|
m_landTexturePitch = m_pitch;
|
||||||
|
m_landTextureLightingMinute = lightingMinute;
|
||||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
|
||||||
painter.drawImage(target, m_landTexture);
|
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
|
void GlobeWidget::drawStars(QPainter &painter) const
|
||||||
{
|
{
|
||||||
painter.setPen(Qt::NoPen);
|
painter.setPen(Qt::NoPen);
|
||||||
@@ -591,6 +838,10 @@ void GlobeWidget::drawStars(QPainter &painter) const
|
|||||||
void GlobeWidget::drawGraticule(QPainter &painter) const
|
void GlobeWidget::drawGraticule(QPainter &painter) const
|
||||||
{
|
{
|
||||||
const double radius = std::min(width(), height()) * 0.255 * m_zoom;
|
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));
|
painter.setPen(QPen(QColor(100, 193, 221, 48), 0.8));
|
||||||
for (int latitude = -60; latitude <= 60; latitude += 30) {
|
for (int latitude = -60; latitude <= 60; latitude += 30) {
|
||||||
QPainterPath path;
|
QPainterPath path;
|
||||||
@@ -707,14 +958,10 @@ void GlobeWidget::drawVisibilityCone(QPainter &painter) const
|
|||||||
earthRadiusPixels * displayOrbitRatio));
|
earthRadiusPixels * displayOrbitRatio));
|
||||||
}
|
}
|
||||||
|
|
||||||
painter.setPen(Qt::NoPen);
|
// Do not fill the projected cone with a triangle fan. When part of the
|
||||||
for (int i = 0; i < Segments; ++i) {
|
// ring is behind the globe that fan self-intersects and produces dark
|
||||||
const double averageDepth = (apex.depth + ring[i].depth + ring[i + 1].depth) / 3.0;
|
// rectangular/polygonal artifacts over the Earth texture. The boundary,
|
||||||
const int alpha = averageDepth >= 0.0 ? 18 : 7;
|
// spokes and sight lines communicate the same mask without obscuring it.
|
||||||
painter.setBrush(QColor(48, 207, 218, alpha));
|
|
||||||
painter.drawPolygon(QPolygonF{apex.point, ring[i].point, ring[i + 1].point});
|
|
||||||
}
|
|
||||||
|
|
||||||
QPainterPath boundary;
|
QPainterPath boundary;
|
||||||
boundary.moveTo(ring.first().point);
|
boundary.moveTo(ring.first().point);
|
||||||
for (int i = 1; i < ring.size(); ++i) boundary.lineTo(ring[i].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)
|
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();
|
update();
|
||||||
event->accept();
|
event->accept();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool GlobeWidget::event(QEvent *event)
|
||||||
|
{
|
||||||
|
if (event->type() == QEvent::NativeGesture) {
|
||||||
|
auto *gesture = static_cast<QNativeGestureEvent *>(event);
|
||||||
|
if (gesture->gestureType() == Qt::ZoomNativeGesture) {
|
||||||
|
m_zoom = std::clamp(m_zoom + gesture->value() * 0.85, 0.62, 1.42);
|
||||||
|
update();
|
||||||
|
gesture->accept();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return QWidget::event(event);
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public slots:
|
|||||||
void setElevationMask(double degrees);
|
void setElevationMask(double degrees);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
|
bool event(QEvent *event) override;
|
||||||
void paintEvent(QPaintEvent *event) override;
|
void paintEvent(QPaintEvent *event) override;
|
||||||
void mousePressEvent(QMouseEvent *event) override;
|
void mousePressEvent(QMouseEvent *event) override;
|
||||||
void mouseMoveEvent(QMouseEvent *event) override;
|
void mouseMoveEvent(QMouseEvent *event) override;
|
||||||
@@ -46,6 +47,8 @@ private:
|
|||||||
void drawStars(QPainter &painter) const;
|
void drawStars(QPainter &painter) const;
|
||||||
void drawOrbit(QPainter &painter, double inclination, double node) const;
|
void drawOrbit(QPainter &painter, double inclination, double node) const;
|
||||||
void drawContinents(QPainter &painter) const;
|
void drawContinents(QPainter &painter) const;
|
||||||
|
void drawBlueMarble(QPainter &painter) const;
|
||||||
|
void drawSatelliteCoverage(QPainter &painter) const;
|
||||||
void drawVisibilityCone(QPainter &painter) const;
|
void drawVisibilityCone(QPainter &painter) const;
|
||||||
void drawGraticule(QPainter &painter) const;
|
void drawGraticule(QPainter &painter) const;
|
||||||
void drawReceiver(QPainter &painter) const;
|
void drawReceiver(QPainter &painter) const;
|
||||||
@@ -63,12 +66,14 @@ private:
|
|||||||
mutable QImage m_landTexture;
|
mutable QImage m_landTexture;
|
||||||
mutable double m_landTextureYaw = 0.0;
|
mutable double m_landTextureYaw = 0.0;
|
||||||
mutable double m_landTexturePitch = 0.0;
|
mutable double m_landTexturePitch = 0.0;
|
||||||
|
mutable qint64 m_landTextureLightingMinute = -1;
|
||||||
QPixmap m_staticLayer;
|
QPixmap m_staticLayer;
|
||||||
QSize m_staticLayerSize;
|
QSize m_staticLayerSize;
|
||||||
qreal m_staticLayerDevicePixelRatio = 0.0;
|
qreal m_staticLayerDevicePixelRatio = 0.0;
|
||||||
double m_staticLayerYaw = 0.0;
|
double m_staticLayerYaw = 0.0;
|
||||||
double m_staticLayerPitch = 0.0;
|
double m_staticLayerPitch = 0.0;
|
||||||
double m_staticLayerZoom = 0.0;
|
double m_staticLayerZoom = 0.0;
|
||||||
|
qint64 m_staticLayerLightingMinute = -1;
|
||||||
QSet<QString> m_observedPrns;
|
QSet<QString> m_observedPrns;
|
||||||
QString m_status = QStringLiteral("Konum bekleniyor");
|
QString m_status = QStringLiteral("Konum bekleniyor");
|
||||||
bool m_statusError = false;
|
bool m_statusError = false;
|
||||||
|
|||||||
+160
-10
@@ -26,14 +26,15 @@
|
|||||||
#include <QHeaderView>
|
#include <QHeaderView>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLineEdit>
|
#include <QLineEdit>
|
||||||
|
#include <QMenuBar>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QPlainTextEdit>
|
#include <QPlainTextEdit>
|
||||||
#include <QScrollArea>
|
#include <QScrollArea>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QRegularExpression>
|
#include <QRegularExpression>
|
||||||
|
#include <QSettings>
|
||||||
#include <QSpinBox>
|
#include <QSpinBox>
|
||||||
#include <QSplitter>
|
#include <QSplitter>
|
||||||
#include <QSettings>
|
|
||||||
#include <QStatusBar>
|
#include <QStatusBar>
|
||||||
#include <QStyle>
|
#include <QStyle>
|
||||||
#include <QTabWidget>
|
#include <QTabWidget>
|
||||||
@@ -167,6 +168,7 @@ LocationAnalysis analyzeLocations(const QVector<TelemetryPoint> &points)
|
|||||||
QVector<TelemetryPoint> preferredAnalysisPoints(const QVector<TelemetryPoint> &points)
|
QVector<TelemetryPoint> preferredAnalysisPoints(const QVector<TelemetryPoint> &points)
|
||||||
{
|
{
|
||||||
QVector<TelemetryPoint> corrected;
|
QVector<TelemetryPoint> corrected;
|
||||||
|
corrected.reserve(points.size());
|
||||||
for (const TelemetryPoint &point : points) {
|
for (const TelemetryPoint &point : points) {
|
||||||
if (point.source == LocationSource::ImuCorrected) {
|
if (point.source == LocationSource::ImuCorrected) {
|
||||||
corrected.push_back(point);
|
corrected.push_back(point);
|
||||||
@@ -386,6 +388,7 @@ MainWindow::MainWindow(QWidget *parent)
|
|||||||
, m_rinex(new RinexNavigation(this))
|
, m_rinex(new RinexNavigation(this))
|
||||||
{
|
{
|
||||||
buildUi();
|
buildUi();
|
||||||
|
buildMenus();
|
||||||
|
|
||||||
m_locationTimeoutTimer = new QTimer(this);
|
m_locationTimeoutTimer = new QTimer(this);
|
||||||
m_locationTimeoutTimer->setSingleShot(true);
|
m_locationTimeoutTimer->setSingleShot(true);
|
||||||
@@ -395,6 +398,9 @@ MainWindow::MainWindow(QWidget *parent)
|
|||||||
m_uiRefreshTimer->setSingleShot(true);
|
m_uiRefreshTimer->setSingleShot(true);
|
||||||
m_uiRefreshTimer->setInterval(UiRefreshIntervalMs);
|
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::lineReceived, this, &MainWindow::handleSerialLine);
|
||||||
connect(m_serial, &SerialReader::statusChanged, this, &MainWindow::handleSerialStatus);
|
connect(m_serial, &SerialReader::statusChanged, this, &MainWindow::handleSerialStatus);
|
||||||
connect(m_serial, &SerialReader::errorOccurred, this, &MainWindow::handleSerialError);
|
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_locationTimeoutTimer, &QTimer::timeout, this, &MainWindow::handleLocationTimeout);
|
||||||
connect(m_uiRefreshTimer, &QTimer::timeout, this, &MainWindow::flushUiUpdates);
|
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,
|
connect(m_rinex, &RinexNavigation::satelliteMotionUpdated,
|
||||||
m_globe, &GlobeWidget::setSatelliteMotion);
|
m_globe, &GlobeWidget::setSatelliteMotion);
|
||||||
connect(m_rinex, &RinexNavigation::statusChanged, this,
|
connect(m_rinex, &RinexNavigation::statusChanged, this,
|
||||||
[this](const QString &status, bool error) {
|
[this](const QString &status, bool error) {
|
||||||
m_globe->setRinexStatus(status, 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);
|
statusBar()->showMessage(status, error ? 8000 : 4000);
|
||||||
});
|
});
|
||||||
connect(m_rinex, &RinexNavigation::loadingChanged, this, [this](bool loading) {
|
connect(m_rinex, &RinexNavigation::loadingChanged, this, [this](bool loading) {
|
||||||
@@ -456,6 +472,7 @@ MainWindow::MainWindow(QWidget *parent)
|
|||||||
rinexRefreshTimer->start();
|
rinexRefreshTimer->start();
|
||||||
|
|
||||||
resize(1480, 900);
|
resize(1480, 900);
|
||||||
|
loadSettings();
|
||||||
setWindowTitle(QStringLiteral("Canli Konum Haritasi"));
|
setWindowTitle(QStringLiteral("Canli Konum Haritasi"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,6 +480,7 @@ void MainWindow::closeEvent(QCloseEvent *event)
|
|||||||
{
|
{
|
||||||
m_replay->stop();
|
m_replay->stop();
|
||||||
stopUartLogging();
|
stopUartLogging();
|
||||||
|
saveSettings();
|
||||||
QMainWindow::closeEvent(event);
|
QMainWindow::closeEvent(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,20 +646,19 @@ bool MainWindow::loadTelemetryLog(const QString &filePath)
|
|||||||
resetTelemetryState();
|
resetTelemetryState();
|
||||||
const int replayCapacity = static_cast<int>(std::min<qsizetype>(10000, lines.size()));
|
const int replayCapacity = static_cast<int>(std::min<qsizetype>(10000, lines.size()));
|
||||||
m_maxPointsSpin->setValue(std::max(10, replayCapacity));
|
m_maxPointsSpin->setValue(std::max(10, replayCapacity));
|
||||||
int parsedLocations = 0;
|
|
||||||
for (const QByteArray &rawLine : lines) {
|
for (const QByteArray &rawLine : lines) {
|
||||||
const QString line = QString::fromUtf8(rawLine).trimmed();
|
const QString line = QString::fromUtf8(rawLine).trimmed();
|
||||||
if (line.isEmpty() || line.startsWith(QLatin1Char('#'))) {
|
if (line.isEmpty() || line.startsWith(QLatin1Char('#'))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const auto telemetry = LocationParser::parseLine(line);
|
|
||||||
if (telemetry.has_value() && telemetry->hasLocation()) {
|
|
||||||
++parsedLocations;
|
|
||||||
}
|
|
||||||
handleSerialLine(line);
|
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();
|
flushUiUpdates();
|
||||||
m_map->fitToPoints();
|
m_map->fitToPoints();
|
||||||
|
const int parsedLocations = m_points.size();
|
||||||
const QString message = QStringLiteral("Replay yuklendi: %1 konum - %2")
|
const QString message = QStringLiteral("Replay yuklendi: %1 konum - %2")
|
||||||
.arg(parsedLocations).arg(filePath);
|
.arg(parsedLocations).arg(filePath);
|
||||||
m_statusLabel->setText(message);
|
m_statusLabel->setText(message);
|
||||||
@@ -754,6 +771,14 @@ void MainWindow::refreshSerialPorts()
|
|||||||
const QString configuredPort = qEnvironmentVariable("LOCATION_PLOTTER_PORT").trimmed();
|
const QString configuredPort = qEnvironmentVariable("LOCATION_PLOTTER_PORT").trimmed();
|
||||||
const QStringList detectedPorts = detectedSerialPorts();
|
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();
|
m_portCombo->clear();
|
||||||
for (const QString &port : detectedPorts) {
|
for (const QString &port : detectedPorts) {
|
||||||
m_portCombo->addItem(port);
|
m_portCombo->addItem(port);
|
||||||
@@ -774,6 +799,110 @@ void MainWindow::refreshSerialPorts()
|
|||||||
m_portCombo->addItem(selectedPort);
|
m_portCombo->addItem(selectedPort);
|
||||||
}
|
}
|
||||||
m_portCombo->setCurrentText(selectedPort);
|
m_portCombo->setCurrentText(selectedPort);
|
||||||
|
if (deviceAdded) {
|
||||||
|
statusBar()->showMessage(QStringLiteral("Yeni USB UART aygiti algilandi: %1")
|
||||||
|
.arg(selectedPort), 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::buildMenus()
|
||||||
|
{
|
||||||
|
auto *fileMenu = menuBar()->addMenu(QStringLiteral("Dosya"));
|
||||||
|
QAction *openAction = fileMenu->addAction(QStringLiteral("Senaryo Logu Ac..."));
|
||||||
|
openAction->setObjectName(QStringLiteral("openLogAction"));
|
||||||
|
openAction->setShortcut(QKeySequence::Open);
|
||||||
|
connect(openAction, &QAction::triggered, this, &MainWindow::openTelemetryLog);
|
||||||
|
QAction *replayAction = fileMenu->addAction(QStringLiteral("UART Log Replay..."));
|
||||||
|
replayAction->setObjectName(QStringLiteral("uartReplayAction"));
|
||||||
|
replayAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+O")));
|
||||||
|
connect(replayAction, &QAction::triggered, this, &MainWindow::openUartReplay);
|
||||||
|
fileMenu->addSeparator();
|
||||||
|
QAction *quitAction = fileMenu->addAction(QStringLiteral("Cikis"));
|
||||||
|
quitAction->setShortcut(QKeySequence::Quit);
|
||||||
|
connect(quitAction, &QAction::triggered, this, &QWidget::close);
|
||||||
|
|
||||||
|
auto *connectionMenu = menuBar()->addMenu(QStringLiteral("Baglanti"));
|
||||||
|
QAction *listenAction = connectionMenu->addAction(QStringLiteral("Seri Baglantiyi Ac / Kapat"));
|
||||||
|
listenAction->setObjectName(QStringLiteral("toggleSerialAction"));
|
||||||
|
listenAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+K")));
|
||||||
|
connect(listenAction, &QAction::triggered, this, &MainWindow::toggleSerial);
|
||||||
|
QAction *portsAction = connectionMenu->addAction(QStringLiteral("Portlari Yenile"));
|
||||||
|
portsAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+Shift+R")));
|
||||||
|
connect(portsAction, &QAction::triggered, this, [this] {
|
||||||
|
m_portsInitialized = false;
|
||||||
|
refreshSerialPorts();
|
||||||
|
});
|
||||||
|
QAction *rinexAction = connectionMenu->addAction(QStringLiteral("RINEX Guncelle"));
|
||||||
|
rinexAction->setObjectName(QStringLiteral("rinexUpdateAction"));
|
||||||
|
rinexAction->setShortcut(QKeySequence::Refresh);
|
||||||
|
connect(rinexAction, &QAction::triggered, m_rinex, &RinexNavigation::refresh);
|
||||||
|
|
||||||
|
auto *viewMenu = menuBar()->addMenu(QStringLiteral("Gorunum"));
|
||||||
|
QAction *fitAction = viewMenu->addAction(QStringLiteral("Rotayi Haritaya Sigdir"));
|
||||||
|
fitAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+0")));
|
||||||
|
connect(fitAction, &QAction::triggered, m_map, &MapWidget::fitToPoints);
|
||||||
|
QAction *zoomInAction = viewMenu->addAction(QStringLiteral("Yaklastir"));
|
||||||
|
zoomInAction->setShortcut(QKeySequence::ZoomIn);
|
||||||
|
connect(zoomInAction, &QAction::triggered, m_map, &MapWidget::zoomIn);
|
||||||
|
QAction *zoomOutAction = viewMenu->addAction(QStringLiteral("Uzaklastir"));
|
||||||
|
zoomOutAction->setShortcut(QKeySequence::ZoomOut);
|
||||||
|
connect(zoomOutAction, &QAction::triggered, m_map, &MapWidget::zoomOut);
|
||||||
|
|
||||||
|
auto *helpMenu = menuBar()->addMenu(QStringLiteral("Yardim"));
|
||||||
|
QAction *aboutAction = helpMenu->addAction(QStringLiteral("Location Plotter Hakkinda"));
|
||||||
|
connect(aboutAction, &QAction::triggered, this, [this] {
|
||||||
|
QMessageBox::about(this, QStringLiteral("Location Plotter"),
|
||||||
|
QStringLiteral("GNSS telemetri, konum analizi ve RINEX uydu gorunumu\n"
|
||||||
|
"macOS surumu 0.2.0"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::loadSettings()
|
||||||
|
{
|
||||||
|
QSettings settings;
|
||||||
|
restoreGeometry(settings.value(QStringLiteral("window/geometry")).toByteArray());
|
||||||
|
if (auto *splitter = findChild<QSplitter *>(QStringLiteral("mainSplitter")))
|
||||||
|
splitter->restoreState(settings.value(QStringLiteral("window/mainSplitter")).toByteArray());
|
||||||
|
if (auto *splitter = findChild<QSplitter *>(QStringLiteral("sideSplitter")))
|
||||||
|
splitter->restoreState(settings.value(QStringLiteral("window/sideSplitter")).toByteArray());
|
||||||
|
|
||||||
|
const QString port = settings.value(QStringLiteral("serial/port")).toString();
|
||||||
|
if (!port.isEmpty()) {
|
||||||
|
if (m_portCombo->findText(port) < 0) m_portCombo->addItem(port);
|
||||||
|
m_portCombo->setCurrentText(port);
|
||||||
|
}
|
||||||
|
m_baudCombo->setCurrentText(settings.value(QStringLiteral("serial/baud"), 115200).toString());
|
||||||
|
m_maxPointsSpin->setValue(settings.value(QStringLiteral("telemetry/maxPoints"), 10).toInt());
|
||||||
|
m_autoFollowCheck->setChecked(settings.value(QStringLiteral("map/autoFollow"), true).toBool());
|
||||||
|
m_showTelemetryCheck->setChecked(settings.value(QStringLiteral("map/showTelemetry"), true).toBool());
|
||||||
|
m_showRecentPointsCheck->setChecked(settings.value(QStringLiteral("map/showRecent"), true).toBool());
|
||||||
|
m_showAverageCheck->setChecked(settings.value(QStringLiteral("map/showAverage"), true).toBool());
|
||||||
|
if (auto *elevation = findChild<QDoubleSpinBox *>(QStringLiteral("elevationMaskSpin")))
|
||||||
|
elevation->setValue(settings.value(QStringLiteral("globe/elevationMask"), 10.0).toDouble());
|
||||||
|
if (auto *tabs = findChild<QTabWidget *>(QStringLiteral("mainViewTabs")))
|
||||||
|
tabs->setCurrentIndex(settings.value(QStringLiteral("window/mainTab"), 0).toInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::saveSettings() const
|
||||||
|
{
|
||||||
|
QSettings settings;
|
||||||
|
settings.setValue(QStringLiteral("window/geometry"), saveGeometry());
|
||||||
|
if (auto *splitter = findChild<QSplitter *>(QStringLiteral("mainSplitter")))
|
||||||
|
settings.setValue(QStringLiteral("window/mainSplitter"), splitter->saveState());
|
||||||
|
if (auto *splitter = findChild<QSplitter *>(QStringLiteral("sideSplitter")))
|
||||||
|
settings.setValue(QStringLiteral("window/sideSplitter"), splitter->saveState());
|
||||||
|
settings.setValue(QStringLiteral("serial/port"), m_portCombo->currentText().trimmed());
|
||||||
|
settings.setValue(QStringLiteral("serial/baud"), selectedBaudRate());
|
||||||
|
settings.setValue(QStringLiteral("telemetry/maxPoints"), m_maxPointsSpin->value());
|
||||||
|
settings.setValue(QStringLiteral("map/autoFollow"), m_autoFollowCheck->isChecked());
|
||||||
|
settings.setValue(QStringLiteral("map/showTelemetry"), m_showTelemetryCheck->isChecked());
|
||||||
|
settings.setValue(QStringLiteral("map/showRecent"), m_showRecentPointsCheck->isChecked());
|
||||||
|
settings.setValue(QStringLiteral("map/showAverage"), m_showAverageCheck->isChecked());
|
||||||
|
if (auto *elevation = findChild<QDoubleSpinBox *>(QStringLiteral("elevationMaskSpin")))
|
||||||
|
settings.setValue(QStringLiteral("globe/elevationMask"), elevation->value());
|
||||||
|
if (auto *tabs = findChild<QTabWidget *>(QStringLiteral("mainViewTabs")))
|
||||||
|
settings.setValue(QStringLiteral("window/mainTab"), tabs->currentIndex());
|
||||||
|
settings.sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::handleSerialLine(const QString &line)
|
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 solveStarted = solveStartRegex.match(line.trimmed()).hasMatch();
|
||||||
const bool trackingSnapshot = trackingSnapshotRegex.match(line).hasMatch();
|
const bool trackingSnapshot = trackingSnapshotRegex.match(line).hasMatch();
|
||||||
if (solveStarted) {
|
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();
|
m_observedPrns.clear();
|
||||||
}
|
}
|
||||||
const QSet<QString> linePrns = LocationParser::parseGpsPrns(line);
|
const QSet<QString> linePrns = LocationParser::parseGpsPrns(line);
|
||||||
@@ -1012,7 +1149,10 @@ void MainWindow::buildUi()
|
|||||||
background: #23445d;
|
background: #23445d;
|
||||||
border-color: #62b8e8;
|
border-color: #62b8e8;
|
||||||
}
|
}
|
||||||
QPushButton:pressed, QToolButton:pressed { background: #dbeaf6; }
|
QPushButton:pressed, QToolButton:pressed {
|
||||||
|
color: #102333;
|
||||||
|
background: #dbeaf6;
|
||||||
|
}
|
||||||
QCheckBox { spacing: 7px; color: #d5e0e9; }
|
QCheckBox { spacing: 7px; color: #d5e0e9; }
|
||||||
QCheckBox::indicator { width: 17px; height: 17px; }
|
QCheckBox::indicator { width: 17px; height: 17px; }
|
||||||
QCheckBox::indicator:unchecked {
|
QCheckBox::indicator:unchecked {
|
||||||
@@ -1089,7 +1229,7 @@ void MainWindow::buildUi()
|
|||||||
|
|
||||||
m_portCombo = new QComboBox(this);
|
m_portCombo = new QComboBox(this);
|
||||||
m_portCombo->setObjectName(QStringLiteral("portSelector"));
|
m_portCombo->setObjectName(QStringLiteral("portSelector"));
|
||||||
m_portCombo->setMinimumWidth(220);
|
m_portCombo->setMinimumWidth(170);
|
||||||
m_portCombo->setEditable(true);
|
m_portCombo->setEditable(true);
|
||||||
m_portCombo->setToolTip(QStringLiteral(
|
m_portCombo->setToolTip(QStringLiteral(
|
||||||
"USB UART portu. macOS'ta /dev/cu.usb... tercih edilir; /dev/tty.usb... da desteklenir."));
|
"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->setIcon(style()->standardIcon(QStyle::SP_MediaPlay));
|
||||||
m_replayButton->setToolTip(QStringLiteral(
|
m_replayButton->setToolTip(QStringLiteral(
|
||||||
"Kayitli UART verisini GPS ve sensor guncelleme hizlariyla yeniden oynat"));
|
"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 = new QCheckBox(QStringLiteral("Kamerayi takip et"), this);
|
||||||
m_autoFollowCheck->setChecked(true);
|
m_autoFollowCheck->setChecked(true);
|
||||||
@@ -1145,6 +1290,7 @@ void MainWindow::buildUi()
|
|||||||
toolbarLayout->addWidget(m_connectButton);
|
toolbarLayout->addWidget(m_connectButton);
|
||||||
toolbarLayout->addWidget(m_openLogButton);
|
toolbarLayout->addWidget(m_openLogButton);
|
||||||
toolbarLayout->addWidget(m_replayButton);
|
toolbarLayout->addWidget(m_replayButton);
|
||||||
|
toolbarLayout->addWidget(m_rinexUpdateButton);
|
||||||
toolbarLayout->addWidget(m_clearButton);
|
toolbarLayout->addWidget(m_clearButton);
|
||||||
toolbarLayout->addWidget(m_autoFollowCheck);
|
toolbarLayout->addWidget(m_autoFollowCheck);
|
||||||
toolbarLayout->addWidget(zoomOutButton);
|
toolbarLayout->addWidget(zoomOutButton);
|
||||||
@@ -1178,7 +1324,7 @@ void MainWindow::buildUi()
|
|||||||
viewTabs->addTab(globeTab, QStringLiteral("3B Dunya · Uydular"));
|
viewTabs->addTab(globeTab, QStringLiteral("3B Dunya · Uydular"));
|
||||||
|
|
||||||
auto *sidePanel = new QWidget(this);
|
auto *sidePanel = new QWidget(this);
|
||||||
sidePanel->setMinimumWidth(390);
|
sidePanel->setMinimumWidth(330);
|
||||||
auto *sideOuterLayout = new QVBoxLayout(sidePanel);
|
auto *sideOuterLayout = new QVBoxLayout(sidePanel);
|
||||||
sideOuterLayout->setContentsMargins(8, 0, 0, 0);
|
sideOuterLayout->setContentsMargins(8, 0, 0, 0);
|
||||||
sideOuterLayout->setSpacing(0);
|
sideOuterLayout->setSpacing(0);
|
||||||
@@ -1381,7 +1527,11 @@ void MainWindow::buildUi()
|
|||||||
connect(m_clearButton, &QPushButton::clicked, this, &MainWindow::clearPoints);
|
connect(m_clearButton, &QPushButton::clicked, this, &MainWindow::clearPoints);
|
||||||
connect(m_openLogButton, &QPushButton::clicked, this, &MainWindow::openTelemetryLog);
|
connect(m_openLogButton, &QPushButton::clicked, this, &MainWindow::openTelemetryLog);
|
||||||
connect(m_replayButton, &QPushButton::clicked, this, &MainWindow::openUartReplay);
|
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<int>(&QSpinBox::valueChanged), this,
|
connect(m_maxPointsSpin, qOverload<int>(&QSpinBox::valueChanged), this,
|
||||||
&MainWindow::trimToMaxPoints);
|
&MainWindow::trimToMaxPoints);
|
||||||
connect(m_autoFollowCheck, &QCheckBox::toggled, m_map, &MapWidget::setAutoFollow);
|
connect(m_autoFollowCheck, &QCheckBox::toggled, m_map, &MapWidget::setAutoFollow);
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ private slots:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
void buildUi();
|
void buildUi();
|
||||||
|
void buildMenus();
|
||||||
|
void loadSettings();
|
||||||
|
void saveSettings() const;
|
||||||
void updateConnectionControls();
|
void updateConnectionControls();
|
||||||
void addTelemetryPoint(const ParsedTelemetry &telemetry);
|
void addTelemetryPoint(const ParsedTelemetry &telemetry);
|
||||||
void refreshTable();
|
void refreshTable();
|
||||||
@@ -89,6 +92,7 @@ private:
|
|||||||
QPushButton *m_clearButton = nullptr;
|
QPushButton *m_clearButton = nullptr;
|
||||||
QPushButton *m_openLogButton = nullptr;
|
QPushButton *m_openLogButton = nullptr;
|
||||||
QPushButton *m_replayButton = nullptr;
|
QPushButton *m_replayButton = nullptr;
|
||||||
|
QPushButton *m_rinexUpdateButton = nullptr;
|
||||||
QPushButton *m_refreshRinexButton = nullptr;
|
QPushButton *m_refreshRinexButton = nullptr;
|
||||||
QCheckBox *m_autoFollowCheck = nullptr;
|
QCheckBox *m_autoFollowCheck = nullptr;
|
||||||
QCheckBox *m_showTelemetryCheck = nullptr;
|
QCheckBox *m_showTelemetryCheck = nullptr;
|
||||||
@@ -113,6 +117,7 @@ private:
|
|||||||
QPlainTextEdit *m_log = nullptr;
|
QPlainTextEdit *m_log = nullptr;
|
||||||
QTimer *m_locationTimeoutTimer = nullptr;
|
QTimer *m_locationTimeoutTimer = nullptr;
|
||||||
QTimer *m_uiRefreshTimer = nullptr;
|
QTimer *m_uiRefreshTimer = nullptr;
|
||||||
|
QTimer *m_portRefreshTimer = nullptr;
|
||||||
QFile *m_uartLogFile = nullptr;
|
QFile *m_uartLogFile = nullptr;
|
||||||
QString m_uartLogPath;
|
QString m_uartLogPath;
|
||||||
|
|
||||||
@@ -125,4 +130,6 @@ private:
|
|||||||
DopValues m_lastDop;
|
DopValues m_lastDop;
|
||||||
ParsedTelemetry m_pendingTelemetry;
|
ParsedTelemetry m_pendingTelemetry;
|
||||||
bool m_telemetryRefreshPending = false;
|
bool m_telemetryRefreshPending = false;
|
||||||
|
bool m_portsInitialized = false;
|
||||||
|
QStringList m_lastDetectedPorts;
|
||||||
};
|
};
|
||||||
|
|||||||
+261
-54
@@ -1,9 +1,11 @@
|
|||||||
#include "MapWidget.h"
|
#include "MapWidget.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLineF>
|
#include <QLineF>
|
||||||
#include <QMouseEvent>
|
#include <QMouseEvent>
|
||||||
|
#include <QNativeGestureEvent>
|
||||||
#include <QNetworkAccessManager>
|
#include <QNetworkAccessManager>
|
||||||
#include <QNetworkDiskCache>
|
#include <QNetworkDiskCache>
|
||||||
#include <QNetworkRequest>
|
#include <QNetworkRequest>
|
||||||
@@ -13,6 +15,7 @@
|
|||||||
#include <QResizeEvent>
|
#include <QResizeEvent>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
|
#include <QTimer>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
#include <QWheelEvent>
|
#include <QWheelEvent>
|
||||||
|
|
||||||
@@ -25,6 +28,8 @@ constexpr int TileSize = 256;
|
|||||||
constexpr int MinZoom = 2;
|
constexpr int MinZoom = 2;
|
||||||
constexpr int MaxZoom = 19;
|
constexpr int MaxZoom = 19;
|
||||||
constexpr int MaxMemoryTileCount = 512;
|
constexpr int MaxMemoryTileCount = 512;
|
||||||
|
constexpr int InitialTileRetryDelayMs = 250;
|
||||||
|
constexpr int MaximumTileRetryDelayMs = 8000;
|
||||||
constexpr double Pi = 3.14159265358979323846;
|
constexpr double Pi = 3.14159265358979323846;
|
||||||
constexpr double WebMercatorRadiusMeters = 6378137.0;
|
constexpr double WebMercatorRadiusMeters = 6378137.0;
|
||||||
constexpr double MaxMercatorLatitude = 85.05112878;
|
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);
|
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)
|
double clampLatitude(double latitude)
|
||||||
{
|
{
|
||||||
return std::clamp(latitude, -MaxMercatorLatitude, MaxMercatorLatitude);
|
return std::clamp(latitude, -MaxMercatorLatitude, MaxMercatorLatitude);
|
||||||
@@ -52,27 +65,16 @@ double metersPerPixel(double latitude, int zoom)
|
|||||||
return latitudeScale * 2.0 * Pi * WebMercatorRadiusMeters / scale;
|
return latitudeScale * 2.0 * Pi * WebMercatorRadiusMeters / scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
int preferredCurrentPointIndex(const QVector<TelemetryPoint> &points)
|
|
||||||
{
|
|
||||||
constexpr qint64 CorrectedPreferenceWindowMs = 1000;
|
|
||||||
const int newestIndex = points.size() - 1;
|
|
||||||
for (int index = points.size() - 1; index >= 0; --index) {
|
|
||||||
if (points[index].source == LocationSource::ImuCorrected) {
|
|
||||||
const qint64 ageMs = points[index].receivedAt.msecsTo(
|
|
||||||
points[newestIndex].receivedAt);
|
|
||||||
return ageMs <= CorrectedPreferenceWindowMs ? index : newestIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return newestIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
MapWidget::MapWidget(QWidget *parent)
|
MapWidget::MapWidget(QWidget *parent)
|
||||||
: QWidget(parent)
|
: QWidget(parent)
|
||||||
, m_network(new QNetworkAccessManager(this))
|
, 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);
|
setMouseTracking(true);
|
||||||
|
|
||||||
const QString cacheRoot = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
|
const QString cacheRoot = QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
|
||||||
@@ -104,9 +106,9 @@ void MapWidget::setPoints(const QVector<TelemetryPoint> &points)
|
|||||||
m_points = points;
|
m_points = points;
|
||||||
|
|
||||||
if (!m_points.isEmpty() && m_autoFollow) {
|
if (!m_points.isEmpty() && m_autoFollow) {
|
||||||
const TelemetryPoint ¤t = m_points[preferredCurrentPointIndex(m_points)];
|
const TelemetryPoint &focus = m_points[preferredFocusIndex()];
|
||||||
m_centerLat = current.latitude;
|
m_centerLat = focus.latitude;
|
||||||
m_centerLon = current.longitude;
|
m_centerLon = focus.longitude;
|
||||||
if (wasEmpty) {
|
if (wasEmpty) {
|
||||||
m_zoom = MaxZoom;
|
m_zoom = MaxZoom;
|
||||||
}
|
}
|
||||||
@@ -129,23 +131,55 @@ void MapWidget::fitToPoints()
|
|||||||
}
|
}
|
||||||
double minimumLatitude = m_points.first().latitude;
|
double minimumLatitude = m_points.first().latitude;
|
||||||
double maximumLatitude = minimumLatitude;
|
double maximumLatitude = minimumLatitude;
|
||||||
double minimumLongitude = m_points.first().longitude;
|
QVector<double> longitudes;
|
||||||
double maximumLongitude = minimumLongitude;
|
longitudes.reserve(m_points.size());
|
||||||
for (const TelemetryPoint &point : m_points) {
|
for (const TelemetryPoint &point : m_points) {
|
||||||
minimumLatitude = std::min(minimumLatitude, point.latitude);
|
minimumLatitude = std::min(minimumLatitude, point.latitude);
|
||||||
maximumLatitude = std::max(maximumLatitude, point.latitude);
|
maximumLatitude = std::max(maximumLatitude, point.latitude);
|
||||||
minimumLongitude = std::min(minimumLongitude, point.longitude);
|
double normalizedLongitude = std::fmod(point.longitude + 360.0, 360.0);
|
||||||
maximumLongitude = std::max(maximumLongitude, point.longitude);
|
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_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 availableWidth = std::max(100.0, width() * 0.82);
|
||||||
const double availableHeight = std::max(100.0, height() * 0.82);
|
const double availableHeight = std::max(100.0, height() * 0.82);
|
||||||
for (int zoom = MaxZoom; zoom >= MinZoom; --zoom) {
|
for (int zoom = MaxZoom; zoom >= MinZoom; --zoom) {
|
||||||
const QPointF topLeft = coordinateToWorld(maximumLatitude, minimumLongitude, zoom);
|
const double worldWidth = static_cast<double>(TileSize) * static_cast<double>(1 << zoom);
|
||||||
const QPointF bottomRight = coordinateToWorld(minimumLatitude, maximumLongitude, zoom);
|
const double longitudeWidth = longitudeSpan / 360.0 * worldWidth;
|
||||||
if (std::fabs(bottomRight.x() - topLeft.x()) <= availableWidth
|
const double top = coordinateToWorld(maximumLatitude, m_centerLon, zoom).y();
|
||||||
&& std::fabs(bottomRight.y() - topLeft.y()) <= availableHeight) {
|
const double bottom = coordinateToWorld(minimumLatitude, m_centerLon, zoom).y();
|
||||||
|
if (longitudeWidth <= availableWidth
|
||||||
|
&& std::fabs(bottom - top) <= availableHeight) {
|
||||||
m_zoom = zoom;
|
m_zoom = zoom;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -218,9 +252,9 @@ void MapWidget::setAutoFollow(bool enabled)
|
|||||||
|
|
||||||
m_autoFollow = enabled;
|
m_autoFollow = enabled;
|
||||||
if (m_autoFollow && !m_points.isEmpty()) {
|
if (m_autoFollow && !m_points.isEmpty()) {
|
||||||
const TelemetryPoint ¤t = m_points[preferredCurrentPointIndex(m_points)];
|
const TelemetryPoint &focus = m_points[preferredFocusIndex()];
|
||||||
m_centerLat = current.latitude;
|
m_centerLat = focus.latitude;
|
||||||
m_centerLon = current.longitude;
|
m_centerLon = focus.longitude;
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
emit autoFollowChanged(m_autoFollow);
|
emit autoFollowChanged(m_autoFollow);
|
||||||
@@ -279,16 +313,52 @@ void MapWidget::paintEvent(QPaintEvent *)
|
|||||||
|
|
||||||
void MapWidget::wheelEvent(QWheelEvent *event)
|
void MapWidget::wheelEvent(QWheelEvent *event)
|
||||||
{
|
{
|
||||||
const int delta = event->angleDelta().y();
|
const QPoint pixelDelta = event->pixelDelta();
|
||||||
if (delta == 0) {
|
const QPoint angleDelta = event->angleDelta();
|
||||||
|
const double delta = !pixelDelta.isNull()
|
||||||
|
? pixelDelta.y() / 36.0
|
||||||
|
: angleDelta.y() / 120.0;
|
||||||
|
if (qFuzzyIsNull(delta)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
disableAutoFollowFromUserAction();
|
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();
|
event->accept();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool MapWidget::event(QEvent *event)
|
||||||
|
{
|
||||||
|
if (event->type() == QEvent::NativeGesture) {
|
||||||
|
auto *gesture = static_cast<QNativeGestureEvent *>(event);
|
||||||
|
if (gesture->gestureType() == Qt::ZoomNativeGesture) {
|
||||||
|
disableAutoFollowFromUserAction();
|
||||||
|
// macOS reports pinch magnification as small deltas. Accumulate
|
||||||
|
// them so map-tile zoom remains stable instead of flickering.
|
||||||
|
m_wheelZoomAccumulator += gesture->value() * 5.0;
|
||||||
|
while (std::abs(m_wheelZoomAccumulator) >= 1.0) {
|
||||||
|
const int direction = m_wheelZoomAccumulator > 0.0 ? 1 : -1;
|
||||||
|
setZoomKeepingPoint(m_zoom + direction, gesture->position());
|
||||||
|
m_wheelZoomAccumulator -= direction;
|
||||||
|
}
|
||||||
|
gesture->accept();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (gesture->gestureType() == Qt::EndNativeGesture) {
|
||||||
|
m_wheelZoomAccumulator = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return QWidget::event(event);
|
||||||
|
}
|
||||||
|
|
||||||
void MapWidget::mousePressEvent(QMouseEvent *event)
|
void MapWidget::mousePressEvent(QMouseEvent *event)
|
||||||
{
|
{
|
||||||
if (event->button() == Qt::RightButton) {
|
if (event->button() == Qt::RightButton) {
|
||||||
@@ -405,6 +475,15 @@ void MapWidget::drawTiles(QPainter &painter)
|
|||||||
|
|
||||||
painter.setRenderHint(QPainter::SmoothPixmapTransform, false);
|
painter.setRenderHint(QPainter::SmoothPixmapTransform, false);
|
||||||
|
|
||||||
|
struct MissingTile {
|
||||||
|
int x;
|
||||||
|
int y;
|
||||||
|
double distanceFromCenter;
|
||||||
|
};
|
||||||
|
QVector<MissingTile> missingTiles;
|
||||||
|
QSet<QString> visibleTileKeys;
|
||||||
|
const QPointF viewportCenter = topLeft + QPointF(width() / 2.0, height() / 2.0);
|
||||||
|
|
||||||
for (int tileY = firstTileY; tileY <= lastTileY; ++tileY) {
|
for (int tileY = firstTileY; tileY <= lastTileY; ++tileY) {
|
||||||
if (tileY < 0 || tileY >= tileCount) {
|
if (tileY < 0 || tileY >= tileCount) {
|
||||||
continue;
|
continue;
|
||||||
@@ -413,6 +492,7 @@ void MapWidget::drawTiles(QPainter &painter)
|
|||||||
for (int tileX = firstTileX; tileX <= lastTileX; ++tileX) {
|
for (int tileX = firstTileX; tileX <= lastTileX; ++tileX) {
|
||||||
const int wrappedTileX = positiveModulo(tileX, tileCount);
|
const int wrappedTileX = positiveModulo(tileX, tileCount);
|
||||||
const QString key = tileKey(m_zoom, wrappedTileX, tileY);
|
const QString key = tileKey(m_zoom, wrappedTileX, tileY);
|
||||||
|
visibleTileKeys.insert(key);
|
||||||
const QRectF target(
|
const QRectF target(
|
||||||
tileX * TileSize - topLeft.x(),
|
tileX * TileSize - topLeft.x(),
|
||||||
tileY * TileSize - topLeft.y(),
|
tileY * TileSize - topLeft.y(),
|
||||||
@@ -421,15 +501,57 @@ void MapWidget::drawTiles(QPainter &painter)
|
|||||||
|
|
||||||
const auto tileIt = m_tileCache.constFind(key);
|
const auto tileIt = m_tileCache.constFind(key);
|
||||||
if (tileIt != m_tileCache.constEnd()) {
|
if (tileIt != m_tileCache.constEnd()) {
|
||||||
painter.drawPixmap(target.toRect(), *tileIt);
|
painter.drawPixmap(target, *tileIt, QRectF(tileIt->rect()));
|
||||||
} else {
|
} else {
|
||||||
|
if (!drawCachedTileOrParent(painter, target, wrappedTileX, tileY, m_zoom)) {
|
||||||
painter.fillRect(target, QColor(230, 234, 230));
|
painter.fillRect(target, QColor(230, 234, 230));
|
||||||
painter.setPen(QColor(198, 205, 200));
|
painter.setPen(QColor(198, 205, 200));
|
||||||
painter.drawRect(target);
|
painter.drawRect(target);
|
||||||
requestTile(wrappedTileX, tileY, m_zoom);
|
}
|
||||||
|
const QPointF tileCenter((tileX + 0.5) * TileSize,
|
||||||
|
(tileY + 0.5) * TileSize);
|
||||||
|
const QPointF offset = tileCenter - viewportCenter;
|
||||||
|
missingTiles.push_back({wrappedTileX, tileY,
|
||||||
|
offset.x() * offset.x() + offset.y() * offset.y()});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A fast pan or auto-follow jump can otherwise leave old requests ahead of
|
||||||
|
// the tiles that are visible now. Cancel those requests and enqueue the
|
||||||
|
// centre tiles first so the viewport fills in promptly.
|
||||||
|
cancelInvisibleTileRequests(visibleTileKeys);
|
||||||
|
std::sort(missingTiles.begin(), missingTiles.end(),
|
||||||
|
[](const MissingTile &left, const MissingTile &right) {
|
||||||
|
return left.distanceFromCenter < right.distanceFromCenter;
|
||||||
|
});
|
||||||
|
for (const MissingTile &tile : missingTiles) {
|
||||||
|
requestTile(tile.x, tile.y, m_zoom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MapWidget::drawCachedTileOrParent(QPainter &painter, const QRectF &target,
|
||||||
|
int tileX, int tileY, int zoom) const
|
||||||
|
{
|
||||||
|
for (int parentZoom = zoom - 1; parentZoom >= MinZoom; --parentZoom) {
|
||||||
|
const int scale = 1 << (zoom - parentZoom);
|
||||||
|
const int parentX = tileX / scale;
|
||||||
|
const int parentY = tileY / scale;
|
||||||
|
const auto parentIt = m_tileCache.constFind(tileKey(parentZoom, parentX, parentY));
|
||||||
|
if (parentIt == m_tileCache.constEnd()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QPixmap &parent = *parentIt;
|
||||||
|
const double sourceWidth = static_cast<double>(parent.width()) / scale;
|
||||||
|
const double sourceHeight = static_cast<double>(parent.height()) / scale;
|
||||||
|
const QRectF source((tileX % scale) * sourceWidth,
|
||||||
|
(tileY % scale) * sourceHeight,
|
||||||
|
sourceWidth, sourceHeight);
|
||||||
|
painter.drawPixmap(target, parent, source);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void MapWidget::drawTelemetry(QPainter &painter)
|
void MapWidget::drawTelemetry(QPainter &painter)
|
||||||
@@ -472,11 +594,10 @@ void MapWidget::drawTelemetry(QPainter &painter)
|
|||||||
drawCepMarker(painter);
|
drawCepMarker(painter);
|
||||||
|
|
||||||
const bool drawNumbers = m_points.size() <= 150;
|
const bool drawNumbers = m_points.size() <= 150;
|
||||||
const int currentPointIndex = preferredCurrentPointIndex(m_points);
|
const int focusIndex = preferredFocusIndex();
|
||||||
const int firstPoint = m_showRecentPoints ? 0 : currentPointIndex;
|
for (int i = 0; i < m_points.size(); ++i) {
|
||||||
const int endPoint = m_showRecentPoints ? m_points.size() : currentPointIndex + 1;
|
if (!m_showRecentPoints && i != focusIndex) continue;
|
||||||
for (int i = firstPoint; i < endPoint; ++i) {
|
const bool isLast = i == focusIndex;
|
||||||
const bool isLast = i == currentPointIndex;
|
|
||||||
const QPointF screen = coordinateToScreen(m_points[i].latitude, m_points[i].longitude);
|
const QPointF screen = coordinateToScreen(m_points[i].latitude, m_points[i].longitude);
|
||||||
const double radius = isLast ? 8.0 : 5.0;
|
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);
|
const QPointF lastScreen = coordinateToScreen(last.latitude, last.longitude);
|
||||||
QStringList lines;
|
QStringList lines;
|
||||||
const QString latitudeText = last.latitudeText.isEmpty()
|
const QString latitudeText = last.latitudeText.isEmpty()
|
||||||
@@ -607,9 +728,10 @@ void MapWidget::drawCepMarker(QPainter &painter)
|
|||||||
painter.drawLine(QPointF(screen.x(), screen.y() - 11.0),
|
painter.drawLine(QPointF(screen.x(), screen.y() - 11.0),
|
||||||
QPointF(screen.x(), screen.y() + 11.0));
|
QPointF(screen.x(), screen.y() + 11.0));
|
||||||
|
|
||||||
const bool overlapsLast = !m_points.isEmpty()
|
const int focusIndex = preferredFocusIndex();
|
||||||
&& QLineF(screen, coordinateToScreen(m_points.last().latitude,
|
const bool overlapsLast = focusIndex >= 0
|
||||||
m_points.last().longitude)).length() < 30.0;
|
&& QLineF(screen, coordinateToScreen(m_points[focusIndex].latitude,
|
||||||
|
m_points[focusIndex].longitude)).length() < 30.0;
|
||||||
if (!overlapsLast) {
|
if (!overlapsLast) {
|
||||||
painter.setFont(QFont(painter.font().family(), 9, QFont::Bold));
|
painter.setFont(QFont(painter.font().family(), 9, QFont::Bold));
|
||||||
painter.setPen(QColor(20, 83, 78));
|
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() - 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));
|
painter.drawLine(QPointF(screen.x(), screen.y() - 12.0), QPointF(screen.x(), screen.y() + 12.0));
|
||||||
|
|
||||||
const bool overlapsLast = !m_points.isEmpty()
|
const int focusIndex = preferredFocusIndex();
|
||||||
&& QLineF(screen, coordinateToScreen(m_points.last().latitude,
|
const bool overlapsLast = focusIndex >= 0
|
||||||
m_points.last().longitude)).length() < 30.0;
|
&& QLineF(screen, coordinateToScreen(m_points[focusIndex].latitude,
|
||||||
|
m_points[focusIndex].longitude)).length() < 30.0;
|
||||||
if (!overlapsLast) {
|
if (!overlapsLast) {
|
||||||
painter.setFont(QFont(painter.font().family(), 9, QFont::Bold));
|
painter.setFont(QFont(painter.font().family(), 9, QFont::Bold));
|
||||||
const QRectF labelRect(screen + QPointF(12.0, 8.0), QSizeF(72.0, 20.0));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const qint64 now = QDateTime::currentMSecsSinceEpoch();
|
||||||
|
if (m_tileRetryAfterMs.value(key, 0) > now) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
m_pendingTiles.insert(key);
|
m_pendingTiles.insert(key);
|
||||||
const QUrl url(QStringLiteral("https://tile.openstreetmap.org/%1/%2/%3.png")
|
QNetworkRequest request(tileUrl(m_tileUrlTemplate, zoom, tileX, tileY));
|
||||||
.arg(zoom)
|
request.setRawHeader("User-Agent", "RocketLocationPlotter/0.3 (Qt desktop application)");
|
||||||
.arg(tileX)
|
request.setTransferTimeout(15000);
|
||||||
.arg(tileY));
|
|
||||||
QNetworkRequest request(url);
|
|
||||||
request.setRawHeader("User-Agent", "RocketLocationPlotter/0.2 (Qt desktop application)");
|
|
||||||
QNetworkReply *reply = m_network->get(request);
|
QNetworkReply *reply = m_network->get(request);
|
||||||
reply->setProperty("tileKey", key);
|
reply->setProperty("tileKey", key);
|
||||||
|
m_tileReplies.insert(key, reply);
|
||||||
|
|
||||||
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
|
||||||
const QString key = reply->property("tileKey").toString();
|
const QString key = reply->property("tileKey").toString();
|
||||||
m_pendingTiles.remove(key);
|
m_pendingTiles.remove(key);
|
||||||
|
if (m_tileReplies.value(key) == reply) {
|
||||||
|
m_tileReplies.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool loaded = false;
|
||||||
if (reply->error() == QNetworkReply::NoError) {
|
if (reply->error() == QNetworkReply::NoError) {
|
||||||
QPixmap pixmap;
|
QPixmap pixmap;
|
||||||
if (pixmap.loadFromData(reply->readAll())) {
|
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.erase(m_tileCache.begin());
|
||||||
}
|
}
|
||||||
m_tileCache.insert(key, pixmap);
|
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();
|
reply->deleteLater();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MapWidget::cancelInvisibleTileRequests(const QSet<QString> &visibleTileKeys)
|
||||||
|
{
|
||||||
|
QVector<QNetworkReply *> obsoleteReplies;
|
||||||
|
for (auto it = m_tileReplies.cbegin(); it != m_tileReplies.cend(); ++it) {
|
||||||
|
if (!visibleTileKeys.contains(it.key())) {
|
||||||
|
obsoleteReplies.push_back(it.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (QNetworkReply *reply : obsoleteReplies) {
|
||||||
|
if (reply == nullptr || reply->isFinished()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
reply->setProperty("tileCancelled", true);
|
||||||
|
reply->abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto it = m_tileRetryAfterMs.begin(); it != m_tileRetryAfterMs.end();) {
|
||||||
|
if (visibleTileKeys.contains(it.key())) {
|
||||||
|
++it;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
m_tileFailureCounts.remove(it.key());
|
||||||
|
it = m_tileRetryAfterMs.erase(it);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MapWidget::repaintWhenTileRetryIsReady(const QString &key)
|
||||||
|
{
|
||||||
|
if (!m_tileRetryAfterMs.contains(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const qint64 remaining = m_tileRetryAfterMs.value(key, 0)
|
||||||
|
- QDateTime::currentMSecsSinceEpoch();
|
||||||
|
if (remaining > 0) {
|
||||||
|
QTimer::singleShot(static_cast<int>(remaining), this, [this, key]() {
|
||||||
|
repaintWhenTileRetryIsReady(key);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
void MapWidget::setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor)
|
void MapWidget::setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor)
|
||||||
{
|
{
|
||||||
newZoom = std::clamp(newZoom, MinZoom, MaxZoom);
|
newZoom = std::clamp(newZoom, MinZoom, MaxZoom);
|
||||||
@@ -725,3 +918,17 @@ void MapWidget::disableAutoFollowFromUserAction()
|
|||||||
m_autoFollow = false;
|
m_autoFollow = false;
|
||||||
emit autoFollowChanged(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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
class QNetworkAccessManager;
|
class QNetworkAccessManager;
|
||||||
|
class QNetworkReply;
|
||||||
class QLabel;
|
class QLabel;
|
||||||
|
|
||||||
class MapWidget : public QWidget {
|
class MapWidget : public QWidget {
|
||||||
@@ -42,6 +43,7 @@ signals:
|
|||||||
void referenceLocationSelected(double latitude, double longitude);
|
void referenceLocationSelected(double latitude, double longitude);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
|
bool event(QEvent *event) override;
|
||||||
void paintEvent(QPaintEvent *event) override;
|
void paintEvent(QPaintEvent *event) override;
|
||||||
void wheelEvent(QWheelEvent *event) override;
|
void wheelEvent(QWheelEvent *event) override;
|
||||||
void mousePressEvent(QMouseEvent *event) override;
|
void mousePressEvent(QMouseEvent *event) override;
|
||||||
@@ -63,15 +65,24 @@ private:
|
|||||||
void drawCepOverlay(QPainter &painter);
|
void drawCepOverlay(QPainter &painter);
|
||||||
void drawCepMarker(QPainter &painter);
|
void drawCepMarker(QPainter &painter);
|
||||||
void drawEmptyState(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 requestTile(int tileX, int tileY, int zoom);
|
||||||
|
void cancelInvisibleTileRequests(const QSet<QString> &visibleTileKeys);
|
||||||
|
void repaintWhenTileRetryIsReady(const QString &key);
|
||||||
void setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor);
|
void setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor);
|
||||||
void disableAutoFollowFromUserAction();
|
void disableAutoFollowFromUserAction();
|
||||||
|
int preferredFocusIndex() const;
|
||||||
|
|
||||||
QVector<TelemetryPoint> m_points;
|
QVector<TelemetryPoint> m_points;
|
||||||
QNetworkAccessManager *m_network = nullptr;
|
QNetworkAccessManager *m_network = nullptr;
|
||||||
QLabel *m_attributionLabel = nullptr;
|
QLabel *m_attributionLabel = nullptr;
|
||||||
QHash<QString, QPixmap> m_tileCache;
|
QHash<QString, QPixmap> m_tileCache;
|
||||||
QSet<QString> m_pendingTiles;
|
QSet<QString> m_pendingTiles;
|
||||||
|
QHash<QString, QNetworkReply *> m_tileReplies;
|
||||||
|
QHash<QString, int> m_tileFailureCounts;
|
||||||
|
QHash<QString, qint64> m_tileRetryAfterMs;
|
||||||
|
QString m_tileUrlTemplate;
|
||||||
|
|
||||||
double m_centerLat = 39.0;
|
double m_centerLat = 39.0;
|
||||||
double m_centerLon = 35.0;
|
double m_centerLon = 35.0;
|
||||||
@@ -90,4 +101,5 @@ private:
|
|||||||
std::optional<double> m_cepRadiusMeters;
|
std::optional<double> m_cepRadiusMeters;
|
||||||
bool m_dragging = false;
|
bool m_dragging = false;
|
||||||
QPoint m_lastMousePos;
|
QPoint m_lastMousePos;
|
||||||
|
double m_wheelZoomAccumulator = 0.0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <QNetworkReply>
|
#include <QNetworkReply>
|
||||||
#include <QNetworkRequest>
|
#include <QNetworkRequest>
|
||||||
#include <QStandardPaths>
|
#include <QStandardPaths>
|
||||||
|
#include <QTimeZone>
|
||||||
#include <QUrl>
|
#include <QUrl>
|
||||||
|
|
||||||
#include <zlib.h>
|
#include <zlib.h>
|
||||||
@@ -148,7 +149,9 @@ void RinexNavigation::handleDownload(const QByteArray &compressed, const QDate &
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
if (!loadCache()) {
|
||||||
emit statusChanged(QStringLiteral("RINEX okunamadi: %1").arg(error), true);
|
emit statusChanged(QStringLiteral("RINEX okunamadi: %1").arg(error), true);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +183,7 @@ bool RinexNavigation::loadCache()
|
|||||||
emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX · %2")
|
emit statusChanged(QStringLiteral("%1 GPS uydusu · onbellekteki RINEX · %2")
|
||||||
.arg(positions.size())
|
.arg(positions.size())
|
||||||
.arg(now.toLocalTime().toString(
|
.arg(now.toLocalTime().toString(
|
||||||
QStringLiteral("HH:mm:ss t"))), true);
|
QStringLiteral("HH:mm:ss t"))), false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "../src/MapWidget.h"
|
#include "../src/MapWidget.h"
|
||||||
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
#include <QAction>
|
||||||
#include <QCheckBox>
|
#include <QCheckBox>
|
||||||
#include <QComboBox>
|
#include <QComboBox>
|
||||||
#include <QDoubleSpinBox>
|
#include <QDoubleSpinBox>
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
#include <QTemporaryDir>
|
#include <QTemporaryDir>
|
||||||
|
|
||||||
#include <cassert>
|
#include <cassert>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
@@ -88,6 +90,10 @@ int main(int argc, char **argv)
|
|||||||
auto *map = window.findChild<MapWidget *>();
|
auto *map = window.findChild<MapWidget *>();
|
||||||
auto *mainViewTabs = window.findChild<QTabWidget *>(QStringLiteral("mainViewTabs"));
|
auto *mainViewTabs = window.findChild<QTabWidget *>(QStringLiteral("mainViewTabs"));
|
||||||
auto *sideSplitter = window.findChild<QSplitter *>(QStringLiteral("sideSplitter"));
|
auto *sideSplitter = window.findChild<QSplitter *>(QStringLiteral("sideSplitter"));
|
||||||
|
auto *rinexUpdateButton = window.findChild<QPushButton *>(QStringLiteral("rinexUpdateButton"));
|
||||||
|
auto *openLogAction = window.findChild<QAction *>(QStringLiteral("openLogAction"));
|
||||||
|
auto *toggleSerialAction = window.findChild<QAction *>(QStringLiteral("toggleSerialAction"));
|
||||||
|
auto *rinexUpdateAction = window.findChild<QAction *>(QStringLiteral("rinexUpdateAction"));
|
||||||
assert(table != nullptr);
|
assert(table != nullptr);
|
||||||
assert(referenceLatInput != nullptr);
|
assert(referenceLatInput != nullptr);
|
||||||
assert(referenceLonInput != nullptr);
|
assert(referenceLonInput != nullptr);
|
||||||
@@ -118,6 +124,32 @@ int main(int argc, char **argv)
|
|||||||
assert(mainViewTabs->count() == 2);
|
assert(mainViewTabs->count() == 2);
|
||||||
assert(sideSplitter != nullptr);
|
assert(sideSplitter != nullptr);
|
||||||
assert(sideSplitter->orientation() == Qt::Vertical);
|
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<TelemetryPoint> followPoints;
|
||||||
|
TelemetryPoint gpsPoint;
|
||||||
|
gpsPoint.source = LocationSource::Gps;
|
||||||
|
gpsPoint.latitude = 40.0;
|
||||||
|
gpsPoint.longitude = 29.0;
|
||||||
|
followPoints.push_back(gpsPoint);
|
||||||
|
TelemetryPoint correctedPoint = gpsPoint;
|
||||||
|
correctedPoint.source = LocationSource::ImuCorrected;
|
||||||
|
correctedPoint.latitude = 40.25;
|
||||||
|
correctedPoint.longitude = 29.5;
|
||||||
|
followPoints.push_back(correctedPoint);
|
||||||
|
TelemetryPoint trajectoryPoint = gpsPoint;
|
||||||
|
trajectoryPoint.source = LocationSource::Trajectory;
|
||||||
|
trajectoryPoint.latitude = 41.0;
|
||||||
|
trajectoryPoint.longitude = 30.0;
|
||||||
|
followPoints.push_back(trajectoryPoint);
|
||||||
|
map->setPoints(followPoints);
|
||||||
|
assert(std::abs(map->centerCoordinate().x() - 40.25) < 0.000001);
|
||||||
|
assert(std::abs(map->centerCoordinate().y() - 29.5) < 0.000001);
|
||||||
|
map->clearPoints();
|
||||||
|
|
||||||
sendLine(window, QStringLiteral("[TRK] tracked satellites=2 prn_indices=[2,7]"));
|
sendLine(window, QStringLiteral("[TRK] tracked satellites=2 prn_indices=[2,7]"));
|
||||||
assert(map != nullptr);
|
assert(map != nullptr);
|
||||||
@@ -185,6 +217,24 @@ int main(int argc, char **argv)
|
|||||||
assert(table->model()->data(table->model()->index(0, 1)).toString()
|
assert(table->model()->data(table->model()->index(0, 1)).toString()
|
||||||
== QStringLiteral("Trajectory (truth)"));
|
== 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));
|
assert(QMetaObject::invokeMethod(&window, "clearPoints", Qt::DirectConnection));
|
||||||
sendLine(window, QStringLiteral(
|
sendLine(window, QStringLiteral(
|
||||||
"lat=40.78925034214206846173 long=29.46277530841727809729"));
|
"lat=40.78925034214206846173 long=29.46277530841727809729"));
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#include "../src/MapWidget.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QElapsedTimer>
|
||||||
|
#include <QHostAddress>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QTcpServer>
|
||||||
|
#include <QTcpSocket>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#include <cassert>
|
||||||
|
|
||||||
|
int main(int argc, char **argv)
|
||||||
|
{
|
||||||
|
QApplication app(argc, argv);
|
||||||
|
|
||||||
|
QTcpServer tileServer;
|
||||||
|
assert(tileServer.listen(QHostAddress::LocalHost, 0));
|
||||||
|
|
||||||
|
int requestCount = 0;
|
||||||
|
QSet<QByteArray> requestedPaths;
|
||||||
|
QObject::connect(&tileServer, &QTcpServer::newConnection, [&]() {
|
||||||
|
while (tileServer.hasPendingConnections()) {
|
||||||
|
QTcpSocket *socket = tileServer.nextPendingConnection();
|
||||||
|
const auto respond = [&, socket]() {
|
||||||
|
if (!socket->canReadLine()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const QByteArray requestLine = socket->readLine().trimmed();
|
||||||
|
const QList<QByteArray> fields = requestLine.split(' ');
|
||||||
|
if (fields.size() >= 2) {
|
||||||
|
++requestCount;
|
||||||
|
requestedPaths.insert(fields[1]);
|
||||||
|
}
|
||||||
|
socket->readAll();
|
||||||
|
socket->write("HTTP/1.1 503 Service Unavailable\r\n"
|
||||||
|
"Content-Length: 0\r\n"
|
||||||
|
"Connection: close\r\n\r\n");
|
||||||
|
socket->disconnectFromHost();
|
||||||
|
};
|
||||||
|
QObject::connect(socket, &QTcpSocket::readyRead, socket, respond);
|
||||||
|
QObject::connect(socket, &QTcpSocket::disconnected,
|
||||||
|
socket, &QObject::deleteLater);
|
||||||
|
respond();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const QByteArray tileUrl = QByteArray("http://127.0.0.1:")
|
||||||
|
+ QByteArray::number(tileServer.serverPort()) + "/{z}/{x}/{y}.png";
|
||||||
|
qputenv("LOCATION_PLOTTER_TILE_URL_TEMPLATE", tileUrl);
|
||||||
|
|
||||||
|
{
|
||||||
|
MapWidget map;
|
||||||
|
map.resize(640, 480);
|
||||||
|
map.show();
|
||||||
|
|
||||||
|
QElapsedTimer timer;
|
||||||
|
timer.start();
|
||||||
|
while (timer.elapsed() < 1200) {
|
||||||
|
app.processEvents(QEventLoop::AllEvents, 50);
|
||||||
|
QThread::msleep(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every URL fails. Seeing any URL more than once proves that a failed
|
||||||
|
// tile schedules a new paint/request instead of remaining a blank
|
||||||
|
// placeholder forever.
|
||||||
|
assert(!requestedPaths.isEmpty());
|
||||||
|
assert(requestCount > requestedPaths.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
qunsetenv("LOCATION_PLOTTER_TILE_URL_TEMPLATE");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user