From c16ca4089428d932f78366e9e5a617db35b92085 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Sat, 19 Feb 2022 18:25:45 -0800 Subject: [PATCH] Move waypoints and commit boundaries to FastAPI. --- game/ato/flight.py | 4 + game/ato/flightwaypoint.py | 137 +++++++++++++----- game/ato/flightwaypointtype.py | 5 +- game/missiongenerator/kneeboard.py | 6 +- game/server/app.py | 4 +- game/server/dependencies.py | 15 +- game/server/flights/__init__.py | 1 + game/server/flights/routes.py | 34 +++++ game/server/waypoints/__init__.py | 1 + game/server/waypoints/routes.py | 81 +++++++++++ game/theater/conflicttheater.py | 6 +- game/theater/latlon.py | 14 +- game/utils.py | 4 +- mypy.ini | 1 + qt_ui/main.py | 3 +- qt_ui/models.py | 5 + qt_ui/widgets/map/model/controlpointjs.py | 2 +- qt_ui/widgets/map/model/flightjs.py | 73 +--------- qt_ui/widgets/map/model/frontlinejs.py | 2 +- qt_ui/widgets/map/model/groundobjectjs.py | 2 +- qt_ui/widgets/map/model/mapmodel.py | 4 +- qt_ui/widgets/map/model/unculledzonejs.py | 2 +- qt_ui/widgets/map/model/waypointjs.py | 111 -------------- qt_ui/windows/QLiberationWindow.py | 3 +- resources/ui/map/map.js | 168 +++++++++++----------- 25 files changed, 360 insertions(+), 328 deletions(-) create mode 100644 game/server/flights/__init__.py create mode 100644 game/server/flights/routes.py create mode 100644 game/server/waypoints/__init__.py create mode 100644 game/server/waypoints/routes.py delete mode 100644 qt_ui/widgets/map/model/waypointjs.py diff --git a/game/ato/flight.py b/game/ato/flight.py index f860bce7..76cb7a8e 100644 --- a/game/ato/flight.py +++ b/game/ato/flight.py @@ -98,6 +98,10 @@ class Flight: state["id"] = uuid.uuid4() self.__dict__.update(state) + @property + def blue(self) -> bool: + return self.squadron.player + @property def departure(self) -> ControlPoint: return self.squadron.location diff --git a/game/ato/flightwaypoint.py b/game/ato/flightwaypoint.py index cf0895a3..152b0510 100644 --- a/game/ato/flightwaypoint.py +++ b/game/ato/flightwaypoint.py @@ -1,20 +1,115 @@ from __future__ import annotations +from collections.abc import Sequence +from dataclasses import field from datetime import timedelta -from typing import Optional, Sequence, TYPE_CHECKING, Union +from typing import Optional, TYPE_CHECKING from dcs import Point -from dcs.point import MovingPoint, PointAction from dcs.unit import Unit +from pydantic.dataclasses import dataclass -from game.utils import Distance, meters from game.ato.flightwaypointtype import FlightWaypointType +from game.theater import LatLon +from game.utils import Distance, meters if TYPE_CHECKING: - from game.theater import ControlPoint, MissionTarget + from game.theater import ConflictTheater, ControlPoint, MissionTarget -class FlightWaypoint: +@dataclass +class BaseFlightWaypoint: + name: str + waypoint_type: FlightWaypointType + x: float + y: float + alt: Distance + alt_type: str + + is_movable: bool = field(init=False) + should_mark: bool = field(init=False) + include_in_path: bool = field(init=False) + + # Do not use unless you're sure it's up to date. Pydantic doesn't have support for + # serializing lazy properties so this needs to be stored in the class, but because + # updating it requires a reference to the ConflictTheater it may not always be set, + # or up to date. Call update_latlng to refresh. + latlng: LatLon | None = None + + def __post_init__(self) -> None: + # Target *points* are the exact location of a unit, whereas the target area is + # only the center of the objective. Allow moving the latter since its exact + # location isn't very important. + # + # Landing, and divert should be changed in the flight settings UI, takeoff + # cannot be changed because that's where the plane is. + # + # Moving the bullseye reference only makes it wrong. + self.is_movable = self.waypoint_type not in { + FlightWaypointType.BULLSEYE, + FlightWaypointType.DIVERT, + FlightWaypointType.LANDING_POINT, + FlightWaypointType.TAKEOFF, + FlightWaypointType.TARGET_POINT, + } + + # We don't need a marker for the departure waypoint (and it's likely + # coincident with the landing waypoint, so hard to see). We do want to draw + # the path from it though. + # + # We also don't need the landing waypoint since we'll be drawing that path + # as well, and it's clear what it is, and only obscured the CP icon. + # + # The divert waypoint also obscures the CP. We don't draw the path to it, + # but it can be seen in the flight settings page, so it's not really a + # problem to exclude it. + # + # Bullseye ought to be (but currently isn't) drawn *once* rather than as a + # flight waypoint. + self.should_mark = self.waypoint_type not in { + FlightWaypointType.BULLSEYE, + FlightWaypointType.DIVERT, + FlightWaypointType.LANDING_POINT, + FlightWaypointType.TAKEOFF, + } + + self.include_in_path = self.waypoint_type not in { + FlightWaypointType.BULLSEYE, + FlightWaypointType.DIVERT, + } + + @property + def position(self) -> Point: + return Point(self.x, self.y) + + def update_latlng(self, theater: ConflictTheater) -> None: + self.latlng = theater.point_to_ll(self.position) + + +class FlightWaypoint(BaseFlightWaypoint): + control_point: ControlPoint | None = None + + # TODO: Merge with pretty_name. + # Only used in the waypoint list in the flight edit page. No sense + # having three names. A short and long form is enough. + description: str = "" + + targets: Sequence[MissionTarget | Unit] = [] + obj_name: str = "" + pretty_name: str = "" + only_for_player: bool = False + flyover: bool = False + + # The minimum amount of fuel remaining at this waypoint in pounds. + min_fuel: float | None = None + + # These are set very late by the air conflict generator (part of mission + # generation). We do it late so that we don't need to propagate changes + # to waypoint times whenever the player alters the package TOT or the + # flight's offset in the UI. + tot: timedelta | None = None + departure_time: timedelta | None = None + def __init__( self, waypoint_type: FlightWaypointType, @@ -34,32 +129,10 @@ class FlightWaypoint: control_point: The control point to associate with this waypoint. Needed for landing points. """ - self.waypoint_type = waypoint_type - self.x = x - self.y = y - self.alt = alt + super().__init__( + name="", waypoint_type=waypoint_type, x=x, y=y, alt=alt, alt_type="BARO" + ) self.control_point = control_point - self.alt_type = "BARO" - self.name = "" - # TODO: Merge with pretty_name. - # Only used in the waypoint list in the flight edit page. No sense - # having three names. A short and long form is enough. - self.description = "" - self.targets: Sequence[Union[MissionTarget, Unit]] = [] - self.obj_name = "" - self.pretty_name = "" - self.only_for_player = False - self.flyover = False - # The minimum amount of fuel remaining at this waypoint in pounds. - self.min_fuel: Optional[float] = None - # These are set very late by the air conflict generator (part of mission - # generation). We do it late so that we don't need to propagate changes - # to waypoint times whenever the player alters the package TOT or the - # flight's offset in the UI. - self.tot: Optional[timedelta] = None - self.departure_time: Optional[timedelta] = None - - @property - def position(self) -> Point: - return Point(self.x, self.y) + def __hash__(self) -> int: + return hash(id(self)) diff --git a/game/ato/flightwaypointtype.py b/game/ato/flightwaypointtype.py index fb2bd58c..03199389 100644 --- a/game/ato/flightwaypointtype.py +++ b/game/ato/flightwaypointtype.py @@ -1,7 +1,8 @@ -from enum import Enum +from enum import IntEnum, unique -class FlightWaypointType(Enum): +@unique +class FlightWaypointType(IntEnum): """Enumeration of waypoint types. The value of the enum has no meaning but should remain stable to prevent breaking diff --git a/game/missiongenerator/kneeboard.py b/game/missiongenerator/kneeboard.py index 41afc403..133d3c47 100644 --- a/game/missiongenerator/kneeboard.py +++ b/game/missiongenerator/kneeboard.py @@ -187,9 +187,9 @@ class KneeboardPage: @staticmethod def format_ll(ll: LatLon) -> str: - ns = "N" if ll.latitude >= 0 else "S" - ew = "E" if ll.longitude >= 0 else "W" - return f"{ll.latitude:.4}°{ns} {ll.longitude:.4}°{ew}" + ns = "N" if ll.lat >= 0 else "S" + ew = "E" if ll.lng >= 0 else "W" + return f"{ll.lat:.4}°{ns} {ll.lng:.4}°{ew}" @dataclass(frozen=True) diff --git a/game/server/app.py b/game/server/app.py index 2b02f3be..fe7c8fa1 100644 --- a/game/server/app.py +++ b/game/server/app.py @@ -1,8 +1,10 @@ from fastapi import Depends, FastAPI -from . import debuggeometries, eventstream +from . import debuggeometries, eventstream, flights, waypoints from .security import ApiKeyManager app = FastAPI(dependencies=[Depends(ApiKeyManager.verify)]) app.include_router(debuggeometries.router) app.include_router(eventstream.router) +app.include_router(flights.router) +app.include_router(waypoints.router) diff --git a/game/server/dependencies.py b/game/server/dependencies.py index eb69e0cb..e93eb7ee 100644 --- a/game/server/dependencies.py +++ b/game/server/dependencies.py @@ -1,15 +1,20 @@ from game import Game +from qt_ui.models import GameModel class GameContext: - _game: Game | None + _game_model: GameModel @classmethod - def set(cls, game: Game | None) -> None: - cls._game = game + def set_model(cls, game_model: GameModel) -> None: + cls._game_model = game_model @classmethod def get(cls) -> Game: - if cls._game is None: + if cls._game_model.game is None: raise RuntimeError("GameContext has no Game set") - return cls._game + return cls._game_model.game + + @classmethod + def get_model(cls) -> GameModel: + return cls._game_model diff --git a/game/server/flights/__init__.py b/game/server/flights/__init__.py new file mode 100644 index 00000000..3a27ef1c --- /dev/null +++ b/game/server/flights/__init__.py @@ -0,0 +1 @@ +from .routes import router diff --git a/game/server/flights/routes.py b/game/server/flights/routes.py new file mode 100644 index 00000000..c6601457 --- /dev/null +++ b/game/server/flights/routes.py @@ -0,0 +1,34 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends +from shapely.geometry import LineString, Point as ShapelyPoint + +from game import Game +from game.server import GameContext +from game.server.leaflet import LeafletPoly, ShapelyUtil +from gen.flights.flightplan import CasFlightPlan, PatrollingFlightPlan + +router: APIRouter = APIRouter(prefix="/flights") + + +@router.get("/{flight_id}/commit-boundary") +def commit_boundary( + flight_id: UUID, game: Game = Depends(GameContext.get) +) -> LeafletPoly: + flight = game.db.flights.get(flight_id) + if not isinstance(flight.flight_plan, PatrollingFlightPlan): + return [] + start = flight.flight_plan.patrol_start + end = flight.flight_plan.patrol_end + if isinstance(flight.flight_plan, CasFlightPlan): + center = flight.flight_plan.target.position + commit_center = ShapelyPoint(center.x, center.y) + else: + commit_center = LineString( + [ + ShapelyPoint(start.x, start.y), + ShapelyPoint(end.x, end.y), + ] + ) + bubble = commit_center.buffer(flight.flight_plan.engagement_distance.meters) + return ShapelyUtil.poly_to_leaflet(bubble, game.theater) diff --git a/game/server/waypoints/__init__.py b/game/server/waypoints/__init__.py new file mode 100644 index 00000000..3a27ef1c --- /dev/null +++ b/game/server/waypoints/__init__.py @@ -0,0 +1 @@ +from .routes import router diff --git a/game/server/waypoints/routes.py b/game/server/waypoints/routes.py new file mode 100644 index 00000000..5400c5a2 --- /dev/null +++ b/game/server/waypoints/routes.py @@ -0,0 +1,81 @@ +from datetime import timedelta +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, status + +from game import Game +from game.ato.flightwaypoint import BaseFlightWaypoint, FlightWaypoint +from game.ato.flightwaypointtype import FlightWaypointType +from game.server import GameContext +from game.theater import LatLon +from game.utils import meters + +router: APIRouter = APIRouter(prefix="/waypoints") + + +@router.get("/{flight_id}", response_model=list[BaseFlightWaypoint]) +def all_waypoints_for_flight( + flight_id: UUID, game: Game = Depends(GameContext.get) +) -> list[FlightWaypoint]: + flight = game.db.flights.get(flight_id) + departure = FlightWaypoint( + FlightWaypointType.TAKEOFF, + flight.departure.position.x, + flight.departure.position.y, + meters(0), + ) + departure.alt_type = "RADIO" + points = [departure] + flight.flight_plan.waypoints + for point in points: + point.update_latlng(game.theater) + return points + + +@router.post("/{flight_id}/{waypoint_idx}/position") +def set_position( + flight_id: UUID, + waypoint_idx: int, + position: LatLon, + game: Game = Depends(GameContext.get), +) -> None: + flight = game.db.flights.get(flight_id) + if waypoint_idx == 0: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + waypoint = flight.flight_plan.waypoints[waypoint_idx - 1] + if not waypoint.is_movable: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + + point = game.theater.ll_to_point(position) + waypoint.x = point.x + waypoint.y = point.y + package_model = ( + GameContext.get_model() + .ato_model_for(flight.blue) + .find_matching_package_model(flight.package) + ) + if package_model is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Could not find PackageModel owning {flight}", + ) + package_model.update_tot() + + +@router.get("/{flight_id}/{waypoint_idx}/timing") +def waypoint_timing( + flight_id: UUID, waypoint_idx: int, game: Game = Depends(GameContext.get) +) -> str | None: + flight = game.db.flights.get(flight_id) + if waypoint_idx == 0: + return f"Depart T+{flight.flight_plan.takeoff_time()}" + + waypoint = flight.flight_plan.waypoints[waypoint_idx - 1] + prefix = "TOT" + time = flight.flight_plan.tot_for_waypoint(waypoint) + if time is None: + prefix = "Depart" + time = flight.flight_plan.depart_time_for_waypoint(waypoint) + if time is None: + return "" + return f"{prefix} T+{timedelta(seconds=int(time.total_seconds()))}" diff --git a/game/theater/conflicttheater.py b/game/theater/conflicttheater.py index 271782ca..bdaa1007 100644 --- a/game/theater/conflicttheater.py +++ b/game/theater/conflicttheater.py @@ -4,17 +4,17 @@ import datetime import math from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING +from typing import Any, Dict, Iterator, List, Optional, TYPE_CHECKING, Tuple from dcs.mapping import Point from dcs.terrain import ( caucasus, + marianaislands, nevada, normandy, persiangulf, syria, thechannel, - marianaislands, ) from dcs.terrain.terrain import Terrain from pyproj import CRS, Transformer @@ -262,7 +262,7 @@ class ConflictTheater: return LatLon(lat, lon) def ll_to_point(self, ll: LatLon) -> Point: - x, y = self.ll_to_point_transformer.transform(ll.latitude, ll.longitude) + x, y = self.ll_to_point_transformer.transform(ll.lat, ll.lng) return Point(x, y) diff --git a/game/theater/latlon.py b/game/theater/latlon.py index b819e30f..6d440638 100644 --- a/game/theater/latlon.py +++ b/game/theater/latlon.py @@ -1,14 +1,16 @@ -from dataclasses import dataclass from typing import List, Tuple +from pydantic.dataclasses import dataclass + @dataclass(frozen=True) class LatLon: - latitude: float - longitude: float + # These names match Leaflet for easier interop. + lat: float + lng: float def as_list(self) -> List[float]: - return [self.latitude, self.longitude] + return [self.lat, self.lng] @staticmethod def _components(dimension: float) -> Tuple[int, int, float]: @@ -28,7 +30,7 @@ class LatLon: precision = 2 if include_decimal_seconds else 0 return " ".join( [ - self._format_component(self.latitude, ("N", "S"), precision), - self._format_component(self.longitude, ("E", "W"), precision), + self._format_component(self.lat, ("N", "S"), precision), + self._format_component(self.lng, ("E", "W"), precision), ] ) diff --git a/game/utils.py b/game/utils.py index 479d65e0..aceeff24 100644 --- a/game/utils.py +++ b/game/utils.py @@ -1,14 +1,14 @@ from __future__ import annotations -from abc import ABC, abstractmethod import itertools import math import random +from abc import ABC, abstractmethod from collections.abc import Iterable -from dataclasses import dataclass from typing import TypeVar, Union from dcs import Point +from pydantic.dataclasses import dataclass from shapely.geometry import Point as ShapelyPoint METERS_TO_FEET = 3.28084 diff --git a/mypy.ini b/mypy.ini index b1cafe55..fe6d3348 100644 --- a/mypy.ini +++ b/mypy.ini @@ -15,6 +15,7 @@ warn_redundant_casts = True # warn_return_any = True warn_unreachable = True warn_unused_ignores = True +plugins = pydantic.mypy [mypy-faker.*] ignore_missing_imports = True diff --git a/qt_ui/main.py b/qt_ui/main.py index 0447931e..3eed28e2 100644 --- a/qt_ui/main.py +++ b/qt_ui/main.py @@ -18,7 +18,7 @@ from game.data.weapons import Pylon, Weapon, WeaponGroup from game.dcs.aircrafttype import AircraftType from game.factions import FACTIONS from game.profiling import logged_duration -from game.server import EventStream, GameContext, Server +from game.server import EventStream, Server from game.settings import Settings from game.theater.start_generator import GameGenerator, GeneratorSettings, ModSettings from pydcs_extensions import load_mods @@ -137,7 +137,6 @@ def run_ui(game: Optional[Game]) -> None: # Apply CSS (need works) GameUpdateSignal() - GameUpdateSignal.get_instance().game_loaded.connect(GameContext.set) GameUpdateSignal.get_instance().game_loaded.connect(EventStream.drain) # Start window diff --git a/qt_ui/models.py b/qt_ui/models.py index ca9ae581..5e2c85c3 100644 --- a/qt_ui/models.py +++ b/qt_ui/models.py @@ -529,3 +529,8 @@ class GameModel: self.game = game self.ato_model.replace_from_game(player=True) self.red_ato_model.replace_from_game(player=False) + + def get(self) -> Game: + if self.game is None: + raise RuntimeError("GameModel has no Game set") + return self.game diff --git a/qt_ui/widgets/map/model/controlpointjs.py b/qt_ui/widgets/map/model/controlpointjs.py index 115428fc..7f511727 100644 --- a/qt_ui/widgets/map/model/controlpointjs.py +++ b/qt_ui/widgets/map/model/controlpointjs.py @@ -62,7 +62,7 @@ class ControlPointJs(QObject): @Property(list, notify=positionChanged) def position(self) -> LeafletLatLon: ll = self.theater.point_to_ll(self.control_point.position) - return [ll.latitude, ll.longitude] + return [ll.lat, ll.lng] @Property(bool, notify=mobileChanged) def mobile(self) -> bool: diff --git a/qt_ui/widgets/map/model/flightjs.py b/qt_ui/widgets/map/model/flightjs.py index 9767ad4b..b73567c8 100644 --- a/qt_ui/widgets/map/model/flightjs.py +++ b/qt_ui/widgets/map/model/flightjs.py @@ -1,41 +1,19 @@ from __future__ import annotations -from typing import List - from PySide2.QtCore import Property, QObject, Signal, Slot -from shapely.geometry import LineString, Point as ShapelyPoint -from game.ato import Flight, FlightWaypoint +from game.ato import Flight from game.ato.flightstate import InFlight -from game.ato.flightwaypointtype import FlightWaypointType -from game.server.leaflet import LeafletLatLon, LeafletPoly, ShapelyUtil +from game.server.leaflet import LeafletLatLon from game.theater import ConflictTheater -from game.utils import meters -from gen.flights.flightplan import CasFlightPlan, PatrollingFlightPlan from qt_ui.models import AtoModel -from .waypointjs import WaypointJs class FlightJs(QObject): idChanged = Signal() positionChanged = Signal() - flightPlanChanged = Signal() blueChanged = Signal() selectedChanged = Signal() - commitBoundaryChanged = Signal() - - originChanged = Signal() - - @Property(list, notify=originChanged) - def origin(self) -> LeafletLatLon: - return self._waypoints[0].position - - targetChanged = Signal() - - @Property(list, notify=targetChanged) - def target(self) -> LeafletLatLon: - ll = self.theater.point_to_ll(self.flight.package.target.position) - return [ll.latitude, ll.longitude] def __init__( self, @@ -49,42 +27,18 @@ class FlightJs(QObject): self._selected = selected self.theater = theater self.ato_model = ato_model - self._waypoints = self.make_waypoints() @Property(str, notify=idChanged) def id(self) -> str: return str(self.flight.id) - def update_waypoints(self) -> None: - for waypoint in self._waypoints: - waypoint.timingChanged.emit() - - def make_waypoints(self) -> List[WaypointJs]: - departure = FlightWaypoint( - FlightWaypointType.TAKEOFF, - self.flight.departure.position.x, - self.flight.departure.position.y, - meters(0), - ) - departure.alt_type = "RADIO" - waypoints = [] - for point in [departure] + self.flight.points: - waypoint = WaypointJs(point, self, self.theater, self.ato_model) - waypoint.positionChanged.connect(self.update_waypoints) - waypoints.append(waypoint) - return waypoints - @Property(list, notify=positionChanged) def position(self) -> LeafletLatLon: if isinstance(self.flight.state, InFlight): ll = self.theater.point_to_ll(self.flight.state.estimate_position()) - return [ll.latitude, ll.longitude] + return [ll.lat, ll.lng] return [] - @Property(list, notify=flightPlanChanged) - def flightPlan(self) -> List[WaypointJs]: - return self._waypoints - @Property(bool, notify=blueChanged) def blue(self) -> bool: return self.flight.departure.captured @@ -104,24 +58,3 @@ class FlightJs(QObject): def set_selected(self, value: bool) -> None: self._selected = value self.selectedChanged.emit() - - @Property(list, notify=commitBoundaryChanged) - def commitBoundary(self) -> LeafletPoly: - if not isinstance(self.flight.flight_plan, PatrollingFlightPlan): - return [] - start = self.flight.flight_plan.patrol_start - end = self.flight.flight_plan.patrol_end - if isinstance(self.flight.flight_plan, CasFlightPlan): - center = self.flight.flight_plan.target.position - commit_center = ShapelyPoint(center.x, center.y) - else: - commit_center = LineString( - [ - ShapelyPoint(start.x, start.y), - ShapelyPoint(end.x, end.y), - ] - ) - bubble = commit_center.buffer( - self.flight.flight_plan.engagement_distance.meters - ) - return ShapelyUtil.poly_to_leaflet(bubble, self.theater) diff --git a/qt_ui/widgets/map/model/frontlinejs.py b/qt_ui/widgets/map/model/frontlinejs.py index d45e1754..c448da49 100644 --- a/qt_ui/widgets/map/model/frontlinejs.py +++ b/qt_ui/widgets/map/model/frontlinejs.py @@ -30,7 +30,7 @@ class FrontLineJs(QObject): self.front_line.attack_heading.left.degrees, nautical_miles(2).meters ) ) - return [[a.latitude, a.longitude], [b.latitude, b.longitude]] + return [[a.lat, a.lng], [b.lat, b.lng]] @Slot() def showPackageDialog(self) -> None: diff --git a/qt_ui/widgets/map/model/groundobjectjs.py b/qt_ui/widgets/map/model/groundobjectjs.py index 493f83ac..36a278b7 100644 --- a/qt_ui/widgets/map/model/groundobjectjs.py +++ b/qt_ui/widgets/map/model/groundobjectjs.py @@ -101,7 +101,7 @@ class GroundObjectJs(QObject): @Property(list, notify=positionChanged) def position(self) -> LeafletLatLon: ll = self.theater.point_to_ll(self.tgo.position) - return [ll.latitude, ll.longitude] + return [ll.lat, ll.lng] @Property(bool, notify=deadChanged) def dead(self) -> bool: diff --git a/qt_ui/widgets/map/model/mapmodel.py b/qt_ui/widgets/map/model/mapmodel.py index 52b0d0be..f6020f25 100644 --- a/qt_ui/widgets/map/model/mapmodel.py +++ b/qt_ui/widgets/map/model/mapmodel.py @@ -160,7 +160,7 @@ class MapModel(QObject): @staticmethod def leaflet_coord_for(point: Point, theater: ConflictTheater) -> LeafletLatLon: ll = theater.point_to_ll(point) - return [ll.latitude, ll.longitude] + return [ll.lat, ll.lng] def reset(self) -> None: if self.game_model.game is None: @@ -183,7 +183,7 @@ class MapModel(QObject): def reset_map_center(self, theater: ConflictTheater) -> None: ll = theater.point_to_ll(theater.terrain.map_view_default.position) - self._map_center = [ll.latitude, ll.longitude] + self._map_center = [ll.lat, ll.lng] self.mapCenterChanged.emit(self._map_center) @Property(str, notify=apiKeyChanged) diff --git a/qt_ui/widgets/map/model/unculledzonejs.py b/qt_ui/widgets/map/model/unculledzonejs.py index baede90c..bd2c5bd6 100644 --- a/qt_ui/widgets/map/model/unculledzonejs.py +++ b/qt_ui/widgets/map/model/unculledzonejs.py @@ -30,5 +30,5 @@ class UnculledZone(QObject): for zone in game.get_culling_zones(): ll = game.theater.point_to_ll(zone) yield UnculledZone( - [ll.latitude, ll.longitude], game.settings.perf_culling_distance * 1000 + [ll.lat, ll.lng], game.settings.perf_culling_distance * 1000 ) diff --git a/qt_ui/widgets/map/model/waypointjs.py b/qt_ui/widgets/map/model/waypointjs.py deleted file mode 100644 index 7fdb26ec..00000000 --- a/qt_ui/widgets/map/model/waypointjs.py +++ /dev/null @@ -1,111 +0,0 @@ -from __future__ import annotations - -from datetime import timedelta -from typing import TYPE_CHECKING - -from PySide2.QtCore import Property, QObject, Signal, Slot - -from game.ato import Flight, FlightWaypoint -from game.ato.flightwaypointtype import FlightWaypointType -from game.server.leaflet import LeafletLatLon -from game.theater import ConflictTheater, LatLon -from gen.flights.flightplan import FlightPlan -from qt_ui.models import AtoModel - -if TYPE_CHECKING: - from .flightjs import FlightJs - - -class WaypointJs(QObject): - positionChanged = Signal() - altitudeFtChanged = Signal() - altitudeReferenceChanged = Signal() - nameChanged = Signal() - timingChanged = Signal() - isTargetPointChanged = Signal() - isTakeoffChanged = Signal() - isLandingChanged = Signal() - isDivertChanged = Signal() - isBullseyeChanged = Signal() - - def __init__( - self, - waypoint: FlightWaypoint, - flight_model: FlightJs, - theater: ConflictTheater, - ato_model: AtoModel, - ) -> None: - super().__init__() - self.waypoint = waypoint - self.flight_model = flight_model - self.theater = theater - self.ato_model = ato_model - - @property - def flight(self) -> Flight: - return self.flight_model.flight - - @property - def flight_plan(self) -> FlightPlan: - return self.flight.flight_plan - - @Property(list, notify=positionChanged) - def position(self) -> LeafletLatLon: - ll = self.theater.point_to_ll(self.waypoint.position) - return [ll.latitude, ll.longitude] - - @Property(int, notify=altitudeFtChanged) - def altitudeFt(self) -> int: - return int(self.waypoint.alt.feet) - - @Property(str, notify=altitudeReferenceChanged) - def altitudeReference(self) -> str: - return "AGL" if self.waypoint.alt_type == "RADIO" else "MSL" - - @Property(str, notify=nameChanged) - def name(self) -> str: - return self.waypoint.name - - @Property(str, notify=timingChanged) - def timing(self) -> str: - prefix = "TOT" - time = self.flight_plan.tot_for_waypoint(self.waypoint) - if time is None: - prefix = "Depart" - time = self.flight_plan.depart_time_for_waypoint(self.waypoint) - if time is None: - return "" - return f"{prefix} T+{timedelta(seconds=int(time.total_seconds()))}" - - @Property(bool, notify=isTargetPointChanged) - def isTargetPoint(self) -> bool: - return self.waypoint.waypoint_type is FlightWaypointType.TARGET_POINT - - @Property(bool, notify=isTakeoffChanged) - def isTakeoff(self) -> bool: - return self.waypoint.waypoint_type is FlightWaypointType.TAKEOFF - - @Property(bool, notify=isLandingChanged) - def isLanding(self) -> bool: - return self.waypoint.waypoint_type is FlightWaypointType.LANDING_POINT - - @Property(bool, notify=isDivertChanged) - def isDivert(self) -> bool: - return self.waypoint.waypoint_type is FlightWaypointType.DIVERT - - @Property(bool, notify=isBullseyeChanged) - def isBullseye(self) -> bool: - return self.waypoint.waypoint_type is FlightWaypointType.BULLSEYE - - @Slot(list, result=str) - def setPosition(self, position: LeafletLatLon) -> str: - point = self.theater.ll_to_point(LatLon(*position)) - self.waypoint.x = point.x - self.waypoint.y = point.y - package = self.ato_model.find_matching_package_model(self.flight.package) - if package is None: - return "Could not find package model containing modified flight" - package.update_tot() - self.positionChanged.emit() - self.flight_model.commitBoundaryChanged.emit() - return "" diff --git a/qt_ui/windows/QLiberationWindow.py b/qt_ui/windows/QLiberationWindow.py index 1cc645d3..dd53510e 100644 --- a/qt_ui/windows/QLiberationWindow.py +++ b/qt_ui/windows/QLiberationWindow.py @@ -20,7 +20,7 @@ from PySide2.QtWidgets import ( import qt_ui.uiconstants as CONST from game import Game, VERSION, persistency from game.debriefing import Debriefing -from game.server import EventStream +from game.server import EventStream, GameContext from game.server.security import ApiKeyManager from qt_ui import liberation_install from qt_ui.dialogs import Dialog @@ -54,6 +54,7 @@ class QLiberationWindow(QMainWindow): self.sim_controller = SimController(self.game) self.sim_controller.sim_update.connect(EventStream.put_nowait) self.game_model = GameModel(game, self.sim_controller) + GameContext.set_model(self.game_model) Dialog.set_game(self.game_model) self.ato_panel = QAirTaskingOrderPanel(self.game_model) self.info_panel = QInfoPanel(self.game) diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index ab981ad9..b23540a0 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -3,6 +3,8 @@ const ENABLE_EXPENSIVE_DEBUG_TOOLS = false; const HTTP_BACKEND = "http://[::1]:5000"; const WS_BACKEND = "ws://[::1]:5000/eventstream"; +METERS_TO_FEET = 3.28084; + // Uniquely generated at startup and passed to use by the QWebChannel. var API_KEY = null; @@ -14,6 +16,17 @@ function getJson(endpoint) { }).then((response) => response.json()); } +function postJson(endpoint, data) { + return fetch(`${HTTP_BACKEND}${endpoint}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": API_KEY, + }, + body: JSON.stringify(data), + }).then((response) => response.json()); +} + const Colors = Object.freeze({ Blue: "#0084ff", Red: "#c85050", @@ -752,79 +765,50 @@ class Waypoint { this.number = number; this.flight = flight; this.marker = this.makeMarker(); - this.waypoint.positionChanged.connect(() => this.relocate()); - this.waypoint.timingChanged.connect(() => this.updateDescription()); } position() { - return this.waypoint.position; + return this.waypoint.latlng; } shouldMark() { - // We don't need a marker for the departure waypoint (and it's likely - // coincident with the landing waypoint, so hard to see). We do want to draw - // the path from it though. - // - // We also don't need the landing waypoint since we'll be drawing that path - // as well and it's clear what it is, and only obscured the CP icon. - // - // The divert waypoint also obscures the CP. We don't draw the path to it, - // but it can be seen in the flight settings page so it's not really a - // problem to exclude it. - // - // Bullseye ought to be (but currently isn't) drawn *once* rather than as a - // flight waypoint. - return !( - this.waypoint.isTakeoff || - this.waypoint.isLanding || - this.waypoint.isDivert || - this.waypoint.isBullseye - ); + return this.waypoint.should_mark; } - draggable() { - // Target *points* are the exact location of a unit, whereas the target area - // is only the center of the objective. Allow moving the latter since its - // exact location isn't very important. - // - // Landing, and divert should be changed in the flight settings UI, takeoff - // cannot be changed because that's where the plane is. - // - // Moving the bullseye reference only makes it wrong. - return !( - this.waypoint.isTargetPoint || - this.waypoint.isTakeoff || - this.waypoint.isLanding || - this.waypoint.isDivert || - this.waypoint.isBullseye - ); + async timing(dragging) { + if (dragging) { + return "Waiting to recompute TOT..."; + } + return await getJson(`/waypoints/${this.flight.id}/${this.number}/timing`); } - description(dragging) { - const timing = dragging - ? "Waiting to recompute TOT..." - : this.waypoint.timing; + async description(dragging) { + const alt = Math.floor( + this.waypoint.alt.distance_in_meters * METERS_TO_FEET + ); + const altRef = this.waypoint.alt_type == "BARO" ? "MSL" : "AGL"; return ( `${this.number} ${this.waypoint.name}
` + - `${this.waypoint.altitudeFt} ft ${this.waypoint.altitudeReference}
` + - `${timing}` + `${alt} ft ${altRef}
` + + `${await this.timing(dragging)}` ); } relocate() { - this.marker.setLatLng(this.waypoint.position); + this.marker.setLatLng(this.position()); } updateDescription(dragging) { - this.marker.setTooltipContent(this.description(dragging)); + this.description(dragging).then((description) => { + this.marker.setTooltipContent(description); + }); } makeMarker() { const zoom = map.getZoom(); - return L.marker(this.waypoint.position, { draggable: this.draggable() }) - .bindTooltip(this.description(), { - permanent: zoom >= SHOW_WAYPOINT_INFO_AT_ZOOM, - }) + const marker = L.marker(this.position(), { + draggable: this.waypoint.is_movable, + }) .on("dragstart", (e) => { this.updateDescription(true); }) @@ -836,19 +820,35 @@ class Waypoint { .on("dragend", (e) => { const marker = e.target; const destination = marker.getLatLng(); - this.waypoint - .setPosition([destination.lat, destination.lng]) - .then((err) => { + postJson( + `/waypoints/${this.flight.id}/${this.number}/position`, + destination + ) + .then(() => { + this.waypoint.position = destination; + this.updateDescription(false); + this.flight.drawCommitBoundary(); + }) + .catch((err) => { if (err) { + this.relocate(); console.log(err); - marker.bindPopup(err); + marker.bindPopup(`${err}`).openPopup(); } }); }); + + this.description(false).then((description) => + marker.bindTooltip(description, { + permanent: zoom >= SHOW_WAYPOINT_INFO_AT_ZOOM, + }) + ); + + return marker; } includeInPath() { - return !this.waypoint.isDivert && !this.waypoint.isBullseye; + return this.waypoint.include_in_path; } } @@ -858,16 +858,11 @@ class Flight { constructor(flight) { this.flight = flight; this.id = flight.id; - this.flightPlan = this.flight.flightPlan.map( - (p, idx) => new Waypoint(p, idx, this) - ); this.aircraft = null; this.path = null; this.markers = []; this.commitBoundary = null; this.flight.selectedChanged.connect(() => this.draw()); - this.flight.flightPlanChanged.connect(() => this.drawFlightPlan()); - this.flight.commitBoundaryChanged.connect(() => this.drawCommitBoundary()); Flight.registerFlight(this); } @@ -948,16 +943,18 @@ class Flight { .removeFrom(allFlightPlansLayer); } if (this.flight.selected) { - if (this.flight.commitBoundary) { - this.commitBoundary = L.polyline(this.flight.commitBoundary, { - color: Colors.Highlight, - weight: 1, - interactive: false, - }) - .addTo(selectedFlightPlansLayer) - .addTo(this.flightPlanLayer()) - .addTo(allFlightPlansLayer); - } + getJson(`/flights/${this.flight.id}/commit-boundary`).then((boundary) => { + if (boundary) { + this.commitBoundary = L.polyline(boundary, { + color: Colors.Highlight, + weight: 1, + interactive: false, + }) + .addTo(selectedFlightPlansLayer) + .addTo(this.flightPlanLayer()) + .addTo(allFlightPlansLayer); + } + }); } } @@ -993,21 +990,24 @@ class Flight { // ATO before drawing. return; } - const path = []; - this.flightPlan.forEach((waypoint) => { - if (waypoint.includeInPath()) { - path.push(waypoint.position()); - } - if (this.shouldMark(waypoint)) { - waypoint.marker - .addTo(selectedFlightPlansLayer) - .addTo(this.flightPlanLayer()) - .addTo(allFlightPlansLayer); - this.markers.push(waypoint.marker); - } - }); - this.drawPath(path); + getJson(`/waypoints/${this.flight.id}`).then((waypoints) => { + const path = []; + waypoints.map((raw, idx) => { + const waypoint = new Waypoint(raw, idx, this); + if (waypoint.includeInPath()) { + path.push(waypoint.position()); + } + if (this.shouldMark(waypoint)) { + waypoint.marker + .addTo(selectedFlightPlansLayer) + .addTo(this.flightPlanLayer()) + .addTo(allFlightPlansLayer); + this.markers.push(waypoint.marker); + } + }); + this.drawPath(path); + }); }); } }