From c9d49f6f40150c2fca36dcb3af88e772910de7e2 Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Fri, 7 Oct 2022 22:56:47 -0700 Subject: [PATCH] Split up the ControlPoint.tsx monolith. This is in need of some serious refactoring so that https://github.com/dcs-liberation/dcs_liberation/issues/2388 can be fixed. --- .../components/controlpoints/ControlPoint.tsx | 279 +----------------- .../components/controlpoints/EventHandlers.ts | 22 ++ client/src/components/controlpoints/Icons.tsx | 15 + .../controlpoints/LocationTooltipText.tsx | 9 + .../controlpoints/MobileControlPoint.tsx | 225 ++++++++++++++ .../components/controlpoints/MovementPath.tsx | 35 +++ .../controlpoints/StaticControlPoint.tsx | 27 ++ 7 files changed, 340 insertions(+), 272 deletions(-) create mode 100644 client/src/components/controlpoints/EventHandlers.ts create mode 100644 client/src/components/controlpoints/Icons.tsx create mode 100644 client/src/components/controlpoints/LocationTooltipText.tsx create mode 100644 client/src/components/controlpoints/MobileControlPoint.tsx create mode 100644 client/src/components/controlpoints/MovementPath.tsx create mode 100644 client/src/components/controlpoints/StaticControlPoint.tsx diff --git a/client/src/components/controlpoints/ControlPoint.tsx b/client/src/components/controlpoints/ControlPoint.tsx index bcf209c1..316fe5d3 100644 --- a/client/src/components/controlpoints/ControlPoint.tsx +++ b/client/src/components/controlpoints/ControlPoint.tsx @@ -1,280 +1,15 @@ -import backend from "../../api/backend"; import { ControlPoint as ControlPointModel } from "../../api/liberationApi"; -import { - useClearControlPointDestinationMutation, - useSetControlPointDestinationMutation, -} from "../../api/liberationApi"; -import { makeLocationMarkerEventHandlers } from "./EventHandlers"; -import { - Icon, - LatLng, - Point, - Marker as LMarker, - Polyline as LPolyline, - LatLngLiteral, -} from "leaflet"; -import { Symbol as MilSymbol } from "milsymbol"; -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, { - size: 24, - colorMode: "Dark", - }); - - return new Icon({ - iconUrl: symbol.toDataURL(), - iconAnchor: new Point(symbol.getAnchor().x, symbol.getAnchor().y), - }); -} +import { MobileControlPoint } from "./MobileControlPoint"; +import { StaticControlPoint } from "./StaticControlPoint"; interface ControlPointProps { controlPoint: ControlPointModel; } -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: LatLngLiteral, - 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`; -} - -/** - * The primary control point marker. For non-mobile control points, this has - * fairly simple behavior: it's a marker in a fixed location that can manage - * units and can have missions planned against it. - * - * For mobile control points, this is a draggable marker. If the control point - * has a destination (either because it was dragged after render, or because it - * had a destination in the game that was loaded), the unit management and - * mission planning behaviors are delegated to SecondaryMarker, and the primary - * marker becomes only a destination marker. It can be dragged to change the - * destination, and can be right clicked to cancel movement. - */ -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] = useClearControlPointDestinationMutation(); - - useEffect(() => { - marker.current?.setTooltipContent( - props.controlPoint.destination - ? destinationTooltipText( - props.controlPoint, - props.controlPoint.destination, - true - ) - : ReactDOMServer.renderToString( - - ) - ); - }); - - const locationClickHandlers = makeLocationMarkerEventHandlers( - props.controlPoint - ); - - return ( - <> - { - if (ref != null) { - marker.current = ref; - } - }} - eventHandlers={{ - click: () => { - if (!hasDestination) { - locationClickHandlers.click(); - } - }, - contextmenu: () => { - if (props.controlPoint.destination) { - cancelTravel({ cpId: props.controlPoint.id }).then(() => { - resetDestination(); - }); - } else { - locationClickHandlers.contextmenu(); - } - }, - 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 - ); - const destination = event.target.getLatLng(); - setDestination(destination); - try { - await putDestination({ - cpId: props.controlPoint.id, - body: { lat: destination.lat, lng: destination.lng }, - }).unwrap(); - } catch (error) { - console.error("setDestination failed", error); - setDestination(currentPosition); - } - }, - }} - > - - - { - if (ref != null) { - pathLine.current = ref; - } - }} - /> - - ); -} - -interface SecondaryMarkerProps { - controlPoint: ControlPointModel; - destination: LatLngLiteral | undefined; -} - -/** - * The secondary marker for a control point. The secondary marker will only be - * shown when the control point has a destination set. For mobile control - * points, the primary marker is draggable, and the secondary marker will be - * shown at the current location iff the control point has been dragged. The - * secondary marker is also the marker that has the normal control point - * interaction options (mission planning and unit management). - */ -function SecondaryMarker(props: SecondaryMarkerProps) { - if (!props.destination) { - return <>; - } - - return ( - - - - - - ); -} - export default function ControlPoint(props: ControlPointProps) { - return ( - <> - - - - ); + if (props.controlPoint.mobile) { + return ; + } else { + return ; + } } diff --git a/client/src/components/controlpoints/EventHandlers.ts b/client/src/components/controlpoints/EventHandlers.ts new file mode 100644 index 00000000..3a347b9d --- /dev/null +++ b/client/src/components/controlpoints/EventHandlers.ts @@ -0,0 +1,22 @@ +import { ControlPoint } from "../../api/_liberationApi"; +import backend from "../../api/backend"; + +function openInfoDialog(controlPoint: ControlPoint) { + backend.post(`/qt/info/control-point/${controlPoint.id}`); +} + +function openNewPackageDialog(controlPoint: ControlPoint) { + backend.post(`/qt/create-package/control-point/${controlPoint.id}`); +} + +export const makeLocationMarkerEventHandlers = (controlPoint: ControlPoint) => { + return { + click: () => { + openInfoDialog(controlPoint); + }, + + contextmenu: () => { + openNewPackageDialog(controlPoint); + }, + }; +}; diff --git a/client/src/components/controlpoints/Icons.tsx b/client/src/components/controlpoints/Icons.tsx new file mode 100644 index 00000000..48e09041 --- /dev/null +++ b/client/src/components/controlpoints/Icons.tsx @@ -0,0 +1,15 @@ +import { ControlPoint } from "../../api/_liberationApi"; +import { Icon, Point } from "leaflet"; +import { Symbol } from "milsymbol"; + +export const iconForControlPoint = (cp: ControlPoint) => { + const symbol = new Symbol(cp.sidc, { + size: 24, + colorMode: "Dark", + }); + + return new Icon({ + iconUrl: symbol.toDataURL(), + iconAnchor: new Point(symbol.getAnchor().x, symbol.getAnchor().y), + }); +}; diff --git a/client/src/components/controlpoints/LocationTooltipText.tsx b/client/src/components/controlpoints/LocationTooltipText.tsx new file mode 100644 index 00000000..1c8cc301 --- /dev/null +++ b/client/src/components/controlpoints/LocationTooltipText.tsx @@ -0,0 +1,9 @@ +interface LocationTooltipTextProps { + name: string; +} + +export const LocationTooltipText = (props: LocationTooltipTextProps) => { + return

