From 3ab10af98bc30c0e4849eb213173609f5613950c Mon Sep 17 00:00:00 2001 From: Davide Passoni Date: Fri, 21 Mar 2025 16:11:57 +0100 Subject: [PATCH 1/3] feat: Added unit clustering and other small performance and loadtime improvements --- frontend/react/src/constants/constants.ts | 4 +- frontend/react/src/events.ts | 102 +++++++------ frontend/react/src/interfaces.ts | 1 + frontend/react/src/map/map.ts | 2 +- .../react/src/map/markers/clustermarker.ts | 33 ++++ .../src/map/markers/stylesheets/units.css | 39 +++++ frontend/react/src/map/stylesheets/map.css | 31 ++++ frontend/react/src/server/servermanager.ts | 35 +++-- frontend/react/src/types/types.ts | 1 + frontend/react/src/ui/panels/header.tsx | 12 +- frontend/react/src/unit/unit.ts | 74 ++++++++- frontend/react/src/unit/unitsmanager.ts | 144 ++++++++++++++---- 12 files changed, 380 insertions(+), 98 deletions(-) create mode 100644 frontend/react/src/map/markers/clustermarker.ts diff --git a/frontend/react/src/constants/constants.ts b/frontend/react/src/constants/constants.ts index 2638cb9b..3219d09a 100644 --- a/frontend/react/src/constants/constants.ts +++ b/frontend/react/src/constants/constants.ts @@ -419,6 +419,7 @@ export const MAP_OPTIONS_DEFAULTS: MapOptions = { hideChromeWarning: false, hideSecureWarning: false, showMissionDrawings: false, + clusterGroundUnits: true }; export const MAP_HIDDEN_TYPES_DEFAULTS = { @@ -515,7 +516,8 @@ export const DELETE_CYCLE_TIME = 0.05; export const DELETE_SLOW_THRESHOLD = 50; export const GROUPING_ZOOM_TRANSITION = 13; -export const SPOTS_EDIT_ZOOM_TRANSITION = 14; +export const CLUSTERING_ZOOM_TRANSITION = 13; +export const SPOTS_EDIT_ZOOM_TRANSITION = 13; export const MAX_SHOTS_SCATTER = 3; export const MAX_SHOTS_INTENSITY = 3; diff --git a/frontend/react/src/events.ts b/frontend/react/src/events.ts index aecae557..b8693d13 100644 --- a/frontend/react/src/events.ts +++ b/frontend/react/src/events.ts @@ -14,6 +14,8 @@ import { Unit } from "./unit/unit"; import { LatLng } from "leaflet"; import { Weapon } from "./weapon/weapon"; +const DEBUG = false; + export class BaseOlympusEvent { static on(callback: () => void, singleShot = false) { document.addEventListener( @@ -27,7 +29,7 @@ export class BaseOlympusEvent { static dispatch() { document.dispatchEvent(new CustomEvent(this.name)); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -44,8 +46,8 @@ export class BaseUnitEvent { static dispatch(unit: Unit) { document.dispatchEvent(new CustomEvent(this.name, { detail: { unit } })); - console.log(`Event ${this.name} dispatched`); - console.log(unit); + if (DEBUG) console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(unit); } } @@ -62,7 +64,7 @@ export class BaseUnitsEvent { static dispatch(units: Unit[]) { document.dispatchEvent(new CustomEvent(this.name, { detail: units })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -81,8 +83,8 @@ export class AppStateChangedEvent { static dispatch(state: OlympusState, subState: OlympusSubState) { const detail = { state, subState }; document.dispatchEvent(new CustomEvent(this.name, { detail })); - console.log(`Event ${this.name} dispatched`); - console.log(`State: ${state} Substate: ${subState}`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`State: ${state} Substate: ${subState}`); } } @@ -99,8 +101,8 @@ export class ConfigLoadedEvent { static dispatch(config: OlympusConfig) { document.dispatchEvent(new CustomEvent(this.name, { detail: config })); - console.log(`Event ${this.name} dispatched`); - console.log(config); + if (DEBUG) console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(config); } } @@ -136,7 +138,7 @@ export class InfoPopupEvent { static dispatch(messages: string[]) { document.dispatchEvent(new CustomEvent(this.name, { detail: { messages } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -155,7 +157,7 @@ export class ShortcutsChangedEvent { static dispatch(shortcuts: { [key: string]: Shortcut }) { document.dispatchEvent(new CustomEvent(this.name, { detail: { shortcuts } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -172,7 +174,7 @@ export class ShortcutChangedEvent { static dispatch(shortcut: Shortcut) { document.dispatchEvent(new CustomEvent(this.name, { detail: { shortcut } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -189,7 +191,7 @@ export class BindShortcutRequestEvent { static dispatch(shortcut: Shortcut) { document.dispatchEvent(new CustomEvent(this.name, { detail: { shortcut } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -206,7 +208,7 @@ export class ModalEvent { static dispatch(modal: boolean) { document.dispatchEvent(new CustomEvent(this.name, { detail: { modal } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -223,7 +225,7 @@ export class SessionDataChangedEvent { static dispatch(sessionData: SessionData) { document.dispatchEvent(new CustomEvent(this.name, { detail: { sessionData } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -243,7 +245,7 @@ export class AdminPasswordChangedEvent { static dispatch(password: string) { document.dispatchEvent(new CustomEvent(this.name, { detail: { password } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -278,24 +280,24 @@ export class HiddenTypesChangedEvent { static dispatch(hiddenTypes: MapHiddenTypes) { document.dispatchEvent(new CustomEvent(this.name, { detail: { hiddenTypes } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } export class MapOptionsChangedEvent { - static on(callback: (mapOptions: MapOptions) => void, singleShot = false) { + static on(callback: (mapOptions: MapOptions, key: keyof MapOptions | undefined) => void, singleShot = false) { document.addEventListener( this.name, (ev: CustomEventInit) => { - callback(ev.detail.mapOptions); + callback(ev.detail.mapOptions, ev.detail.key); }, { once: singleShot } ); } - static dispatch(mapOptions: MapOptions) { - document.dispatchEvent(new CustomEvent(this.name, { detail: { mapOptions } })); - console.log(`Event ${this.name} dispatched`); + static dispatch(mapOptions: MapOptions, key?: (keyof MapOptions) | undefined) { + document.dispatchEvent(new CustomEvent(this.name, { detail: { mapOptions, key: key } })); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -312,7 +314,7 @@ export class MapSourceChangedEvent { static dispatch(source: string) { document.dispatchEvent(new CustomEvent(this.name, { detail: { source } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -329,7 +331,7 @@ export class CoalitionAreaSelectedEvent { static dispatch(coalitionArea: CoalitionCircle | CoalitionPolygon | null) { document.dispatchEvent(new CustomEvent(this.name, { detail: { coalitionArea } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -348,7 +350,7 @@ export class CoalitionAreasChangedEvent { static dispatch(coalitionAreas: (CoalitionCircle | CoalitionPolygon)[]) { document.dispatchEvent(new CustomEvent(this.name, { detail: { coalitionAreas } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -365,7 +367,7 @@ export class AirbaseSelectedEvent { static dispatch(airbase: Airbase | null) { document.dispatchEvent(new CustomEvent(this.name, { detail: { airbase } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -382,7 +384,7 @@ export class SelectionEnabledChangedEvent { static dispatch(enabled: boolean) { document.dispatchEvent(new CustomEvent(this.name, { detail: { enabled } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -399,7 +401,7 @@ export class PasteEnabledChangedEvent { static dispatch(enabled: boolean) { document.dispatchEvent(new CustomEvent(this.name, { detail: { enabled } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -433,7 +435,7 @@ export class ContextActionSetChangedEvent { static dispatch(contextActionSet: ContextActionSet | null) { document.dispatchEvent(new CustomEvent(this.name, { detail: { contextActionSet } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -450,7 +452,7 @@ export class ContextActionChangedEvent { static dispatch(contextAction: ContextAction | null) { document.dispatchEvent(new CustomEvent(this.name, { detail: { contextAction } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -467,7 +469,7 @@ export class CopiedUnitsEvents { static dispatch(unitsData: UnitData[]) { document.dispatchEvent(new CustomEvent(this.name, { detail: { unitsData } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -505,7 +507,7 @@ export class UnitExplosionRequestEvent { static dispatch(units: Unit[]) { document.dispatchEvent(new CustomEvent(this.name, { detail: { units } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -522,7 +524,7 @@ export class FormationCreationRequestEvent { static dispatch(leader: Unit, wingmen: Unit[]) { document.dispatchEvent(new CustomEvent(this.name, { detail: { leader, wingmen } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -539,7 +541,7 @@ export class MapContextMenuRequestEvent { static dispatch(latlng: L.LatLng) { document.dispatchEvent(new CustomEvent(this.name, { detail: { latlng } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -556,7 +558,7 @@ export class UnitContextMenuRequestEvent { static dispatch(unit: Unit) { document.dispatchEvent(new CustomEvent(this.name, { detail: { unit } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -573,7 +575,7 @@ export class SpawnContextMenuRequestEvent { static dispatch(latlng: L.LatLng) { document.dispatchEvent(new CustomEvent(this.name, { detail: { latlng } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -590,7 +592,7 @@ export class SpawnHeadingChangedEvent { static dispatch(heading: number) { document.dispatchEvent(new CustomEvent(this.name, { detail: { heading } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -607,7 +609,7 @@ export class HotgroupsChangedEvent { static dispatch(hotgroups: { [key: number]: Unit[] }) { document.dispatchEvent(new CustomEvent(this.name, { detail: { hotgroups } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -624,7 +626,7 @@ export class StarredSpawnsChangedEvent { static dispatch(starredSpawns: { [key: number]: SpawnRequestTable }) { document.dispatchEvent(new CustomEvent(this.name, { detail: { starredSpawns } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -658,7 +660,7 @@ export class DrawingsInitEvent { static dispatch(drawingsData: any /*TODO*/) { document.dispatchEvent(new CustomEvent(this.name, {detail: drawingsData})); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -678,7 +680,7 @@ export class CommandModeOptionsChangedEvent { static dispatch(options: CommandModeOptions) { document.dispatchEvent(new CustomEvent(this.name, { detail: options })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -696,8 +698,8 @@ export class AudioSourcesChangedEvent { static dispatch(audioSources: AudioSource[]) { document.dispatchEvent(new CustomEvent(this.name, { detail: { audioSources } })); - console.log(`Event ${this.name} dispatched`); - console.log(audioSources); + if (DEBUG) console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(audioSources); } } @@ -714,8 +716,8 @@ export class AudioSinksChangedEvent { static dispatch(audioSinks: AudioSink[]) { document.dispatchEvent(new CustomEvent(this.name, { detail: { audioSinks } })); - console.log(`Event ${this.name} dispatched`); - console.log(audioSinks); + if (DEBUG) console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(audioSinks); } } @@ -749,7 +751,7 @@ export class AudioManagerStateChangedEvent { static dispatch(state: boolean) { document.dispatchEvent(new CustomEvent(this.name, { detail: { state } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -766,7 +768,7 @@ export class AudioManagerDevicesChangedEvent { static dispatch(devices: MediaDeviceInfo[]) { document.dispatchEvent(new CustomEvent(this.name, { detail: { devices } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -783,7 +785,7 @@ export class AudioManagerInputChangedEvent { static dispatch(input: MediaDeviceInfo) { document.dispatchEvent(new CustomEvent(this.name, { detail: { input } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -800,7 +802,7 @@ export class AudioManagerOutputChangedEvent { static dispatch(output: MediaDeviceInfo) { document.dispatchEvent(new CustomEvent(this.name, { detail: { output } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -817,7 +819,7 @@ export class AudioManagerCoalitionChangedEvent { static dispatch(coalition: Coalition) { document.dispatchEvent(new CustomEvent(this.name, { detail: { coalition } })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } @@ -887,6 +889,6 @@ export class WeaponsRefreshedEvent { static dispatch(weapons: Weapon[]) { document.dispatchEvent(new CustomEvent(this.name, { detail: weapons })); - console.log(`Event ${this.name} dispatched`); + if (DEBUG) console.log(`Event ${this.name} dispatched`); } } diff --git a/frontend/react/src/interfaces.ts b/frontend/react/src/interfaces.ts index 869237cd..cbe4ba40 100644 --- a/frontend/react/src/interfaces.ts +++ b/frontend/react/src/interfaces.ts @@ -172,6 +172,7 @@ export interface ObjectIconOptions { showSummary: boolean; showCallsign: boolean; rotateToHeading: boolean; + showCluster: boolean; } export interface GeneralSettings { diff --git a/frontend/react/src/map/map.ts b/frontend/react/src/map/map.ts index 28c0f4e5..48ec8bde 100644 --- a/frontend/react/src/map/map.ts +++ b/frontend/react/src/map/map.ts @@ -779,7 +779,7 @@ export class Map extends L.Map { setOption(key, value) { this.#options[key] = value; - MapOptionsChangedEvent.dispatch(this.#options); + MapOptionsChangedEvent.dispatch(this.#options, key); } setOptions(options) { diff --git a/frontend/react/src/map/markers/clustermarker.ts b/frontend/react/src/map/markers/clustermarker.ts new file mode 100644 index 00000000..0a77778c --- /dev/null +++ b/frontend/react/src/map/markers/clustermarker.ts @@ -0,0 +1,33 @@ +import { DivIcon, LatLngExpression, MarkerOptions } from "leaflet"; +import { CustomMarker } from "./custommarker"; +import { Coalition } from "../../types/types"; + +export class ClusterMarker extends CustomMarker { + #coalition: Coalition; + #numberOfUnits: number; + + constructor(latlng: LatLngExpression, coalition: Coalition, numberOfUnits:number, options?: MarkerOptions) { + super(latlng, options); + this.setZIndexOffset(9999); + this.#coalition = coalition; + this.#numberOfUnits = numberOfUnits; + } + + createIcon() { + this.setIcon( + new DivIcon({ + iconSize: [52, 52], + iconAnchor: [26, 26], + className: "leaflet-cluster-marker", + }) + ); + var el = document.createElement("div"); + el.classList.add("ol-cluster-icon"); + el.classList.add(`${this.#coalition}`); + this.getElement()?.appendChild(el); + var span = document.createElement("span"); + span.classList.add("ol-cluster-number"); + span.textContent = `${this.#numberOfUnits}`; + el.appendChild(span); + } +} diff --git a/frontend/react/src/map/markers/stylesheets/units.css b/frontend/react/src/map/markers/stylesheets/units.css index a2326448..bc079bb0 100644 --- a/frontend/react/src/map/markers/stylesheets/units.css +++ b/frontend/react/src/map/markers/stylesheets/units.css @@ -93,6 +93,42 @@ translate: -1px 1px; } +.unit-cluster { + border: 2px solid #272727; + border-radius: var(--border-radius-xs); + display: none; + height: 20px; + position: absolute; + translate: 70% -70%; + width: 30px; + +} + +.unit-cluster.red { + background-color: var(--unit-background-red); +} + +.unit-cluster.blue { + background-color: var(--unit-background-blue); +} + +.unit-cluster.neutral { + background-color: var(--unit-background-neutral); +} + +.unit-cluster-id { + background-color: transparent; + color: #272727; + font-size: 12px; + font-weight: bolder; + translate: -3px -1px; + border-left: 3px solid #272727; + height: 50px; + padding-left: 4px; + text-align: center; + width: 50px; +} + .unit-icon { height: var(--unit-height); position: absolute; @@ -404,6 +440,7 @@ } [data-object|="unit"][data-is-in-hotgroup] .unit-hotgroup, +[data-object|="unit"][data-is-cluster-leader] .unit-cluster, [data-object|="unit"][data-is-selected] .unit-ammo, [data-object|="unit"][data-is-selected] .unit-fuel, [data-object|="unit"][data-is-selected] .unit-health, @@ -561,6 +598,8 @@ [data-object|="unit"][data-is-dead] .unit-vvi, [data-object|="unit"][data-is-dead] .unit-hotgroup, [data-object|="unit"][data-is-dead] .unit-hotgroup-id, +[data-object|="unit"][data-is-dead] .unit-cluster, +[data-object|="unit"][data-is-dead] .unit-cluster-id, [data-object|="unit"][data-is-dead] .unit-state, [data-object|="unit"][data-is-dead] .unit-fuel, [data-object|="unit"][data-is-dead] .unit-health, diff --git a/frontend/react/src/map/stylesheets/map.css b/frontend/react/src/map/stylesheets/map.css index 155208cd..6c9623a4 100644 --- a/frontend/react/src/map/stylesheets/map.css +++ b/frontend/react/src/map/stylesheets/map.css @@ -277,3 +277,34 @@ path.leaflet-interactive:focus { .ol-arrow-icon svg path { fill: #ffffff; } + +.ol-cluster-icon { + height: 100%; + width: 100%; + + filter: drop-shadow(3px 3px 3px rgba(0, 0, 0, 0.2)); +} + +.ol-cluster-icon.neutral { + background-image: url("/images/markers/cluster-neutral.svg");; +} + +.ol-cluster-icon.blue { + background-image: url("/images/markers/cluster-blue.svg");; +} + +.ol-cluster-icon.red { + background-image: url("/images/markers/cluster-red.svg");; +} + +.ol-cluster-number { + width: 100%; + height: 100%; + font-size: 14px; + font-weight: 700; + color: #272727; + text-align: center; + display: flex; + justify-content: center; + align-items: center; +} \ No newline at end of file diff --git a/frontend/react/src/server/servermanager.ts b/frontend/react/src/server/servermanager.ts index 7c624384..8516b553 100644 --- a/frontend/react/src/server/servermanager.ts +++ b/frontend/react/src/server/servermanager.ts @@ -215,7 +215,7 @@ export class ServerManager { } getUnits(callback: CallableFunction, refresh: boolean = false, errorCallback: CallableFunction = () => {}) { - this.GET(callback, errorCallback, UNITS_URI, { time: refresh ? 0 : this.#lastUpdateTimes[UNITS_URI] }, "arraybuffer", refresh); + this.GET(callback, errorCallback, UNITS_URI, { time: refresh ? 0 : this.#lastUpdateTimes[UNITS_URI] }, "arraybuffer", false); } getWeapons(callback: CallableFunction, refresh: boolean = false, errorCallback: CallableFunction = () => {}) { @@ -342,7 +342,13 @@ export class ServerManager { this.PUT(data, callback); } - cloneUnits(units: { ID: number; location: LatLng }[], deleteOriginal: boolean, spawnPoints: number, coalition: Coalition, callback: CallableFunction = () => {}) { + cloneUnits( + units: { ID: number; location: LatLng }[], + deleteOriginal: boolean, + spawnPoints: number, + coalition: Coalition, + callback: CallableFunction = () => {} + ) { var command = { units: units, coalition: coalition, @@ -582,7 +588,7 @@ export class ServerManager { targetingRange: targetingRange, aimMethodRange: aimMethodRange, acquisitionRange: acquisitionRange, - } + }; var data = { setEngagementProperties: command }; this.PUT(data, callback); @@ -618,11 +624,14 @@ export class ServerManager { loadEnvResources() { /* Load the drawings */ - this.getDrawings((drawingsData: { drawings: Record> }) => { - if (drawingsData) { - getApp().getDrawingsManager()?.initDrawings(drawingsData); - } - }, () => {}); + this.getDrawings( + (drawingsData: { drawings: Record> }) => { + if (drawingsData) { + getApp().getDrawingsManager()?.initDrawings(drawingsData); + } + }, + () => {} + ); // TODO: load navPoints } @@ -790,10 +799,12 @@ export class ServerManager { return time; }, true); - this.getUnits((buffer: ArrayBuffer) => { - var time = getApp().getUnitsManager()?.update(buffer, true); - return time; - }, true); + window.setInterval(() => { + this.getUnits((buffer: ArrayBuffer) => { + var time = getApp().getUnitsManager()?.update(buffer, true); + return time; + }, true); + }, 500); } checkSessionHash(newSessionHash: string) { diff --git a/frontend/react/src/types/types.ts b/frontend/react/src/types/types.ts index f3114a56..8a3f600b 100644 --- a/frontend/react/src/types/types.ts +++ b/frontend/react/src/types/types.ts @@ -31,6 +31,7 @@ export type MapOptions = { hideChromeWarning: boolean; hideSecureWarning: boolean; showMissionDrawings: boolean; + clusterGroundUnits: boolean; }; export type MapHiddenTypes = { diff --git a/frontend/react/src/ui/panels/header.tsx b/frontend/react/src/ui/panels/header.tsx index 120971c6..55a2e7e1 100644 --- a/frontend/react/src/ui/panels/header.tsx +++ b/frontend/react/src/ui/panels/header.tsx @@ -13,6 +13,7 @@ import { faWifi, faHourglass, faInfo, + faObjectGroup, } from "@fortawesome/free-solid-svg-icons"; import { OlDropdownItem, OlDropdown } from "../components/oldropdown"; import { OlLabelToggle } from "../components/ollabeltoggle"; @@ -166,8 +167,8 @@ export function Header() { @@ -328,6 +329,13 @@ export function Header() { className={""} tooltip={"Hide/show units acquisition rings"} /> + getApp().getMap().setOption("clusterGroundUnits", !mapOptions.clusterGroundUnits)} + checked={mapOptions.clusterGroundUnits} + icon={faObjectGroup} + className={""} + tooltip={"Enable/disable ground unit clustering"} + /> { - this.#redrawMarker(); + MapOptionsChangedEvent.on((mapOptions, key) => { + if ( + key === undefined || + key === "hideGroupMembers" || + key === "clusterGroundUnits" || + key === "showUnitLabels" || + key === "AWACSMode" || + key === "AWACSCoalition" + ) + this.#redrawMarker(); /* Circles don't like to be updated when the map is zooming */ if (!getApp().getMap().isZooming()) this.#drawRanges(); @@ -880,7 +891,7 @@ export abstract class Unit extends CustomMarker { /* When the group leader is selected, if grouping is active, all the other group members are also selected */ if (this.getCategory() === "GroundUnit" && getApp().getMap().getZoom() < GROUPING_ZOOM_TRANSITION) { - if (this.#isLeader) { + if (this.#isLeader && this.getGroupMembers().length > 0) { /* Redraw the marker in case the leader unit was replaced by a group marker, like for SAM Sites */ this.#redrawMarker(); this.getGroupMembers().forEach((unit: Unit) => unit.setSelected(selected)); @@ -889,6 +900,17 @@ export abstract class Unit extends CustomMarker { } } + /* When the group leader is selected, if clustering is active, all the other group members are also selected */ + if (this.getCategory() === "GroundUnit" && getApp().getMap().getZoom() < CLUSTERING_ZOOM_TRANSITION) { + if (this.#isClusterLeader && this.#clusterUnits.length > 0) { + /* Redraw the marker in case the leader unit was replaced by a group marker, like for SAM Sites */ + this.#redrawMarker(); + this.#clusterUnits.forEach((unit: Unit) => unit.setSelected(selected)); + } else { + this.#updateMarker(); + } + } + /* Activate the selection effects on the marker */ this.getElement()?.querySelector(`.unit`)?.toggleAttribute("data-is-selected", selected); @@ -1078,6 +1100,17 @@ export abstract class Unit extends CustomMarker { el.append(hotgroup); } + /* Cluster indicator */ + if (iconOptions.showCluster) { + var cluster = document.createElement("div"); + cluster.classList.add("unit-cluster"); + cluster.classList.add(this.getCoalition()); + var clusterId = document.createElement("div"); + clusterId.classList.add("unit-cluster-id"); + cluster.appendChild(clusterId); + el.append(cluster); + } + /* Main icon */ if (iconOptions.showUnitIcon) { var unitIcon = document.createElement("div"); @@ -1191,6 +1224,13 @@ export abstract class Unit extends CustomMarker { !this.getSelected() && this.getCategory() == "GroundUnit" && getApp().getMap().getZoom() < GROUPING_ZOOM_TRANSITION && + (this.belongsToCommandedCoalition() || (!this.belongsToCommandedCoalition() && this.#detectionMethods.length == 0))) || + /* Hide the unit if clustering is activated, the unit is in a cluster, it is not selected, and the zoom is higher than the clustering threshold */ + (getApp().getMap().getOptions().clusterGroundUnits && + !this.#isClusterLeader && + !this.getSelected() && + this.getCategory() == "GroundUnit" && + getApp().getMap().getZoom() < CLUSTERING_ZOOM_TRANSITION && (this.belongsToCommandedCoalition() || (!this.belongsToCommandedCoalition() && this.#detectionMethods.length == 0))); /* Force dead units to be hidden */ @@ -1251,6 +1291,14 @@ export abstract class Unit extends CustomMarker { return getApp().getUnitsManager().getUnitByID(this.#leaderID); } + setClusterUnits(clusterUnits: Unit[]) { + this.#clusterUnits = clusterUnits; + } + + setIsClusterLeader(clusterLeader: boolean) { + this.#isClusterLeader = clusterLeader; + } + canFulfillRole(roles: string | string[]) { if (typeof roles === "string") roles = [roles]; @@ -1751,6 +1799,22 @@ export abstract class Unit extends CustomMarker { if (hotgroupEl) hotgroupEl.innerText = String(this.#hotgroup); } + /* Draw the cluster element */ + element + .querySelector(".unit") + ?.toggleAttribute( + "data-is-cluster-leader", + this.#isClusterLeader && + this.#clusterUnits.length > 1 && + getApp().getMap().getOptions().clusterGroundUnits && + getApp().getMap().getZoom() < CLUSTERING_ZOOM_TRANSITION && + !this.getSelected() + ); + if (this.#isClusterLeader && this.#clusterUnits.length > 1) { + const clusterEl = element.querySelector(".unit-cluster-id") as HTMLElement; + if (clusterEl) clusterEl.innerText = String(this.#clusterUnits.length); + } + /* Set bullseyes positions */ const bullseyes = getApp().getMissionManager().getBullseyes(); if (Object.keys(bullseyes).length > 0) { @@ -2318,6 +2382,7 @@ export abstract class AirUnit extends Unit { showSummary: belongsToCommandedCoalition || this.getDetectionMethods().some((value) => [VISUAL, OPTIC, RADAR, IRST, DLINK].includes(value)), showCallsign: belongsToCommandedCoalition && /*TODO !getApp().getMap().getOptions().AWACSMode || */ this.getHuman(), rotateToHeading: false, + showCluster: false, } as ObjectIconOptions; } @@ -2405,6 +2470,7 @@ export class GroundUnit extends Unit { showSummary: false, showCallsign: belongsToCommandedCoalition && /*TODO !getApp().getMap().getOptions().AWACSMode || */ this.getHuman(), rotateToHeading: false, + showCluster: true, } as ObjectIconOptions; } @@ -2433,6 +2499,7 @@ export class GroundUnit extends Unit { checkZoomRedraw(): boolean { return ( this.getIsLeader() && + this.getGroupMembers().length > 0 && (getApp().getMap().getOptions().hideGroupMembers as boolean) && ((getApp().getMap().getZoom() >= GROUPING_ZOOM_TRANSITION && getApp().getMap().getPreviousZoom() < GROUPING_ZOOM_TRANSITION) || (getApp().getMap().getZoom() < GROUPING_ZOOM_TRANSITION && getApp().getMap().getPreviousZoom() >= GROUPING_ZOOM_TRANSITION)) @@ -2470,6 +2537,7 @@ export class NavyUnit extends Unit { showSummary: false, showCallsign: belongsToCommandedCoalition && /*TODO !getApp().getMap().getOptions().AWACSMode || */ this.getHuman(), rotateToHeading: false, + showCluster: false, } as ObjectIconOptions; } diff --git a/frontend/react/src/unit/unitsmanager.ts b/frontend/react/src/unit/unitsmanager.ts index 99ce1c2c..b019ef2d 100644 --- a/frontend/react/src/unit/unitsmanager.ts +++ b/frontend/react/src/unit/unitsmanager.ts @@ -1,6 +1,6 @@ -import { LatLng, LatLngBounds } from "leaflet"; +import { DomEvent, DomUtil, LatLng, LatLngBounds } from "leaflet"; import { getApp } from "../olympusapp"; -import { AirUnit, Unit } from "./unit"; +import { AirUnit, GroundUnit, NavyUnit, Unit } from "./unit"; import { areaContains, bearingAndDistanceToLatLng, @@ -13,7 +13,17 @@ import { msToKnots, } from "../other/utils"; import { CoalitionPolygon } from "../map/coalitionarea/coalitionpolygon"; -import { BLUE_COMMANDER, DELETE_CYCLE_TIME, DELETE_SLOW_THRESHOLD, DataIndexes, GAME_MASTER, IADSDensities, OlympusState, RED_COMMANDER, UnitControlSubState } from "../constants/constants"; +import { + BLUE_COMMANDER, + DELETE_CYCLE_TIME, + DELETE_SLOW_THRESHOLD, + DataIndexes, + GAME_MASTER, + IADSDensities, + OlympusState, + RED_COMMANDER, + UnitControlSubState, +} from "../constants/constants"; import { DataExtractor } from "../server/dataextractor"; import { citiesDatabase } from "./databases/citiesdatabase"; import { TemporaryUnitMarker } from "../map/markers/temporaryunitmarker"; @@ -40,6 +50,7 @@ import { UnitDatabase } from "./databases/unitdatabase"; import * as turf from "@turf/turf"; import { PathMarker } from "../map/markers/pathmarker"; import { Coalition } from "../types/types"; +import { ClusterMarker } from "../map/markers/clustermarker"; /** 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 @@ -352,8 +363,51 @@ export class UnitsManager { }); } - /* Compute the base clusters */ - this.#clusters = this.computeClusters(); + /* Compute the base air unit clusters */ + this.#clusters = this.computeClusters(AirUnit); + + /* Compute the base ground unit clusters */ + Object.values(this.#units).forEach((unit: Unit) => unit.setIsClusterLeader(true)); + if (getApp().getMap().getOptions().clusterGroundUnits) { + /* Get a list of all existing ground unit types */ + let groundUnitTypes: string[] = []; + Object.values(this.#units) + .filter((unit) => unit.getAlive()) + .forEach((unit: Unit) => { + if (unit.getCategory() === "GroundUnit" && !groundUnitTypes.includes(unit.getType())) groundUnitTypes.push(unit.getType()); + }); + + ["blue", "red", "neutral"].forEach((coalition: string) => { + groundUnitTypes.forEach((type: string) => { + let clusters = this.computeClusters( + GroundUnit, + (unit: Unit) => { + if (getApp().getMap().getOptions().hideGroupMembers) return unit.getType() === type && unit.getIsLeader(); + else return unit.getType() === type; + }, + 2, + coalition as Coalition, + 5 + ); + + /* Find the unit closest to the cluster center */ + Object.values(clusters).forEach((clusterUnits: Unit[]) => { + const clusterCenter = turf.center( + turf.featureCollection(clusterUnits.map((unit: Unit) => turf.point([unit.getPosition().lng, unit.getPosition().lat]))) + ); + const clusterCenterCoords = clusterCenter.geometry.coordinates; + const clusterCenterLatLng = new LatLng(clusterCenterCoords[1], clusterCenterCoords[0]); + + const closestUnit = clusterUnits.reduce((prev, current) => { + return prev.getPosition().distanceTo(clusterCenterLatLng) < current.getPosition().distanceTo(clusterCenterLatLng) ? prev : current; + }); + + clusterUnits.forEach((unit: Unit) => unit.setIsClusterLeader(unit === closestUnit)); + closestUnit.setClusterUnits(clusterUnits); + }); + }); + }); + } if (fullUpdate) UnitsRefreshedEvent.dispatch(Object.values(this.#units)); else UnitsUpdatedEvent.dispatch(updatedUnits); @@ -1271,7 +1325,9 @@ export class UnitsManager { if (getApp().getMissionManager().getCommandModeOptions().commandMode === BLUE_COMMANDER) coalition = "blue"; else if (getApp().getMissionManager().getCommandModeOptions().commandMode === RED_COMMANDER) coalition = "red"; - getApp().getServerManager().cloneUnits(unitsData, true, 0 /* No spawn points, we delete the original units */, coalition as Coalition); + getApp() + .getServerManager() + .cloneUnits(unitsData, true, 0 /* No spawn points, we delete the original units */, coalition as Coalition); this.#showActionMessage(units, `created a group`); } else { getApp().addInfoMessage(`Groups can only be created from units of the same category`); @@ -1484,13 +1540,19 @@ export class UnitsManager { getApp() .getServerManager() - .cloneUnits(units, false, getApp().getMissionManager().getCommandModeOptions().commandMode === GAME_MASTER? 0: spawnPoints, coalition as Coalition, (res: any) => { - if (res !== undefined) { - markers.forEach((marker: TemporaryUnitMarker) => { - marker.setCommandHash(res); - }); + .cloneUnits( + units, + false, + getApp().getMissionManager().getCommandModeOptions().commandMode === GAME_MASTER ? 0 : spawnPoints, + coalition as Coalition, + (res: any) => { + if (res !== undefined) { + markers.forEach((marker: TemporaryUnitMarker) => { + marker.setCommandHash(res); + }); + } } - }); + ); } getApp().addInfoMessage(`${this.#copiedUnits.length} units pasted`); } else { @@ -1668,36 +1730,48 @@ export class UnitsManager { getApp().addInfoMessage("Aircrafts can be air spawned during the SETUP phase only"); return false; } - spawnPoints = getApp().getMissionManager().getCommandModeOptions().commandMode === GAME_MASTER? 0: units.reduce((points: number, unit: UnitSpawnTable) => { - return points + this.getDatabase().getSpawnPointsByName(unit.unitType); - }, 0); + spawnPoints = + getApp().getMissionManager().getCommandModeOptions().commandMode === GAME_MASTER + ? 0 + : units.reduce((points: number, unit: UnitSpawnTable) => { + return points + this.getDatabase().getSpawnPointsByName(unit.unitType); + }, 0); spawnFunction = () => getApp().getServerManager().spawnAircrafts(units, coalition, airbase, country, immediate, spawnPoints, callback); } else if (category === "helicopter") { if (airbase == "" && spawnsRestricted) { getApp().addInfoMessage("Helicopters can be air spawned during the SETUP phase only"); return false; } - spawnPoints = getApp().getMissionManager().getCommandModeOptions().commandMode === GAME_MASTER? 0: units.reduce((points: number, unit: UnitSpawnTable) => { - return points + this.getDatabase().getSpawnPointsByName(unit.unitType); - }, 0); + spawnPoints = + getApp().getMissionManager().getCommandModeOptions().commandMode === GAME_MASTER + ? 0 + : units.reduce((points: number, unit: UnitSpawnTable) => { + return points + this.getDatabase().getSpawnPointsByName(unit.unitType); + }, 0); spawnFunction = () => getApp().getServerManager().spawnHelicopters(units, coalition, airbase, country, immediate, spawnPoints, callback); } else if (category === "groundunit") { if (spawnsRestricted) { getApp().addInfoMessage("Ground units can be spawned during the SETUP phase only"); return false; } - spawnPoints = getApp().getMissionManager().getCommandModeOptions().commandMode === GAME_MASTER? 0: units.reduce((points: number, unit: UnitSpawnTable) => { - return points + this.getDatabase().getSpawnPointsByName(unit.unitType); - }, 0); + spawnPoints = + getApp().getMissionManager().getCommandModeOptions().commandMode === GAME_MASTER + ? 0 + : units.reduce((points: number, unit: UnitSpawnTable) => { + return points + this.getDatabase().getSpawnPointsByName(unit.unitType); + }, 0); spawnFunction = () => getApp().getServerManager().spawnGroundUnits(units, coalition, country, immediate, spawnPoints, callback); } else if (category === "navyunit") { if (spawnsRestricted) { getApp().addInfoMessage("Navy units can be spawned during the SETUP phase only"); return false; } - spawnPoints = getApp().getMissionManager().getCommandModeOptions().commandMode === GAME_MASTER? 0: units.reduce((points: number, unit: UnitSpawnTable) => { - return points + this.getDatabase().getSpawnPointsByName(unit.unitType); - }, 0); + spawnPoints = + getApp().getMissionManager().getCommandModeOptions().commandMode === GAME_MASTER + ? 0 + : units.reduce((points: number, unit: UnitSpawnTable) => { + return points + this.getDatabase().getSpawnPointsByName(unit.unitType); + }, 0); spawnFunction = () => getApp().getServerManager().spawnNavyUnits(units, coalition, country, immediate, spawnPoints, callback); } @@ -1728,20 +1802,32 @@ export class UnitsManager { return this.#AWACSReference; } - computeClusters(filter: (unit: Unit) => boolean = (unit) => true, distance: number = 5 /* km */) { + computeClusters( + unitType: typeof AirUnit | typeof GroundUnit | typeof NavyUnit, + filter: (unit: Unit) => boolean = (unit) => true, + distance: number = 5 /* km */, + coalition?: Coalition, + minPoints?: number + ) { let units = Object.values(this.#units) - .filter((unit) => unit.getAlive() && unit instanceof AirUnit) + .filter((unit) => unit.getAlive() && unit instanceof unitType) .filter(filter); + if (coalition !== undefined) { + units = units.filter((unit) => unit.getCoalition() === coalition); + } + 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 }); + var clustered = turf.clustersDbscan(geojson, distance, { minPoints: 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]); + if (feature.properties.cluster !== undefined) { + if (clusters[feature.properties.cluster] === undefined) clusters[feature.properties.cluster] = [] as Unit[]; + clusters[feature.properties.cluster].push(units[idx]); + } }); return clusters; From 326fbff982547beb5b716e835987a397fa310d77 Mon Sep 17 00:00:00 2001 From: Davide Passoni Date: Fri, 21 Mar 2025 17:12:20 +0100 Subject: [PATCH 2/3] fix: Incorrect representation of some ground unit markers --- .../images/units/map/normal/blue/groundunit-sam-launcher.svg | 2 +- .../images/units/map/normal/neutral/groundunit-sam-launcher.svg | 2 +- .../images/units/map/normal/red/groundunit-sam-launcher.svg | 2 +- frontend/react/src/map/markers/stylesheets/units.css | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/react/public/images/units/map/normal/blue/groundunit-sam-launcher.svg b/frontend/react/public/images/units/map/normal/blue/groundunit-sam-launcher.svg index ebe2ff8f..07583538 100644 --- a/frontend/react/public/images/units/map/normal/blue/groundunit-sam-launcher.svg +++ b/frontend/react/public/images/units/map/normal/blue/groundunit-sam-launcher.svg @@ -13,7 +13,7 @@ xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"> Date: Fri, 21 Mar 2025 17:40:25 +0100 Subject: [PATCH 3/3] fix: Added databases copy step in build scripts --- frontend/server/scripts/build-release.bat | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/server/scripts/build-release.bat b/frontend/server/scripts/build-release.bat index 388a6375..2e12fc2c 100644 --- a/frontend/server/scripts/build-release.bat +++ b/frontend/server/scripts/build-release.bat @@ -1,6 +1,7 @@ call npm run tsc echo D|xcopy /Y /S /E .\public ..\..\build\frontend\public +echo D|xcopy /Y /S /E .\databases ..\..\build\frontend\public\databases echo D|xcopy /Y /S /E .\views ..\..\build\frontend\cert echo D|xcopy /Y /S /E .\build ..\..\build\frontend\build