diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 1785a61d..b4e0b717 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -6,11 +6,35 @@ import { LatLng } from "leaflet"; export const apiSlice = createApi({ reducerPath: "api", baseQuery: fetchBaseQuery({ baseUrl: HTTP_URL }), + tagTypes: ["ControlPoint"], endpoints: (builder) => ({ getCommitBoundaryForFlight: builder.query({ query: (flightId) => `flights/${flightId}/commit-boundary`, + providesTags: ["ControlPoint"], + }), + setControlPointDestination: builder.mutation< + void, + { id: number; destination: LatLng } + >({ + query: ({ id, destination }) => ({ + url: `control-points/${id}/destination`, + method: "PUT", + body: { lat: destination.lat, lng: destination.lng }, + invalidatesTags: ["ControlPoint"], + }), + }), + controlPointCancelTravel: builder.mutation({ + query: (id) => ({ + url: `control-points/${id}/cancel-travel`, + method: "PUT", + invalidatesTags: ["ControlPoint"], + }), }), }), }); -export const { useGetCommitBoundaryForFlightQuery } = apiSlice; +export const { + useGetCommitBoundaryForFlightQuery, + useSetControlPointDestinationMutation, + useControlPointCancelTravelMutation, +} = apiSlice; diff --git a/client/src/components/controlpoints/ControlPoint.tsx b/client/src/components/controlpoints/ControlPoint.tsx index 42cdd767..f84a7000 100644 --- a/client/src/components/controlpoints/ControlPoint.tsx +++ b/client/src/components/controlpoints/ControlPoint.tsx @@ -1,8 +1,26 @@ +import { + useControlPointCancelTravelMutation, + useSetControlPointDestinationMutation, +} from "../../api/api"; import backend from "../../api/backend"; import { ControlPoint as ControlPointModel } from "../../api/controlpoint"; -import { Icon, Point } from "leaflet"; +import { + Icon, + LatLng, + Point, + Marker as LMarker, + Polyline as LPolyline, +} from "leaflet"; import { Symbol as MilSymbol } from "milsymbol"; -import { Marker, Tooltip } from "react-leaflet"; +import { + MutableRefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import ReactDOMServer from "react-dom/server"; +import { Marker, Polyline, Tooltip } from "react-leaflet"; function iconForControlPoint(cp: ControlPointModel) { const symbol = new MilSymbol(cp.sidc, { @@ -16,11 +34,205 @@ function iconForControlPoint(cp: ControlPointModel) { }); } +function openInfoDialog(controlPoint: ControlPointModel) { + backend.post(`/qt/info/control-point/${controlPoint.id}`); +} + +function openNewPackageDialog(controlPoint: ControlPointModel) { + backend.post(`/qt/create-package/control-point/${controlPoint.id}`); +} + interface ControlPointProps { controlPoint: ControlPointModel; } -export default function ControlPoint(props: ControlPointProps) { +function LocationTooltipText(props: ControlPointProps) { + return

{props.controlPoint.name}

; +} + +function metersToNauticalMiles(meters: number) { + return meters * 0.000539957; +} + +function formatLatLng(latLng: LatLng) { + const lat = latLng.lat.toFixed(2); + const lng = latLng.lng.toFixed(2); + const ns = latLng.lat >= 0 ? "N" : "S"; + const ew = latLng.lng >= 0 ? "E" : "W"; + return `${lat}°${ns} ${lng}°${ew}`; +} + +function destinationTooltipText( + cp: ControlPointModel, + destinationish: LatLng, + inRange: boolean +) { + const destination = new LatLng(destinationish.lat, destinationish.lng); + const distance = metersToNauticalMiles( + destination.distanceTo(cp.position) + ).toFixed(1); + if (!inRange) { + return `Out of range (${distance}nm away)`; + } + const dest = formatLatLng(destination); + return `${cp.name} moving ${distance}nm to ${dest} next turn`; +} + +function PrimaryMarker(props: ControlPointProps) { + // We can't use normal state to update the marker tooltip or the line points + // because if we set any state in the drag event it will re-render the + // component and interrupt dragging. Instead, keep refs to the objects and + // mutate them directly. + // + // For the same reason, the path is owned by this component, because updating + // sibling state would be messy. Lifting the state into the parent would still + // cause this component to redraw. + const marker: MutableRefObject = useRef(); + const pathLine: MutableRefObject = useRef(); + + const [hasDestination, setHasDestination] = useState( + props.controlPoint.destination != null + ); + const [pathDestination, setPathDestination] = useState( + props.controlPoint.destination + ? props.controlPoint.destination + : props.controlPoint.position + ); + const [position, setPosition] = useState( + props.controlPoint.destination + ? props.controlPoint.destination + : props.controlPoint.position + ); + + const setDestination = useCallback((destination: LatLng) => { + setPathDestination(destination); + setPosition(destination); + setHasDestination(true); + }, []); + + const resetDestination = useCallback(() => { + setPathDestination(props.controlPoint.position); + setPosition(props.controlPoint.position); + setHasDestination(false); + }, [props.controlPoint.position]); + + const [putDestination, { isLoading }] = + useSetControlPointDestinationMutation(); + const [cancelTravel] = useControlPointCancelTravelMutation(); + + useEffect(() => { + marker.current?.setTooltipContent( + props.controlPoint.destination + ? destinationTooltipText( + props.controlPoint, + props.controlPoint.destination, + true + ) + : ReactDOMServer.renderToString( + + ) + ); + }); + + return ( + <> + { + if (ref != null) { + marker.current = ref; + } + }} + eventHandlers={{ + click: () => { + if (!hasDestination) { + openInfoDialog(props.controlPoint); + } + }, + contextmenu: () => { + if (props.controlPoint.destination) { + cancelTravel(props.controlPoint.id).then(() => { + resetDestination(); + }); + } else { + openNewPackageDialog(props.controlPoint); + } + }, + drag: (event) => { + const destination = event.target.getLatLng(); + backend + .get( + `/control-points/${props.controlPoint.id}/destination-in-range?lat=${destination.lat}&lng=${destination.lng}` + ) + .then((inRange) => { + marker.current?.setTooltipContent( + destinationTooltipText( + props.controlPoint, + destination, + inRange.data + ) + ); + }); + pathLine.current?.setLatLngs([ + props.controlPoint.position, + destination, + ]); + }, + dragend: async (event) => { + const currentPosition = new LatLng( + pathDestination.lat, + pathDestination.lng, + pathDestination.alt + ); + const destination = event.target.getLatLng(); + setDestination(destination); + try { + await putDestination({ + id: props.controlPoint.id, + destination: destination, + }).unwrap(); + } catch (error) { + console.error("setDestination failed", error); + setDestination(currentPosition); + } + }, + }} + > + + + { + if (ref != null) { + pathLine.current = ref; + } + }} + /> + + ); +} + +interface SecondaryMarkerProps { + controlPoint: ControlPointModel; + destination: LatLng | null; +} + +function SecondaryMarker(props: SecondaryMarkerProps) { + if (!props.destination) { + return <>; + } + return ( { - backend.post(`/qt/info/control-point/${props.controlPoint.id}`); + openInfoDialog(props.controlPoint); }, contextmenu: () => { - backend.post( - `/qt/create-package/control-point/${props.controlPoint.id}` - ); + openNewPackageDialog(props.controlPoint); }, }} > -

{props.controlPoint.name}

+
); } + +export default function ControlPoint(props: ControlPointProps) { + return ( + <> + + + + ); +} diff --git a/game/server/controlpoints/routes.py b/game/server/controlpoints/routes.py index 73eeecba..234ac332 100644 --- a/game/server/controlpoints/routes.py +++ b/game/server/controlpoints/routes.py @@ -1,8 +1,13 @@ +from dcs import Point +from dcs.mapping import LatLng from fastapi import APIRouter, Depends, HTTPException, status from game import Game from .models import ControlPointJs +from .. import EventStream from ..dependencies import GameContext +from ..leaflet import LeafletPoint +from ...sim import GameUpdateEvents router: APIRouter = APIRouter(prefix="/control-points") @@ -26,3 +31,67 @@ def get_control_point( detail=f"Game has no control point with ID {cp_id}", ) return ControlPointJs.for_control_point(cp) + + +@router.get("/{cp_id}/destination-in-range") +def destination_in_range( + cp_id: int, lat: float, lng: float, game: Game = Depends(GameContext.get) +) -> bool: + cp = game.theater.find_control_point_by_id(cp_id) + if cp is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"Game has no control point with ID {cp_id}", + ) + + point = Point.from_latlng(LatLng(lat, lng), game.theater.terrain) + return cp.destination_in_range(point) + + +@router.put("/{cp_id}/destination") +def set_destination( + cp_id: int, destination: LeafletPoint, game: Game = Depends(GameContext.get) +) -> None: + cp = game.theater.find_control_point_by_id(cp_id) + if cp is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"Game has no control point with ID {cp_id}", + ) + if not cp.moveable: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail=f"{cp} is not mobile") + if not cp.captured: + raise HTTPException( + status.HTTP_403_FORBIDDEN, detail=f"{cp} is not owned by the player" + ) + + point = Point.from_latlng( + LatLng(destination.lat, destination.lng), game.theater.terrain + ) + if not cp.destination_in_range(point): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"Cannot move {cp} more than " + f"{cp.max_move_distance.nautical_miles}nm.", + ) + cp.target_position = point + EventStream.put_nowait(GameUpdateEvents().update_control_point(cp)) + + +@router.put("/{cp_id}/cancel-travel") +def cancel_travel(cp_id: int, game: Game = Depends(GameContext.get)) -> None: + cp = game.theater.find_control_point_by_id(cp_id) + if cp is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail=f"Game has no control point with ID {cp_id}", + ) + if not cp.moveable: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail=f"{cp} is not mobile") + if not cp.captured: + raise HTTPException( + status.HTTP_403_FORBIDDEN, detail=f"{cp} is not owned by the player" + ) + + cp.target_position = None + EventStream.put_nowait(GameUpdateEvents().update_control_point(cp)) diff --git a/game/theater/controlpoint.py b/game/theater/controlpoint.py index bb620679..a955a9e9 100644 --- a/game/theater/controlpoint.py +++ b/game/theater/controlpoint.py @@ -41,7 +41,7 @@ from game.sidc import ( Status, SymbolSet, ) -from game.utils import Heading +from game.utils import Distance, Heading, meters from .base import Base from .frontline import FrontLine from .missiontarget import MissionTarget @@ -540,7 +540,15 @@ class ControlPoint(MissionTarget, SidcDescribable, ABC): """ :return: Whether this control point can be moved around """ - return False + return self.max_move_distance > meters(0) + + @property + def max_move_distance(self) -> Distance: + return meters(0) + + def destination_in_range(self, destination: Point) -> bool: + distance = meters(destination.distance_to_point(self.position)) + return distance <= self.max_move_distance @property @abstractmethod @@ -1117,8 +1125,8 @@ class NavalControlPoint(ControlPoint, ABC): return False @property - def moveable(self) -> bool: - return True + def max_move_distance(self) -> Distance: + return nautical_miles(80) @property def can_deploy_ground_units(self) -> bool: