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 (
void; children? <>

1 Use the coalition toggle to change your coalition as AWACS.

-

2 Set enemy unit hotgroups to automatically create picture calls to read on radio for your CAP.

-

3 Optionally, set a friendly unit as reference by right clicking on it and selecting "Set AWACS reference" to create tactical calls.

+

2 Optionally, set a friendly unit as reference by right clicking on it and selecting "Set AWACS reference" to create tactical calls.

@@ -101,11 +124,11 @@ export function AWACSMenu(props: { open: boolean; onClose: () => void; children? > { - coalition === "blue" && setCoalition("neutral"); - coalition === "neutral" && setCoalition("red"); - coalition === "red" && setCoalition("blue"); + mapOptions.AWACSCoalition === "blue" && getApp().getMap().setOption("AWACSCoalition", "neutral"); + mapOptions.AWACSCoalition === "neutral" && getApp().getMap().setOption("AWACSCoalition", "red"); + mapOptions.AWACSCoalition === "red" && getApp().getMap().setOption("AWACSCoalition","blue"); }} - coalition={coalition} + coalition={mapOptions.AWACSCoalition} />
@@ -118,24 +141,6 @@ export function AWACSMenu(props: { open: boolean; onClose: () => void; children? />{" "} Enable AWACS map mode
-
- { - getApp().getMap().setOption("showUnitBullseyes", !mapOptions.showUnitBullseyes); - }} - toggled={mapOptions.showUnitBullseyes} - />{" "} - Show units Bullseye position -
-
- { - getApp().getMap().setOption("showUnitBRAA", !mapOptions.showUnitBRAA); - }} - toggled={mapOptions.showUnitBRAA} - /> - Show units BRAA from reference unit -
{activeGroups.length == 0 ? ( diff --git a/frontend/react/src/unit/unit.ts b/frontend/react/src/unit/unit.ts index 3def0663..767ee08c 100644 --- a/frontend/react/src/unit/unit.ts +++ b/frontend/react/src/unit/unit.ts @@ -42,6 +42,7 @@ import { ContextActions, ContextActionTarget, SHORT_PRESS_MILLISECONDS, + TRAIL_LENGTH, } from "../constants/constants"; import { DataExtractor } from "../server/dataextractor"; import { Weapon } from "../weapon/weapon"; @@ -62,6 +63,8 @@ import { UnitUpdatedEvent, } from "../events"; +const bearingStrings = ["north", "north-east", "east", "south-east", "south", "south-west", "west", "north-west", "north"]; + var pathIcon = new Icon({ iconUrl: "/vite/images/markers/marker-icon.png", shadowUrl: "/vite/images/markers/marker-shadow.png", @@ -157,6 +160,8 @@ export abstract class Unit extends CustomMarker { #targetPositionPolyline: Polyline; #hotgroup: number | null = null; #detectionMethods: number[] = []; + #trailPositions: LatLng[] = []; + #trailPolylines: Polyline[] = []; /* Inputs timers */ #debounceTimeout: number | null = null; @@ -466,6 +471,8 @@ export abstract class Unit extends CustomMarker { this.#hasTask = dataExtractor.extractBool(); break; case DataIndexes.position: + this.#trailPositions.unshift(this.#position); + this.#trailPositions.splice(TRAIL_LENGTH); this.#position = dataExtractor.extractLatLng(); updateMarker = true; break; @@ -902,7 +909,7 @@ export abstract class Unit extends CustomMarker { if (this.belongsToCommandedCoalition() || this.getDetectionMethods().some((value) => [VISUAL, OPTIC].includes(value))) marker = this.getBlueprint()?.markerFile ?? this.getDefaultMarker(); else marker = "aircraft"; - img.src = `/vite/images/units/map/${getApp().getMap().getOptions().AWACSMode? 'awacs': 'normal'}/${this.getCoalition()}/${marker}.svg`; + img.src = `/vite/images/units/map/${getApp().getMap().getOptions().AWACSMode ? "awacs" : "normal"}/${this.getCoalition()}/${marker}.svg`; img.onload = () => SVGInjector(img); unitIcon.appendChild(img); @@ -968,28 +975,15 @@ export abstract class Unit extends CustomMarker { summary.appendChild(altitude); summary.appendChild(speed); el.appendChild(summary); - } - var tactical = document.createElement("div"); - tactical.classList.add("unit-tactical"); - if (iconOptions.showBullseyes) { - var bullseyes = document.createElement("div"); - bullseyes.classList.add("unit-bullseyes"); - var blueBullseye = document.createElement("div"); - blueBullseye.classList.add("unit-blue-bullseye"); - var redBullseye = document.createElement("div"); - redBullseye.classList.add("unit-red-bullseye"); - bullseyes.appendChild(blueBullseye); - bullseyes.appendChild(redBullseye); - tactical.appendChild(bullseyes); - } + var bullseye = document.createElement("div"); + bullseye.classList.add("unit-bullseye"); + summary.appendChild(bullseye); - if (iconOptions.showBRAA) { var BRAA = document.createElement("div"); BRAA.classList.add("unit-braa"); - tactical.appendChild(BRAA); + summary.appendChild(BRAA); } - el.appendChild(tactical); this.getElement()?.appendChild(el); } @@ -1337,7 +1331,10 @@ export abstract class Unit extends CustomMarker { this.#isLeftMouseDown = true; this.#leftMouseDownEpoch = Date.now(); } else if (e.originalEvent?.button === 2) { - if (getApp().getState() === OlympusState.UNIT_CONTROL && getApp().getMap().getContextAction()?.getTarget() !== ContextActionTarget.POINT) { + if ( + getApp().getState() === OlympusState.IDLE || + (getApp().getState() === OlympusState.UNIT_CONTROL && getApp().getMap().getContextAction()?.getTarget() !== ContextActionTarget.POINT) + ) { DomEvent.stop(e); DomEvent.preventDefault(e); e.originalEvent.stopImmediatePropagation(); @@ -1375,6 +1372,19 @@ export abstract class Unit extends CustomMarker { #onRightLongClick(e: any) { console.log(`Right long click on ${this.getUnitName()}`); + if (getApp().getState() === OlympusState.IDLE) { + this.setSelected(!this.getSelected()); + + DomEvent.stop(e); + DomEvent.preventDefault(e); + e.originalEvent.stopImmediatePropagation(); + + window.setTimeout(() => { + getApp().setState(OlympusState.UNIT_CONTROL, UnitControlSubState.UNIT_CONTEXT_MENU); + UnitContextMenuRequestEvent.dispatch(this); + }, 200); + } + if (getApp().getState() === OlympusState.UNIT_CONTROL && !getApp().getMap().getContextAction()) { DomEvent.stop(e); DomEvent.preventDefault(e); @@ -1402,6 +1412,10 @@ export abstract class Unit extends CustomMarker { } #updateMarker() { + /* Delete existing trails */ + this.#trailPolylines.forEach((polyline) => polyline.removeFrom(getApp().getMap())); + this.#trailPolylines = []; + this.updateVisibility(); /* Draw the minimap marker */ @@ -1516,11 +1530,8 @@ export abstract class Unit extends CustomMarker { /* Set bullseyes positions */ const bullseyes = getApp().getMissionManager().getBullseyes(); if (Object.keys(bullseyes).length > 0) { - computeBearingRangeString - const blueBullseye = `${computeBearingRangeString(bullseyes[2].getLatLng(), this.getPosition())}`; - const redBullseye = `${computeBearingRangeString(bullseyes[1].getLatLng(), this.getPosition())}`; - if (element.querySelector(".unit-blue-bullseye")) (element.querySelector(".unit-blue-bullseye")).innerText = `${blueBullseye}`; - if (element.querySelector(".unit-red-bullseye")) (element.querySelector(".unit-red-bullseye")).innerText = `${redBullseye}`; + const bullseye = `${computeBearingRangeString(bullseyes[coalitionToEnum(getApp().getMap().getOptions().AWACSCoalition)].getLatLng(), this.getPosition())}`; + if (element.querySelector(".unit-bullseye")) (element.querySelector(".unit-bullseye")).innerText = `${bullseye}`; } /* Set BRAA */ @@ -1534,6 +1545,35 @@ export abstract class Unit extends CustomMarker { /* Set vertical offset for altitude stacking */ var pos = getApp().getMap().latLngToLayerPoint(this.getLatLng()).round(); this.setZIndexOffset(1000 + Math.floor(this.#position.alt as number) - pos.y + (this.#highlighted || this.#selected ? 5000 : 0)); + + /* Get the cluster this unit is in to position the label correctly */ + let cluster = Object.values(getApp().getUnitsManager().getClusters()).find((cluster) => cluster.includes(this)); + if (cluster && cluster.length > 1) { + let clusterMean = turf.centroid(turf.featureCollection(cluster.map((unit) => turf.point([unit.getPosition().lng, unit.getPosition().lat])))); + let bearingFromCluster = bearing( + clusterMean.geometry.coordinates[1], + clusterMean.geometry.coordinates[0], + this.getPosition().lat, + this.getPosition().lng + ); + + if (bearingFromCluster < 0) bearingFromCluster += 360; + let trackIndex = Math.round(bearingFromCluster / 45); + + for (let idx = 0; idx < bearingStrings.length; idx++) element?.querySelector(".unit-summary")?.classList.remove("cluster-" + bearingStrings[idx]); + element?.querySelector(".unit-summary")?.classList.add("cluster-" + bearingStrings[trackIndex]); + } else { + for (let idx = 0; idx < bearingStrings.length; idx++) element?.querySelector(".unit-summary")?.classList.remove("cluster-" + bearingStrings[idx]); + element?.querySelector(".unit-summary")?.classList.add("cluster-north-east"); + } + + /* Draw the contact trail */ + if (getApp().getMap().getOptions().AWACSMode) { + this.#trailPolylines = this.#trailPositions.map( + (latlng, idx) => new Polyline([latlng, latlng], { color: "#FFFFFF", opacity: 1 - (idx + 1) / TRAIL_LENGTH }) + ); + this.#trailPolylines.forEach((polyline) => polyline.addTo(getApp().getMap())); + } } } @@ -1807,10 +1847,8 @@ export abstract class AirUnit extends Unit { showFuel: belongsToCommandedCoalition, showAmmo: belongsToCommandedCoalition, showSummary: belongsToCommandedCoalition || this.getDetectionMethods().some((value) => [VISUAL, OPTIC, RADAR, IRST, DLINK].includes(value)), - showCallsign: belongsToCommandedCoalition, - rotateToHeading: false, - showBullseyes: true, - showBRAA: true, + showCallsign: belongsToCommandedCoalition && (!getApp().getMap().getOptions().AWACSMode || this.getHuman()), + rotateToHeading: false } as ObjectIconOptions; } @@ -1898,10 +1936,8 @@ export class GroundUnit extends Unit { showFuel: false, showAmmo: false, showSummary: false, - showCallsign: belongsToCommandedCoalition, + showCallsign: belongsToCommandedCoalition && (!getApp().getMap().getOptions().AWACSMode || this.getHuman()), rotateToHeading: false, - showBullseyes: false, - showBRAA: false, } as ObjectIconOptions; } @@ -1969,10 +2005,8 @@ export class NavyUnit extends Unit { showFuel: false, showAmmo: false, showSummary: false, - showCallsign: belongsToCommandedCoalition, + showCallsign: belongsToCommandedCoalition && (!getApp().getMap().getOptions().AWACSMode || this.getHuman()), rotateToHeading: false, - showBullseyes: false, - showBRAA: false, } as ObjectIconOptions; } diff --git a/frontend/react/src/unit/unitsmanager.ts b/frontend/react/src/unit/unitsmanager.ts index 700189a6..43df862a 100644 --- a/frontend/react/src/unit/unitsmanager.ts +++ b/frontend/react/src/unit/unitsmanager.ts @@ -1,16 +1,7 @@ import { LatLng, LatLngBounds } from "leaflet"; import { getApp } from "../olympusapp"; -import { Unit } from "./unit"; -import { - areaContains, - bearingAndDistanceToLatLng, - deg2rad, - getGroundElevation, - latLngToMercator, - mToFt, - mercatorToLatLng, - msToKnots, -} from "../other/utils"; +import { AirUnit, Unit } from "./unit"; +import { areaContains, bearingAndDistanceToLatLng, deg2rad, getGroundElevation, latLngToMercator, mToFt, mercatorToLatLng, msToKnots } from "../other/utils"; import { CoalitionPolygon } from "../map/coalitionarea/coalitionpolygon"; import { DELETE_CYCLE_TIME, DELETE_SLOW_THRESHOLD, DataIndexes, GAME_MASTER, IADSDensities, OlympusState, UnitControlSubState } from "../constants/constants"; import { DataExtractor } from "../server/dataextractor"; @@ -33,6 +24,8 @@ import { UnitSelectedEvent, } from "../events"; import { UnitDatabase } from "./databases/unitdatabase"; +import * as turf from "@turf/turf"; +import * as turfC from "@turf/clusters"; /** The UnitsManager handles the creation, update, and control of units. Data is strictly updated by the server ONLY. This means that any interaction from the user will always and only * result in a command to the server, executed by means of a REST PUT request. Any subsequent change in data will be reflected only when the new data is sent back by the server. This strategy allows @@ -51,6 +44,7 @@ export class UnitsManager { #unitDatabase: UnitDatabase; #protectionCallback: (units: Unit[]) => void = (units) => {}; #AWACSReference: Unit | null = null; + #clusters: {[key: number]: Unit[]} = {}; constructor() { this.#unitDatabase = new UnitDatabase(); @@ -82,7 +76,7 @@ export class UnitsManager { code: "KeyA", ctrlKey: true, shiftKey: false, - altKey: false + altKey: false, }) .addShortcut("copyUnits", { label: "Copy units", @@ -90,7 +84,7 @@ export class UnitsManager { code: "KeyC", ctrlKey: true, shiftKey: false, - altKey: false + altKey: false, }) .addShortcut("pasteUnits", { label: "Paste units", @@ -98,7 +92,7 @@ export class UnitsManager { code: "KeyV", ctrlKey: true, shiftKey: false, - altKey: false + altKey: false, }); const digits = ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9"]; @@ -113,8 +107,9 @@ export class UnitsManager { code: code, shiftKey: false, altKey: false, - ctrlKey: false - }).addShortcut(`hotgroup${idx + 1}add`, { + ctrlKey: false, + }) + .addShortcut(`hotgroup${idx + 1}add`, { label: `Hotgroup ${idx + 1} (Add to)`, keyUpCallback: (ev: KeyboardEvent) => { this.addToHotgroup(parseInt(ev.code.substring(5))); @@ -122,8 +117,9 @@ export class UnitsManager { code: code, shiftKey: true, altKey: false, - ctrlKey: false - }).addShortcut(`hotgroup${idx + 1}set`, { + ctrlKey: false, + }) + .addShortcut(`hotgroup${idx + 1}set`, { label: `Hotgroup ${idx + 1} (Set)`, keyUpCallback: (ev: KeyboardEvent) => { this.setHotgroup(parseInt(ev.code.substring(5))); @@ -131,8 +127,9 @@ export class UnitsManager { code: code, ctrlKey: true, altKey: false, - shiftKey: false - }).addShortcut(`hotgroup${idx + 1}also`, { + shiftKey: false, + }) + .addShortcut(`hotgroup${idx + 1}also`, { label: `Hotgroup ${idx + 1} (Select also)`, keyUpCallback: (ev: KeyboardEvent) => { this.selectUnitsByHotgroup(parseInt(ev.code.substring(5)), false); @@ -140,7 +137,7 @@ export class UnitsManager { code: code, ctrlKey: true, shiftKey: true, - altKey: false + altKey: false, }); }); @@ -280,6 +277,9 @@ export class UnitsManager { if (this.#units[ID].getSelected()) this.#units[ID].drawLines(); } + /* Compute the base clusters */ + this.#clusters = this.computeClusters(); + return updateTime; } @@ -1548,13 +1548,37 @@ export class UnitsManager { setAWACSReference(ID) { this.#AWACSReference = this.#units[ID] ?? null; - AWACSReferenceChangedEvent.dispatch(this.#AWACSReference) + AWACSReferenceChangedEvent.dispatch(this.#AWACSReference); } getAWACSReference() { return this.#AWACSReference; } + computeClusters(filter: (unit: Unit) => boolean = (unit) => true, distance: number = 5 /* km */) { + let units = Object.values(this.#units) + .filter((unit) => unit.getAlive() && unit instanceof AirUnit) + .filter(filter); + + var geojson = turf.featureCollection(units.map((unit) => turf.point([unit.getPosition().lng, unit.getPosition().lat]))); + + //@ts-ignore + var clustered = turf.clustersDbscan(geojson, distance, { minPoints: 1 }); + + let clusters: {[key: number]: Unit[]} = {}; + clustered.features.forEach((feature, idx) => { + if (clusters[feature.properties.cluster] === undefined) + clusters[feature.properties.cluster] = [] as Unit[]; + clusters[feature.properties.cluster].push(units[idx]); + }) + + return clusters; + } + + getClusters() { + return this.#clusters; + } + /***********************************************/ #onUnitSelection(unit: Unit) { if (this.getSelectedUnits().length > 0) { diff --git a/frontend/react/src/weapon/weapon.ts b/frontend/react/src/weapon/weapon.ts index 9ffbb683..a5957eff 100644 --- a/frontend/react/src/weapon/weapon.ts +++ b/frontend/react/src/weapon/weapon.ts @@ -178,7 +178,7 @@ export class Weapon extends CustomMarker { var unitIcon = document.createElement("div"); unitIcon.classList.add("unit-icon"); var img = document.createElement("img"); - img.src = `/vite/images/units/${this.getMarkerCategory()}.svg`; + img.src = `/vite/images/units/map/${getApp().getMap().getOptions().AWACSMode ? "awacs" : "normal"}/${this.getCoalition()}/${this.getMarkerCategory()}.svg`; img.onload = () => SVGInjector(img); unitIcon.appendChild(img); unitIcon.toggleAttribute("data-rotate-to-heading", this.getIconOptions().rotateToHeading);