diff --git a/frontend/react/package.json b/frontend/react/package.json index 6aa7d7dd..1f20b338 100644 --- a/frontend/react/package.json +++ b/frontend/react/package.json @@ -36,6 +36,7 @@ }, "devDependencies": { "@eslint/js": "^9.6.0", + "@turf/clusters": "^7.1.0", "@types/node": "^22.5.1", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", diff --git a/frontend/react/src/constants/constants.ts b/frontend/react/src/constants/constants.ts index 0ddd46e1..81649e71 100644 --- a/frontend/react/src/constants/constants.ts +++ b/frontend/react/src/constants/constants.ts @@ -1,5 +1,5 @@ import { LatLng, LatLngBounds } from "leaflet"; -import { MapOptions } from "../types/types"; +import { Coalition, MapOptions } from "../types/types"; import { CommandModeOptions } from "../interfaces"; import { ContextAction } from "../unit/contextaction"; import { @@ -30,6 +30,8 @@ export const SELECT_TOLERANCE_PX = 5; export const SHORT_PRESS_MILLISECONDS = 200; export const DEBOUNCE_MILLISECONDS = 200; +export const TRAIL_LENGTH = 10; + export const UNITS_URI = "units"; export const WEAPONS_URI = "weapons"; export const LOGS_URI = "logs"; @@ -349,9 +351,8 @@ export const MAP_OPTIONS_DEFAULTS: MapOptions = { cameraPluginEnabled: false, cameraPluginMode: "map", tabletMode: false, - showUnitBullseyes: false, - showUnitBRAA: false, - AWACSMode: false + AWACSMode: false, + AWACSCoalition: "blue" }; export const MAP_HIDDEN_TYPES_DEFAULTS = { diff --git a/frontend/react/src/interfaces.ts b/frontend/react/src/interfaces.ts index 156122e2..34758ceb 100644 --- a/frontend/react/src/interfaces.ts +++ b/frontend/react/src/interfaces.ts @@ -130,8 +130,6 @@ export interface ObjectIconOptions { showSummary: boolean; showCallsign: boolean; rotateToHeading: boolean; - showBullseyes: boolean; - showBRAA: boolean; } export interface GeneralSettings { diff --git a/frontend/react/src/map/map.ts b/frontend/react/src/map/map.ts index 130fb92c..80f58d9c 100644 --- a/frontend/react/src/map/map.ts +++ b/frontend/react/src/map/map.ts @@ -228,8 +228,6 @@ export class Map extends L.Map { MapOptionsChangedEvent.on((options: MapOptions) => { this.getContainer().toggleAttribute("data-awacs-mode", options.AWACSMode); this.getContainer().toggleAttribute("data-hide-labels", !options.showUnitLabels); - this.getContainer().toggleAttribute("data-hide-bullseyes", !options.showUnitBullseyes); - this.getContainer().toggleAttribute("data-hide-BRAA", !options.showUnitBRAA); this.#cameraControlPort = options.cameraPluginPort; this.#cameraZoomRatio = 50 / (20 + options.cameraPluginRatio); this.#slaveDCSCamera = options.cameraPluginEnabled; @@ -242,6 +240,8 @@ export class Map extends L.Map { }, 500); // DCS does not always apply the altitude correctly at the first set when changing map type } + if (options.AWACSMode && this.#layerName !== "AWACS") this.setLayerName("AWACS"); + this.updateMinimap(); }); diff --git a/frontend/react/src/map/markers/stylesheets/units.css b/frontend/react/src/map/markers/stylesheets/units.css index bb27d037..ee85b6de 100644 --- a/frontend/react/src/map/markers/stylesheets/units.css +++ b/frontend/react/src/map/markers/stylesheets/units.css @@ -1,6 +1,5 @@ /*** Unit marker elements ***/ [data-object|="unit"] { - align-items: center; cursor: pointer; display: flex; height: 100%; @@ -93,17 +92,6 @@ display: none; } -[data-awacs-mode] [data-is-selected] { - animation: blinker 0.5s linear infinite; -} - -@keyframes blinker { - 50% { - opacity: 30%; - } -} - - /*** Basic colours ***/ [data-coalition="blue"] .unit-icon svg > *:first-child { fill: var(--unit-background-blue); @@ -140,6 +128,22 @@ stroke: var(--unit-background-neutral) !important; } +[data-awacs-mode] [data-is-selected] .unit-icon svg { + stroke: #FF0 !important; +} + +[data-awacs-mode] [data-is-selected] .unit-vvi { + background-color: #FF0 !important; +} + +[data-awacs-mode] [data-is-selected] .unit-summary { + color: #FF0 !important; +} + +[data-awacs-mode] [data-is-selected] .unit-summary::after { + background-color: #FF0 !important; +} + /*** Cursors ***/ [data-is-dead], [data-object|="unit-missile"], @@ -155,6 +159,7 @@ line-height: normal; position: absolute; font-size: 12px; + translate: 0px 18px; } [data-object|="unit-groundunit"] .unit-short-label { @@ -169,7 +174,7 @@ display: none; height: var(--unit-health-height); position: absolute; - translate: var(--unit-health-x) var(--unit-health-y); + translate: var(--unit-health-x) calc(25px + var(--unit-health-y)); width: var(--unit-health-width); } @@ -181,7 +186,7 @@ display: none; height: var(--unit-fuel-height); position: absolute; - translate: var(--unit-fuel-x) var(--unit-fuel-y); + translate: var(--unit-fuel-x) calc(25px + var(--unit-fuel-y)); width: var(--unit-fuel-width); } @@ -198,7 +203,7 @@ display: none; height: fit-content; position: absolute; - translate: var(--unit-ammo-x) var(--unit-ammo-y); + translate: var(--unit-ammo-x) calc(25px + var(--unit-ammo-y)); width: fit-content; } @@ -217,7 +222,7 @@ flex-wrap: wrap; font-size: 11px; font-weight: bold; - justify-content: right; + justify-content: start; line-height: 12px; pointer-events: none; position: absolute; @@ -227,24 +232,89 @@ 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000; - right: 120%; - width: fit-content; + width: 100px; + translate: 80px 10px; } -[data-awacs-mode] [data-object|="unit"] .unit-summary { - top: -40px; +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-north { + translate: 50px -45px; } -[data-awacs-mode] .unit-summary::before { +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-north-east { + translate: 76px -32px; +} + +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-east { + translate: 95px 7px; +} + +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-south-east { + translate: 79px 50px; +} + +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-south { + translate: 50px 63px; +} + +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-south-west { + translate: -68px 50px; +} + +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-west { + translate: -80px 7px; +} + +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-north-west { + translate: -69px -35px; +} + +[data-awacs-mode] .unit-summary::after { content: " "; background-color: white; width: 40px; height: 1px; - transform: rotate(45deg); - left: 40px; - top: 50px; - position: relative; + position: absolute; z-index: -1; + transform-origin: 0% 0%; + top: 30px +} + +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-north::after { + transform: rotate(90deg); +} + +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-north-east::after { + transform: rotate(135deg); + translate: 2px; +} + +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-east::after { + transform: rotate(180deg); + translate: -5px -12px; +} + +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-south-east::after { + transform: rotate(225deg); + translate: -2px -28px; +} + +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-south::after { + transform: rotate(270deg); + translate: -0px -28px; +} + +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-south-west::after { + transform: rotate(315deg); + translate: 90px -28px; +} + +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-west::after { + translate: 90px -12px; +} + +[data-awacs-mode] [data-object|="unit"] .unit-summary.cluster-north-west::after { + transform: rotate(45deg); + translate: 90px; } [data-hide-labels] [data-object|="unit"] .unit-summary { @@ -262,10 +332,11 @@ transform-origin: right; white-space: nowrap; width: 80px; + text-overflow: ellipsis; } [data-object|="unit"]:hover .unit-summary .unit-callsign { - direction: rtl; + direction: ltr; overflow: visible; } @@ -470,58 +541,13 @@ opacity: 0.5; } -/*** Unit summary ***/ -[data-object|="unit"] .unit-tactical { - color: white; - column-gap: 6px; - display: flex; - flex-wrap: wrap; - font-size: 11px; - font-weight: bold; - justify-content: left; - line-height: 12px; - pointer-events: none; - position: absolute; - row-gap: 1px; - - right: 120%; - top: -7px; - width: fit-content; -} - -[data-object|="unit"] .unit-bullseyes { - display: flex; - line-height: 12px; - flex-wrap: wrap; - row-gap: 2px; -} - -[data-hide-labels] [data-object|="unit"] .unit-tactical { +.unit-bullseye, .unit-braa { display: none; } -[data-hide-bullseyes] [data-object|="unit"] .unit-bullseyes { - display: none; -} - -[data-hide-BRAA] [data-object|="unit"] .unit-braa { - display: none; -} - -[data-object|="unit"] .unit-bullseyes { - text-shadow: - -1px -1px 0 #000, - 1px -1px 0 #000, - -1px 1px 0 #000, - 1px 1px 0 #000; -} - -[data-object|="unit"] .unit-braa { - text-shadow: - -1px -1px 0 #000, - 1px -1px 0 #000, - -1px 1px 0 #000, - 1px 1px 0 #000; +[data-awacs-mode] .unit-bullseye, +[data-awacs-mode] .unit-braa { + display: inline; } [data-awacs-mode] [data-object|="unit"] .unit-selected-spotlight, diff --git a/frontend/react/src/map/markers/temporaryunitmarker.ts b/frontend/react/src/map/markers/temporaryunitmarker.ts index e119ab15..439aec25 100644 --- a/frontend/react/src/map/markers/temporaryunitmarker.ts +++ b/frontend/react/src/map/markers/temporaryunitmarker.ts @@ -56,8 +56,7 @@ export class TemporaryUnitMarker extends CustomMarker { var unitIcon = document.createElement("div"); unitIcon.classList.add("unit-icon"); var img = document.createElement("img"); - - img.src = `/vite/images/units/${blueprint.markerFile ?? blueprint.category}.svg`; + img.src = `/vite/images/units/map/${getApp().getMap().getOptions().AWACSMode ? "awacs" : "normal"}/${this.#coalition}/${blueprint.markerFile ?? blueprint.category}.svg`; img.onload = () => SVGInjector(img); unitIcon.appendChild(img); unitIcon.toggleAttribute("data-rotate-to-heading", false); diff --git a/frontend/react/src/server/servermanager.ts b/frontend/react/src/server/servermanager.ts index 77bfc40e..18fa6122 100644 --- a/frontend/react/src/server/servermanager.ts +++ b/frontend/react/src/server/servermanager.ts @@ -14,7 +14,7 @@ import { reactionsToThreat, } from "../constants/constants"; import { AirbasesData, BullseyesData, CommandModeOptions, GeneralSettings, MissionData, Radio, ServerRequestOptions, ServerStatus, TACAN } from "../interfaces"; -import { ServerStatusUpdatedEvent } from "../events"; +import { MapOptionsChangedEvent, ServerStatusUpdatedEvent } from "../events"; export class ServerManager { #connected: boolean = false; @@ -28,6 +28,7 @@ export class ServerManager { #serverIsPaused: boolean = false; #intervals: number[] = []; #requests: { [key: string]: XMLHttpRequest } = {}; + #updateMode = "normal"; // normal or awacs constructor() { this.#lastUpdateTimes[UNITS_URI] = Date.now(); @@ -44,6 +45,16 @@ export class ServerManager { }, code: "Enter" }) + + MapOptionsChangedEvent.on((mapOptions) => { + if (this.#updateMode === "normal" && mapOptions.AWACSMode) { + this.#updateMode = "awacs"; + this.startUpdate(); + } else if (this.#updateMode === "awacs" && !mapOptions.AWACSMode) { + this.#updateMode = "normal"; + this.startUpdate(); + } + }) } setUsername(newUsername: string) { @@ -566,7 +577,7 @@ export class ServerManager { return time; }, false); } - }, 250) + }, this.#updateMode === "normal"? 250: 2000) ); this.#intervals.push( @@ -577,7 +588,7 @@ export class ServerManager { return time; }, false); } - }, 250) + }, this.#updateMode === "normal"? 250: 2000) ); this.#intervals.push( @@ -590,7 +601,7 @@ export class ServerManager { }, true); } }, - this.getServerIsPaused() ? 500 : 5000 + 5000 ) ); diff --git a/frontend/react/src/types/types.ts b/frontend/react/src/types/types.ts index cbf03f81..3dad636f 100644 --- a/frontend/react/src/types/types.ts +++ b/frontend/react/src/types/types.ts @@ -26,9 +26,8 @@ export type MapOptions = { cameraPluginEnabled: boolean; cameraPluginMode: string; tabletMode: boolean; - showUnitBullseyes: boolean; - showUnitBRAA: boolean; AWACSMode: boolean; + AWACSCoalition: Coalition; }; export type MapHiddenTypes = { diff --git a/frontend/react/src/ui/panels/awacsmenu.tsx b/frontend/react/src/ui/panels/awacsmenu.tsx index 4d1dbe83..08471a34 100644 --- a/frontend/react/src/ui/panels/awacsmenu.tsx +++ b/frontend/react/src/ui/panels/awacsmenu.tsx @@ -7,43 +7,67 @@ import { BullseyesDataChanged, HotgroupsChangedEvent, MapOptionsChangedEvent, + UnitUpdatedEvent, } from "../../events"; import { getApp } from "../../olympusapp"; import { OlCoalitionToggle } from "../components/olcoalitiontoggle"; -import { Coalition } from "../../types/types"; import { FaQuestionCircle } from "react-icons/fa"; import { Unit } from "../../unit/unit"; import { Bullseye } from "../../mission/bullseye"; -import { coalitionToEnum, computeBearingRangeString, mToFt, rad2deg } from "../../other/utils"; +import { bearing, coalitionToEnum, computeBearingRangeString, mToFt, rad2deg } from "../../other/utils"; -const trackStrings = ["North", "North-East", "East", "South-East", "South", "South-West", "West", "North-West"] +const trackStrings = ["North", "North-East", "East", "South-East", "South", "South-West", "West", "North-West", "North"] +const relTrackStrings = ["hot", "flank right", "beam right", "cold", "cold", "cold", "beam left", "flank left", "hot"] export function AWACSMenu(props: { open: boolean; onClose: () => void; children?: JSX.Element | JSX.Element[] }) { const [callsign, setCallsign] = useState("Magic"); const [mapOptions, setMapOptions] = useState(MAP_OPTIONS_DEFAULTS); - const [coalition, setCoalition] = useState("blue" as Coalition); const [hotgroups, setHotgroups] = useState({} as { [key: number]: Unit[] }); const [referenceUnit, setReferenceUnit] = useState(null as Unit | null); const [bullseyes, setBullseyes] = useState(null as null | { [name: string]: Bullseye }); + const [refreshTime, setRefreshTime] = useState(0); useEffect(() => { MapOptionsChangedEvent.on((mapOptions) => setMapOptions({ ...mapOptions })); HotgroupsChangedEvent.on((hotgroups) => setHotgroups({ ...hotgroups })); - AWACSReferenceUnitChangedEvent.on((unit) => setReferenceUnit(unit)); + AWACSReferenceUnitChangedEvent.on((unit) => setReferenceUnit(unit)); BullseyesDataChanged.on((bullseyes) => setBullseyes(bullseyes)); + UnitUpdatedEvent.on((unit) => setRefreshTime(Date.now())); }, []); - const activeGroups = Object.values(hotgroups).filter((hotgroup) => { - return hotgroup.every((unit) => unit.getCoalition() !== coalition); - }); + const activeGroups = Object.values(getApp()?.getUnitsManager().computeClusters((unit) => unit.getCoalition() !== mapOptions.AWACSCoalition, 6) ?? {}); + + /*Object.values(hotgroups).filter((hotgroup) => { + return hotgroup.every((unit) => unit.getCoalition() !== mapOptions.AWACSCoalition); + });*/ let readout: string[] = []; if (bullseyes) { if (referenceUnit) { - readout.push(`$`); + readout.push(`${callsign}, ${activeGroups.length} group${activeGroups.length > 1 ? "s": ""}`); + readout.push( + ...activeGroups.map((group, idx) => { + let order = "th"; + if (idx == 0) order = "st"; + else if (idx == 1) order = "nd"; + else if (idx == 2) order = "rd"; + + let trackDegs = bearing(group[0].getPosition().lat, group[0].getPosition().lng, referenceUnit.getPosition().lat, referenceUnit.getPosition().lng) - rad2deg(group[0].getTrack()) + if (trackDegs < 0) trackDegs += 360 + if (trackDegs > 360) trackDegs -= 360 + let trackIndex = Math.round(trackDegs / 45) + + let groupLine = `${activeGroups.length > 1? (idx + 1 + "" + order + " group"): "Single group"} bullseye ${computeBearingRangeString(bullseyes[coalitionToEnum(mapOptions.AWACSCoalition)].getLatLng(), group[0].getPosition()).replace("/", " ")}, ${ (mToFt(group[0].getPosition().alt ?? 0) / 1000).toFixed()} thousand, ${relTrackStrings[trackIndex]}`; + + if (group.find((unit) => unit.getCoalition() === "neutral")) groupLine += ", bogey" + else groupLine += ", hostile" + + return groupLine; + }) + ); } else { - readout.push(`${callsign}, ${activeGroups.length} group${activeGroups.length > 1 && "s"}`); + readout.push(`${callsign}, ${activeGroups.length} group${activeGroups.length > 1 ? "s": ""}`); readout.push( ...activeGroups.map((group, idx) => { let order = "th"; @@ -55,15 +79,17 @@ export function AWACSMenu(props: { open: boolean; onClose: () => void; children? if (trackDegs < 0) trackDegs += 360 let trackIndex = Math.round(trackDegs / 45) - let groupLine = `${idx + 1}${order} group bullseye ${computeBearingRangeString(bullseyes[coalitionToEnum(coalition)].getLatLng(), group[0].getPosition()).replace("/", " ")}, ${ (mToFt(group[0].getPosition().alt ?? 0) / 1000).toFixed()} thousand, track ${trackStrings[trackIndex]}`; + let groupLine = `${activeGroups.length > 1? (idx + 1 + "" + order + " group"): "Single group"} bullseye ${computeBearingRangeString(bullseyes[coalitionToEnum(mapOptions.AWACSCoalition)].getLatLng(), group[0].getPosition()).replace("/", " ")}, ${ (mToFt(group[0].getPosition().alt ?? 0) / 1000).toFixed()} thousand, track ${trackStrings[trackIndex]}`; + + if (group.find((unit) => unit.getCoalition() === "neutral")) groupLine += ", bogey" + else groupLine += ", hostile" + return groupLine; }) ); - } } - return (