Merge branch 'main' into 73-add-tankers-and-awacs-spawning

This commit is contained in:
Pax1601
2023-04-19 16:35:37 +02:00
22 changed files with 972 additions and 464 deletions

View File

@@ -1,86 +1,141 @@
import { ToggleableFeature } from "../toggleablefeature";
import Sortable from 'sortablejs';
import { ATCFLightList } from "./flightlist";
import { ATCBoard } from "./atcboard";
import { ATCBoardFlight } from "./board/flight";
export class ATC extends ToggleableFeature {
export interface FlightInterface {
id : string;
name : string;
status : "unknown";
takeoffTime : number;
}
class ATCDataHandler {
#atc:ATC;
#flights:{[key:string]: FlightInterface} = {};
#updateInterval:number|undefined = undefined;
#updateIntervalDelay:number = 1000;
constructor( atc:ATC ) {
this.#atc = atc;
}
getFlights() {
return this.#flights;
}
startUpdates() {
this.#updateInterval = setInterval( () => {
fetch( '/api/atc/flight', {
method: 'GET',
headers: {
'Accept': '*/*',
'Content-Type': 'application/json'
}
})
.then( response => response.json() )
.then( data => {
this.setFlights( data );
});
}, this.#updateIntervalDelay );
}
setFlights( flights:{[key:string]: any} ) {
this.#flights = flights;
}
stopUpdates() {
clearInterval( this.#updateInterval );
}
}
export class ATC {
#boards:ATCBoard[] = [];
#dataHandler:ATCDataHandler;
#initDate:Date = new Date();
constructor() {
super( true );
this.#dataHandler = new ATCDataHandler( this );
//this.#generateFlightList();
let $list = document.getElementById( "atc-strip-board-arrivals" );
if ( $list instanceof HTMLElement ) {
Sortable.create( $list, {
"handle": ".handle"
});
}
this.lookForBoards();
}
#generateFlightList() {
addBoard<T extends ATCBoard>( board:T ) {
const flightList = new ATCFLightList();
const flights:any = flightList.getFlights( true );
board.startUpdates();
const $tbody = document.getElementById( "atc-flight-list-table-body" );
if ( $tbody instanceof HTMLElement ) {
if ( flights.length > 0 ) {
let flight:any = {};
let $button, i;
this.#boards.push( board );
for ( [ i, flight ] of flights.entries() ) {
const $row = document.createElement( "tr" );
$row.dataset.status = flight.status
let $td = document.createElement( "td" );
$td.innerText = flight.name;
$row.appendChild( $td );
$td = document.createElement( "td" );
$td.innerText = flight.takeOffTime;
$row.appendChild( $td );
$td = document.createElement( "td" );
$td.innerText = "00:0" + ( 5 + i );
$row.appendChild( $td );
$td = document.createElement( "td" );
$td.innerText = flight.status;
$row.appendChild( $td );
}
$td = document.createElement( "td" );
$button = document.createElement( "button" );
$button.innerText = "...";
$td.appendChild( $button );
getDataHandler() {
return this.#dataHandler;
}
$row.appendChild( $td );
$tbody.appendChild( $row );
}
getMissionElapsedSeconds() : number {
return new Date().getTime() - this.#initDate.getTime();
}
getMissionStartDateTime() : Date {
return new Date( 1990, 3, 1, 18, 0, 0 );
}
getMissionDateTime() : Date {
return new Date( this.getMissionStartDateTime().getTime() + this.getMissionElapsedSeconds() );
}
lookForBoards() {
document.querySelectorAll( ".ol-strip-board" ).forEach( board => {
if ( board instanceof HTMLElement ) {
this.addBoard( new ATCBoardFlight( this, board ) );
}
}
});
}
startUpdates() {
this.#dataHandler.startUpdates();
}
protected onStatusUpdate(): void {
document.body.classList.toggle( "atc-enabled", this.getStatus() );
stopUpdates() {
this.#dataHandler.stopUpdates();
}

185
client/src/atc/atcboard.ts Normal file
View File

@@ -0,0 +1,185 @@
import { Draggable } from "leaflet";
import { Dropdown } from "../controls/dropdown";
import { zeroAppend } from "../other/utils";
import { ATC } from "./atc";
export interface StripBoardStripInterface {
"id": string,
"element": HTMLElement,
"dropdowns": {[key:string]: Dropdown}
}
export abstract class ATCBoard {
#atc:ATC;
#templates: {[key:string]: string} = {};
// Elements
#boardElement:HTMLElement;
#clockElement:HTMLElement;
#stripBoardElement:HTMLElement;
// Content
#strips:{[key:string]: StripBoardStripInterface} = {};
// Update timing
#updateInterval:number|undefined = undefined;
#updateIntervalDelay:number = 1000;
constructor( atc:ATC, boardElement:HTMLElement ) {
this.#atc = atc;
this.#boardElement = boardElement;
this.#stripBoardElement = <HTMLElement>this.getBoardElement().querySelector( ".ol-strip-board-strips" );
this.#clockElement = <HTMLElement>this.getBoardElement().querySelector( ".ol-strip-board-clock" );
if ( this.#boardElement.classList.contains( "ol-draggable" ) ) {
let options:any = {};
let handle = this.#boardElement.querySelector( ".handle" );
if ( handle instanceof HTMLElement ) {
options.handle = handle;
}
}
setInterval( () => {
this.updateClock();
}, 1000 );
}
addStrip( strip:StripBoardStripInterface ) {
this.#strips[ strip.id ] = strip;
}
calculateTimeToGo( fromTimestamp:number, toTimestamp:number ) {
let timestamp = ( toTimestamp - fromTimestamp ) / 1000;
const hasElapsed = ( timestamp < 0 ) ? true : false;
if ( hasElapsed ) {
timestamp = -( timestamp );
}
const hours = ( timestamp < 3600 ) ? "00" : zeroAppend( Math.floor( timestamp / 3600 ), 2 );
const rMinutes = timestamp % 3600;
const minutes = ( timestamp < 60 ) ? "00" : zeroAppend( Math.floor( rMinutes / 60 ), 2 );
const seconds = zeroAppend( Math.floor( rMinutes % 60 ), 2 );
return {
"elapsedMarker": ( hasElapsed ) ? "+" : "-",
"hasElapsed": hasElapsed,
"hours": hours,
"minutes": minutes,
"seconds": seconds,
"time": `${hours}:${minutes}:${seconds}`,
"totalSeconds": timestamp
};
}
deleteStrip( id:string ) {
if ( this.#strips.hasOwnProperty( id ) ) {
this.#strips[ id ].element.remove();
delete this.#strips[ id ];
}
}
getATC() {
return this.#atc;
}
getBoardElement() {
return this.#boardElement;
}
getStripBoardElement() {
return this.#stripBoardElement;
}
getStrips() {
return this.#strips;
}
getStrip( id:string ) {
return this.#strips[ id ] || false;
}
getTemplate( key:string ) {
return this.#templates[ key ] || false;
}
protected update() {
console.warn( "No custom update method defined." );
}
setTemplates( templates:{[key:string]: string} ) {
this.#templates = templates;
}
startUpdates() {
this.#updateInterval = setInterval( () => {
this.update();
}, this.#updateIntervalDelay );
}
stopUpdates() {
clearInterval( this.#updateInterval );
}
timestampToLocaleTime( timestamp:number ) {
return ( timestamp === -1 ) ? "-" : new Date( timestamp ).toLocaleTimeString();
}
timeToGo( timestamp:number ) {
const timeData = this.calculateTimeToGo( this.getATC().getMissionDateTime().getTime(), timestamp );
return ( timestamp === -1 ) ? "-" : timeData.elapsedMarker + timeData.time;
}
updateClock() {
const now = this.#atc.getMissionDateTime();
this.#clockElement.innerText = now.toLocaleTimeString();
}
}

View File

@@ -1,7 +0,0 @@
export abstract class ATCMockAPI {
constructor() {}
generateMockData() {}
}

View File

@@ -1,40 +0,0 @@
import { ATCMockAPI } from "../atcmockapi";
export class ATCMockAPI_Flights extends ATCMockAPI {
generateMockData() {
let data = [];
const statuses = [ "unknown", "checkedIn", "readyToTaxi" ]
for ( const [ i, flightName ] of [ "Shark", "Whale", "Dolphin" ].entries() ) {
data.push({
"name": flightName,
"status": statuses[ i ],
"takeOffTime": "18:0" + i
});
}
localStorage.setItem( "flightList", JSON.stringify( data ) );
}
get( generateMockDataIfEmpty?:boolean ) : object {
generateMockDataIfEmpty = generateMockDataIfEmpty || false;
let data = localStorage.getItem( "flightList" ) || "[]";
if ( data === "[]" && generateMockDataIfEmpty ) {
this.generateMockData();
}
return JSON.parse( data );
}
}

View File

@@ -0,0 +1,254 @@
import { getMissionData } from "../..";
import { Dropdown } from "../../controls/dropdown";
import { ATC } from "../atc";
import { ATCBoard, StripBoardStripInterface } from "../atcboard";
export class ATCBoardFlight extends ATCBoard {
constructor( atc:ATC, element:HTMLElement ) {
super( atc, element );
document.addEventListener( "deleteFlightStrip", ( ev:CustomEventInit ) => {
if ( ev.detail.id ) {
fetch( '/api/atc/flight/' + ev.detail.id, {
method: 'DELETE',
headers: {
'Accept': '*/*',
'Content-Type': 'application/json'
}
});
}
});
this.getBoardElement().querySelectorAll( "form.ol-strip-board-add-flight" ).forEach( form => {
if ( form instanceof HTMLFormElement ) {
form.addEventListener( "submit", ev => {
ev.preventDefault();
if ( ev.target instanceof HTMLFormElement ) {
const elements = ev.target.elements;
const flightName = <HTMLInputElement>elements[0];
if ( flightName.value === "" ) {
return;
}
fetch( '/api/atc/flight/', {
method: 'POST',
headers: {
'Accept': '*/*',
'Content-Type': 'application/json'
},
"body": JSON.stringify({
"name": flightName.value
})
});
form.reset();
}
});
}
});
}
update() {
const flights = Object.values( this.getATC().getDataHandler().getFlights() );
const stripBoard = this.getStripBoardElement();
const missionTime = this.getATC().getMissionDateTime().getTime();
for( const strip of stripBoard.children ) {
strip.toggleAttribute( "data-updating", true );
}
flights.forEach( flight => {
let strip = this.getStrip( flight.id );
if ( !strip ) {
const template = `<div class="ol-strip-board-strip" data-flight-id="${flight.id}" data-flight-status="${flight.status}">
<div data-point="name">${flight.name}</div>
<div id="flight-status-${flight.id}" class="ol-select narrow" data-point="status">
<div class="ol-select-value">${flight.status}</div>
<div class="ol-select-options"></div>
</div>
<div data-point="takeoffTime"><input type="text" name="takeoffTime" value="${this.timestampToLocaleTime( flight.takeoffTime )}" /></div>
<div data-point="timeToGo">${this.timeToGo( flight.takeoffTime )}</div>
<button data-on-click="deleteFlightStrip" data-on-click-params='{"id":"${flight.id}"}'>Delete</button>
</div>`;
stripBoard.insertAdjacentHTML( "beforeend", template );
strip = {
"id": flight.id,
"element": <HTMLElement>stripBoard.lastElementChild,
"dropdowns": {}
};
strip.element.querySelectorAll( ".ol-select" ).forEach( select => {
switch( select.getAttribute( "data-point" ) ) {
case "status":
strip.dropdowns.status = new Dropdown( select.id, ( value:string, ev:MouseEvent ) => {
fetch( '/api/atc/flight/' + flight.id, {
method: 'PATCH',
headers: {
'Accept': '*/*',
'Content-Type': 'application/json'
},
"body": JSON.stringify({
"status": value
})
});
}, [
"unknown", "checkedin", "readytotaxi", "clearedtotaxi", "halted", "terminated"
]);
break;
}
});
strip.element.querySelectorAll( `input[type="text"]` ).forEach( input => {
if ( input instanceof HTMLInputElement ) {
input.addEventListener( "blur", ( ev ) => {
const target = ev.target;
if ( target instanceof HTMLInputElement ) {
if ( /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/.test( target.value ) ) {
target.value += ":00";
}
const value = target.value;
if ( value === target.dataset.previousValue ) {
return;
} else if ( value === "" ) {
this.#updateTakeoffTime( flight.id, -1 );
} else if ( /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/.test( value ) ) {
let [ hours, minutes, seconds ] = value.split( ":" ).map( str => parseInt( str ) );
const missionStart = this.getATC().getMissionStartDateTime();
this.#updateTakeoffTime( flight.id, new Date(
missionStart.getFullYear(),
missionStart.getMonth(),
missionStart.getDate(),
hours,
minutes,
seconds
).getTime() );
} else {
target.value === target.dataset.previousValue
}
}
});
}
});
this.addStrip( strip );
} else {
if ( flight.status !== strip.element.getAttribute( "data-flight-status" ) ) {
strip.element.setAttribute( "data-flight-status", flight.status );
strip.dropdowns.status.selectText( flight.status );
}
strip.element.querySelectorAll( `input[name="takeoffTime"]:not(:focus)` ).forEach( el => {
if ( el instanceof HTMLInputElement ) {
el.value = this.timestampToLocaleTime( flight.takeoffTime );
el.dataset.previousValue = el.value;
}
});
strip.element.querySelectorAll( `[data-point="timeToGo"]` ).forEach( el => {
if ( flight.takeoffTime > 0 && this.calculateTimeToGo( missionTime, flight.takeoffTime ).totalSeconds <= 120 ) {
strip.element.setAttribute( "data-time-warning", "level-1" );
}
if ( el instanceof HTMLElement ) {
el.innerText = this.timeToGo( flight.takeoffTime );
}
});
}
strip.element.toggleAttribute( "data-updating", false );
});
stripBoard.querySelectorAll( `[data-updating]` ).forEach( strip => {
this.deleteStrip( strip.getAttribute( "data-flight-id" ) || "" );
});
}
#updateTakeoffTime = function( flightId:string, time:number ) {
fetch( '/api/atc/flight/' + flightId, {
method: 'PATCH',
headers: {
'Accept': '*/*',
'Content-Type': 'application/json'
},
"body": JSON.stringify({
"takeoffTime": time
})
});
}
}

View File

@@ -1,18 +0,0 @@
import { ATCMockAPI_Flights } from "./atcmockapi/flights";
export class ATCFLightList {
constructor() {
}
getFlights( generateMockDataIfEmpty?:boolean ) {
let api = new ATCMockAPI_Flights();
return api.get( generateMockDataIfEmpty );
}
}

View File

@@ -24,6 +24,8 @@ export class Dropdown {
this.#clip();
});
this.#options.classList.add( "ol-scrollable" );
// Commented out since it is a bit frustrating, particularly when the dropdown opens towards the top and not to the bottom
//this.#element.addEventListener("mouseleave", ev => {
// this.#close();
@@ -53,6 +55,15 @@ export class Dropdown {
}));
}
selectText( text:string ) {
const index = [].slice.call( this.#options.children ).findIndex( ( opt:Element ) => opt.querySelector( "button" )?.innerText === text );
if ( index > -1 ) {
this.selectValue( index );
}
}
selectValue(idx: number)
{
if (idx < this.#optionsList.length)

View File

@@ -169,7 +169,11 @@ export class MapContextMenu extends ContextMenu {
#setGroundUnitRole(role: string) {
this.#spawnOptions.role = role;
this.#resetGroundUnitType();
this.#groundUnitTypeDropdown.setOptions(groundUnitsDatabase.getByRole(role).map((blueprint) => { return blueprint.label }));
const types = groundUnitsDatabase.getByRole(role).map((blueprint) => { return blueprint.label } );
types.sort();
this.#groundUnitTypeDropdown.setOptions( types );
this.#groundUnitTypeDropdown.selectValue(0);
this.clip();
}
@@ -179,7 +183,11 @@ export class MapContextMenu extends ContextMenu {
(<HTMLButtonElement>this.getContainer()?.querySelector("#loadout-list")).replaceChildren();
this.#groundUnitRoleDropdown.reset();
this.#groundUnitTypeDropdown.reset();
this.#groundUnitRoleDropdown.setOptions(groundUnitsDatabase.getRoles());
const roles = groundUnitsDatabase.getRoles();
roles.sort();
this.#groundUnitRoleDropdown.setOptions( roles );
this.clip();
}

View File

@@ -13,6 +13,7 @@ import { getAirbases, getBullseye as getBullseyes, getConfig, getMission, getUni
import { UnitDataTable } from "./units/unitdatatable";
import { keyEventWasInInput } from "./other/utils";
import { Popup } from "./popups/popup";
import { Dropdown } from "./controls/dropdown";
var map: Map;
@@ -71,9 +72,12 @@ function setup() {
let atcFeatureSwitch = featureSwitches.getSwitch("atc");
if (atcFeatureSwitch?.isEnabled()) {
atc = new ATC();
// TODO: add back buttons
atc.startUpdates();
}
new Dropdown( "app-icon", () => {} );
/* Setup event handlers */
setupEvents();
@@ -227,6 +231,7 @@ function setupEvents() {
el.classList.toggle( "hide" );
})
});
}
export function getMap() {