{props.name}

; +}; + +export default LocationTooltipText; diff --git a/client/src/components/controlpoints/MobileControlPoint.tsx b/client/src/components/controlpoints/MobileControlPoint.tsx new file mode 100644 index 00000000..5cd92be1 --- /dev/null +++ b/client/src/components/controlpoints/MobileControlPoint.tsx @@ -0,0 +1,225 @@ +import { ControlPoint } from "../../api/_liberationApi"; +import backend from "../../api/backend"; +import { + useClearControlPointDestinationMutation, + useSetControlPointDestinationMutation, +} from "../../api/liberationApi"; +import { makeLocationMarkerEventHandlers } from "./EventHandlers"; +import { iconForControlPoint } from "./Icons"; +import LocationTooltipText from "./LocationTooltipText"; +import { MovementPath, MovementPathHandle } from "./MovementPath"; +import { StaticControlPoint } from "./StaticControlPoint"; +import { LatLng, Marker as LMarker, LatLngLiteral } from "leaflet"; +import { useCallback, useEffect, useRef, useState } from "react"; +import ReactDOMServer from "react-dom/server"; +import { Marker, Tooltip } from "react-leaflet"; + +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: ControlPoint, + destinationish: LatLngLiteral, + 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`; +} + +interface PrimaryMarkerProps { + controlPoint: ControlPoint; +} + +/** + * The primary control point marker. For non-mobile control points, this has + * fairly simple behavior: it's a marker in a fixed location that can manage + * units and can have missions planned against it. + * + * For mobile control points, this is a draggable marker. If the control point + * has a destination (either because it was dragged after render, or because it + * had a destination in the game that was loaded), the unit management and + * mission planning behaviors are delegated to SecondaryMarker, and the primary + * marker becomes only a destination marker. It can be dragged to change the + * destination, and can be right clicked to cancel movement. + */ +function PrimaryMarker(props: PrimaryMarkerProps) { + // 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 this + // component and all children, interrupting 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 markerRef = useRef(null); + const pathRef = useRef(null); + + const [hasDestination, setHasDestination] = useState( + props.controlPoint.destination != null + ); + const [position, setPosition] = useState( + props.controlPoint.destination + ? props.controlPoint.destination + : props.controlPoint.position + ); + + const setDestination = useCallback((destination: LatLng) => { + setPosition(destination); + setHasDestination(true); + }, []); + + const resetDestination = useCallback(() => { + setPosition(props.controlPoint.position); + setHasDestination(false); + }, [props]); + + const [putDestination, { isLoading }] = + useSetControlPointDestinationMutation(); + const [cancelTravel] = useClearControlPointDestinationMutation(); + + useEffect(() => { + markerRef.current?.setTooltipContent( + props.controlPoint.destination + ? destinationTooltipText( + props.controlPoint, + props.controlPoint.destination, + true + ) + : ReactDOMServer.renderToString( + + ) + ); + }); + + const locationClickHandlers = makeLocationMarkerEventHandlers( + props.controlPoint + ); + + return ( + <> + { + if (ref != null) { + markerRef.current = ref; + } + }} + eventHandlers={{ + click: () => { + if (!hasDestination) { + locationClickHandlers.click(); + } + }, + contextmenu: () => { + if (props.controlPoint.destination) { + cancelTravel({ cpId: props.controlPoint.id }).then(() => { + resetDestination(); + }); + } else { + locationClickHandlers.contextmenu(); + } + }, + 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) => { + markerRef.current?.setTooltipContent( + destinationTooltipText( + props.controlPoint, + destination, + inRange.data + ) + ); + }); + pathRef.current?.setDestination(destination); + }, + dragend: async (event) => { + const currentPosition = new LatLng(position.lat, position.lng); + const destination = event.target.getLatLng(); + setDestination(destination); + try { + await putDestination({ + cpId: props.controlPoint.id, + body: { lat: destination.lat, lng: destination.lng }, + }).unwrap(); + } catch (error) { + console.error("setDestination failed", error); + setDestination(currentPosition); + } + }, + }} + > + + + + + ); +} + +interface SecondaryMarkerProps { + controlPoint: ControlPoint; + destination: LatLngLiteral | undefined; +} + +/** + * The secondary marker for a control point. The secondary marker will only be + * shown when the control point has a destination set. For mobile control + * points, the primary marker is draggable, and the secondary marker will be + * shown at the current location iff the control point has been dragged. The + * secondary marker is also the marker that has the normal control point + * interaction options (mission planning and unit management). + */ +function SecondaryMarker(props: SecondaryMarkerProps) { + if (!props.destination) { + return <>; + } + + return ; +} + +interface MobileControlPointProps { + controlPoint: ControlPoint; +} + +export const MobileControlPoint = (props: MobileControlPointProps) => { + return ( + <> + + + + ); +}; diff --git a/client/src/components/controlpoints/MovementPath.tsx b/client/src/components/controlpoints/MovementPath.tsx new file mode 100644 index 00000000..2b2b5ae0 --- /dev/null +++ b/client/src/components/controlpoints/MovementPath.tsx @@ -0,0 +1,35 @@ +import { LatLngLiteral, Polyline as LPolyline } from "leaflet"; +import { forwardRef, useImperativeHandle, useRef } from "react"; +import { Polyline } from "react-leaflet"; + +interface MovementPathProps { + source: LatLngLiteral; + destination: LatLngLiteral; +} + +export interface MovementPathHandle { + setDestination: (destination: LatLngLiteral) => void; +} + +export const MovementPath = forwardRef( + (props: MovementPathProps, ref) => { + const lineRef = useRef(null); + useImperativeHandle( + ref, + () => ({ + setDestination: (destination: LatLngLiteral) => { + lineRef.current?.setLatLngs([props.source, destination]); + }, + }), + [props] + ); + return ( + + ); + } +); diff --git a/client/src/components/controlpoints/StaticControlPoint.tsx b/client/src/components/controlpoints/StaticControlPoint.tsx new file mode 100644 index 00000000..bdb0c930 --- /dev/null +++ b/client/src/components/controlpoints/StaticControlPoint.tsx @@ -0,0 +1,27 @@ +import { ControlPoint } from "../../api/_liberationApi"; +import { makeLocationMarkerEventHandlers } from "./EventHandlers"; +import { iconForControlPoint } from "./Icons"; +import LocationTooltipText from "./LocationTooltipText"; +import { Marker, Tooltip } from "react-leaflet"; + +interface StaticControlPointProps { + controlPoint: ControlPoint; +} + +export const StaticControlPoint = (props: StaticControlPointProps) => { + return ( + + + + + + ); +};