Add polished macOS location plotter experience
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:
2026-07-18 15:20:37 +03:00
parent 049f6301e8
commit e648eca324
11 changed files with 438 additions and 50 deletions
+280 -21
View File
@@ -1,12 +1,14 @@
#include "GlobeWidget.h"
#include <QLinearGradient>
#include <QDateTime>
#include <QHash>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QMouseEvent>
#include <QNativeGestureEvent>
#include <QPainter>
#include <QPainterPath>
#include <QPolygonF>
@@ -184,6 +186,15 @@ const QImage &landMask()
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)
{
const QImage &mask = landMask();
@@ -369,11 +380,28 @@ void GlobeWidget::paintEvent(QPaintEvent *)
QPainterPath sphereClip;
sphereClip.addEllipse(center, radius, radius);
painter.setClipPath(sphereClip);
drawContinents(painter);
drawBlueMarble(painter);
drawGraticule(painter);
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);
drawSatelliteCoverage(painter);
if (m_hasReceiver) drawVisibilityCone(painter);
if (m_hasReceiver) drawReceiver(painter);
drawSatellites(painter);
@@ -391,6 +419,9 @@ void GlobeWidget::paintEvent(QPaintEvent *)
painter.setFont(smallFont);
painter.setPen(m_statusError ? QColor(255, 177, 77) : QColor(132, 177, 204));
painter.drawText(QRect(20, 43, width() - 40, 22), Qt::AlignLeft | Qt::AlignVCenter, m_status);
painter.setPen(QColor(105, 143, 166, 145));
painter.drawText(QRect(20, 43, width() - 40, 22), Qt::AlignRight | Qt::AlignVCenter,
QStringLiteral("Earth: NASA/GSFC Blue Marble"));
const int legendY = height() - 25;
const int legendX = width() / 2 - 125;
@@ -425,6 +456,100 @@ void GlobeWidget::paintEvent(QPaintEvent *)
}
}
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;
const double pixelRatio = std::min(devicePixelRatioF(), 2.0);
const int textureSize = 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
{
const double radius = std::min(width(), height()) * 0.255 * m_zoom;
@@ -434,8 +559,11 @@ void GlobeWidget::drawContinents(QPainter &painter) const
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_landTextureYaw == m_yaw && m_landTexturePitch == m_pitch
&& m_landTextureLightingMinute == lightingMinute) {
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
painter.drawImage(target, m_landTexture);
return;
@@ -447,6 +575,18 @@ void GlobeWidget::drawContinents(QPainter &painter) const
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 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) {
QRgb *scanLine = reinterpret_cast<QRgb *>(land.scanLine(py));
const double screenZ = -((py + 0.5) / textureSize * 2.0 - 1.0);
@@ -467,16 +607,37 @@ void GlobeWidget::drawContinents(QPainter &painter) const
|| maskIsLand(longitude - 0.55, latitude)
|| 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 (nearLand) {
const int alpha = static_cast<int>(65.0 * std::clamp(depth * 1.5, 0.0, 1.0));
scanLine[px] = qRgba(33, 151, 174, alpha);
}
const double oceanPattern = 0.5 + 0.25 * std::sin(longitude * Pi / 31.0)
+ 0.18 * std::cos(latitude * Pi / 17.0 + longitude * Pi / 47.0);
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;
}
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 lat = latitude * Pi / 180.0;
const double broadRelief = 0.50 + 0.24 * std::sin(lon * 5.0 + lat * 2.2)
@@ -505,31 +666,110 @@ void GlobeWidget::drawContinents(QPainter &painter) const
red = red * (1.0 - snow) + 224.0 * snow;
green = green * (1.0 - snow) + 231.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)
|| !maskIsLand(longitude - 0.55, latitude)
|| !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) {
red += 28.0;
green += 31.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(
std::clamp(static_cast<int>(red * shade), 0, 255),
std::clamp(static_cast<int>(green * shade), 0, 255),
std::clamp(static_cast<int>(blue * shade), 0, 255), 238);
std::clamp(static_cast<int>(red * shade + cityLights * 235.0), 0, 255),
std::clamp(static_cast<int>(green * shade + cityLights * 155.0), 0, 255),
std::clamp(static_cast<int>(blue * shade + cityLights * 52.0), 0, 255), 248);
}
}
m_landTexture = land;
m_landTextureYaw = m_yaw;
m_landTexturePitch = m_pitch;
m_landTextureLightingMinute = lightingMinute;
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);
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
{
painter.setPen(Qt::NoPen);
@@ -546,6 +786,10 @@ void GlobeWidget::drawStars(QPainter &painter) const
void GlobeWidget::drawGraticule(QPainter &painter) const
{
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));
for (int latitude = -60; latitude <= 60; latitude += 30) {
QPainterPath path;
@@ -662,14 +906,10 @@ void GlobeWidget::drawVisibilityCone(QPainter &painter) const
earthRadiusPixels * displayOrbitRatio));
}
painter.setPen(Qt::NoPen);
for (int i = 0; i < Segments; ++i) {
const double averageDepth = (apex.depth + ring[i].depth + ring[i + 1].depth) / 3.0;
const int alpha = averageDepth >= 0.0 ? 18 : 7;
painter.setBrush(QColor(48, 207, 218, alpha));
painter.drawPolygon(QPolygonF{apex.point, ring[i].point, ring[i + 1].point});
}
// Do not fill the projected cone with a triangle fan. When part of the
// ring is behind the globe that fan self-intersects and produces dark
// rectangular/polygonal artifacts over the Earth texture. The boundary,
// spokes and sight lines communicate the same mask without obscuring it.
QPainterPath boundary;
boundary.moveTo(ring.first().point);
for (int i = 1; i < ring.size(); ++i) boundary.lineTo(ring[i].point);
@@ -808,7 +1048,26 @@ void GlobeWidget::mouseReleaseEvent(QMouseEvent *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();
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);
}