diff --git a/plugins/feature/map/cesiuminterface.cpp b/plugins/feature/map/cesiuminterface.cpp index 031d027a3..84857c1d0 100644 --- a/plugins/feature/map/cesiuminterface.cpp +++ b/plugins/feature/map/cesiuminterface.cpp @@ -50,6 +50,15 @@ void CesiumInterface::setView(float latitude, float longitude, float altitude) send(obj); } +void CesiumInterface::setViewFirstPerson(bool firstPerson) +{ + QJsonObject obj { + {"command", "setViewFirstPerson"}, + {"firstPerson", firstPerson} + }; + send(obj); +} + // Play glTF model animation for the map item with the specified name void CesiumInterface::playAnimation(const QString &name, Animation *animation) { diff --git a/plugins/feature/map/cesiuminterface.h b/plugins/feature/map/cesiuminterface.h index 3322c65fa..a82606617 100644 --- a/plugins/feature/map/cesiuminterface.h +++ b/plugins/feature/map/cesiuminterface.h @@ -56,6 +56,7 @@ public: CesiumInterface(const MapSettings *settings, QObject *parent = nullptr); void setHomeView(float latitude, float longitude, float angle=1.0f); void setView(float latitude, float longitude, float altitude=60000); + void setViewFirstPerson(bool firstPerson); void playAnimation(const QString &name, Animation *animation); void setDateTime(QDateTime dateTime); void getDateTime(); @@ -69,6 +70,7 @@ public: void setHDR(bool enabled); void setFog(bool enabled); void showFPS(bool show); + void showPFD(bool show); void showMUF(bool show); void showfoF2(bool show); void showMagDec(bool show); diff --git a/plugins/feature/map/czml.cpp b/plugins/feature/map/czml.cpp index 26aca7e22..b086106ba 100644 --- a/plugins/feature/map/czml.cpp +++ b/plugins/feature/map/czml.cpp @@ -23,6 +23,8 @@ #include "util/coordinates.h" +// FIXME: Cesium now has some additional options: CLAMP_TO_TERRAIN, RELATIVE_TO_TERRAIN, CLAMP_TO_3D_TILE, RELATIVE_TO_3D_TILE +// CLIP_TO_GROUND is our own addition const QStringList CZML::m_heightReferences = {"NONE", "CLAMP_TO_GROUND", "RELATIVE_TO_GROUND", "CLIP_TO_GROUND"}; CZML::CZML(const MapSettings *settings) : @@ -48,7 +50,8 @@ bool CZML::filter(const MapItem *mapItem) const QJsonObject CZML::init() { - QString start = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); + // Start a few seconds in the past, to allow for data to be received + QString start = QDateTime::currentDateTimeUtc().addSecs(-4).toString(Qt::ISODate); QString stop = QDateTime::currentDateTimeUtc().addSecs(60*60).toString(Qt::ISODate); QString interval = QString("%1/%2").arg(start).arg(stop); @@ -241,6 +244,48 @@ QJsonObject CZML::update(PolylineMapItem *mapItem) return obj; } +static void insertConstantProperty(QJsonObject& properties, const QString& name, const QString& value) +{ + properties.insert(name, value); +} + +static void insertProperty(QJsonObject& properties, const QString& name, const QString& dateTime, float value) +{ + if (!std::isnan(value)) + { + QJsonArray ar; + ar.push_back(dateTime); + ar.push_back(value); + QJsonObject obj { + {"number", ar}, + {"backwardExtrapolationType", "HOLD"}, + {"backwardExtrapolationDuration", 30}, + {"forwardExtrapolationType", "HOLD"}, + {"forwardExtrapolationDuration", 30}, + }; + properties.insert(name, obj); + } +} + +// SampledProperties are interpolated +// Need to use intervals to avoid interpolation +// See: https://sandcastle.cesium.com/?src=CZML%20Custom%20Properties.html&label=All +static void insertProperty0(QJsonObject& properties, const QString& name, const QString& dateTime, float value) +{ + if (!std::isnan(value)) + { + QJsonObject obj { + {"interval", dateTime + "/3000"}, // Year 3000 + {"number", value} + }; + + QJsonArray array { + obj + }; + properties.insert(name, array); + } +} + QJsonObject CZML::update(ObjectMapItem *mapItem, bool isTarget, bool isSelected) { (void) isTarget; @@ -274,8 +319,9 @@ QJsonObject CZML::update(ObjectMapItem *mapItem, bool isTarget, bool isSelected) // Keep a hash of the time we first saw each item bool existingId = m_ids.contains(id); + State& state = m_ids[id]; if (!existingId) { - m_ids.insert(id, dt); + state.m_firstSeenDateTime = dt; } bool removeObj = false; @@ -319,13 +365,48 @@ QJsonObject CZML::update(ObjectMapItem *mapItem, bool isTarget, bool isSelected) } else { - // Only send latest position, to reduce processing - if (!fixedPosition && mapItem->m_positionDateTime.isValid()) { - coords.push_back(mapItem->m_positionDateTime.toString(Qt::ISODateWithMs)); + bool useDateTime = !fixedPosition && mapItem->m_positionDateTime.isValid(); + + // Update positions that have been recalculated with interpolation + if (!mapItem->m_interpolatedCoords.isEmpty()) + { + for (int i = 0; i < mapItem->m_interpolatedCoords.size(); i++) + { + if (useDateTime) { + coords.push_back(mapItem->m_interpolatedDateTimes[i]->toString(Qt::ISODateWithMs)); + } + coords.push_back(mapItem->m_interpolatedCoords[i]->longitude()); + coords.push_back(mapItem->m_interpolatedCoords[i]->latitude()); + coords.push_back(mapItem->m_interpolatedCoords[i]->altitude()); + } + mapItem->m_interpolatedCoords.clear(); + mapItem->m_interpolatedDateTimes.clear(); + } + else + { + // Only send latest position, to reduce processing + if (!mapItem->m_takenTrackPositionExtrapolated.isEmpty() && mapItem->m_takenTrackPositionExtrapolated.back()) + { + if (useDateTime) { + coords.push_back(mapItem->m_takenTrackDateTimes.back()->toString(Qt::ISODateWithMs)); + } + coords.push_back(mapItem->m_takenTrackCoords.back()->longitude()); + coords.push_back(mapItem->m_takenTrackCoords.back()->latitude()); + } + else + { + if (useDateTime) { + coords.push_back(mapItem->m_positionDateTime.toString(Qt::ISODateWithMs)); + } + coords.push_back(mapItem->m_longitude); + coords.push_back(mapItem->m_latitude); + } + if (!mapItem->m_takenTrackAltitudeExtrapolated.isEmpty() && mapItem->m_takenTrackAltitudeExtrapolated.back()) { + coords.push_back(mapItem->m_takenTrackCoords.back()->altitude()); + } else { + coords.push_back(mapItem->m_altitude + mapItem->m_modelAltitudeOffset); // See nodeTransformations comment below, as to why we use this here + } } - coords.push_back(mapItem->m_longitude); - coords.push_back(mapItem->m_latitude); - coords.push_back(mapItem->m_altitude); } } else @@ -343,6 +424,7 @@ QJsonObject CZML::update(ObjectMapItem *mapItem, bool isTarget, bool isSelected) { // Need 2 different positions to enable extrapolation, otherwise entity may not appear bool hasMoved = m_hasMoved.contains(id); + if (!hasMoved && m_lastPosition.contains(id) && (m_lastPosition.value(id) != coords)) { hasMoved = true; @@ -351,12 +433,16 @@ QJsonObject CZML::update(ObjectMapItem *mapItem, bool isTarget, bool isSelected) if (hasMoved && (mapItem->m_itemSettings->m_extrapolate > 0)) { position.insert("forwardExtrapolationType", "EXTRAPOLATE"); + //position.insert("forwardExtrapolationType", "LINEAR_EXTRAPOLATE"); // LAGRANGE is poor for extrapolation + //position.insert("forwardExtrapolationType", "HOLD"); // Keeps at last position position.insert("forwardExtrapolationDuration", mapItem->m_itemSettings->m_extrapolate); - // Use linear interpolation for now - other two can go crazy with aircraft on the ground + // To calc acceleration, we need to use non-linear interpolation. + position.insert("interpolationAlgorithm", "LINEAR"); + position.insert("interpolationDegree", 2); //position.insert("interpolationAlgorithm", "HERMITE"); - //position.insert("interpolationDegree", "2"); + //position.insert("interpolationDegree", 2); //position.insert("interpolationAlgorithm", "LAGRANGE"); - //position.insert("interpolationDegree", "5"); + //position.insert("interpolationDegree", 5); // crazy interpolation for LAGRANGE } else { @@ -365,11 +451,11 @@ QJsonObject CZML::update(ObjectMapItem *mapItem, bool isTarget, bool isSelected) } else { - // Interpolation goes wrong at end points + // Interpolation goes wrong at end points. FIXME: Check if still true //position.insert("interpolationAlgorithm", "LAGRANGE"); - //position.insert("interpolationDegree", "5"); + //position.insert("interpolationDegree", 5); //position.insert("interpolationAlgorithm", "HERMITE"); - //position.insert("interpolationDegree", "2"); + //position.insert("interpolationDegree", 2); } } @@ -398,151 +484,235 @@ QJsonObject CZML::update(ObjectMapItem *mapItem, bool isTarget, bool isSelected) {"forwardExtrapolationType", "NONE"} }; - // Point - QColor pointColor = QColor::fromRgba(mapItem->m_itemSettings->m_3DPointColor); - QJsonArray pointRGBA { - pointColor.red(), pointColor.green(), pointColor.blue(), pointColor.alpha() - }; - QJsonObject pointColorObj { - {"rgba", pointRGBA} - }; - QJsonObject point { - {"pixelSize", 8}, - {"color", pointColorObj}, - {"heightReference", heightReferences[mapItem->m_altitudeReference]}, - {"show", mapItem->m_itemSettings->m_enabled && mapItem->m_itemSettings->m_display3DPoint} - }; - - // Model - QJsonArray node0Cartesian { - {0.0, mapItem->m_modelAltitudeOffset, 0.0} - }; - QJsonObject node0Translation { - {"cartesian", node0Cartesian} - }; - QJsonObject node0Transform { - {"translation", node0Translation} - }; - QJsonObject nodeTransforms { - {"node0", node0Transform}, - }; - QJsonObject model { - {"gltf", m_settings->m_modelURL + mapItem->m_model}, - {"incrementallyLoadTextures", false}, // Aircraft will flash as they appear without textures if this is the default of true - {"heightReference", heightReferences[mapItem->m_altitudeReference]}, - {"runAnimations", false}, - {"show", mapItem->m_itemSettings->m_enabled && mapItem->m_itemSettings->m_display3DModel}, - {"minimumPixelSize", mapItem->m_itemSettings->m_3DModelMinPixelSize}, - {"maximumScale", 20000} // Stop it getting too big when zoomed really far out - }; - if (mapItem->m_modelAltitudeOffset != 0.0) { - model.insert("nodeTransformations", nodeTransforms); - } - - // Path - QColor pathColor = QColor::fromRgba(mapItem->m_itemSettings->m_3DTrackColor); - QJsonArray pathColorRGBA { - pathColor.red(), pathColor.green(), pathColor.blue(), pathColor.alpha() - }; - QJsonObject pathColorObj { - {"rgba", pathColorRGBA} - }; - // Paths can't be clamped to ground, so AIS paths can be underground if terrain is used - // See: https://github.com/CesiumGS/cesium/issues/7133 - QJsonObject pathSolidColorMaterial { - {"color", pathColorObj} - }; - QJsonObject pathMaterial { - {"solidColor", pathSolidColorMaterial} - }; - bool showPath = mapItem->m_itemSettings->m_enabled - && mapItem->m_itemSettings->m_display3DTrack - && ( m_settings->m_displayAllGroundTracks - || (m_settings->m_displaySelectedGroundTracks && isSelected)); - QJsonObject path { - // We want full paths for sat tracker, so leadTime and trailTime should be 0 - // Should be configurable.. 6000=100mins ~> 1 orbit for LEO - //{"leadTime", "6000"}, - //{"trailTime", "6000"}, - {"width", "3"}, - {"material", pathMaterial}, - {"show", showPath} - }; - - // 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") - || (mapItem->m_group == "NavAid") - || (mapItem->m_group == "Waypoints") - ) { - 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 { - 1, 0 - }; - QJsonObject labelPixelOffset { - {"cartesian2", labelPixelOffsetArray} - }; - QJsonArray labelEyeOffsetArray { - 0, mapItem->m_labelAltitudeOffset, 0 // Position above the object, dependent on the height of the model - }; - QJsonObject labelEyeOffset { - {"cartesian", labelEyeOffsetArray} - }; - QJsonObject labelHorizontalOrigin { - {"horizontalOrigin", "LEFT"} - }; - QJsonArray labelDisplayDistance { - 0, displayDistanceMax - }; - QJsonObject labelDistanceDisplayCondition { - {"distanceDisplayCondition", labelDisplayDistance} - }; - QString labelText = mapItem->m_label; - labelText.replace("
", "\n"); - QJsonObject label { - {"text", labelText}, - {"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("distanceDisplayCondition", labelDistanceDisplayCondition); - } - - // Use billboard for APRS as we don't currently have 3D objects - QString imageURL = mapItem->m_image; - if (imageURL.startsWith("qrc://")) { - imageURL = imageURL.mid(6); // Redirect to our embedded webserver, which will check resources - } - QJsonObject billboard { - {"image", imageURL}, - {"heightReference", heightReferences[mapItem->m_altitudeReference]}, - {"verticalOrigin", "BOTTOM"} // To stop it being cut in half when zoomed out - }; - if (!removeObj) { + if (mapItem->m_aircraftState) + { + QJsonObject properties; + QDateTime aircraftStateDateTime = mapItem->m_positionDateTime; + QString dateTime = aircraftStateDateTime.toString(Qt::ISODateWithMs); + bool aircraftStateDateTimeChanged = aircraftStateDateTime != state.m_aircraftStateDateTime; + QString aircraftCallsign = mapItem->m_aircraftState->m_callsign; + QString aircraftType = mapItem->m_aircraftState->m_aircraftType; + QString aircraftIndicatedAirspeedDateTime = mapItem->m_aircraftState->m_indicatedAirspeedDateTime; + float aircraftIndicatedAirspeed = mapItem->m_aircraftState->m_indicatedAirspeed; + float aircraftTrueAirspeed = mapItem->m_aircraftState->m_trueAirspeed; + float aircraftGroundspeed = mapItem->m_aircraftState->m_groundspeed; + QString aircraftAltitudeDateTime = mapItem->m_aircraftState->m_altitudeDateTime; + float aircraftAltitude = mapItem->m_aircraftState->m_altitude; + int aircraftOnSurface = mapItem->m_aircraftState->m_onSurface; + float aircraftMach = mapItem->m_aircraftState->m_mach; + float aircraftQNH = mapItem->m_aircraftState->m_qnh; + float aircraftVerticalSpeed = mapItem->m_aircraftState->m_verticalSpeed; + float aircraftHeading = mapItem->m_aircraftState->m_heading; + float aircraftTrack = mapItem->m_aircraftState->m_track; + float aircraftRoll = mapItem->m_roll; + float aircraftSelectedAltitude = mapItem->m_aircraftState->m_selectedAltitude; + float aircraftSelectedHeading = mapItem->m_aircraftState->m_selectedHeading; + int aircraftAutopilot = mapItem->m_aircraftState->m_autopilot; + MapAircraftState::VerticalMode aircraftVerticalMode = mapItem->m_aircraftState->m_verticalMode; + MapAircraftState::LateralMode aircraftLateralMode = mapItem->m_aircraftState->m_lateralMode; + MapAircraftState::TCASMode aircraftTCASMode = mapItem->m_aircraftState->m_tcasMode; + float aircraftWindSpeed = mapItem->m_aircraftState->m_windSpeed; + float aircraftWindDirection = mapItem->m_aircraftState->m_windDirection; + float aircraftStaticAirTemperature = mapItem->m_aircraftState->m_staticAirTemperature; + + if ( (!existingId && !aircraftCallsign.isEmpty()) + || (aircraftCallsign != state.m_aircraftState.m_callsign) + ) + { + insertConstantProperty(properties, "pfdCallsign", aircraftCallsign); + state.m_aircraftState.m_callsign = aircraftCallsign; + } + if ( (!existingId && !aircraftType.isEmpty()) + || (aircraftType != state.m_aircraftState.m_aircraftType) + ) + { + insertConstantProperty(properties, "pfdAircraftType", aircraftType); + state.m_aircraftState.m_aircraftType = aircraftType; + } + if ( (!existingId && !aircraftIndicatedAirspeedDateTime.isEmpty()) + || (aircraftIndicatedAirspeedDateTime != state.m_aircraftState.m_indicatedAirspeedDateTime) + || (aircraftIndicatedAirspeed != state.m_aircraftState.m_indicatedAirspeed) + ) + { + insertProperty(properties, "pfdIndicatedAirspeed", aircraftIndicatedAirspeedDateTime, aircraftIndicatedAirspeed); + state.m_aircraftState.m_indicatedAirspeedDateTime = aircraftIndicatedAirspeedDateTime; + state.m_aircraftState.m_indicatedAirspeed = aircraftIndicatedAirspeed; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftTrueAirspeed != state.m_aircraftState.m_trueAirspeed) + ) + { + insertProperty(properties, "pfdTrueAirspeed", dateTime, aircraftTrueAirspeed); + state.m_aircraftState.m_trueAirspeed = aircraftTrueAirspeed; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftGroundspeed != state.m_aircraftState.m_groundspeed) + ) + { + insertProperty(properties, "pfdGroundspeed", dateTime, aircraftGroundspeed); + state.m_aircraftState.m_groundspeed = aircraftGroundspeed; + } + if ( (!existingId && !aircraftAltitudeDateTime.isEmpty()) + || (aircraftAltitudeDateTime != state.m_aircraftState.m_altitudeDateTime) + || (aircraftAltitude != state.m_aircraftState.m_altitude) + ) + { + insertProperty(properties, "pfdAltitude", aircraftAltitudeDateTime, aircraftAltitude); + state.m_aircraftState.m_altitudeDateTime = aircraftAltitudeDateTime; + state.m_aircraftState.m_altitude = aircraftAltitude; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftOnSurface != state.m_aircraftState.m_onSurface) + ) + { + insertProperty0(properties, "pfdOnSurface", dateTime, aircraftOnSurface); + state.m_aircraftState.m_onSurface = aircraftOnSurface; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftMach != state.m_aircraftState.m_mach) + ) + { + insertProperty(properties, "pfdMach", dateTime, aircraftMach); + state.m_aircraftState.m_mach = aircraftMach; + + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftQNH != state.m_aircraftState.m_qnh) + ) + { + insertProperty0(properties, "pfdQNH", dateTime, aircraftQNH); + state.m_aircraftState.m_qnh = aircraftQNH; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftVerticalSpeed != state.m_aircraftState.m_verticalSpeed) + ) + { + insertProperty(properties, "pfdVerticalSpeed", dateTime, aircraftVerticalSpeed); + state.m_aircraftState.m_verticalSpeed = aircraftVerticalSpeed; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftHeading != state.m_aircraftState.m_heading) + ) + { + insertProperty(properties, "pfdHeading", dateTime, aircraftHeading); + state.m_aircraftState.m_heading = aircraftHeading; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftTrack != state.m_aircraftState.m_track) + ) + { + insertProperty(properties, "pfdTrack", dateTime, aircraftTrack); + state.m_aircraftState.m_track = aircraftTrack; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftRoll != state.m_aircraftRoll) + ) + { + insertProperty(properties, "pfdRoll", dateTime, aircraftRoll); + state.m_aircraftRoll = aircraftRoll; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftSelectedAltitude != state.m_aircraftState.m_selectedAltitude) + ) + { + insertProperty0(properties, "pfdSelectedAltitude", dateTime, aircraftSelectedAltitude); + state.m_aircraftState.m_selectedAltitude = aircraftSelectedAltitude; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftSelectedHeading != state.m_aircraftState.m_selectedHeading) + ) + { + insertProperty0(properties, "pfdSelectedHeading", dateTime, aircraftSelectedHeading); + state.m_aircraftState.m_selectedHeading = aircraftSelectedHeading; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftAutopilot != state.m_aircraftState.m_autopilot) + ) + { + insertProperty0(properties, "pfdAutopilot", dateTime, aircraftAutopilot); + state.m_aircraftState.m_autopilot = aircraftAutopilot; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftVerticalMode != state.m_aircraftState.m_verticalMode) + ) + { + insertProperty0(properties, "pfdVerticalMode", dateTime, aircraftVerticalMode); + state.m_aircraftState.m_verticalMode = aircraftVerticalMode; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftLateralMode != state.m_aircraftState.m_lateralMode) + ) + { + insertProperty0(properties, "pfdLateralMode", dateTime, aircraftLateralMode); + state.m_aircraftState.m_lateralMode = aircraftLateralMode; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftTCASMode != state.m_aircraftState.m_tcasMode) + ) + { + insertProperty0(properties, "pfdTCASMode", dateTime, aircraftTCASMode); + state.m_aircraftState.m_tcasMode = aircraftTCASMode; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftWindSpeed != state.m_aircraftState.m_windSpeed) + ) + { + insertProperty0(properties, "pfdWindSpeed", dateTime, aircraftWindSpeed); + state.m_aircraftState.m_windSpeed = aircraftWindSpeed; + } + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftWindDirection != state.m_aircraftState.m_windDirection) + ) + { + insertProperty0(properties, "pfdWindDirection", dateTime, aircraftWindDirection); + state.m_aircraftState.m_windDirection = aircraftWindDirection; + } + + if ( !existingId + || aircraftStateDateTimeChanged + || (aircraftStaticAirTemperature != state.m_aircraftState.m_staticAirTemperature) + ) + { + insertProperty0(properties, "pfdStaticAirTemperature", dateTime, aircraftStaticAirTemperature); + state.m_aircraftState.m_staticAirTemperature = aircraftStaticAirTemperature; + } + + //QJsonObject speedProperty { + // {"velocityReference", "#position"}, + //}; + //properties.insert("pfdSpeed", speedProperty); + + if ( !existingId + || aircraftStateDateTimeChanged + ) + { + state.m_aircraftStateDateTime = aircraftStateDateTime; + } + + if (properties.size() > 0) { + obj.insert("properties", properties); + } + } + obj.insert("position", position); if (!fixedPosition) { @@ -552,16 +722,260 @@ QJsonObject CZML::update(ObjectMapItem *mapItem, bool isTarget, bool isSelected) obj.insert("orientation", orientationPosition); } } - obj.insert("point", point); - if (!mapItem->m_model.isEmpty()) { - obj.insert("model", model); - } else { + + // Point + quint32 pointColorInt = mapItem->m_itemSettings->m_3DPointColor; + int pointAltitudeReference = mapItem->m_altitudeReference; + bool pointShow = mapItem->m_itemSettings->m_enabled && mapItem->m_itemSettings->m_display3DPoint; + + if ( !existingId + || (pointColorInt != state.m_pointColorInt) + || (pointAltitudeReference != state.m_pointAltitudeReference) + || (pointShow != state.m_pointShow) + ) + { + QColor pointColor = QColor::fromRgba(pointColorInt); + QJsonArray pointRGBA { + pointColor.red(), pointColor.green(), pointColor.blue(), pointColor.alpha() + }; + QJsonObject pointColorObj { + {"rgba", pointRGBA} + }; + QJsonObject point { + {"pixelSize", 8}, + {"color", pointColorObj}, + {"heightReference", heightReferences[pointAltitudeReference]}, + {"show", pointShow} + }; + obj.insert("point", point); + + state.m_pointColorInt = pointColorInt; + state.m_pointAltitudeReference = pointAltitudeReference; + state.m_pointShow = pointShow; + } + + // Label + + float labelAltitudeOffset = mapItem->m_labelAltitudeOffset; + QString labelText = mapItem->m_label; + bool labelShow = m_settings->m_displayNames && mapItem->m_itemSettings->m_enabled && mapItem->m_itemSettings->m_display3DLabel; + float labelScale = mapItem->m_itemSettings->m_3DLabelScale; + int labelAltitudeReference = mapItem->m_altitudeReference; + + if ( !existingId + || (labelAltitudeOffset != state.m_lableAltitudeOffset) + || (labelText != state.m_labelText) + || (labelShow != state.m_labelShow) + || (labelScale != state.m_labelScale) + || (labelAltitudeReference != state.m_labelAltitudeReference) + ) + { + // 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") + || (mapItem->m_group == "NavAid") + || (mapItem->m_group == "Waypoints") + ) { + 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 { + 1, 0 + }; + QJsonObject labelPixelOffset { + {"cartesian2", labelPixelOffsetArray} + }; + QJsonArray labelEyeOffsetArray { + 0, labelAltitudeOffset, 0 // Position above the object, dependent on the height of the model + }; + QJsonObject labelEyeOffset { + {"cartesian", labelEyeOffsetArray} + }; + QJsonObject labelHorizontalOrigin { + {"horizontalOrigin", "LEFT"} + }; + QJsonArray labelDisplayDistance { + 0, displayDistanceMax + }; + QJsonObject labelDistanceDisplayCondition { + {"distanceDisplayCondition", labelDisplayDistance} + }; + + labelText.replace("
", "\n"); + QJsonObject label { + //{"text", labelText}, + {"show", labelShow}, + {"scale", labelScale}, + {"pixelOffset", labelPixelOffset}, + {"pixelOffsetScaleByDistance", labelPixelOffsetScaleObject}, + {"eyeOffset", labelEyeOffset}, + {"verticalOrigin", "BASELINE"}, + {"horizontalOrigin", "LEFT"}, + {"heightReference", heightReferences[labelAltitudeReference]}, + {"style", "FILL_AND_OUTLINE"}, + }; + if (!mapItem->m_labelDateTime.isValid()) + { + label.insert("text", labelText); + } + else + { + QString interval = mapItem->m_labelDateTime.toString(Qt::ISODateWithMs) + "/2999-12-31"; + QJsonObject labelInterval { + {"interval", interval}, + {"string", labelText} + }; + QJsonArray labelIntervalArray { + labelInterval + }; + label.insert("text", labelIntervalArray); + } + if (displayDistanceMax != std::numeric_limits::max()) { + label.insert("distanceDisplayCondition", labelDistanceDisplayCondition); + } + + obj.insert("label", label); + + state.m_lableAltitudeOffset = labelAltitudeOffset; + state.m_labelText = labelText; + state.m_labelShow = labelShow; + state.m_labelScale = labelScale; + state.m_labelAltitudeReference = labelAltitudeReference; + } + + if (!mapItem->m_model.isEmpty()) + { + // Model + QString modelGLTF = m_settings->m_modelURL + mapItem->m_model; + int modelAltitudeReference = mapItem->m_altitudeReference; + bool modelShow = mapItem->m_itemSettings->m_enabled && mapItem->m_itemSettings->m_display3DModel; + int modelMinimumPixelSize = mapItem->m_itemSettings->m_3DModelMinPixelSize; + + if ( !existingId + || (modelGLTF != state.m_modelGLTF) + || (modelAltitudeReference != state.m_modelAltitudeReference) + || (modelShow != state.m_modelShow) + || (modelMinimumPixelSize != state.m_modelMinimumPixelSize) + ) + { + QJsonObject model { + {"gltf", modelGLTF}, + //{"incrementallyLoadTextures", false}, // Aircraft will flash as they appear without textures if this is the default of true + {"heightReference", heightReferences[modelAltitudeReference]}, + {"runAnimations", false}, + {"show", modelShow}, + {"minimumPixelSize", modelMinimumPixelSize}, + {"maximumScale", 20000} // Stop it getting too big when zoomed really far out + }; + // Using nodeTransformations stops animations from running. + // See: https://github.com/CesiumGS/cesium/issues/11566 + /*QJsonArray node0Cartesian { + {0.0, mapItem->m_modelAltitudeOffset, 0.0} + }; + QJsonObject node0Translation { + {"cartesian", node0Cartesian} + }; + QJsonObject node0Transform { + {"translation", node0Translation} + }; + QJsonObject nodeTransforms { + {"node0", node0Transform}, + }; + if (mapItem->m_modelAltitudeOffset != 0.0) { + model.insert("nodeTransformations", nodeTransforms); + }*/ + + obj.insert("model", model); + + state.m_modelGLTF = modelGLTF; + state.m_modelAltitudeReference = modelAltitudeReference; + state.m_modelShow = modelShow; + state.m_modelMinimumPixelSize = modelMinimumPixelSize; + } + } + else + { + // Use billboard for APRS as we don't currently have 3D objects + QString imageURL = mapItem->m_image; + if (imageURL.startsWith("qrc://")) { + imageURL = imageURL.mid(6); // Redirect to our embedded webserver, which will check resources + } + QJsonObject billboard { + {"image", imageURL}, + {"heightReference", heightReferences[mapItem->m_altitudeReference]}, + {"verticalOrigin", "BOTTOM"} // To stop it being cut in half when zoomed out + }; + obj.insert("billboard", billboard); } - obj.insert("label", label); - obj.insert("description", mapItem->m_text); - if (!fixedPosition) { - obj.insert("path", path); + + // Description + QString description = mapItem->m_text; + if ( !existingId + || (description != state.m_description) + ) + { + obj.insert("description", description); + state.m_description = description; + } + + // Path + if (!fixedPosition) + { + quint32 pathColorInt = mapItem->m_itemSettings->m_3DTrackColor; + bool pathShow = mapItem->m_itemSettings->m_enabled + && mapItem->m_itemSettings->m_display3DTrack + && ( m_settings->m_displayAllGroundTracks + || (m_settings->m_displaySelectedGroundTracks && isSelected)); + + if ( !existingId + || (pathColorInt != state.m_pathColorInt) + || (pathShow != state.m_pathShow) + ) + { + QColor pathColor = QColor::fromRgba(pathColorInt); + QJsonArray pathColorRGBA { + pathColor.red(), pathColor.green(), pathColor.blue(), pathColor.alpha() + }; + QJsonObject pathColorObj { + {"rgba", pathColorRGBA} + }; + // Paths can't be clamped to ground, so AIS paths can be underground if terrain is used + // See: https://github.com/CesiumGS/cesium/issues/7133 + QJsonObject pathSolidColorMaterial { + {"color", pathColorObj} + }; + QJsonObject pathMaterial { + {"solidColor", pathSolidColorMaterial} + }; + QJsonObject path { + // We want full paths for sat tracker, so leadTime and trailTime should be 0 + // Should be configurable.. 6000=100mins ~> 1 orbit for LEO + //{"leadTime", "6000"}, + //{"trailTime", "6000"}, + {"width", "3"}, + {"material", pathMaterial}, + {"show", pathShow}, + {"resolution", 1} + }; + + obj.insert("path", path); + + state.m_pathColorInt = pathColorInt; + state.m_pathShow = pathShow; + } } if (!fixedPosition) @@ -577,7 +991,7 @@ QJsonObject CZML::update(ObjectMapItem *mapItem, bool isTarget, bool isSelected) { if (mapItem->m_availableUntil.isValid()) { - QString period = QString("%1/%2").arg(m_ids[id]).arg(mapItem->m_availableUntil.toString(Qt::ISODateWithMs)); + QString period = QString("%1/%2").arg(state.m_firstSeenDateTime).arg(mapItem->m_availableUntil.toString(Qt::ISODateWithMs)); obj.insert("availability", period); } } @@ -586,7 +1000,7 @@ QJsonObject CZML::update(ObjectMapItem *mapItem, bool isTarget, bool isSelected) { if (mapItem->m_availableUntil.isValid()) { - QString period = QString("%1/%2").arg(m_ids[id]).arg(mapItem->m_availableUntil.toString(Qt::ISODateWithMs)); + QString period = QString("%1/%2").arg(state.m_firstSeenDateTime).arg(mapItem->m_availableUntil.toString(Qt::ISODateWithMs)); obj.insert("availability", period); } } @@ -599,13 +1013,24 @@ QJsonObject CZML::update(ObjectMapItem *mapItem, bool isTarget, bool isSelected) } // Use our own clipping routine, due to - // https://github.com/CesiumGS/cesium/issues/4049 - if (mapItem->m_altitudeReference == 3) { - obj.insert("altitudeReference", "CLIP_TO_GROUND"); - } + // https://github.com/CesiumGS/cesium/issues/4049 - Has now been fixed + //if (mapItem->m_altitudeReference == 3) { + // obj.insert("altitudeReference", "CLIP_TO_GROUND"); + //} //qDebug() << obj; + /*if (id == "400b00") + { + QJsonDocument doc(obj); + if (!m_file.isOpen()) + { + m_file.setFileName(QString("%1.czml").arg(id)); + m_file.open(QIODeviceBase::WriteOnly); + } + m_file.write(doc.toJson()); + m_file.write(",\n"); + }*/ + return obj; } - diff --git a/plugins/feature/map/czml.h b/plugins/feature/map/czml.h index fc0635e62..356f2ddfa 100644 --- a/plugins/feature/map/czml.h +++ b/plugins/feature/map/czml.h @@ -24,10 +24,13 @@ #define INCLUDE_FEATURE_CZML_H_ #include +#include #include #include #include +#include "mapaircraftstate.h" + struct MapSettings; class MapItem; class ObjectMapItem; @@ -36,13 +39,59 @@ class PolylineMapItem; class CZML { + // Record previous state of object, so we only send changes in state + struct State { + QString m_firstSeenDateTime; + + QString m_modelGLTF; + int m_modelAltitudeReference; + bool m_modelShow; + int m_modelMinimumPixelSize; + + quint32 m_pointColorInt; + int m_pointAltitudeReference; + bool m_pointShow; + + float m_lableAltitudeOffset; + QString m_labelText; + bool m_labelShow; + float m_labelScale; + int m_labelAltitudeReference; + + quint32 m_pathColorInt; + bool m_pathShow; + + QString m_description; + + QDateTime m_aircraftStateDateTime; + MapAircraftState m_aircraftState; + float m_aircraftRoll; + + State() : + m_modelAltitudeReference(0), + m_modelShow(false), + m_modelMinimumPixelSize(0), + m_pointColorInt(0), + m_pointAltitudeReference(0), + m_pointShow(false), + m_lableAltitudeOffset(0.0), + m_labelShow(false), + m_labelScale(0.0), + m_labelAltitudeReference(0), + m_pathColorInt(0), + m_pathShow(false) + { } + }; + private: const MapSettings *m_settings; - QHash m_ids; + QHash m_ids; QHash m_lastPosition; QHash m_hasMoved; QGeoCoordinate m_position; static const QStringList m_heightReferences; + QFile m_file; + QFile m_csvFile; public: CZML(const MapSettings *settings); diff --git a/plugins/feature/map/icons/firstperson.png b/plugins/feature/map/icons/firstperson.png new file mode 100644 index 000000000..9a6164311 Binary files /dev/null and b/plugins/feature/map/icons/firstperson.png differ diff --git a/plugins/feature/map/icons/pfd.png b/plugins/feature/map/icons/pfd.png new file mode 100644 index 000000000..54cf6c9d2 Binary files /dev/null and b/plugins/feature/map/icons/pfd.png differ diff --git a/plugins/feature/map/icons/thirdperson.png b/plugins/feature/map/icons/thirdperson.png new file mode 100644 index 000000000..d25abe8cb Binary files /dev/null and b/plugins/feature/map/icons/thirdperson.png differ diff --git a/plugins/feature/map/map/cockpit.js b/plugins/feature/map/map/cockpit.js new file mode 100644 index 000000000..1da72d86b --- /dev/null +++ b/plugins/feature/map/map/cockpit.js @@ -0,0 +1,1463 @@ +// Airbus like PFD (Primary Flight Display) + +var icao; +var callsign; +var aircraftType; +var onSurface; +var wasOnSurface60SecsAgo; +var speed; +var modelSpeed; // Speed of 3D model in Cesium +var trueAirspeed; +var groundspeed; +var targetSpeed; +var altitude; +var targetAltitude; +var radioAltitude; +var verticalSpeed; // ft/m +var heading; // degrees mag +var targetHeading; +var track; // degrees true +var pressure; // mb +var mach; +var roll; +var autopilot; +var verticalMode; +var lateralMode; +var tcasMode; +var windSpeed; +var windDirection; +var staticAirTemperature; + +var mdoelSpeed; +var toga = false; +var rollOut = false; +var runwayAltitude; + +const grayColor = "#6A75AE"; +const greenColor = "#20E966"; +const cyanColor = "#2FF7FE"; +const yellowColor = "#F4F82F"; +const orangeColor = "#FEDA30"; +const skyColor = "#26C9FF"; +const groundColor = "#D35E34"; + +function setPFDData(forward, newICAO, newCallsign, newAircraftType, newOnSurface, newWasOnSurface60SecsAgo, newRunwayAltitudeEstimate, + newModelSpeed, newIndicatedAirspeed, newTrueAirspeed, newGroundspeed, newMach, newAltitude, newRadioAltitude, newQnh, newVerticalSpeed, + newHeading, newTrack, newRoll, newSelectedAltitude, newSelectedHeading, + newAutopilot, newVerticalMode, newLateralMode, newTCASMode, + newWindSpeed, newWindDirection, newStaticAirTemperature) { + + //console.log('PFD', newIndicatedAirspeed, newMach, newAltitude, newQnh, newVerticalSpeed, newHeading, newTrack, newRoll, newSelectedAltitude, newSelectedHeading); + + const newAircraft = icao !== newICAO; + + if (newAircraft) { + toga = false; + rollOut = false; + runwayAltitude = undefined; + } + + icao = newICAO; + callsign = newCallsign; + aircraftType = newAircraftType; + onSurface = newOnSurface; + wasOnSurface60SecsAgo = newWasOnSurface60SecsAgo; + modelSpeed = newModelSpeed; + if ((newIndicatedAirspeed === undefined) || (newOnSurface > 0)) { + // IAS not transmitted frequently. After landing will no longer be valid + if (newGroundspeed !== undefined) { + speed = newGroundspeed; + } else { + speed = newModelSpeed; + } + } else { + speed = newIndicatedAirspeed; + } + trueAirspeed = newTrueAirspeed; + groundspeed = newGroundspeed; + mach = newMach; + altitude = newAltitude; + radioAltitude = newRadioAltitude; + pressure = newQnh; + verticalSpeed = newVerticalSpeed; + if (newHeading === undefined) { + heading = newTrack; // Track more frequent than heading + } else { + heading = newHeading; + } + track = newTrack; + roll = newRoll; + targetAltitude = newSelectedAltitude; + targetHeading = newSelectedHeading; + if (newAutopilot === -1) { + autopilot = undefined; + } else { + autopilot = newAutopilot; + } + verticalMode = newVerticalMode; + lateralMode = newLateralMode; + tcasMode = newTCASMode; + windSpeed = newWindSpeed; + windDirection = newWindDirection; + staticAirTemperature = newStaticAirTemperature; + + // Correct altitude for QNH setting + if (pressure !== undefined) { + altitude += (pressure - 1013.25) * 30; + } + + if (!rollOut && forward && (wasOnSurface60SecsAgo === 0) && (onSurface > 0) && (modelSpeed >= 70)) { + rollOut = true; + } else if (rollOut && ((modelSpeed < 40) || (onSurface === 0))) { + rollOut = false; + } + + const accelerationHeight = 1500; + + if (!toga && !rollOut && forward && (onSurface > 0) && (modelSpeed >= 35)) { + toga = true; // Start take-off roll on surface + runwayAltitude = altitude; + } else if (!toga && !rollOut && (modelSpeed >= 35) && (runwayAltitude === undefined) && (wasOnSurface60SecsAgo > 0) && (newRunwayAltitudeEstimate !== undefined)) { + toga = true; // For when selecting an aircraft having just taken off + runwayAltitude = newRunwayAltitudeEstimate; + } else if (!toga && !rollOut && (onSurface === 0) && (wasOnSurface60SecsAgo > 0) && (altitude !== undefined) && (runwayAltitude !== undefined) && (altitude < runwayAltitude + accelerationHeight)) { + toga = true; // For when played in reverse + } else if (toga && (modelSpeed <= 20)) { + toga = false; + } else if (toga && (altitude !== undefined) && (runwayAltitude !== undefined) && (altitude >= runwayAltitude + accelerationHeight)) { + toga = false; + } + if (toga && (runwayAltitude === undefined) && (newRunwayAltitudeEstimate !== undefined)) { + runwayAltitude = newRunwayAltitudeEstimate; + } +} + +function isStd() { + return (pressure >= 1012) && (pressure < 1014); +} + +function drawAirspeedIndicator(ctx, fm20, fm30) { + // Airspeed indicator + + const speedIndWidth = 100; + const speedIndHeight = 500; + const speedIndLeft = 30; + const speedIndRight = speedIndLeft + speedIndWidth; + const speedIndTop = 250; + const speedIndBottom = speedIndTop + speedIndHeight; + const speedIndMid = speedIndTop + speedIndHeight / 2; + + // Background + ctx.fillStyle = grayColor; + ctx.fillRect(speedIndLeft, speedIndTop, speedIndWidth, speedIndHeight); + + // Clip speed tape + ctx.save(); + ctx.rect(speedIndLeft, speedIndTop, speedIndWidth, speedIndHeight); + ctx.clip(); + + // Speed tape markings every 10knts + const speedTapMinSpeed = 0; // A320 only starts drawning from 30knts, but we draw from 0 + const speedTapeLeft = speedIndRight - 20; + const speedTapeRight = speedIndRight; + const speedTapeSpacing = 60; + const speedTapeStep = 10; + const speedTextStep = 20; + const speedPerPixel = speedTapeSpacing / speedTapeStep; + const speedOffset = (speed % speedTextStep) * speedPerPixel; + ctx.beginPath(); + var speedTape = Math.round(speed - speed % speedTextStep + 6 * speedTapeStep); + for (var i = -6; i < 5; i++) { + if (speedTape >= speedTapMinSpeed) { + ctx.moveTo(speedTapeLeft, speedOffset + i * speedTapeSpacing + speedIndMid); + ctx.lineTo(speedTapeRight, speedOffset + i * speedTapeSpacing + speedIndMid); + } + speedTape -= speedTapeStep; + } + ctx.lineWidth = 2; + ctx.strokeStyle = "white"; + ctx.stroke(); + + // Speeds every 20knts + var speedText = Math.round(speed - speed % speedTextStep + 3 * speedTextStep); + for (var i = -3; i < 3; i++) { + ctx.font = "30px Arial"; + ctx.fillStyle = "white"; + if (speedText >= speedTapMinSpeed) { + const speedTextString = speedText.toString().padStart(3, '0'); + ctx.fillText(speedTextString, speedIndLeft + 20, speedOffset + i * speedTapeSpacing * 2 + speedIndMid + fm30.actualBoundingBoxAscent / 2); + } + speedText -= speedTextStep; + } + + // Disable clipping + ctx.restore(); + + ctx.beginPath(); + ctx.moveTo(speedIndLeft, speedIndTop); + ctx.lineTo(speedIndRight + 30, speedIndTop); + var speedIndRightLineHeight = speed / (speedIndHeight / 2 / speedPerPixel); // White line on right only drawn down to speedTapMinSpeed + if (speedIndRightLineHeight > 1) { + speedIndRightLineHeight = 1; + ctx.moveTo(speedIndLeft, speedIndTop + speedIndHeight); + ctx.lineTo(speedIndRight + 30, speedIndTop + speedIndHeight); + } + ctx.moveTo(speedIndRight, speedIndTop); + ctx.lineTo(speedIndRight, speedIndTop + speedIndRightLineHeight * (speedIndHeight / 2) + (speedIndHeight / 2)); + ctx.lineWidth = 2; + ctx.strokeStyle = "white"; + ctx.stroke(); + + // Speed indicator + ctx.beginPath(); + ctx.moveTo(speedIndLeft - 10, speedIndMid); + ctx.lineTo(speedIndLeft, speedIndMid); + ctx.moveTo(speedTapeLeft - 5, speedIndMid); + ctx.lineTo(speedTapeRight + 20, speedIndMid); + ctx.lineWidth = 2; + ctx.strokeStyle = yellowColor; + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(speedTapeRight + 10, speedIndMid); + ctx.lineTo(speedTapeRight + 35, speedIndMid - 10); + ctx.lineTo(speedTapeRight + 35, speedIndMid + 10); + ctx.lineTo(speedTapeRight + 10, speedIndMid); + ctx.fillStyle = yellowColor; + ctx.fill(); + + // Target speed + + if (targetSpeed !== undefined) { + var targetSpeedY = (speed - targetSpeed) * speedPerPixel; + targetSpeedY = targetSpeedY + speedIndMid; + if (targetSpeedY < speedIndTop) { + // Target speed as text on top + ctx.font = "20px Arial"; + ctx.fillStyle = cyanColor; + const targetSpeedMetrics = ctx.measureText(targetSpeed); + ctx.fillText(targetSpeed, speedIndRight - targetSpeedMetrics.width / 2, speedIndTop - 4); + + } else if (targetSpeedY > speedIndBottom) { + // Target speed as text underneath + ctx.font = "20px Arial"; + ctx.fillStyle = cyanColor; + const targetSpeedMetrics = ctx.measureText(targetSpeed); + ctx.fillText(targetSpeed, speedIndRight - targetSpeedMetrics.width / 2, speedIndBottom + fm20.actualBoundingBoxAscent + 4); + } else { + // Cyan triangle on right side + const targetSpeedWidth = 40; + const targetSpeedHeight = 30; + const targetSpeedLeft = speedIndRight; + const targetSpeedRight = targetSpeedLeft + targetSpeedWidth; + + ctx.beginPath(); + ctx.moveTo(targetSpeedLeft, targetSpeedY); + ctx.lineTo(targetSpeedRight, targetSpeedY - targetSpeedHeight / 2); + ctx.lineTo(targetSpeedRight, targetSpeedY + targetSpeedHeight / 2); + ctx.lineTo(targetSpeedLeft, targetSpeedY); + ctx.lineWidth = 2; + ctx.strokeStyle = cyanColor; + ctx.stroke(); + } + } +} + +function drawAltitudeIndicator(ctx, fm20, fm30) { + // Altitude indicator + + const altitudeIndWidth = 70; + const altitudeIndHeight = 500; + const altitudeIndLeft = 750; + const altitudeIndRight = altitudeIndLeft + altitudeIndWidth; + const altitudeIndTop = 250; + const altitudeIndBottom = altitudeIndTop + altitudeIndHeight; + const altitudeIndMid = altitudeIndTop + altitudeIndHeight / 2; + + // Background + ctx.fillStyle = grayColor; + ctx.fillRect(altitudeIndLeft, altitudeIndTop, altitudeIndWidth, altitudeIndHeight); + + ctx.font = "30px Arial"; + const greaterThanMetrics = ctx.measureText(">"); + + // Mid point marker (for glideslope, but always visible) + ctx.fillStyle = yellowColor; + ctx.fillRect(altitudeIndLeft - 70, altitudeIndMid - 3, 50, 6); + + // Clip altitude tape + ctx.save(); + ctx.rect(altitudeIndLeft - greaterThanMetrics.width, altitudeIndTop, altitudeIndWidth + greaterThanMetrics.width, altitudeIndHeight); + ctx.clip(); + + // Altitude tape markings every 100 ft + const altitudeTapeLeft = altitudeIndRight - 10; + const altitudeTapeRight = altitudeIndRight; + const altitudeTapeSpacing = 40; + const altitudeTapeStep = 100; + const altitudeTextStep = 500; + const altitudePerPixel = altitudeTapeSpacing / altitudeTapeStep; // reciprocal + const altitudeOffset = (altitude % altitudeTextStep) * altitudePerPixel; + ctx.beginPath(); + var altitudeTape = Math.round(altitude - altitude % altitudeTextStep + 10 * altitudeTapeStep); + for (var i = -10; i < 7; i++) { + ctx.moveTo(altitudeTapeLeft, altitudeOffset + i * altitudeTapeSpacing + altitudeIndMid); + ctx.lineTo(altitudeTapeRight, altitudeOffset + i * altitudeTapeSpacing + altitudeIndMid); + altitudeTape -= altitudeTapeStep; + } + ctx.lineWidth = 2; + ctx.strokeStyle = "white"; + ctx.stroke(); + + // Flight level texts every 500ft + ctx.font = "30px Arial"; + ctx.fillStyle = "white"; + var altitudeText = Math.round(altitude - altitude % altitudeTextStep + 2 * altitudeTextStep); + for (var i = -2; i < 2; i++) { + const flightLevelText = Math.abs(altitudeText / 100); // Don't display - if negative + const flightLevelTextString = ">" + flightLevelText.toString().padStart(3, '0'); + ctx.fillText(flightLevelTextString, altitudeIndLeft - greaterThanMetrics.width, altitudeOffset + i * altitudeTapeSpacing * 5 + altitudeIndMid + fm30.actualBoundingBoxAscent / 2); + altitudeText -= altitudeTextStep; + } + + // Disable clipping + ctx.restore(); + + // White lines at top, bottom and on right + ctx.beginPath(); + ctx.moveTo(altitudeIndLeft, altitudeIndTop); + ctx.lineTo(altitudeIndRight + 30, altitudeIndTop); + ctx.moveTo(altitudeIndLeft, altitudeIndTop + altitudeIndHeight); + ctx.lineTo(altitudeIndRight + 30, altitudeIndTop + altitudeIndHeight); + ctx.moveTo(altitudeIndRight, altitudeIndTop); + ctx.lineTo(altitudeIndRight, altitudeIndTop + altitudeIndHeight); + ctx.lineWidth = 2; + ctx.strokeStyle = "white"; + ctx.stroke(); + + // Target altitude + + if (targetAltitude !== undefined) { + var targetAltitudeY = (altitude - targetAltitude) * altitudePerPixel; + targetAltitudeY = targetAltitudeY + altitudeIndMid; + + if (isStd()) { + targetAltitudeText = Math.round(targetAltitude / 100); + } else { + targetAltitudeText = Math.round(targetAltitude); + } + + if (targetAltitudeY < altitudeIndTop) { + // Target altitude as text on top + ctx.font = "30px Arial"; + ctx.fillStyle = cyanColor; + const targetAltitudeMetrics = ctx.measureText(targetAltitudeText); + if (isStd()) { + ctx.fillText("FL", altitudeIndLeft, altitudeIndTop - 4); + } + ctx.fillText(targetAltitudeText, altitudeIndRight - targetAltitudeMetrics.width / 2, altitudeIndTop - 4); + } else if (targetAltitudeY > altitudeIndBottom) { + // Target altitude as text underneath + ctx.font = "30px Arial"; + ctx.fillStyle = cyanColor; + const targetAltitudeMetrics = ctx.measureText(targetAltitudeText); + if (isStd()) { + ctx.fillText("FL", altitudeIndLeft, altitudeIndBottom + fm30.actualBoundingBoxAscent + 4); + } + ctx.fillText(targetAltitudeText, altitudeIndRight - targetAltitudeMetrics.width / 2, altitudeIndBottom + fm30.actualBoundingBoxAscent + 4); + } else { + + // Clip + ctx.save(); + ctx.rect(altitudeIndLeft - greaterThanMetrics.width, altitudeIndTop, altitudeIndWidth + greaterThanMetrics.width, altitudeIndHeight); + ctx.clip(); + + targetAltitudeText = targetAltitudeText.toString().padStart(3, '0'); + + ctx.font = "30px Arial"; + ctx.fillStyle = cyanColor; + const targetAltitudeMetrics = ctx.measureText(targetAltitudeText); + + // Cyan box + const targetAltitudeWidth = 30; + const targetAltitudeHeight = 3 * (fm30.actualBoundingBoxAscent + fm30.actualBoundingBoxDescent); + const targetAltitudeBoxHeight = 1.1 * (fm30.actualBoundingBoxAscent + fm30.actualBoundingBoxDescent); + const targetAltitudeLeft = altitudeIndLeft - 5; + const targetAltitudeRight = targetAltitudeLeft + targetAltitudeWidth; + + ctx.beginPath(); + ctx.moveTo(targetAltitudeLeft, targetAltitudeY - targetAltitudeHeight / 2); + ctx.lineTo(targetAltitudeLeft, targetAltitudeY - 5); + ctx.lineTo(targetAltitudeLeft + 5, targetAltitudeY); + ctx.lineTo(targetAltitudeLeft, targetAltitudeY + 5); + ctx.lineTo(targetAltitudeLeft, targetAltitudeY + targetAltitudeHeight / 2); + ctx.lineTo(targetAltitudeRight, targetAltitudeY + targetAltitudeHeight / 2); + ctx.lineTo(targetAltitudeRight, targetAltitudeY - targetAltitudeHeight / 2); + ctx.lineTo(targetAltitudeLeft, targetAltitudeY - targetAltitudeHeight / 2); + ctx.lineWidth = 2; + ctx.lineWidth = 2; + ctx.strokeStyle = cyanColor; + ctx.stroke(); + + ctx.fillStyle = 'black'; + ctx.fillRect(altitudeIndLeft, targetAltitudeY - targetAltitudeBoxHeight / 2, altitudeIndWidth - 2, targetAltitudeBoxHeight); + ctx.fillStyle = cyanColor; + ctx.fillText(targetAltitudeText, altitudeIndLeft, targetAltitudeY + fm30.actualBoundingBoxAscent / 2); + + // Disable clipping + ctx.restore(); + } + } + + // Altitude + + ctx.font = "34px Arial"; + const levelText = (altitude === undefined) || isNaN(altitude) ? "" : Math.floor(altitude / 100).toString(); + const levelTextMetrics = ctx.measureText(levelText); + const levelTextHeightMetrics = ctx.measureText("0"); + const border = 2; + const levelHeight = levelTextHeightMetrics.actualBoundingBoxAscent + levelTextHeightMetrics.actualBoundingBoxDescent + 8 * border; + + const subscaleHeight = 60; + const subscaleWidth = 30; + + // Black background box + ctx.fillStyle = 'black'; + ctx.fillRect(altitudeIndLeft, altitudeIndMid - levelHeight / 2, altitudeIndWidth + 1, levelHeight); + + // Current flight level + if (levelText !== "") { + ctx.fillStyle = greenColor; + ctx.fillText(levelText, altitudeIndRight - levelTextMetrics.width, altitudeIndMid + levelTextMetrics.actualBoundingBoxAscent / 2); + } + + // Yellow box + ctx.beginPath(); + ctx.moveTo(altitudeIndLeft, altitudeIndMid - levelHeight / 2); + ctx.lineTo(altitudeIndRight, altitudeIndMid - levelHeight / 2); + ctx.lineTo(altitudeIndRight, altitudeIndMid - subscaleHeight / 2); + ctx.lineTo(altitudeIndRight + subscaleWidth, altitudeIndMid - subscaleHeight / 2); + ctx.lineTo(altitudeIndRight + subscaleWidth, altitudeIndMid + subscaleHeight / 2); + ctx.lineTo(altitudeIndRight, altitudeIndMid + subscaleHeight / 2); + ctx.lineTo(altitudeIndRight, altitudeIndMid + levelHeight / 2); + ctx.lineTo(altitudeIndLeft, altitudeIndMid + levelHeight / 2); + ctx.lineWidth = 2; + if (Math.abs(verticalSpeed) >= 6000) { + ctx.strokeStyle = orangeColor; + } else { + ctx.strokeStyle = yellowColor; + } + ctx.stroke(); + + // Clip around subscale + ctx.save(); + ctx.rect(altitudeIndRight, altitudeIndMid - subscaleHeight / 2, subscaleWidth, subscaleHeight); + ctx.clip(); + + const subscaleStep = 20; + var subscaleText = (altitude % 100) + subscaleStep * 2; + var subscaleSpacing = fm20.actualBoundingBoxAscent + fm20.actualBoundingBoxDescent + 2; + const subscalePerPixel = subscaleSpacing / subscaleStep; // reciprocal + const subscaleOffset = (altitude % subscaleStep) * subscalePerPixel; + ctx.font = "20px Arial"; + for (var i = -2; i < 2; i++) { + const subscaleTextString = (Math.floor((subscaleText % 100) / subscaleStep) * subscaleStep).toString().padStart(2, '0'); + ctx.fillText(subscaleTextString, altitudeIndRight + 1, subscaleOffset + i * subscaleSpacing + altitudeIndMid + fm20.actualBoundingBoxAscent / 2); + subscaleText -= subscaleStep; + if (subscaleText < 0) { + subscaleText += 100; + } + } + + // Disable clipping + ctx.restore(); +} +function drawHeadingIndicator(ctx, fm20, fm30) { + + const headingIndWidth = 450; + const headingIndHeight = 60; + const headingIndLeft = 225; + const headingIndRight = headingIndLeft + headingIndWidth; + const headingIndTop = 890; + const headingIndBottom = headingIndTop + headingIndHeight; + const headingIndMid = headingIndLeft + headingIndWidth / 2; + + ctx.fillStyle = grayColor; + ctx.fillRect(headingIndLeft, headingIndTop, headingIndWidth, headingIndHeight); + + // White lines left, right and top + ctx.beginPath(); + ctx.moveTo(headingIndLeft, headingIndBottom); + ctx.lineTo(headingIndLeft, headingIndTop); + ctx.lineTo(headingIndRight, headingIndTop); + ctx.lineTo(headingIndRight, headingIndBottom); + ctx.lineWidth = 2; + ctx.strokeStyle = "white"; + ctx.stroke(); + + // Heading indicator line + ctx.beginPath(); + ctx.moveTo(headingIndMid, headingIndTop - 30); + ctx.lineTo(headingIndMid, headingIndTop); + ctx.lineWidth = 4; + ctx.strokeStyle = yellowColor; + ctx.stroke(); + + // Clip tape + ctx.save(); + ctx.rect(headingIndLeft, headingIndTop, headingIndWidth, headingIndHeight); + ctx.clip(); + + // Tape markings every 5 degrees + const headingTapeLeft = headingIndRight - 20; + const headingTapeRight = headingIndRight; + const headingTapeSpacing = 50; + const headingTapeStep = 5; + const headingTextStep = 10; + const headingPerPixel = headingTapeSpacing / headingTapeStep; + const headingOffset = - (heading % headingTextStep) * headingPerPixel; + ctx.beginPath(); + var headingTape = Math.round(heading - heading % headingTextStep - 6 * headingTapeStep); + for (var i = -6; i < 7; i++) { + ctx.moveTo(headingOffset + i * headingTapeSpacing + headingIndMid, headingIndTop); + if (headingTape % 10 != 0) { + ctx.lineTo(headingOffset + i * headingTapeSpacing + headingIndMid, headingIndTop + 10); + } else { + ctx.lineTo(headingOffset + i * headingTapeSpacing + headingIndMid, headingIndTop + 20); + } + headingTape += headingTapeStep; + } + ctx.lineWidth = 2; + ctx.strokeStyle = "white"; + ctx.stroke(); + + ctx.font = "30px Arial"; + ctx.fillStyle = "white"; + const headingMetricsBig = ctx.measureText("36"); + ctx.font = "24px Arial"; + ctx.fillStyle = "white"; + const headingMetricsSmall = ctx.measureText("36"); + + // Headings every 10 degrees + var headingText = Math.round(heading - heading % headingTextStep - 3 * headingTextStep); + for (var i = -3; i < 4; i++) { + const big = headingText % 30 == 0; + if (big) { + ctx.font = "30px Arial"; + } else { + ctx.font = "24px Arial"; + } + ctx.fillStyle = "white"; + var headingTextMod = Math.round(headingText / 10) % 36; + if (headingTextMod < 0) { + headingTextMod += 36; + } + const headingTextString = headingTextMod.toString(); + const headingMetrics = ctx.measureText(headingTextString); + + ctx.fillText(headingTextString, headingOffset + i * headingTapeSpacing * 2 + headingIndMid - headingMetrics.width / 2, headingIndTop + 30 + headingMetrics.actualBoundingBoxAscent); + headingText += headingTextStep; + } + + // Track diamond + + if (track !== undefined) { + const trackMid = headingIndMid + (track - heading) * headingPerPixel; + const trackSize = 20; + + ctx.beginPath(); + ctx.moveTo(trackMid, headingIndTop); + ctx.lineTo(trackMid - trackSize / 2, headingIndTop + trackSize / 2); + ctx.lineTo(trackMid, headingIndTop + trackSize); + ctx.lineTo(trackMid + trackSize / 2, headingIndTop + trackSize / 2); + ctx.lineTo(trackMid, headingIndTop); + ctx.lineWidth = 5; + ctx.strokeStyle = greenColor; + ctx.stroke(); + } + + // Disable clipping + ctx.restore(); + + // Target heading + + if (targetHeading !== undefined) { + var targetDiff = targetHeading - heading; + if (targetDiff > 180) { + targetDiff -= 360; + } + var targetMid = headingIndMid + targetDiff * headingPerPixel; + if (targetMid < headingIndLeft) { + + // Display target heading as text on left + ctx.font = "20px Arial"; + ctx.fillStyle = cyanColor; + const targetHeadingText = Math.round(targetHeading).toString().padStart(3, '0'); + const targetHeadingMetrics = ctx.measureText(targetHeadingText); + ctx.fillText(targetHeadingText, headingIndLeft, headingIndTop - 4); + + } else if (targetMid > headingIndRight) { + + // Display target heading as text on right + ctx.font = "20px Arial"; + ctx.fillStyle = cyanColor; + const targetHeadingText = Math.round(targetHeading).toString().padStart(3, '0'); + const targetHeadingMetrics = ctx.measureText(targetHeadingText); + ctx.fillText(targetHeadingText, headingIndRight - targetHeadingMetrics.width, headingIndTop - 4); + + } else { + // Target indicator triangle + + const targetWidth = 30; + const targetHeight = 40; + + ctx.beginPath(); + ctx.moveTo(targetMid, headingIndTop); + ctx.lineTo(targetMid - targetWidth / 2, headingIndTop - targetHeight); + ctx.lineTo(targetMid + targetWidth / 2, headingIndTop - targetHeight); + ctx.lineTo(targetMid, headingIndTop); + ctx.lineWidth = 2; + ctx.strokeStyle = cyanColor; + ctx.stroke(); + } + } +} + +function drawBankAngleBoxMarker(ctx, radius, height) { + ctx.beginPath(); + ctx.moveTo(-5, -radius); + ctx.lineTo(-5, -radius - height); + ctx.lineTo(5, -radius - height); + ctx.lineTo(5, -radius); + ctx.lineWidth = 2; + ctx.strokeStyle = 'white'; + ctx.stroke(); +} + +function drawBankAngleLineMarker(ctx, radius, height) { + ctx.beginPath(); + ctx.moveTo(0, -radius); + ctx.lineTo(0, -radius - height); + ctx.lineWidth = 2; + ctx.strokeStyle = 'white'; + ctx.stroke(); +} +function drawAttitudeIndicator(ctx, fm20, fm30) { + + if (roll === undefined) { + return; + } + + // Height the same as airspeed indicator - cropped width same as heading indicator + + const speedIndRight = 30 + 100; + const altitudeIndLeft = 750; + + const attitudeIndMidX = speedIndRight + ((altitudeIndLeft - speedIndRight) / 2); + //const attitudeIndLeft = 30 + 100; // speedIndRight + //const attitudeIndRight = 750; // altitudeIndLeft + const attitudeIndWidth = 500; // speedIndHeight = 500; // attitudeIndRight - attitudeIndLeft; + const attitudeIndTop = 250; + const attitudeIndLeft = attitudeIndMidX - attitudeIndWidth / 2; + const attitudeIndHeight = attitudeIndWidth; // It's a circle + const attitudeIndMidY = attitudeIndTop + attitudeIndHeight / 2; + const attitudeIndCrop = (attitudeIndWidth - 450) / 2; // headingIndWidth = 450 + + // Clip + ctx.save(); + //ctx.arc(attitudeIndMidX, attitudeIndMidY, attitudeIndWidth / 2, 0, 2 * Math.PI); + ctx.beginPath(); + ctx.arc(attitudeIndMidX, attitudeIndMidY, attitudeIndWidth / 2, Math.PI / 10 + Math.PI, Math.PI - Math.PI / 10 + Math.PI); + ctx.arc(attitudeIndMidX, attitudeIndMidY, attitudeIndWidth / 2, Math.PI / 10, Math.PI - Math.PI / 10); +// //ctx.rect(attitudeIndLeft + attitudeIndCrop, attitudeIndTop, attitudeIndWidth - 2 * attitudeIndCrop, attitudeIndWidth); + // ctx.rect(attitudeIndLeft + attitudeIndCrop, attitudeIndTop, attitudeIndWidth - 2 * attitudeIndCrop, attitudeIndWidth); + ctx.clip(); + + + // Background + //ctx.beginPath(); + //ctx.fillStyle = cyanColor; + //ctx.arc(attitudeIndMidX, attitudeIndMidY, attitudeIndWidth / 2, Math.PI / 10, Math.PI - Math.PI / 10); + //ctx.arc(attitudeIndMidX, attitudeIndMidY, attitudeIndWidth / 2, Math.PI / 10 + Math.PI, Math.PI - Math.PI / 10 + Math.PI); + //console.log(attitudeIndMidX, attitudeIndMidY, attitudeIndWidth / 2, 0, 2 * Math.PI); + //ctx.fill(); + + ctx.fillStyle = skyColor; + ctx.fillRect(attitudeIndLeft, attitudeIndTop, attitudeIndWidth, attitudeIndWidth); + + const rollRad = -roll * Math.PI / 180; + const radius = attitudeIndWidth / 2; + + ctx.fillStyle = groundColor; + ctx.beginPath(); + ctx.arc(attitudeIndMidX, attitudeIndMidY, radius, 0 + rollRad, Math.PI + rollRad); + ctx.fill(); + + //const offsetX = Math.cos(rollRad) * radius; + //const offsetY = Math.sin(rollRad) * radius; + //ctx.beginPath(); + //ctx.moveTo(attitudeIndMidX - offsetX, attitudeIndMidY - offsetY); + //ctx.lineTo(attitudeIndMidX + offsetX, attitudeIndMidY + offsetY); + //ctx.lineWidth = 2; + //ctx.strokeStyle = 'white'; + //ctx.stroke(); + + // Disable clipping + //ctx.restore(); + + const twoAndHalfDegOffset = 15; + const fiveDegOffset = twoAndHalfDegOffset * 2; + const tenDegOffset = twoAndHalfDegOffset * 4; + const fifteenDegOffset = twoAndHalfDegOffset * 2.5; + const spacing = 50; + + // ctx.save(); + + ctx.translate(attitudeIndMidX, attitudeIndMidY); + ctx.rotate(rollRad); + + ctx.beginPath(); + ctx.moveTo(-radius, 0); + ctx.lineTo(radius, 0); + for (var i = -2; i < 4; i++) { + ctx.moveTo(-twoAndHalfDegOffset, -i * spacing - spacing / 2); + ctx.lineTo(twoAndHalfDegOffset, -i * spacing - spacing / 2); + } + for (var i = -1; i < 5; i++) { + ctx.moveTo(-fiveDegOffset, -i * spacing); + ctx.lineTo(fiveDegOffset, -i * spacing); + } + for (var i = -1; i < 3; i++) { + ctx.moveTo(-tenDegOffset, -i * 2 * spacing); + ctx.lineTo(tenDegOffset, -i * 2 * spacing); + } + ctx.moveTo(-fifteenDegOffset, 2.75 * spacing); + ctx.lineTo(fifteenDegOffset, 2.75 * spacing); + ctx.moveTo(-tenDegOffset, 3.5 * spacing); + ctx.lineTo(tenDegOffset, 3.5 * spacing); + ctx.lineWidth = 2; + ctx.strokeStyle = 'white'; + ctx.stroke(); + + const greenThingWidth = 20; + ctx.beginPath(); + ctx.strokeStyle = greenColor; + ctx.moveTo(-fifteenDegOffset - greenThingWidth, 2.75 * spacing - 3); + ctx.lineTo(-fifteenDegOffset, 2.75 * spacing - 3); + ctx.moveTo(-fifteenDegOffset - greenThingWidth, 2.75 * spacing + 3); + ctx.lineTo(-fifteenDegOffset, 2.75 * spacing + 3); + ctx.moveTo(fifteenDegOffset + greenThingWidth, 2.75 * spacing - 3); + ctx.lineTo(fifteenDegOffset, 2.75 * spacing - 3); + ctx.moveTo(fifteenDegOffset + greenThingWidth, 2.75 * spacing + 3); + ctx.lineTo(fifteenDegOffset, 2.75 * spacing + 3); + ctx.stroke(); + + ctx.font = "20px Arial"; + ctx.fillStyle = 'white'; + const labelMetrics = ctx.measureText("20"); + const labelCenterY = labelMetrics.actualBoundingBoxAscent / 2; + const labelOffset = tenDegOffset + 10; + + ctx.fillText("10", labelOffset, spacing * 2 + labelCenterY); + ctx.fillText("10", -labelOffset - labelMetrics.width, spacing * 2 + labelCenterY); + ctx.fillText("10", labelOffset, -spacing * 2 + labelCenterY); + ctx.fillText("10", -labelOffset - labelMetrics.width, -spacing * 2 + labelCenterY); + ctx.fillText("20", labelOffset, -spacing * 4 + labelCenterY); + ctx.fillText("20", -labelOffset - labelMetrics.width, -spacing * 4 + labelCenterY); + ctx.fillText("20", labelOffset, spacing * 3.5 + labelCenterY); + ctx.fillText("20", -labelOffset - labelMetrics.width, spacing * 3.5 + labelCenterY); + + const rollBig = 18; + const rollSmall = 10; + + const rollTriangleOffet = 4; + + ctx.beginPath(); + ctx.moveTo(0, -radius + rollTriangleOffet); + ctx.lineTo(rollBig, -radius + rollBig + rollTriangleOffet); + ctx.lineTo(-rollBig, -radius + rollBig + rollTriangleOffet); + ctx.lineTo(0, -radius + rollTriangleOffet); + ctx.lineWidth = 2; + ctx.strokeStyle = yellowColor; + ctx.stroke(); + + const rollTrapX1 = 20; + const rollTrapX2 = 30; + const rollTrapY1 = -radius + rollBig + rollTriangleOffet + 5; + const rollTrapY2 = rollTrapY1 + 10; + + ctx.beginPath(); + ctx.moveTo(-rollTrapX1, rollTrapY1); + ctx.lineTo(rollTrapX1, rollTrapY1); + ctx.lineTo(rollTrapX2, rollTrapY2); + ctx.lineTo(-rollTrapX2, rollTrapY2); + ctx.lineTo(-rollTrapX1, rollTrapY1); + ctx.lineWidth = 2; + ctx.strokeStyle = yellowColor; + ctx.stroke(); + + ctx.restore(); + + // Roll scale + + ctx.save(); + ctx.translate(attitudeIndMidX, attitudeIndMidY); + + ctx.beginPath(); + ctx.rect(-radius, -radius - 2 *rollBig , radius * 2, radius); + ctx.clip(); + + ctx.beginPath(); + ctx.moveTo(0, -radius); + ctx.lineTo(rollBig, -radius - rollBig); + ctx.lineTo(-rollBig, -radius - rollBig); + ctx.lineTo(0, -radius); + ctx.lineWidth = 2; + ctx.strokeStyle = yellowColor; + ctx.stroke(); + + ctx.beginPath(); + ctx.arc(0, 0, radius, (240 - 2) * Math.PI / 180, (300 + 2) * Math.PI / 180); + ctx.lineWidth = 2; + ctx.strokeStyle = 'white'; + ctx.stroke(); + + ctx.rotate(45 * Math.PI / 180); + drawBankAngleLineMarker(ctx, radius, rollBig); + ctx.rotate(-15 * Math.PI / 180); + drawBankAngleBoxMarker(ctx, radius, rollBig); + ctx.rotate(-10 * Math.PI / 180); + drawBankAngleBoxMarker(ctx, radius, 10); + ctx.rotate(-10 * Math.PI / 180); + drawBankAngleBoxMarker(ctx, radius, 10); + ctx.rotate(-10 * Math.PI / 180); + + ctx.rotate(-10 * Math.PI / 180); + drawBankAngleBoxMarker(ctx, radius, 10); + ctx.rotate(-10 * Math.PI / 180); + drawBankAngleBoxMarker(ctx, radius, 10); + ctx.rotate(-10 * Math.PI / 180); + drawBankAngleBoxMarker(ctx, radius, rollBig); + ctx.rotate(-15 * Math.PI / 180); + drawBankAngleLineMarker(ctx, radius, rollBig); + + + // ctx.rotate(-rollRad); + // ctx.translate(-attitudeIndMidX, -attitudeIndMidY); + + ctx.restore(); + + // Wings + const wingThickness = 14; + const wingHeight = 35; + const wingWidth = 90; + const wingOffset = 130; + + ctx.beginPath(); + ctx.moveTo(attitudeIndMidX - wingOffset - wingWidth, attitudeIndMidY - wingThickness / 2); + ctx.lineTo(attitudeIndMidX - wingOffset, attitudeIndMidY - wingThickness / 2); + ctx.lineTo(attitudeIndMidX - wingOffset, attitudeIndMidY + wingHeight); + ctx.lineTo(attitudeIndMidX - wingOffset - wingThickness, attitudeIndMidY + wingHeight); + ctx.lineTo(attitudeIndMidX - wingOffset - wingThickness, attitudeIndMidY + wingThickness / 2); + ctx.lineTo(attitudeIndMidX - wingOffset - wingWidth, attitudeIndMidY + wingThickness / 2); + ctx.lineTo(attitudeIndMidX - wingOffset - wingWidth, attitudeIndMidY - wingThickness / 2); + ctx.lineWidth = 4; + ctx.fillStyle = 'black'; + ctx.strokeStyle = yellowColor; + ctx.stroke(); + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(attitudeIndMidX + wingOffset + wingWidth, attitudeIndMidY - wingThickness / 2); + ctx.lineTo(attitudeIndMidX + wingOffset, attitudeIndMidY - wingThickness / 2); + ctx.lineTo(attitudeIndMidX + wingOffset, attitudeIndMidY + wingHeight); + ctx.lineTo(attitudeIndMidX + wingOffset + wingThickness, attitudeIndMidY + wingHeight); + ctx.lineTo(attitudeIndMidX + wingOffset + wingThickness, attitudeIndMidY + wingThickness / 2); + ctx.lineTo(attitudeIndMidX + wingOffset + wingWidth, attitudeIndMidY + wingThickness / 2); + ctx.lineTo(attitudeIndMidX + wingOffset + wingWidth, attitudeIndMidY - wingThickness / 2); + ctx.lineWidth = 4; + ctx.fillStyle = 'black'; + ctx.strokeStyle = yellowColor; + ctx.stroke(); + ctx.fill(); + + ctx.strokeRect(attitudeIndMidX - wingThickness / 2, attitudeIndMidY - wingThickness / 2, wingThickness, wingThickness); + + // Radio altitude + if ((radioAltitude !== undefined) && (radioAltitude <= 2500)) { + ctx.font = "30px Arial"; + if (radioAltitude < 100) { + ctx.fillStyle = orangeColor; + } else { + ctx.fillStyle = greenColor; + } + const raText = Math.round(radioAltitude).toString(); + const raTextMetrics = ctx.measureText(raText); + ctx.fillText(raText, attitudeIndMidX - raTextMetrics.width / 2, attitudeIndTop + attitudeIndHeight - 4); + } +} + +function drawPressure(ctx, fm20, fm30) { + + if (pressure !== undefined) { + + const pressureLeft = 750; // Righthand side of altitude indicator + const pressureMidX = 750 + 80; // Lefthand side of altitude indicator + const pressureMidY = 850; + + const pressureInt = Math.round(pressure); + + if (isStd()) { + + const border = 3; + ctx.font = "30px Arial"; + ctx.fillStyle = cyanColor; + const pressureText = "STD"; + const pressureMetrics = ctx.measureText(pressureText); + const height = pressureMetrics.actualBoundingBoxAscent + pressureMetrics.actualBoundingBoxDescent + 2 * border; + ctx.fillText(pressureText, pressureMidX - pressureMetrics.width / 2, pressureMidY); + + ctx.lineWidth = 2; + ctx.strokeStyle = yellowColor; + ctx.strokeRect(pressureMidX - pressureMetrics.width / 2 - border, pressureMidY - pressureMetrics.actualBoundingBoxAscent - 2 * border, pressureMetrics.width + border * 2, height + border * 2); + + } else { + ctx.font = "30px Arial"; + ctx.fillStyle = "white"; + ctx.fillText("QNH", pressureLeft, pressureMidY); + + ctx.fillStyle = cyanColor; + const pressureText = pressureInt.toString(); + ctx.fillText(pressureText, pressureMidX, pressureMidY); + } + } +} + +function drawMachIndicator(ctx) { + if (mach !== undefined) { + if (mach >= 0.45) { // Actually comes on at .5 and goes off at 0.45 + + ctx.font = "38px Arial"; + ctx.fillStyle = greenColor; + var machString; + if (mach < 1) { + machString = mach.toFixed(3).substring(1); // No leading 0 + } else { + machString = mach.toFixed(2); + } + + ctx.fillText(machString, 40, 840); + } + } +} +function drawVerticalSpeedIndicator(ctx, fm20, fm30) { + + const verticalSpeedIndWidth = 50; + const verticalSpeedIndHeight = 620; + const verticalSpeedIndLeft = 900; + const verticalSpeedIndTop = 190; + const verticalSpeedIndBottom = verticalSpeedIndTop + verticalSpeedIndHeight; + const verticalSpeedIndMidY = verticalSpeedIndTop + + verticalSpeedIndHeight / 2; + const verticalSpeedIndMidX = verticalSpeedIndLeft + verticalSpeedIndWidth / 2; + const verticalSpeedIndRight = verticalSpeedIndLeft + verticalSpeedIndWidth; + const verticalSpeedBevel = 80; + const verticalSpeedMarkerSpacing = 35; + const verticalSpeedMarkerWidth = verticalSpeedIndWidth / 4; + const verticalSpeedMarkerLeft = verticalSpeedIndLeft + verticalSpeedMarkerWidth; + const verticalSpeedIndText = verticalSpeedIndLeft + 1; + const verticalSpeedLineRight = verticalSpeedIndRight + 30; + const verticalSpeedLineLeft = verticalSpeedIndMidX - 5; + + + // Background + ctx.beginPath(); + ctx.moveTo(verticalSpeedIndLeft, verticalSpeedIndTop); + ctx.lineTo(verticalSpeedIndMidX, verticalSpeedIndTop); + ctx.lineTo(verticalSpeedIndRight, verticalSpeedIndTop + verticalSpeedBevel); + ctx.lineTo(verticalSpeedIndRight, verticalSpeedIndBottom - verticalSpeedBevel); + ctx.lineTo(verticalSpeedIndMidX, verticalSpeedIndBottom); + ctx.lineTo(verticalSpeedIndLeft, verticalSpeedIndBottom); + ctx.lineTo(verticalSpeedIndLeft, verticalSpeedIndTop); + ctx.fillStyle = grayColor; + ctx.fill(); + + // Labels + ctx.font = "20px Arial"; + ctx.fillStyle = 'white'; + ctx.fillText("6", verticalSpeedIndText, verticalSpeedIndMidY - 8 * verticalSpeedMarkerSpacing + fm20.actualBoundingBoxAscent / 2); + ctx.fillText("2", verticalSpeedIndText, verticalSpeedIndMidY - 6 * verticalSpeedMarkerSpacing + fm20.actualBoundingBoxAscent / 2); + ctx.fillText("1", verticalSpeedIndText, verticalSpeedIndMidY - 4 * verticalSpeedMarkerSpacing + fm20.actualBoundingBoxAscent / 2); + ctx.fillText("1", verticalSpeedIndText, verticalSpeedIndMidY + 4 * verticalSpeedMarkerSpacing + fm20.actualBoundingBoxAscent / 2); + ctx.fillText("2", verticalSpeedIndText, verticalSpeedIndMidY + 6 * verticalSpeedMarkerSpacing + fm20.actualBoundingBoxAscent / 2); + ctx.fillText("6", verticalSpeedIndText, verticalSpeedIndMidY + 8 * verticalSpeedMarkerSpacing + fm20.actualBoundingBoxAscent / 2); + + // Markers + ctx.fillStyle = 'white'; + for (var i = -8; i <= 8; i++) + { + const j = Math.abs(i); + if (!((j == 1) || (j == 3))) { + var height; + if ((j == 4) || (j == 6) || (j == 8)) { + height = 6; + } else { + height = 2; + } + ctx.fillRect(verticalSpeedMarkerLeft, verticalSpeedIndMidY - i * verticalSpeedMarkerSpacing - height / 2, verticalSpeedMarkerWidth, height); + } + } + + // 0 marker + ctx.fillStyle = yellowColor; + ctx.fillRect(verticalSpeedIndLeft, verticalSpeedIndMidY - 3, verticalSpeedIndWidth / 2, 6); + + // Indicator line + + const verticalSpeedAbsolute = Math.abs(verticalSpeed); + var verticalOffset; + var color = greenColor; + + if (verticalSpeedAbsolute <= 1000) { + verticalOffset = verticalSpeedAbsolute / 1000 * 4 * verticalSpeedMarkerSpacing; + } else if (verticalSpeedAbsolute < 2000) { + verticalOffset = (verticalSpeedAbsolute - 1000) / 1000 * 2 * verticalSpeedMarkerSpacing + 4 * verticalSpeedMarkerSpacing; + } else if (verticalSpeedAbsolute < 6000) { + verticalOffset = (verticalSpeedAbsolute - 2000) / 4000 * 2 * verticalSpeedMarkerSpacing + 6 * verticalSpeedMarkerSpacing; + } else { + verticalOffset = 8 * verticalSpeedMarkerSpacing; + color = orangeColor; + } + + const verticalSpeedLineY = verticalSpeedIndMidY - verticalOffset * Math.sign(verticalSpeed); + + ctx.beginPath(); + ctx.moveTo(verticalSpeedLineRight, verticalSpeedIndMidY); + ctx.lineTo(verticalSpeedLineLeft, verticalSpeedLineY); + ctx.lineWidth = 4; + ctx.strokeStyle = color; + ctx.stroke(); + + // Text + if (verticalSpeedAbsolute > 200) { + + const verticalSpeedTextX = verticalSpeedLineLeft + 3; + const verticalSpeedTextHeight = fm20.actualBoundingBoxAscent + fm20.actualBoundingBoxDescent + 2; + const verticalSpeedText = Math.round(verticalSpeedAbsolute / 100); + const verticalSpeedTextString = verticalSpeedText.toString().padStart(2, '0'); + + // Background + ctx.fillStyle = 'black'; + if (verticalSpeed > 0) { + ctx.fillRect(verticalSpeedTextX, verticalSpeedLineY - verticalSpeedTextHeight, verticalSpeedIndWidth / 2, verticalSpeedTextHeight); + } else { + ctx.fillRect(verticalSpeedTextX, verticalSpeedLineY, verticalSpeedIndWidth / 2, verticalSpeedTextHeight); + } + + ctx.fillStyle = color; + if (verticalSpeed > 0) { + ctx.fillText(verticalSpeedTextString, verticalSpeedTextX, verticalSpeedLineY - fm20.actualBoundingBoxDescent - 1); + } else { + ctx.fillText(verticalSpeedTextString, verticalSpeedTextX, verticalSpeedLineY + fm20.actualBoundingBoxAscent + 1); + } + + } + +} + +// For testing only +var speedInc = 0.1; +var headingInc = 0.1; +var altitudeInc = 1; +var verticalSpeedInc = 10; +var rollInc = 0.1; +function animatePFD() { + if (speed === undefined) { + speed = 0; + targetSpeed = 200; + } else if (speed <= 0) { + speedInc = 0.1; + } else if (speed >= 350) { + speedInc = -0.1; + } + if (heading === undefined) { + heading = 0; + targetHeading = 180; + } else if (heading <= 0) { + headingInc = 0.1; + } else if (heading >= 360) { + headingInc = -0.1; + } + if (altitude === undefined) { + altitude = 0; + targetAltitude = 1000; + } else if (altitude <= 0) { + altitudeInc = 1; + } else if (altitude >= 45000) { + altitudeInc = -1; + } + if (verticalSpeed === undefined) { + verticalSpeed = -7000; + } else if (verticalSpeed <= -7000) { + verticalSpeedInc = 10; + } else if (verticalSpeed >= 7000) { + verticalSpeedInc = -10; + } + if (roll === undefined) { + roll = 45; + } else if (roll <= -45) { + rollInc = 0.1; + } else if (roll >= 45) { + rollInc = -0.1; + } + speed = speed + speedInc; + groundspeed = 150; + trueAirspeed = 130; + heading = heading + headingInc; + altitude = altitude + altitudeInc; + altitude = 39; + radioAltitude = 1720; + onSurface = 0; + verticalSpeed = verticalSpeed + verticalSpeedInc; + roll = roll + rollInc; + callsign = "BAW123G"; + aircraftType = "A320"; + windDirection = 85; + windSpeed = 15; + staticAirTemperature = 7; + verticalMode = 3; + lateralMode = 2; + autopilot = 1; + tcasMode = 1; + pressure = 1013; + //pressure = 1018; +} +function drawPFD() { + + const canvas = document.getElementById("pfdCanvas"); + const ctx = canvas.getContext("2d"); + + ctx.font = "20px Arial"; + const fm20 = ctx.measureText("180"); + ctx.font = "30px Arial"; + const fm30 = ctx.measureText("180"); + + // Background + + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, 1000, 1000); + + // Airspeed indicator + + drawAirspeedIndicator(ctx, fm20, fm30); + + // Attitude indicator + + drawAttitudeIndicator(ctx, fm20, fm30); + + // Altimeter + + drawAltitudeIndicator(ctx, fm20, fm30); + + // Vertical speed indicator + + drawVerticalSpeedIndicator(ctx, fm20, fm30); + + // Heading indicator + + drawHeadingIndicator(ctx, fm20, fm30); + + // Pressure setting + + drawPressure(ctx, fm20, fm30); + + // Mach indicator + + drawMachIndicator(ctx); + + // Flight mode announciators + + ctx.beginPath(); + ctx.moveTo(220, 5); + ctx.lineTo(220, 140); + ctx.lineWidth = 1; + ctx.strokeStyle = "white"; + ctx.stroke(); + if (targetSpeed !== undefined) { + ctx.font = "30px Arial"; + ctx.fillStyle = greenColor; + ctx.fillText("SPEED", 55, 50); // THR CLB is climbing? + } else if (toga) { + ctx.font = "30px Arial"; + ctx.fillStyle = "white"; + ctx.fillText("TOGA", 70, 50); + } + + // FIXME: + //ctx.font = "30px Arial"; + //ctx.fillStyle = "white"; + //ctx.fillText("toga " + toga + " roll " + rollOut + " Surf " + onSurface + " wasSurf " + wasOnSurface60SecsAgo + " MS " + modelSpeed + " r/w Alt " + runwayAltitude, 50, 150); + + var landing = false; + if (verticalMode !== undefined) { + ctx.font = "30px Arial"; + ctx.fillStyle = greenColor; + if (verticalMode == 1) { + if (verticalSpeed !== undefined) { + if (verticalSpeed > 0) { + ctx.fillText("CLB", 290, 50); + } else if (verticalSpeed < 0) { + ctx.fillText("DES", 290, 50); + } else { + ctx.fillText("ALT", 290, 50); + } + } else if (targetAltitude !== undefined && altitude !== undefined) { + if (targetAltitude > altitude) { + ctx.fillText("CLB", 290, 50); + } else if (targetAltitude < altitude) { + ctx.fillText("DES", 290, 50); + } else { + ctx.fillText("ALT", 290, 50); + } + } + } else if (verticalMode == 2) { + ctx.fillText("ALT", 290, 50); + } else if (verticalMode == 3) { + if (rollOut) { + ctx.fillText("ROLL OUT", 370, 50); + landing = true; + } else if ((onSurface === 0) && (radioAltitude < 40)) { + ctx.fillText("FLARE", 390, 50); + landing = true; + } else if ((onSurface === 0) && (radioAltitude < 400)) { + ctx.fillText("LAND", 400, 50); + landing = true; + } else { + ctx.fillText("G/S", 290, 50); + } + } + } + if (!landing) { + ctx.beginPath(); + ctx.moveTo(420, 5); + ctx.lineTo(420, 140); + ctx.lineWidth = 1; + ctx.strokeStyle = "white"; + ctx.stroke(); + } + + ctx.beginPath(); + ctx.moveTo(660, 5); + ctx.lineTo(660, 140); + ctx.lineWidth = 1; + ctx.strokeStyle = "white"; + ctx.stroke(); + if (lateralMode !== undefined) { + ctx.font = "30px Arial"; + ctx.fillStyle = greenColor; + if (lateralMode == 1) { + ctx.fillText("NAV", 510, 50); + } else if (lateralMode == 2) { + if (!landing) { + ctx.fillText("LOC", 510, 50); + } + } + } + + ctx.beginPath(); + ctx.moveTo(850, 5); + ctx.lineTo(850, 140); + ctx.lineWidth = 1; + ctx.strokeStyle = "white"; + ctx.stroke(); + ctx.font = "30px Arial"; + ctx.fillStyle = "white"; + if (autopilot !== undefined) { + if (autopilot) { + if (verticalMode == 3) { + // We can't tell whether both APs are enabled - but they typically would be + ctx.fillText("AP1+2", 870, 40); + ctx.fillText("CAT3", 720, 40); + ctx.fillText("DUAL", 720, 85); + } else { + ctx.fillText("AP1", 890, 40); + } + } + } + ctx.fillText("1 FD 2", 870, 85); + if (((targetSpeed !== undefined) || autopilot) && !rollOut) { + ctx.fillText("A/THR", 870, 130); + } + + // Aircraft callsign and type + + ctx.fillStyle = "white"; + ctx.font = "30px Arial"; + if (callsign !== undefined) { + ctx.fillText(callsign, 20, 940); + } + ctx.font = "21px Arial"; + if (aircraftType !== undefined) { + ctx.fillText(aircraftType, 20, 980); + } + + const dataLeft = 750; // Aligned with QNH + const dataTop = 890; + + // TCAS - As displayed on ND/ECAM + if (tcasMode === 1) { + ctx.fillStyle = orangeColor; + ctx.font = "21px Arial"; + ctx.fillText("TCAS STBY", dataLeft, dataTop); + } else if (tcasMode === 2) { + ctx.fillStyle = "white"; + ctx.font = "21px Arial"; + ctx.fillText("TA ONLY", dataLeft, dataTop); + } + + // Groundspeed and TAS as displayed on ND + if ((groundspeed !== undefined) || (trueAirspeed !== undefined)) { + ctx.font = "21px Arial"; + if (groundspeed !== undefined) { + ctx.fillStyle = "white"; + ctx.fillText("GS", dataLeft, dataTop + 30); + ctx.fillStyle = greenColor; + ctx.fillText(Math.round(groundspeed).toString(), dataLeft + 40, dataTop + 30); + } + if (trueAirspeed !== undefined) { + ctx.fillStyle = "white"; + ctx.fillText("TAS", dataLeft + 90, dataTop + 30); + ctx.fillStyle = greenColor; + ctx.fillText(Math.round(trueAirspeed).toString(), dataLeft + 140, dataTop + 30); + } + } + + // Wind speed and direction as displayed on ND + if ((windSpeed !== undefined) && (windDirection !== undefined)) { + const windText = pad(Math.round(windDirection), 3) + "/" + Math.round(windSpeed); + ctx.fillStyle = greenColor; + ctx.font = "21px Arial"; + ctx.fillText(windText, dataLeft, dataTop + 60); + + if (heading !== undefined) { + // An arrow showing wind direction relative to heading + var angle = (windDirection - heading) * Math.PI / 180; + + ctx.save(); + ctx.translate(dataLeft + 80, dataTop + 53); + ctx.rotate(angle); + + ctx.strokeStyle = greenColor; + ctx.beginPath(); + ctx.moveTo(0, -10); + ctx.lineTo(0, 10); + ctx.lineTo(-5, 7); + ctx.moveTo(0, 10); + ctx.lineTo(5, 7); + ctx.stroke(); + + ctx.restore(); + } + } + + // Static air temperature as displayed on lower ECAM + if (staticAirTemperature !== undefined) { + var satText; + if (staticAirTemperature > 0) { + satText = "+" + Math.round(staticAirTemperature); + } else { + satText = Math.round(staticAirTemperature).toString(); + } + + ctx.font = "21px Arial"; + ctx.fillStyle = "white"; + ctx.fillText("SAT", dataLeft, dataTop + 90); + ctx.fillStyle = greenColor; + ctx.fillText(satText, dataLeft + 60, dataTop + 90); + ctx.fillStyle = cyanColor; + ctx.fillText("\u00B0C", dataLeft + 100, dataTop + 90); + } +} + +const canvas = document.getElementById('pfdCanvas'); +canvas.onmousedown = pfdMouseDown; +canvas.onmouseup = pfdMouseUp; +canvas.onmousemove = pfdMouseMove; +canvas.onmouseleave = pfdMouseLeave; +canvas.onmouseenter = pfdMouseEnter; +var docMouseMove = null; +var docMouseUp = null; +var movePFD = false; +var scalePFD = false; +var posX = 0; +var posY = 0; +var canvasWidth = 0; +var canvasHeight = 0; + +function pad(num, size) { + num = num.toString(); + while (num.length < size) num = "0" + num; + return num; +} + +function pfdMouseDown(e) { + e.preventDefault(); + e.stopPropagation(); + const r = canvas.getBoundingClientRect(); + posX = r.left; + posY = r.top; + canvasWidth = r.width; + canvasHeight = r.height; + if ((e.offsetX > 0.95 * canvasWidth) && (e.offsetY > 0.95 * canvasHeight)) { + scalePFD = true; + } else { + movePFD = true; + } +} +function pfdMouseUp(e) { + e.preventDefault(); + e.stopPropagation(); + movePFD = false; + scalePFD = false; + pfdRestoreDocMouse(); +} +function pfdMouseMove(e) { + e.preventDefault(); + e.stopPropagation(); + if (movePFD) { + posX = posX + e.movementX; + posY = posY + e.movementY; + canvas.style.position = "absolute"; + canvas.style.left = posX + "px"; + canvas.style.top = posY + "px"; + } else if (scalePFD) { + canvasWidth = canvasWidth + e.movementX; + canvasHeight = canvasHeight + e.movementY; + const constrainedWidth = Math.max(canvasWidth, 250); + const constrainedHeight = Math.max(canvasHeight, 250); + canvas.style.width = constrainedWidth + "px"; + canvas.style.height = constrainedHeight + "px"; + } +} + +function pfdMouseLeave(e) { + //movePFD = false; + //scalePFD = false; + if (scalePFD || movePFD) { + docMouseUp = document.onmouseup; + docMouseMove = document.onmousemove; + document.onmouseup = pfdMouseUp; + document.onmousemove = pfdMouseMove; + } +} + +function pfdRestoreDocMouse() { + if (docMouseUp) { + document.onmouseup = docMouseUp; + document.onmousemove = docMouseMove; + docMouseDown = null; + docMouseMove = null; + } +} +function pfdMouseEnter(e) { + pfdRestoreDocMouse(); +} diff --git a/plugins/feature/map/map/map3d.html b/plugins/feature/map/map/map3d.html index b1cc86b2e..da0cda347 100644 --- a/plugins/feature/map/map/map3d.html +++ b/plugins/feature/map/map/map3d.html @@ -1,848 +1,1348 @@ - - + + - + -
+
+ - + + + + + + - diff --git a/plugins/feature/map/mapaircraftstate.h b/plugins/feature/map/mapaircraftstate.h new file mode 100644 index 000000000..0462aba80 --- /dev/null +++ b/plugins/feature/map/mapaircraftstate.h @@ -0,0 +1,61 @@ +/////////////////////////////////////////////////////////////////////////////////// +// Copyright (C) 2025 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_FEATURE_MAPAIRCRAFTSTATE_H_ +#define INCLUDE_FEATURE_MAPAIRCRAFTSTATE_H_ + +struct MapAircraftState { + QString m_callsign; + QString m_aircraftType; + int m_onSurface; // -1 for unknown + float m_indicatedAirspeed; // NaN for unknown + QString m_indicatedAirspeedDateTime; + float m_trueAirspeed; + float m_groundspeed; + float m_mach; + float m_altitude; + QString m_altitudeDateTime; + float m_qnh; + float m_verticalSpeed; + float m_heading; + float m_track; + float m_selectedAltitude; + float m_selectedHeading; + int m_autopilot; // -1 for unknown + enum VerticalMode { + UNKNOWN_VERTICAL_MODE, + VNAV, + ALT_HOLD, + GS + } m_verticalMode; + enum LateralMode { + UNKNOWN_LATERAL_MODE, + LNAV, + LOC + } m_lateralMode; + enum TCASMode { + UNKNOWN_TCAS_MODE, + TCAS_OFF, + TA, + TA_RA + } m_tcasMode; + float m_windSpeed; + float m_windDirection; + float m_staticAirTemperature; +}; + +#endif // INCLUDE_FEATURE_MAPAIRCRAFTSTATE_H_ diff --git a/plugins/feature/map/mapgui.cpp b/plugins/feature/map/mapgui.cpp index b8fbd9c8d..d54840b94 100644 --- a/plugins/feature/map/mapgui.cpp +++ b/plugins/feature/map/mapgui.cpp @@ -1929,7 +1929,6 @@ void MapGUI::applyMap3DSettings(bool reloadMap) ui->web->load(QUrl(QString("http://127.0.0.1:%1/map/map/map3d.html").arg(m_webPort))); //ui->web->load(QUrl(QString("http://webglreport.com/"))); //ui->web->load(QUrl(QString("https://sandcastle.cesium.com/"))); - //ui->web->load(QUrl("chrome://gpu/")); ui->web->show(); } else if (!m_settings.m_map3DEnabled && (m_cesium != nullptr)) @@ -1948,9 +1947,11 @@ void MapGUI::applyMap3DSettings(bool reloadMap) m_cesium->setCameraReferenceFrame(m_settings.m_eciCamera); m_cesium->setAntiAliasing(m_settings.m_fxaa, m_settings.m_msaa); m_cesium->getDateTime(); + m_cesium->setViewFirstPerson(m_settings.m_viewFirstPerson); m_cesium->setHDR(m_settings.m_hdr); m_cesium->setFog(m_settings.m_fog); m_cesium->showFPS(m_settings.m_fps); + m_cesium->showPFD(m_settings.m_displayPFD); m_cesium->showMUF(m_settings.m_displayMUF); m_cesium->showfoF2(m_settings.m_displayfoF2); m_cesium->showMagDec(m_settings.m_displayMagDec); @@ -1968,9 +1969,12 @@ void MapGUI::applyMap3DSettings(bool reloadMap) m_polylineMapModel.allUpdated(); } MapSettings::MapItemSettings *ionosondeItemSettings = getItemSettings("Ionosonde Stations"); - m_giro->getIndexPeriodically((m_settings.m_displayMUF || m_settings.m_displayfoF2) ? 15 : 0); - if (ionosondeItemSettings) { - m_giro->getDataPeriodically(ionosondeItemSettings->m_enabled ? 2 : 0); + if (m_giro) + { + m_giro->getIndexPeriodically((m_settings.m_displayMUF || m_settings.m_displayfoF2) ? 15 : 0); + if (ionosondeItemSettings) { + m_giro->getDataPeriodically(ionosondeItemSettings->m_enabled ? 2 : 0); + } } if (m_aurora) { m_aurora->getDataPeriodically(m_settings.m_displayAurora ? 30 : 0); @@ -2084,6 +2088,8 @@ void MapGUI::displaySettings() setTitle(m_settings.m_title); blockApplySettings(true); ui->displayNames->setChecked(m_settings.m_displayNames); + ui->viewFirstPerson->setChecked(m_settings.m_viewFirstPerson); + ui->displayPFD->setChecked(m_settings.m_displayPFD); ui->displaySelectedGroundTracks->setChecked(m_settings.m_displaySelectedGroundTracks); ui->displayAllGroundTracks->setChecked(m_settings.m_displayAllGroundTracks); ui->displayRain->setChecked(m_settings.m_displayRain); @@ -2202,6 +2208,24 @@ void MapGUI::on_maidenhead_clicked() dialog.exec(); } +void MapGUI::on_viewFirstPerson_clicked(bool checked) +{ + m_settings.m_viewFirstPerson = checked; + if (m_cesium) { + m_cesium->setViewFirstPerson(checked); + } + applySetting("viewFirstPerson"); +} + +void MapGUI::on_displayPFD_clicked(bool checked) +{ + m_settings.m_displayPFD = checked; + if (m_cesium) { + m_cesium->showPFD(checked); + } + applySetting("viewFirstPerson"); +} + void MapGUI::on_displayNames_clicked(bool checked) { m_settings.m_displayNames = checked; @@ -2415,10 +2439,10 @@ void MapGUI::on_displayMUF_clicked(bool checked) m_displayMUF->setChecked(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->getIndexPeriodically((m_settings.m_displayMUF || m_settings.m_displayfoF2) ? 15 : 0); - if (m_cesium && !m_settings.m_displayMUF) { + if (m_giro) { + m_giro->getIndexPeriodically((m_settings.m_displayMUF || m_settings.m_displayfoF2) ? 15 : 0); + } + if (m_cesium) { m_cesium->showMUF(m_settings.m_displayMUF); } applySetting("displayMUF"); @@ -2433,8 +2457,10 @@ void MapGUI::on_displayfoF2_clicked(bool checked) m_displayfoF2->setChecked(checked); } m_settings.m_displayfoF2 = checked; - m_giro->getIndexPeriodically((m_settings.m_displayMUF || m_settings.m_displayfoF2) ? 15 : 0); - if (m_cesium && !m_settings.m_displayfoF2) { + if (m_giro) { + m_giro->getIndexPeriodically((m_settings.m_displayMUF || m_settings.m_displayfoF2) ? 15 : 0); + } + if (m_cesium) { m_cesium->showfoF2(m_settings.m_displayfoF2); } applySetting("displayfoF2"); @@ -3009,6 +3035,8 @@ void MapGUI::preferenceChanged(int elementType) void MapGUI::makeUIConnections() { QObject::connect(ui->displayNames, &ButtonSwitch::clicked, this, &MapGUI::on_displayNames_clicked); + QObject::connect(ui->viewFirstPerson, &ButtonSwitch::clicked, this, &MapGUI::on_viewFirstPerson_clicked); + QObject::connect(ui->displayPFD, &ButtonSwitch::clicked, this, &MapGUI::on_displayPFD_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->displayRain, &ButtonSwitch::clicked, this, &MapGUI::on_displayRain_clicked); diff --git a/plugins/feature/map/mapgui.h b/plugins/feature/map/mapgui.h index d3cf398c1..e53bec505 100644 --- a/plugins/feature/map/mapgui.h +++ b/plugins/feature/map/mapgui.h @@ -326,6 +326,8 @@ private slots: void onMenuDialogCalled(const QPoint &p); void onWidgetRolled(QWidget* widget, bool rollDown); void handleInputMessages(); + void on_viewFirstPerson_clicked(bool checked=false); + void on_displayPFD_clicked(bool checked=false); void on_displayNames_clicked(bool checked=false); void on_displayAllGroundTracks_clicked(bool checked=false); void on_displaySelectedGroundTracks_clicked(bool checked=false); diff --git a/plugins/feature/map/mapguiwebengine.ui b/plugins/feature/map/mapguiwebengine.ui index f9e85f6b0..61416d15a 100644 --- a/plugins/feature/map/mapguiwebengine.ui +++ b/plugins/feature/map/mapguiwebengine.ui @@ -29,7 +29,7 @@ - Qt::StrongFocus + Qt::FocusPolicy::StrongFocus Map @@ -39,7 +39,7 @@ 0 0 - 1221 + 1251 41 @@ -166,7 +166,7 @@ IBP - + :/map/icons/ibp.png:/map/icons/ibp.png @@ -180,7 +180,7 @@ - + :/map/icons/clock.png:/map/icons/clock.png @@ -191,11 +191,11 @@ - + :/map/icons/layers.png:/map/icons/layers.png - QToolButton::InstantPopup + QToolButton::ToolButtonPopupMode::InstantPopup @@ -205,10 +205,10 @@ Display satellite infra-red (clouds) - ^ + - + :/map/icons/cloud.png:/map/icons/cloud.png @@ -225,10 +225,10 @@ Display weather radar (rain/snow) - ^ + - + :/map/icons/precipitation.png:/map/icons/precipitation.png @@ -245,10 +245,10 @@ Display sea marks - ^ + - + :/map/icons/anchor.png:/map/icons/anchor.png @@ -265,10 +265,10 @@ Display railways - ^ + - + :/map/icons/railway.png:/map/icons/railway.png @@ -285,10 +285,10 @@ Display MUF (Maximum Usable Frequency) contours (3D only) - ^ + - + :/map/icons/muf.png:/map/icons/muf.png @@ -305,10 +305,10 @@ Display foF2 (F2 layer critical frequency) contours (3D only) - ^ + - + :/map/icons/fof2.png:/map/icons/fof2.png @@ -365,10 +365,10 @@ Display NASA GIBS data - ^ + - + :/map/icons/earthsat.png:/map/icons/earthsat.png @@ -430,7 +430,7 @@ Display names - ^ + @@ -444,13 +444,54 @@ + + + + First person / third person view on 3D map + + + + + + + :/map/icons/thirdperson.png + :/map/icons/firstperson.png:/map/icons/thirdperson.png + + + true + + + true + + + + + + + Display aircraft PFD (Primary Flight Display) on 3D map + + + + + + + :/map/icons/pfd.png:/map/icons/pfd.png + + + true + + + false + + + Display ground tracks for selected item - ^ + @@ -470,10 +511,10 @@ Display all ground tracks - ^ + - + :/map/icons/groundtracks.png:/map/icons/groundtracks.png @@ -573,7 +614,7 @@ - Qt::Vertical + Qt::Orientation::Vertical @@ -592,7 +633,7 @@ Map - QQuickWidget::SizeRootObjectToView + QQuickWidget::ResizeMode::SizeRootObjectToView @@ -655,7 +696,7 @@ - + diff --git a/plugins/feature/map/mapicons.qrc b/plugins/feature/map/mapicons.qrc index 1e71f738d..b7ee70a6e 100644 --- a/plugins/feature/map/mapicons.qrc +++ b/plugins/feature/map/mapicons.qrc @@ -15,6 +15,7 @@ icons/waypoints.png icons/earthsat.png icons/aurora.png + icons/pfd.png icons/compass.png icons/grid.png icons/thirdperson.png diff --git a/plugins/feature/map/mapitem.cpp b/plugins/feature/map/mapitem.cpp index 4cb043807..65538e6ed 100644 --- a/plugins/feature/map/mapitem.cpp +++ b/plugins/feature/map/mapitem.cpp @@ -34,6 +34,11 @@ void MapItem::update(SWGSDRangel::SWGMapItem *mapItem) } else { m_label = ""; } + if (mapItem->getLabelDateTime()) { + m_labelDateTime = QDateTime::fromString(*mapItem->getLabelDateTime(), Qt::ISODateWithMs); + } else { + m_labelDateTime = QDateTime(); + } m_latitude = mapItem->getLatitude(); m_longitude = mapItem->getLongitude(); m_altitude = mapItem->getAltitude(); @@ -47,6 +52,8 @@ QGeoCoordinate MapItem::getCoordinates() return coords; } +WhittakerEilers ObjectMapItem::m_filter; + void ObjectMapItem::update(SWGSDRangel::SWGMapItem *mapItem) { MapItem::update(mapItem); @@ -55,6 +62,11 @@ void ObjectMapItem::update(SWGSDRangel::SWGMapItem *mapItem) } else { m_positionDateTime = QDateTime(); } + if (mapItem->getAltitudeDateTime()) { + m_altitudeDateTime = QDateTime::fromString(*mapItem->getAltitudeDateTime(), Qt::ISODateWithMs); + } else { + m_altitudeDateTime = QDateTime(); + } m_useHeadingPitchRoll = mapItem->getOrientation() == 1; m_heading = mapItem->getHeading(); m_pitch = mapItem->getPitch(); @@ -79,6 +91,14 @@ void ObjectMapItem::update(SWGSDRangel::SWGMapItem *mapItem) } m_labelAltitudeOffset = mapItem->getLabelAltitudeOffset(); m_modelAltitudeOffset = mapItem->getModelAltitudeOffset(); + // FIXME: See nodeTransformations comment in czml.cpp + // We can't use nodeTransformations, so adjust altitude instead + if (m_modelAltitudeOffset != 0) + { + m_labelAltitudeOffset -= m_modelAltitudeOffset; + m_altitude += m_modelAltitudeOffset; + m_modelAltitudeOffset = 0; + } m_altitudeReference = mapItem->getAltitudeReference(); m_fixedPosition = mapItem->getFixedPosition(); QList *animations = mapItem->getAnimations(); @@ -91,7 +111,7 @@ void ObjectMapItem::update(SWGSDRangel::SWGMapItem *mapItem) findFrequencies(); if (!m_fixedPosition) { - updateTrack(mapItem->getTrack()); + updateTrack(mapItem->getTrack(), m_itemSettings); updatePredictedTrack(mapItem->getPredictedTrack()); } if (mapItem->getAvailableFrom()) { @@ -104,6 +124,47 @@ void ObjectMapItem::update(SWGSDRangel::SWGMapItem *mapItem) } else { m_availableUntil = QDateTime(); } + if (mapItem->getAircraftState()) { + if (!m_aircraftState) { + m_aircraftState = new MapAircraftState(); + } + SWGSDRangel::SWGMapAircraftState *as = mapItem->getAircraftState(); + if (as->getCallsign()) { + m_aircraftState->m_callsign = *as->getCallsign(); + } + if (as->getAircraftType()) { + m_aircraftState->m_aircraftType = *as->getAircraftType(); + } + m_aircraftState->m_onSurface = as->getOnSurface(); + m_aircraftState->m_indicatedAirspeed = as->getAirspeed(); + if (as->getAirspeedDateTime()) { + m_aircraftState->m_indicatedAirspeedDateTime = *as->getAirspeedDateTime(); + } else { + m_aircraftState->m_indicatedAirspeedDateTime = QString(); + } + m_aircraftState->m_trueAirspeed = as->getTrueAirspeed(); + m_aircraftState->m_groundspeed = as->getGroundspeed(); + m_aircraftState->m_mach = as->getMach(); + m_aircraftState->m_altitude = as->getAltitude(); + if (as->getAltitudeDateTime()) { + m_aircraftState->m_altitudeDateTime = *as->getAltitudeDateTime(); + } else { + m_aircraftState->m_altitudeDateTime = QString(); + } + m_aircraftState->m_qnh = as->getQnh(); + m_aircraftState->m_verticalSpeed = as->getVerticalSpeed(); + m_aircraftState->m_heading = as->getHeading(); + m_aircraftState->m_track = as->getTrack(); + m_aircraftState->m_selectedAltitude = as->getSelectedAltitude(); + m_aircraftState->m_selectedHeading = as->getSelectedHeading(); + m_aircraftState->m_autopilot = as->getAutopilot(); + m_aircraftState->m_verticalMode = (MapAircraftState::VerticalMode) as->getVerticalMode(); + m_aircraftState->m_lateralMode = (MapAircraftState::LateralMode) as->getLateralMode(); + m_aircraftState->m_tcasMode = (MapAircraftState::TCASMode) as->getTcasMode(); + m_aircraftState->m_windSpeed = as->getWindSpeed(); + m_aircraftState->m_windDirection = as->getWindDirection(); + m_aircraftState->m_staticAirTemperature = as->getStaticAirTemperature(); + } } void ImageMapItem::update(SWGSDRangel::SWGMapItem *mapItem) @@ -225,7 +286,145 @@ void ObjectMapItem::findFrequencies() } } -void ObjectMapItem::updateTrack(QList *track) +void ObjectMapItem::extrapolatePosition(QGeoCoordinate *c, const QDateTime& dateTime) +{ + int p1; + int p2; + + // Find last two non extrapolated position + for (p2 = m_takenTrackPositionExtrapolated.size() - 1 ; p2 >= 0; p2--) + { + if (!m_takenTrackPositionExtrapolated[p2]) { + break; + } + } + for (p1 = p2 - 1 ; p1 >= 0; p1--) + { + if (!m_takenTrackPositionExtrapolated[p1]) { + break; + } + } + + if (p1 < 0) { + return; + } + + + qint64 t1 = m_takenTrackDateTimes[p1]->msecsTo(*m_takenTrackDateTimes[p2]); + qint64 t2 = m_takenTrackDateTimes[p2]->msecsTo(dateTime); + + double latV = (m_takenTrackCoords[p2]->latitude() - m_takenTrackCoords[p1]->latitude()) / t1; + double lonV = (m_takenTrackCoords[p2]->longitude() - m_takenTrackCoords[p1]->longitude()) / t1; + + double newLat = m_takenTrackCoords[p2]->latitude() + latV * t2; + double newLon = m_takenTrackCoords[p2]->longitude() + lonV * t2; + + c->setLatitude(newLat); + c->setLongitude(newLon); +} + +void ObjectMapItem::extrapolateAltitude(QGeoCoordinate *c, const QDateTime& dateTime) +{ + int p1; + int p2; + + // Find last two non extrapolated position + for (p2 = m_takenTrackPositionExtrapolated.size() - 1 ; p2 >= 0; p2--) + { + if (!m_takenTrackPositionExtrapolated[p2]) { + break; + } + } + for (p1 = p2 - 1 ; p1 >= 0; p1--) + { + if (!m_takenTrackPositionExtrapolated[p1]) { + break; + } + } + + if (p1 < 0) { + return; + } + + + qint64 t1 = m_takenTrackDateTimes[p1]->msecsTo(*m_takenTrackDateTimes[p2]); + qint64 t2 = m_takenTrackDateTimes[p2]->msecsTo(dateTime); + + double vertV = (m_takenTrackCoords[p2]->altitude() - m_takenTrackCoords[p1]->altitude()) / t1; + + double newAlt = m_takenTrackCoords[p2]->latitude() + vertV * t2; + + c->setAltitude(newAlt); +} + +void ObjectMapItem::interpolatePosition(int p2, const float p3Latitude, const float p3Longitude, const QDateTime &p3DateTime) +{ + // p1 last non extrapolated position + // p2 interpolated position + // p3 current non extrapolated position + + // Find last non extrapolated position + int p1; + + for (p1 = p2 - 1; p1 >= 0; p1--) + { + if (!m_takenTrackPositionExtrapolated[p1]) { + break; + } + } + if (p1 < 0) { + return; + } + + qint64 t1 = m_takenTrackDateTimes[p1]->msecsTo(p3DateTime); + qint64 t2 = m_takenTrackDateTimes[p1]->msecsTo(*m_takenTrackDateTimes[p2]); + + double latV = (p3Latitude - m_takenTrackCoords[p1]->latitude()) / t1; + double lonV = (p3Longitude - m_takenTrackCoords[p1]->longitude()) / t1; + + double newLat = m_takenTrackCoords[p1]->latitude() + latV * t2; + double newLon = m_takenTrackCoords[p1]->longitude() + lonV * t2; + + m_takenTrackCoords[p2]->setLatitude(newLat); + m_takenTrackCoords[p2]->setLongitude(newLon); + + m_interpolatedCoords.append(m_takenTrackCoords[p2]); + m_interpolatedDateTimes.append(m_takenTrackDateTimes[p2]); +} + +void ObjectMapItem::interpolateAltitude(int p2, const float p3Altitude, const QDateTime &p3DateTime) +{ + // p1 last non extrapolated position + // p2 interpolated position + // p3 current non extrapolated position + + // Find last non extrapolated position + int p1; + + for (p1 = p2 - 1; p1 >= 0; p1--) + { + if (!m_takenTrackPositionExtrapolated[p1]) { + break; + } + } + if (p1 < 0) { + return; + } + + qint64 t1 = m_takenTrackDateTimes[p1]->msecsTo(p3DateTime); + qint64 t2 = m_takenTrackDateTimes[p1]->msecsTo(*m_takenTrackDateTimes[p2]); + + double vertV = (p3Altitude - m_takenTrackCoords[p1]->altitude()) / t1; + + double newAlt = m_takenTrackCoords[p1]->altitude() + vertV * t2; + + m_takenTrackCoords[p2]->setAltitude(newAlt); + + m_interpolatedCoords.append(m_takenTrackCoords[p2]); + m_interpolatedDateTimes.append(m_takenTrackDateTimes[p2]); +} + +void ObjectMapItem::updateTrack(QList *track, MapSettings::MapItemSettings *itemSettings) { if (track != nullptr) { @@ -233,6 +432,8 @@ void ObjectMapItem::updateTrack(QList *track) m_takenTrackCoords.clear(); qDeleteAll(m_takenTrackDateTimes); m_takenTrackDateTimes.clear(); + m_takenTrackPositionExtrapolated.clear(); + m_takenTrackAltitudeExtrapolated.clear(); m_takenTrack.clear(); m_takenTrack1.clear(); m_takenTrack2.clear(); @@ -243,6 +444,8 @@ void ObjectMapItem::updateTrack(QList *track) QDateTime *d = new QDateTime(QDateTime::fromString(*p->getDateTime(), Qt::ISODate)); m_takenTrackCoords.push_back(c); m_takenTrackDateTimes.push_back(d); + m_takenTrackPositionExtrapolated.push_back(false); + m_takenTrackAltitudeExtrapolated.push_back(false); m_takenTrack.push_back(QVariant::fromValue(*c)); } } @@ -253,28 +456,188 @@ void ObjectMapItem::updateTrack(QList *track) { QGeoCoordinate *c = new QGeoCoordinate(m_latitude, m_longitude, m_altitude); m_takenTrackCoords.push_back(c); - if (m_positionDateTime.isValid()) { + if (m_altitudeDateTime.isValid()) { + m_takenTrackDateTimes.push_back(new QDateTime(m_altitudeDateTime)); + } else if (m_positionDateTime.isValid()) { m_takenTrackDateTimes.push_back(new QDateTime(m_positionDateTime)); } else { m_takenTrackDateTimes.push_back(new QDateTime(QDateTime::currentDateTime())); } + m_takenTrackPositionExtrapolated.push_back(false); + m_takenTrackAltitudeExtrapolated.push_back(false); m_takenTrack.push_back(QVariant::fromValue(*c)); } else { - QGeoCoordinate *prev = m_takenTrackCoords.last(); + // For Whittaker-Eilers filtering, we need to make sure we don't have 2 data with the same time + // so we just update the last item if the prev time is the same + // To reduce size of list for stationary items, we only store two items with same position + // We store two, rather than one, so that we have the times this position was arrived at and left + + const bool interpolate = false; + + QGeoCoordinate *prev1 = m_takenTrackCoords.last(); + bool samePos1 = (prev1->latitude() == m_latitude) && (prev1->longitude() == m_longitude) && (prev1->altitude() == m_altitude); + QGeoCoordinate *prev2 = m_takenTrackCoords.size() > 1 ? m_takenTrackCoords[m_takenTrackCoords.size() - 2] : nullptr; + bool samePos2 = prev2 && samePos1 ? (prev2->latitude() == m_latitude) && (prev2->longitude() == m_longitude) && (prev2->altitude() == m_altitude) : false; + QDateTime *prevDateTime = m_takenTrackDateTimes.last(); - if ((prev->latitude() != m_latitude) || (prev->longitude() != m_longitude) - || (prev->altitude() != m_altitude) || (*prevDateTime != m_positionDateTime)) + + QGeoCoordinate c(m_latitude, m_longitude, m_altitude); + int prevSize = m_takenTrackPositionExtrapolated.size(); + + if (m_altitudeDateTime.isValid() && m_positionDateTime.isValid() && (m_altitudeDateTime > m_positionDateTime)) { - QGeoCoordinate *c = new QGeoCoordinate(m_latitude, m_longitude, m_altitude); - m_takenTrackCoords.push_back(c); - if (m_positionDateTime.isValid()) { - m_takenTrackDateTimes.push_back(new QDateTime(m_positionDateTime)); - } else { - m_takenTrackDateTimes.push_back(new QDateTime(QDateTime::currentDateTime())); + if (interpolate) + { + for (int i = m_takenTrackAltitudeExtrapolated.size() - 1; (i >= 0) && m_takenTrackAltitudeExtrapolated[i]; i--) + { + interpolateAltitude(i, m_altitude, m_altitudeDateTime); + m_takenTrackAltitudeExtrapolated[i] = false; + } + } + + if (samePos2) + { + *m_takenTrackDateTimes.last() = m_altitudeDateTime; + } + else + { + extrapolatePosition(&c, m_altitudeDateTime); + if (m_altitudeDateTime == *prevDateTime) + { + m_takenTrackPositionExtrapolated[m_takenTrackPositionExtrapolated.size() - 1] = true; + *m_takenTrackCoords.last() = c; + } + else + { + m_takenTrackDateTimes.push_back(new QDateTime(m_altitudeDateTime)); + m_takenTrackPositionExtrapolated.push_back(true); + m_takenTrackAltitudeExtrapolated.push_back(false); + m_takenTrackCoords.push_back(new QGeoCoordinate(c)); + } + } + } + else if (m_positionDateTime.isValid()) + { + if (interpolate) + { + for (int i = m_takenTrackPositionExtrapolated.size() - 1; (i >= 0) && m_takenTrackPositionExtrapolated[i]; i--) + { + interpolatePosition(i, m_latitude, m_longitude, m_positionDateTime); + m_takenTrackPositionExtrapolated[i] = false; + } + } + + if (m_positionDateTime > *m_takenTrackDateTimes.last()) + { + if (samePos2) + { + *m_takenTrackDateTimes.last() = m_positionDateTime; + } + else + { + bool extrapolateAlt = m_altitudeDateTime.isValid() && (m_positionDateTime > m_altitudeDateTime); + if (extrapolateAlt) { + extrapolateAltitude(&c, m_positionDateTime); + } + if (m_positionDateTime == *prevDateTime) + { + m_takenTrackAltitudeExtrapolated[m_takenTrackPositionExtrapolated.size() - 1] = extrapolateAlt; + *m_takenTrackCoords.last() = c; + } + else + { + m_takenTrackDateTimes.push_back(new QDateTime(m_positionDateTime)); + m_takenTrackPositionExtrapolated.push_back(false); + m_takenTrackAltitudeExtrapolated.push_back(extrapolateAlt); + m_takenTrackCoords.push_back(new QGeoCoordinate(c)); + } + } + } + else + { + //qDebug() << "m_positionDateTime matches last datetime" << samePos1 << samePos2; + } + } + else + { + m_takenTrackDateTimes.push_back(new QDateTime(QDateTime::currentDateTime())); + m_takenTrackPositionExtrapolated.push_back(false); + m_takenTrackAltitudeExtrapolated.push_back(false); + //m_takenTrackCoords.push_back(c); + m_takenTrackCoords.push_back(new QGeoCoordinate(c)); + } + //m_takenTrack.push_back(QVariant::fromValue(*c)); + m_takenTrack.push_back(QVariant::fromValue(c)); + + if (m_takenTrackDateTimes.size() >= 2) { + if (*m_takenTrackDateTimes[m_takenTrackDateTimes.size() - 1] < *m_takenTrackDateTimes[m_takenTrackDateTimes.size() - 2]) { + qDebug() << "Out of order"; + } + } + + if ((m_takenTrackPositionExtrapolated.size() > 0) && (prevSize != m_takenTrackPositionExtrapolated.size())) + { + const int filterLen = itemSettings->m_smoothingWindow; + if ((filterLen > 0) && (m_takenTrackCoords.size() >= filterLen) && (m_takenTrackCoords.size() % (filterLen/2)) == 0) + { + // Filter last filterLen coords + QVector x(filterLen); + QVector y1(filterLen); + QVector y2(filterLen); + QVector y3(filterLen); + QVector w1(filterLen); + QVector w3(filterLen); + + //qDebug() << "Filter from" << (m_takenTrackCoords.size() - (filterLen - 0)) << "to" << (m_takenTrackCoords.size() - (filterLen - (filterLen - 1))); + for (int i = 0; i < filterLen; i++) + { + int idx = m_takenTrackCoords.size() - (filterLen - i); + x[i] = (m_takenTrackDateTimes[idx]->toMSecsSinceEpoch() - m_takenTrackDateTimes[0]->toMSecsSinceEpoch()) / 1000.0; + y1[i] = m_takenTrackCoords[idx]->latitude(); + y2[i] = m_takenTrackCoords[idx]->longitude(); + y3[i] = m_takenTrackCoords[idx]->altitude(); + if (i < (filterLen / 4)) + { + w1[i] = 10.0; // Try to avoid discontinuities between windows + w3[i] = 10.0; + } + else if (i == filterLen - 1) + { + w1[i] = 1.0; + w3[i] = 1.0; + } + else + { + w1[i] = m_takenTrackPositionExtrapolated[idx] ? 0.0 : 1.0; + w3[i] = m_takenTrackAltitudeExtrapolated[idx] ? 0.0 : 1.0; + } + } + + const double lambda = itemSettings->m_smoothingLambda; + m_filter.filter(x.data(), y1.data(), w1.data(), filterLen, lambda); + m_filter.filter(x.data(), y2.data(), w1.data(), filterLen, lambda); + m_filter.filter(x.data(), y3.data(), w3.data(), filterLen, lambda); + + for (int i = 0; i < filterLen; i++) + { + int idx = m_takenTrackCoords.size() - (filterLen - i); + m_takenTrackCoords[idx]->setLatitude(y1[i]); + m_takenTrackCoords[idx]->setLongitude(y2[i]); + m_takenTrackCoords[idx]->setAltitude(y3[i]); + m_takenTrackPositionExtrapolated[idx] = false; + m_takenTrackAltitudeExtrapolated[idx] = false; + + m_interpolatedCoords.append(m_takenTrackCoords[idx]); + m_interpolatedDateTimes.append(m_takenTrackDateTimes[idx]); + } + + // Update current position + m_latitude = m_takenTrackCoords[filterLen-1]->latitude(); + m_longitude = m_takenTrackCoords[filterLen-1]->longitude(); + m_altitude = m_takenTrackCoords[filterLen-1]->altitude(); } - m_takenTrack.push_back(QVariant::fromValue(*c)); } } } diff --git a/plugins/feature/map/mapitem.h b/plugins/feature/map/mapitem.h index e76406bd7..e3220fee8 100644 --- a/plugins/feature/map/mapitem.h +++ b/plugins/feature/map/mapitem.h @@ -25,6 +25,8 @@ #include "mapsettings.h" #include "cesiuminterface.h" +#include "util/whittakereilers.h" +#include "mapaircraftstate.h" #include "SWGMapItem.h" @@ -58,6 +60,7 @@ protected: QString m_name; // Unique id QString m_label; + QDateTime m_labelDateTime; // Date & time from which this label is valid from (for 3D map). Invalid date/time is forever float m_latitude; // Position for label float m_longitude; float m_altitude; // In metres @@ -65,13 +68,13 @@ protected: QDateTime m_availableUntil; // Date & time this item is visible until (for 3D map). Invalid date/time is forever }; - // Information required about each item displayed on the map class ObjectMapItem : public MapItem { public: ObjectMapItem(const QObject *sourcePipe, const QString &group, MapSettings::MapItemSettings *itemSettings, SWGSDRangel::SWGMapItem *mapItem) : - MapItem(sourcePipe, group, itemSettings, mapItem) + MapItem(sourcePipe, group, itemSettings, mapItem), + m_aircraftState(nullptr) { update(mapItem); } @@ -79,12 +82,17 @@ public: protected: void findFrequencies(); - void updateTrack(QList *track); + void updateTrack(QList *track, MapSettings::MapItemSettings *itemSettings); void updatePredictedTrack(QList *track); + void extrapolatePosition(QGeoCoordinate *c, const QDateTime& dateTime); + void extrapolateAltitude(QGeoCoordinate *c, const QDateTime& dateTime); + void interpolatePosition(int i, const float latitude, const float longitude, const QDateTime &dateTime); + void interpolateAltitude(int i, const float altitude, const QDateTime &dateTime); friend ObjectMapModel; friend CZML; QDateTime m_positionDateTime; + QDateTime m_altitudeDateTime; bool m_useHeadingPitchRoll; float m_heading; float m_pitch; @@ -107,6 +115,8 @@ protected: QGeoCoordinate m_predictedEnd2; QList m_takenTrackCoords; QList m_takenTrackDateTimes; + QList m_takenTrackPositionExtrapolated; + QList m_takenTrackAltitudeExtrapolated; QVariantList m_takenTrack; // Line showing where the object has been QVariantList m_takenTrack1; QVariantList m_takenTrack2; @@ -114,6 +124,8 @@ protected: QGeoCoordinate m_takenStart2; QGeoCoordinate m_takenEnd1; QGeoCoordinate m_takenEnd2; + QList m_interpolatedCoords; + QList m_interpolatedDateTimes; // For 3D map QString m_model; @@ -121,6 +133,9 @@ protected: float m_labelAltitudeOffset; float m_modelAltitudeOffset; QList m_animations; + MapAircraftState *m_aircraftState; + + static WhittakerEilers m_filter; // For smoothing/interpolating position }; class PolygonMapItem : public MapItem { diff --git a/plugins/feature/map/mapsettings.cpp b/plugins/feature/map/mapsettings.cpp index e72212050..167e42d81 100644 --- a/plugins/feature/map/mapsettings.cpp +++ b/plugins/feature/map/mapsettings.cpp @@ -269,7 +269,6 @@ QByteArray MapSettings::serialize() const s.writeBool(29, m_sunLightEnabled); s.writeBool(30, m_eciCamera); s.writeString(31, m_cesiumIonAPIKey); - s.writeString(32, m_antiAliasing); s.writeS32(33, m_workspaceIndex); s.writeBlob(34, m_geometryBytes); @@ -292,6 +291,9 @@ QByteArray MapSettings::serialize() const s.writeBool(48, m_displayMaidenheadGrid); s.writeString(49, m_defaultImagery); s.writeString(50, m_arcGISAPIKey); + s.writeBool(51, m_displayPFD); + s.writeBool(52, m_viewFirstPerson); + s.writeBool(53, m_terrainLighting); s.writeBool(54, m_water); s.writeBool(55, m_hdr); @@ -299,6 +301,7 @@ QByteArray MapSettings::serialize() const s.writeBool(57, m_fps); s.writeBool(58, m_fxaa); s.writeS32(59, m_msaa); + return s.final(); } @@ -369,7 +372,6 @@ bool MapSettings::deserialize(const QByteArray& data) d.readBool(29, &m_sunLightEnabled, true); d.readBool(30, &m_eciCamera, false); d.readString(31, &m_cesiumIonAPIKey, ""); - d.readString(32, &m_antiAliasing, "None"); d.readS32(33, &m_workspaceIndex, 0); d.readBlob(34, &m_geometryBytes); @@ -391,6 +393,9 @@ bool MapSettings::deserialize(const QByteArray& data) d.readBool(48, &m_displayMaidenheadGrid, false); d.readString(49, &m_defaultImagery, "Sentinel-2"); d.readString(50, &m_arcGISAPIKey, ""); + d.readBool(51, &m_displayPFD, false); + d.readBool(52, &m_viewFirstPerson, false); + d.readBool(53, &m_terrainLighting, true); d.readBool(54, &m_water, false); d.readBool(55, &m_hdr, true); @@ -398,6 +403,7 @@ bool MapSettings::deserialize(const QByteArray& data) d.readBool(57, &m_fps, false); d.readBool(58, &m_fxaa, false); d.readS32(59, &m_msaa, 1); + return true; } else @@ -451,6 +457,8 @@ void MapSettings::MapItemSettings::resetToDefaults() m_filterName = ""; m_filterDistance = 0; m_extrapolate = 60; + m_smoothingWindow = 0; + m_smoothingLambda = 100; } QByteArray MapSettings::MapItemSettings::serialize() const @@ -475,6 +483,8 @@ QByteArray MapSettings::MapItemSettings::serialize() const s.writeString(16, m_filterName); s.writeS32(17, m_filterDistance); s.writeS32(18, m_extrapolate); + s.writeS32(19, m_smoothingWindow); + s.writeFloat(20, m_smoothingLambda); return s.final(); } @@ -511,6 +521,16 @@ bool MapSettings::MapItemSettings::deserialize(const QByteArray& data) d.readS32(18, &m_extrapolate, 60); m_filterNameRE.setPattern(m_filterName); m_filterNameRE.optimize(); + if (m_group == "ADSBDemod") + { + d.readS32(19, &m_smoothingWindow, 10); + d.readFloat(20, &m_smoothingLambda, 100); + } + else + { + d.readS32(19, &m_smoothingWindow, 0); + d.readFloat(20, &m_smoothingLambda, 100); + } return true; } else @@ -703,6 +723,12 @@ void MapSettings::applySettings(const QStringList& settingsKeys, const MapSettin if (settingsKeys.contains("arcGISAPIKey")) { m_arcGISAPIKey = settings.m_arcGISAPIKey; } + if (settingsKeys.contains("displayPFD")) { + m_displayPFD = settings.m_displayPFD; + } + if (settingsKeys.contains("viewFirstPerson")) { + m_viewFirstPerson = settings.m_viewFirstPerson; + } if (settingsKeys.contains("workspaceIndex")) { m_workspaceIndex = settings.m_workspaceIndex; } @@ -847,6 +873,12 @@ QString MapSettings::getDebugString(const QStringList& settingsKeys, bool force) if (settingsKeys.contains("arcGISAPIKey") || force) { ostr << " m_arcGISAPIKey: " << m_arcGISAPIKey.toStdString(); } + if (settingsKeys.contains("displayPFD") || force) { + ostr << " m_displayPFD: " << m_displayPFD; + } + if (settingsKeys.contains("viewFirstPerson") || force) { + ostr << " m_viewFirstPerson: " << m_viewFirstPerson; + } if (settingsKeys.contains("workspaceIndex") || force) { ostr << " m_workspaceIndex: " << m_workspaceIndex; } diff --git a/plugins/feature/map/mapsettings.h b/plugins/feature/map/mapsettings.h index 7bb8bfe0d..33af69219 100644 --- a/plugins/feature/map/mapsettings.h +++ b/plugins/feature/map/mapsettings.h @@ -52,6 +52,8 @@ struct MapSettings QRegularExpression m_filterNameRE; int m_filterDistance; // Filter items > this distance in metres away from My Position. <= 0 don't filter int m_extrapolate; // Extrapolate duration in seconds on 3D map + int m_smoothingWindow; // Window size (numer of points) to smooth over. 0 for no smoothing + float m_smoothingLambda; // Lambda parameter for WhittakerEilers, controls how much smoothing to apply MapItemSettings(const QString& group, bool enabled, const QColor color, bool display2DTrack=true, bool display3DPoint=true, int minZoom=11, int modelMinPixelSize=0); MapItemSettings(const QByteArray& data); @@ -120,6 +122,8 @@ struct MapSettings bool m_displayAurora; bool m_displayMagDec; bool m_displayMaidenheadGrid; + bool m_displayPFD; + bool m_viewFirstPerson; QString m_defaultImagery; QString m_checkWXAPIKey; //!< checkwxapi.com API key diff --git a/plugins/feature/map/mapsettingsdialog.cpp b/plugins/feature/map/mapsettingsdialog.cpp index ea08f5afa..505fb232a 100644 --- a/plugins/feature/map/mapsettingsdialog.cpp +++ b/plugins/feature/map/mapsettingsdialog.cpp @@ -53,10 +53,20 @@ MapItemSettingsGUI::MapItemSettingsGUI(QTableWidget *table, int row, MapSettings m_filterDistance->setAlignment(Qt::AlignRight | Qt::AlignVCenter); m_filterDistance->setSpecialValueText(" "); m_filterDistance->setCorrectionMode(QAbstractSpinBox::CorrectToNearestValue); + m_smoothingWindow = new QSpinBox(table); + m_smoothingWindow->setRange(0, 1000); + m_smoothingWindow->setValue(settings->m_smoothingWindow); + m_smoothingWindow->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + m_smoothingLambda = new QDoubleSpinBox(table); + m_smoothingLambda->setRange(0, 1e9); + m_smoothingLambda->setValue(settings->m_smoothingLambda); + m_smoothingLambda->setAlignment(Qt::AlignRight | Qt::AlignVCenter); table->setCellWidget(row, MapSettingsDialog::COL_2D_MIN_ZOOM, m_minZoom); table->setCellWidget(row, MapSettingsDialog::COL_3D_MIN_PIXELS, m_minPixels); table->setCellWidget(row, MapSettingsDialog::COL_3D_LABEL_SCALE, m_labelScale); table->setCellWidget(row, MapSettingsDialog::COL_FILTER_DISTANCE, m_filterDistance); + table->setCellWidget(row, MapSettingsDialog::COL_SMOOTHING_WINDOW, m_smoothingWindow); + table->setCellWidget(row, MapSettingsDialog::COL_SMOOTHING_LAMBDA, m_smoothingLambda); } MapSettingsDialog::MapSettingsDialog(MapSettings *settings, QWidget* parent) : @@ -142,6 +152,12 @@ MapSettingsDialog::MapSettingsDialog(MapSettings *settings, QWidget* parent) : item = new QTableWidgetItem(itemSettings->m_filterName); ui->mapItemSettings->setItem(row, COL_FILTER_NAME, item); + item = new QTableWidgetItem(); + ui->mapItemSettings->setItem(row, COL_SMOOTHING_WINDOW, item); + + item = new QTableWidgetItem(); + ui->mapItemSettings->setItem(row, COL_SMOOTHING_LAMBDA, item); + MapItemSettingsGUI *gui = new MapItemSettingsGUI(ui->mapItemSettings, row, itemSettings); m_mapItemSettingsGUIs.append(gui); } @@ -170,6 +186,8 @@ MapSettingsDialog::MapSettingsDialog(MapSettings *settings, QWidget* parent) : ui->mapItemSettings->hideColumn(COL_3D_POINT); ui->mapItemSettings->hideColumn(COL_3D_TRACK); ui->mapItemSettings->hideColumn(COL_3D_LABEL_SCALE); + ui->mapItemSettings->hideColumn(COL_SMOOTHING_WINDOW); + ui->mapItemSettings->hideColumn(COL_SMOOTHING_LAMBDA); #endif } @@ -203,7 +221,6 @@ void MapSettingsDialog::accept() m_settings->m_mapBoxAPIKey = mapBoxAPIKey; m_settings->m_osmURL = osmURL; m_settings->m_mapBoxStyles = mapBoxStyles; - m_settings->m_cesiumIonAPIKey = cesiumIonAPIKey; m_map2DSettingsChanged = true; } else @@ -223,7 +240,6 @@ void MapSettingsDialog::accept() { m_map3DSettingsChanged = false; } - if (m_settings->m_map2DEnabled != ui->map2DEnabled->isChecked()) { m_settings->m_map2DEnabled = ui->map2DEnabled->isChecked(); @@ -323,6 +339,8 @@ void MapSettingsDialog::accept() itemSettings->m_filterNameRE.setPattern(itemSettings->m_filterName); itemSettings->m_filterNameRE.optimize(); itemSettings->m_filterDistance = gui->m_filterDistance->value() * 1000; + itemSettings->m_smoothingWindow = gui->m_smoothingWindow->value(); + itemSettings->m_smoothingLambda = gui->m_smoothingLambda->value(); } QDialog::accept(); @@ -359,6 +377,8 @@ void MapSettingsDialog::on_map3DEnabled_clicked(bool checked) ui->mapItemSettings->showColumn(COL_3D_POINT); ui->mapItemSettings->showColumn(COL_3D_TRACK); ui->mapItemSettings->showColumn(COL_3D_LABEL_SCALE); + ui->mapItemSettings->showColumn(COL_SMOOTHING_WINDOW); + ui->mapItemSettings->showColumn(COL_SMOOTHING_LAMBDA); } else { @@ -368,6 +388,8 @@ void MapSettingsDialog::on_map3DEnabled_clicked(bool checked) ui->mapItemSettings->hideColumn(COL_3D_POINT); ui->mapItemSettings->hideColumn(COL_3D_TRACK); ui->mapItemSettings->hideColumn(COL_3D_LABEL_SCALE); + ui->mapItemSettings->hideColumn(COL_SMOOTHING_WINDOW); + ui->mapItemSettings->hideColumn(COL_SMOOTHING_LAMBDA); } ui->terrain->setEnabled(checked); ui->buildings->setEnabled(checked); diff --git a/plugins/feature/map/mapsettingsdialog.h b/plugins/feature/map/mapsettingsdialog.h index 163e74831..fb80c2279 100644 --- a/plugins/feature/map/mapsettingsdialog.h +++ b/plugins/feature/map/mapsettingsdialog.h @@ -48,6 +48,8 @@ public: QSpinBox *m_minPixels; QDoubleSpinBox *m_labelScale; QSpinBox *m_filterDistance; + QSpinBox *m_smoothingWindow; + QDoubleSpinBox *m_smoothingLambda; }; class MapSettingsDialog : public QDialog { @@ -70,7 +72,9 @@ public: COL_3D_TRACK, COL_3D_LABEL_SCALE, COL_FILTER_NAME, - COL_FILTER_DISTANCE + COL_FILTER_DISTANCE, + COL_SMOOTHING_WINDOW, + COL_SMOOTHING_LAMBDA }; public: diff --git a/plugins/feature/map/mapsettingsdialog.ui b/plugins/feature/map/mapsettingsdialog.ui index 5fc94d86d..df2a98334 100644 --- a/plugins/feature/map/mapsettingsdialog.ui +++ b/plugins/feature/map/mapsettingsdialog.ui @@ -7,7 +7,7 @@ 0 0 1267 - 648 + 775 @@ -153,13 +153,52 @@ + + + Default imagery + + + + + + + Default imagery (Note there is a quota on Bing Maps Ariel usage) + + + + ArcGIS world imagery + + + + + Bing Maps Aerial + + + + + Sentinel-2 + + + + + Earth at night + + + + + Ersi world ocean + + + + + Terrain - + @@ -183,14 +222,48 @@ - + + + + Terrain lighting + + + + + + + Enable terrain lighting + + + + + + + + + + Water effects + + + + + + + Enable water effects such as waves + + + + + + + Buildings - + @@ -204,14 +277,14 @@ - + Lighting - + Whether lighting is from the Sun or Camera @@ -228,14 +301,71 @@ - + + + + Camera light Intensity + + + + + + + Intensity of camera light + + + 1 + + + 100.000000000000000 + + + 3.000000000000000 + + + + + + + HDR + + + + + + + High dynamic range + + + + + + + + + + Fog + + + + + + + Enable fog effect + + + + + + + Camera reference frame - + Selects camera reference frame. For ECEF the camera rotates with the Earth. For ECI, the camera position is fixed relative to the stars and the Earth's rotation will be visible. @@ -252,28 +382,77 @@ - - + + - Anti-aliasing + Display FPS - - + + - Set anti-aliasing to use. This can remove jagged pixels on the edge of 3D models. + Display frames per second (FPS) + + + + + + + + + + MSAA + + + + + + + Multisample Anti-Aliasing - None + Off - FXAA + 2 + + + 4 + + + + + 8 + + + + + 16 + + + + + + + + FXAA + + + + + + + Fast Approximate Anti-aliasing + + + + @@ -282,7 +461,7 @@ - Qt::Vertical + Qt::Orientation::Vertical @@ -313,7 +492,7 @@ - + :/map/icons/controltower.png:/map/icons/controltower.png @@ -327,7 +506,7 @@ - + :/map/icons/vor.png:/map/icons/vor.png @@ -341,7 +520,7 @@ - + :/map/icons/waypoints.png:/map/icons/waypoints.png @@ -349,7 +528,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -371,7 +550,7 @@ - QAbstractItemView::NoSelection + QAbstractItemView::SelectionMode::NoSelection @@ -468,6 +647,22 @@ Filter objects further than this distance in km away from My Position + + + Smoothing Window + + + How many coordinates to apply smoothing filter to. Set to 0 for no smoothing. + + + + + Smoothing Lambda + + + Smoothing parameter. Higher values result in more smoothing. + + @@ -563,20 +758,34 @@ - + CheckWX API key - + checkwxapi.com API key for accessing airport weather (METARs) + + + + ArcGIS API Key + + + + + + + Enter an ArcGIS API Key + + + @@ -590,10 +799,10 @@ - Qt::Horizontal + Qt::Orientation::Horizontal - QDialogButtonBox::Cancel|QDialogButtonBox::Ok + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok @@ -609,7 +818,7 @@ buildings sunLightEnabled eciCamera - antiAliasing + msaa downloadModels thunderforestAPIKey maptilerAPIKey @@ -617,7 +826,7 @@ cesiumIonAPIKey - +