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 (
+
+
+
+
+
+ );
+};