diff --git a/CMakeLists.txt b/CMakeLists.txt index 29ba1e88e8a51..544a1dbc9a64f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -571,6 +571,7 @@ ADD_PLUGIN(Observability 1) ADD_PLUGIN(Oculars 1) ADD_PLUGIN(Oculus 0) ADD_PLUGIN(OnlineQueries 1) +ADD_PLUGIN(Planes 1) ADD_PLUGIN(PointerCoordinates 1) ADD_PLUGIN(Pulsars 1) ADD_PLUGIN(Quasars 1) diff --git a/cmake/default_cfg.ini.cmake b/cmake/default_cfg.ini.cmake index f54d11b1ce658..4a20c2c76ffa4 100644 --- a/cmake/default_cfg.ini.cmake +++ b/cmake/default_cfg.ini.cmake @@ -10,6 +10,7 @@ Exoplanets = true MeteorShowers = true Novae = true FOV = true +Planes = false [video] fullscreen = true diff --git a/plugins/Planes/CMakeLists.txt b/plugins/Planes/CMakeLists.txt new file mode 100644 index 0000000000000..4b7537b554d72 --- /dev/null +++ b/plugins/Planes/CMakeLists.txt @@ -0,0 +1 @@ +ADD_SUBDIRECTORY(src) diff --git a/plugins/Planes/module.ini b/plugins/Planes/module.ini new file mode 100644 index 0000000000000..1bf1382346d4a --- /dev/null +++ b/plugins/Planes/module.ini @@ -0,0 +1,4 @@ +[module] +id=Planes +version=0.1.0 +stellarium-minimum-version=26.1.0 diff --git a/plugins/Planes/resources/Planes.qrc b/plugins/Planes/resources/Planes.qrc new file mode 100644 index 0000000000000..bba4dbcd2cd29 --- /dev/null +++ b/plugins/Planes/resources/Planes.qrc @@ -0,0 +1,7 @@ + + + plane.png + planes_off_160.png + planes_on_160.png + + diff --git a/plugins/Planes/resources/plane.png b/plugins/Planes/resources/plane.png new file mode 100644 index 0000000000000..321e38716b9e5 Binary files /dev/null and b/plugins/Planes/resources/plane.png differ diff --git a/plugins/Planes/resources/planes_off_160.png b/plugins/Planes/resources/planes_off_160.png new file mode 100644 index 0000000000000..5561ad70a19ac Binary files /dev/null and b/plugins/Planes/resources/planes_off_160.png differ diff --git a/plugins/Planes/resources/planes_on_160.png b/plugins/Planes/resources/planes_on_160.png new file mode 100644 index 0000000000000..8aa3f4e16bb35 Binary files /dev/null and b/plugins/Planes/resources/planes_on_160.png differ diff --git a/plugins/Planes/src/AircraftObject.cpp b/plugins/Planes/src/AircraftObject.cpp new file mode 100644 index 0000000000000..60403b6ec976e --- /dev/null +++ b/plugins/Planes/src/AircraftObject.cpp @@ -0,0 +1,320 @@ +/* + * Copyright (C) 2013 Felix Zeltner + * Copyright (C) 2026 Kamil Zaraś (astronow.pl) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335, USA. + */ + +#include "AircraftObject.hpp" + +#include "StelCore.hpp" +#include "StelLocation.hpp" +#include "StelPainter.hpp" +#include "StelProjector.hpp" +#include "StelTranslator.hpp" +#include "StelUtils.hpp" + +#include +#include + +namespace +{ +constexpr double kEarthFlattening = 1.0 / 298.257223563; +constexpr double kEarthRadiusMeters = 6378137.0; +constexpr double kSecondsPerDay = 86400.0; +constexpr double kMaxDeadReckoningSeconds = 30.0; +constexpr double kHeadingProbeSeconds = 1.0; +constexpr double kMetersToFeet = 3.280839895; +constexpr double kMetersPerSecondToKnots = 1.943844492; +constexpr double kMetersPerSecondToFeetPerMinute = 196.8503937; +constexpr float kPlaneSpriteSize = 16.0f; +constexpr float kSpriteHeadingOffsetDegrees = -180.0f; + +Vec3d toEcef(double latitudeRad, double longitudeRad, double altitudeMeters) +{ + const double sinLat = std::sin(latitudeRad); + const double c = 1.0 / std::sqrt(1.0 + kEarthFlattening * (kEarthFlattening - 2.0) * (sinLat * sinLat)); + const double sq = (1.0 - kEarthFlattening) * (1.0 - kEarthFlattening) * c; + const double radius = (kEarthRadiusMeters * c + altitudeMeters) * std::cos(latitudeRad); + + return Vec3d(radius * std::cos(longitudeRad), + radius * std::sin(longitudeRad), + (kEarthRadiusMeters * sq + altitudeMeters) * sinLat); +} + +double normalizeLongitudeRadians(double longitudeRad) +{ + while (longitudeRad > M_PI) + longitudeRad -= 2.0 * M_PI; + while (longitudeRad < -M_PI) + longitudeRad += 2.0 * M_PI; + return longitudeRad; +} + +double normalizeDegrees(double degrees) +{ + while (degrees >= 360.0) + degrees -= 360.0; + while (degrees < 0.0) + degrees += 360.0; + return degrees; +} + +QString headingToCompass(double degrees) +{ + static const char* labels[] = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"}; + const int index = static_cast(std::floor((normalizeDegrees(degrees) + 22.5) / 45.0)) % 8; + return QString::fromLatin1(labels[index]); +} +} + +const QString AircraftObject::STEL_TYPE = QStringLiteral("Flight"); + +AircraftObject::AircraftObject(const AircraftRecord& record) + : aircraftRecord(record) +{ +} + +void AircraftObject::updateRecord(const AircraftRecord& record) +{ + aircraftRecord = record; +} + +QString AircraftObject::getObjectTypeI18n() const +{ + return q_(getObjectType()); +} + +QString AircraftObject::getID() const +{ + return aircraftRecord.icao24; +} + +QString AircraftObject::getEnglishName() const +{ + if (!aircraftRecord.callsign.isEmpty()) + return aircraftRecord.callsign; + return aircraftRecord.icao24; +} + +QString AircraftObject::getNameI18n() const +{ + return getEnglishName(); +} + +QString AircraftObject::labelText() const +{ + if (!aircraftRecord.callsign.isEmpty()) + return aircraftRecord.callsign; + if (!aircraftRecord.aircraftType.isEmpty()) + return QString("%1 (%2)").arg(aircraftRecord.icao24, aircraftRecord.aircraftType); + return aircraftRecord.icao24; +} + +QString AircraftObject::displayLabelText(int labelMode) const +{ + if (labelMode == 1 && !aircraftRecord.aircraftType.isEmpty()) + return aircraftRecord.aircraftType; + return labelText(); +} + +double AircraftObject::getElapsedSeconds() const +{ + if (aircraftRecord.snapshotJd <= 0.0) + return 0.0; + + const double elapsedSeconds = (StelUtils::getJDFromSystem() - aircraftRecord.snapshotJd) * kSecondsPerDay; + if (elapsedSeconds <= 0.0) + return 0.0; + return qMin(elapsedSeconds, kMaxDeadReckoningSeconds); +} + +AircraftRecord AircraftObject::getExtrapolatedRecord(double elapsedSeconds) const +{ + AircraftRecord record = aircraftRecord; + if (elapsedSeconds <= 0.0 || record.groundSpeedMs <= 0.0) + { + record.altitudeMeters = qMax(0.0, record.altitudeMeters + record.verticalRateMs * elapsedSeconds); + return record; + } + + const double lat1 = record.latitude * M_PI / 180.0; + const double lon1 = record.longitude * M_PI / 180.0; + const double bearing = normalizeDegrees(record.trackDegrees) * M_PI / 180.0; + const double angularDistance = (record.groundSpeedMs * elapsedSeconds) / kEarthRadiusMeters; + + const double sinLat1 = std::sin(lat1); + const double cosLat1 = std::cos(lat1); + const double sinAngularDistance = std::sin(angularDistance); + const double cosAngularDistance = std::cos(angularDistance); + + const double lat2 = std::asin(sinLat1 * cosAngularDistance + + cosLat1 * sinAngularDistance * std::cos(bearing)); + const double lon2 = lon1 + std::atan2(std::sin(bearing) * sinAngularDistance * cosLat1, + cosAngularDistance - sinLat1 * std::sin(lat2)); + + record.latitude = lat2 * 180.0 / M_PI; + record.longitude = normalizeLongitudeRadians(lon2) * 180.0 / M_PI; + record.altitudeMeters = qMax(0.0, record.altitudeMeters + record.verticalRateMs * elapsedSeconds); + return record; +} + +QString AircraftObject::getInfoString(const StelCore* core, const InfoStringGroup& flags) const +{ + QString str; + QTextStream stream(&str); + const AircraftRecord currentRecord = getExtrapolatedRecord(getElapsedSeconds()); + + if (flags & Name) + stream << QString("

