diff --git a/frontend/react/public/images/others/arrow.png b/frontend/react/public/images/others/arrow.png new file mode 100644 index 00000000..be3b1708 Binary files /dev/null and b/frontend/react/public/images/others/arrow.png differ diff --git a/frontend/react/public/images/others/arrow.svg b/frontend/react/public/images/others/arrow.svg new file mode 100644 index 00000000..8ecc05ae --- /dev/null +++ b/frontend/react/public/images/others/arrow.svg @@ -0,0 +1,39 @@ + + + + + + + diff --git a/frontend/react/public/images/others/arrow_background.png b/frontend/react/public/images/others/arrow_background.png new file mode 100644 index 00000000..3ea94954 Binary files /dev/null and b/frontend/react/public/images/others/arrow_background.png differ diff --git a/frontend/react/src/events.ts b/frontend/react/src/events.ts index 2ceccc57..ae526fc0 100644 --- a/frontend/react/src/events.ts +++ b/frontend/react/src/events.ts @@ -558,6 +558,23 @@ export class SpawnContextMenuRequestEvent { } } +export class SpawnHeadingChangedEvent { + static on(callback: (heading: number) => void, singleShot = false) { + document.addEventListener( + this.name, + (ev: CustomEventInit) => { + callback(ev.detail.heading); + }, + { once: singleShot } + ); + } + + static dispatch(heading: number) { + document.dispatchEvent(new CustomEvent(this.name, { detail: { heading } })); + console.log(`Event ${this.name} dispatched`); + } +} + export class HotgroupsChangedEvent { static on(callback: (hotgroups: { [key: number]: Unit[] }) => void, singleShot = false) { document.addEventListener( diff --git a/frontend/react/src/interfaces.ts b/frontend/react/src/interfaces.ts index 6b190c87..8648c956 100644 --- a/frontend/react/src/interfaces.ts +++ b/frontend/react/src/interfaces.ts @@ -142,6 +142,7 @@ export interface UnitSpawnTable { liveryID: string; altitude?: number; loadout?: string; + heading?: number; } export interface ObjectIconOptions { diff --git a/frontend/react/src/map/map.ts b/frontend/react/src/map/map.ts index b1c2cec7..531d6c46 100644 --- a/frontend/react/src/map/map.ts +++ b/frontend/react/src/map/map.ts @@ -56,6 +56,7 @@ import { SelectionEnabledChangedEvent, SessionDataLoadedEvent, SpawnContextMenuRequestEvent, + SpawnHeadingChangedEvent, StarredSpawnsChangedEvent, UnitDeselectedEvent, UnitSelectedEvent, @@ -144,6 +145,7 @@ export class Map extends L.Map { #temporaryMarkers: TemporaryUnitMarker[] = []; #currentSpawnMarker: TemporaryUnitMarker | null = null; #currentEffectMarker: ExplosionMarker | SmokeMarker | null = null; + #spawnHeading: number = 0; /* JTAC tools */ #ECHOPoint: TextMarker | null = null; @@ -565,6 +567,19 @@ export class Map extends L.Map { this.#currentSpawnMarker = this.addTemporaryMarker(spawnRequestTable.unit.location, spawnRequestTable.unit.unitType, spawnRequestTable.coalition, true); } + getSpawnRequestTable() { + return this.#spawnRequestTable; + } + + setSpawnHeading(heading: number) { + this.#spawnHeading = heading; + SpawnHeadingChangedEvent.dispatch(heading); + } + + getSpawnHeading() { + return this.#spawnHeading; + } + addStarredSpawnRequestTable(key, spawnRequestTable: SpawnRequestTable, quickAccessName: string) { this.#starredSpawnRequestTables[key] = spawnRequestTable; this.#starredSpawnRequestTables[key].quickAccessName = quickAccessName; @@ -833,7 +848,7 @@ export class Map extends L.Map { new L.LatLng(0, 0), this.#spawnRequestTable?.unit.unitType ?? "", this.#spawnRequestTable?.coalition ?? "neutral", - false + true ); this.#currentSpawnMarker.addTo(this); } else if (subState === SpawnSubState.SPAWN_EFFECT) { @@ -923,6 +938,7 @@ export class Map extends L.Map { if (getApp().getSubState() === SpawnSubState.SPAWN_UNIT) { if (this.#spawnRequestTable !== null) { this.#spawnRequestTable.unit.location = e.latlng; + this.#spawnRequestTable.unit.heading = deg2rad(this.#spawnHeading); getApp() .getUnitsManager() .spawnUnits( @@ -937,6 +953,7 @@ export class Map extends L.Map { e.latlng, this.#spawnRequestTable?.unit.unitType ?? "unknown", this.#spawnRequestTable?.coalition ?? "blue", + false, hash ); } diff --git a/frontend/react/src/map/markers/stylesheets/units.css b/frontend/react/src/map/markers/stylesheets/units.css index 93b8a5d6..13d326d9 100644 --- a/frontend/react/src/map/markers/stylesheets/units.css +++ b/frontend/react/src/map/markers/stylesheets/units.css @@ -421,7 +421,7 @@ color: var(--secondary-blue-text); } -[data-object|="unit"][data-coalition="blue"][data-is-selected] path { +[data-object|="unit"][data-coalition="blue"][data-is-selected] path:nth-child(1) { fill: var(--secondary-blue-text); } @@ -577,10 +577,41 @@ display: block; } -.ol-temporary-marker { +.ol-temporary-marker .unit-icon { opacity: 0.5; } +.ol-temporary-marker .unit-short-label { + opacity: 0.5; +} + +.ol-temporary-marker .heading-handle { + width: 30px; + height: 30px; + position: absolute; + top: -40px; + left: 50%; + transform: translateX(-50%); + cursor: move; + pointer-events: all; +} + +.ol-temporary-marker .heading-handle svg { + position: absolute; + width: 100%; +} + +.ol-temporary-marker [data-coalition="blue"] .heading-handle svg { + fill: var(--unit-background-blue); +} + +.ol-temporary-marker [data-coalition="red"] .heading-handle svg { + fill: var(--unit-background-red); +} + +.ol-temporary-marker [data-coalition="neutral"] .heading-handle svg { + fill: var(--unit-background-neutral); +} .unit-bullseye, .unit-braa { width: 50%; diff --git a/frontend/react/src/map/markers/temporaryunitmarker.ts b/frontend/react/src/map/markers/temporaryunitmarker.ts index c809aea0..87ad7703 100644 --- a/frontend/react/src/map/markers/temporaryunitmarker.ts +++ b/frontend/react/src/map/markers/temporaryunitmarker.ts @@ -3,6 +3,8 @@ import { DivIcon, LatLng } from "leaflet"; import { SVGInjector } from "@tanem/svg-injector"; import { getApp } from "../../olympusapp"; import { UnitBlueprint } from "../../interfaces"; +import { deg2rad, normalizeAngle, rad2deg } from "../../other/utils"; +import { SpawnHeadingChangedEvent } from "../../events"; export class TemporaryUnitMarker extends CustomMarker { #name: string; @@ -72,6 +74,50 @@ export class TemporaryUnitMarker extends CustomMarker { el.append(shortLabel); } + // Heading handle + if (this.#headingHandle) { + var handle = document.createElement("div"); + var handleImg = document.createElement("img"); + handleImg.src = "/images/others/arrow.svg"; + handleImg.onload = () => SVGInjector(handleImg); + handle.classList.add("heading-handle"); + el.append(handle); + + handle.append(handleImg); + + const rotateHandle = (heading) => { + el.style.transform = `rotate(${heading}deg)`; + unitIcon.style.transform = `rotate(-${heading}deg)`; + shortLabel.style.transform = `rotate(-${heading}deg)`; + }; + + SpawnHeadingChangedEvent.on((heading) => rotateHandle(heading)); + rotateHandle(getApp().getMap().getSpawnHeading()); + + // Add drag and rotate functionality + handle.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + + const onMouseMove = (e) => { + const rect = el.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + let angle = rad2deg(Math.atan2(e.clientY - centerY, e.clientX - centerX)) + 90; + angle = normalizeAngle(angle); + getApp().getMap().setSpawnHeading(angle); + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }); + } + this.getElement()?.appendChild(el); this.getElement()?.classList.add("ol-temporary-marker"); } diff --git a/frontend/react/src/other/utils.ts b/frontend/react/src/other/utils.ts index 2c721da6..58a680ce 100644 --- a/frontend/react/src/other/utils.ts +++ b/frontend/react/src/other/utils.ts @@ -600,4 +600,18 @@ export function computeBrightness(color) { let brightness = 0.299 * r + 0.587 * g + 0.114 * b; return brightness; +} + +/** + * Normalizes an angle to be within the range of 0 to 360 degrees. + * @param {number} angle - The angle to normalize. + * @returns {number} - The normalized angle. + */ +export function normalizeAngle(angle: number): number { + // Ensure the angle is within the range of 0 to 360 degrees + angle = angle % 360; + if (angle < 0) { + angle += 360; + } + return angle; } \ No newline at end of file diff --git a/frontend/react/src/ui/contextmenus/spawncontextmenu.tsx b/frontend/react/src/ui/contextmenus/spawncontextmenu.tsx index 089727db..05245da0 100644 --- a/frontend/react/src/ui/contextmenus/spawncontextmenu.tsx +++ b/frontend/react/src/ui/contextmenus/spawncontextmenu.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { BLUE_COMMANDER, colors, COMMAND_MODE_OPTIONS_DEFAULTS, GAME_MASTER, NO_SUBSTATE, OlympusState, OlympusSubState } from "../../constants/constants"; import { LatLng } from "leaflet"; import { @@ -10,7 +10,7 @@ import { } from "../../events"; import { getApp } from "../../olympusapp"; import { SpawnRequestTable, UnitBlueprint } from "../../interfaces"; -import { faArrowLeft, faEllipsisVertical, faExplosion, faListDots, faSearch, faSmog, faStar } from "@fortawesome/free-solid-svg-icons"; +import { faEllipsisVertical, faExplosion, faSearch, faSmog, faStar } from "@fortawesome/free-solid-svg-icons"; import { EffectSpawnMenu } from "../panels/effectspawnmenu"; import { UnitSpawnMenu } from "../panels/unitspawnmenu"; import { OlEffectListEntry } from "../components/oleffectlistentry"; @@ -28,6 +28,7 @@ import { OlDropdownItem } from "../components/oldropdown"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { OlCoalitionToggle } from "../components/olcoalitiontoggle"; import { Coalition } from "../../types/types"; +import { spawn } from "child_process"; enum CategoryGroup { NONE, @@ -62,6 +63,7 @@ export function SpawnContextMenu(props: {}) { const [spawnCoalition, setSpawnCoalition] = useState("blue" as Coalition); const [showMore, setShowMore] = useState(false); const [height, setHeight] = useState(0); + const [translated, setTranslated] = useState(false); useEffect(() => { if (selectedRole) setBlueprints(getApp()?.getUnitsManager().getDatabase().getByRole(selectedRole)); @@ -110,6 +112,19 @@ export function SpawnContextMenu(props: {}) { setSelectedRole(null); }, [openAccordion]); + const translateMenu = useCallback(() => { + if (blueprint && !translated) { + setTranslated(true); + setXPosition(xPosition + 60); + setYPosition(yPosition + 40); + } else if (!blueprint && translated) { + setTranslated(false); + setXPosition(xPosition - 60); + setYPosition(yPosition - 40); + } + }, [blueprint, translated]) + useEffect(translateMenu, [blueprint, translated]) + /* Filter the blueprints according to the label */ const filteredBlueprints: UnitBlueprint[] = []; if (blueprints && filterString !== "") { @@ -131,6 +146,7 @@ export function SpawnContextMenu(props: {}) { const containerPoint = getApp().getMap().latLngToContainerPoint(latlng); setXPosition(getApp().getMap().getContainer().offsetLeft + containerPoint.x); setYPosition(getApp().getMap().getContainer().offsetTop + containerPoint.y); + setTranslated(false); }); }, []); diff --git a/frontend/react/src/ui/panels/audiomenu.tsx b/frontend/react/src/ui/panels/audiomenu.tsx index e9f37c71..7adeb529 100644 --- a/frontend/react/src/ui/panels/audiomenu.tsx +++ b/frontend/react/src/ui/panels/audiomenu.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from "react"; import { Menu } from "./components/menu"; import { getApp } from "../../olympusapp"; -import { FaPlus, FaPlusCircle, FaQuestionCircle } from "react-icons/fa"; +import { FaPlus, FaQuestionCircle } from "react-icons/fa"; import { AudioSourcePanel } from "./components/sourcepanel"; import { AudioSource } from "../../audio/audiosource"; import { RadioSinkPanel } from "./components/radiosinkpanel"; diff --git a/frontend/react/src/ui/panels/unitspawnmenu.tsx b/frontend/react/src/ui/panels/unitspawnmenu.tsx index 6aeb67b0..55226c76 100644 --- a/frontend/react/src/ui/panels/unitspawnmenu.tsx +++ b/frontend/react/src/ui/panels/unitspawnmenu.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { OlUnitSummary } from "../components/olunitsummary"; import { OlCoalitionToggle } from "../components/olcoalitiontoggle"; import { OlNumberInput } from "../components/olnumberinput"; @@ -9,7 +9,7 @@ import { LoadoutBlueprint, SpawnRequestTable, UnitBlueprint } from "../../interf import { OlStateButton } from "../components/olstatebutton"; import { Coalition } from "../../types/types"; import { getApp } from "../../olympusapp"; -import { deepCopyTable, ftToM, hash, mode } from "../../other/utils"; +import { deepCopyTable, deg2rad, ftToM, hash, mode, normalizeAngle } from "../../other/utils"; import { LatLng } from "leaflet"; import { Airbase } from "../../mission/airbase"; import { altitudeIncrements, groupUnitCount, maxAltitudeValues, minAltitudeValues, OlympusState, SpawnSubState } from "../../constants/constants"; @@ -17,8 +17,9 @@ import { faArrowLeft, faStar } from "@fortawesome/free-solid-svg-icons"; import { OlStringInput } from "../components/olstringinput"; import { countryCodes } from "../data/codes"; import { OlAccordion } from "../components/olaccordion"; -import { AppStateChangedEvent } from "../../events"; +import { AppStateChangedEvent, SpawnHeadingChangedEvent } from "../../events"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { FaQuestionCircle } from "react-icons/fa"; export function UnitSpawnMenu(props: { visible: boolean; @@ -123,6 +124,46 @@ export function UnitSpawnMenu(props: { if (props.coalition) setSpawnCoalition(props.coalition); }, [props.coalition]); + /* Heading compass */ + const [compassAngle, setCompassAngle] = useState(0); + const compassRef = useRef(null); + + const updateSpawnRequestTableHeading = useCallback(() => { + getApp()?.getMap().setSpawnHeading(compassAngle); + }, [compassAngle]); + useEffect(updateSpawnRequestTableHeading, [compassAngle]); + + useEffect(() => { + SpawnHeadingChangedEvent.on((heading) => { + setCompassAngle(heading); + }); + }, []); + + useEffect(() => { + setCompassAngle(getApp()?.getMap().getSpawnHeading() ?? 0); + }, [appState]); + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + const onMouseMove = (e: MouseEvent) => { + if (compassRef.current) { + const rect = compassRef.current.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const angle = Math.atan2(e.clientY - centerY, e.clientX - centerX) * (180 / Math.PI); + setCompassAngle(Math.round(normalizeAngle(angle + 90))); + } + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }; + /* Get a list of all the roles */ const roles: string[] = []; props.blueprint?.loadouts?.forEach((loadout) => { @@ -198,9 +239,11 @@ export function UnitSpawnMenu(props: { /> { - if (spawnRequestTable) + if (spawnRequestTable) { + spawnRequestTable.unit.heading = compassAngle; if (key in props.starredSpawns) getApp().getMap().removeStarredSpawnRequestTable(key); else getApp().getMap().addStarredSpawnRequestTable(key, spawnRequestTable, quickAccessName); + } }} tooltip="Save this spawn for quick access" checked={key in props.starredSpawns} @@ -355,10 +398,9 @@ export function UnitSpawnMenu(props: { `} > {props.blueprint?.liveries && props.blueprint?.liveries[id].countries.length == 1 && ( - + )}
@@ -410,6 +452,48 @@ export function UnitSpawnMenu(props: {
+
+
+ Spawn heading +
+
Drag to change
+
+
+ + { + setCompassAngle(Number(ev.target.value)); + }} + onDecrease={() => { + setCompassAngle(normalizeAngle(compassAngle - 1)); + }} + onIncrease={() => { + setCompassAngle(normalizeAngle(compassAngle + 1)); + }} + value={compassAngle} + /> + +
+ + +
+
{ - if (spawnRequestTable) + if (spawnRequestTable){ + spawnRequestTable.unit.heading = deg2rad(compassAngle); getApp() .getUnitsManager() .spawnUnits( @@ -477,6 +562,7 @@ export function UnitSpawnMenu(props: { false, props.airbase?.getName() ?? undefined ); + } getApp().setState(OlympusState.IDLE); }} @@ -694,9 +780,10 @@ export function UnitSpawnMenu(props: { `} > {props.blueprint?.liveries && props.blueprint?.liveries[id].countries.length == 1 && ( - + )}
@@ -748,6 +835,48 @@ export function UnitSpawnMenu(props: { })}
+
+
+ Spawn heading +
+
Drag to change
+
+
+ + { + setCompassAngle(Number(ev.target.value)); + }} + onDecrease={() => { + setCompassAngle(normalizeAngle(compassAngle - 1)); + }} + onIncrease={() => { + setCompassAngle(normalizeAngle(compassAngle + 1)); + }} + value={compassAngle} + /> + +
+ + +
+
{spawnLoadout && spawnLoadout.items.length > 0 && (
{ - if (spawnRequestTable) + if (spawnRequestTable) { getApp() .getUnitsManager() .spawnUnits( @@ -812,6 +941,7 @@ export function UnitSpawnMenu(props: { false, props.airbase?.getName() ); + } }} > Spawn