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_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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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