diff --git a/backend/core/include/commands.h b/backend/core/include/commands.h index 638ad877..64e6a2ba 100644 --- a/backend/core/include/commands.h +++ b/backend/core/include/commands.h @@ -432,10 +432,10 @@ private: }; /* Shine a laser with a specific code */ -class Laser : public Command +class FireLaser : public Command { public: - Laser(unsigned int ID, unsigned int code, Coords destination, function callback = []() {}) : + FireLaser(unsigned int ID, unsigned int code, Coords destination, function callback = []() {}) : Command(callback), ID(ID), destination(destination), @@ -453,10 +453,10 @@ private: }; /* Shine a infrared light */ -class Infrared : public Command +class FireInfrared : public Command { public: - Infrared(unsigned int ID, Coords destination, function callback = []() {}) : + FireInfrared(unsigned int ID, Coords destination, function callback = []() {}) : Command(callback), ID(ID), destination(destination) @@ -470,3 +470,58 @@ private: const unsigned int ID; const Coords destination; }; + +/* Change a laser code */ +class SetLaserCode : public Command +{ +public: + SetLaserCode(unsigned int spotID, unsigned int code, function callback = []() {}) : + Command(callback), + spotID(spotID), + code(code) + { + priority = CommandPriority::LOW; + }; + virtual string getString(); + virtual unsigned int getLoad() { return 5; } + +private: + const unsigned int spotID; + const unsigned int code; +}; + +/* Delete a spot code */ +class DeleteSpot : public Command +{ +public: + DeleteSpot(unsigned int spotID, function callback = []() {}) : + Command(callback), + spotID(spotID) + { + priority = CommandPriority::LOW; + }; + virtual string getString(); + virtual unsigned int getLoad() { return 5; } + +private: + const unsigned int spotID; +}; + +/* Move spot to a new target */ +class MoveSpot : public Command +{ +public: + MoveSpot(unsigned int spotID, Coords destination, function callback = []() {}) : + Command(callback), + spotID(spotID), + destination(destination) + { + priority = CommandPriority::LOW; + }; + virtual string getString(); + virtual unsigned int getLoad() { return 5; } + +private: + const unsigned int spotID; + const Coords destination; +}; \ No newline at end of file diff --git a/backend/core/src/commands.cpp b/backend/core/src/commands.cpp index b6f7b34e..240bfb87 100644 --- a/backend/core/src/commands.cpp +++ b/backend/core/src/commands.cpp @@ -259,8 +259,8 @@ string Explosion::getString() return commandSS.str(); } -/* Laser command */ -string Laser::getString() +/* FireLaser command */ +string FireLaser::getString() { std::ostringstream commandSS; commandSS.precision(10); @@ -272,8 +272,8 @@ string Laser::getString() return commandSS.str(); } -/* Infrared command */ -string Infrared::getString() +/* FireInfrared command */ +string FireInfrared::getString() { std::ostringstream commandSS; commandSS.precision(10); @@ -282,4 +282,37 @@ string Infrared::getString() << destination.lat << ", " << destination.lng; return commandSS.str(); +} + +/* SetLaserCode command */ +string SetLaserCode::getString() +{ + std::ostringstream commandSS; + commandSS.precision(10); + commandSS << "Olympus.setLaserCode, " + << spotID << ", " + << code; + return commandSS.str(); +} + +/* MoveSpot command */ +string MoveSpot::getString() +{ + std::ostringstream commandSS; + commandSS.precision(10); + commandSS << "Olympus.moveSpot, " + << spotID << ", " + << destination.lat << ", " + << destination.lng; + return commandSS.str(); +} + +/* DeleteSpot command */ +string DeleteSpot::getString() +{ + std::ostringstream commandSS; + commandSS.precision(10); + commandSS << "Olympus.deleteSpot, " + << spotID; + return commandSS.str(); } \ No newline at end of file diff --git a/backend/core/src/scheduler.cpp b/backend/core/src/scheduler.cpp index b45fb9eb..5e167a53 100644 --- a/backend/core/src/scheduler.cpp +++ b/backend/core/src/scheduler.cpp @@ -705,9 +705,9 @@ void Scheduler::handleRequest(string key, json::value value, string username, js Coords loc; loc.lat = lat; loc.lng = lng; unsigned int code = value[L"code"].as_integer(); - log("Adding laser with code " + to_string(code) + " from unit " + unit->getUnitName() + " to (" + to_string(lat) + ", " + to_string(lng) + ")"); + log("Firing laser with code " + to_string(code) + " from unit " + unit->getUnitName() + " to (" + to_string(lat) + ", " + to_string(lng) + ")"); - command = dynamic_cast(new Laser(ID, code, loc)); + command = dynamic_cast(new FireLaser(ID, code, loc)); } } /************************/ @@ -719,13 +719,41 @@ void Scheduler::handleRequest(string key, json::value value, string username, js double lat = value[L"location"][L"lat"].as_double(); double lng = value[L"location"][L"lng"].as_double(); Coords loc; loc.lat = lat; loc.lng = lng; - - log("Adding infrared from unit " + unit->getUnitName() + " to (" + to_string(lat) + ", " + to_string(lng) + ")"); - command = dynamic_cast(new Infrared(ID, loc)); + log("Firing infrared from unit " + unit->getUnitName() + " to (" + to_string(lat) + ", " + to_string(lng) + ")"); + + command = dynamic_cast(new FireInfrared(ID, loc)); } } /************************/ + else if (key.compare("setLaserCode") == 0) + { + unsigned int spotID = value[L"spotID"].as_integer(); + unsigned int code = value[L"code"].as_integer(); + + log("Setting laser code " + to_string(code) + " to spot with ID " + to_string(spotID)); + command = dynamic_cast(new SetLaserCode(spotID, code)); + } + /************************/ + else if (key.compare("moveSpot") == 0) + { + unsigned int spotID = value[L"spotID"].as_integer(); + + double lat = value[L"location"][L"lat"].as_double(); + double lng = value[L"location"][L"lng"].as_double(); + Coords loc; loc.lat = lat; loc.lng = lng; + + log("Moving spot with ID " + to_string(spotID) + " to (" + to_string(lat) + ", " + to_string(lng) + ")"); + command = dynamic_cast(new MoveSpot(spotID, loc)); + } + /************************/ + else if (key.compare("deleteSpot") == 0) + { + unsigned int spotID = value[L"spotID"].as_integer(); + log("Deleting spot with ID " + to_string(spotID)); + command = dynamic_cast(new DeleteSpot(spotID)); + } + /************************/ else if (key.compare("setCommandModeOptions") == 0) { setCommandModeOptions(value); diff --git a/frontend/react/package.json b/frontend/react/package.json index 938a2473..012f0c6e 100644 --- a/frontend/react/package.json +++ b/frontend/react/package.json @@ -9,17 +9,32 @@ "preview": "vite preview" }, "dependencies": { - + "chart.js": "^4.4.7", + "react-chartjs-2": "^5.3.0", + "react-circular-progressbar": "^2.1.0", + "react-clock": "^5.1.0" }, "devDependencies": { "@eslint/js": "^9.6.0", + "@fortawesome/fontawesome-svg-core": "^6.5.1", + "@fortawesome/free-brands-svg-icons": "^6.5.2", + "@fortawesome/free-regular-svg-icons": "^6.6.0", + "@fortawesome/free-solid-svg-icons": "^6.5.1", + "@fortawesome/react-fontawesome": "^0.2.0", + "@tanem/svg-injector": "^10.1.68", "@turf/clusters": "^7.1.0", + "@turf/turf": "^6.5.0", + "@types/dom-webcodecs": "^0.1.12", + "@types/leaflet": "^1.9.8", "@types/node": "^22.5.1", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", + "@types/react-leaflet": "^3.0.0", + "@types/turf": "^3.5.32", "@typescript-eslint/parser": "^7.14.1", "@vitejs/plugin-react": "^4.2.1", "autoprefixer": "^10.4.19", + "buffer": "^6.0.3", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", @@ -28,26 +43,6 @@ "eslint-plugin-react-refresh": "^0.4.6", "eslint-plugin-readable-tailwind": "^1.5.2", "globals": "^15.7.0", - "postcss": "^8.4.38", - "prettier": "^3.3.2", - "tailwindcss": "^3.4.3", - "typescript-eslint": "^7.14.1", - "vite": "^5.2.0", - "vite-plugin-externals": "^0.6.2", - "vite-plugin-file": "^1.0.5", - "web-audio-peak-meter": "^3.1.0", - "@fortawesome/fontawesome-svg-core": "^6.5.1", - "@fortawesome/free-brands-svg-icons": "^6.5.2", - "@fortawesome/free-regular-svg-icons": "^6.6.0", - "@fortawesome/free-solid-svg-icons": "^6.5.1", - "@fortawesome/react-fontawesome": "^0.2.0", - "@tanem/svg-injector": "^10.1.68", - "@turf/turf": "^6.5.0", - "@types/dom-webcodecs": "^0.1.12", - "@types/leaflet": "^1.9.8", - "@types/react-leaflet": "^3.0.0", - "@types/turf": "^3.5.32", - "buffer": "^6.0.3", "js-sha256": "^0.11.0", "jsstore": "^4.8.2", "leaflet": "^1.9.4", @@ -55,11 +50,19 @@ "leaflet-path-drag": "^1.9.5", "magvar": "^1.1.5", "opus-decoder": "^0.7.6", + "postcss": "^8.4.38", + "prettier": "^3.3.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^5.0.1", "react-leaflet": "^4.2.1", + "tailwindcss": "^3.4.3", "turf": "^3.0.14", - "usng": "^0.3.0" + "typescript-eslint": "^7.14.1", + "usng": "^0.3.0", + "vite": "^5.2.0", + "vite-plugin-externals": "^0.6.2", + "vite-plugin-file": "^1.0.5", + "web-audio-peak-meter": "^3.1.0" } } diff --git a/frontend/react/src/constants/constants.ts b/frontend/react/src/constants/constants.ts index 79e5b304..93a9a57a 100644 --- a/frontend/react/src/constants/constants.ts +++ b/frontend/react/src/constants/constants.ts @@ -496,6 +496,7 @@ 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 MAX_SHOTS_SCATTER = 3; export const MAX_SHOTS_INTENSITY = 3; diff --git a/frontend/react/src/map/map.ts b/frontend/react/src/map/map.ts index 97f781a0..c83a82f5 100644 --- a/frontend/react/src/map/map.ts +++ b/frontend/react/src/map/map.ts @@ -69,7 +69,7 @@ import { SmokeMarker } from "./markers/smokemarker"; /* Register the handler for the box selection */ L.Map.addInitHook("addHandler", "boxSelect", BoxSelect); -initDraggablePath(L); // TODO: breaks app when compiled +initDraggablePath(L); export class Map extends L.Map { /* Options */ diff --git a/frontend/react/src/map/markers/destinationpreviewHandle.ts b/frontend/react/src/map/markers/destinationpreviewHandle.ts deleted file mode 100644 index 62431a02..00000000 --- a/frontend/react/src/map/markers/destinationpreviewHandle.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { DivIcon, LatLng } from "leaflet"; -import { CustomMarker } from "./custommarker"; - -export class DestinationPreviewHandle extends CustomMarker { - constructor(latlng: LatLng) { - super(latlng, { interactive: true, draggable: true }); - } - - createIcon() { - this.setIcon( - new DivIcon({ - iconSize: [18, 18], - iconAnchor: [9, 9], - className: "leaflet-destination-preview-handle-marker", - }) - ); - var el = document.createElement("div"); - el.classList.add("ol-destination-preview-handle-icon"); - this.getElement()?.appendChild(el); - } -} diff --git a/frontend/react/src/map/markers/spoteditmarker.ts b/frontend/react/src/map/markers/spoteditmarker.ts index c37aec0b..c8a5c369 100644 --- a/frontend/react/src/map/markers/spoteditmarker.ts +++ b/frontend/react/src/map/markers/spoteditmarker.ts @@ -1,4 +1,4 @@ -import { Marker, LatLng, DivIcon, DomEvent, Map } from "leaflet"; +import { Marker, LatLng, DivIcon, Map } from "leaflet"; export class SpotEditMarker extends Marker { #textValue: string; @@ -6,14 +6,28 @@ export class SpotEditMarker extends Marker { #rotationAngle: number; // Rotation angle in radians #previousValue: string; - constructor(latlng: LatLng, textValue: string, rotationAngle: number = 0) { + onValueUpdated: (value: number) => void = () => {}; + onDeleteButtonClicked: () => void = () => {}; + + /** + * Constructor for SpotEditMarker + * @param {LatLng} latlng - The geographical position of the marker. + * @param {string} textValue - The initial text value to display. + * @param {number} rotationAngle - The initial rotation angle in radians. + */ + constructor(latlng: LatLng, textValue: string, rotationAngle: number = 0, type: string) { super(latlng, { icon: new DivIcon({ className: "leaflet-spot-input-marker", - html: `
+ html: + type === "laser" + ? `
${textValue}
X
+
` + : `
+
X
`, }), }); @@ -23,21 +37,34 @@ export class SpotEditMarker extends Marker { this.#previousValue = textValue; } + /** + * Called when the marker is added to the map. + * @param {Map} map - The map instance. + * @returns {this} - The current instance of SpotEditMarker. + */ onAdd(map: Map): this { super.onAdd(map); const element = this.getElement(); if (element) { - const text = element.querySelector(".text"); - const button = element.querySelector(".delete"); + const container = element.querySelector(".container") as HTMLDivElement; + const button = element.querySelector(".delete") as HTMLDivElement; const input = element.querySelector(".input") as HTMLInputElement; // Add click event listener to toggle edit mode - text?.addEventListener("mousedown", (ev) => this.#toggleEditMode(ev)); - text?.addEventListener("mouseup", (ev) => this.#stopEventPropagation(ev)); - text?.addEventListener("dblclick", (ev) => this.#stopEventPropagation(ev)); + container?.addEventListener("mousedown", (ev) => { + ev.stopPropagation(); + ev.preventDefault(); + this.#setEditMode(ev, !this.#isEditable); + }); + container?.addEventListener("mouseup", (ev) => this.#stopEventPropagation(ev)); + container?.addEventListener("dblclick", (ev) => this.#stopEventPropagation(ev)); // Add click event listener to delete spot - button?.addEventListener("mousedown", (ev) => this.#stopEventPropagation(ev)); + button?.addEventListener("mousedown", (ev) => { + ev.stopPropagation(); + ev.preventDefault(); + this.#onButtonClicked(ev); + }); button?.addEventListener("mouseup", (ev) => this.#stopEventPropagation(ev)); button?.addEventListener("dblclick", (ev) => this.#stopEventPropagation(ev)); @@ -45,46 +72,62 @@ export class SpotEditMarker extends Marker { input?.addEventListener("mousedown", (ev) => this.#stopEventPropagation(ev)); input?.addEventListener("mouseup", (ev) => this.#stopEventPropagation(ev)); input?.addEventListener("dblclick", (ev) => this.#stopEventPropagation(ev)); - input?.addEventListener("blur", (ev) => this.#toggleEditMode(ev)); - input?.addEventListener("keydown", (ev) => this.#acceptInput(ev)); + input?.addEventListener("blur", (ev) => this.#setEditMode(ev, false)); + input?.addEventListener("keydown", (ev) => this.#onKeyDown(ev)); input?.addEventListener("input", (ev) => this.#validateInput(ev)); } return this; } + /** + * Called when the marker is removed from the map. + * @param {Map} map - The map instance. + * @returns {this} - The current instance of SpotEditMarker. + */ onRemove(map: Map): this { super.onRemove(map); const element = this.getElement(); if (element) { - const text = element.querySelector(".text"); - const button = element.querySelector(".delete"); - const input = element.querySelector(".input"); + const container = element.querySelector(".container") as HTMLDivElement; + const button = element.querySelector(".delete") as HTMLDivElement; + const input = element.querySelector(".input") as HTMLInputElement; - // Add click event listener to toggle edit mode - text?.removeEventListener("mousedown", (ev) => this.#toggleEditMode(ev)); - text?.removeEventListener("mouseup", (ev) => this.#stopEventPropagation(ev)); - text?.removeEventListener("dblclick", (ev) => this.#stopEventPropagation(ev)); + // Remove click event listener to toggle edit mode + container?.removeEventListener("mousedown", (ev) => { + ev.stopPropagation(); + ev.preventDefault(); + this.#setEditMode(ev, !this.#isEditable); + }); + container?.removeEventListener("mouseup", (ev) => this.#stopEventPropagation(ev)); + container?.removeEventListener("dblclick", (ev) => this.#stopEventPropagation(ev)); - // Add click event listener to delete spot - button?.removeEventListener("mousedown", (ev) => this.#stopEventPropagation(ev)); + // Remove click event listener to delete spot + button?.removeEventListener("mousedown", (ev) => { + ev.stopPropagation(); + ev.preventDefault(); + this.#onButtonClicked(ev); + }); button?.removeEventListener("mouseup", (ev) => this.#stopEventPropagation(ev)); button?.removeEventListener("dblclick", (ev) => this.#stopEventPropagation(ev)); - // Add click event listener to input spot + // Remove click event listener to input spot input?.removeEventListener("mousedown", (ev) => this.#stopEventPropagation(ev)); input?.removeEventListener("mouseup", (ev) => this.#stopEventPropagation(ev)); input?.removeEventListener("dblclick", (ev) => this.#stopEventPropagation(ev)); - input?.removeEventListener("blur", (ev) => this.#toggleEditMode(ev)); - input?.removeEventListener("keydown", (ev) => this.#acceptInput(ev)); + input?.removeEventListener("blur", (ev) => this.#setEditMode(ev, false)); + input?.removeEventListener("keydown", (ev) => this.#onKeyDown(ev)); input?.removeEventListener("input", (ev) => this.#validateInput(ev)); } return this; } - // Method to set the text value + /** + * Sets the text value of the marker. + * @param {string} textValue - The new text value. + */ setTextValue(textValue: string) { this.#textValue = textValue; const element = this.getElement(); @@ -94,12 +137,18 @@ export class SpotEditMarker extends Marker { } } - // Method to get the text value + /** + * Gets the text value of the marker. + * @returns {string} - The current text value. + */ getTextValue() { return this.#textValue; } - // Method to set the rotation angle in radians + /** + * Sets the rotation angle of the marker in radians. + * @param {number} angle - The new rotation angle in radians. + */ setRotationAngle(angle: number) { this.#rotationAngle = angle; if (!this.#isEditable) { @@ -107,12 +156,17 @@ export class SpotEditMarker extends Marker { } } - // Method to get the rotation angle in radians + /** + * Gets the rotation angle of the marker in radians. + * @returns {number} - The current rotation angle in radians. + */ getRotationAngle() { return this.#rotationAngle; } - // Method to update the rotation angle to ensure the text is always readable + /** + * Updates the rotation angle to ensure the text is always readable. + */ #updateRotation() { const element = this.getElement(); if (element) { @@ -129,11 +183,12 @@ export class SpotEditMarker extends Marker { } } - #toggleEditMode(ev) { - this.#isEditable = !this.#isEditable; - - ev.stopPropagation(); - ev.preventDefault(); + /** + * Toggles the edit mode of the marker. + * @param {Event} ev - The event object. + */ + #setEditMode(ev: Event, editable: boolean) { + this.#isEditable = editable; const element = this.getElement(); if (element) { @@ -167,23 +222,43 @@ export class SpotEditMarker extends Marker { } } - #stopEventPropagation(ev) { + /** + * Stops the event propagation. + * @param {Event} ev - The event object. + */ + #stopEventPropagation(ev: Event) { ev.stopPropagation(); } - #validateInput(ev) { + /** + * Validates the input value. + * @param {Event} ev - The event object. + */ + #validateInput(ev: Event) { const element = this.getElement(); if (element) { const input = ev.target as HTMLInputElement; const text = element.querySelector(".text"); - // Validate the input value + // Validate the input value (must be a valid laser code) const value = input.value; // Check if the value is a partial valid input + // Conditions for partial validity: + // 1. The first digit is 1. + // 2. The first digit is 1 and the second digit is between 1 and 7. + // 3. The first digit is 1, the second digit is between 1 and 7, and the third digit is between 1 and 8. + // 4. The first digit is 1, the second digit is between 1 and 7, the third digit is between 1 and 8, and the fourth digit is between 1 and 8. const isPartialValid = /^[1]$|^[1][1-7]$|^[1][1-7][1-8]$|^[1][1-7][1-8][1-8]$/.test(value); // Check if the value is a complete valid input + // Conditions for complete validity: + // 1. The input is a four-digit number where: + // - The first digit is 1. + // - The second digit is between 1 and 7. + // - The third digit is between 1 and 8. + // - The fourth digit is between 1 and 8. + // 2. The number is between 1111 and 1788. const isValid = /^[1][1-7][1-8][1-8]$/.test(value) && Number(value) <= 1788; if (isPartialValid || isValid) { @@ -197,6 +272,9 @@ export class SpotEditMarker extends Marker { } } + /** + * Handles invalid input by causing a small red flash around the input element. + */ #errorFunction() { const element = this.getElement(); if (element) { @@ -210,7 +288,59 @@ export class SpotEditMarker extends Marker { } } + /** + * Handles the key down event. + * @param {Event} ev - The keyboard event object. + */ + #onKeyDown(ev) { + if (ev.key === "Enter") this.#acceptInput(ev); + else if (ev.key === "Escape") this.#setEditMode(ev, false); + } + + /** + * Accepts the input value when the Enter key is pressed. + * @param {Event} ev - The keyboard event object. + */ #acceptInput(ev) { - if (ev.key === "Enter") this.#toggleEditMode(ev); + const element = this.getElement(); + if (element) { + const input = element.querySelector(".input") as HTMLInputElement; + if (input) { + // Validate the input value (must be a valid laser code) + const value = Number(input.value); + if (value >= 1111 && value <= 1788) { + this.#setEditMode(ev, false); + this.onValueUpdated(value); + this.#successFunction(); + } else { + this.#errorFunction(); + } + } + } + } + + /** + * Handles the button click event. + * @param {Event} ev - The event object. + */ + #onButtonClicked(ev: Event) { + if (this.#isEditable) this.#acceptInput(ev); + else this.onDeleteButtonClicked(); + } + + /** + * Handles valid input by causing a green flash around the container. + */ + #successFunction() { + const element = this.getElement(); + if (element) { + const container = element.querySelector(".container") as HTMLDivElement; + if (container) { + container.classList.add("success-flash"); + setTimeout(() => { + container.classList.remove("success-flash"); + }, 900); // Duration of the flash effect (3 flashes, 300ms each) + } + } } } diff --git a/frontend/react/src/map/markers/spotmarker.ts b/frontend/react/src/map/markers/spotmarker.ts index 23f63d08..a13175ab 100644 --- a/frontend/react/src/map/markers/spotmarker.ts +++ b/frontend/react/src/map/markers/spotmarker.ts @@ -4,7 +4,8 @@ import { CustomMarker } from "./custommarker"; export class SpotMarker extends CustomMarker { constructor(latlng: LatLngExpression, options?: MarkerOptions) { super(latlng, options); - this.options.interactive = false; + this.options.interactive = true; + this.options.draggable = true; this.setZIndexOffset(9999); } diff --git a/frontend/react/src/map/markers/stylesheets/spot.css b/frontend/react/src/map/markers/stylesheets/spot.css index 2eaf8182..d108c5bc 100644 --- a/frontend/react/src/map/markers/stylesheets/spot.css +++ b/frontend/react/src/map/markers/stylesheets/spot.css @@ -1,3 +1,4 @@ +/* Container for the spot input marker */ .leaflet-spot-input-marker { text-align: center; display: flex !important; @@ -5,6 +6,7 @@ align-items: center; } +/* Delete button styles */ .leaflet-spot-input-marker .delete { background-color: darkred; color: #fffd; @@ -17,10 +19,12 @@ align-content: center; } +/* Delete button hover effect */ .leaflet-spot-input-marker .delete:hover { background-color: lightcoral; } +/* Container for the spot input marker content */ .leaflet-spot-input-marker .container { width: fit-content; display: flex; @@ -31,14 +35,17 @@ font-size: 13px; column-gap: 6px; align-content: center; + border: 2px solid transparent; } +/* Text inside the spot input marker */ .leaflet-spot-input-marker .text { margin-left: 12px; margin-top: auto; margin-bottom: auto; } +/* Input field inside the spot input marker */ .leaflet-spot-input-marker .input { display: none; color: white; @@ -50,25 +57,32 @@ outline: none; /* Remove default outline */ font-size: 13px; width: 80px; + border-top-left-radius: 999px; + border-bottom-left-radius: 999px; } +/* Input field focus effect */ .leaflet-spot-input-marker .input:focus { border-color: #777; /* Border color on focus */ box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); /* Shadow on focus */ } +/* Hide input field inside the container */ .leaflet-spot-input-marker .container .input { display: none; } +/* Hide text in edit mode */ .leaflet-spot-input-marker .container .text.edit-mode { display: none; } +/* Error flash animation for input field */ .leaflet-spot-input-marker .input.error-flash { animation: error-flash 0.3s; } +/* Keyframes for error flash animation */ @keyframes error-flash { 0% { border-color: #555; @@ -81,10 +95,27 @@ } } +/* Edit mode styles for delete button */ .leaflet-spot-input-marker .delete.edit-mode { background-color: green; } +/* Edit mode hover effect for delete button */ .leaflet-spot-input-marker .delete.edit-mode:hover { background-color: lightgreen; } + +/* Success flash animation for container */ +.leaflet-spot-input-marker .container.success-flash { + animation: success-flash 0.3s 3; +} + +/* Keyframes for success flash animation */ +@keyframes success-flash { + 0%, 100% { + border-color: transparent; + } + 33%, 66% { + border-color: green; + } +} \ No newline at end of file diff --git a/frontend/react/src/mission/missionmanager.ts b/frontend/react/src/mission/missionmanager.ts index 6c22af15..2900dc06 100644 --- a/frontend/react/src/mission/missionmanager.ts +++ b/frontend/react/src/mission/missionmanager.ts @@ -70,11 +70,26 @@ export class MissionManager { updateSpots(data: SpotsData) { for (let idx in data.spots) { const spotID = Number(idx); + const spot = data.spots[idx]; if (this.#spots[spotID] === undefined) { - const spot = data.spots[idx]; this.#spots[spotID] = new Spot(spotID, spot.type, new LatLng(spot.targetPosition.lat, spot.targetPosition.lng), spot.sourceUnitID, spot.code); + } else { + if (spot.type === "laser") + this.#spots[spotID].setCode(spot.code ?? 0) + this.#spots[spotID].setTargetPosition( new LatLng(spot.targetPosition.lat, spot.targetPosition.lng)); } } + + /* Iterate the existing spots and remove all spots that where deleted */ + for (let idx in this.#spots) { + if (data.spots[idx] === undefined) { + delete this.#spots[idx]; + } + } + } + + getSpotByID(spotID: number) { + return this.#spots[spotID]; } /** Update airbase information diff --git a/frontend/react/src/olympusapp.ts b/frontend/react/src/olympusapp.ts index d395f3ff..b00a7bf6 100644 --- a/frontend/react/src/olympusapp.ts +++ b/frontend/react/src/olympusapp.ts @@ -179,7 +179,7 @@ export class OlympusApp { const urlParams = new URLSearchParams(window.location.search); const server = urlParams.get("server"); - if (server == null) { + if (!server) { this.setState(OlympusState.IDLE); /* If no profile exists already with that name, create it from scratch from the defaults */ if (this.getProfile() === null) this.saveProfile(); @@ -342,20 +342,20 @@ export class OlympusApp { } startServerMode() { - ConfigLoadedEvent.on((config) => { - this.getAudioManager().start(); - - Object.values(config.controllers).forEach((controllerOptions) => { - if (controllerOptions.type.toLowerCase() === "awacs") { - this.getControllerManager().addController( - new AWACSController( - { frequency: controllerOptions.frequency, modulation: controllerOptions.modulation }, - controllerOptions.coalition, - controllerOptions.callsign - ) - ); - } - }); - }); + //ConfigLoadedEvent.on((config) => { + // this.getAudioManager().start(); +// + // Object.values(config.controllers).forEach((controllerOptions) => { + // if (controllerOptions.type.toLowerCase() === "awacs") { + // this.getControllerManager().addController( + // new AWACSController( + // { frequency: controllerOptions.frequency, modulation: controllerOptions.modulation }, + // controllerOptions.coalition, + // controllerOptions.callsign + // ) + // ); + // } + // }); + //}); } } diff --git a/frontend/react/src/server/servermanager.ts b/frontend/react/src/server/servermanager.ts index dd37bdcf..c6cfffb9 100644 --- a/frontend/react/src/server/servermanager.ts +++ b/frontend/react/src/server/servermanager.ts @@ -14,7 +14,18 @@ import { emissionsCountermeasures, reactionsToThreat, } from "../constants/constants"; -import { AirbasesData, BullseyesData, CommandModeOptions, GeneralSettings, MissionData, Radio, ServerRequestOptions, ServerStatus, SpotsData, TACAN } from "../interfaces"; +import { + AirbasesData, + BullseyesData, + CommandModeOptions, + GeneralSettings, + MissionData, + Radio, + ServerRequestOptions, + ServerStatus, + SpotsData, + TACAN, +} from "../interfaces"; import { MapOptionsChangedEvent, ServerStatusUpdatedEvent, WrongCredentialsEvent } from "../events"; export class ServerManager { @@ -40,13 +51,15 @@ export class ServerManager { this.#lastUpdateTimes[BULLSEYE_URI] = Date.now(); this.#lastUpdateTimes[MISSION_URI] = Date.now(); - getApp().getShortcutManager().addShortcut("togglePause", { - label: "Pause data update", - keyUpCallback: () => { - this.setPaused(!this.getPaused()); - }, - code: "Enter" - }) + getApp() + .getShortcutManager() + .addShortcut("togglePause", { + label: "Pause data update", + keyUpCallback: () => { + this.setPaused(!this.getPaused()); + }, + code: "Enter", + }); MapOptionsChangedEvent.on((mapOptions) => { /* TODO if (this.#updateMode === "normal" && mapOptions.AWACSMode) { @@ -56,7 +69,7 @@ export class ServerManager { this.#updateMode = "normal"; this.startUpdate(); } */ - }) + }); } setUsername(newUsername: string) { @@ -117,8 +130,10 @@ export class ServerManager { if (xmlHttp.responseType == "arraybuffer") this.#lastUpdateTimes[uri] = callback(xmlHttp.response); else { /* Check if the response headers contain the enabled command modes and set them */ - if (xmlHttp.getResponseHeader("X-Enabled-Command-Modes")) - getApp().getMissionManager().setEnabledCommandModes(xmlHttp.getResponseHeader("X-Enabled-Command-Modes")?.split(",") ??[]) + if (xmlHttp.getResponseHeader("X-Enabled-Command-Modes")) + getApp() + .getMissionManager() + .setEnabledCommandModes(xmlHttp.getResponseHeader("X-Enabled-Command-Modes")?.split(",") ?? []); const result = JSON.parse(xmlHttp.responseText); this.#lastUpdateTimes[uri] = callback(result); @@ -534,14 +549,29 @@ export class ServerManager { this.PUT(data, callback); } - setCommandModeOptions( - commandModeOptions: CommandModeOptions, - callback: CallableFunction = () => {} - ) { + setCommandModeOptions(commandModeOptions: CommandModeOptions, callback: CallableFunction = () => {}) { var data = { setCommandModeOptions: commandModeOptions }; this.PUT(data, callback); } + setLaserCode(spotID: number, code: number, callback: CallableFunction = () => {}) { + var command = { spotID: spotID, code: code }; + var data = { setLaserCode: command }; + this.PUT(data, callback); + } + + moveSpot(spotID: number, latlng: LatLng, callback: CallableFunction = () => {}) { + var command = { spotID: spotID, location: latlng }; + var data = { moveSpot: command }; + this.PUT(data, callback); + } + + deleteSpot(spotID: number, callback: CallableFunction = () => {}) { + var command = { spotID: spotID }; + var data = { deleteSpot: command }; + this.PUT(data, callback); + } + reloadDatabases(callback: CallableFunction = () => {}) { var data = { reloadDatabases: {} }; this.PUT(data, callback); @@ -615,41 +645,44 @@ export class ServerManager { ); this.#intervals.push( - window.setInterval(() => { - if (!this.getPaused() && getApp().getMissionManager().getCommandModeOptions().commandMode != NONE) { - this.getUnits((buffer: ArrayBuffer) => { - var time = getApp().getUnitsManager()?.update(buffer, false); - return time; - }, false); - } - }, this.#updateMode === "normal"? 250: 2000) - ); - - this.#intervals.push( - window.setInterval(() => { - if (!this.getPaused() && getApp().getMissionManager().getCommandModeOptions().commandMode != NONE) { - this.getWeapons((buffer: ArrayBuffer) => { - var time = getApp().getWeaponsManager()?.update(buffer, false); - return time; - }, false); - } - }, this.#updateMode === "normal"? 250: 2000) + window.setInterval( + () => { + if (!this.getPaused() && getApp().getMissionManager().getCommandModeOptions().commandMode != NONE) { + this.getUnits((buffer: ArrayBuffer) => { + var time = getApp().getUnitsManager()?.update(buffer, false); + return time; + }, false); + } + }, + this.#updateMode === "normal" ? 250 : 2000 + ) ); this.#intervals.push( window.setInterval( () => { if (!this.getPaused() && getApp().getMissionManager().getCommandModeOptions().commandMode != NONE) { - this.getUnits((buffer: ArrayBuffer) => { - var time = getApp().getUnitsManager()?.update(buffer, true); + this.getWeapons((buffer: ArrayBuffer) => { + var time = getApp().getWeaponsManager()?.update(buffer, false); return time; - }, true); + }, false); } }, - 5000 + this.#updateMode === "normal" ? 250 : 2000 ) ); + this.#intervals.push( + window.setInterval(() => { + if (!this.getPaused() && getApp().getMissionManager().getCommandModeOptions().commandMode != NONE) { + this.getUnits((buffer: ArrayBuffer) => { + var time = getApp().getUnitsManager()?.update(buffer, true); + return time; + }, true); + } + }, 5000) + ); + // Mission clock and elapsed time this.#intervals.push( window.setInterval(() => { diff --git a/frontend/react/src/ui/serveroverlay.tsx b/frontend/react/src/ui/serveroverlay.tsx index 7b3c65e3..58831b40 100644 --- a/frontend/react/src/ui/serveroverlay.tsx +++ b/frontend/react/src/ui/serveroverlay.tsx @@ -4,12 +4,26 @@ import { ServerStatus } from "../interfaces"; import { FaCheck, FaXmark } from "react-icons/fa6"; import { zeroAppend } from "../other/utils"; import { colors } from "../constants/constants"; +import { CircularProgressbar, buildStyles } from "react-circular-progressbar"; +import "react-circular-progressbar/dist/styles.css"; +import { Line } from "react-chartjs-2"; +import { Chart, LineElement, LinearScale, Title, CategoryScale, Legend, PointElement } from "chart.js"; + +Chart.register(LineElement, LinearScale, Title, CategoryScale, Legend, PointElement); export function ServerOverlay() { const [serverStatus, setServerStatus] = useState({} as ServerStatus); + const [loadData, setLoadData] = useState([]); + const [frameRateData, setFrameRateData] = useState([]); + const [timeLabels, setTimeLabels] = useState([]); useEffect(() => { - ServerStatusUpdatedEvent.on((status) => setServerStatus(status)); + ServerStatusUpdatedEvent.on((status) => { + setServerStatus(status); + setLoadData((prevData) => [...prevData, status.load].slice(-300)); + setFrameRateData((prevData) => [...prevData, status.frameRate].slice(-300)); + setTimeLabels((prevLabels) => [...prevLabels, new Date().toLocaleTimeString()].slice(-300)); + }); }, []); let loadColor = colors.OLYMPUS_GREEN; @@ -20,9 +34,9 @@ export function ServerOverlay() { if (serverStatus.frameRate < 30) frameRateColor = colors.OLYMPUS_RED; else if (serverStatus.frameRate >= 30 && serverStatus.frameRate < 60) frameRateColor = colors.OLYMPUS_ORANGE; - const MThours = serverStatus.missionTime? serverStatus.missionTime.h: 0; - const MTminutes = serverStatus.missionTime? serverStatus.missionTime.m: 0; - const MTseconds = serverStatus.missionTime? serverStatus.missionTime.s: 0; + const MThours = serverStatus.missionTime ? serverStatus.missionTime.h : 0; + const MTminutes = serverStatus.missionTime ? serverStatus.missionTime.m : 0; + const MTseconds = serverStatus.missionTime ? serverStatus.missionTime.s : 0; const EThours = Math.floor((serverStatus.elapsedTime ?? 0) / 3600); const ETminutes = Math.floor((serverStatus.elapsedTime ?? 0) / 60) % 60; @@ -31,43 +45,136 @@ export function ServerOverlay() { let MTtimeString = `${zeroAppend(MThours, 2)}:${zeroAppend(MTminutes, 2)}:${zeroAppend(MTseconds, 2)}`; let ETtimeString = `${zeroAppend(EThours, 2)}:${zeroAppend(ETminutes, 2)}:${zeroAppend(ETseconds, 2)}`; + const missionTime = new Date(); + missionTime.setHours(MThours); + missionTime.setMinutes(MTminutes); + missionTime.setSeconds(MTseconds); + + const data = { + labels: timeLabels, + datasets: [ + { + label: "Server Load", + data: loadData, + borderColor: colors.LIGHT_BLUE, + borderWidth: 2, + fill: false, + pointRadius: 0, + yAxisID: "y-load", // Specify the y-axis for load + }, + { + label: "Server Framerate", + data: frameRateData, + borderColor: colors.WHITE, + borderWidth: 2, + fill: false, + pointRadius: 0, + yAxisID: "y-framerate", // Specify the y-axis for framerate + }, + ], + }; + + const options = { + animation: false, + responsive: true, + scales: { + x: { + type: "category", + labels: timeLabels, + }, + "y-framerate": { + type: "linear", + position: "left", + beginAtZero: true, + max: 120, // Max value for framerate + }, + "y-load": { + type: "linear", + position: "right", + beginAtZero: true, + max: 2000, // Max value for load + grid: { + drawOnChartArea: false, // Only want the grid lines for one axis to show up + }, + }, + }, + plugins: { + legend: { + display: true, + position: "top", + }, + }, + }; + return (
-
-

DCS Olympus server

-
-
-
Connected to DCS:
-
{serverStatus.connected? : }
+
+

DCS Olympus Server

+
+
+
+
Connected to DCS:
+
{serverStatus.connected ? : }
+
+
+
Elapsed time:
+
{ETtimeString}
+
+
+
Mission local time:
+
{MTtimeString}
+
-
-
Server load:
-
{serverStatus.load}
-
-
-
Server framerate:
-
{serverStatus.frameRate} fps
-
-
-
Elapsed time:
-
{ETtimeString}
-
-
-
Mission local time:
-
{MTtimeString}
+
+
+
Load
+
+ +
+
+
+
Framerate
+
+ +
+
- +
+ {/* @ts-ignore */} + +
-
); } diff --git a/frontend/react/src/ui/ui.css b/frontend/react/src/ui/ui.css index ca1aa76c..1f5e8ac4 100644 --- a/frontend/react/src/ui/ui.css +++ b/frontend/react/src/ui/ui.css @@ -121,4 +121,32 @@ input[type="range"]:focus::-moz-range-thumb { 100% { width: 200px; } -} \ No newline at end of file +} + +.bouncing-ball { + position: relative; + width: 100px; + height: 100px; + background-color: transparent; + border-radius: 50%; + overflow: hidden; + animation: bounce 2s infinite; +} + +.ball-logo { + width: 100%; + height: 100%; + object-fit: cover; +} + +@keyframes bounce { + 0%, 20%, 50%, 80%, 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-150px); + } + 60% { + transform: translateY(-75px); + } +} diff --git a/frontend/react/src/ui/ui.tsx b/frontend/react/src/ui/ui.tsx index 8dd125eb..16558c82 100644 --- a/frontend/react/src/ui/ui.tsx +++ b/frontend/react/src/ui/ui.tsx @@ -8,13 +8,7 @@ import { MainMenu } from "./panels/mainmenu"; import { SideBar } from "./panels/sidebar"; import { OptionsMenu } from "./panels/optionsmenu"; import { MapHiddenTypes, MapOptions } from "../types/types"; -import { - NO_SUBSTATE, - OlympusState, - OlympusSubState, - OptionsSubstate, - UnitControlSubState -} from "../constants/constants"; +import { NO_SUBSTATE, OlympusState, OlympusSubState, OptionsSubstate, UnitControlSubState } from "../constants/constants"; import { getApp, setupApp } from "../olympusapp"; import { LoginModal } from "./modals/loginmodal"; @@ -30,7 +24,7 @@ import { ProtectionPromptModal } from "./modals/protectionpromptmodal"; import { KeybindModal } from "./modals/keybindmodal"; import { UnitExplosionMenu } from "./panels/unitexplosionmenu"; import { JTACMenu } from "./panels/jtacmenu"; -import { AppStateChangedEvent } from "../events"; +import { AppStateChangedEvent, ServerStatusUpdatedEvent } from "../events"; import { GameMasterMenu } from "./panels/gamemastermenu"; import { InfoBar } from "./panels/infobar"; import { HotGroupBar } from "./panels/hotgroupsbar"; @@ -41,6 +35,7 @@ import { AWACSMenu } from "./panels/awacsmenu"; import { ServerOverlay } from "./serveroverlay"; import { ImportExportModal } from "./modals/importexportmodal"; import { WarningModal } from "./modals/warningmodal"; +import { ServerStatus } from "../interfaces"; export type OlympusUIState = { mainMenuVisible: boolean; @@ -57,12 +52,19 @@ export type OlympusUIState = { export function UI() { const [appState, setAppState] = useState(OlympusState.NOT_INITIALIZED); const [appSubState, setAppSubState] = useState(NO_SUBSTATE as OlympusSubState); + const [serverStatus, setServerStatus] = useState({} as ServerStatus); + const [connectedOnce, setConnectedOnce] = useState(false); useEffect(() => { AppStateChangedEvent.on((state, subState) => { setAppState(state); setAppSubState(subState); }); + ServerStatusUpdatedEvent.on((status) => { + // If we connected at least once, record it + if (status.connected) setConnectedOnce(true); + setServerStatus(status); + }); }, []); useEffect(() => { @@ -76,11 +78,7 @@ export function UI() { font-sans `} > - {appState !== OlympusState.SERVER && ( - <> -
- - )} + {appState !== OlympusState.SERVER &&
}
{appState === OlympusState.SERVER && } @@ -138,6 +136,45 @@ export function UI() { )}
+ + {!serverStatus.connected && appState !== OlympusState.LOGIN && ( +
+
+
+ Olympus Logo +
+ {!connectedOnce &&
Establishing connection
} + {connectedOnce &&
Connection lost
} + {!connectedOnce &&
Trying to connect with the server, please wait...
} + {connectedOnce && ( +
+ Try reloading this page. However, this usually means that you internet connection is down, or that the server is offline, paused, or loading a + new mission. +
+ )} +
+
+ )} + + {appState === OlympusState.NOT_INITIALIZED && ( +
+ Loading... +
+ )}
); } diff --git a/frontend/react/src/unit/unit.ts b/frontend/react/src/unit/unit.ts index 6cb4a894..118be4cd 100644 --- a/frontend/react/src/unit/unit.ts +++ b/frontend/react/src/unit/unit.ts @@ -47,6 +47,7 @@ import { TRAIL_LENGTH, colors, UnitState, + SPOTS_EDIT_ZOOM_TRANSITION, } from "../constants/constants"; import { DataExtractor } from "../server/dataextractor"; import { Weapon } from "../weapon/weapon"; @@ -71,6 +72,7 @@ import { ArrowMarker } from "../map/markers/arrowmarker"; import { Spot } from "../mission/spot"; import { SpotEditMarker } from "../map/markers/spoteditmarker"; import { SpotMarker } from "../map/markers/spotmarker"; +import { get } from "http"; const bearingStrings = ["north", "north-east", "east", "south-east", "south", "south-west", "west", "north-west", "north"]; @@ -180,7 +182,7 @@ export abstract class Unit extends CustomMarker { #racetrackAnchorMarkers: CoalitionAreaHandle[] = [new CoalitionAreaHandle(new LatLng(0, 0)), new CoalitionAreaHandle(new LatLng(0, 0))]; #racetrackArrow: ArrowMarker = new ArrowMarker(new LatLng(0, 0)); #inhibitRacetrackDraw: boolean = false; - #spots: { [key: number]: Polyline } = {}; + #spotLines: { [key: number]: Polyline } = {}; #spotEditMarkers: { [key: number]: SpotEditMarker } = {}; #spotMarkers: { [key: number]: SpotMarker } = {}; @@ -1867,39 +1869,150 @@ export abstract class Unit extends CustomMarker { } #drawSpots() { + // Iterate over all spots and draw lines, edit markers, and markers Object.values(getApp().getMissionManager().getSpots()).forEach((spot: Spot) => { if (spot.getSourceUnitID() === this.ID) { const spotBearing = deg2rad(bearing(this.getPosition().lat, this.getPosition().lng, spot.getTargetPosition().lat, spot.getTargetPosition().lng, false)); const spotDistance = this.getPosition().distanceTo(spot.getTargetPosition()); const midPosition = bearingAndDistanceToLatLng(this.getPosition().lat, this.getPosition().lng, spotBearing, spotDistance / 2); - if (this.#spots[spot.getID()] === undefined) { - this.#spots[spot.getID()] = new Polyline([this.getPosition(), spot.getTargetPosition()], { - color: spot.getType() === "laser" ? colors.BLUE_VIOLET : colors.DARK_RED, - dashArray: "1, 8", - }); - this.#spots[spot.getID()].addTo(getApp().getMap()); - this.#spotEditMarkers[spot.getID()] = new SpotEditMarker(midPosition, `${spot.getCode() ?? ""}`); - this.#spotEditMarkers[spot.getID()].addTo(getApp().getMap()); - this.#spotEditMarkers[spot.getID()].setRotationAngle(spotBearing + Math.PI / 2); - this.#spotMarkers[spot.getID()] = new SpotMarker(spot.getTargetPosition()); - } else { - if (!getApp().getMap().hasLayer(this.#spots[spot.getID()])) this.#spots[spot.getID()].addTo(getApp().getMap()); - if (!getApp().getMap().hasLayer(this.#spotEditMarkers[spot.getID()])) this.#spotEditMarkers[spot.getID()].addTo(getApp().getMap()); - if (!getApp().getMap().hasLayer(this.#spotMarkers[spot.getID()])) this.#spotMarkers[spot.getID()].addTo(getApp().getMap()); - this.#spots[spot.getID()].setLatLngs([this.getPosition(), spot.getTargetPosition()]); - this.#spotEditMarkers[spot.getID()].setLatLng(midPosition); - this.#spotMarkers[spot.getID()].setLatLng(spot.getTargetPosition()); + // Draw the spot line + this.#drawSpotLine(spot, spotBearing); + + // Draw the spot edit marker if the map is zoomed in enough + if (getApp().getMap().getZoom() >= SPOTS_EDIT_ZOOM_TRANSITION) { + // Draw the spot edit marker + this.#drawSpotEditMarker(spot, midPosition, spotBearing); } - this.#spotEditMarkers[spot.getID()].setRotationAngle(spotBearing + Math.PI / 2); - this.#spotEditMarkers[spot.getID()].setTextValue(`${spot.getCode() ?? ""}`); + + // Draw the spot marker + this.#drawSpotMarker(spot); + } + }); + } + + /** + * Draws or updates the spot line. + * @param {Spot} spot - The spot object. + * @param {number} spotBearing - The bearing of the spot. + */ + #drawSpotLine(spot: Spot, spotBearing: number) { + if (this.#spotLines[spot.getID()] === undefined) { + // Create a new polyline for the spot + this.#spotLines[spot.getID()] = new Polyline([this.getPosition(), spot.getTargetPosition()], { + color: spot.getType() === "laser" ? colors.BLUE_VIOLET : colors.DARK_RED, + dashArray: "1, 8", + }); + this.#spotLines[spot.getID()].addTo(getApp().getMap()); + } else { + // Update the existing polyline + if (!getApp().getMap().hasLayer(this.#spotLines[spot.getID()])) this.#spotLines[spot.getID()].addTo(getApp().getMap()); + this.#spotLines[spot.getID()].setLatLngs([this.getPosition(), spot.getTargetPosition()]); + } + + /* Iterate all existing lines and remove those associated to a spot that no longer exists */ + Object.keys(this.#spotLines).forEach((spotID) => { + if (getApp().getMissionManager().getSpotByID(Number(spotID)) === undefined) { + getApp().getMap().removeLayer(this.#spotLines[spotID]); + delete this.#spotLines[spotID]; + } + }); + } + + /** + * Draws or updates the spot edit marker. + * @param {Spot} spot - The spot object. + * @param {LatLng} midPosition - The midpoint position between the unit and the spot. + * @param {number} spotBearing - The bearing of the spot. + */ + #drawSpotEditMarker(spot: Spot, midPosition: LatLng, spotBearing: number) { + if (this.#spotEditMarkers[spot.getID()] === undefined) { + // Create a new spot edit marker + this.#spotEditMarkers[spot.getID()] = new SpotEditMarker(midPosition, `${spot.getCode() ?? ""}`, 0, spot.getType()); + this.#spotEditMarkers[spot.getID()].addTo(getApp().getMap()); + this.#spotEditMarkers[spot.getID()].setRotationAngle(spotBearing + Math.PI / 2); + this.#spotEditMarkers[spot.getID()].onDeleteButtonClicked = () => { + getApp().getServerManager().deleteSpot(spot.getID()); + }; + this.#spotEditMarkers[spot.getID()].onValueUpdated = (value) => { + getApp().getServerManager().setLaserCode(spot.getID(), value); + }; + } else { + // Update the existing spot edit marker + if (!getApp().getMap().hasLayer(this.#spotEditMarkers[spot.getID()])) this.#spotEditMarkers[spot.getID()].addTo(getApp().getMap()); + this.#spotEditMarkers[spot.getID()].setLatLng(midPosition); + } + this.#spotEditMarkers[spot.getID()].setRotationAngle(spotBearing + Math.PI / 2); + this.#spotEditMarkers[spot.getID()].setTextValue(`${spot.getCode() ?? ""}`); + + /* Iterate all existing edit markers and remove those associated to a spot that no longer exists */ + Object.keys(this.#spotEditMarkers).forEach((spotID) => { + if (getApp().getMissionManager().getSpotByID(Number(spotID)) === undefined) { + getApp().getMap().removeLayer(this.#spotEditMarkers[spotID]); + delete this.#spotEditMarkers[spotID]; + } + }); + } + + /** + * Draws or updates the spot marker. + * @param {Spot} spot - The spot object. + */ + #drawSpotMarker(spot: Spot) { + if (this.#spotMarkers[spot.getID()] === undefined) { + // Create a new spot marker + this.#spotMarkers[spot.getID()] = new SpotMarker(spot.getTargetPosition()); + this.#spotMarkers[spot.getID()].addTo(getApp().getMap()); + this.#spotMarkers[spot.getID()].on("dragstart", (event) => { + event.target.options["freeze"] = true; + }); + this.#spotMarkers[spot.getID()].on("dragend", (event) => { + getApp().getServerManager().moveSpot(spot.getID(), event.target.getLatLng()); + event.target.options["freeze"] = false; + }); + } else { + // Update the existing spot marker + if (!getApp().getMap().hasLayer(this.#spotMarkers[spot.getID()])) this.#spotMarkers[spot.getID()].addTo(getApp().getMap()); + var frozen = this.#spotMarkers[spot.getID()].options["freeze"]; + if (!frozen) { + this.#spotMarkers[spot.getID()].setLatLng(spot.getTargetPosition()); + } + } + + /* Iterate all existing markers and remove those associated to a spot that no longer exists */ + Object.keys(this.#spotMarkers).forEach((spotID) => { + if (getApp().getMissionManager().getSpotByID(Number(spotID)) === undefined) { + getApp().getMap().removeLayer(this.#spotMarkers[spotID]); + delete this.#spotMarkers[spotID]; } }); } #clearSpots() { - Object.values(this.#spots).forEach((spot) => getApp().getMap().removeLayer(spot)); + // Clear all spot lines, edit markers, and markers + this.#clearSpotLines(); + this.#clearSpotEditMarkers(); + this.#clearSpotMarkers(); + } + + /** + * Clears all spot lines from the map. + */ + #clearSpotLines() { + Object.values(this.#spotLines).forEach((spot) => getApp().getMap().removeLayer(spot)); + } + + /** + * Clears all spot edit markers from the map. + */ + #clearSpotEditMarkers() { Object.values(this.#spotEditMarkers).forEach((spotEditMarker) => getApp().getMap().removeLayer(spotEditMarker)); + } + + /** + * Clears all spot markers from the map. + */ + #clearSpotMarkers() { Object.values(this.#spotMarkers).forEach((spotMarker) => getApp().getMap().removeLayer(spotMarker)); } @@ -2093,6 +2206,11 @@ export abstract class Unit extends CustomMarker { #onZoom(e: any) { if (this.checkZoomRedraw()) this.#redrawMarker(); this.#updateMarker(); + + // Clear the spot edit markers if the map is zoomed out too much + if (getApp().getMap().getZoom() < SPOTS_EDIT_ZOOM_TRANSITION) { + this.#clearSpotEditMarkers(); + } } } diff --git a/frontend/react/src/unit/unitsmanager.ts b/frontend/react/src/unit/unitsmanager.ts index 8af9e940..71589b28 100644 --- a/frontend/react/src/unit/unitsmanager.ts +++ b/frontend/react/src/unit/unitsmanager.ts @@ -301,7 +301,7 @@ export class UnitsManager { this.#requestDetectionUpdate = false; } - /* Update the detection lines of all the units. This code is handled by the UnitsManager since it must be run both when the detected OR the detecting unit is updated */ + /* Update all the lines of all the selected units. This code is handled by the UnitsManager since, for example, it must be run both when the detected OR the detecting unit is updated */ for (let ID in this.#units) { if (this.#units[ID].getSelected()) this.#units[ID].drawLines(); } diff --git a/scripts/lua/backend/OlympusCommand.lua b/scripts/lua/backend/OlympusCommand.lua index 6aba3015..ef015a01 100644 --- a/scripts/lua/backend/OlympusCommand.lua +++ b/scripts/lua/backend/OlympusCommand.lua @@ -615,6 +615,33 @@ function Olympus.fireInfrared(ID, lat, lng) end end +-- Set new laser code +function Olympus.setLaserCode(spotID, code) + local spot = Olympus.spots[spotID] + if spot ~= nil and spot.type == "laser" then + spot.object:setCode(code) + spot.code = code + end +end + +-- Move spot to a new location +function Olympus.moveSpot(spotID, lat, lng) + local spot = Olympus.spots[spotID] + if spot ~= nil then + spot.object:setPoint(coord.LLtoLO(lat, lng, 0)) + spot.targetPosition = {lat = lat, lng = lng} + end +end + +-- Remove the spot +function Olympus.deleteSpot(spotID) + local spot = Olympus.spots[spotID] + if spot ~= nil then + spot.object:destroy() + Olympus.spots[spotID] = nil + end +end + -- Spawns a new unit or group -- Spawn table contains the following parameters -- category: (string), either Aircraft, Helicopter, GroundUnit or NavyUnit