Fix incomplete map tile updates
Ubuntu build / build-and-test (ubuntu-22.04) (push) Has been cancelled
Ubuntu build / build-and-test (ubuntu-24.04) (push) Has been cancelled

This commit is contained in:
Onur Cayan
2026-08-10 00:51:00 +03:00
parent 745c7d91af
commit f7fa2b3e4d
4 changed files with 245 additions and 12 deletions
+14
View File
@@ -67,6 +67,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
+146 -9
View File
@@ -1,5 +1,6 @@
#include "MapWidget.h" #include "MapWidget.h"
#include <QDateTime>
#include <QDir> #include <QDir>
#include <QLabel> #include <QLabel>
#include <QLineF> #include <QLineF>
@@ -14,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>
@@ -26,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;
@@ -35,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);
@@ -58,6 +70,9 @@ double metersPerPixel(double latitude, int zoom)
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(480, 320); setMinimumSize(480, 320);
setMouseTracking(true); setMouseTracking(true);
@@ -460,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;
@@ -468,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(),
@@ -476,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)
@@ -726,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())) {
@@ -747,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);
+9
View File
@@ -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 {
@@ -64,7 +65,11 @@ 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; int preferredFocusIndex() const;
@@ -74,6 +79,10 @@ private:
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;
+73
View File
@@ -0,0 +1,73 @@
#include "../src/MapWidget.h"
#include <QApplication>
#include <QElapsedTimer>
#include <QHostAddress>
#include <QSet>
#include <QTcpServer>
#include <QTcpSocket>
#include <QThread>
#include <cassert>
int main(int argc, char **argv)
{
QApplication app(argc, argv);
QTcpServer tileServer;
assert(tileServer.listen(QHostAddress::LocalHost, 0));
int requestCount = 0;
QSet<QByteArray> requestedPaths;
QObject::connect(&tileServer, &QTcpServer::newConnection, [&]() {
while (tileServer.hasPendingConnections()) {
QTcpSocket *socket = tileServer.nextPendingConnection();
const auto respond = [&, socket]() {
if (!socket->canReadLine()) {
return;
}
const QByteArray requestLine = socket->readLine().trimmed();
const QList<QByteArray> fields = requestLine.split(' ');
if (fields.size() >= 2) {
++requestCount;
requestedPaths.insert(fields[1]);
}
socket->readAll();
socket->write("HTTP/1.1 503 Service Unavailable\r\n"
"Content-Length: 0\r\n"
"Connection: close\r\n\r\n");
socket->disconnectFromHost();
};
QObject::connect(socket, &QTcpSocket::readyRead, socket, respond);
QObject::connect(socket, &QTcpSocket::disconnected,
socket, &QObject::deleteLater);
respond();
}
});
const QByteArray tileUrl = QByteArray("http://127.0.0.1:")
+ QByteArray::number(tileServer.serverPort()) + "/{z}/{x}/{y}.png";
qputenv("LOCATION_PLOTTER_TILE_URL_TEMPLATE", tileUrl);
{
MapWidget map;
map.resize(640, 480);
map.show();
QElapsedTimer timer;
timer.start();
while (timer.elapsed() < 1200) {
app.processEvents(QEventLoop::AllEvents, 50);
QThread::msleep(5);
}
// Every URL fails. Seeing any URL more than once proves that a failed
// tile schedules a new paint/request instead of remaining a blank
// placeholder forever.
assert(!requestedPaths.isEmpty());
assert(requestCount > requestedPaths.size());
}
qunsetenv("LOCATION_PLOTTER_TILE_URL_TEMPLATE");
return 0;
}