diff --git a/client/app.js b/client/app.js index de7446eb..7c2ad473 100644 --- a/client/app.js +++ b/client/app.js @@ -4,6 +4,7 @@ var cookieParser = require('cookie-parser'); var logger = require('morgan'); var fs = require('fs'); +var atcRouter = require('./routes/api/atc'); var indexRouter = require('./routes/index'); var uikitRouter = require('./routes/uikit'); var usersRouter = require('./routes/users'); @@ -17,6 +18,7 @@ app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'))); app.use('/', indexRouter); +app.use('/api/atc', atcRouter); app.use('/users', usersRouter); app.use('/uikit', uikitRouter); diff --git a/client/public/stylesheets/atc.css b/client/public/stylesheets/atc.css index 61f79d75..7eb22afb 100644 --- a/client/public/stylesheets/atc.css +++ b/client/public/stylesheets/atc.css @@ -1,211 +1,114 @@ -/*** Control panel ***/ - -#atc-control-panel { - align-self: flex-end; - background: white; - border-radius: 10px; - display:flex; - margin: 0 0 50px 100px; - padding:5px; - position: absolute; - z-index: 9999; -} - -.atc-tool { - align-self: center; - border-radius: 10px; - display:none; - justify-self: center; - padding: 10px; - position: absolute; - z-index: 9999; -} -.atc-enabled .atc-tool { - display:flex; -} - - - - -#atc-flight-list { - flex-direction: column; -} - -#atc-flight-list table { - color:white; -} - -#atc-flight-list table td { - padding:0 10px; - text-align: center; -} - -#atc-flight-list table td:first-of-type { - text-align: left; -} - -#atc-flight-list table tr[data-status='checkedIn'] td { - background-color:goldenrod; -} - -#atc-flight-list table tr[data-status='readyToTaxi'] td { - background-color:darkgreen; -} - -#atc-flight-list table button { - background-color: #666; - border:1px solid white; - color:white; - font-weight: bold; - margin:2px 0; -} - - -.atc-strip-board { - align-self: center; - display:flex; - justify-self: center; - position: absolute; - z-index: 9999 ; -} - -.atc-strip-board-header { - display:none; -} - -.atc-strip-board-strips { +.ol-strip-board-strips { display:flex; flex-direction: column; + row-gap: 4px; } -.atc-strip-board-strip { - display:flex; - flex-direction: row; -} - - -/* -.atc-strip-board-header { - background:black; - color:white; - display:none; - justify-content: right; -} - - -.atc-strip-board { - display:flex; - flex-direction: column; - row-gap: 5px; -} - -.atc-strip-board-strips { - display:flex; - flex-direction: column; - padding:10px; - row-gap: 5px; -} - -.atc-strip-board-strips > div { +.ol-strip-board-strip { align-items: center; + border-radius: var( --border-radius-sm ); + column-gap: 4px; + display:flex; + flex-flow: row nowrap; + row-gap:4px; +} + +.ol-strip-board-strip[data-flight-status="checkedin"] { + background-color: #ffffff2A; +} + +.ol-strip-board-strip[data-flight-status="readytotaxi"] { + background-color: #ffff0063; +} + +.ol-strip-board-strip[data-flight-status="clearedtotaxi"] { + background-color: #00ff0030; +} + +.ol-strip-board-strip[data-flight-status="halted"] { + background-color: #FF000040; +} + +.ol-strip-board-strip[data-flight-status="terminated"] { + background-color: black; +} + +.ol-strip-board-headers { + column-gap: 4px; + display:flex; + flex-flow:row nowrap; + text-align: center; +} + +.ol-strip-board-headers > *, .ol-strip-board-strip > * { + padding: 4px; + text-overflow: ellipsis; + white-space: nowrap; + width:80px; +} + +.ol-strip-board-strip input[type="text"] { + appearance: none; + background-color: transparent; + border:1px solid #ffffff30; + border-radius: var( --border-radius-sm ); color:white; - column-gap: 2px; - display: flex; - flex-direction: row; - padding: 5px; -} - -.atc-strip-board-header > div, .atc-strip-board-strips > div > div { - text-align: center; - width: 75px; -} - -.atc-strip-board-header > .name { - width:150px; -} - -.atc-strip-board-header > div, .atc-strip-board-strips > div > div { - text-align: center; - width: 75px; -} - -.atc-strip-board-strips > div > .name { - text-align: left; - width:150px; -} - -.atc-strip-board-strips > div { - align-items: center; - column-gap: 5px; - display: flex; - flex-direction: row; font-size:12px; - font-weight: 600; - padding: 5px; - row-gap: 5px; -} - - -/* - -.atc-strip-board-header, .atc-strip-board-strips > div { - align-items: center; - background:#FFF3; - color:white; - column-gap: 5px; - display: flex; - flex-direction: row; - font-size:12px; - font-weight: 600; - padding: 5px; - row-gap: 5px; -} - -.atc-strip-board-header { - background:black; - color:white; - display:none; - justify-content: right; -} - -.atc-strip-board-strips > div { - border-bottom:1px solid black; -} - -.atc-strip-board-header > div, .atc-strip-board-strips > div > div { + font-weight:normal; + outline:none; + padding: 4px 0; text-align: center; - width: 75px; + width:100%; } -.atc-strip-board-header > .name { - width:150px; +.ol-strip-board-strip[data-time-warning="level-1"] [data-point="timeToGo"] { + border:1px solid #cc0000; } -.atc-strip-board-strips > div > .handle { - background: black; - border-radius: 50%; - cursor:grab; - height:10px; - width:10px; +.ol-strip-board-headers :nth-child(1), +.ol-strip-board-headers :nth-child(2), +.ol-strip-board-strip :nth-child(1), +.ol-strip-board-strip :nth-child(2) { + width:120px; } -.atc-strip-board-strips > div > .name { - text-align: left; - width:150px; +.ol-strip-board-strip :nth-child(4) { + text-align: center; } -.atc-strip-board-strips > div > .warning { - background:red; - color: white; - font-weight: bold; +.ol-strip-board-strip > [data-point="name"] { + text-overflow: ellipsis; + overflow:hidden; } -.atc-strip-board-strips > div > .link-warning { - border: 1px solid red; - color: red; - font-weight: bold; - +.ol-strip-board-strip .ol-select-value { + opacity: .85; } - */ + + +.ol-strip-board-add-flight { + display:flex; + flex-flow: row nowrap; +} + + +.ol-strip-board-add-flight > * { + border:none; + outline: none; + padding:4px 8px; +} + +.ol-strip-board-add-flight button { + background-color: darkgreen; + border-bottom-right-radius: var( --border-radius-sm ); + border-bottom-left-radius: 0; + border-top-left-radius: 0; + border-top-right-radius: var( --border-radius-sm ); +} + +.ol-strip-board-add-flight input { + border-bottom-left-radius: var( --border-radius-sm ); + border-top-left-radius: var( --border-radius-sm ); +} \ No newline at end of file diff --git a/client/public/stylesheets/layout.css b/client/public/stylesheets/layout.css index ed682be4..fe45ecaf 100644 --- a/client/public/stylesheets/layout.css +++ b/client/public/stylesheets/layout.css @@ -7,6 +7,8 @@ } #primary-toolbar { + align-items: center; + display:flex; position: absolute; left: 10px; top: 10px; @@ -124,8 +126,8 @@ dl.ol-data-grid dd { font-size:16px; font-weight: var( --font-weight-bolder ); position: absolute; - right: 25px; - top: 25px; + right: 20px; + top: 10px; } .ol-dialog-close::before { @@ -137,6 +139,10 @@ dl.ol-data-grid dd { padding-bottom:10px; } +.ol-dialog-content { + margin:4px 0; +} + .ol-dialog-footer { border-top:1px solid var( --background-grey ); padding-top:15px; diff --git a/client/public/stylesheets/olympus.css b/client/public/stylesheets/olympus.css index ee1b97c8..2864d9cc 100644 --- a/client/public/stylesheets/olympus.css +++ b/client/public/stylesheets/olympus.css @@ -1,5 +1,6 @@ @import url("layout.css"); @import url("airbase.css"); +@import url("atc.css"); @import url("connectionstatuspanel.css"); @import url("contextmenus.css"); @import url("mouseinfopanel.css"); @@ -140,14 +141,17 @@ form > div { align-items: center; background-color: var(--background-grey); border-radius: var(--border-radius-sm); - padding: 1em; + padding: 1em 30px 1em 20px; width: 100%; - padding-left: 20px; - padding-right: 30px; overflow: hidden; text-overflow: ellipsis; } +.ol-select.narrow:not(.ol-select-image)>.ol-select-value { + opacity: .9; + padding:6px 30px 6px 15px; +} + .ol-select:not(.ol-select-image)>.ol-select-value svg { margin-right: 10px; } @@ -350,6 +354,11 @@ nav.ol-panel> :last-child { row-gap: 4px; } +.ol-group-header { + text-align: center; + width: 100%; +} + .ol-panel .ol-group.wrap { flex-wrap: wrap; } @@ -549,30 +558,6 @@ nav.ol-panel> :last-child { width: 28px; } -#unit-selection #unit-identification [data-object|="unit"] .unit-short-label { - font-size: 12px; -} - -#unit-selection #unit-identification #unit-name { - background-color: transparent; - border: none; - color: white; - font-size: 16px; - font-weight: var(--font-weight-bolder); - outline: none; - overflow: hidden; - white-space: nowrap; - width: 150px; -} - -#edit-unit-name { - background-image: url("/images/buttons/edit.svg"); - background-repeat: no-repeat; - height: 14px; - margin-left: 10px; - width: 15px; -} - #unit-visibility-control { align-items: center; } @@ -623,6 +608,18 @@ body[data-hide-navyunit] #unit-visibility-control-navyunit { background-image: var(--visibility-control-navyunit-hidden-url); } +#atc-navbar-control { + align-items: center; + display:flex; + flex-direction: column; +} + +#atc-navbar-control button { + background:#ffffff20; + border-radius: var( --border-radius-sm ); + padding:4px; +} + .toggle { --width: 40px; diff --git a/client/routes/api/atc.js b/client/routes/api/atc.js new file mode 100644 index 00000000..517533cb --- /dev/null +++ b/client/routes/api/atc.js @@ -0,0 +1,190 @@ +var express = require('express'); +var app = express(); + +const bodyParser = require('body-parser'); +app.use(bodyParser.urlencoded({ extended: false})); +app.use(bodyParser.json()); + + +/* + + Flight: + "name" + "take-off time" + "priority" + "status" + +//*/ + +function uuidv4() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); +} + + + +function Flight( name ) { + this.id = uuidv4(); + this.name = name; + this.status = "unknown"; + this.takeoffTime = -1; +} + +Flight.prototype.getData = function() { + return { + "id" : this.id, + "name" : this.name, + "status" : this.status, + "takeoffTime" : this.takeoffTime + }; +} + + +Flight.prototype.setStatus = function( status ) { + + if ( [ "unknown", "checkedin", "readytotaxi", "clearedtotaxi", "halted", "terminated" ].indexOf( status ) < 0 ) { + return "Invalid status"; + } + + this.status = status; + + return true; + +} + + +Flight.prototype.setTakeoffTime = function( takeoffTime ) { + + if ( takeoffTime === "" || takeoffTime === -1 ) { + this.takeoffTime = -1; + } + + if ( isNaN( takeoffTime ) ) { + return "Invalid takeoff time" + } + + this.takeoffTime = parseInt( takeoffTime ); + + return true; + +} + + + +function ATCDataHandler( data ) { + this.data = data; +} + +ATCDataHandler.prototype.addFlight = function( flight ) { + + if ( flight instanceof Flight === false ) { + throw new Error( "Given flight is not an instance of Flight" ); + } + + this.data.flights[ flight.id ] = flight; + +} + + +ATCDataHandler.prototype.deleteFlight = function( flightId ) { + delete this.data.flights[ flightId ]; +} + + +ATCDataHandler.prototype.getFlight = function( flightId ) { + return this.data.flights[ flightId ] || false; +} + + +ATCDataHandler.prototype.getFlights = function() { + return this.data.flights; +} + + +const dataHandler = new ATCDataHandler( { + "flights": {} +} ); + + + +/**************************************************************************************************************/ +// Endpoints +/**************************************************************************************************************/ + + +app.get( "/flight", ( req, res ) => { + + res.json( dataHandler.getFlights() ); + +}); + + +app.patch( "/flight/:flightId", ( req, res ) => { + + const flightId = req.params.flightId; + const flight = dataHandler.getFlight( flightId ); + + if ( !flight ) { + res.status( 400 ).send( `Unrecognised flight ID (given: "${req.params.flightId}")` ); + } + + if ( req.body.status ) { + + const statusChangeSuccess = flight.setStatus( req.body.status ); + + if ( statusChangeSuccess !== true ) { + res.status( 400 ).send( statusChangeSuccess ); + } + + } + + if ( req.body.hasOwnProperty( "takeoffTime" ) ) { + + const takeoffChangeSuccess = flight.setTakeoffTime( req.body.takeoffTime ); + + if ( takeoffChangeSuccess !== true ) { + res.status( 400 ).send( takeoffChangeSuccess ); + } + + } + + res.json( flight.getData() ); + +}); + + +app.post( "/flight", ( req, res ) => { + + if ( !req.body.name ) { + res.status( 400 ).send( "Invalid/missing flight name" ); + } + + const flight = new Flight( req.body.name ); + + dataHandler.addFlight( flight ); + + res.status( 201 ); + + res.json( flight.getData() ); + +}); + + +app.delete( "/flight/:flightId", ( req, res ) => { + + const flight = dataHandler.getFlight( req.params.flightId ); + + if ( !flight ) { + res.status( 400 ).send( `Unrecognised flight ID (given: "${req.params.flightId}")` ); + } + + dataHandler.deleteFlight( req.params.flightId ); + + res.status( 204 ).send( "" ); + +}); + + +module.exports = app; \ No newline at end of file diff --git a/client/src/atc/atc.ts b/client/src/atc/atc.ts index 14710071..d63634bf 100644 --- a/client/src/atc/atc.ts +++ b/client/src/atc/atc.ts @@ -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( 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(); } diff --git a/client/src/atc/atcboard.ts b/client/src/atc/atcboard.ts new file mode 100644 index 00000000..0ab9f52d --- /dev/null +++ b/client/src/atc/atcboard.ts @@ -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 = this.getBoardElement().querySelector( ".ol-strip-board-strips" ); + this.#clockElement = 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(); + + } + + +} \ No newline at end of file diff --git a/client/src/atc/atcmockapi.ts b/client/src/atc/atcmockapi.ts deleted file mode 100644 index 9720e2d0..00000000 --- a/client/src/atc/atcmockapi.ts +++ /dev/null @@ -1,7 +0,0 @@ -export abstract class ATCMockAPI { - - constructor() {} - - generateMockData() {} - -} \ No newline at end of file diff --git a/client/src/atc/atcmockapi/flights.ts b/client/src/atc/atcmockapi/flights.ts deleted file mode 100644 index 6d6b6435..00000000 --- a/client/src/atc/atcmockapi/flights.ts +++ /dev/null @@ -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 ); - - } - -} \ No newline at end of file diff --git a/client/src/atc/board/flight.ts b/client/src/atc/board/flight.ts new file mode 100644 index 00000000..b9830fc8 --- /dev/null +++ b/client/src/atc/board/flight.ts @@ -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 = 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 = `
+
${flight.name}
+ +
+
${flight.status}
+
+
+ +
+ +
${this.timeToGo( flight.takeoffTime )}
+ + +
`; + + stripBoard.insertAdjacentHTML( "beforeend", template ); + + + strip = { + "id": flight.id, + "element": 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 + }) + }); + + } + +} \ No newline at end of file diff --git a/client/src/atc/flightlist.ts b/client/src/atc/flightlist.ts deleted file mode 100644 index 36185eef..00000000 --- a/client/src/atc/flightlist.ts +++ /dev/null @@ -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 ); - } - -} \ No newline at end of file diff --git a/client/src/controls/dropdown.ts b/client/src/controls/dropdown.ts index fb807855..a74d2628 100644 --- a/client/src/controls/dropdown.ts +++ b/client/src/controls/dropdown.ts @@ -83,6 +83,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) diff --git a/client/src/index.ts b/client/src/index.ts index 14f0037f..feb401e2 100644 --- a/client/src/index.ts +++ b/client/src/index.ts @@ -65,7 +65,7 @@ function setup() { let atcFeatureSwitch = featureSwitches.getSwitch("atc"); if (atcFeatureSwitch?.isEnabled()) { atc = new ATC(); - // TODO: add back buttons + atc.startUpdates(); } @@ -217,6 +217,7 @@ function setupEvents() { el.classList.toggle( "hide" ); }) }); + } export function getMap() { diff --git a/client/views/atc.ejs b/client/views/atc.ejs index ee082b92..062f5738 100644 --- a/client/views/atc.ejs +++ b/client/views/atc.ejs @@ -1,106 +1,26 @@ -
-
-
+
- -
- - - - - - - - - - - -
FlightT/OTTGStatus
-
- - -
- -
-
Name
-
BR
-
t. Alt
-
Alt
-
t. Spd
-
Speed
-
RWY
-
Line
-
+
- -
-
-
-
-
Shark 3
-
250 / 28
-
-
-
10000
-
-
-
421
-
-
-
-
-
-
-
-
-
-
Shark 2
-
250 / 24
-
6000
-
6000
-
-
-
400
-
-
-
-
-
-
-
-
-
-
Shark 1
- -
5000
-
5100
-
-
-
367
-
-
-
-
-
-
-
-
-
-
Dolphin 1
-
250 / 4
- -
4100
-
-
-
511
-
25L
-
2nd
-
-
-
-
-
-
Whale 1
-
070 / 2
-
1500
-
1650
- -
312
-
25L
-
1st
-
-
+
+
+
+
+
Flight
+
Status
+
T/O Time
+
TTG
+
+
+
-
+ +
\ No newline at end of file diff --git a/client/views/navbar.ejs b/client/views/navbar.ejs index 9d5993c6..d7dad433 100644 --- a/client/views/navbar.ejs +++ b/client/views/navbar.ejs @@ -50,4 +50,13 @@
+ + +
+
ATC
+
+ + +
+
\ No newline at end of file