diff --git a/doc/img/Map_plugin_muf.png b/doc/img/Map_plugin_muf.png new file mode 100644 index 000000000..11ce21f82 Binary files /dev/null and b/doc/img/Map_plugin_muf.png differ diff --git a/plugins/feature/map/cesiuminterface.cpp b/plugins/feature/map/cesiuminterface.cpp index 92701769f..7be9cf9d0 100644 --- a/plugins/feature/map/cesiuminterface.cpp +++ b/plugins/feature/map/cesiuminterface.cpp @@ -159,6 +159,24 @@ void CesiumInterface::setAntiAliasing(const QString &antiAliasing) send(obj); } +void CesiumInterface::showMUF(bool show) +{ + QJsonObject obj { + {"command", "showMUF"}, + {"show", show} + }; + send(obj); +} + +void CesiumInterface::showfoF2(bool show) +{ + QJsonObject obj { + {"command", "showfoF2"}, + {"show", show} + }; + send(obj); +} + void CesiumInterface::updateImage(const QString &name, float east, float west, float north, float south, float altitude, const QString &data) { QJsonObject obj { diff --git a/plugins/feature/map/cesiuminterface.h b/plugins/feature/map/cesiuminterface.h index 8a46c2c85..46a4a4bdb 100644 --- a/plugins/feature/map/cesiuminterface.h +++ b/plugins/feature/map/cesiuminterface.h @@ -64,6 +64,8 @@ public: void setCameraReferenceFrame(bool eci); void setSunLight(bool useSunLight); void setAntiAliasing(const QString &antiAliasing); + void showMUF(bool show); + void showfoF2(bool show); void updateImage(const QString &name, float east, float west, float north, float south, float altitude, const QString &data); void removeImage(const QString &name); void removeAllImages(); diff --git a/plugins/feature/map/czml.cpp b/plugins/feature/map/czml.cpp index ed865ebd5..8e08db6dd 100644 --- a/plugins/feature/map/czml.cpp +++ b/plugins/feature/map/czml.cpp @@ -75,13 +75,8 @@ QJsonObject CZML::update(MapItem *mapItem, bool isTarget, bool isSelected) bool removeObj = false; bool fixedPosition = mapItem->m_fixedPosition; - - float displayDistanceMax = std::numeric_limits::max(); - QString image = mapItem->m_image; - if ((image == "antenna.png") || (image == "antennaam.png") || (image == "antennadab.png") || (image == "antennafm.png") || (image == "antennatime.png")) { - displayDistanceMax = 1000000; - } - if (image == "") { + if (mapItem->m_image == "") + { // Need to remove this from the map removeObj = true; } @@ -212,12 +207,6 @@ QJsonObject CZML::update(MapItem *mapItem, bool isTarget, bool isSelected) {"heightReference", heightReferences[mapItem->m_altitudeReference]}, {"show", mapItem->m_itemSettings->m_enabled && mapItem->m_itemSettings->m_display3DPoint} }; - // If clamping to ground, we need to disable depth test, so part of the point isn't clipped - // However, when the point isn't clamped to ground, we shouldn't use this, otherwise - // the point will become visible through the globe - if (mapItem->m_altitudeReference == 1) { - point.insert("disableDepthTestDistance", 100000000); - } // Model QJsonArray node0Cartesian { @@ -276,8 +265,26 @@ QJsonObject CZML::update(MapItem *mapItem, bool isTarget, bool isSelected) }; // Label + + // Prevent labels from being too cluttered when zoomed out + // FIXME: These values should come from mapItem or mapItemSettings + float displayDistanceMax = std::numeric_limits::max(); + if ((mapItem->m_group == "Beacons") || (mapItem->m_group == "AM") || (mapItem->m_group == "FM") || (mapItem->m_group == "DAB")) { + displayDistanceMax = 1000000; + } else if ((mapItem->m_group == "Station") || (mapItem->m_group == "Radar") || (mapItem->m_group == "Radio Time Transmitters")) { + displayDistanceMax = 10000000; + } else if (mapItem->m_group == "Ionosonde Stations") { + displayDistanceMax = 30000000; + } + + QJsonArray labelPixelOffsetScaleArray { + 1000000, 20, 10000000, 5 + }; + QJsonObject labelPixelOffsetScaleObject { + {"nearFarScalar", labelPixelOffsetScaleArray} + }; QJsonArray labelPixelOffsetArray { - 20, 0 + 1, 0 }; QJsonObject labelPixelOffset { {"cartesian2", labelPixelOffsetArray} @@ -302,14 +309,13 @@ QJsonObject CZML::update(MapItem *mapItem, bool isTarget, bool isSelected) {"show", m_settings->m_displayNames && mapItem->m_itemSettings->m_enabled && mapItem->m_itemSettings->m_display3DLabel}, {"scale", mapItem->m_itemSettings->m_3DLabelScale}, {"pixelOffset", labelPixelOffset}, + {"pixelOffsetScaleByDistance", labelPixelOffsetScaleObject}, {"eyeOffset", labelEyeOffset}, {"verticalOrigin", "BASELINE"}, {"horizontalOrigin", "LEFT"}, {"heightReference", heightReferences[mapItem->m_altitudeReference]}, }; - if (displayDistanceMax != std::numeric_limits::max()) - { - label.insert("disableDepthTestDistance", 100000000.0); + if (displayDistanceMax != std::numeric_limits::max()) { label.insert("distanceDisplayCondition", labelDistanceDisplayCondition); } @@ -323,9 +329,6 @@ QJsonObject CZML::update(MapItem *mapItem, bool isTarget, bool isSelected) {"heightReference", heightReferences[mapItem->m_altitudeReference]}, {"verticalOrigin", "BOTTOM"} // To stop it being cut in half when zoomed out }; - if (mapItem->m_altitudeReference == 1) { - billboard.insert("disableDepthTestDistance", 100000000); - } QJsonObject obj { {"id", id} // id must be unique diff --git a/plugins/feature/map/icons.qrc b/plugins/feature/map/icons.qrc index 9cfa49cfd..e136bbc56 100644 --- a/plugins/feature/map/icons.qrc +++ b/plugins/feature/map/icons.qrc @@ -3,5 +3,7 @@ icons/groundtracks.png icons/clock.png icons/ibp.png + icons/muf.png + icons/fof2.png diff --git a/plugins/feature/map/icons/fof2.png b/plugins/feature/map/icons/fof2.png new file mode 100644 index 000000000..1d1eb334e Binary files /dev/null and b/plugins/feature/map/icons/fof2.png differ diff --git a/plugins/feature/map/icons/muf.png b/plugins/feature/map/icons/muf.png new file mode 100644 index 000000000..8fec210e9 Binary files /dev/null and b/plugins/feature/map/icons/muf.png differ diff --git a/plugins/feature/map/map.qrc b/plugins/feature/map/map.qrc index f57f85053..98f30fc48 100644 --- a/plugins/feature/map/map.qrc +++ b/plugins/feature/map/map.qrc @@ -7,6 +7,7 @@ map/antennadab.png map/antennafm.png map/antennaam.png + map/ionosonde.png map/map3d.html diff --git a/plugins/feature/map/map/ionosonde.png b/plugins/feature/map/map/ionosonde.png new file mode 100644 index 000000000..8ab7f1cfd Binary files /dev/null and b/plugins/feature/map/map/ionosonde.png differ diff --git a/plugins/feature/map/map/map3d.html b/plugins/feature/map/map/map3d.html index 57747267d..3d7f252f8 100644 --- a/plugins/feature/map/map/map3d.html +++ b/plugins/feature/map/map/map3d.html @@ -157,11 +157,40 @@ geocoder: false, fullscreenButton: true, navigationHelpButton: false, - navigationInstructionsInitiallyVisible: false + navigationInstructionsInitiallyVisible: false, + terrainProviderViewModels: [] // User should adjust terrain via dialog, so depthTestAgainstTerrain doesn't get set }); + viewer.scene.globe.depthTestAgainstTerrain = false; // So labels/points aren't clipped by terrain var buildings = undefined; const images = new Map(); + var mufGeoJSONStream = null; + var foF2GeoJSONStream = null; + + // Generate HTML for MUF contour info box from properties in GeoJSON + function describeMUF(properties, nameProperty) { + let html = ""; + if (properties.hasOwnProperty("level-value")) { + const value = properties["level-value"]; + if (Cesium.defined(value)) { + html = `

MUF: ${value} MHz

MUF (Maximum Usable Frequency) is the highest frequency that will reflect from the ionosphere on a 3000km path`; + } + } + return html; + } + + // Generate HTML for foF2 contour info box from properties in GeoJSON + function describefoF2(properties, nameProperty) { + let html = ""; + if (properties.hasOwnProperty("level-value")) { + const value = properties["level-value"]; + if (Cesium.defined(value)) { + html = `

foF2: ${value} MHz

foF2 (F2 region critical frequency) is the highest frequency that will be reflected vertically from the F2 ionosphere region`; + } + } + return html; + } + // Use CZML to stream data from Map plugin to Cesium var czmlStream = new Cesium.CzmlDataSource(); @@ -238,6 +267,7 @@ } else { console.log(`Unknown terrain ${command.terrain}`); } + viewer.scene.globe.depthTestAgainstTerrain = false; // So labels/points aren't clipped by terrain } else if (command.command == "setBuildings") { if (command.buildings == "None") { if (buildings !== undefined) { @@ -274,6 +304,32 @@ } else { viewer.scene.postProcessStages.fxaa.enabled = false; } + } else if (command.command == "showMUF") { + if (mufGeoJSONStream != null) { + viewer.dataSources.remove(mufGeoJSONStream, true); + mufGeoJSONStream = null; + } + if (command.show == true) { + viewer.dataSources.add( + Cesium.GeoJsonDataSource.load( + "muf.geojson", + {describe: describeMUF} + ) + ).then(function(dataSource) {mufGeoJSONStream = dataSource; }); + } + } else if (command.command == "showfoF2") { + if (foF2GeoJSONStream != null) { + viewer.dataSources.remove(foF2GeoJSONStream, true); + foF2GeoJSONStream = null; + } + if (command.show == true) { + viewer.dataSources.add( + Cesium.GeoJsonDataSource.load( + "fof2.geojson", + {describe: describefoF2} + ) + ).then(function(dataSource) {foF2GeoJSONStream = dataSource; }); + } } else if (command.command == "updateImage") { // Textures on entities can flash white when changed: https://github.com/CesiumGS/cesium/issues/1640 @@ -426,7 +482,6 @@ reportClock(); }; - diff --git a/plugins/feature/map/mapgui.cpp b/plugins/feature/map/mapgui.cpp index 56ad80f9e..b28f6a5ed 100644 --- a/plugins/feature/map/mapgui.cpp +++ b/plugins/feature/map/mapgui.cpp @@ -275,6 +275,7 @@ MapGUI::MapGUI(PluginAPI* pluginAPI, FeatureUISet *featureUISet, Feature *featur addRadioTimeTransmitters(); addRadar(); + addIonosonde(); displaySettings(); applySettings(true); @@ -302,6 +303,7 @@ MapGUI::~MapGUI() m_webServer->close(); delete m_webServer; } + delete m_giro; delete ui; } @@ -452,6 +454,72 @@ void MapGUI::addRadar() update(m_map, &radarMapItem, "Radar"); } +// Ionosonde stations +void MapGUI::addIonosonde() +{ + m_giro = GIRO::create(); + if (m_giro) + { + connect(m_giro, &GIRO::dataUpdated, this, &MapGUI::giroDataUpdated); + connect(m_giro, &GIRO::mufUpdated, this, &MapGUI::mufUpdated); + connect(m_giro, &GIRO::foF2Updated, this, &MapGUI::foF2Updated); + } +} + +void MapGUI::giroDataUpdated(const GIRO::GIROStationData& data) +{ + if (!data.m_station.isEmpty()) + { + IonosondeStation *station = nullptr; + // See if we already have the station in our hash + if (!m_ionosondeStations.contains(data.m_station)) + { + // Create new station + station = new IonosondeStation(data); + m_ionosondeStations.insert(data.m_station, station); + } + else + { + station = m_ionosondeStations.value(data.m_station); + } + station->update(data); + + // Add/update map + SWGSDRangel::SWGMapItem ionosondeStationMapItem; + ionosondeStationMapItem.setName(new QString(station->m_name)); + ionosondeStationMapItem.setLatitude(station->m_latitude); + ionosondeStationMapItem.setLongitude(station->m_longitude); + ionosondeStationMapItem.setAltitude(0.0); + ionosondeStationMapItem.setImage(new QString("ionosonde.png")); + ionosondeStationMapItem.setImageRotation(0); + ionosondeStationMapItem.setText(new QString(station->m_text)); + ionosondeStationMapItem.setModel(new QString("antenna.glb")); + ionosondeStationMapItem.setFixedPosition(true); + ionosondeStationMapItem.setOrientation(0); + ionosondeStationMapItem.setLabel(new QString(station->m_label)); + ionosondeStationMapItem.setLabelAltitudeOffset(4.5); + ionosondeStationMapItem.setAltitudeReference(1); + update(m_map, &ionosondeStationMapItem, "Ionosonde Stations"); + } +} + +void MapGUI::mufUpdated(const QJsonDocument& document) +{ + // Could possibly try render on 2D map, but contours + // that cross anti-meridian are not drawn properly + //${Qt5Location_PRIVATE_INCLUDE_DIRS} + //#include + //QVariantList list = QGeoJson::importGeoJson(document); + m_webServer->addFile("/map/map/muf.geojson", document.toJson()); + m_cesium->showMUF(m_settings.m_displayMUF); +} + +void MapGUI::foF2Updated(const QJsonDocument& document) +{ + m_webServer->addFile("/map/map/fof2.geojson", document.toJson()); + m_cesium->showfoF2(m_settings.m_displayfoF2); +} + static QString arrayToString(QJsonArray array) { QString s; @@ -841,7 +909,15 @@ void MapGUI::applyMap3DSettings(bool reloadMap) m_cesium->setCameraReferenceFrame(m_settings.m_eciCamera); m_cesium->setAntiAliasing(m_settings.m_antiAliasing); m_cesium->getDateTime(); + m_cesium->showMUF(m_settings.m_displayMUF); + m_cesium->showfoF2(m_settings.m_displayfoF2); } + MapSettings::MapItemSettings *ionosondeItemSettings = getItemSettings("Ionosonde Stations"); + if (ionosondeItemSettings) { + m_giro->getDataPeriodically(ionosondeItemSettings->m_enabled ? 2 : 0); + } + m_giro->getMUFPeriodically(m_settings.m_displayMUF ? 15 : 0); + m_giro->getfoF2Periodically(m_settings.m_displayfoF2 ? 15 : 0); } void MapGUI::init3DMap() @@ -863,6 +939,9 @@ void MapGUI::init3DMap() // Set 3D view after loading initial objects m_cesium->setHomeView(stationLatitude, stationLongitude); + + m_cesium->showMUF(m_settings.m_displayMUF); + m_cesium->showfoF2(m_settings.m_displayfoF2); } void MapGUI::displaySettings() @@ -874,6 +953,8 @@ void MapGUI::displaySettings() ui->displayNames->setChecked(m_settings.m_displayNames); ui->displaySelectedGroundTracks->setChecked(m_settings.m_displaySelectedGroundTracks); ui->displayAllGroundTracks->setChecked(m_settings.m_displayAllGroundTracks); + ui->displayMUF->setChecked(m_settings.m_displayMUF); + ui->displayfoF2->setChecked(m_settings.m_displayfoF2); m_mapModel.setDisplayNames(m_settings.m_displayNames); m_mapModel.setDisplaySelectedGroundTracks(m_settings.m_displaySelectedGroundTracks); m_mapModel.setDisplayAllGroundTracks(m_settings.m_displayAllGroundTracks); @@ -949,6 +1030,26 @@ void MapGUI::on_displayAllGroundTracks_clicked(bool checked) m_mapModel.setDisplayAllGroundTracks(checked); } +void MapGUI::on_displayMUF_clicked(bool checked) +{ + m_settings.m_displayMUF = checked; + // Only call show if disabling, so we don't get two updates + // (as getMUFPeriodically results in a call to showMUF when the data is available) + m_giro->getMUFPeriodically(m_settings.m_displayMUF ? 15 : 0); + if (m_cesium && !m_settings.m_displayMUF) { + m_cesium->showMUF(m_settings.m_displayMUF); + } +} + +void MapGUI::on_displayfoF2_clicked(bool checked) +{ + m_settings.m_displayfoF2 = checked; + m_giro->getfoF2Periodically(m_settings.m_displayfoF2 ? 15 : 0); + if (m_cesium && !m_settings.m_displayfoF2) { + m_cesium->showfoF2(m_settings.m_displayfoF2); + } +} + void MapGUI::on_find_returnPressed() { find(ui->find->text().trimmed()); @@ -1234,6 +1335,8 @@ void MapGUI::makeUIConnections() QObject::connect(ui->displayNames, &ButtonSwitch::clicked, this, &MapGUI::on_displayNames_clicked); QObject::connect(ui->displayAllGroundTracks, &ButtonSwitch::clicked, this, &MapGUI::on_displayAllGroundTracks_clicked); QObject::connect(ui->displaySelectedGroundTracks, &ButtonSwitch::clicked, this, &MapGUI::on_displaySelectedGroundTracks_clicked); + QObject::connect(ui->displayMUF, &ButtonSwitch::clicked, this, &MapGUI::on_displayMUF_clicked); + QObject::connect(ui->displayfoF2, &ButtonSwitch::clicked, this, &MapGUI::on_displayfoF2_clicked); QObject::connect(ui->find, &QLineEdit::returnPressed, this, &MapGUI::on_find_returnPressed); QObject::connect(ui->maidenhead, &QToolButton::clicked, this, &MapGUI::on_maidenhead_clicked); QObject::connect(ui->deleteAll, &QToolButton::clicked, this, &MapGUI::on_deleteAll_clicked); diff --git a/plugins/feature/map/mapgui.h b/plugins/feature/map/mapgui.h index 4a2d29a2c..fad77974c 100644 --- a/plugins/feature/map/mapgui.h +++ b/plugins/feature/map/mapgui.h @@ -29,6 +29,7 @@ #include "feature/featuregui.h" #include "util/messagequeue.h" +#include "util/giro.h" #include "util/azel.h" #include "settings/rollupstate.h" @@ -62,6 +63,68 @@ struct RadioTimeTransmitter { int m_power; // In kW }; +struct IonosondeStation { + QString m_name; + float m_latitude; // In degrees + float m_longitude; // In degrees + QString m_text; + QString m_label; + + IonosondeStation(const GIRO::GIROStationData& data) : + m_name(data.m_station) + { + update(data); + } + + void update(const GIRO::GIROStationData& data) + { + m_latitude = data.m_latitude; + m_longitude = data.m_longitude; + QStringList text; + QStringList label; + text.append("Ionosonde Station"); + text.append(QString("Name: %1").arg(m_name.split(",")[0])); + if (!isnan(data.m_mufd)) + { + text.append(QString("MUF: %1 MHz").arg(data.m_mufd)); + label.append(QString("%1").arg((int)round(data.m_mufd))); + } + else + { + label.append("-"); + } + if (!isnan(data.m_md)) { + text.append(QString("M(D): %1").arg(data.m_md)); + } + if (!isnan(data.m_foF2)) + { + text.append(QString("foF2: %1 MHz").arg(data.m_foF2)); + label.append(QString("%1").arg((int)round(data.m_foF2))); + } + else + { + label.append("-"); + } + if (!isnan(data.m_hmF2)) { + text.append(QString("hmF2: %1 km").arg(data.m_hmF2)); + } + if (!isnan(data.m_foE)) { + text.append(QString("foE: %1 MHz").arg(data.m_foE)); + } + if (!isnan(data.m_tec)) { + text.append(QString("TEC: %1").arg(data.m_tec)); + } + if (data.m_confidence >= 0) { + text.append(QString("Confidence: %1").arg(data.m_confidence)); + } + if (data.m_dateTime.isValid()) { + text.append(data.m_dateTime.toString()); + } + m_text = text.join("\n"); + m_label = label.join("/"); + } +}; + class MapGUI : public FeatureGUI { Q_OBJECT public: @@ -86,6 +149,7 @@ public: QList getRadioTimeTransmitters() { return m_radioTimeTransmitters; } void addRadioTimeTransmitters(); void addRadar(); + void addIonosonde(); void addDAB(); void find(const QString& target); void track3D(const QString& target); @@ -114,6 +178,8 @@ private: quint16 m_osmPort; OSMTemplateServer *m_templateServer; QTimer m_redrawMapTimer; + GIRO *m_giro; + QHash m_ionosondeStations; CesiumInterface *m_cesium; WebServer *m_webServer; @@ -149,6 +215,8 @@ private slots: void on_displayNames_clicked(bool checked=false); void on_displayAllGroundTracks_clicked(bool checked=false); void on_displaySelectedGroundTracks_clicked(bool checked=false); + void on_displayMUF_clicked(bool checked=false); + void on_displayfoF2_clicked(bool checked=false); void on_find_returnPressed(); void on_maidenhead_clicked(); void on_deleteAll_clicked(); @@ -162,6 +230,9 @@ private slots: virtual bool eventFilter(QObject *obj, QEvent *event); void fullScreenRequested(QWebEngineFullScreenRequest fullScreenRequest); void preferenceChanged(int elementType); + void giroDataUpdated(const GIRO::GIROStationData& data); + void mufUpdated(const QJsonDocument& document); + void foF2Updated(const QJsonDocument& document); }; diff --git a/plugins/feature/map/mapgui.ui b/plugins/feature/map/mapgui.ui index 70834ba70..b9a3d4898 100644 --- a/plugins/feature/map/mapgui.ui +++ b/plugins/feature/map/mapgui.ui @@ -39,13 +39,13 @@ 0 0 - 471 + 480 41 - 350 + 480 0 @@ -165,6 +165,46 @@ + + + + Display MUF contours + + + ^ + + + + :/map/icons/muf.png:/map/icons/muf.png + + + true + + + true + + + + + + + Display foF2 contours + + + ^ + + + + :/map/icons/fof2.png:/map/icons/fof2.png + + + true + + + true + + + @@ -187,12 +227,6 @@ - - - Adobe Devanagari - 9 - - Display ground tracks for selected item @@ -213,12 +247,6 @@ - - - Adobe Devanagari - 9 - - Display all ground tracks @@ -339,7 +367,7 @@ - + 0 diff --git a/plugins/feature/map/mapsettings.cpp b/plugins/feature/map/mapsettings.cpp index 587a7cd62..192654b8d 100644 --- a/plugins/feature/map/mapsettings.cpp +++ b/plugins/feature/map/mapsettings.cpp @@ -69,6 +69,11 @@ MapSettings::MapSettings() : m_itemSettings.insert("Radiosonde", new MapItemSettings("Radiosonde", QColor(102, 0, 102), false, 11, modelMinPixelSize)); m_itemSettings.insert("Radio Time Transmitters", new MapItemSettings("Radio Time Transmitters", QColor(255, 0, 0), true, 8)); m_itemSettings.insert("Radar", new MapItemSettings("Radar", QColor(255, 0, 0), true, 8)); + + MapItemSettings *ionosondeItemSettings = new MapItemSettings("Ionosonde Stations", QColor(255, 255, 0), true, 4); + ionosondeItemSettings->m_display2DIcon = false; + m_itemSettings.insert("Ionosonde Stations", ionosondeItemSettings); + MapItemSettings *stationItemSettings = new MapItemSettings("Station", QColor(255, 0, 0), true, 11); stationItemSettings->m_display2DTrack = false; m_itemSettings.insert("Station", stationItemSettings); @@ -110,6 +115,8 @@ void MapSettings::resetToDefaults() m_eciCamera = false; m_modelDir = HttpDownloadManager::downloadDir() + "/3d"; m_antiAliasing = "None"; + m_displayMUF = false; + m_displayfoF2 = false; m_workspaceIndex = 0; } @@ -152,6 +159,9 @@ QByteArray MapSettings::serialize() const s.writeS32(33, m_workspaceIndex); s.writeBlob(34, m_geometryBytes); + s.writeBool(35, m_displayMUF); + s.writeBool(36, m_displayfoF2); + return s.final(); } @@ -224,6 +234,9 @@ bool MapSettings::deserialize(const QByteArray& data) d.readS32(33, &m_workspaceIndex, 0); d.readBlob(34, &m_geometryBytes); + d.readBool(35, &m_displayMUF, false); + d.readBool(36, &m_displayfoF2, false); + return true; } else diff --git a/plugins/feature/map/mapsettings.h b/plugins/feature/map/mapsettings.h index 7ce04b65f..8f25eb194 100644 --- a/plugins/feature/map/mapsettings.h +++ b/plugins/feature/map/mapsettings.h @@ -104,6 +104,9 @@ struct MapSettings QString m_cesiumIonAPIKey; QString m_antiAliasing; + bool m_displayMUF; // Plot MUF contours + bool m_displayfoF2; // Plot foF2 contours + // Per source settings QHash m_itemSettings; diff --git a/plugins/feature/map/readme.md b/plugins/feature/map/readme.md index 35e9208f7..32df1db55 100644 --- a/plugins/feature/map/readme.md +++ b/plugins/feature/map/readme.md @@ -11,10 +11,14 @@ On top of this, it can plot data from other plugins, such as: * Satellites from the Satellite Tracker, * Weather imagery from APT Demodulator, * The Sun, Moon and Stars from the Star Tracker, -* Weather ballons from the RadioSonde feature, +* Weather ballons from the RadioSonde feature. + +As well as other other data sources: + * Beacons based on the IARU Region 1 beacon database and International Beacon Project, * Radio time transmitters, -* GRAVES radar. +* GRAVES radar, +* Ionosonde station data. It can also create tracks showing the path aircraft, ships and APRS objects have taken, as well as predicted paths for satellites. @@ -22,7 +26,7 @@ It can also create tracks showing the path aircraft, ships and APRS objects have ![3D Map feature](../../../doc/img/Map_plugin_apt.png) -3D Models are not included with SDRangel. They must be downloaded by pressing the Download 3D Models button in the Display Settings dialog (11). +3D Models are not included with SDRangel. They must be downloaded by pressing the Download 3D Models button in the Display Settings dialog (13).

Interface

@@ -78,23 +82,33 @@ When clicked, opens the Radio Time Transmitters dialog. ![Radio Time transmitters dialog](../../../doc/img/Map_plugin_radiotime_dialog.png) -

7: Display Names

+

7: Display MUF Contours

+ +When checked, contours will be downloaded and displayed on the 3D map, showing the MUF (Maximum Usable Frequency) for a 3000km path that reflects off the ionosphere. +The contours will be updated every 15 minutes. The latest contour data will always be displayed, irrespective of the time set on the 3D Map. + +

8: Display coF2 Contours

+ +When checked, contours will be downloaded and displayed on the 3D map, showing coF2 (F2 layer critical frequency), the maximum frequency at which radio waves will be reflected vertically from the F2 region of the ionosphere. +The contours will be updated every 15 minutes. The latest contour data will always be displayed, irrespective of the time set on the 3D Map. + +

8: Display Names

When checked, names of objects are displayed in a bubble next to each object. -

8: Display tracks for selected object

+

9: Display tracks for selected object

When checked, displays the track (taken or predicted) for the selected object. -

9: Display tracks for all objects

+

10: Display tracks for all objects

When checked, displays the track (taken or predicted) for the all objects. -

10: Delete

+

11: Delete

When clicked, all items will be deleted from the map. -

11: Display settings

+

12: Display settings

When clicked, opens the Map Display Settings dialog: @@ -154,6 +168,25 @@ The 2D map will only display the last reported positions for objects. The 3D map, however, has a timeline that allows replaying how objects have moved over time. To the right of the timeline is the fullscreen toggle button, which allows the 3D map to be displayed fullscreen. +

Ionosonde Stations

+ +When Ionosonde Stations are displayed, data is downloaded and displayed every 2 minutes. The data includes: + +* MUF - Maximum Usable Frequency in MHz for 3000km path. +* M(D) - M-factor (~MUF/foF2) for 3000km path. +* foF2 - F2 region critical frequency in MHz. +* hmF2 - F2 region height in km. +* foE - E region critical frequency in MHz. +* TEC - Total Electron Content. + +Each station is labelled on the maps as "MUF/foF2". + +MUF and foF2 can be displayed as countors: + +![MUF contours](../../../doc/img/Map_plugin_muf.png) + +The contours can be clicked on which will display the data for that contour in the info box. +

Attribution

IARU Region 1 beacon list used with permission from: https://iaru-r1-c5-beacons.org/ To add or update a beacon, see: https://iaru-r1-c5-beacons.org/index.php/beacon-update/ @@ -161,7 +194,10 @@ IARU Region 1 beacon list used with permission from: https://iaru-r1-c5-beacons. Mapping and geolocation services are by Open Street Map: https://www.openstreetmap.org/ esri: https://www.esri.com/ Mapbox: https://www.mapbox.com/ Cesium: https://www.cesium.com Bing: https://www.bing.com/maps/ +Ionosonde data and MUF/coF2 contours from [KC2G](https://prop.kc2g.com/) with source data from [GIRO](https://giro.uml.edu/) and [NOAA NCEI](https://www.ngdc.noaa.gov/stp/iono/ionohome.html). + Icons made by Google from Flaticon https://www.flaticon.com +World icons created by turkkub from Flaticon https://www.flaticon.com 3D models are by various artists under a variety of liceneses. See: https://github.com/srcejon/sdrangel-3d-models diff --git a/plugins/feature/map/webserver.cpp b/plugins/feature/map/webserver.cpp index 17c5e4a80..c0e336b89 100644 --- a/plugins/feature/map/webserver.cpp +++ b/plugins/feature/map/webserver.cpp @@ -37,6 +37,7 @@ WebServer::WebServer(quint16 &port, QObject* parent) : m_mimeTypes.insert(".js", new MimeType("text/javascript")); m_mimeTypes.insert(".css", new MimeType("text/css")); m_mimeTypes.insert(".json", new MimeType("application/json")); + m_mimeTypes.insert(".geojson", new MimeType("application/geo+json")); } void WebServer::incomingConnection(qintptr socket) @@ -88,6 +89,11 @@ QString WebServer::substitute(QString path, QString html) return html; } +void WebServer::addFile(const QString &path, const QByteArray &data) +{ + m_files.insert(path, data); +} + void WebServer::sendFile(QTcpSocket* socket, const QByteArray &data, MimeType *mimeType, const QString &path) { QString header = QString("HTTP/1.0 200 Ok\r\nContent-Type: %1\r\n\r\n").arg(mimeType->m_type); @@ -163,9 +169,14 @@ void WebServer::readClient() sendFile(socket, data, mimeType, path); } #endif + else if (m_files.contains(path)) + { + // Path is a file held in memory + sendFile(socket, m_files.value(path).data(), mimeType, path); + } else { - // See if we can find a file + // See if we can find a file on disk QFile file(path); if (file.open(QIODevice::ReadOnly)) { diff --git a/plugins/feature/map/webserver.h b/plugins/feature/map/webserver.h index d69ca58cf..2ee094a7b 100644 --- a/plugins/feature/map/webserver.h +++ b/plugins/feature/map/webserver.h @@ -49,12 +49,15 @@ class WebServer : public QTcpServer private: - // Hash of a list of paths to substitude + // Hash of a list of paths to substitute QHash m_pathSubstitutions; // Hash of path to a list of substitutions to make in the file QHash*> m_substitutions; + // Hash of files held in memory + QHash m_files; + // Hash of filename extension to MIME type information QHash m_mimeTypes; MimeType m_defaultMimeType; @@ -64,6 +67,7 @@ public: void incomingConnection(qintptr socket) override; void addPathSubstitution(const QString &from, const QString &to); void addSubstitution(QString path, QString from, QString to); + void addFile(const QString &path, const QByteArray &data); QString substitute(QString path, QString html); void sendFile(QTcpSocket* socket, const QByteArray &data, MimeType *mimeType, const QString &path); diff --git a/sdrbase/CMakeLists.txt b/sdrbase/CMakeLists.txt index 8ebd6369f..c28073864 100644 --- a/sdrbase/CMakeLists.txt +++ b/sdrbase/CMakeLists.txt @@ -181,6 +181,7 @@ set(sdrbase_SOURCES util/fixedtraits.cpp util/fits.cpp util/flightinformation.cpp + util/giro.cpp util/golay2312.cpp util/httpdownloadmanager.cpp util/interpolation.cpp @@ -395,6 +396,7 @@ set(sdrbase_HEADERS util/fixedtraits.h util/fits.h util/flightinformation.h + util/giro.h util/golay2312.h util/httpdownloadmanager.h util/incrementalarray.h diff --git a/sdrbase/util/giro.cpp b/sdrbase/util/giro.cpp new file mode 100644 index 000000000..fb770fdc1 --- /dev/null +++ b/sdrbase/util/giro.cpp @@ -0,0 +1,227 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2022 Jon Beniston, M7RCE // +// // +// 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 as version 3 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 V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#include "giro.h" + +#include +#include +#include +#include +#include + +GIRO::GIRO() +{ + connect(&m_dataTimer, &QTimer::timeout, this, &GIRO::getData); + connect(&m_mufTimer, &QTimer::timeout, this, &GIRO::getMUF); + connect(&m_foF2Timer, &QTimer::timeout, this, &GIRO::getfoF2); + m_networkManager = new QNetworkAccessManager(); + connect(m_networkManager, &QNetworkAccessManager::finished, this, &GIRO::handleReply); +} + +GIRO::~GIRO() +{ + disconnect(&m_dataTimer, &QTimer::timeout, this, &GIRO::getData); + disconnect(&m_mufTimer, &QTimer::timeout, this, &GIRO::getMUF); + disconnect(&m_foF2Timer, &QTimer::timeout, this, &GIRO::getfoF2); + disconnect(m_networkManager, &QNetworkAccessManager::finished, this, &GIRO::handleReply); + delete m_networkManager; +} + +GIRO* GIRO::create(const QString& service) +{ + if (service == "prop.kc2g.com") + { + return new GIRO(); + } + else + { + qDebug() << "GIRO::create: Unsupported service: " << service; + return nullptr; + } +} + +void GIRO::getDataPeriodically(int periodInMins) +{ + if (periodInMins > 0) + { + m_dataTimer.setInterval(periodInMins*60*1000); + m_dataTimer.start(); + getData(); + } + else + { + m_dataTimer.stop(); + } +} + +void GIRO::getMUFPeriodically(int periodInMins) +{ + if (periodInMins > 0) + { + m_mufTimer.setInterval(periodInMins*60*1000); + m_mufTimer.start(); + getMUF(); + } + else + { + m_mufTimer.stop(); + } +} + +void GIRO::getfoF2Periodically(int periodInMins) +{ + if (periodInMins > 0) + { + m_foF2Timer.setInterval(periodInMins*60*1000); + m_foF2Timer.start(); + getfoF2(); + } + else + { + m_foF2Timer.stop(); + } +} + +void GIRO::getData() +{ + QUrl url(QString("https://prop.kc2g.com/api/stations.json")); + m_networkManager->get(QNetworkRequest(url)); +} + +void GIRO::getMUF() +{ + QUrl url(QString("https://prop.kc2g.com/renders/current/mufd-normal-now.geojson")); + m_networkManager->get(QNetworkRequest(url)); +} + +void GIRO::getfoF2() +{ + QUrl url(QString("https://prop.kc2g.com/renders/current/fof2-normal-now.geojson")); + m_networkManager->get(QNetworkRequest(url)); +} + +bool GIRO::containsNonNull(const QJsonObject& obj, const QString &key) const +{ + if (obj.contains(key)) + { + QJsonValue val = obj.value(key); + return !val.isNull(); + } + return false; +} + +void GIRO::handleReply(QNetworkReply* reply) +{ + if (reply) + { + if (!reply->error()) + { + QJsonDocument document = QJsonDocument::fromJson(reply->readAll()); + + if (reply->url().fileName() == "stations.json") + { + if (document.isArray()) + { + QJsonArray array = document.array(); + for (auto valRef : array) + { + if (valRef.isObject()) + { + QJsonObject obj = valRef.toObject(); + + GIROStationData data; + + if (obj.contains(QStringLiteral("station"))) + { + QJsonObject stationObj = obj.value(QStringLiteral("station")).toObject(); + + if (stationObj.contains(QStringLiteral("name"))) { + data.m_station = stationObj.value(QStringLiteral("name")).toString(); + } + if (stationObj.contains(QStringLiteral("latitude"))) { + data.m_latitude = (float)stationObj.value(QStringLiteral("latitude")).toString().toFloat(); + } + if (stationObj.contains(QStringLiteral("longitude"))) { + data.m_longitude = (float)stationObj.value(QStringLiteral("longitude")).toString().toFloat(); + if (data.m_longitude >= 180.0f) { + data.m_longitude -= 360.0f; + } + } + } + + if (containsNonNull(obj, QStringLiteral("time"))) { + data.m_dateTime = QDateTime::fromString(obj.value(QStringLiteral("time")).toString(), Qt::ISODateWithMs); + } + if (containsNonNull(obj, QStringLiteral("mufd"))) { + data.m_mufd = (float)obj.value(QStringLiteral("mufd")).toDouble(); + } + if (containsNonNull(obj, QStringLiteral("md"))) { + data.m_md = obj.value(QStringLiteral("md")).toString().toFloat(); + } + if (containsNonNull(obj, QStringLiteral("tec"))) { + data.m_tec = (float)obj.value(QStringLiteral("tec")).toDouble(); + } + if (containsNonNull(obj, QStringLiteral("fof2"))) { + data.m_foF2 = (float)obj.value(QStringLiteral("fof2")).toDouble(); + } + if (containsNonNull(obj, QStringLiteral("hmf2"))) { + data.m_hmF2 = (float)obj.value(QStringLiteral("hmf2")).toDouble(); + } + if (containsNonNull(obj, QStringLiteral("foe"))) { + data.m_foE = (float)obj.value(QStringLiteral("foe")).toDouble(); + } + if (containsNonNull(obj, QStringLiteral("cs"))) { + data.m_confidence = (int)obj.value(QStringLiteral("cs")).toDouble(); + } + + emit dataUpdated(data); + } + else + { + qDebug() << "GIRO::handleReply: Array element is not an object: " << valRef; + } + } + } + else + { + qDebug() << "GIRO::handleReply: Document is not an array: " << document; + } + } + else if (reply->url().fileName() == "mufd-normal-now.geojson") + { + emit mufUpdated(document); + } + else if (reply->url().fileName() == "fof2-normal-now.geojson") + { + emit foF2Updated(document); + } + else + { + qDebug() << "GIRO::handleReply: unexpected filename: " << reply->url().fileName(); + } + } + else + { + qDebug() << "GIRO::handleReply: error: " << reply->error(); + } + reply->deleteLater(); + } + else + { + qDebug() << "GIRO::handleReply: reply is null"; + } +} diff --git a/sdrbase/util/giro.h b/sdrbase/util/giro.h new file mode 100644 index 000000000..fb176d1f0 --- /dev/null +++ b/sdrbase/util/giro.h @@ -0,0 +1,99 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2022 Jon Beniston, M7RCE // +// // +// 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 as version 3 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 V3 for more details. // +// // +// You should have received a copy of the GNU General Public License // +// along with this program. If not, see . // +/////////////////////////////////////////////////////////////////////////////////// + +#ifndef INCLUDE_GIRO_H +#define INCLUDE_GIRO_H + +#include +#include +#include + +#include "export.h" + +class QNetworkAccessManager; +class QNetworkReply; + +// GIRO - Global Ionosphere Radio Observatory +// Gets MUFD, TEC, foF2 and other data for various stations around the world +// Also gets MUF and foF2 contours as GeoJSON +// Data from https://prop.kc2g.com/stations/ +class SDRBASE_API GIRO : public QObject +{ + Q_OBJECT +protected: + GIRO(); + +public: + // See the following paper for an explanation of some of these variables + // https://sbgf.org.br/mysbgf/eventos/expanded_abstracts/13th_CISBGf/A%20Simple%20Method%20to%20Calculate%20the%20Maximum%20Usable%20Frequency.pdf + struct GIROStationData { + QString m_station; + float m_latitude; + float m_longitude; + QDateTime m_dateTime; + float m_mufd; // Maximum usable frequency + float m_md; // Propagation coefficient? D=3000km? + float m_tec; // Total electron content + float m_foF2; // Critical frequency of F2 layer in ionosphere (highest frequency to be reflected) + float m_hmF2; // F2 layer height of peak electron density (km?) + float m_foE; // Critical frequency of E layer + int m_confidence; + GIROStationData() : + m_latitude(NAN), + m_longitude(NAN), + m_mufd(NAN), + m_md(NAN), + m_tec(NAN), + m_foF2(NAN), + m_hmF2(NAN), + m_foE(NAN), + m_confidence(-1) + { + } + }; + + static GIRO* create(const QString& service="prop.kc2g.com"); + + ~GIRO(); + void getDataPeriodically(int periodInMins); + void getMUFPeriodically(int periodInMins); + void getfoF2Periodically(int periodInMins); + +private slots: + void getData(); + void getMUF(); + void getfoF2(); + +private slots: + void handleReply(QNetworkReply* reply); + +signals: + void dataUpdated(const GIROStationData& data); // Called when new data available. + void mufUpdated(const QJsonDocument& doc); + void foF2Updated(const QJsonDocument& doc); + +private: + bool GIRO::containsNonNull(const QJsonObject& obj, const QString &key) const; + + QTimer m_dataTimer; // Timer for periodic updates + QTimer m_mufTimer; + QTimer m_foF2Timer; + QNetworkAccessManager *m_networkManager; + +}; + +#endif /* INCLUDE_GIRO_H */