diff --git a/backend/context-service/resources/ATM/schemas.py b/backend/context-service/resources/ATM/schemas.py index c065325b..ca12d54b 100644 --- a/backend/context-service/resources/ATM/schemas.py +++ b/backend/context-service/resources/ATM/schemas.py @@ -10,16 +10,30 @@ class PlaneMetadataSchemaATM(MetadataSchema): Current_airspeed = Float() Latitude = Float() Longitude = Float() + # True heading, degrees clockwise from north (0-360). Optional + heading = Float(required=False) + # True if another aircraft is inside this one's protected zone right now. + in_los = fields.Boolean(required=False) wpList = List(Dict()) +class ShapeMetadataSchemaATM(MetadataSchema): + name = String(required=True) + # SECTOR | WEATHER | VOLCANIC | OBSTACLE, see build_shapes_payload() in + # ai4realnet_rl_batch_bridge.py. + kind = String(required=False) + coordinates = List(List(Float()), required=True) + + class MetadataSchemaATM(MetadataSchema): airplanes = List(fields.Nested(PlaneMetadataSchemaATM), required=True) - + shapes = List(fields.Nested(ShapeMetadataSchemaATM), required=False) + # Backward compatibility: optional fields for the single airplane case ApDest = Dict(required=False) Current_airspeed = Float(required=False) Latitude = Float(required=False) Longitude = Float(required=False) + heading = Float(required=False) wpList = List(Dict(), required=False) @pre_load @@ -27,7 +41,7 @@ def handle_backward_compatibility(self, data, **kwargs): # If the new 'airplanes' field is not provided, assume the old format. if 'airplanes' not in data: airplane = {} - for field in ['ApDest', 'Current_airspeed', 'Latitude', 'Longitude', 'wpList']: + for field in ['ApDest', 'Current_airspeed', 'Latitude', 'Longitude', 'heading', 'wpList']: if field in data: airplane[field] = data[field] # Provide a default id_plane if not present. diff --git a/frontend/public/img/icons/map_markers/ATM.svg b/frontend/public/img/icons/map_markers/ATM.svg index f11e3495..5a22a247 100644 --- a/frontend/public/img/icons/map_markers/ATM.svg +++ b/frontend/public/img/icons/map_markers/ATM.svg @@ -1 +1,48 @@ - \ No newline at end of file + + + + + + + diff --git a/frontend/src/components/organisms/Map.vue b/frontend/src/components/organisms/Map.vue index be58466b..7fc954e2 100644 --- a/frontend/src/components/organisms/Map.vue +++ b/frontend/src/components/organisms/Map.vue @@ -6,6 +6,19 @@ :url="tileLayer" layer-type="base" name="OpenStreetMap" /> + + + {{ polygon.id.replace(/^shape-/, '') }} + + + + {{ waypoint.id }} + + :class-name=" + 'context-marker ' + + waypoint.severity + + ($route.params.entity === 'ATM' ? ' context-marker-plain' : '') + "> + + @@ -65,11 +111,13 @@ import 'leaflet/dist/leaflet.css' import { + LCircle, LCircleMarker, LControlScale, LIcon, LMap, LMarker, + LPolygon, LPolyline, LTileLayer, LTooltip @@ -100,6 +148,11 @@ const props = withDefaults( const mapStore = useMapStore() const appStore = useAppStore() +// Protected-zone ring drawn around each ATM aircraft context marker. +// Leaflet's LCircle radius is in meters; 1 NM = 1852 m. +const PROTECTED_ZONE_RADIUS_NM = 5 +const PROTECTED_ZONE_RADIUS_M = PROTECTED_ZONE_RADIUS_NM * 1852 + const lockView = ref(true) const zoom = ref(6) const map = ref() @@ -140,6 +193,10 @@ onUnmounted(() => { } .context-marker { + // Overrides leaflet.css's .leaflet-div-icon defaults (white fill + grey + // border) now that this marker is rendered as an L.divIcon (needed so the + // ATM plane icon inside it can be rotated -- see Map.vue's LIcon usage). + border: none !important; transition: var(--duration); background: var(--color-success); border-radius: var(--radius-circular); @@ -151,6 +208,11 @@ onUnmounted(() => { background: var(--color-error); } } +// ATM-only: no colored circular badge behind the plane icon +.context-marker.context-marker-plain { + background: transparent !important; + padding: 0 !important; +} .cab-map-lockview { width: calc(var(--unit) * 5); display: flex; diff --git a/frontend/src/entities/ATM/CAB/Context.vue b/frontend/src/entities/ATM/CAB/Context.vue index 3f87ea19..3020a45f 100644 --- a/frontend/src/entities/ATM/CAB/Context.vue +++ b/frontend/src/entities/ATM/CAB/Context.vue @@ -9,10 +9,11 @@ import { onBeforeMount, onUnmounted, ref } from 'vue' import { useI18n } from 'vue-i18n' import Context from '@/components/organisms/CAB/Context.vue' import Map from '@/components/organisms/Map.vue' -import type { AirplaneContext, LegacyContext, ContextType } from '@/entities/ATM/types' +import type { AirplaneContext, LegacyContext, ContextType, ShapeContext } from '@/entities/ATM/types' import { useAppStore } from '@/stores/app' import { useMapStore } from '@/stores/components/map' import { useServicesStore } from '@/stores/services' +import type { Polygon } from '@/types/components/map' const { t, locale } = useI18n() const servicesStore = useServicesStore() @@ -21,6 +22,47 @@ const appStore = useAppStore() const faulty = ref(false) +// Styling per shape kind, see build_shapes_payload() in +// ai4realnet_rl_batch_bridge.py for where `kind` comes from. +const SHAPE_STYLE: Record, NonNullable> = { + SECTOR: { color: 'var(--color-secondary)', weight: 1, dashArray: '4 4', fill: false }, + WEATHER: { + // MEDIUM criticality / warning + color: 'var(--color-warning)', + weight: 2, + fill: true, + fillColor: 'var(--color-warning)', + fillOpacity: 0.2 + }, + VOLCANIC: { + // MEDIUM criticality / warning + color: 'var(--color-warning)', + weight: 2, + fill: true, + fillColor: 'var(--color-warning)', + fillOpacity: 0.25 + }, + OBSTACLE: { + color: 'var(--color-error)', + weight: 2, + fill: true, + fillColor: 'var(--color-error)', + fillOpacity: 0.1 + } +} + +function addShapes(shapes: ShapeContext[]) { + mapStore.removeCategoryPolygon('SHAPE') + for (const shape of shapes) { + mapStore.addPolygon({ + id: `shape-${shape.name}`, + points: shape.coordinates, + category: 'SHAPE', + options: SHAPE_STYLE[shape.kind ?? 'OBSTACLE'] ?? SHAPE_STYLE.OBSTACLE + }) + } +} + onBeforeMount(async () => { locale.value = `en-ATM` await servicesStore.getContext('ATM', (context: { data: ContextType }) => { @@ -29,11 +71,15 @@ onBeforeMount(async () => { mapStore.removeCategoryWaypoint('ROUTE') // 2- add new markers and ROUTE waypoints if ('airplanes' in context.data) { + addShapes(context.data.shapes ?? []) context.data.airplanes.forEach((airplane: AirplaneContext) => { mapStore.addContextWaypoint({ lat: airplane.Latitude, lng: airplane.Longitude, - id: `plane-${airplane.id_plane}` + // Bare acid (from bluesky) as the label (e.g. "AC1") + id: `${airplane.id_plane}`, + heading: airplane.heading, + inLos: airplane.in_los }) // build the route waypoints const waypoints = [ @@ -67,11 +113,13 @@ onBeforeMount(async () => { }) } else { // Legacy context data handling + addShapes([]) const legacy = context.data as LegacyContext mapStore.addContextWaypoint({ lat: legacy.Latitude, lng: legacy.Longitude, - id: t('map.context') + id: t('map.context'), + heading: legacy.heading }) const waypoints = [ ...(legacy.wpList diff --git a/frontend/src/entities/ATM/CAB/Timeline.vue b/frontend/src/entities/ATM/CAB/Timeline.vue index 3da1cc63..27570db8 100644 --- a/frontend/src/entities/ATM/CAB/Timeline.vue +++ b/frontend/src/entities/ATM/CAB/Timeline.vue @@ -1,7 +1,7 @@ diff --git a/frontend/src/entities/ATM/types.ts b/frontend/src/entities/ATM/types.ts index f5bdba48..8149a02b 100644 --- a/frontend/src/entities/ATM/types.ts +++ b/frontend/src/entities/ATM/types.ts @@ -3,6 +3,10 @@ export type AirplaneContext = { Current_airspeed: number; Latitude: number; Longitude: number; + // True heading, degrees clockwise from north (0-360). Optional. + heading?: number; + // True if another aircraft is currently inside this one's protected zone. + in_los?: boolean; ApDest?: { apcity: string; apid: Uppercase; @@ -22,6 +26,7 @@ export type LegacyContext = { Current_airspeed: number; Latitude: number; Longitude: number; + heading?: number; ApDest?: { apcity: string; apid: Uppercase; @@ -37,7 +42,15 @@ export type LegacyContext = { }[]; }; -export type ContextType = { airplanes: AirplaneContext[] } | LegacyContext; +export type ShapeContext = { + name: string; + kind?: 'SECTOR' | 'WEATHER' | 'VOLCANIC' | 'OBSTACLE' | string; + coordinates: [number, number][]; +}; + +export type ContextType = + | { airplanes: AirplaneContext[]; shapes?: ShapeContext[] } + | LegacyContext; export type ATM = { Context: ContextType; diff --git a/frontend/src/stores/components/map.ts b/frontend/src/stores/components/map.ts index 767de2dc..1e13abf7 100644 --- a/frontend/src/stores/components/map.ts +++ b/frontend/src/stores/components/map.ts @@ -1,18 +1,20 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import type { Polyline, Waypoint } from '@/types/components/map' +import type { Polygon, Polyline, Waypoint } from '@/types/components/map' import { addOrUpdate, remove } from '@/utils/utils' export const useMapStore = defineStore('map', () => { const waypoints = ref([]) const polylines = ref([]) const contextWaypoints = ref([]) + const polygons = ref([]) function reset() { resetWaypoints() resetPolylines() resetContextWaypoints() + resetPolygons() } function addWaypoint(waypoint: Waypoint) { @@ -54,10 +56,28 @@ export const useMapStore = defineStore('map', () => { contextWaypoints.value.splice(0, contextWaypoints.value.length) } + function addPolygon(polygon: Polygon) { + addOrUpdate(polygons.value, polygon, (el) => el.id === polygon.id) + } + + function removePolygon(polygon: Polygon) { + remove(polygons.value, (el) => el.id === polygon.id) + } + + function removeCategoryPolygon(category: string) { + for (const polygon of polygons.value.filter((p) => p.category === category)) + remove(polygons.value, (el) => el.id === polygon.id) + } + + function resetPolygons() { + polygons.value.splice(0, polygons.value.length) + } + return { waypoints, polylines, contextWaypoints, + polygons, reset, addWaypoint, removeWaypoint, @@ -68,6 +88,10 @@ export const useMapStore = defineStore('map', () => { resetPolylines, addContextWaypoint, removeContextWaypoint, - resetContextWaypoints + resetContextWaypoints, + addPolygon, + removePolygon, + removeCategoryPolygon, + resetPolygons } }) diff --git a/frontend/src/types/components/map.ts b/frontend/src/types/components/map.ts index 67d143b1..585bd17c 100644 --- a/frontend/src/types/components/map.ts +++ b/frontend/src/types/components/map.ts @@ -6,6 +6,13 @@ export type Waypoint = { id: string category?: Uppercase permanentTooltip?: boolean + // True heading, degrees clockwise from north (0-360), used to rotate the + // marker icon. Only meaningful for context waypoints that represent a + // heading object (e.g. ATM aircraft); leave unset for plain waypoints. + heading?: number + // True if another aircraft is currently inside this one's protected zone + // (ATM only) + inLos?: boolean options?: Partial<{ stroke: boolean radius: number @@ -24,3 +31,19 @@ export type Polyline = { color?: string } } + +export type Polygon = { + id: string + points: [number, number][] + category?: Uppercase + options?: Partial<{ + stroke: boolean + color: string + weight: number + opacity: number + fill: boolean + fillColor: string + fillOpacity: number + dashArray: string + }> +}