Fix incomplete map tile updates
This commit is contained in:
@@ -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
|
||||
$<$<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
|
||||
tests/main_window_tests.cpp
|
||||
src/LocationParser.cpp
|
||||
|
||||
+149
-12
@@ -1,5 +1,6 @@
|
||||
#include "MapWidget.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QLabel>
|
||||
#include <QLineF>
|
||||
@@ -14,6 +15,7 @@
|
||||
#include <QResizeEvent>
|
||||
#include <QStandardPaths>
|
||||
#include <QStringList>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
#include <QWheelEvent>
|
||||
|
||||
@@ -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<MissingTile> missingTiles;
|
||||
QSet<QString> 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<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)
|
||||
@@ -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<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)
|
||||
{
|
||||
newZoom = std::clamp(newZoom, MinZoom, MaxZoom);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <optional>
|
||||
|
||||
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<QString> &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<QString, QPixmap> m_tileCache;
|
||||
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_centerLon = 35.0;
|
||||
|
||||
@@ -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