diff --git a/client/public/stylesheets/other/contextmenus.css b/client/public/stylesheets/other/contextmenus.css
index 172afdac..fd816bb7 100644
--- a/client/public/stylesheets/other/contextmenus.css
+++ b/client/public/stylesheets/other/contextmenus.css
@@ -8,24 +8,20 @@
z-index: 9999;
}
-#map-contextmenu>div:nth-child(2) {
- align-items: center;
+
+
+/* #map-contextmenu>div:nth-child(n+4)>div {
+ width: 100%;
+} */
+
+#map-contextmenu .spawn-mode {
display: flex;
- flex-direction: row;
+ flex-direction: column;
justify-content: space-between;
- padding-right: 0px;
+ row-gap: 5px;
}
-#map-contextmenu>div:nth-child(3) {
- align-items: center;
- display: flex;
- flex-direction: row;
- justify-content: space-between;
- padding-right: 0px;
-}
-
-#map-contextmenu>div:nth-child(n+4) {
- align-items: center;
+.ol-context-menu-panel {
display: flex;
flex-direction: column;
justify-content: space-between;
@@ -33,10 +29,6 @@
padding: 20px;
}
-#map-contextmenu>div:nth-child(n+4)>div {
- width: 100%;
-}
-
.contextmenu-advanced-options,
.contextmenu-metadata {
align-items: center;
@@ -143,20 +135,6 @@
padding: 2px 5px;
}
-/*
-.ol-tag-CA {
- background-color: #FF000022;
-}
-
-.ol-tag-Radar {
- background-color: #00FF0022;
-}
-
-.ol-tag-IR {
- background-color: #0000FF22;
-}
-*/
-
.unit-loadout-list {
min-width: 0;
}
@@ -187,7 +165,65 @@
content: " (" attr(data-points) " points)";
}
-.upper-bar svg>* {
+#spawn-mode-tabs {
+ align-items: center;
+ column-gap: 6px;
+ display: flex;
+ position: absolute;
+ right: 0;
+ top:0;
+ translate: -6px -100%;
+ z-index: 9998;
+}
+
+#spawn-mode-tabs button {
+ align-items: center;
+ border-bottom:2px solid transparent;
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ border-top-left-radius: var(--border-radius-sm);
+ border-top-right-radius: var(--border-radius-sm);
+ display: flex;
+ height:32px;
+ justify-content: center;
+ margin:0;
+ width:38px;
+}
+
+#spawn-mode-tabs button:hover {
+ background-color: var(--background-steel);
+}
+
+[data-coalition="blue"] + #spawn-mode-tabs button {
+ border-bottom-color: var(--primary-blue);
+}
+
+
+[data-coalition="red"] + #spawn-mode-tabs button {
+ border-bottom-color: var(--primary-red);
+}
+
+
+[data-coalition="neutral"] + #spawn-mode-tabs button {
+ border-bottom-color: var(--primary-neutral);
+}
+
+#spawn-mode-tabs button svg {
+ height:24px;
+ margin:6px;
+ width:24px;
+}
+
+.upper-bar {
+ align-items: center;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ padding-right: 0px;
+}
+
+.upper-bar svg>*,
+#spawn-mode-tabs button svg * {
fill: white;
}
@@ -200,24 +236,78 @@
margin-left: auto;
}
+#spawn-history-menu {
+ align-items: center;
+ flex-direction: column;
+ max-height: 300px;
+ row-gap: 6px;
+}
+
+#spawn-history-menu button {
+ align-items: center;
+ column-gap: 6px;
+ display:flex;
+ height:32px;
+ text-align: left;
+ padding:0;
+ width:100%;
+}
+
+#spawn-history-menu button span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+#spawn-history-menu button svg {
+ border-radius: var(--border-radius-sm);
+ height:24px;
+ padding:4px;
+ width:24px;
+}
+
+#spawn-history-menu button:hover {
+ background-color: transparent;
+ text-decoration: underline;
+}
+
+#spawn-history-menu button:hover svg * {
+ fill:white !important;
+}
+
+#spawn-history-menu button[data-spawned-coalition="blue"] svg {
+ background-color: var(--primary-blue);
+}
+
+#spawn-history-menu button[data-spawned-coalition="red"] svg {
+ background-color: var(--primary-red);
+}
+
+#spawn-history-menu button[data-spawned-coalition="neutral"] svg {
+ background-color: var(--primary-neutral);
+}
+
[data-coalition="blue"]#active-coalition-label,
[data-coalition="blue"].deploy-unit-button,
[data-coalition="blue"]#spawn-airbase-aircraft-button,
-[data-coalition="blue"].create-iads-button {
+[data-coalition="blue"].create-iads-button,
+[data-coalition="blue"] + #spawn-mode-tabs button.selected {
background-color: var(--primary-blue)
}
[data-coalition="red"]#active-coalition-label,
[data-coalition="red"].deploy-unit-button,
[data-coalition="red"]#spawn-airbase-aircraft-button,
-[data-coalition="red"].create-iads-button {
+[data-coalition="red"].create-iads-button,
+[data-coalition="red"] + #spawn-mode-tabs button.selected {
background-color: var(--primary-red)
}
[data-coalition="neutral"]#active-coalition-label,
[data-coalition="neutral"].deploy-unit-button,
[data-coalition="neutral"]#spawn-airbase-aircraft-button,
-[data-coalition="neutral"].create-iads-button {
+[data-coalition="neutral"].create-iads-button,
+[data-coalition="neutral"] + #spawn-mode-tabs button.selected {
background-color: var(--primary-neutral)
}
diff --git a/client/public/themes/olympus/images/buttons/other/arrow-down-solid.svg b/client/public/themes/olympus/images/buttons/other/arrow-down-solid.svg
new file mode 100644
index 00000000..a31ed4b6
--- /dev/null
+++ b/client/public/themes/olympus/images/buttons/other/arrow-down-solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/public/themes/olympus/images/buttons/other/clock-rotate-left-solid.svg b/client/public/themes/olympus/images/buttons/other/clock-rotate-left-solid.svg
new file mode 100644
index 00000000..50966792
--- /dev/null
+++ b/client/public/themes/olympus/images/buttons/other/clock-rotate-left-solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/src/constants/constants.ts b/client/src/constants/constants.ts
index de8cfb55..7ada419b 100644
--- a/client/src/constants/constants.ts
+++ b/client/src/constants/constants.ts
@@ -221,16 +221,19 @@ export const MAP_MARKER_CONTROLS: MapMarkerVisibilityControl[] = [{
"toggles": ["dcs"],
"tooltip": "Toggle DCS-controlled units' visibility"
}, {
+ "category": "Aircraft",
"image": "visibility/aircraft.svg",
"name": "Aircraft",
"toggles": ["aircraft"],
"tooltip": "Toggle aircraft's visibility"
}, {
+ "category": "Helicopter",
"image": "visibility/helicopter.svg",
"name": "Helicopter",
"toggles": ["helicopter"],
"tooltip": "Toggle helicopters' visibility"
}, {
+ "category": "AirDefence",
"image": "visibility/groundunit-sam.svg",
"name": "Air defence",
"toggles": ["groundunit-sam"],
@@ -241,6 +244,7 @@ export const MAP_MARKER_CONTROLS: MapMarkerVisibilityControl[] = [{
"toggles": ["groundunit"],
"tooltip": "Toggle ground units' visibility"
}, {
+ "category": "GroundUnit",
"image": "visibility/navyunit.svg",
"name": "Naval",
"toggles": ["navyunit"],
diff --git a/client/src/contextmenus/mapcontextmenu.ts b/client/src/contextmenus/mapcontextmenu.ts
index d7619c99..bff15bc3 100644
--- a/client/src/contextmenus/mapcontextmenu.ts
+++ b/client/src/contextmenus/mapcontextmenu.ts
@@ -5,8 +5,10 @@ import { Switch } from "../controls/switch";
import { GAME_MASTER } from "../constants/constants";
import { CoalitionArea } from "../map/coalitionarea/coalitionarea";
import { AirDefenceUnitSpawnMenu, AircraftSpawnMenu, GroundUnitSpawnMenu, HelicopterSpawnMenu, NavyUnitSpawnMenu } from "../controls/unitspawnmenu";
-import { Airbase } from "../mission/airbase";
import { SmokeMarker } from "../map/markers/smokemarker";
+import { UnitSpawnTable } from "../interfaces";
+import { getCategoryBlueprintIconSVG, getUnitDatabaseByCategory } from "../other/utils";
+import { SVGInjector } from "@tanem/svg-injector";
/** The MapContextMenu is the main contextmenu shown to the user whenever it rightclicks on the map. It is the primary interaction method for the user.
* It allows to spawn units, create explosions and smoke, and edit CoalitionAreas.
@@ -96,6 +98,8 @@ export class MapContextMenu extends ContextMenu {
this.getContainer()?.addEventListener("hide", () => this.#airDefenceUnitSpawnMenu.clearCirclesPreviews());
this.getContainer()?.addEventListener("hide", () => this.#groundUnitSpawnMenu.clearCirclesPreviews());
this.getContainer()?.addEventListener("hide", () => this.#navyUnitSpawnMenu.clearCirclesPreviews());
+
+ this.#setupHistory();
}
/** Show the contextmenu on top of the map, usually at the location where the user has clicked on it.
@@ -257,4 +261,91 @@ export class MapContextMenu extends ContextMenu {
this.#groundUnitSpawnMenu.setCountries();
this.#navyUnitSpawnMenu.setCountries();
}
+
+ /** Handles all of the logic for historal logging.
+ *
+ */
+ #setupHistory() {
+ /* Set up the tab clicks */
+ const spawnModes = this.getContainer()?.querySelectorAll(".spawn-mode");
+ const activeCoalitionLabel = document.getElementById("active-coalition-label");
+ const tabs = this.getContainer()?.querySelectorAll(".spawn-mode-tab");
+
+ // Default selected tab to the "spawn now" option
+ if (tabs) tabs[tabs.length-1].classList.add("selected");
+
+ tabs?.forEach((btn:Element) => {
+ btn.addEventListener("click", (ev:MouseEventInit) => {
+ // Highlight tab
+ tabs.forEach(tab => tab.classList.remove("selected"));
+ btn.classList.add("selected");
+
+ // Hide/reset
+ spawnModes?.forEach(div => div.classList.add("hide"));
+
+ const prevSiblings = [];
+ let prev = btn.previousElementSibling;
+
+ /* Tabs and content windows are assumed to be in the same order */
+ // Count previous
+ while ( prev ) {
+ prevSiblings.push(prev);
+ prev = prev.previousElementSibling;
+ }
+
+ // Show content
+ if (spawnModes && spawnModes[prevSiblings.length]) {
+ spawnModes[prevSiblings.length].classList.remove("hide");
+ }
+
+ // We don't want to see the "Spawn [coalition] unit" label
+ if (activeCoalitionLabel) activeCoalitionLabel.classList.toggle("hide", !btn.hasAttribute("data-show-label"));
+ });
+ });
+
+ const history = document.getElementById("spawn-history-menu");
+ const maxEntries = 20;
+
+ /** Listen for unit spawned **/
+ document.addEventListener( "unitSpawned", (ev:CustomEventInit) => {
+ const buttons = history.querySelectorAll("button");
+ const detail:any = ev.detail;
+ if (buttons.length === 0) history.innerHTML = ""; // Take out any "no data" messages
+ const button = document.createElement("button");
+ button.title = "Click to spawn";
+ button.setAttribute("data-spawned-coalition", detail.coalition);
+ button.setAttribute("data-unit-type", detail.unitSpawnTable[0].unitType);
+ button.setAttribute("data-unit-qty", detail.unitSpawnTable.length);
+
+ const db = getUnitDatabaseByCategory(detail.category);
+ button.innerHTML = `
${db?.getByName(detail.unitSpawnTable[0].unitType)?.label} (${detail.unitSpawnTable.length})`;
+
+ // Remove a previous instance to save clogging up the list
+ const previous:any = [].slice.call(buttons).find( (button:Element) => (
+ detail.coalition === button.getAttribute("data-spawned-coalition") &&
+ detail.unitSpawnTable[0].unitType === button.getAttribute("data-unit-type") &&
+ detail.unitSpawnTable.length === parseInt(button.getAttribute("data-unit-qty") || "-1")));
+
+ if (previous instanceof HTMLElement) previous.remove();
+
+ /* Click to do the spawn */
+ button.addEventListener("click", (ev:MouseEventInit) => {
+ detail.unitSpawnTable.forEach((table:UnitSpawnTable, i:number) => {
+ table.location = this.getLatLng(); // Set to new menu location
+ table.location.lat += 0.00015 * i;
+ });
+ getApp().getUnitsManager().spawnUnits(detail.category, detail.unitSpawnTable, detail.coalition, detail.immediate, detail.airbase, detail.country);
+ this.hide();
+ });
+
+ /* Insert into DOM */
+ history.prepend(button);
+ SVGInjector(button.querySelectorAll("img"));
+
+ /* Trim down to max number of entries */
+ while (history.querySelectorAll("button").length > maxEntries) {
+ history.childNodes[maxEntries].remove();
+ }
+ });
+ }
}
\ No newline at end of file
diff --git a/client/src/map/map.ts b/client/src/map/map.ts
index be47a116..6218214a 100644
--- a/client/src/map/map.ts
+++ b/client/src/map/map.ts
@@ -38,6 +38,7 @@ require("../../public/javascripts/leaflet.nauticscale.js")
require("../../public/javascripts/L.Path.Drag.js")
export type MapMarkerVisibilityControl = {
+ "category"?: string;
"image": string;
"isProtected"?: boolean,
"name": string,
diff --git a/client/src/other/utils.ts b/client/src/other/utils.ts
index 74866674..0476a610 100644
--- a/client/src/other/utils.ts
+++ b/client/src/other/utils.ts
@@ -5,7 +5,7 @@ import { aircraftDatabase } from "../unit/databases/aircraftdatabase";
import { helicopterDatabase } from "../unit/databases/helicopterdatabase";
import { groundUnitDatabase } from "../unit/databases/groundunitdatabase";
import { Buffer } from "buffer";
-import { ROEs, emissionsCountermeasures, reactionsToThreat, states } from "../constants/constants";
+import { GROUND_UNIT_AIR_DEFENCE_REGEX, ROEs, emissionsCountermeasures, reactionsToThreat, states } from "../constants/constants";
import { Dropdown } from "../controls/dropdown";
import { navyUnitDatabase } from "../unit/databases/navyunitdatabase";
import { DateAndTime, UnitBlueprint } from "../interfaces";
@@ -379,6 +379,20 @@ export function getUnitDatabaseByCategory(category: string) {
return null;
}
+export function getCategoryBlueprintIconSVG(category:string, unitName:string) {
+
+ const path = "/resources/theme/images/buttons/visibility/";
+
+ // We can just send these back okay
+ if (["Aircraft", "Helicopter", "NavyUnit"].includes(category)) return `${path}${category.toLowerCase()}.svg`;
+
+ // Return if not a ground units as it's therefore something we don't recognise
+ if (category !== "GroundUnit") return false;
+
+ /** We need to get the unit detail for ground units so we can work out if it's an air defence unit or not **/
+ return GROUND_UNIT_AIR_DEFENCE_REGEX.test(unitName) ? `${path}groundunit-sam.svg` : `${path}groundunit.svg`;
+}
+
export function base64ToBytes(base64: string) {
return Buffer.from(base64, 'base64').buffer;
}
diff --git a/client/src/unit/unitsmanager.ts b/client/src/unit/unitsmanager.ts
index da455dc1..28556b46 100644
--- a/client/src/unit/unitsmanager.ts
+++ b/client/src/unit/unitsmanager.ts
@@ -1427,6 +1427,16 @@ export class UnitsManager {
if (spawnPoints <= getApp().getMissionManager().getAvailableSpawnPoints()) {
getApp().getMissionManager().setSpentSpawnPoints(spawnPoints);
spawnFunction();
+ document.dispatchEvent( new CustomEvent( "unitSpawned", {
+ "detail": {
+ "airbase": airbase,
+ "category": category,
+ "coalition": coalition,
+ "country": country,
+ "immediate": immediate,
+ "unitSpawnTable": units
+ }
+ }));
return true;
} else {
(getApp().getPopupsManager().get("infoPopup") as Popup).setText("Not enough spawn points available!");
diff --git a/client/views/contextmenus/map.ejs b/client/views/contextmenus/map.ejs
index a3b87b49..4f375786 100644
--- a/client/views/contextmenus/map.ejs
+++ b/client/views/contextmenus/map.ejs
@@ -1,58 +1,71 @@