From 9a2c10a98f78d9061fb26db8d1b7e142c7400b54 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Wed, 2 Mar 2022 23:10:11 -0800 Subject: [PATCH] Draw supply routes on the react map. https://github.com/dcs-liberation/dcs_liberation/issues/2039 --- client/src/api/supplyRoutesSlice.ts | 28 +++++++ client/src/api/supplyroute.ts | 11 +++ client/src/app/store.ts | 2 + .../liberationmap/LiberationMap.tsx | 4 + .../src/components/splitlines/SplitLines.tsx | 20 +++++ .../components/supplyroute/SupplyRoute.tsx | 68 +++++++++++++++ client/src/components/supplyroute/index.ts | 1 + .../supplyrouteslayer/SupplyRoutesLayer.tsx | 15 ++++ .../src/components/supplyrouteslayer/index.ts | 1 + client/src/components/tgoslayer/TgosLayer.tsx | 1 - client/src/hooks/useInitialGameState.tsx | 10 +++ game/server/app.py | 2 + game/server/supplyroutes/__init__.py | 1 + game/server/supplyroutes/models.py | 83 +++++++++++++++++++ game/server/supplyroutes/routes.py | 34 ++++++++ 15 files changed, 280 insertions(+), 1 deletion(-) create mode 100644 client/src/api/supplyRoutesSlice.ts create mode 100644 client/src/api/supplyroute.ts create mode 100644 client/src/components/splitlines/SplitLines.tsx create mode 100644 client/src/components/supplyroute/SupplyRoute.tsx create mode 100644 client/src/components/supplyroute/index.ts create mode 100644 client/src/components/supplyrouteslayer/SupplyRoutesLayer.tsx create mode 100644 client/src/components/supplyrouteslayer/index.ts create mode 100644 game/server/supplyroutes/__init__.py create mode 100644 game/server/supplyroutes/models.py create mode 100644 game/server/supplyroutes/routes.py diff --git a/client/src/api/supplyRoutesSlice.ts b/client/src/api/supplyRoutesSlice.ts new file mode 100644 index 00000000..a829e17d --- /dev/null +++ b/client/src/api/supplyRoutesSlice.ts @@ -0,0 +1,28 @@ +import { PayloadAction, createSlice } from "@reduxjs/toolkit"; + +import { RootState } from "../app/store"; +import SupplyRoute from "./supplyroute"; + +interface SupplyRoutesState { + routes: SupplyRoute[]; +} + +const initialState: SupplyRoutesState = { + routes: [], +}; + +export const supplyRoutesSlice = createSlice({ + name: "supplyRoutes", + initialState, + reducers: { + setSupplyRoutes: (state, action: PayloadAction) => { + state.routes = action.payload; + }, + }, +}); + +export const { setSupplyRoutes } = supplyRoutesSlice.actions; + +export const selectSupplyRoutes = (state: RootState) => state.supplyRoutes; + +export default supplyRoutesSlice.reducer; diff --git a/client/src/api/supplyroute.ts b/client/src/api/supplyroute.ts new file mode 100644 index 00000000..4ef2a349 --- /dev/null +++ b/client/src/api/supplyroute.ts @@ -0,0 +1,11 @@ +import { LatLng } from "leaflet"; + +export interface SupplyRoute { + points: LatLng[]; + front_active: boolean; + is_sea: boolean; + blue: boolean; + active_transports: string[]; +} + +export default SupplyRoute; diff --git a/client/src/app/store.ts b/client/src/app/store.ts index 99c01f00..373d7020 100644 --- a/client/src/app/store.ts +++ b/client/src/app/store.ts @@ -2,12 +2,14 @@ import { Action, ThunkAction, configureStore } from "@reduxjs/toolkit"; import controlPointsReducer from "../api/controlPointsSlice"; import flightsReducer from "../api/flightsSlice"; +import supplyRoutesReducer from "../api/supplyRoutesSlice"; import tgosReducer from "../api/tgosSlice"; export const store = configureStore({ reducer: { flights: flightsReducer, controlPoints: controlPointsReducer, + supplyRoutes: supplyRoutesReducer, tgos: tgosReducer, }, }); diff --git a/client/src/components/liberationmap/LiberationMap.tsx b/client/src/components/liberationmap/LiberationMap.tsx index b95ceb20..f886cbee 100644 --- a/client/src/components/liberationmap/LiberationMap.tsx +++ b/client/src/components/liberationmap/LiberationMap.tsx @@ -7,6 +7,7 @@ import { BasemapLayer } from "react-esri-leaflet"; import ControlPointsLayer from "../controlpointslayer"; import FlightPlansLayer from "../flightplanslayer"; import { LatLng } from "leaflet"; +import SupplyRoutesLayer from "../supplyrouteslayer"; import { TgoType } from "../../api/tgo"; import TgosLayer from "../tgoslayer/TgosLayer"; @@ -38,6 +39,9 @@ export default function LiberationMap(props: GameProps) { ); })} + + + diff --git a/client/src/components/splitlines/SplitLines.tsx b/client/src/components/splitlines/SplitLines.tsx new file mode 100644 index 00000000..9fe4dbac --- /dev/null +++ b/client/src/components/splitlines/SplitLines.tsx @@ -0,0 +1,20 @@ +interface SplitLinesProps { + items: string[]; +} + +const SplitLines = (props: SplitLinesProps) => { + return ( + <> + {props.items.map((text) => { + return ( + <> + {text} +
+ + ); + })} + + ); +}; + +export default SplitLines; diff --git a/client/src/components/supplyroute/SupplyRoute.tsx b/client/src/components/supplyroute/SupplyRoute.tsx new file mode 100644 index 00000000..ab341a8e --- /dev/null +++ b/client/src/components/supplyroute/SupplyRoute.tsx @@ -0,0 +1,68 @@ +import { Polyline, Tooltip } from "react-leaflet"; +import { useEffect, useRef } from "react"; + +import { Polyline as LPolyline } from "leaflet"; +import SplitLines from "../splitlines/SplitLines"; +import { SupplyRoute as SupplyRouteModel } from "../../api/supplyroute"; + +interface SupplyRouteProps { + route: SupplyRouteModel; +} + +function SupplyRouteTooltip(props: SupplyRouteProps) { + if (!props.route.active_transports.length) { + return This supply route is inactive.; + } + + return ( + + + + ); +} + +function ActiveSupplyRouteHighlight(props: SupplyRouteProps) { + if (!props.route.active_transports.length) { + return <>; + } + + return ( + + ); +} + +function colorFor(route: SupplyRouteModel) { + if (route.front_active) { + return "#c85050"; + } + if (route.blue) { + return "#2d3e50"; + } + return "#8c1414"; +} + +export default function SupplyRoute(props: SupplyRouteProps) { + const color = colorFor(props.route); + const weight = props.route.is_sea ? 4 : 6; + + const path = useRef(); + + useEffect(() => { + // Ensure that the highlight line draws on top of this. We have to bring + // this to the back rather than bringing the highlight to the front because + // the highlight won't necessarily be drawn yet. + path.current?.bringToBack(); + }); + + return ( + (path.current = ref)} + > + + + + ); +} diff --git a/client/src/components/supplyroute/index.ts b/client/src/components/supplyroute/index.ts new file mode 100644 index 00000000..4c81c3fa --- /dev/null +++ b/client/src/components/supplyroute/index.ts @@ -0,0 +1 @@ +export { default } from "./SupplyRoute"; diff --git a/client/src/components/supplyrouteslayer/SupplyRoutesLayer.tsx b/client/src/components/supplyrouteslayer/SupplyRoutesLayer.tsx new file mode 100644 index 00000000..99f6e979 --- /dev/null +++ b/client/src/components/supplyrouteslayer/SupplyRoutesLayer.tsx @@ -0,0 +1,15 @@ +import { LayerGroup } from "react-leaflet"; +import SupplyRoute from "../supplyroute/SupplyRoute"; +import { selectSupplyRoutes } from "../../api/supplyRoutesSlice"; +import { useAppSelector } from "../../app/hooks"; + +export default function SupplyRoutesLayer() { + const routes = useAppSelector(selectSupplyRoutes).routes; + return ( + + {routes.map((route, idx) => { + return ; + })} + + ); +} diff --git a/client/src/components/supplyrouteslayer/index.ts b/client/src/components/supplyrouteslayer/index.ts new file mode 100644 index 00000000..4fe62c3b --- /dev/null +++ b/client/src/components/supplyrouteslayer/index.ts @@ -0,0 +1 @@ +export { default } from "./SupplyRoutesLayer"; diff --git a/client/src/components/tgoslayer/TgosLayer.tsx b/client/src/components/tgoslayer/TgosLayer.tsx index 1f996e2d..be2fb24c 100644 --- a/client/src/components/tgoslayer/TgosLayer.tsx +++ b/client/src/components/tgoslayer/TgosLayer.tsx @@ -11,7 +11,6 @@ interface TgosLayerProps { export default function TgosLayer(props: TgosLayerProps) { const allTgos = useAppSelector(selectTgos); const tgos = allTgos.tgosByType[props.type]; - console.dir(Object.entries(TgoType)); return ( {tgos.map((tgo) => { diff --git a/client/src/hooks/useInitialGameState.tsx b/client/src/hooks/useInitialGameState.tsx index ca1d06ec..d1375c1c 100644 --- a/client/src/hooks/useInitialGameState.tsx +++ b/client/src/hooks/useInitialGameState.tsx @@ -1,9 +1,11 @@ import { ControlPoint } from "../api/controlpoint"; import { Flight } from "../api/flight"; +import SupplyRoute from "../api/supplyroute"; import Tgo from "../api/tgo"; import backend from "../api/backend"; import { registerFlight } from "../api/flightsSlice"; import { setControlPoints } from "../api/controlPointsSlice"; +import { setSupplyRoutes } from "../api/supplyRoutesSlice"; import { setTgos } from "../api/tgosSlice"; import { useAppDispatch } from "../app/hooks"; import { useEffect } from "react"; @@ -31,6 +33,14 @@ export const useInitialGameState = () => { dispatch(setTgos(response.data as Tgo[])); } }); + backend + .get("/supply-routes") + .catch((error) => console.log(`Error fetching supply routes: ${error}`)) + .then((response) => { + if (response != null) { + dispatch(setSupplyRoutes(response.data as SupplyRoute[])); + } + }); backend .get("/flights?with_waypoints=true") .catch((error) => console.log(`Error fetching flights: ${error}`)) diff --git a/game/server/app.py b/game/server/app.py index 55d17be4..2d74530f 100644 --- a/game/server/app.py +++ b/game/server/app.py @@ -8,6 +8,7 @@ from . import ( flights, mapzones, navmesh, + supplyroutes, tgos, waypoints, ) @@ -25,6 +26,7 @@ app.include_router(eventstream.router) app.include_router(flights.router) app.include_router(mapzones.router) app.include_router(navmesh.router) +app.include_router(supplyroutes.router) app.include_router(tgos.router) app.include_router(waypoints.router) diff --git a/game/server/supplyroutes/__init__.py b/game/server/supplyroutes/__init__.py new file mode 100644 index 00000000..3a27ef1c --- /dev/null +++ b/game/server/supplyroutes/__init__.py @@ -0,0 +1 @@ +from .routes import router diff --git a/game/server/supplyroutes/models.py b/game/server/supplyroutes/models.py new file mode 100644 index 00000000..f60163b7 --- /dev/null +++ b/game/server/supplyroutes/models.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from typing import Any, TYPE_CHECKING + +from dcs import Point +from pydantic import BaseModel + +from game.server.leaflet import LeafletPoint + +if TYPE_CHECKING: + from game import Game + from game.theater import ControlPoint + from game.transfers import MultiGroupTransport, TransportMap + + +class TransportFinder: + def __init__( + self, game: Game, control_point_a: ControlPoint, control_point_b: ControlPoint + ) -> None: + self.game = game + self.control_point_a = control_point_a + self.control_point_b = control_point_b + + def find_in_transport_map( + self, transport_map: TransportMap[Any] + ) -> list[MultiGroupTransport]: + transports = [] + transport = transport_map.find_transport( + self.control_point_a, self.control_point_b + ) + if transport is not None: + transports.append(transport) + transport = transport_map.find_transport( + self.control_point_b, self.control_point_a + ) + if transport is not None: + transports.append(transport) + return transports + + def find_transports(self, sea_route: bool) -> list[MultiGroupTransport]: + if sea_route: + return self.find_in_transport_map( + self.game.blue.transfers.cargo_ships + ) + self.find_in_transport_map(self.game.red.transfers.cargo_ships) + return self.find_in_transport_map( + self.game.blue.transfers.convoys + ) + self.find_in_transport_map(self.game.red.transfers.convoys) + + def describe_active_transports(self, sea_route: bool) -> list[str]: + transports = self.find_transports(sea_route) + if not transports: + return [] + + descriptions = [] + for transport in transports: + units = "units" if transport.size > 1 else "unit" + descriptions.append( + f"{transport.size} {units} transferring from {transport.origin} to " + f"{transport.destination}" + ) + return descriptions + + +class SupplyRouteJs(BaseModel): + points: list[LeafletPoint] + front_active: bool + is_sea: bool + blue: bool + active_transports: list[str] + + @staticmethod + def for_link( + game: Game, a: ControlPoint, b: ControlPoint, points: list[Point], sea: bool + ) -> SupplyRouteJs: + return SupplyRouteJs( + points=[p.latlng() for p in points], + front_active=not sea and a.front_is_active(b), + is_sea=sea, + blue=a.captured, + active_transports=TransportFinder(game, a, b).describe_active_transports( + sea + ), + ) diff --git a/game/server/supplyroutes/routes.py b/game/server/supplyroutes/routes.py new file mode 100644 index 00000000..73fd2613 --- /dev/null +++ b/game/server/supplyroutes/routes.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Depends + +from game import Game +from .models import SupplyRouteJs +from ..dependencies import GameContext + +router: APIRouter = APIRouter(prefix="/supply-routes") + + +@router.get("/") +def list_supply_routes(game: Game = Depends(GameContext.get)) -> list[SupplyRouteJs]: + seen = set() + routes = [] + for control_point in game.theater.controlpoints: + seen.add(control_point) + for destination, route in control_point.convoy_routes.items(): + if destination in seen: + continue + routes.append( + SupplyRouteJs.for_link( + game, control_point, destination, list(route), sea=False + ) + ) + for destination, route in control_point.shipping_lanes.items(): + if destination in seen: + continue + if not destination.is_friendly_to(control_point): + continue + routes.append( + SupplyRouteJs.for_link( + game, control_point, destination, list(route), sea=True + ) + ) + return routes