diff --git a/qt_ui/widgets/map/mapmodel.py b/qt_ui/widgets/map/mapmodel.py index b77788a4..61d42b23 100644 --- a/qt_ui/widgets/map/mapmodel.py +++ b/qt_ui/widgets/map/mapmodel.py @@ -16,6 +16,7 @@ from game.theater import ( ControlPoint, TheaterGroundObject, FrontLine, + LatLon, ) from game.utils import meters, nautical_miles from gen.ato import AirTaskingOrder @@ -57,6 +58,8 @@ class ControlPointJs(QObject): nameChanged = Signal() blueChanged = Signal() positionChanged = Signal() + mobileChanged = Signal() + destinationChanged = Signal(list) def __init__( self, @@ -83,6 +86,42 @@ class ControlPointJs(QObject): ll = self.theater.point_to_ll(self.control_point.position) return [ll.latitude, ll.longitude] + @Property(bool, notify=mobileChanged) + def mobile(self) -> bool: + return self.control_point.moveable and self.control_point.captured + + @Property(list, notify=destinationChanged) + def destination(self) -> LeafletLatLon: + if self.control_point.target_position is None: + # Qt seems to convert None to [] for list Properties :( + return [] + return self.theater.point_to_ll(self.control_point.target_position).as_list() + + @Slot(list, result=str) + def setDestination(self, destination: LeafletLatLon) -> str: + if not self.control_point.moveable: + return f"{self.control_point} is not mobile" + if not self.control_point.captured: + return f"{self.control_point} is not owned by player" + point = self.theater.ll_to_point(LatLon(*destination)) + from qt_ui.widgets.map.QLiberationMap import MAX_SHIP_DISTANCE + + move_distance = meters(point.distance_to_point(self.control_point.position)) + if move_distance > MAX_SHIP_DISTANCE: + return ( + f"Cannot move {self.control_point} more than " + f"{MAX_SHIP_DISTANCE.nautical_miles}nm. Attempted " + f"{move_distance.nautical_miles}nm" + ) + self.control_point.target_position = point + self.destinationChanged.emit(destination) + return "" + + @Slot() + def cancelTravel(self) -> None: + self.control_point.target_position = None + self.destinationChanged.emit([]) + @Slot() def showInfoDialog(self) -> None: if self.dialog is None: diff --git a/resources/ui/map/map.js b/resources/ui/map/map.js index e32dece8..309237ae 100644 --- a/resources/ui/map/map.js +++ b/resources/ui/map/map.js @@ -4,7 +4,6 @@ * - Culling * - Threat zones * - Navmeshes - * - CV waypoints * - Time of day/weather themeing * - Exclusion zones * - Supply route status @@ -122,24 +121,119 @@ function iconFor(player) { const SHOW_BASE_NAME_AT_ZOOM = 8; -function drawControlPoints() { - controlPointsLayer.clearLayers(); - const zoom = map.getZoom(); - game.controlPoints.forEach((cp) => { +class ControlPoint { + constructor(cp) { + this.cp = cp; + this.locationMarker = this.makeLocationMarker(); + this.destinationMarker = this.makeDestinationMarker(); + this.path = this.makePath(); + this.cp.destinationChanged.connect(() => this.onDestinationChanged()); + } + + hasDestination() { + return this.cp.destination.length > 0; + } + + resetLocationMarker() { + // It seems that moving this without removing/adding it to the layer does + // nothing. + this.locationMarker + .removeFrom(controlPointsLayer) + .setLatLng(this.cp.position) + .addTo(controlPointsLayer); + } + + hideDestination() { + this.destinationMarker.removeFrom(controlPointsLayer); + this.path.removeFrom(controlPointsLayer); + } + + setDestination(destination) { + this.cp.setDestination([destination.lat, destination.lng]).then((err) => { + if (err) { + console.log(`Could not set control point destination: ${err}`); + } + // No need to update destination positions. The backend will emit an event + // that causes that if we've successfully changed the destination. If it + // was not successful, we've already reset the origin so no need for a + //change. + }); + } + + makeLocationMarker() { // We might draw other markers on top of the CP. The tooltips from the other - // markers are helpful so we want to keep them, but make sure the CP is always - // the clickable thing. - L.marker(cp.position, { icon: iconFor(cp.blue), zIndexOffset: 1000 }) - .bindTooltip(`

${cp.name}

`, { + // markers are helpful so we want to keep them, but make sure the CP is + // always the clickable thing. + const zoom = map.getZoom(); + return L.marker(this.cp.position, { + icon: iconFor(this.cp.blue), + zIndexOffset: 1000, + draggable: this.cp.mobile, + autoPan: true, + }) + .bindTooltip(`

${this.cp.name}

`, { permanent: zoom >= SHOW_BASE_NAME_AT_ZOOM, }) - .on("click", function () { - cp.showInfoDialog(); + .on("click", () => { + this.cp.showInfoDialog(); }) - .on("contextmenu", function () { - cp.showPackageDialog(); + .on("contextmenu", () => { + this.cp.showPackageDialog(); }) - .addTo(controlPointsLayer); + .on("dragend", (event) => { + const marker = event.target; + const newPosition = marker.getLatLng(); + this.setDestination(newPosition); + this.resetLocationMarker(); + }); + } + + makeDestinationMarker() { + const destination = this.hasDestination() ? this.cp.destination : [0, 0]; + return L.marker(destination, { + icon: iconFor(this.cp.blue), + zIndexOffset: 1000, + }) + .bindTooltip(`${this.cp.name} destination`) + .on("contextmenu", () => this.cp.cancelTravel()); + } + + makePath() { + const destination = this.hasDestination() ? this.cp.destination : [0, 0]; + return L.polyline([this.cp.position, destination], { + color: "#80BA80", + weight: 1, + }); + } + + onDestinationChanged() { + if (this.hasDestination()) { + this.destinationMarker.setLatLng(this.cp.destination); + this.destinationMarker.addTo(controlPointsLayer); + this.path.setLatLngs([this.cp.position, this.cp.destination]); + this.path.addTo(controlPointsLayer); + } else { + this.hideDestination(); + } + } + + drawDestination() { + this.destinationMarker.addTo(controlPointsLayer); + this.path.addTo(controlPointsLayer); + } + + draw() { + this.locationMarker.addTo(controlPointsLayer); + if (this.hasDestination()) { + this.drawDestination(); + } + } +} + +function drawControlPoints() { + controlPointsLayer.clearLayers(); + game.controlPoints.forEach((cp) => { + new ControlPoint(cp).draw(); }); }