diff --git a/client/demo.js b/client/demo.js index 300b7d85..ac750b83 100644 --- a/client/demo.js +++ b/client/demo.js @@ -71,7 +71,7 @@ const DEMO_UNIT_DATA = { AI: true, name: "KC-135", unitName: "Olympus 1-2", - groupName: "Group 1", + groupName: "Group 3", alive: true, category: "Aircraft", }, @@ -114,7 +114,7 @@ const DEMO_UNIT_DATA = { AI: true, name: "2S6 Tunguska", unitName: "Olympus 1-3", - groupName: "Group 1", + groupName: "Group 4", alive: true, category: "GroundUnit", }, diff --git a/client/public/stylesheets/olympus.css b/client/public/stylesheets/olympus.css index a0cd6fc7..3478faa6 100644 --- a/client/public/stylesheets/olympus.css +++ b/client/public/stylesheets/olympus.css @@ -930,4 +930,16 @@ body[data-hide-navyunit] #unit-visibility-control-navyunit { .hotgroup-selector>.unit-hotgroup { display: flex; translate: 0% -300%; +} + +.ol-destination-preview-icon { + width: 52px; + height: 52px; + background-color: var(--secondary-yellow); + pointer-events: none; + border-radius: 999px; +} + +.ol-destination-preview { + pointer-events: none; } \ No newline at end of file diff --git a/client/src/map/map.ts b/client/src/map/map.ts index a531e33a..a1f18db7 100644 --- a/client/src/map/map.ts +++ b/client/src/map/map.ts @@ -9,6 +9,7 @@ import { AirbaseContextMenu } from "../controls/airbasecontextmenu"; import { Dropdown } from "../controls/dropdown"; import { Airbase } from "../missionhandler/airbase"; import { Unit } from "../units/unit"; +import { bearing } from "../other/utils"; // TODO a bit of a hack, this module is provided as pure javascript only require("../../node_modules/leaflet.nauticscale/dist/leaflet.nauticscale.js") @@ -24,6 +25,13 @@ var temporaryIcon = new L.Icon({ iconAnchor: [26, 26] }); +var destinationPreviewIcon = new L.DivIcon({ + html: `
`, + iconSize: [52, 52], + iconAnchor: [26, 26], + className: "ol-destination-preview" +}) + export class ClickableMiniMap extends MiniMap { constructor(layer: L.TileLayer | L.LayerGroup, options?: MiniMapOptions) { super(layer, options); @@ -51,6 +59,10 @@ export class Map extends L.Map { #miniMap: ClickableMiniMap | null = null; #miniMapLayerGroup: L.LayerGroup; #temporaryMarkers: L.Marker[] = []; + #destinationPreviewMarkers: L.Marker[] = []; + #destinationGroupRotation: number = 0; + #computeDestinationRotation: boolean = false; + #destinationRotationCenter: L.LatLng | null = null; #mapContextMenu: MapContextMenu = new MapContextMenu("map-contextmenu"); #unitContextMenu: UnitContextMenu = new UnitContextMenu("unit-contextmenu"); @@ -67,53 +79,18 @@ export class Map extends L.Map { this.setLayer("ArcGIS Satellite"); /* Minimap */ - /* Draw the limits of the maps in the minimap*/ - var latlngs = [[ // NTTR - new L.LatLng(39.7982463, -119.985425), - new L.LatLng(34.4037128, -119.7806729), - new L.LatLng(34.3483316, -112.4529351), - new L.LatLng(39.7372411, -112.1130805), - new L.LatLng(39.7982463, -119.985425) - ], - [ // Syria - new L.LatLng(37.3630556, 29.2686111), - new L.LatLng(31.8472222, 29.8975), - new L.LatLng(32.1358333, 42.1502778), - new L.LatLng(37.7177778, 42.3716667), - new L.LatLng(37.3630556, 29.2686111) - ], - [ // Caucasus - new L.LatLng(39.6170191, 27.634935), - new L.LatLng(38.8735863, 47.1423108), - new L.LatLng(47.3907982, 49.3101946), - new L.LatLng(48.3955879, 26.7753625), - new L.LatLng(39.6170191, 27.634935) - ], - [ // Persian Gulf - new L.LatLng(32.9355285, 46.5623682), - new L.LatLng(21.729393, 47.572675), - new L.LatLng(21.8501348, 63.9734737), - new L.LatLng(33.131584, 64.7313594), - new L.LatLng(32.9355285, 46.5623682) - ], - [ // Marianas - new L.LatLng(22.09, 135.0572222), - new L.LatLng(10.5777778, 135.7477778), - new L.LatLng(10.7725, 149.3918333), - new L.LatLng(22.5127778, 149.5427778), - new L.LatLng(22.09, 135.0572222) - ] - ]; - var minimapLayer = new L.TileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { minZoom: 0, maxZoom: 13 }); this.#miniMapLayerGroup = new L.LayerGroup([minimapLayer]); - var miniMapPolyline = new L.Polyline(latlngs, { color: '#202831' }); + var miniMapPolyline = new L.Polyline(this.#getMinimapBoundaries(), { color: '#202831' }); miniMapPolyline.addTo(this.#miniMapLayerGroup); /* Scale */ //@ts-ignore TODO more hacking because the module is provided as a pure javascript module only L.control.scalenautic({ position: "topright", maxWidth: 300, nautic: true, metric: true, imperial: false }).addTo(this); + /* Map source dropdown */ + this.#mapSourceDropdown = new Dropdown("map-type", (layerName: string) => this.setLayer(layerName), this.getLayers()) + /* Init the state machine */ this.#state = IDLE; @@ -127,7 +104,10 @@ export class Map extends L.Map { this.on('mousedown', (e: any) => this.#onMouseDown(e)); this.on('mouseup', (e: any) => this.#onMouseUp(e)); this.on('mousemove', (e: any) => this.#onMouseMove(e)); - + this.on('keydown', (e: any) => this.#updateDestinationPreview(e)); + this.on('keyup', (e: any) => this.#updateDestinationPreview(e)); + + /* Event listeners */ document.addEventListener("toggleCoalitionVisibility", (ev: CustomEventInit) => { ev.detail._element.classList.toggle("off"); document.body.toggleAttribute("data-hide-" + ev.detail.coalition); @@ -144,8 +124,7 @@ export class Map extends L.Map { this.#panToUnit(this.#centerUnit); }); - this.#mapSourceDropdown = new Dropdown("map-type", (layerName: string) => this.setLayer(layerName), this.getLayers()) - + /* Pan interval */ this.#panInterval = window.setInterval(() => { this.panBy(new L.Point( ((this.#panLeft? -1: 0) + (this.#panRight? 1: 0)) * this.#deafultPanDelta, ((this.#panUp? -1: 0) + (this.#panDown? 1: 0)) * this.#deafultPanDelta)); @@ -205,9 +184,34 @@ export class Map extends L.Map { this.#state = state; if (this.#state === IDLE) { L.DomUtil.removeClass(this.getContainer(), 'crosshair-cursor-enabled'); + + /* Remove all the destination preview markers */ + this.#destinationPreviewMarkers.forEach((marker: L.Marker) => { + this.removeLayer(marker); + }) + this.#destinationPreviewMarkers = []; + + this.#destinationGroupRotation = 0; + this.#computeDestinationRotation = false; + this.#destinationRotationCenter = null; } else if (this.#state === MOVE_UNIT) { L.DomUtil.addClass(this.getContainer(), 'crosshair-cursor-enabled'); + + /* Remove all the exising destination preview markers */ + this.#destinationPreviewMarkers.forEach((marker: L.Marker) => { + this.removeLayer(marker); + }) + this.#destinationPreviewMarkers = []; + + if (getUnitsManager().getSelectedUnits({excludeHumans: true}).length < 20) { + /* Create the unit destination preview markers */ + this.#destinationPreviewMarkers = getUnitsManager().getSelectedUnits({excludeHumans: true}).map((unit: Unit) => { + var marker = new L.Marker(this.getMouseCoordinates(), {icon: destinationPreviewIcon, interactive: false}); + marker.addTo(this); + return marker; + }) + } } document.dispatchEvent(new CustomEvent("mapStateChanged")); } @@ -328,7 +332,6 @@ export class Map extends L.Map { if (this.#miniMap) this.setView(e.latlng); }) - } getMiniMapLayerGroup() { @@ -433,7 +436,7 @@ export class Map extends L.Map { if (!e.originalEvent.ctrlKey) { getUnitsManager().selectedUnitsClearDestinations(); } - getUnitsManager().selectedUnitsAddDestination(e.latlng) + getUnitsManager().selectedUnitsAddDestination(this.#computeDestinationRotation && this.#destinationRotationCenter != null? this.#destinationRotationCenter: e.latlng, !e.originalEvent.shiftKey, this.#destinationGroupRotation) } } @@ -448,14 +451,31 @@ export class Map extends L.Map { #onMouseDown(e: any) { this.hideAllContextMenus(); + + if (this.#state == MOVE_UNIT && e.originalEvent.button == 2) + { + this.#computeDestinationRotation = true; + this.#destinationRotationCenter = this.getMouseCoordinates(); + } } #onMouseUp(e: any) { + if (this.#state == MOVE_UNIT) + { + this.#computeDestinationRotation = false; + this.#destinationRotationCenter = null; + this.#destinationGroupRotation = 0; + } } #onMouseMove(e: any) { this.#lastMousePosition.x = e.originalEvent.x; this.#lastMousePosition.y = e.originalEvent.y; + + if (this.#computeDestinationRotation && this.#destinationRotationCenter != null) + this.#destinationGroupRotation = -bearing(this.#destinationRotationCenter.lat, this.#destinationRotationCenter.lng, this.getMouseCoordinates().lat, this.getMouseCoordinates().lng); + + this.#updateDestinationPreview(e); } #onZoom(e: any) { @@ -467,4 +487,51 @@ export class Map extends L.Map { var unitPosition = new L.LatLng(unit.getFlightData().latitude, unit.getFlightData().longitude); this.setView(unitPosition, this.getZoom(), { animate: false }); } + + #getMinimapBoundaries() { + /* Draw the limits of the maps in the minimap*/ + return [[ // NTTR + new L.LatLng(39.7982463, -119.985425), + new L.LatLng(34.4037128, -119.7806729), + new L.LatLng(34.3483316, -112.4529351), + new L.LatLng(39.7372411, -112.1130805), + new L.LatLng(39.7982463, -119.985425) + ], + [ // Syria + new L.LatLng(37.3630556, 29.2686111), + new L.LatLng(31.8472222, 29.8975), + new L.LatLng(32.1358333, 42.1502778), + new L.LatLng(37.7177778, 42.3716667), + new L.LatLng(37.3630556, 29.2686111) + ], + [ // Caucasus + new L.LatLng(39.6170191, 27.634935), + new L.LatLng(38.8735863, 47.1423108), + new L.LatLng(47.3907982, 49.3101946), + new L.LatLng(48.3955879, 26.7753625), + new L.LatLng(39.6170191, 27.634935) + ], + [ // Persian Gulf + new L.LatLng(32.9355285, 46.5623682), + new L.LatLng(21.729393, 47.572675), + new L.LatLng(21.8501348, 63.9734737), + new L.LatLng(33.131584, 64.7313594), + new L.LatLng(32.9355285, 46.5623682) + ], + [ // Marianas + new L.LatLng(22.09, 135.0572222), + new L.LatLng(10.5777778, 135.7477778), + new L.LatLng(10.7725, 149.3918333), + new L.LatLng(22.5127778, 149.5427778), + new L.LatLng(22.09, 135.0572222) + ] + ]; + } + + #updateDestinationPreview(e: any) { + Object.values(getUnitsManager().selectedUnitsComputeGroupDestination(this.#computeDestinationRotation && this.#destinationRotationCenter != null? this.#destinationRotationCenter: this.getMouseCoordinates(), this.#destinationGroupRotation)).forEach((latlng: L.LatLng, idx: number) => { + if (idx < this.#destinationPreviewMarkers.length) + this.#destinationPreviewMarkers[idx].setLatLng(!e.originalEvent.shiftKey? latlng: this.getMouseCoordinates()); + }) + } } diff --git a/client/src/other/utils.ts b/client/src/other/utils.ts index 69c164c7..b3899d41 100644 --- a/client/src/other/utils.ts +++ b/client/src/other/utils.ts @@ -11,48 +11,6 @@ export function bearing(lat1: number, lon1: number, lat2: number, lon2: number) return brng; } - -export function ConvertDDToDMS(D: number, lng: boolean) { - var dir = D < 0 ? (lng ? "W" : "S") : lng ? "E" : "N"; - var deg = 0 | (D < 0 ? (D = -D) : D); - var min = 0 | (((D += 1e-9) % 1) * 60); - var sec = (0 | (((D * 60) % 1) * 6000)) / 100; - var dec = Math.round((sec - Math.floor(sec)) * 100); - var sec = Math.floor(sec); - if (lng) - return dir + zeroPad(deg, 3) + "°" + zeroPad(min, 2) + "'" + zeroPad(sec, 2) + "." + zeroPad(dec, 2) + "\""; - else - return dir + zeroPad(deg, 2) + "°" + zeroPad(min, 2) + "'" + zeroPad(sec, 2) + "." + zeroPad(dec, 2) + "\""; -} - - -export function dataPointMap( container:HTMLElement, data:any) { - - Object.keys( data ).forEach( ( key ) => { - - const val = "" + data[ key ]; // Ensure a string - - container.querySelectorAll( `[data-point="${key}"]`).forEach( el => { - - // We could probably have options here - if ( el instanceof HTMLInputElement ) { - el.value = val; - } else if ( el instanceof HTMLElement ) { - el.innerText = val; - } - }); - - }); - -} - - -export function deg2rad(deg: number) { - var pi = Math.PI; - return deg * (pi / 180); -} - - export function distance(lat1: number, lon1: number, lat2: number, lon2: number) { const R = 6371e3; // metres const φ1 = deg2rad(lat1); // φ, λ in radians @@ -68,6 +26,42 @@ export function distance(lat1: number, lon1: number, lat2: number, lon2: number) return d; } +export function ConvertDDToDMS(D: number, lng: boolean) { + var dir = D < 0 ? (lng ? "W" : "S") : lng ? "E" : "N"; + var deg = 0 | (D < 0 ? (D = -D) : D); + var min = 0 | (((D += 1e-9) % 1) * 60); + var sec = (0 | (((D * 60) % 1) * 6000)) / 100; + var dec = Math.round((sec - Math.floor(sec)) * 100); + var sec = Math.floor(sec); + if (lng) + return dir + zeroPad(deg, 3) + "°" + zeroPad(min, 2) + "'" + zeroPad(sec, 2) + "." + zeroPad(dec, 2) + "\""; + else + return dir + zeroPad(deg, 2) + "°" + zeroPad(min, 2) + "'" + zeroPad(sec, 2) + "." + zeroPad(dec, 2) + "\""; +} + +export function dataPointMap( container:HTMLElement, data:any) { + Object.keys( data ).forEach( ( key ) => { + const val = "" + data[ key ]; // Ensure a string + container.querySelectorAll( `[data-point="${key}"]`).forEach( el => { + // We could probably have options here + if ( el instanceof HTMLInputElement ) { + el.value = val; + } else if ( el instanceof HTMLElement ) { + el.innerText = val; + } + }); + }); +} + +export function deg2rad(deg: number) { + var pi = Math.PI; + return deg * (pi / 180); +} + +export function rad2deg(rad: number) { + var pi = Math.PI; + return rad / (pi / 180); +} export function generateUUIDv4() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { @@ -76,33 +70,15 @@ export function generateUUIDv4() { }); } - export function keyEventWasInInput( event:KeyboardEvent ) { - const target = event.target; - return ( target instanceof HTMLElement && ( [ "INPUT", "TEXTAREA" ].includes( target.nodeName ) ) ); - } - -export function rad2deg(rad: number) { - var pi = Math.PI; - return rad / (pi / 180); -} - - export function reciprocalHeading(heading: number): number { - - if (heading > 180) { - return heading - 180; - } - - return heading + 180; - + return heading > 180? heading - 180: heading + 180; } - export const zeroAppend = function (num: number, places: number) { var string = String(num); while (string.length < places) { @@ -111,7 +87,6 @@ export const zeroAppend = function (num: number, places: number) { return string; } - export const zeroPad = function (num: number, places: number) { var string = String(num); while (string.length < places) { @@ -120,7 +95,6 @@ export const zeroPad = function (num: number, places: number) { return string; } - export function similarity(s1: string, s2: string) { var longer = s1; var shorter = s2; @@ -160,4 +134,24 @@ export function editDistance(s1: string, s2: string) { costs[s2.length] = lastValue; } return costs[s2.length]; +} + +export function latLngToMercator(lat: number, lng: number): {x: number, y: number} { + var rMajor = 6378137; //Equatorial Radius, WGS84 + var shift = Math.PI * rMajor; + var x = lng * shift / 180; + var y = Math.log(Math.tan((90 + lat) * Math.PI / 360)) / (Math.PI / 180); + y = y * shift / 180; + + return {x: x, y: y}; +} + +export function mercatorToLatLng(x: number, y: number) { + var rMajor = 6378137; //Equatorial Radius, WGS84 + var shift = Math.PI * rMajor; + var lng = x / shift * 180.0; + var lat = y / shift * 180.0; + lat = 180 / Math.PI * (2 * Math.atan(Math.exp(lat * Math.PI / 180.0)) - Math.PI / 2.0); + + return { lng: lng, lat: lat }; } \ No newline at end of file diff --git a/client/src/units/unit.ts b/client/src/units/unit.ts index e5bfc296..161d7fd4 100644 --- a/client/src/units/unit.ts +++ b/client/src/units/unit.ts @@ -142,14 +142,11 @@ export class Unit extends Marker { if ((this.getBaseData().alive || !selected) && this.getSelectable() && this.getSelected() != selected) { this.#selected = selected; this.getElement()?.querySelector(`[data-object|="unit"]`)?.toggleAttribute("data-is-selected"); - if (selected){ + if (selected) document.dispatchEvent(new CustomEvent("unitSelection", { detail: this })); - this.getGroupMembers().forEach((unit: Unit) => unit.setSelected(true)); - } - else { + else document.dispatchEvent(new CustomEvent("unitDeselection", { detail: this })); - this.getGroupMembers().forEach((unit: Unit) => unit.setSelected(false)); - } + this.getGroupMembers().forEach((unit: Unit) => unit.setSelected(selected)); } } @@ -174,8 +171,11 @@ export class Unit extends Marker { } setHighlighted(highlighted: boolean) { - this.getElement()?.querySelector(`[data-object|="unit"]`)?.toggleAttribute("data-is-highlighted", highlighted); - this.#highlighted = highlighted; + if (this.#highlighted != highlighted) { + this.getElement()?.querySelector(`[data-object|="unit"]`)?.toggleAttribute("data-is-highlighted", highlighted); + this.#highlighted = highlighted; + this.getGroupMembers().forEach((unit: Unit) => unit.setHighlighted(highlighted)); + } } getHighlighted() { diff --git a/client/src/units/unitsmanager.ts b/client/src/units/unitsmanager.ts index f5bc6fd8..cba8fb29 100644 --- a/client/src/units/unitsmanager.ts +++ b/client/src/units/unitsmanager.ts @@ -3,7 +3,7 @@ import { getHotgroupPanel, getInfoPopup, getMap, getUnitDataTable } from ".."; import { Unit } from "./unit"; import { cloneUnit } from "../server/server"; import { IDLE, MOVE_UNIT } from "../map/map"; -import { keyEventWasInInput } from "../other/utils"; +import { deg2rad, keyEventWasInInput, latLngToMercator, mercatorToLatLng } from "../other/utils"; export class UnitsManager { #units: { [ID: number]: Unit }; @@ -171,8 +171,16 @@ export class UnitsManager { }; /*********************** Actions on selected units ************************/ - selectedUnitsAddDestination(latlng: L.LatLng) { + selectedUnitsAddDestination(latlng: L.LatLng, mantainRelativePosition: boolean, rotation: number) { var selectedUnits = this.getSelectedUnits({excludeHumans: true}); + + /* Compute the destination for each unit. If mantainRelativePosition is true, compute the destination so to hold the relative distances */ + var unitDestinations: {[key: number]: LatLng} = {}; + if (mantainRelativePosition) + unitDestinations = this.selectedUnitsComputeGroupDestination(latlng, rotation); + else + selectedUnits.forEach((unit: Unit) => {unitDestinations[unit.ID] = latlng}); + for (let idx in selectedUnits) { const unit = selectedUnits[idx]; /* If a unit is following another unit, and that unit is also selected, send the command to the followed unit */ @@ -180,11 +188,14 @@ export class UnitsManager { const leader = this.getUnitByID(unit.getFormationData().leaderID) if (leader && leader.getSelected()) leader.addDestination(latlng); - else + else unit.addDestination(latlng); } - else - unit.addDestination(latlng); + else { + if (unit.ID in unitDestinations) + unit.addDestination(unitDestinations[unit.ID]); + } + } this.#showActionMessage(selectedUnits, " new destination added"); } @@ -307,7 +318,7 @@ export class UnitsManager { else if (formation === "Front") { offset.x = 100; offset.y = 0; offset.z = 0; } else offset = undefined; } - var selectedUnits = this.getSelectedUnits({excludeHumans: true}); + var selectedUnits = this.getSelectedUnits({excludeHumans: true}); var count = 1; var xr = 0; var yr = 1; var zr = -1; var layer = 1; @@ -351,6 +362,39 @@ export class UnitsManager { getHotgroupPanel().refreshHotgroups(); } + selectedUnitsComputeGroupDestination(latlng: LatLng, rotation: number) + { + var selectedUnits = this.getSelectedUnits({excludeHumans: true}); + /* Compute the center of the group */ + var center = {x: 0, y: 0}; + selectedUnits.forEach((unit: Unit) => { + var mercator = latLngToMercator(unit.getFlightData().latitude, unit.getFlightData().longitude); + center.x += mercator.x / selectedUnits.length; + center.y += mercator.y / selectedUnits.length; + }); + + /* Compute the distances from the center of the group */ + + var unitDestinations: {[key: number]: LatLng} = {}; + selectedUnits.forEach((unit: Unit) => { + var mercator = latLngToMercator(unit.getFlightData().latitude, unit.getFlightData().longitude); + var distancesFromCenter = {dx: mercator.x - center.x, dy: mercator.y - center.y}; + + /* Rotate the distance according to the group rotation */ + var rotatedDistancesFromCenter: {dx: number, dy: number} = {dx: 0, dy: 0}; + rotatedDistancesFromCenter.dx = distancesFromCenter.dx * Math.cos(deg2rad(rotation)) - distancesFromCenter.dy * Math.sin(deg2rad(rotation)); + rotatedDistancesFromCenter.dy = distancesFromCenter.dx * Math.sin(deg2rad(rotation)) + distancesFromCenter.dy * Math.cos(deg2rad(rotation)); + + /* Compute the final position of the unit */ + var destMercator = latLngToMercator(latlng.lat, latlng.lng); // Convert destination point to mercator + var unitMercator = {x: destMercator.x + rotatedDistancesFromCenter.dx, y: destMercator.y + rotatedDistancesFromCenter.dy}; // Compute final position of this unit in mercator coordinates + var unitLatLng = mercatorToLatLng(unitMercator.x, unitMercator.y); + unitDestinations[unit.ID] = new LatLng(unitLatLng.lat, unitLatLng.lng); + }); + + return unitDestinations; + } + /***********************************************/ copyUnits() { this.#copiedUnits = this.getSelectedUnits(); /* Can be applied to humans too */