From 406a64ae3f41abf2abd994c4b5556bc8e63f392b Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Tue, 1 Mar 2022 00:08:34 -0800 Subject: [PATCH] Draw flight plan paths in the react UI. https://github.com/dcs-liberation/dcs_liberation/issues/2039 --- client/src/App.tsx | 22 ++++++-- client/src/app/store.ts | 5 +- client/src/game/ato/atoSlice.ts | 53 +++++++++++++++++++ client/src/game/flight.ts | 10 ++++ client/src/game/waypoint.ts | 11 ++++ client/src/map/flightplan/FlightPlan.tsx | 33 ++++++++++++ .../map/flightplanslayer/FlightPlansLayer.tsx | 20 +++++++ .../src/map/liberationmap/LiberationMap.tsx | 3 ++ game/server/eventstream/models.py | 4 +- game/server/flights/models.py | 14 ++++- game/server/flights/routes.py | 12 +++-- game/server/waypoints/routes.py | 14 +++-- 12 files changed, 183 insertions(+), 18 deletions(-) create mode 100644 client/src/game/ato/atoSlice.ts create mode 100644 client/src/game/flight.ts create mode 100644 client/src/game/waypoint.ts create mode 100644 client/src/map/flightplan/FlightPlan.tsx create mode 100644 client/src/map/flightplanslayer/FlightPlansLayer.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index e05db873..e4862003 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,12 +1,14 @@ -import { LatLng } from "leaflet"; import "./App.css"; -import { LiberationMap } from "./map/liberationmap/LiberationMap"; import { ControlPoint } from "./game/controlpoint"; -import { useEffect } from "react"; -import { useAppDispatch } from "./app/hooks"; -import { setControlPoints } from "./game/theater/theaterSlice"; +import { Flight } from "./game/flight"; +import { LatLng } from "leaflet"; +import { LiberationMap } from "./map/liberationmap/LiberationMap"; import axios from "axios"; +import { registerFlight } from "./game/ato/atoSlice"; +import { setControlPoints } from "./game/theater/theaterSlice"; +import { useAppDispatch } from "./app/hooks"; +import { useEffect } from "react"; function App() { const mapCenter: LatLng = new LatLng(25.58, 54.9); @@ -22,6 +24,16 @@ function App() { dispatch(setControlPoints(response.data as ControlPoint[])); } }); + axios + .get("http://[::1]:5000/flights?with_waypoints=true") + .catch((error) => console.log(`Error fetching flights: ${error}`)) + .then((response) => { + if (response != null) { + for (const flight of response.data) { + dispatch(registerFlight(flight as Flight)); + } + } + }); }); console.log(`mapCenter=${mapCenter}`); diff --git a/client/src/app/store.ts b/client/src/app/store.ts index c58b679e..b399b4de 100644 --- a/client/src/app/store.ts +++ b/client/src/app/store.ts @@ -1,8 +1,11 @@ -import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"; +import { Action, ThunkAction, configureStore } from "@reduxjs/toolkit"; + +import atoReducer from "../game/ato/atoSlice"; import theaterReducer from "../game/theater/theaterSlice"; export const store = configureStore({ reducer: { + atos: atoReducer, theater: theaterReducer, }, }); diff --git a/client/src/game/ato/atoSlice.ts b/client/src/game/ato/atoSlice.ts new file mode 100644 index 00000000..4563f986 --- /dev/null +++ b/client/src/game/ato/atoSlice.ts @@ -0,0 +1,53 @@ +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; + +import { Flight } from "../flight"; +import { RootState } from "../../app/store"; + +interface AtoState { + blue: { [id: string]: Flight }; + red: { [id: string]: Flight }; +} + +const initialState: AtoState = { + blue: {}, + red: {}, +}; + +export const atoSlice = createSlice({ + name: "ato", + initialState, + reducers: { + clearFlights: (state) => { + state.blue = {}; + state.red = {}; + }, + registerFlight: (state, action: PayloadAction) => { + const flight = action.payload; + const ato = flight.blue ? state.blue : state.red; + if (flight.id in ato) { + console.log(`Overriding flight with ID: ${flight.id}`); + } + ato[flight.id] = flight; + }, + unregisterFlight: (state, action: PayloadAction) => { + const id = action.payload; + if (id in state.blue) { + delete state.blue[id]; + } else if (id in state.red) { + delete state.red[id]; + } else { + console.log( + `Could not delete flight with ID ${id} because no flight with that ` + + `ID exists` + ); + } + }, + }, +}); + +export const { clearFlights, registerFlight, unregisterFlight } = + atoSlice.actions; + +export const selectAtos = (state: RootState) => state.atos; + +export default atoSlice.reducer; diff --git a/client/src/game/flight.ts b/client/src/game/flight.ts new file mode 100644 index 00000000..5ad0fa08 --- /dev/null +++ b/client/src/game/flight.ts @@ -0,0 +1,10 @@ +import { LatLng } from "leaflet"; +import { Waypoint } from "./waypoint"; + +export interface Flight { + id: string; + blue: boolean; + position: LatLng; + sidc: string; + waypoints: Waypoint[] | null; +} diff --git a/client/src/game/waypoint.ts b/client/src/game/waypoint.ts new file mode 100644 index 00000000..9908aef9 --- /dev/null +++ b/client/src/game/waypoint.ts @@ -0,0 +1,11 @@ +import { LatLng } from "leaflet"; + +export interface Waypoint { + name: string; + position: LatLng; + altitude_ft: number; + altitude_reference: string; + is_movable: boolean; + should_mark: boolean; + include_in_path: boolean; +} diff --git a/client/src/map/flightplan/FlightPlan.tsx b/client/src/map/flightplan/FlightPlan.tsx new file mode 100644 index 00000000..0c2ecd5f --- /dev/null +++ b/client/src/map/flightplan/FlightPlan.tsx @@ -0,0 +1,33 @@ +import { Flight } from "../../game/flight"; +import { Polyline } from "react-leaflet"; + +const BLUE_PATH = "#0084ff"; +const RED_PATH = "#c85050"; + +interface FlightPlanProps { + flight: Flight; + selected: boolean; +} + +export function FlightPlanPath(props: FlightPlanProps) { + const color = props.flight.blue ? BLUE_PATH : RED_PATH; + const waypoints = props.flight.waypoints; + if (waypoints == null) { + return <>; + } + const points = waypoints.map((waypoint) => waypoint.position); + return ( + + ); +} + +export function FlightPlan(props: FlightPlanProps) { + return ( + <> + + + ); +} diff --git a/client/src/map/flightplanslayer/FlightPlansLayer.tsx b/client/src/map/flightplanslayer/FlightPlansLayer.tsx new file mode 100644 index 00000000..136d8272 --- /dev/null +++ b/client/src/map/flightplanslayer/FlightPlansLayer.tsx @@ -0,0 +1,20 @@ +import { FlightPlan } from "../flightplan/FlightPlan"; +import { LayerGroup } from "react-leaflet"; +import { selectAtos } from "../../game/ato/atoSlice"; +import { useAppSelector } from "../../app/hooks"; + +interface FlightPlansLayerProps { + blue: boolean; +} + +export function FlightPlansLayer(props: FlightPlansLayerProps) { + const atos = useAppSelector(selectAtos); + const flights = props.blue ? atos.blue : atos.red; + return ( + + {Object.values(flights).map((flight) => { + return ; + })} + + ); +} diff --git a/client/src/map/liberationmap/LiberationMap.tsx b/client/src/map/liberationmap/LiberationMap.tsx index 051c2391..38eb1c77 100644 --- a/client/src/map/liberationmap/LiberationMap.tsx +++ b/client/src/map/liberationmap/LiberationMap.tsx @@ -3,6 +3,7 @@ import { BasemapLayer } from "react-esri-leaflet"; import { ControlPointsLayer } from "../controlpointslayer/ControlPointsLayer"; import "./LiberationMap.css"; import { LatLng } from "leaflet"; +import { FlightPlansLayer } from "../flightplanslayer/FlightPlansLayer"; interface GameProps { mapCenter: LatLng; @@ -13,6 +14,8 @@ export function LiberationMap(props: GameProps) { + + ); } diff --git a/game/server/eventstream/models.py b/game/server/eventstream/models.py index e25c48aa..a2cabd38 100644 --- a/game/server/eventstream/models.py +++ b/game/server/eventstream/models.py @@ -46,7 +46,9 @@ class GameUpdateEventsJs(BaseModel): navmesh_updates=events.navmesh_updates, unculled_zones_updated=events.unculled_zones_updated, threat_zones_updated=events.threat_zones_updated, - new_flights=[FlightJs.for_flight(f) for f in events.new_flights], + new_flights=[ + FlightJs.for_flight(f, with_waypoints=True) for f in events.new_flights + ], updated_flights=events.updated_flights, deleted_flights=events.deleted_flights, selected_flight=events.selected_flight, diff --git a/game/server/flights/models.py b/game/server/flights/models.py index 0d05a68a..68d004b2 100644 --- a/game/server/flights/models.py +++ b/game/server/flights/models.py @@ -7,6 +7,8 @@ from pydantic import BaseModel from game.ato import Flight from game.ato.flightstate import InFlight from game.server.leaflet import LeafletPoint +from game.server.waypoints.models import FlightWaypointJs +from game.server.waypoints.routes import waypoints_for_flight class FlightJs(BaseModel): @@ -14,9 +16,10 @@ class FlightJs(BaseModel): blue: bool position: LeafletPoint | None sidc: str + waypoints: list[FlightWaypointJs] | None @staticmethod - def for_flight(flight: Flight) -> FlightJs: + def for_flight(flight: Flight, with_waypoints: bool) -> FlightJs: # Don't provide a location for aircraft that aren't in the air. Later we can # expand the model to include the state data for the UI so that it can make its # own decisions about whether or not to draw the aircraft, but for now we'll @@ -24,6 +27,13 @@ class FlightJs(BaseModel): position = None if isinstance(flight.state, InFlight): position = flight.position().latlng() + waypoints = None + if with_waypoints: + waypoints = waypoints_for_flight(flight) return FlightJs( - id=flight.id, blue=flight.blue, position=position, sidc=str(flight.sidc()) + id=flight.id, + blue=flight.blue, + position=position, + sidc=str(flight.sidc()), + waypoints=waypoints, ) diff --git a/game/server/flights/routes.py b/game/server/flights/routes.py index e98fcb29..3d9ce93d 100644 --- a/game/server/flights/routes.py +++ b/game/server/flights/routes.py @@ -13,19 +13,23 @@ router: APIRouter = APIRouter(prefix="/flights") @router.get("/") -def list_flights(game: Game = Depends(GameContext.get)) -> list[FlightJs]: +def list_flights( + with_waypoints: bool = False, game: Game = Depends(GameContext.get) +) -> list[FlightJs]: flights = [] for coalition in game.coalitions: for package in coalition.ato.packages: for flight in package.flights: - flights.append(FlightJs.for_flight(flight)) + flights.append(FlightJs.for_flight(flight, with_waypoints)) return flights @router.get("/{flight_id}") -def get_flight(flight_id: UUID, game: Game = Depends(GameContext.get)) -> FlightJs: +def get_flight( + flight_id: UUID, with_waypoints: bool = False, game: Game = Depends(GameContext.get) +) -> FlightJs: flight = game.db.flights.get(flight_id) - return FlightJs.for_flight(flight) + return FlightJs.for_flight(flight, with_waypoints) @router.get("/{flight_id}/commit-boundary") diff --git a/game/server/waypoints/routes.py b/game/server/waypoints/routes.py index 91a758e4..ab1ea1af 100644 --- a/game/server/waypoints/routes.py +++ b/game/server/waypoints/routes.py @@ -5,6 +5,7 @@ from dcs.mapping import LatLng, Point from fastapi import APIRouter, Depends, HTTPException, status from game import Game +from game.ato import Flight from game.ato.flightwaypoint import FlightWaypoint from game.ato.flightwaypointtype import FlightWaypointType from game.server import GameContext @@ -15,11 +16,7 @@ from game.utils import meters router: APIRouter = APIRouter(prefix="/waypoints") -@router.get("/{flight_id}", response_model=list[FlightWaypointJs]) -def all_waypoints_for_flight( - flight_id: UUID, game: Game = Depends(GameContext.get) -) -> list[FlightWaypointJs]: - flight = game.db.flights.get(flight_id) +def waypoints_for_flight(flight: Flight) -> list[FlightWaypointJs]: departure = FlightWaypointJs.for_waypoint( FlightWaypoint( "TAKEOFF", @@ -34,6 +31,13 @@ def all_waypoints_for_flight( ] +@router.get("/{flight_id}", response_model=list[FlightWaypointJs]) +def all_waypoints_for_flight( + flight_id: UUID, game: Game = Depends(GameContext.get) +) -> list[FlightWaypointJs]: + return waypoints_for_flight(game.db.flights.get(flight_id)) + + @router.post("/{flight_id}/{waypoint_idx}/position") def set_position( flight_id: UUID,