diff --git a/CMakeLists.txt b/CMakeLists.txt index d82c11f..4ae8670 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -67,6 +67,20 @@ target_compile_options(location_parser_tests PRIVATE ) add_test(NAME location_parser_tests COMMAND location_parser_tests) +add_executable(location_map_widget_tests + tests/map_widget_tests.cpp + src/MapWidget.cpp + src/MapWidget.h + src/TelemetryPoint.h +) +target_link_libraries(location_map_widget_tests PRIVATE Qt6::Core Qt6::Widgets Qt6::Network) +target_compile_options(location_map_widget_tests PRIVATE + $<$:-UNDEBUG> +) +add_test(NAME location_map_widget_tests COMMAND location_map_widget_tests) +set_tests_properties(location_map_widget_tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen;XDG_CACHE_HOME=/tmp/location-plotter-map-test-cache") + add_executable(location_main_window_tests tests/main_window_tests.cpp src/LocationParser.cpp diff --git a/src/MapWidget.cpp b/src/MapWidget.cpp index f892e6b..24fea30 100644 --- a/src/MapWidget.cpp +++ b/src/MapWidget.cpp @@ -1,5 +1,6 @@ #include "MapWidget.h" +#include #include #include #include @@ -14,6 +15,7 @@ #include #include #include +#include #include #include @@ -26,6 +28,8 @@ constexpr int TileSize = 256; constexpr int MinZoom = 2; constexpr int MaxZoom = 19; constexpr int MaxMemoryTileCount = 512; +constexpr int InitialTileRetryDelayMs = 250; +constexpr int MaximumTileRetryDelayMs = 8000; constexpr double Pi = 3.14159265358979323846; constexpr double WebMercatorRadiusMeters = 6378137.0; constexpr double MaxMercatorLatitude = 85.05112878; @@ -35,6 +39,14 @@ QString tileKey(int zoom, int tileX, int tileY) return QStringLiteral("%1/%2/%3").arg(zoom).arg(tileX).arg(tileY); } +QUrl tileUrl(QString urlTemplate, int zoom, int tileX, int tileY) +{ + urlTemplate.replace(QStringLiteral("{z}"), QString::number(zoom)); + urlTemplate.replace(QStringLiteral("{x}"), QString::number(tileX)); + urlTemplate.replace(QStringLiteral("{y}"), QString::number(tileY)); + return QUrl(urlTemplate); +} + double clampLatitude(double latitude) { return std::clamp(latitude, -MaxMercatorLatitude, MaxMercatorLatitude); @@ -58,6 +70,9 @@ double metersPerPixel(double latitude, int zoom) MapWidget::MapWidget(QWidget *parent) : QWidget(parent) , m_network(new QNetworkAccessManager(this)) + , m_tileUrlTemplate(qEnvironmentVariable( + "LOCATION_PLOTTER_TILE_URL_TEMPLATE", + QStringLiteral("https://tile.openstreetmap.org/{z}/{x}/{y}.png"))) { setMinimumSize(480, 320); setMouseTracking(true); @@ -460,6 +475,15 @@ void MapWidget::drawTiles(QPainter &painter) painter.setRenderHint(QPainter::SmoothPixmapTransform, false); + struct MissingTile { + int x; + int y; + double distanceFromCenter; + }; + QVector missingTiles; + QSet visibleTileKeys; + const QPointF viewportCenter = topLeft + QPointF(width() / 2.0, height() / 2.0); + for (int tileY = firstTileY; tileY <= lastTileY; ++tileY) { if (tileY < 0 || tileY >= tileCount) { continue; @@ -468,6 +492,7 @@ void MapWidget::drawTiles(QPainter &painter) for (int tileX = firstTileX; tileX <= lastTileX; ++tileX) { const int wrappedTileX = positiveModulo(tileX, tileCount); const QString key = tileKey(m_zoom, wrappedTileX, tileY); + visibleTileKeys.insert(key); const QRectF target( tileX * TileSize - topLeft.x(), tileY * TileSize - topLeft.y(), @@ -476,15 +501,57 @@ void MapWidget::drawTiles(QPainter &painter) const auto tileIt = m_tileCache.constFind(key); if (tileIt != m_tileCache.constEnd()) { - painter.drawPixmap(target.toRect(), *tileIt); + painter.drawPixmap(target, *tileIt, QRectF(tileIt->rect())); } else { - painter.fillRect(target, QColor(230, 234, 230)); - painter.setPen(QColor(198, 205, 200)); - painter.drawRect(target); - requestTile(wrappedTileX, tileY, m_zoom); + if (!drawCachedTileOrParent(painter, target, wrappedTileX, tileY, m_zoom)) { + painter.fillRect(target, QColor(230, 234, 230)); + painter.setPen(QColor(198, 205, 200)); + painter.drawRect(target); + } + const QPointF tileCenter((tileX + 0.5) * TileSize, + (tileY + 0.5) * TileSize); + const QPointF offset = tileCenter - viewportCenter; + missingTiles.push_back({wrappedTileX, tileY, + offset.x() * offset.x() + offset.y() * offset.y()}); } } } + + // A fast pan or auto-follow jump can otherwise leave old requests ahead of + // the tiles that are visible now. Cancel those requests and enqueue the + // centre tiles first so the viewport fills in promptly. + cancelInvisibleTileRequests(visibleTileKeys); + std::sort(missingTiles.begin(), missingTiles.end(), + [](const MissingTile &left, const MissingTile &right) { + return left.distanceFromCenter < right.distanceFromCenter; + }); + for (const MissingTile &tile : missingTiles) { + requestTile(tile.x, tile.y, m_zoom); + } +} + +bool MapWidget::drawCachedTileOrParent(QPainter &painter, const QRectF &target, + int tileX, int tileY, int zoom) const +{ + for (int parentZoom = zoom - 1; parentZoom >= MinZoom; --parentZoom) { + const int scale = 1 << (zoom - parentZoom); + const int parentX = tileX / scale; + const int parentY = tileY / scale; + const auto parentIt = m_tileCache.constFind(tileKey(parentZoom, parentX, parentY)); + if (parentIt == m_tileCache.constEnd()) { + continue; + } + + const QPixmap &parent = *parentIt; + const double sourceWidth = static_cast(parent.width()) / scale; + const double sourceHeight = static_cast(parent.height()) / scale; + const QRectF source((tileX % scale) * sourceWidth, + (tileY % scale) * sourceHeight, + sourceWidth, sourceHeight); + painter.drawPixmap(target, parent, source); + return true; + } + return false; } void MapWidget::drawTelemetry(QPainter &painter) @@ -726,20 +793,27 @@ void MapWidget::requestTile(int tileX, int tileY, int zoom) return; } + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + if (m_tileRetryAfterMs.value(key, 0) > now) { + return; + } + m_pendingTiles.insert(key); - const QUrl url(QStringLiteral("https://tile.openstreetmap.org/%1/%2/%3.png") - .arg(zoom) - .arg(tileX) - .arg(tileY)); - QNetworkRequest request(url); - request.setRawHeader("User-Agent", "RocketLocationPlotter/0.2 (Qt desktop application)"); + QNetworkRequest request(tileUrl(m_tileUrlTemplate, zoom, tileX, tileY)); + request.setRawHeader("User-Agent", "RocketLocationPlotter/0.3 (Qt desktop application)"); + request.setTransferTimeout(15000); QNetworkReply *reply = m_network->get(request); reply->setProperty("tileKey", key); + m_tileReplies.insert(key, reply); connect(reply, &QNetworkReply::finished, this, [this, reply]() { const QString key = reply->property("tileKey").toString(); m_pendingTiles.remove(key); + if (m_tileReplies.value(key) == reply) { + m_tileReplies.remove(key); + } + bool loaded = false; if (reply->error() == QNetworkReply::NoError) { QPixmap pixmap; if (pixmap.loadFromData(reply->readAll())) { @@ -747,14 +821,77 @@ void MapWidget::requestTile(int tileX, int tileY, int zoom) m_tileCache.erase(m_tileCache.begin()); } m_tileCache.insert(key, pixmap); - update(); + m_tileFailureCounts.remove(key); + m_tileRetryAfterMs.remove(key); + loaded = true; } } + const bool cancelled = reply->property("tileCancelled").toBool(); + if (!loaded && !cancelled) { + const int failures = m_tileFailureCounts.value(key, 0) + 1; + m_tileFailureCounts.insert(key, failures); + const int shift = std::min(failures - 1, 5); + const int retryDelay = std::min(MaximumTileRetryDelayMs, + InitialTileRetryDelayMs * (1 << shift)); + m_tileRetryAfterMs.insert(key, + QDateTime::currentMSecsSinceEpoch() + retryDelay); + QTimer::singleShot(retryDelay, this, [this, key]() { + repaintWhenTileRetryIsReady(key); + }); + } + + if (loaded) { + update(); + } + reply->deleteLater(); }); } +void MapWidget::cancelInvisibleTileRequests(const QSet &visibleTileKeys) +{ + QVector obsoleteReplies; + for (auto it = m_tileReplies.cbegin(); it != m_tileReplies.cend(); ++it) { + if (!visibleTileKeys.contains(it.key())) { + obsoleteReplies.push_back(it.value()); + } + } + + for (QNetworkReply *reply : obsoleteReplies) { + if (reply == nullptr || reply->isFinished()) { + continue; + } + reply->setProperty("tileCancelled", true); + reply->abort(); + } + + for (auto it = m_tileRetryAfterMs.begin(); it != m_tileRetryAfterMs.end();) { + if (visibleTileKeys.contains(it.key())) { + ++it; + continue; + } + m_tileFailureCounts.remove(it.key()); + it = m_tileRetryAfterMs.erase(it); + } +} + +void MapWidget::repaintWhenTileRetryIsReady(const QString &key) +{ + if (!m_tileRetryAfterMs.contains(key)) { + return; + } + const qint64 remaining = m_tileRetryAfterMs.value(key, 0) + - QDateTime::currentMSecsSinceEpoch(); + if (remaining > 0) { + QTimer::singleShot(static_cast(remaining), this, [this, key]() { + repaintWhenTileRetryIsReady(key); + }); + return; + } + update(); +} + void MapWidget::setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor) { newZoom = std::clamp(newZoom, MinZoom, MaxZoom); diff --git a/src/MapWidget.h b/src/MapWidget.h index 3efcb3a..b40d5fd 100644 --- a/src/MapWidget.h +++ b/src/MapWidget.h @@ -11,6 +11,7 @@ #include class QNetworkAccessManager; +class QNetworkReply; class QLabel; class MapWidget : public QWidget { @@ -64,7 +65,11 @@ private: void drawCepOverlay(QPainter &painter); void drawCepMarker(QPainter &painter); void drawEmptyState(QPainter &painter); + bool drawCachedTileOrParent(QPainter &painter, const QRectF &target, + int tileX, int tileY, int zoom) const; void requestTile(int tileX, int tileY, int zoom); + void cancelInvisibleTileRequests(const QSet &visibleTileKeys); + void repaintWhenTileRetryIsReady(const QString &key); void setZoomKeepingPoint(int newZoom, const QPointF &screenAnchor); void disableAutoFollowFromUserAction(); int preferredFocusIndex() const; @@ -74,6 +79,10 @@ private: QLabel *m_attributionLabel = nullptr; QHash m_tileCache; QSet m_pendingTiles; + QHash m_tileReplies; + QHash m_tileFailureCounts; + QHash m_tileRetryAfterMs; + QString m_tileUrlTemplate; double m_centerLat = 39.0; double m_centerLon = 35.0; diff --git a/tests/map_widget_tests.cpp b/tests/map_widget_tests.cpp new file mode 100644 index 0000000..11f67ff --- /dev/null +++ b/tests/map_widget_tests.cpp @@ -0,0 +1,73 @@ +#include "../src/MapWidget.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +int main(int argc, char **argv) +{ + QApplication app(argc, argv); + + QTcpServer tileServer; + assert(tileServer.listen(QHostAddress::LocalHost, 0)); + + int requestCount = 0; + QSet requestedPaths; + QObject::connect(&tileServer, &QTcpServer::newConnection, [&]() { + while (tileServer.hasPendingConnections()) { + QTcpSocket *socket = tileServer.nextPendingConnection(); + const auto respond = [&, socket]() { + if (!socket->canReadLine()) { + return; + } + const QByteArray requestLine = socket->readLine().trimmed(); + const QList fields = requestLine.split(' '); + if (fields.size() >= 2) { + ++requestCount; + requestedPaths.insert(fields[1]); + } + socket->readAll(); + socket->write("HTTP/1.1 503 Service Unavailable\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n\r\n"); + socket->disconnectFromHost(); + }; + QObject::connect(socket, &QTcpSocket::readyRead, socket, respond); + QObject::connect(socket, &QTcpSocket::disconnected, + socket, &QObject::deleteLater); + respond(); + } + }); + + const QByteArray tileUrl = QByteArray("http://127.0.0.1:") + + QByteArray::number(tileServer.serverPort()) + "/{z}/{x}/{y}.png"; + qputenv("LOCATION_PLOTTER_TILE_URL_TEMPLATE", tileUrl); + + { + MapWidget map; + map.resize(640, 480); + map.show(); + + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() < 1200) { + app.processEvents(QEventLoop::AllEvents, 50); + QThread::msleep(5); + } + + // Every URL fails. Seeing any URL more than once proves that a failed + // tile schedules a new paint/request instead of remaining a blank + // placeholder forever. + assert(!requestedPaths.isEmpty()); + assert(requestCount > requestedPaths.size()); + } + + qunsetenv("LOCATION_PLOTTER_TILE_URL_TEMPLATE"); + return 0; +}