%1

").arg(labelText().toHtmlEscaped()); + if (flags & ObjectType) + stream << QString("%1: %2
").arg(q_("Type"), getObjectTypeI18n()); + if (flags & Extra) + { + const double dataAgeSeconds = getElapsedSeconds(); + const QString heading = QString("%1° (%2)") + .arg(QString::number(normalizeDegrees(currentRecord.trackDegrees), 'f', 0), headingToCompass(currentRecord.trackDegrees)); + const QString altitude = QString("%1 m / %2 ft") + .arg(QString::number(currentRecord.altitudeMeters, 'f', 0), + QString::number(currentRecord.altitudeMeters * kMetersToFeet, 'f', 0)); + const QString groundSpeed = QString("%1 m/s / %2 kt") + .arg(QString::number(currentRecord.groundSpeedMs, 'f', 0), + QString::number(currentRecord.groundSpeedMs * kMetersPerSecondToKnots, 'f', 0)); + const QString verticalRate = QString("%1 m/s / %2 ft/min") + .arg(QString::number(currentRecord.verticalRateMs, 'f', 1), + QString::number(currentRecord.verticalRateMs * kMetersPerSecondToFeetPerMinute, 'f', 0)); + + stream << QString("%1: %2
").arg(q_("Identifier"), aircraftRecord.icao24.toHtmlEscaped()); + if (!aircraftRecord.callsign.isEmpty()) + stream << QString("%1: %2
").arg(q_("Flight"), aircraftRecord.callsign.toHtmlEscaped()); + if (!aircraftRecord.aircraftType.isEmpty()) + stream << QString("%1: %2
").arg(q_("Model"), aircraftRecord.aircraftType.toHtmlEscaped()); + stream << "
"; + stream << ""; + stream << QString("").arg(q_("Altitude"), altitude); + stream << QString("").arg(q_("Ground speed"), groundSpeed); + stream << QString("").arg(q_("Vertical rate"), verticalRate); + stream << QString("").arg(q_("Track"), heading); + stream << QString("") + .arg(q_("Data age"), QString::number(dataAgeSeconds, 'f', dataAgeSeconds < 10.0 ? 1 : 0)); + stream << "
%1:%2
%1:%2
%1:%2
%1:%2
%1:%2 s
"; + } + + stream << getCommonInfoString(core, flags); + postProcessInfoString(str, flags); + return str; +} + +Vec3f AircraftObject::getInfoColor() const +{ + return Vec3f(0.30f, 0.86f, 1.0f); +} + +Vec3d AircraftObject::getAltAzPos(const StelCore* core, double elapsedSeconds) const +{ + if (!core) + return Vec3d(0.0, 0.0, 1.0); + + const AircraftRecord record = getExtrapolatedRecord(elapsedSeconds); + const StelLocation& location = core->getCurrentLocation(); + const double obsLat = static_cast(location.getLatitude()) * M_PI / 180.0; + const double obsLon = static_cast(location.getLongitude()) * M_PI / 180.0; + const double tgtLat = record.latitude * M_PI / 180.0; + const double tgtLon = record.longitude * M_PI / 180.0; + + const Vec3d observer = toEcef(obsLat, obsLon, static_cast(location.altitude)); + const Vec3d aircraft = toEcef(tgtLat, tgtLon, record.altitudeMeters); + const Vec3d toPoint = aircraft - observer; + + const double sla = std::sin(obsLat); + const double cla = std::cos(obsLat); + const double slo = std::sin(obsLon); + const double clo = std::cos(obsLon); + + Vec3d altAz; + altAz[0] = sla * clo * toPoint[0] + sla * slo * toPoint[1] - cla * toPoint[2]; + altAz[1] = -slo * toPoint[0] + clo * toPoint[1]; + altAz[2] = cla * clo * toPoint[0] + cla * slo * toPoint[1] + sla * toPoint[2]; + altAz.normalize(); + return altAz; +} + +Vec3d AircraftObject::getJ2000EquatorialPos(const StelCore* core) const +{ + return core ? core->altAzToJ2000(getAltAzPos(core, getElapsedSeconds()), StelCore::RefractionOff) : Vec3d(1.0, 0.0, 0.0); +} + +float AircraftObject::getVMagnitude(const StelCore* core) const +{ + Q_UNUSED(core) + return 1.5f; +} + +double AircraftObject::getAngularRadius(const StelCore* core) const +{ + Q_UNUSED(core) + return 0.0002; +} + +float AircraftObject::getSelectPriority(const StelCore* core) const +{ + Q_UNUSED(core) + return -10.0f; +} + +bool AircraftObject::isAboveHorizon(const StelCore* core) const +{ + return getAltAzPos(core, getElapsedSeconds())[2] > 0.0; +} + +float AircraftObject::getScreenRotationDegrees(StelCore* core, const StelProjectorP& projector, const Vec3d& currentScreenPos) const +{ + if (!projector) + return static_cast(normalizeDegrees(aircraftRecord.trackDegrees) + kSpriteHeadingOffsetDegrees); + + Vec3d futureScreenPos; + if (!projector->project(getAltAzPos(core, getElapsedSeconds() + kHeadingProbeSeconds), futureScreenPos)) + return static_cast(normalizeDegrees(aircraftRecord.trackDegrees) + kSpriteHeadingOffsetDegrees); + + const double dx = futureScreenPos[0] - currentScreenPos[0]; + const double dy = futureScreenPos[1] - currentScreenPos[1]; + if (std::abs(dx) < 0.01 && std::abs(dy) < 0.01) + return static_cast(normalizeDegrees(aircraftRecord.trackDegrees) + kSpriteHeadingOffsetDegrees); + + const double angleDeg = std::atan2(dy, dx) * 180.0 / M_PI; + return static_cast(angleDeg + kSpriteHeadingOffsetDegrees); +} + +void AircraftObject::draw(StelCore* core, StelPainter* painter, bool drawLabels, int labelMode) const +{ + if (!core || !painter || !isAboveHorizon(core)) + return; + + StelProjectorP projector = core->getProjection(StelCore::FrameAltAz, StelCore::RefractionOff); + const double elapsedSeconds = getElapsedSeconds(); + Vec3d screenPos; + if (!projector->project(getAltAzPos(core, elapsedSeconds), screenPos) || !projector->checkInViewport(screenPos)) + return; + + const Vec3f color = getInfoColor(); + painter->setColor(color, 1.0f); + painter->drawSprite2dMode(static_cast(screenPos[0]), static_cast(screenPos[1]), + kPlaneSpriteSize, getScreenRotationDegrees(core, projector, screenPos)); + + if (drawLabels) + { + painter->drawText(static_cast(screenPos[0]), static_cast(screenPos[1]), displayLabelText(labelMode), 0, 10.0f, 10.0f, false); + } +} diff --git a/plugins/Planes/src/AircraftObject.hpp b/plugins/Planes/src/AircraftObject.hpp new file mode 100644 index 0000000000000..077bc7c82ffc0 --- /dev/null +++ b/plugins/Planes/src/AircraftObject.hpp @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2013 Felix Zeltner + * Copyright (C) 2026 Kamil Zaraś (astronow.pl) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335, USA. + */ + +#ifndef PLANES_AIRCRAFTOBJECT_HPP +#define PLANES_AIRCRAFTOBJECT_HPP + +#include "AircraftRecord.hpp" +#include "StelObject.hpp" +#include "StelProjectorType.hpp" +#include "StelTranslator.hpp" + +#include +#include + +class StelPainter; + +class AircraftObject : public StelObject +{ +public: + static const QString STEL_TYPE; + + explicit AircraftObject(const AircraftRecord& record); + void updateRecord(const AircraftRecord& record); + + QString getType() const override { return STEL_TYPE; } + QString getObjectType() const override { return N_("plane"); } + QString getObjectTypeI18n() const override; + QString getID() const override; + QString getEnglishName() const override; + QString getNameI18n() const override; + QString getInfoString(const StelCore* core, const InfoStringGroup& flags) const override; + Vec3f getInfoColor() const override; + Vec3d getJ2000EquatorialPos(const StelCore* core) const override; + float getVMagnitude(const StelCore* core) const override; + double getAngularRadius(const StelCore* core) const override; + float getSelectPriority(const StelCore* core) const override; + + bool isAboveHorizon(const StelCore* core) const; + void draw(StelCore* core, StelPainter* painter, bool drawLabels, int labelMode) const; + + const AircraftRecord& record() const { return aircraftRecord; } + +private: + AircraftRecord getExtrapolatedRecord(double elapsedSeconds) const; + double getElapsedSeconds() const; + Vec3d getAltAzPos(const StelCore* core, double elapsedSeconds=0.0) const; + float getScreenRotationDegrees(StelCore* core, const StelProjectorP& projector, const Vec3d& currentScreenPos) const; + QString labelText() const; + QString displayLabelText(int labelMode) const; + + AircraftRecord aircraftRecord; +}; + +using AircraftObjectP = QSharedPointer; + +#endif diff --git a/plugins/Planes/src/AircraftRecord.hpp b/plugins/Planes/src/AircraftRecord.hpp new file mode 100644 index 0000000000000..2c63e9204eaf0 --- /dev/null +++ b/plugins/Planes/src/AircraftRecord.hpp @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2013 Felix Zeltner + * Copyright (C) 2026 Kamil Zaraś (astronow.pl) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335, USA. + */ + +#ifndef PLANES_AIRCRAFTRECORD_HPP +#define PLANES_AIRCRAFTRECORD_HPP + +#include + +struct AircraftRecord +{ + QString icao24; + QString callsign; + QString aircraftType; + double latitude = 0.0; + double longitude = 0.0; + double altitudeMeters = 0.0; + double groundSpeedMs = 0.0; + double trackDegrees = 0.0; + double verticalRateMs = 0.0; + double snapshotJd = 0.0; +}; + +#endif diff --git a/plugins/Planes/src/CMakeLists.txt b/plugins/Planes/src/CMakeLists.txt new file mode 100644 index 0000000000000..b36afa4a2512a --- /dev/null +++ b/plugins/Planes/src/CMakeLists.txt @@ -0,0 +1,57 @@ +INCLUDE_DIRECTORIES( + . + gui + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/src/gui + ${CMAKE_BINARY_DIR}/plugins/Planes/src + ${CMAKE_BINARY_DIR}/plugins/Planes/src/gui +) + +LINK_DIRECTORIES(${BUILD_DIR}/src) + +SET(Planes_SRCS + AircraftObject.hpp + AircraftObject.cpp + AircraftRecord.hpp + Planes.hpp + Planes.cpp +) +IF (STELLARIUM_GUI_MODE STREQUAL "Standard") +LIST(APPEND Planes_SRCS + gui/PlanesDialog.hpp + gui/PlanesDialog.cpp +) + +SET(Planes_UIS + gui/planesDialog.ui +) +ENDIF() + +SET(Planes_RES ../resources/Planes.qrc) +IF (${QT_VERSION_MAJOR} EQUAL "5") + IF (STELLARIUM_GUI_MODE STREQUAL "Standard") + QT5_WRAP_UI(Planes_UIS_H ${Planes_UIS}) + ENDIF() + QT5_ADD_RESOURCES(Planes_RES_CXX ${Planes_RES}) +ELSE() + IF (STELLARIUM_GUI_MODE STREQUAL "Standard") + QT_WRAP_UI(Planes_UIS_H ${Planes_UIS}) + ENDIF() + QT_ADD_RESOURCES(Planes_RES_CXX ${Planes_RES}) +ENDIF() + +SET(Planes_Qt_Libraries + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::Network + Qt${QT_VERSION_MAJOR}::OpenGL + Qt${QT_VERSION_MAJOR}::Widgets +) + +ADD_LIBRARY(Planes-static STATIC ${Planes_SRCS} ${Planes_RES_CXX} ${Planes_UIS_H}) +SET_TARGET_PROPERTIES(Planes-static PROPERTIES OUTPUT_NAME "Planes") +TARGET_LINK_LIBRARIES(Planes-static ${Planes_Qt_Libraries}) +SET_TARGET_PROPERTIES(Planes-static PROPERTIES COMPILE_FLAGS "-DQT_STATICPLUGIN") +ADD_DEPENDENCIES(AllStaticPlugins Planes-static) + +SET_TARGET_PROPERTIES(Planes-static PROPERTIES FOLDER "plugins/Planes") diff --git a/plugins/Planes/src/Planes.cpp b/plugins/Planes/src/Planes.cpp new file mode 100644 index 0000000000000..9198a0da0b5bb --- /dev/null +++ b/plugins/Planes/src/Planes.cpp @@ -0,0 +1,805 @@ +/* + * Copyright (C) 2013 Felix Zeltner + * Copyright (C) 2026 Kamil Zaraś (astronow.pl) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335, USA. + */ + +#include "Planes.hpp" + +#include "StelApp.hpp" +#include "StelCore.hpp" +#include "StelFileMgr.hpp" +#include "StelGui.hpp" +#include "StelGuiItems.hpp" +#include "StelLocation.hpp" +#include "StelModuleMgr.hpp" +#include "StelObjectMgr.hpp" +#include "StelProjector.hpp" +#include "StelTextureMgr.hpp" +#include "StelTranslator.hpp" +#include "StelUtils.hpp" + +#ifndef NO_GUI +#include "gui/PlanesDialog.hpp" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +struct ProviderDefinition +{ + QString id; + QString displayName; + QString urlTemplate; + QString websiteUrl; + int maxRadiusNm; +}; + +const QString kAdsbFiTemplate = QStringLiteral("https://opendata.adsb.fi/api/v2/lat/%1/lon/%2/dist/%3"); +const QString kAirplanesLiveTemplate = QStringLiteral("https://api.airplanes.live/v2/point/%1/%2/%3"); +const QString kPluginVersion = QStringLiteral("0.1.0"); +constexpr int kLabelModeFlightNumber = 0; +constexpr int kLabelModeAircraftModel = 1; +constexpr int kDefaultFetchIntervalSec = 15; +constexpr int kMinFetchIntervalSec = 15; +constexpr int kMaxFetchIntervalSec = 60; +constexpr int kDefaultRadiusNm = 250; +constexpr int kMinRadiusNm = 25; +constexpr int kMaxRadiusNm = 500; +constexpr int kMaxPublishedAircraft = 200; +constexpr double kFeetToMeters = 0.3048; +constexpr double kKnotsToMetersPerSecond = 0.514444; +constexpr double kFeetPerMinuteToMetersPerSecond = 0.00508; +const QString kRealtimeOnlyStatus = QStringLiteral("Live aircraft are shown only in real-time mode."); + +ProviderDefinition providerDefinition(const QString& providerId) +{ + if (providerId == QStringLiteral("airplanes_live")) + { + return { + QStringLiteral("airplanes_live"), + QStringLiteral("airplanes.live"), + kAirplanesLiveTemplate, + QStringLiteral("https://airplanes.live/api-guide/"), + 250 + }; + } + + return { + QStringLiteral("adsb_fi"), + QStringLiteral("adsb.fi"), + kAdsbFiTemplate, + QStringLiteral("https://adsb.fi/"), + kMaxRadiusNm + }; +} + +QString providerIdFromTemplate(const QString& urlTemplate) +{ + if (urlTemplate.contains(QStringLiteral("airplanes.live"), Qt::CaseInsensitive)) + return QStringLiteral("airplanes_live"); + return QStringLiteral("adsb_fi"); +} + +int sanitizeInterval(int seconds) +{ + if (seconds <= 15) + return 15; + if (seconds <= 20) + return 20; + if (seconds <= 30) + return 30; + return 60; +} + +int sanitizeRadius(int nm) +{ + if (nm < kMinRadiusNm) + return kMinRadiusNm; + if (nm > kMaxRadiusNm) + return kMaxRadiusNm; + return nm; +} + +int sanitizeLabelMode(int mode) +{ + return mode == kLabelModeAircraftModel ? kLabelModeAircraftModel : kLabelModeFlightNumber; +} + +bool shouldSkipAircraft(const QJsonObject& object) +{ + if (!object.contains("lat") || !object.contains("lon")) + return true; + if (object.value("lat").isNull() || object.value("lon").isNull()) + return true; + return object.value("alt_baro").toString() == QStringLiteral("ground"); +} + +AircraftRecord parseAircraftRecord(const QJsonObject& object, double snapshotJd) +{ + AircraftRecord record; + record.icao24 = object.value("hex").toString().trimmed().toLower(); + record.callsign = object.value("flight").toString().trimmed(); + record.aircraftType = object.value("t").toString().trimmed(); + record.latitude = object.value("lat").toDouble(); + record.longitude = object.value("lon").toDouble(); + record.altitudeMeters = object.value("alt_baro").toDouble() * kFeetToMeters; + record.groundSpeedMs = object.value("gs").toDouble() * kKnotsToMetersPerSecond; + record.trackDegrees = object.value("track").toDouble(); + record.verticalRateMs = object.value("baro_rate").toDouble() * kFeetPerMinuteToMetersPerSecond; + record.snapshotJd = snapshotJd; + return record; +} + +QVector parseAircraftRecords(const QJsonArray& aircraftArray, double snapshotJd) +{ + QVector nextRecords; + nextRecords.reserve(qMin(aircraftArray.size(), kMaxPublishedAircraft)); + + for (const QJsonValue& value : aircraftArray) + { + const QJsonObject object = value.toObject(); + if (shouldSkipAircraft(object)) + continue; + + const AircraftRecord record = parseAircraftRecord(object, snapshotJd); + if (record.icao24.isEmpty()) + continue; + + nextRecords.append(record); + if (nextRecords.size() >= kMaxPublishedAircraft) + break; + } + + return nextRecords; +} + +QJsonArray aircraftArrayFromResponse(const QJsonObject& root) +{ + const QJsonArray aircraftArray = root.value("aircraft").toArray(); + if (!aircraftArray.isEmpty()) + return aircraftArray; + return root.value("ac").toArray(); +} + +bool isRealtimeMode(const StelCore* core) +{ + return core && core->getIsTimeNow(); +} +} + +StelModule* PlanesStelPluginInterface::getStelModule() const +{ + return new Planes(); +} + +StelPluginInfo PlanesStelPluginInterface::getPluginInfo() const +{ + StelPluginInfo info; + info.id = QStringLiteral("Planes"); + info.displayedName = N_("Planes"); + info.authors = QStringLiteral("Felix Zeltner, Georg Zotti, Kamil Zaraś (astronow.pl)"); + info.contact = STELLARIUM_DEV_URL; + info.description = N_("Display live ADS-B aircraft in the sky."); + info.version = kPluginVersion; + info.license = QStringLiteral("GPL v2 or later"); + return info; +} + +Planes::Planes() + : networkMgr(nullptr) + , fetchTimer(nullptr) + , refreshDebounceTimer(nullptr) + , inFlightReply(nullptr) +#ifndef NO_GUI + , configDialog(nullptr) + , toolbarButton(nullptr) +#endif + , sourceUrlTemplate(kAdsbFiTemplate) + , fetchIntervalSec(kDefaultFetchIntervalSec) + , radiusNm(kDefaultRadiusNm) + , pendingRefresh(false) + , enabled(false) + , showLabels(false) + , showButton(true) + , lastRealtimeState(true) + , labelMode(kLabelModeFlightNumber) + , lastStatus(QStringLiteral("idle")) + , lastSuccessfulUpdate(QStringLiteral("Never")) +{ + setObjectName(QStringLiteral("Planes")); +#ifndef NO_GUI + configDialog = new PlanesDialog(); +#endif +} + +Planes::~Planes() +{ +#ifndef NO_GUI + delete configDialog; +#endif +} + +void Planes::loadSettings() +{ + QSettings* conf = StelApp::getInstance().getSettings(); + conf->beginGroup("Planes"); + sourceUrlTemplate = conf->value("source_url_template", kAdsbFiTemplate).toString().trimmed(); + if (sourceUrlTemplate.isEmpty()) + sourceUrlTemplate = kAdsbFiTemplate; + fetchIntervalSec = sanitizeInterval(conf->value("fetch_interval_sec", kDefaultFetchIntervalSec).toInt()); + radiusNm = sanitizeRadius(conf->value("radius_nm", kDefaultRadiusNm).toInt()); + enabled = conf->value("enabled", false).toBool(); + showLabels = conf->value("show_labels", true).toBool(); + showButton = conf->value("show_button", true).toBool(); + labelMode = sanitizeLabelMode(conf->value("label_mode", kLabelModeFlightNumber).toInt()); + conf->endGroup(); + applyProviderDefaults(); +} + +void Planes::saveSettings() const +{ + QSettings* conf = StelApp::getInstance().getSettings(); + conf->beginGroup("Planes"); + conf->setValue("source_url_template", sourceUrlTemplate); + conf->setValue("fetch_interval_sec", fetchIntervalSec); + conf->setValue("radius_nm", radiusNm); + conf->setValue("enabled", enabled); + conf->setValue("show_labels", showLabels); + conf->setValue("show_button", showButton); + conf->setValue("label_mode", labelMode); + conf->endGroup(); +} + +void Planes::init() +{ + Q_INIT_RESOURCE(Planes); + loadSettings(); + planeTexture = StelApp::getInstance().getTextureManager().createTexture(":/planes/plane.png"); + pointerTexture = StelApp::getInstance().getTextureManager().createTexture(StelFileMgr::getInstallationDir()+"/textures/pointeur5.png"); + + networkMgr = new QNetworkAccessManager(this); + connect(networkMgr, &QNetworkAccessManager::finished, this, &Planes::onReply); + + fetchTimer = new QTimer(this); + fetchTimer->setInterval(fetchIntervalSec * 1000); + connect(fetchTimer, &QTimer::timeout, this, &Planes::fetchAircraft); + + refreshDebounceTimer = new QTimer(this); + refreshDebounceTimer->setSingleShot(true); + refreshDebounceTimer->setInterval(1200); + connect(refreshDebounceTimer, &QTimer::timeout, this, &Planes::fetchAircraft); + + GETSTELMODULE(StelObjectMgr)->registerStelObjectMgr(this); + connect(StelApp::getInstance().getCore(), &StelCore::locationChanged, this, &Planes::onLocationChanged); + lastRealtimeState = isRealtimeMode(StelApp::getInstance().getCore()); + + addAction("actionShow_Planes", N_("Planes"), N_("Show Planes"), "enabled", "Shift+P"); +#ifndef NO_GUI + addAction("actionShow_Planes_dialog", N_("Planes"), N_("Show settings dialog"), configDialog, "visible", "Ctrl+P"); + applyButtonVisibility(); +#endif + if (enabled) + { + fetchTimer->start(); + if (lastRealtimeState) + { + updateStatus(QStringLiteral("Waiting for first update...")); + QTimer::singleShot(1500, this, &Planes::fetchAircraft); + } + else + { + updateStatus(kRealtimeOnlyStatus); + } + } + else + { + updateStatus(QStringLiteral("disabled; live updates are off")); + } +} + +void Planes::deinit() +{ + saveSettings(); + if (inFlightReply) + inFlightReply->abort(); + aircraft.clear(); + planeTexture.clear(); + pointerTexture.clear(); +} + +void Planes::update(double deltaTime) +{ + Q_UNUSED(deltaTime) + + StelCore* core = StelApp::getInstance().getCore(); + const bool realtimeNow = isRealtimeMode(core); + if (realtimeNow == lastRealtimeState) + return; + + lastRealtimeState = realtimeNow; + if (!enabled) + return; + + if (realtimeNow) + { + updateStatus(QStringLiteral("Returned to real-time mode.")); + scheduleRefresh(0); + } + else + { + updateStatus(kRealtimeOnlyStatus); + } +} + +QString Planes::buildRequestUrl(const StelCore* core) const +{ + if (!core) + return QString(); + + const StelLocation& loc = core->getCurrentLocation(); + return sourceUrlTemplate + .arg(loc.getLatitude(), 0, 'f', 4) + .arg(loc.getLongitude(), 0, 'f', 4) + .arg(radiusNm); +} + +QString Planes::getProviderId() const +{ + return providerIdFromTemplate(sourceUrlTemplate); +} + +QString Planes::getProviderDisplayName() const +{ + return providerDefinition(getProviderId()).displayName; +} + +QString Planes::getProviderWebsiteUrl() const +{ + return providerDefinition(getProviderId()).websiteUrl; +} + +int Planes::getProviderMaxRadiusNm() const +{ + return providerDefinition(getProviderId()).maxRadiusNm; +} + +void Planes::applyProviderDefaults(bool clampRadius) +{ + const ProviderDefinition provider = providerDefinition(providerIdFromTemplate(sourceUrlTemplate)); + sourceUrlTemplate = provider.urlTemplate; + if (clampRadius) + radiusNm = sanitizeRadius(qMin(radiusNm, provider.maxRadiusNm)); +} + +void Planes::fetchAircraft() +{ + if (!networkMgr || !enabled) + return; + + StelCore* core = StelApp::getInstance().getCore(); + if (!isRealtimeMode(core)) + return; + + const QString url = buildRequestUrl(core); + if (url.isEmpty()) + { + updateStatus(QStringLiteral("missing core or URL"), false, true); + return; + } + + if (inFlightReply) + { + pendingRefresh = true; + return; + } + + QNetworkRequest request{QUrl(url)}; + request.setRawHeader("User-Agent", QString("Stellarium-Planes/%1").arg(kPluginVersion).toUtf8()); + request.setRawHeader("Accept", "application/json"); + inFlightReply = networkMgr->get(request); + pendingRefresh = false; + updateStatus(QStringLiteral("Updating live aircraft feed...")); +} + +void Planes::onReply(QNetworkReply* reply) +{ + if (reply == inFlightReply) + inFlightReply = nullptr; + + const bool shouldRefreshAgain = pendingRefresh && enabled; + pendingRefresh = false; + reply->deleteLater(); + + if (!isRealtimeMode(StelApp::getInstance().getCore())) + { + updateStatus(kRealtimeOnlyStatus); + return; + } + + if (reply->error() != QNetworkReply::NoError) + { + if (reply->error() != QNetworkReply::OperationCanceledError) + updateStatus(QString("Update failed: %1").arg(reply->errorString()), false, true); + if (shouldRefreshAgain) + scheduleRefresh(250); + return; + } + + const QByteArray payload = reply->readAll(); + + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) + { + updateStatus(QString("Update failed: %1").arg(parseError.errorString()), false, true); + if (shouldRefreshAgain) + scheduleRefresh(250); + return; + } + + const QVector records = parseAircraftRecords(aircraftArrayFromResponse(doc.object()), StelUtils::getJDFromSystem()); + QHash existingById; + existingById.reserve(aircraft.size()); + for (const AircraftObjectP& object : std::as_const(aircraft)) + { + if (object) + existingById.insert(object->getID(), object); + } + + QVector nextAircraft; + nextAircraft.reserve(records.size()); + for (const AircraftRecord& record : records) + { + const auto it = existingById.constFind(record.icao24); + if (it != existingById.constEnd()) + { + it.value()->updateRecord(record); + nextAircraft.append(it.value()); + } + else + { + nextAircraft.append(AircraftObjectP::create(record)); + } + } + aircraft = nextAircraft; + lastSuccessfulUpdate = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss")); + updateStatus(QString("Showing %1 aircraft from %2").arg(aircraft.size()).arg(getProviderDisplayName())); + if (shouldRefreshAgain) + scheduleRefresh(250); +} + +void Planes::onLocationChanged(const StelLocation& loc) +{ + Q_UNUSED(loc) + scheduleRefresh(); +} + +void Planes::draw(StelCore* core) +{ + if (!core || !enabled || !isRealtimeMode(core)) + return; + + StelProjectorP projection = core->getProjection(StelCore::FrameAltAz, StelCore::RefractionOff); + StelPainter painter(projection); + painter.setColor(1.0f, 1.0f, 1.0f, 1.0f); + painter.setBlending(true); + if (planeTexture) + planeTexture->bind(); + for (const AircraftObjectP& object : std::as_const(aircraft)) + object->draw(core, &painter, showLabels, labelMode); + + StelObjectMgr* objectMgr = GETSTELMODULE(StelObjectMgr); + if (!objectMgr->getFlagSelectedObjectPointer()) + return; + + const QList selectedAircraft = objectMgr->getSelectedObject(AircraftObject::STEL_TYPE); + if (selectedAircraft.empty() || !pointerTexture) + return; + + StelPainter pointerPainter(core->getProjection(StelCore::FrameJ2000, StelCore::RefractionOff)); + const StelObjectP object = selectedAircraft.constFirst(); + Vec3f screenPos; + if (!pointerPainter.getProjector()->project(object->getJ2000EquatorialPos(core).toVec3f(), screenPos)) + return; + + pointerPainter.setColor(0.4f, 0.5f, 0.8f); + pointerTexture->bind(); + pointerPainter.setBlending(true); + const float scale = StelApp::getInstance().getScreenScale(); + float size = static_cast(object->getAngularRadius(core) * (2. * M_PI_180) * + static_cast(pointerPainter.getProjector()->getPixelPerRadAtCenter())); + size += (12.f + 3.f * std::sin(2. * StelApp::getInstance().getTotalRunTime())) * scale; + const float radius = 20.f * scale; + const float x = screenPos[0]; + const float y = screenPos[1]; + pointerPainter.drawSprite2dModeNoDeviceScale(x - size / 2.f, y - size / 2.f, radius, 90.f); + pointerPainter.drawSprite2dModeNoDeviceScale(x - size / 2.f, y + size / 2.f, radius, 0.f); + pointerPainter.drawSprite2dModeNoDeviceScale(x + size / 2.f, y + size / 2.f, radius, -90.f); + pointerPainter.drawSprite2dModeNoDeviceScale(x + size / 2.f, y - size / 2.f, radius, -180.f); +} + +double Planes::getCallOrder(StelModuleActionName actionName) const +{ + if (actionName == StelModule::ActionDraw) + return StelApp::getInstance().getModuleMgr().getModule(QStringLiteral("SolarSystem"))->getCallOrder(actionName) + 1.0; + return 0.0; +} + +bool Planes::configureGui(bool show) +{ +#ifndef NO_GUI + if (configDialog) + { + if (show) + configDialog->setVisible(true); + return true; + } +#else + Q_UNUSED(show) +#endif + return false; +} + +void Planes::setEnabled(bool value) +{ + if (enabled == value) + return; + + enabled = value; + if (!enabled) + { + if (fetchTimer) + fetchTimer->stop(); + if (refreshDebounceTimer) + refreshDebounceTimer->stop(); + pendingRefresh = false; + if (inFlightReply) + inFlightReply->abort(); + aircraft.clear(); + updateStatus(QStringLiteral("Live updates are off")); + } + else + { + if (fetchTimer) + fetchTimer->start(); + if (isRealtimeMode(StelApp::getInstance().getCore())) + { + updateStatus(QStringLiteral("Waiting for first update...")); + scheduleRefresh(0); + } + else + { + updateStatus(kRealtimeOnlyStatus); + } + } + emit enabledChanged(enabled); +} + +void Planes::setFlagShowLabels(bool value) +{ + if (showLabels == value) + return; + showLabels = value; + emit showLabelsChanged(showLabels); +} + +void Planes::setFlagShowButton(bool value) +{ + if (showButton == value) + return; + showButton = value; +#ifndef NO_GUI + applyButtonVisibility(); +#endif + emit showButtonChanged(showButton); +} + +void Planes::setLabelMode(int mode) +{ + const int sanitized = sanitizeLabelMode(mode); + if (labelMode == sanitized) + return; + labelMode = sanitized; + emit labelModeChanged(labelMode); +} + +void Planes::setProviderId(const QString& providerId) +{ + const QString currentProviderId = getProviderId(); + const ProviderDefinition nextProvider = providerDefinition(providerId); + if (currentProviderId == nextProvider.id) + return; + + sourceUrlTemplate = nextProvider.urlTemplate; + const int clampedRadius = sanitizeRadius(qMin(radiusNm, nextProvider.maxRadiusNm)); + const bool radiusAdjusted = clampedRadius != radiusNm; + radiusNm = clampedRadius; + + emit providerChanged(nextProvider.id); + if (radiusAdjusted) + emit radiusChanged(radiusNm); + + if (enabled) + { + updateStatus(QString("Switched to %1").arg(nextProvider.displayName)); + scheduleRefresh(0); + } +} + +void Planes::setFetchIntervalSec(int seconds) +{ + const int sanitized = sanitizeInterval(seconds); + if (fetchIntervalSec == sanitized) + return; + fetchIntervalSec = sanitized; + if (fetchTimer) + fetchTimer->setInterval(fetchIntervalSec * 1000); + emit fetchIntervalChanged(fetchIntervalSec); + scheduleRefresh(); +} + +void Planes::setRadiusNm(int nm) +{ + const int sanitized = sanitizeRadius(nm); + if (radiusNm == sanitized) + return; + radiusNm = sanitized; + emit radiusChanged(radiusNm); + scheduleRefresh(); +} + +void Planes::refreshNow() +{ + if (enabled) + { + if (!isRealtimeMode(StelApp::getInstance().getCore())) + { + updateStatus(kRealtimeOnlyStatus); + return; + } + fetchAircraft(); + } +} + +void Planes::scheduleRefresh(int delayMs) +{ + if (!enabled || !refreshDebounceTimer || !isRealtimeMode(StelApp::getInstance().getCore())) + return; + + refreshDebounceTimer->start(delayMs); +} + +void Planes::updateStatus(const QString& status, bool logInfo, bool logWarning) +{ + lastStatus = status; + if (logInfo) + qInfo().noquote() << "[Planes]" << lastStatus; + if (logWarning) + qWarning().noquote() << "[Planes]" << lastStatus; + emit statusChanged(lastStatus); +} + +void Planes::applyButtonVisibility() +{ +#ifndef NO_GUI + StelGui* gui = dynamic_cast(StelApp::getInstance().getGui()); + if (!gui) + return; + + if (showButton) + { + if (!toolbarButton) + { + toolbarButton = new StelButton(nullptr, + QPixmap(":/planes/planes_on_160.png"), + QPixmap(":/planes/planes_off_160.png"), + QPixmap(":/graphicGui/miscGlow32x32.png"), + "actionShow_Planes", + false, + "actionShow_Planes_dialog"); + } + gui->getButtonBar()->addButton(toolbarButton, "065-pluginsGroup"); + } + else + { + gui->getButtonBar()->hideButton("actionShow_Planes"); + } +#endif +} + +QList Planes::searchAround(const Vec3d& v, double limitFov, const StelCore* core) const +{ + QList result; + if (!core) + return result; + + const double cosLimitFov = std::cos(limitFov * M_PI / 180.0); + Vec3d normalized = v; + normalized.normalize(); + + for (const AircraftObjectP& object : aircraft) + { + if (!object->isAboveHorizon(core)) + continue; + Vec3d objectPos = object->getJ2000EquatorialPos(core); + objectPos.normalize(); + if (normalized.dot(objectPos) >= cosLimitFov) + result.append(qSharedPointerCast(object)); + } + + return result; +} + +StelObjectP Planes::searchByNameI18n(const QString& nameI18n) const +{ + return searchByName(nameI18n); +} + +StelObjectP Planes::searchByName(const QString& name) const +{ + const QString needle = name.trimmed().toUpper(); + for (const AircraftObjectP& object : aircraft) + { + if (object->getEnglishName().toUpper() == needle || object->getID().toUpper() == needle) + return qSharedPointerCast(object); + } + return StelObjectP(); +} + +StelObjectP Planes::searchByID(const QString& id) const +{ + return searchByName(id); +} + +QVector> Planes::listMatchingObjects(const QString& objPrefix, int maxNbItem, bool useStartOfWords) const +{ + Q_UNUSED(useStartOfWords) + QVector> result; + const QString needle = objPrefix.trimmed().toUpper(); + for (const AircraftObjectP& object : aircraft) + { + if (!object->getEnglishName().toUpper().startsWith(needle) && !object->getID().toUpper().startsWith(needle)) + continue; + result.append(qMakePair(object->getEnglishName(), qSharedPointerCast(object))); + if (result.size() >= maxNbItem) + break; + } + return result; +} + +QVector> Planes::listAllObjects(bool inEnglish) const +{ + Q_UNUSED(inEnglish) + QVector> result; + result.reserve(aircraft.size()); + for (const AircraftObjectP& object : aircraft) + result.append(qMakePair(object->getEnglishName(), qSharedPointerCast(object))); + return result; +} diff --git a/plugins/Planes/src/Planes.hpp b/plugins/Planes/src/Planes.hpp new file mode 100644 index 0000000000000..1318e2154b940 --- /dev/null +++ b/plugins/Planes/src/Planes.hpp @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2013 Felix Zeltner + * Copyright (C) 2026 Kamil Zaraś (astronow.pl) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335, USA. + */ + +#ifndef PLANES_HPP +#define PLANES_HPP + +#include "AircraftObject.hpp" +#include "StelLocation.hpp" +#include "StelObjectModule.hpp" +#include "StelTextureTypes.hpp" + +#include +#include +#include + +class QNetworkAccessManager; +class QNetworkReply; +class QTimer; +class StelButton; +class PlanesDialog; + +class Planes : public StelObjectModule +{ + Q_OBJECT + Q_PROPERTY(bool enabled READ isEnabled WRITE setEnabled NOTIFY enabledChanged) + Q_PROPERTY(bool showLabels READ getFlagShowLabels WRITE setFlagShowLabels NOTIFY showLabelsChanged) + Q_PROPERTY(bool showButton READ getFlagShowButton WRITE setFlagShowButton NOTIFY showButtonChanged) + Q_PROPERTY(int labelMode READ getLabelMode WRITE setLabelMode NOTIFY labelModeChanged) + +public: + Planes(); + ~Planes() override; + + void init() override; + void deinit() override; + void update(double deltaTime) override; + void draw(StelCore* core) override; + double getCallOrder(StelModuleActionName actionName) const override; + bool configureGui(bool show=true) override; + + QList searchAround(const Vec3d& v, double limitFov, const StelCore* core) const override; + StelObjectP searchByNameI18n(const QString& nameI18n) const override; + StelObjectP searchByName(const QString& name) const override; + StelObjectP searchByID(const QString& id) const override; + QVector> listMatchingObjects(const QString& objPrefix, int maxNbItem, bool useStartOfWords) const override; + QVector> listAllObjects(bool inEnglish) const override; + + QString getName() const override { return QStringLiteral("Planes"); } + QString getStelObjectType() const override { return AircraftObject::STEL_TYPE; } + bool isEnabled() const { return enabled; } + bool getFlagShowLabels() const { return showLabels; } + bool getFlagShowButton() const { return showButton; } + int getLabelMode() const { return labelMode; } + int getFetchIntervalSec() const { return fetchIntervalSec; } + int getRadiusNm() const { return radiusNm; } + QString getLastStatus() const { return lastStatus; } + QString getLastSuccessfulUpdate() const { return lastSuccessfulUpdate; } + QString getProviderId() const; + QString getProviderDisplayName() const; + QString getProviderWebsiteUrl() const; + int getProviderMaxRadiusNm() const; + QString getSourceUrlTemplate() const { return sourceUrlTemplate; } + +public slots: + void setEnabled(bool value); + void setFlagShowLabels(bool value); + void setFlagShowButton(bool value); + void setLabelMode(int mode); + void setProviderId(const QString& providerId); + void setFetchIntervalSec(int seconds); + void setRadiusNm(int nm); + void refreshNow(); + +private slots: + void fetchAircraft(); + void onReply(QNetworkReply* reply); + void onLocationChanged(const StelLocation& loc); + +signals: + void enabledChanged(bool value); + void showLabelsChanged(bool value); + void showButtonChanged(bool value); + void labelModeChanged(int mode); + void providerChanged(const QString& providerId); + void fetchIntervalChanged(int seconds); + void radiusChanged(int nm); + void statusChanged(const QString& status); + +private: + void loadSettings(); + void saveSettings() const; + QString buildRequestUrl(const StelCore* core) const; + void applyProviderDefaults(bool clampRadius=true); + void scheduleRefresh(int delayMs=1200); + void updateStatus(const QString& status, bool logInfo=false, bool logWarning=false); + void applyButtonVisibility(); + + QNetworkAccessManager* networkMgr; + QTimer* fetchTimer; + QTimer* refreshDebounceTimer; + QPointer inFlightReply; + QVector aircraft; + StelTextureSP planeTexture; + StelTextureSP pointerTexture; + +#ifndef NO_GUI + PlanesDialog* configDialog; + StelButton* toolbarButton; +#endif + + QString sourceUrlTemplate; + int fetchIntervalSec; + int radiusNm; + bool pendingRefresh; + bool enabled; + bool showLabels; + bool showButton; + bool lastRealtimeState; + int labelMode; + QString lastStatus; + QString lastSuccessfulUpdate; +}; + +#include +#include "StelPluginInterface.hpp" + +class PlanesStelPluginInterface : public QObject, public StelPluginInterface +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID StelPluginInterface_iid) + Q_INTERFACES(StelPluginInterface) + +public: + StelModule* getStelModule() const override; + StelPluginInfo getPluginInfo() const override; + QObjectList getExtensionList() const override { return QObjectList(); } +}; + +#endif diff --git a/plugins/Planes/src/gui/PlanesDialog.cpp b/plugins/Planes/src/gui/PlanesDialog.cpp new file mode 100644 index 0000000000000..b8d58cbac021c --- /dev/null +++ b/plugins/Planes/src/gui/PlanesDialog.cpp @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2013 Felix Zeltner + * Copyright (C) 2026 Kamil Zaraś (astronow.pl) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335, USA. + */ + +#include "gui/PlanesDialog.hpp" +#include "ui_planesDialog.h" + +#include "Planes.hpp" +#include "Dialog.hpp" +#include "StelApp.hpp" +#include "StelGui.hpp" +#include "StelModuleMgr.hpp" +#include "StelTranslator.hpp" + +#include +#include +#include +#include +#include + +namespace +{ +QString providerWebsiteLink(const QString& label, const QString& url) +{ + return QString("%2").arg(url.toHtmlEscaped(), label.toHtmlEscaped()); +} +} + +PlanesDialog::PlanesDialog() + : StelDialog("Planes") + , planes(nullptr) + , ui(new Ui_planesDialog) +{ +} + +PlanesDialog::~PlanesDialog() +{ + delete ui; +} + +void PlanesDialog::updateComboTexts() +{ + if (!ui) + return; + + ui->labelModeComboBox->setItemText(0, q_("Flight number")); + ui->labelModeComboBox->setItemText(1, q_("Aircraft model")); +} + +void PlanesDialog::retranslate() +{ + if (dialog) + { + ui->retranslateUi(dialog); + updateComboTexts(); + setAboutHtml(); + } +} + +void PlanesDialog::createDialogContent() +{ + planes = GETSTELMODULE(Planes); + ui->setupUi(dialog); + + ui->refreshIntervalSpinBox->setMinimum(15); + ui->refreshIntervalSpinBox->setMaximum(60); + ui->refreshIntervalSpinBox->setSingleStep(5); + ui->refreshIntervalSpinBox->setSuffix(QStringLiteral(" s")); + ui->radiusSpinBox->setMinimum(25); + ui->radiusSpinBox->setMaximum(500); + ui->radiusSpinBox->setSingleStep(25); + ui->radiusSpinBox->setSuffix(QStringLiteral(" NM")); + ui->providerComboBox->clear(); + ui->providerComboBox->addItem(QStringLiteral("adsb.fi"), QStringLiteral("adsb_fi")); + ui->providerComboBox->addItem(QStringLiteral("airplanes.live"), QStringLiteral("airplanes_live")); + ui->labelModeComboBox->clear(); + ui->labelModeComboBox->addItem(QString(), 0); + ui->labelModeComboBox->addItem(QString(), 1); + updateComboTexts(); + ui->providerValueLabel->setOpenExternalLinks(false); + ui->providerValueLabel->setTextInteractionFlags(Qt::TextBrowserInteraction); + + kineticScrollingList << ui->aboutTextBrowser; + StelGui* gui = dynamic_cast(StelApp::getInstance().getGui()); + if (gui) + { + enableKineticScrolling(gui->getFlagUseKineticScrolling()); + connect(gui, SIGNAL(flagUseKineticScrollingChanged(bool)), this, SLOT(enableKineticScrolling(bool))); + } + + connect(&StelApp::getInstance(), SIGNAL(languageChanged()), this, SLOT(retranslate())); + connect(ui->titleBar, &TitleBar::closeClicked, this, &StelDialog::close); + connect(ui->titleBar, SIGNAL(movedTo(QPoint)), this, SLOT(handleMovedTo(QPoint))); + + connect(ui->enabledCheckBox, &QCheckBox::toggled, this, &PlanesDialog::setEnabledFlag); + connect(ui->showLabelsCheckBox, &QCheckBox::toggled, this, &PlanesDialog::setShowLabels); + connect(ui->showButtonCheckBox, &QCheckBox::toggled, this, &PlanesDialog::setShowButton); + connect(ui->labelModeComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, &PlanesDialog::setLabelMode); + connect(ui->providerComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, &PlanesDialog::setProvider); + connect(ui->providerValueLabel, &QLabel::linkActivated, this, &PlanesDialog::openExternalLink); + connect(ui->radiusSpinBox, QOverload::of(&QSpinBox::valueChanged), this, &PlanesDialog::setRadius); + connect(ui->refreshIntervalSpinBox, QOverload::of(&QSpinBox::valueChanged), this, &PlanesDialog::setFetchInterval); + connect(ui->refreshButton, &QPushButton::clicked, this, &PlanesDialog::triggerRefresh); + + connect(planes, &Planes::enabledChanged, this, &PlanesDialog::updateFromPlugin); + connect(planes, &Planes::showLabelsChanged, this, &PlanesDialog::updateFromPlugin); + connect(planes, &Planes::showButtonChanged, this, &PlanesDialog::updateFromPlugin); + connect(planes, &Planes::labelModeChanged, this, &PlanesDialog::updateFromPlugin); + connect(planes, &Planes::providerChanged, this, &PlanesDialog::updateFromPlugin); + connect(planes, &Planes::radiusChanged, this, &PlanesDialog::updateFromPlugin); + connect(planes, &Planes::fetchIntervalChanged, this, &PlanesDialog::updateFromPlugin); + connect(planes, &Planes::statusChanged, this, &PlanesDialog::setStatus); + + updateFromPlugin(); + setAboutHtml(); +} + +void PlanesDialog::updateFromPlugin() +{ + if (!planes) + return; + + ui->enabledCheckBox->blockSignals(true); + ui->showLabelsCheckBox->blockSignals(true); + ui->showButtonCheckBox->blockSignals(true); + ui->labelModeComboBox->blockSignals(true); + ui->providerComboBox->blockSignals(true); + ui->radiusSpinBox->blockSignals(true); + ui->refreshIntervalSpinBox->blockSignals(true); + + ui->enabledCheckBox->setChecked(planes->isEnabled()); + ui->showLabelsCheckBox->setChecked(planes->getFlagShowLabels()); + ui->showButtonCheckBox->setChecked(planes->getFlagShowButton()); + ui->labelModeComboBox->setCurrentIndex(planes->getLabelMode()); + ui->providerComboBox->setCurrentText(planes->getProviderDisplayName()); + ui->radiusSpinBox->setMaximum(planes->getProviderMaxRadiusNm()); + ui->radiusSpinBox->setValue(planes->getRadiusNm()); + ui->refreshIntervalSpinBox->setValue(planes->getFetchIntervalSec()); + ui->providerValueLabel->setText(providerWebsiteLink(planes->getProviderDisplayName(), planes->getProviderWebsiteUrl())); + ui->lastUpdateValueLabel->setText(planes->getLastSuccessfulUpdate()); + ui->statusValueLabel->setText(planes->getLastStatus()); + ui->statusValueLabel->setWordWrap(true); + ui->refreshButton->setEnabled(planes->isEnabled()); + + ui->enabledCheckBox->blockSignals(false); + ui->showLabelsCheckBox->blockSignals(false); + ui->showButtonCheckBox->blockSignals(false); + ui->labelModeComboBox->blockSignals(false); + ui->providerComboBox->blockSignals(false); + ui->radiusSpinBox->blockSignals(false); + ui->refreshIntervalSpinBox->blockSignals(false); +} + +void PlanesDialog::setStatus(const QString& status) +{ + ui->statusValueLabel->setText(status); +} + +void PlanesDialog::setEnabledFlag(bool enabled) +{ + if (planes) + planes->setEnabled(enabled); +} + +void PlanesDialog::setShowLabels(bool enabled) +{ + if (planes) + planes->setFlagShowLabels(enabled); +} + +void PlanesDialog::setShowButton(bool enabled) +{ + if (planes) + planes->setFlagShowButton(enabled); +} + +void PlanesDialog::setLabelMode(int index) +{ + if (!planes) + return; + + if (index >= 0) + planes->setLabelMode(ui->labelModeComboBox->itemData(index).toInt()); +} + +void PlanesDialog::setProvider(int index) +{ + if (!planes) + return; + + if (index >= 0) + planes->setProviderId(ui->providerComboBox->itemData(index).toString()); +} + +void PlanesDialog::setRadius(int radiusNm) +{ + if (planes) + planes->setRadiusNm(radiusNm); +} + +void PlanesDialog::setFetchInterval(int seconds) +{ + if (planes) + planes->setFetchIntervalSec(seconds); +} + +void PlanesDialog::triggerRefresh() +{ + if (planes) + planes->refreshNow(); +} + +void PlanesDialog::openExternalLink(const QString& url) +{ + QDesktopServices::openUrl(QUrl(url)); +} + +void PlanesDialog::setAboutHtml() +{ + QString html = ""; + html += "

