Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions backend/context-service/resources/ATM/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,38 @@ 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
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.
Expand Down
49 changes: 48 additions & 1 deletion frontend/public/img/icons/map_markers/ATM.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
68 changes: 65 additions & 3 deletions frontend/src/components/organisms/Map.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@
:url="tileLayer"
layer-type="base"
name="OpenStreetMap" />
<LPolygon
v-for="polygon of mapStore.polygons"
:key="polygon.id"
:lat-lngs="polygon.points"
:color="`var(--color-${criticalityToColor(maxCriticality('ROUTINE'))})`"
:weight="2"
:fill="true"
:fill-opacity="0.15"
v-bind="polygon.options">
<LTooltip :options="{ direction: 'center', className: 'shape-tooltip' }">
{{ polygon.id.replace(/^shape-/, '') }}
</LTooltip>
</LPolygon>
<LPolyline
v-for="polyline of mapStore.polylines"
:key="polyline.id"
Expand Down Expand Up @@ -34,6 +47,24 @@
{{ waypoint.id }}
</LTooltip>
</LCircleMarker>
<!--
ATM-only: Protected zone of 5 NM: scales with the map and stays centered on the aircraft.
Turns red when waypoint.inLos (another aircraft is inside this zone right now, per
BlueSky's own conflict detection -- see _aircraft_in_los() in the bridge script).
-->
<template v-if="$route.params.entity === 'ATM'">
<LCircle
v-for="waypoint of mapStore.contextWaypoints"
:key="`protected-zone-${waypoint.id}`"
:lat-lng="[waypoint.lat, waypoint.lng]"
:radius="PROTECTED_ZONE_RADIUS_M"
:color="waypoint.inLos ? '#ff0000' : '#009e8f'"
:weight="waypoint.inLos ? 2 : 1"
:dash-array="waypoint.inLos ? undefined : '4 4'"
:fill="!!waypoint.inLos"
fill-color="#ff0000"
:fill-opacity="0.15" />
</template>
<LMarker
v-for="waypoint of mapStore.contextWaypoints"
:key="waypoint.id"
Expand All @@ -44,11 +75,26 @@
:options="{ permanent: waypoint.permanentTooltip, direction: 'top', offset: [0, -12] }">
{{ waypoint.id }}
</LTooltip>
<!--
Switched to L-divIcon to allow rotation of the ATM plane icon, based on the heading. For non atm use-cases it should default to 0 degrees orientation.
-->
<LIcon
:icon-url="`/img/icons/map_markers/${$route.params.entity}.svg`"
:icon-size="[32, 32]"
class="context-marker"
:class-name="'context-marker ' + waypoint.severity" />
:class-name="
'context-marker ' +
waypoint.severity +
($route.params.entity === 'ATM' ? ' context-marker-plain' : '')
">
<img
:src="`/img/icons/map_markers/${$route.params.entity}.svg`"
:style="{
display: 'block',
width: '100%',
height: '100%',
transformOrigin: 'center center',
transform: `rotate(${waypoint.heading ?? 0}deg)`
}" />
</LIcon>
</LMarker>
<LControlScale />
</LMap>
Expand All @@ -65,11 +111,13 @@
import 'leaflet/dist/leaflet.css'

import {
LCircle,
LCircleMarker,
LControlScale,
LIcon,
LMap,
LMarker,
LPolygon,
LPolyline,
LTileLayer,
LTooltip
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down
54 changes: 51 additions & 3 deletions frontend/src/entities/ATM/CAB/Context.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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<ShapeContext['kind']>, NonNullable<Polygon['options']>> = {
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 }) => {
Expand All @@ -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 = [
Expand Down Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions frontend/src/entities/ATM/CAB/Timeline.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<section class="cab-panel">
<h1>{{ $t('cab.timeline') }}</h1>
<Timeline v-slot="{ card }" :start="-120" :end="120" entity="ATM">
<h1>{{ $t('cab.timeline') }} ({{ tzLabel }})</h1>
<Timeline v-slot="{ card }" :start="-60" :end="60" entity="ATM" :now="simNow">
<SVG
src="/img/icons/warning_hex.svg"
:fill="`var(--color-${criticalityToColor(card.data.criticality)})`"
Expand All @@ -10,7 +10,27 @@
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'

import SVG from '@/components/atoms/SVG.vue'
import Timeline from '@/components/organisms/Timeline.vue'
import { useServicesStore } from '@/stores/services'
import { criticalityToColor } from '@/utils/utils'

const servicesStore = useServicesStore()

// Browser's own timezone abbreviation (e.g. "CET"/"CEST"), shown next to
// the heading. The simulated clock itself is UTC-anchored but it is always rendered in local time, so this
// label just makes explicit what timezone that is.
const tzLabel = computed(() => {
const parts = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).formatToParts(new Date())
return parts.find((p) => p.type === 'timeZoneName')?.value ?? ''
})

// Use BlueSky's simulated clock (bs.sim.utc, forwarded as this context's
// 'date') for the 'now' cursor.
const simNow = computed(() => {
const date = servicesStore.context('ATM')?.date
return date === undefined ? undefined : new Date(date)
})
</script>
15 changes: 14 additions & 1 deletion frontend/src/entities/ATM/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
Expand All @@ -22,6 +26,7 @@ export type LegacyContext = {
Current_airspeed: number;
Latitude: number;
Longitude: number;
heading?: number;
ApDest?: {
apcity: string;
apid: Uppercase<string>;
Expand All @@ -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;
Expand Down
Loading