diff --git a/src/components/map/airports/MapAirport.vue b/src/components/map/airports/MapAirport.vue index 309915bf..579860c6 100644 --- a/src/components/map/airports/MapAirport.vue +++ b/src/components/map/airports/MapAirport.vue @@ -128,6 +128,7 @@ import { sortControllersByPosition } from '~/composables/atc'; import MapAirportCounts from '~/components/map/airports/MapAirportCounts.vue'; import type { NavigraphAirportData } from '~/types/data/navigraph'; import { useMapStore } from '~/store/map'; +import type { StoreOverlayAirport } from '~/store/map'; import { getCurrentThemeRgbColor, useScrollExists } from '~/composables'; import type { Coordinate } from 'ol/coordinate'; import type { AirportTraconFeature } from '~/components/map/airports/MapAirportsList.vue'; @@ -201,6 +202,9 @@ const airportsSource = inject>('airports-source' const atcPopup = ref<{ $el: HTMLDivElement } | null>(null); const approachPopup = ref<{ $el: HTMLDivElement } | null>(null); const hoveredFacility = ref(false); +const airportData = shallowRef(null); +const detailedAircraft = getAircraftForAirport(airportData as Ref); + const hoveredController = ref(false); const facilityScroll = useScrollExists(computed(() => { @@ -260,6 +264,8 @@ const localsFacilities = computed(() => { let feature: Feature | null = null; let hoverFeature: Feature | null = null; +let popularRingCircleFeature: Feature | null = null; +let popularRingLabelFeature: Feature | null = null; interface ArrFeature { id: string; @@ -274,6 +280,7 @@ let runwaysFeatures: Feature[] = []; const airportName = computed(() => (props.airport.isPseudo && props.airport.iata) ? props.airport.iata : props.airport.icao); const hoveredFeature = computed(() => arrFeatures.value.find(x => x.id === props.hoveredId)); +const showPopularRing = computed(() => !(store.mapSettings.visibility?.popularAirportRings ?? true)); function initAirport() { if (!('lon' in props.airport) || isPseudoAirport.value) return; @@ -297,6 +304,114 @@ function initAirport() { airportsSource.value?.addFeature(feature); } +const ringStyle = new Style({ + stroke: new Stroke({ + color: `rgba(${ radarColors.airportPopularRingRgb.join(',') }, 0.4)`, + width: 1, + }), +}); +const popularRingLabelStyle = (icao: string) => { + return new Style({ + text: new Text({ + text: icao, + font: '10px Montserrat', + fill: new Fill({ + color: `rgba(${ radarColors.airportPopularRingRgb.join(',') }, 0.8)`, + }), + offsetY: -5, + }), + }) +} + +function setPopularRing() { + clearPopularRing(); + + const minMovement: number = 8; // minimum required movements to show the ring + const maxMovement: number = 120; // the number of movements when the maximum radius is reached + const minRadius: number = 70000; // the radius at the minimum movements + const maxRadius: number = 1200000; // the radius at the maximum movements + + if (isPrimaryAirport.value) return; + if (!showPopularRing.value) return; + + const vatAirport = dataStore.vatsim.data.airports.value.find(x => x.icao === props.airport.icao); + + if ((vatAirport?.aircraft?.arrivals?.length || 0) + (vatAirport?.aircraft?.groundDep?.length || 0) < minMovement) return; // We know for sure the airport has less than the minimum relevant movements, we don#t show the ring and skip the rest + + if (!airportData.value) { // check if airportData is already initialized + // the airportData is not yet initialized, so we do it by setting the icao code. This automatically fills the detailedAircraft variable + // we trigger the initialization of the airportData here to make sure it is not initialized when the rings are disabled for performance reasons + airportData.value = { + icao: props.airport.icao, + }; + } + + + // calculate movementCounter + const departureCounter: number = (detailedAircraft.value?.groundDep.length || 0) + (detailedAircraft.value?.prefiles.length || 0); + let arrivalCounter: number = 0; + if (detailedAircraft.value?.arrivals) { + for (let i = 0; i < detailedAircraft.value?.arrivals.length; i++) { + const arrival = detailedAircraft.value?.arrivals[i]; + if (!arrival.eta) continue; + + const currentDate = new Date() as Date; + const differenceInMs = arrival.eta.getTime() - currentDate.getTime(); + const differenceInMinutes = differenceInMs / (1000 * 60); + + if (differenceInMinutes > 0 && differenceInMinutes < 60) arrivalCounter++; + } + } + const movementCounter: number = departureCounter + arrivalCounter; + + let radius: number = 0; // radius is in meter + if (movementCounter >= maxMovement) { + radius = maxRadius; + } + else if (movementCounter > minMovement) { + radius = minRadius + ((movementCounter - minMovement) * ((maxRadius - minRadius) / (maxMovement - minMovement))); + } + + if (!radius) return; // If radius is 0, we don't need to draw the ring + if (!('lon' in props.airport)) return; + + popularRingCircleFeature = new Feature({ + geometry: new Circle([props.airport.lon, props.airport.lat], radius), + type: 'airportPopularRing', + icao: props.airport.icao, + }); + popularRingCircleFeature.setStyle(ringStyle); + airportsSource.value?.addFeature(popularRingCircleFeature); + + + // Calculate the top position of the circle for the text + const geometry = popularRingCircleFeature.getGeometry(); + const center = geometry?.getCenter(); + const topPosition = [center[0], center[1] + radius]; + + // Create a new point feature for the text + popularRingLabelFeature = new Feature({ + geometry: new Point(topPosition), + type: 'airportPopularLabel', + icao: props.airport.icao, + }); + popularRingLabelFeature.setStyle(popularRingLabelStyle(props.airport.icao)); + airportsSource.value?.addFeature(popularRingLabelFeature); +} + +function clearPopularRing() { + if (popularRingCircleFeature) { + airportsSource.value?.removeFeature(popularRingCircleFeature); + popularRingCircleFeature.dispose(); + popularRingCircleFeature = null; + } + if (popularRingLabelFeature) { + airportsSource.value?.removeFeature(popularRingLabelFeature); + popularRingLabelFeature.dispose(); + popularRingLabelFeature = null; + } +} + watch(getAirportColor, () => { if (!feature) return; @@ -419,7 +534,18 @@ onMounted(async () => { immediate: true, }); + watch(showPopularRing, (newValue, oldValue) => { + if (newValue) { + setPopularRing(); + } + else { + clearPopularRing(); + } + }); + function initAndUpdateData(force = false) { + setPopularRing(); + if (!props.arrAtc?.length || isPrimaryAirport.value || isHideAtcType('approach')) { clearArrFeatures(); arrAtcLocal.value.clear(); @@ -589,6 +715,7 @@ onMounted(async () => { deep: true, }); + if (isPrimaryAirport.value) { const overlay = await mapStore.addAirportOverlay(props.airport.icao); if (overlay) { @@ -611,6 +738,7 @@ onBeforeUnmount(() => { } clearArrFeatures(); + clearPopularRing(); gatesFeatures.forEach(feature => { vectorSource.value?.removeFeature(feature); diff --git a/src/components/map/filters/settings/MapSettingsVisibility.vue b/src/components/map/filters/settings/MapSettingsVisibility.vue index 23924f9f..50320645 100644 --- a/src/components/map/filters/settings/MapSettingsVisibility.vue +++ b/src/components/map/filters/settings/MapSettingsVisibility.vue @@ -142,6 +142,25 @@ > Runways + +
+ Airport popular rings + + + This will show rings around airport with more than 8 movements in the next hour. The size is decided based on the expected movements in the next hour. + + +
+
@@ -153,8 +172,18 @@ import CommonBlockTitle from '~/components/common/blocks/CommonBlockTitle.vue'; import { useStore } from '~/store'; import CommonSelect from '~/components/common/basic/CommonSelect.vue'; import CommonTabs from '~/components/common/basic/CommonTabs.vue'; +import QuestionIcon from 'assets/icons/basic/question.svg?component'; +import CommonTooltip from '~/components/common/basic/CommonTooltip.vue'; const store = useStore(); const tab = ref('modes'); + + diff --git a/src/types/map.ts b/src/types/map.ts index a1cc0db1..3003200c 100644 --- a/src/types/map.ts +++ b/src/types/map.ts @@ -54,6 +54,9 @@ interface IUserLocalSettings { layerVector?: boolean; transparencySettings?: UserLayersTransparencySettings; }; + options?: { + airportPopularRings?: boolean; // Shows rings around popular airports + }; notamsSortBy?: NotamsSortBy; }; diff --git a/src/utils/backend/map-settings.ts b/src/utils/backend/map-settings.ts index d6a2f39c..71c4d337 100644 --- a/src/utils/backend/map-settings.ts +++ b/src/utils/backend/map-settings.ts @@ -161,6 +161,7 @@ export interface IUserMapSettings { pilots?: boolean; gates?: boolean; runways?: boolean; + popularAirportRings?: boolean; }; heatmapLayer: boolean; groundTraffic: { diff --git a/src/utils/backend/styles.ts b/src/utils/backend/styles.ts index 8390b218..9bbc358f 100644 --- a/src/utils/backend/styles.ts +++ b/src/utils/backend/styles.ts @@ -2,6 +2,7 @@ import type { PartialRecord } from '~/types'; export const colorsList = { mapSectorBorder: '#2d2d30', + airportPopularRing: '#DA5225', lightgray0: '#F7F7FA', lightgray50: '#F2F2F7',