" + q_("Planes Plug-in") + "

"; + html += ""; + html += ""; + html += ""; + html += "
" + q_("Version") + ":0.1.0
" + q_("License") + ":GPL v2 or later
" + q_("Authors") + ":Felix Zeltner, Georg Zotti, Kamil Zaraś (astronow.pl)
"; + html += "

" + q_("This plug-in shows live ADS-B aircraft as native Stellarium objects.") + "

"; + html += "

" + q_("It provides basic visibility, label, and refresh controls for a live aircraft feed around the current observer location.") + "

"; + html += "

" + q_("Live requests remain disabled until you enable aircraft display. When enabled, the plugin sends the current observer latitude, longitude, and search radius to the configured data source.") + "

"; + html += "

" + q_("The plugin is not loaded at Stellarium startup unless you explicitly enable it in the plug-in manager.") + "

"; + html += ""; + + StelGui* gui = dynamic_cast(StelApp::getInstance().getGui()); + if (gui) + { + QString htmlStyleSheet(gui->getStelStyle().htmlStyleSheet); + ui->aboutTextBrowser->document()->setDefaultStyleSheet(htmlStyleSheet); + } + ui->aboutTextBrowser->setHtml(html); +} diff --git a/plugins/Planes/src/gui/PlanesDialog.hpp b/plugins/Planes/src/gui/PlanesDialog.hpp new file mode 100644 index 0000000000000..f858524e66ebc --- /dev/null +++ b/plugins/Planes/src/gui/PlanesDialog.hpp @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2013 Felix Zeltner + * Copyright (C) 2026 Kamil Zaraś (astronow.pl) + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Suite 500, Boston, MA 02110-1335, USA. + */ + +#ifndef PLANESDIALOG_HPP +#define PLANESDIALOG_HPP + +#include "StelDialog.hpp" + +class Ui_planesDialog; +class Planes; + +class PlanesDialog : public StelDialog +{ + Q_OBJECT + +public: + PlanesDialog(); + ~PlanesDialog() override; + +public slots: + void retranslate() override; + void updateFromPlugin(); + void setStatus(const QString& status); + +protected: + void createDialogContent() override; + +private slots: + void setEnabledFlag(bool enabled); + void setShowLabels(bool enabled); + void setShowButton(bool enabled); + void setLabelMode(int index); + void setProvider(int index); + void setRadius(int radiusNm); + void setFetchInterval(int seconds); + void triggerRefresh(); + void openExternalLink(const QString& url); + +private: + void updateComboTexts(); + void setAboutHtml(); + + Planes* planes; + Ui_planesDialog* ui; +}; + +#endif diff --git a/plugins/Planes/src/gui/planesDialog.ui b/plugins/Planes/src/gui/planesDialog.ui new file mode 100644 index 0000000000000..de72d7dc240ab --- /dev/null +++ b/plugins/Planes/src/gui/planesDialog.ui @@ -0,0 +1,260 @@ + + + planesDialog + + + + 0 + 0 + 700 + 480 + + + + Planes Plug-in Configuration + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Planes Plug-in Configuration + + + + + + + 0 + + + + Settings + + + + + + General + + + + + + Show live aircraft + + + + + + + true + + + When enabled, Stellarium requests live ADS-B positions around the current observer location. + + + + + + + Show aircraft labels + + + + + + + + + Label content + + + + + + + + + + + + Show toolbar button + + + + + + + + + + Live Data + + + + + + Search radius + + + + + + + + + + Refresh interval + + + + + + + + + + + + + + + + + true + + + Intervals below 15 seconds are disabled to reduce the risk of provider rate limits. + + + + + + + Provider + + + + + + + + + + Provider website + + + + + + + true + + + true + + + + + + + + + + Last successful update + + + + + + + true + + + Never + + + + + + + Status + + + + + + + idle + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Update now + + + + + + + + + + About + + + + + + + + + + + + + + + TitleBar + QFrame +
Dialog.hpp
+ 1 +
+
+ +
diff --git a/po/stellarium/POTFILES.in b/po/stellarium/POTFILES.in index 06605f58eb47e..33414519f1e00 100644 --- a/po/stellarium/POTFILES.in +++ b/po/stellarium/POTFILES.in @@ -265,6 +265,10 @@ plugins/OnlineQueries/src/HipOnlineQuery.cpp plugins/OnlineQueries/src/OnlineQueries.cpp plugins/OnlineQueries/src/gui/OnlineQueriesDialog.cpp plugins/OnlineQueries/src/ui_onlineQueriesDialog.h +plugins/Planes/src/AircraftObject.cpp +plugins/Planes/src/Planes.cpp +plugins/Planes/src/gui/PlanesDialog.cpp +plugins/Planes/src/ui_planesDialog.h plugins/LensDistortionEstimator/src/LensDistortionEstimator.cpp plugins/LensDistortionEstimator/src/gui/LensDistortionEstimatorDialog.cpp plugins/LensDistortionEstimator/src/ui_lensDistortionEstimatorDialog.h diff --git a/src/core/StelApp.cpp b/src/core/StelApp.cpp index 7d72a781314d8..154c4bd8360f6 100644 --- a/src/core/StelApp.cpp +++ b/src/core/StelApp.cpp @@ -224,6 +224,10 @@ Q_IMPORT_PLUGIN(VtsStelPluginInterface) Q_IMPORT_PLUGIN(OnlineQueriesPluginInterface) #endif +#ifdef USE_STATIC_PLUGIN_PLANES +Q_IMPORT_PLUGIN(PlanesStelPluginInterface) +#endif + #ifdef USE_STATIC_PLUGIN_NEBULATEXTURES Q_IMPORT_PLUGIN(NebulaTexturesStelPluginInterface) #endif