diff --git a/frontend/react/public/images/cursors/clone.svg b/frontend/react/public/images/cursors/clone.svg new file mode 100644 index 00000000..37f543fd --- /dev/null +++ b/frontend/react/public/images/cursors/clone.svg @@ -0,0 +1,41 @@ + + diff --git a/frontend/react/public/images/cursors/plus.svg b/frontend/react/public/images/cursors/plus.svg new file mode 100644 index 00000000..2e4e79a1 --- /dev/null +++ b/frontend/react/public/images/cursors/plus.svg @@ -0,0 +1,50 @@ + + diff --git a/frontend/react/src/events.ts b/frontend/react/src/events.ts index 87e01fb4..2b90ea14 100644 --- a/frontend/react/src/events.ts +++ b/frontend/react/src/events.ts @@ -150,7 +150,7 @@ export class ModalEvent { } } -export class SessionDataLoadedEvent { +export class SessionDataChangedEvent { static on(callback: (sessionData: SessionData) => void) { document.addEventListener(this.name, (ev: CustomEventInit) => { callback(ev.detail.sessionData); @@ -163,6 +163,9 @@ export class SessionDataLoadedEvent { } } +export class SessionDataSavedEvent extends SessionDataChangedEvent {} +export class SessionDataLoadedEvent extends SessionDataChangedEvent {} + /************** Map events ***************/ export class MouseMovedEvent { static on(callback: (latlng: LatLng, elevation: number) => void) { @@ -229,6 +232,8 @@ export class CoalitionAreaSelectedEvent { } } +export class CoalitionAreaChangedEvent extends CoalitionAreaSelectedEvent {} + export class CoalitionAreasChangedEvent { static on(callback: (coalitionAreas: (CoalitionCircle | CoalitionPolygon)[]) => void) { document.addEventListener(this.name, (ev: CustomEventInit) => { diff --git a/frontend/react/src/interfaces.ts b/frontend/react/src/interfaces.ts index baeb5719..112b460f 100644 --- a/frontend/react/src/interfaces.ts +++ b/frontend/react/src/interfaces.ts @@ -5,10 +5,10 @@ export interface OlympusConfig { frontend: { port: number; customAuthHeaders: { - enabled: boolean, - username: string, - group: string - } + enabled: boolean; + username: string; + group: string; + }; elevationProvider: { provider: string; username: string | null; @@ -32,23 +32,25 @@ export interface OlympusConfig { WSPort?: number; WSEndpoint?: string; }; - controllers: [ - {type: string, coalition: Coalition, frequency: number, modulation: number, callsign: string}, - ]; + controllers: [{ type: string; coalition: Coalition; frequency: number; modulation: number; callsign: string }]; local: boolean; - profiles?: {[key: string]: ProfileOptions}; + profiles?: { [key: string]: ProfileOptions }; authentication?: { - gameMasterPassword: string, - blueCommanderPasword: string, - redCommanderPassword: string - } + gameMasterPassword: string; + blueCommanderPasword: string; + redCommanderPassword: string; + }; } export interface SessionData { radios?: { frequency: number; modulation: number; pan: number }[]; fileSources?: { filename: string; volume: number }[]; - unitSinks?: {ID: number}[]; + unitSinks?: { ID: number }[]; connections?: any[]; + coalitionAreas?: ( + | { type: 'circle', label: string; latlng: { lat: number; lng: number }; radius: number; coalition: Coalition } + | { type: 'polygon', label: string; latlngs: { lat: number; lng: number }[]; coalition: Coalition } + )[]; } export interface ProfileOptions { diff --git a/frontend/react/src/map/coalitionarea/coalitionareamanager.ts b/frontend/react/src/map/coalitionarea/coalitionareamanager.ts index 186d44b2..ee88400b 100644 --- a/frontend/react/src/map/coalitionarea/coalitionareamanager.ts +++ b/frontend/react/src/map/coalitionarea/coalitionareamanager.ts @@ -1,10 +1,11 @@ import { LatLng, LeafletMouseEvent } from "leaflet"; import { DrawSubState, OlympusState } from "../../constants/constants"; -import { AppStateChangedEvent, CoalitionAreasChangedEvent, CoalitionAreaSelectedEvent } from "../../events"; +import { AppStateChangedEvent, CoalitionAreaChangedEvent, CoalitionAreasChangedEvent, CoalitionAreaSelectedEvent, SessionDataLoadedEvent } from "../../events"; import { getApp } from "../../olympusapp"; import { areaContains } from "../../other/utils"; import { CoalitionCircle } from "./coalitioncircle"; import { CoalitionPolygon } from "./coalitionpolygon"; +import { SessionData } from "../../interfaces"; export class CoalitionAreasManager { /* Coalition areas drawing */ @@ -37,6 +38,34 @@ export class CoalitionAreasManager { } }, 200); }); + + CoalitionAreaChangedEvent.on((area) => { + CoalitionAreasChangedEvent.dispatch(this.#areas); + }); + + SessionDataLoadedEvent.on((sessionData: SessionData) => { + /* Make a local copy */ + const localSessionData = JSON.parse(JSON.stringify(sessionData)) as SessionData; + this.#areas.forEach((area) => this.deleteCoalitionArea(area)); + localSessionData.coalitionAreas?.forEach((options) => { + if (options.type === 'circle') { + let newCircle = new CoalitionCircle(new LatLng(options.latlng.lat, options.latlng.lng), { radius: options.radius }, false); + newCircle.setCoalition(options.coalition); + newCircle.setLabelText(options.label); + newCircle.setSelected(false); + this.#areas.push(newCircle); + } else if (options.type === 'polygon') { + if (options.latlngs.length >= 3) { + let newPolygon = new CoalitionPolygon(options.latlngs.map((latlng) => new LatLng(latlng.lat, latlng.lng)), {}, false); + newPolygon.setCoalition(options.coalition); + newPolygon.setLabelText(options.label); + newPolygon.setSelected(false); + this.#areas.push(newPolygon); + } + } + }); + CoalitionAreasChangedEvent.dispatch(this.#areas); + }); } setSelectedArea(area: CoalitionCircle | CoalitionPolygon | null) { @@ -118,4 +147,8 @@ export class CoalitionAreasManager { getApp().setState(OlympusState.DRAW, DrawSubState.EDIT); else getApp().setState(OlympusState.DRAW); } + + getAreas() { + return this.#areas; + } } diff --git a/frontend/react/src/map/coalitionarea/coalitioncircle.ts b/frontend/react/src/map/coalitionarea/coalitioncircle.ts index 85b643f4..5563aeba 100644 --- a/frontend/react/src/map/coalitionarea/coalitioncircle.ts +++ b/frontend/react/src/map/coalitionarea/coalitioncircle.ts @@ -4,19 +4,20 @@ import { CoalitionAreaHandle } from "./coalitionareahandle"; import { BLUE_COMMANDER, RED_COMMANDER } from "../../constants/constants"; import { Coalition } from "../../types/types"; import * as turf from "@turf/turf"; -import { CoalitionAreaSelectedEvent } from "../../events"; +import { CoalitionAreaChangedEvent, CoalitionAreaSelectedEvent } from "../../events"; let totalCircles = 0; export class CoalitionCircle extends Circle { #coalition: Coalition = "blue"; #selected: boolean = true; - #creating: boolean = true; + #creating: boolean = false; #radiusHandle: CoalitionAreaHandle; #labelText: string; #label: Marker; + #updateTimeout: number | null = null; - constructor(latlng: LatLngExpression, options: CircleOptions) { + constructor(latlng: LatLngExpression, options: CircleOptions, creating = true) { if (options === undefined) options = { radius: 0 }; totalCircles++; @@ -30,6 +31,7 @@ export class CoalitionCircle extends Circle { this.#setColors(); this.#labelText = `Circle ${totalCircles}`; + this.#creating = creating; if ([BLUE_COMMANDER, RED_COMMANDER].includes(getApp().getMissionManager().getCommandModeOptions().commandMode)) this.setCoalition(getApp().getMissionManager().getCommandedCoalition()); @@ -37,6 +39,12 @@ export class CoalitionCircle extends Circle { this.on("drag", () => { this.#setRadiusHandle(); this.#drawLabel(); + + if (this.#updateTimeout) window.clearTimeout(this.#updateTimeout); + this.#updateTimeout = window.setTimeout(() => { + CoalitionAreaChangedEvent.dispatch(this); + this.#updateTimeout = null; + }, 500); }); getApp().getMap().addLayer(this); @@ -45,6 +53,7 @@ export class CoalitionCircle extends Circle { setCoalition(coalition: Coalition) { this.#coalition = coalition; this.#setColors(); + CoalitionAreaChangedEvent.dispatch(this); } getCoalition() { @@ -91,18 +100,20 @@ export class CoalitionCircle extends Circle { setLabelText(labelText: string) { this.#labelText = labelText; this.#drawLabel(); + CoalitionAreaChangedEvent.dispatch(this); } onAdd(map: Map): this { super.onAdd(map); this.#drawLabel(); + return this; } onRemove(map: Map): this { super.onRemove(map); this.#label?.removeFrom(map); - this.#radiusHandle.removeFrom(map); + this.#radiusHandle?.removeFrom(map); return this; } diff --git a/frontend/react/src/map/coalitionarea/coalitionpolygon.ts b/frontend/react/src/map/coalitionarea/coalitionpolygon.ts index e36afb01..eff68b80 100644 --- a/frontend/react/src/map/coalitionarea/coalitionpolygon.ts +++ b/frontend/react/src/map/coalitionarea/coalitionpolygon.ts @@ -5,21 +5,22 @@ import { CoalitionAreaMiddleHandle } from "./coalitionareamiddlehandle"; import { BLUE_COMMANDER, RED_COMMANDER } from "../../constants/constants"; import { Coalition } from "../../types/types"; import { polyCenter } from "../../other/utils"; -import { CoalitionAreaSelectedEvent } from "../../events"; +import { CoalitionAreaChangedEvent, CoalitionAreaSelectedEvent } from "../../events"; let totalPolygons = 0; export class CoalitionPolygon extends Polygon { #coalition: Coalition = "blue"; #selected: boolean = true; - #creating: boolean = true; + #creating: boolean = false; #handles: CoalitionAreaHandle[] = []; #middleHandles: CoalitionAreaMiddleHandle[] = []; #activeIndex: number = 0; #labelText: string; #label: Marker; + #updateTimeout: number | null = null; - constructor(latlngs: LatLngExpression[] | LatLngExpression[][] | LatLngExpression[][][], options?: PolylineOptions) { + constructor(latlngs: LatLngExpression[] | LatLngExpression[][] | LatLngExpression[][][], options?: PolylineOptions, creating = true) { if (options === undefined) options = {}; totalPolygons++; @@ -33,6 +34,7 @@ export class CoalitionPolygon extends Polygon { this.#setColors(); this.#labelText = `Polygon ${totalPolygons}`; + this.#creating = creating; if ([BLUE_COMMANDER, RED_COMMANDER].includes(getApp().getMissionManager().getCommandModeOptions().commandMode)) this.setCoalition(getApp().getMissionManager().getCommandedCoalition()); @@ -41,6 +43,12 @@ export class CoalitionPolygon extends Polygon { this.#setHandles(); this.#setMiddleHandles(); this.#drawLabel(); + + if (this.#updateTimeout) window.clearTimeout(this.#updateTimeout); + this.#updateTimeout = window.setTimeout(() => { + CoalitionAreaChangedEvent.dispatch(this); + this.#updateTimeout = null; + }, 500); }); getApp().getMap().addLayer(this); @@ -49,6 +57,7 @@ export class CoalitionPolygon extends Polygon { setCoalition(coalition: Coalition) { this.#coalition = coalition; this.#setColors(); + CoalitionAreaChangedEvent.dispatch(this); } getCoalition() { @@ -111,6 +120,7 @@ export class CoalitionPolygon extends Polygon { setLabelText(labelText: string) { this.#labelText = labelText; this.#drawLabel(); + CoalitionAreaChangedEvent.dispatch(this); } onAdd(map: Map): this { @@ -129,6 +139,13 @@ export class CoalitionPolygon extends Polygon { setLatLngs(latlngs: LatLngExpression[] | LatLngExpression[][] | LatLngExpression[][][]) { super.setLatLngs(latlngs); this.#drawLabel(); + + if (this.#updateTimeout) window.clearTimeout(this.#updateTimeout); + this.#updateTimeout = window.setTimeout(() => { + CoalitionAreaChangedEvent.dispatch(this); + this.#updateTimeout = null; + }, 500); + return this; } diff --git a/frontend/react/src/map/map.ts b/frontend/react/src/map/map.ts index 0a02d15a..361ac230 100644 --- a/frontend/react/src/map/map.ts +++ b/frontend/react/src/map/map.ts @@ -24,6 +24,7 @@ import { ContextActions, SHORT_PRESS_MILLISECONDS, DEBOUNCE_MILLISECONDS, + DrawSubState, } from "../constants/constants"; import { MapHiddenTypes, MapOptions } from "../types/types"; import { EffectRequestTable, OlympusConfig, SpawnRequestTable } from "../interfaces"; @@ -806,6 +807,7 @@ export class Map extends L.Map { } this.getContainer().classList.remove(`explosion-cursor`); ["white", "blue", "red", "green", "orange"].forEach((color) => this.getContainer().classList.remove(`smoke-${color}-cursor`)); + this.getContainer().classList.remove(`plus-cursor`); /* Operations to perform when entering a state */ if (state === OlympusState.IDLE) { @@ -832,7 +834,9 @@ export class Map extends L.Map { } else if (state === OlympusState.UNIT_CONTROL) { console.log(`Context action:`); console.log(this.#contextAction); - } + } else if (state === OlympusState.DRAW) { + if (subState === DrawSubState.DRAW_CIRCLE || subState === DrawSubState.DRAW_POLYGON) this.getContainer().classList.add(`plus-cursor`); + } } #onDragStart(e: any) { @@ -1064,6 +1068,15 @@ export class Map extends L.Map { this.#destinationRotation -= e.originalEvent.movementX; } + if (getApp().getState() === OlympusState.DRAW && (getApp().getSubState() === DrawSubState.NO_SUBSTATE || getApp().getSubState() === DrawSubState.EDIT)) { + getApp() + .getCoalitionAreasManager() + .getAreas() + .find((area) => areaContains(e.latlng, area)) + ? this.getContainer()?.classList.add("pointer-cursor") + : this.getContainer()?.classList.remove("pointer-cursor"); + } + this.#moveDestinationPreviewMarkers(); } diff --git a/frontend/react/src/map/markers/stylesheets/airbase.css b/frontend/react/src/map/markers/stylesheets/airbase.css index 21fb3a4c..e0ad9a75 100644 --- a/frontend/react/src/map/markers/stylesheets/airbase.css +++ b/frontend/react/src/map/markers/stylesheets/airbase.css @@ -1,6 +1,6 @@ .airbase-icon { align-items: center; - cursor: url("/images/cursors/pointer.svg"), auto; + cursor: url("/images/cursors/pointer.svg") 13 5, auto; display: flex; justify-content: center; position: relative; diff --git a/frontend/react/src/map/markers/stylesheets/bullseye.css b/frontend/react/src/map/markers/stylesheets/bullseye.css index 7e3984e6..7d2896e8 100644 --- a/frontend/react/src/map/markers/stylesheets/bullseye.css +++ b/frontend/react/src/map/markers/stylesheets/bullseye.css @@ -1,6 +1,6 @@ .bullseye-icon { align-items: center; - cursor: url("/images/cursors/pointer.svg"), auto; + cursor: url("/images/cursors/pointer.svg") 13 5, auto; display: flex; justify-content: center; position: relative; diff --git a/frontend/react/src/map/markers/stylesheets/units.css b/frontend/react/src/map/markers/stylesheets/units.css index 01a3234e..4b7bf004 100644 --- a/frontend/react/src/map/markers/stylesheets/units.css +++ b/frontend/react/src/map/markers/stylesheets/units.css @@ -1,6 +1,6 @@ /*** Unit marker elements ***/ [data-object|="unit"] { - cursor: url("/images/cursors/pointer.svg"), auto; + cursor: url("/images/cursors/pointer.svg") 13 5, auto; display: flex; height: 100%; justify-content: center; diff --git a/frontend/react/src/map/stylesheets/map.css b/frontend/react/src/map/stylesheets/map.css index c7b07fa6..e2bb4e2d 100644 --- a/frontend/react/src/map/stylesheets/map.css +++ b/frontend/react/src/map/stylesheets/map.css @@ -99,6 +99,7 @@ } .ol-coalitionarea-handle-icon { + cursor: url("/images/cursors/pointer.svg") 13 5, auto; background-color: #FFFFFFAA; width: 100%; height: 100%; @@ -106,6 +107,7 @@ } .ol-coalitionarea-middle-handle-icon { + cursor: url("/images/cursors/pointer.svg") 13 5, auto; background-color: #FFFFFFAA; width: 100%; height: 100%; @@ -207,6 +209,14 @@ path.leaflet-interactive:focus { cursor: url("/images/cursors/simulate-fire-fight.svg"), auto !important; } +.clone-cursor { + cursor: url("/images/cursors/clone.svg"), auto !important; +} + +.plus-cursor { + cursor: url("/images/cursors/plus.svg"), auto !important; +} + #map-container.leaflet-grab { cursor: url("/images/cursors/grab.svg") 16 16, auto; } @@ -237,4 +247,8 @@ path.leaflet-interactive:focus { #map-container .disable-pointer-events { pointer-events: none; +} + +.pointer-cursor { + cursor: url("/images/cursors/pointer.svg") 13 5, auto !important; } \ No newline at end of file diff --git a/frontend/react/src/sessiondata.ts b/frontend/react/src/sessiondata.ts index ba324ee2..09937c75 100644 --- a/frontend/react/src/sessiondata.ts +++ b/frontend/react/src/sessiondata.ts @@ -1,10 +1,13 @@ +import { LatLng } from "leaflet"; import { AudioSink } from "./audio/audiosink"; import { FileSource } from "./audio/filesource"; import { RadioSink } from "./audio/radiosink"; import { UnitSink } from "./audio/unitsink"; import { OlympusState } from "./constants/constants"; -import { AudioSinksChangedEvent, AudioSourcesChangedEvent, SessionDataLoadedEvent as SessionDataChangedEvent } from "./events"; +import { AudioSinksChangedEvent, AudioSourcesChangedEvent, CoalitionAreasChangedEvent, SessionDataChangedEvent, SessionDataLoadedEvent, SessionDataSavedEvent } from "./events"; import { SessionData } from "./interfaces"; +import { CoalitionCircle } from "./map/coalitionarea/coalitioncircle"; +import { CoalitionPolygon } from "./map/coalitionarea/coalitionpolygon"; import { getApp } from "./olympusapp"; export class SessionDataManager { @@ -74,6 +77,34 @@ export class SessionDataManager { this.#saveSessionData(); } }); + + CoalitionAreasChangedEvent.on(() => { + this.#sessionData.coalitionAreas = [] + getApp().getCoalitionAreasManager().getAreas().forEach((area) => { + if (area instanceof CoalitionCircle) { + this.#sessionData.coalitionAreas?.push( + { + type: 'circle', + latlng: {lat: area.getLatLng().lat, lng: area.getLatLng().lng}, + coalition: area.getCoalition(), + label: area.getLabelText(), + radius: area.getRadius() + } + ) + } else if (area instanceof CoalitionPolygon) { + this.#sessionData.coalitionAreas?.push( + { + type: 'polygon', + latlngs: (area.getLatLngs()[0] as LatLng[]).map((latlng) => {return {lat: latlng.lat, lng: latlng.lng}}), + coalition: area.getCoalition(), + label: area.getLabelText() + } + ) + } + }) + + this.#saveSessionData(); + }) } loadSessionData(sessionHash?: string) { @@ -103,8 +134,8 @@ export class SessionDataManager { }) // Parse the response as JSON .then((sessionData) => { this.#sessionData = sessionData; - this.#applySessionData(); - SessionDataChangedEvent.dispatch(this.#sessionData); + console.log(this.#sessionData); + SessionDataLoadedEvent.dispatch(this.#sessionData); }) .catch((error) => console.error(error)); // Handle errors } @@ -116,6 +147,8 @@ export class SessionDataManager { #saveSessionData() { if (getApp().getState() === OlympusState.SERVER) return; + SessionDataChangedEvent.dispatch(this.#sessionData); + if (this.#saveSessionDataTimeout) window.clearTimeout(this.#saveSessionDataTimeout); this.#saveSessionDataTimeout = window.setTimeout(() => { const requestOptions = { @@ -129,7 +162,7 @@ export class SessionDataManager { if (response.status === 200) { console.log(`Session data for profile ${getApp().getServerManager().getUsername()} and session hash ${this.#sessionHash} saved correctly`); console.log(this.#sessionData); - SessionDataChangedEvent.dispatch(this.#sessionData); + SessionDataSavedEvent.dispatch(this.#sessionData); } else { getApp().addInfoMessage("Error loading session data"); throw new Error("Error loading session data"); @@ -139,8 +172,4 @@ export class SessionDataManager { this.#saveSessionDataTimeout = null; }, 1000); } - - #applySessionData() { - let asd = 1; - } } diff --git a/frontend/react/src/ui/panels/header.tsx b/frontend/react/src/ui/panels/header.tsx index 972ca587..658ef227 100644 --- a/frontend/react/src/ui/panels/header.tsx +++ b/frontend/react/src/ui/panels/header.tsx @@ -15,10 +15,11 @@ import { olButtonsVisibilityNavyunit, olButtonsVisibilityOlympus, } from "../components/olicons"; -import { FaChevronLeft, FaChevronRight, FaComputer, FaTabletScreenButton } from "react-icons/fa6"; -import { CommandModeOptionsChangedEvent, ConfigLoadedEvent, HiddenTypesChangedEvent, MapOptionsChangedEvent, MapSourceChangedEvent } from "../../events"; +import { FaChevronLeft, FaChevronRight, FaComputer, FaFloppyDisk, FaTabletScreenButton } from "react-icons/fa6"; +import { CommandModeOptionsChangedEvent, ConfigLoadedEvent, HiddenTypesChangedEvent, MapOptionsChangedEvent, MapSourceChangedEvent, SessionDataChangedEvent, SessionDataSavedEvent } from "../../events"; import { BLUE_COMMANDER, COMMAND_MODE_OPTIONS_DEFAULTS, MAP_HIDDEN_TYPES_DEFAULTS, MAP_OPTIONS_DEFAULTS } from "../../constants/constants"; import { OlympusConfig } from "../../interfaces"; +import { FaCheck, FaSpinner } from "react-icons/fa"; export function Header() { const [mapHiddenTypes, setMapHiddenTypes] = useState(MAP_HIDDEN_TYPES_DEFAULTS); @@ -29,6 +30,7 @@ export function Header() { const [scrolledRight, setScrolledRight] = useState(false); const [audioEnabled, setAudioEnabled] = useState(false); const [commandModeOptions, setCommandModeOptions] = useState(COMMAND_MODE_OPTIONS_DEFAULTS); + const [savingSessionData, setSavingSessionData] = useState(false); useEffect(() => { HiddenTypesChangedEvent.on((hiddenTypes) => setMapHiddenTypes({ ...hiddenTypes })); @@ -44,6 +46,8 @@ export function Header() { CommandModeOptionsChangedEvent.on((commandModeOptions) => { setCommandModeOptions(commandModeOptions); }); + SessionDataChangedEvent.on(() => setSavingSessionData(true)); + SessionDataSavedEvent.on(() => setSavingSessionData(false)); }, []); /* Initialize the "scroll" position of the element */ @@ -112,7 +116,15 @@ export function Header() { {IP} + {savingSessionData ?
:
} + {commandModeOptions.commandMode === BLUE_COMMANDER && (
BLUE Commander ({commandModeOptions.spawnPoints.blue} points)