diff --git a/client/public/stylesheets/atc.css b/client/public/stylesheets/atc.css index e69de29b..9c779eb3 100644 --- a/client/public/stylesheets/atc.css +++ b/client/public/stylesheets/atc.css @@ -0,0 +1,114 @@ + + +.ol-strip-board-strips { + display:flex; + flex-direction: column; + row-gap: 4px; +} + +.ol-strip-board-strip { + align-items: center; + border-radius: var( --border-radius-sm ); + column-gap: 4px; + display:flex; + flex-flow: row nowrap; + padding:4px; +} + +.ol-strip-board-strip[data-flight-status="checkedin"] { + background-color: #ffffff2A; +} + +.ol-strip-board-strip[data-flight-status="readytotaxi"] { + background-color: #ffff002A; +} + +.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:70px; +} + +.ol-strip-board-strip input[type="text"] { + appearance: none; + background-color: transparent; + border:1px solid #ffffff30; + border-radius: var( --border-radius-sm ); + color:white; + font-size:12px; + font-weight:normal; + outline:none; + padding: 4px 0; + text-align: center; + width:100%; +} + +.ol-strip-board-strip[data-time-warning="level-1"] [data-point="timeToGo"] { + border:1px solid #cc0000; +} + +.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; +} + +.ol-strip-board-strip :nth-child(4) { + text-align: center; +} + +.ol-strip-board-strip > [data-point="name"] { + text-overflow: ellipsis; + overflow:hidden; +} + +.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..4c713e0e 100644 --- a/client/public/stylesheets/layout.css +++ b/client/public/stylesheets/layout.css @@ -124,8 +124,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 +137,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 02b41a03..a273c0ef 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"); @@ -123,14 +124,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; } diff --git a/client/routes/api/atc.js b/client/routes/api/atc.js index fa6efabf..517533cb 100644 --- a/client/routes/api/atc.js +++ b/client/routes/api/atc.js @@ -26,18 +26,53 @@ function uuidv4() { function Flight( name ) { - this.id = uuidv4(); - this.name = name; - this.status = "unknown"; + this.id = uuidv4(); + this.name = name; + this.status = "unknown"; + this.takeoffTime = -1; } Flight.prototype.getData = function() { return { - "id": this.id, - "name": this.name + "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; } @@ -86,6 +121,40 @@ app.get( "/flight", ( req, res ) => { }); +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 ) { diff --git a/client/src/atc/atc.ts b/client/src/atc/atc.ts index d3010987..d63634bf 100644 --- a/client/src/atc/atc.ts +++ b/client/src/atc/atc.ts @@ -2,9 +2,10 @@ import { ATCBoard } from "./atcboard"; import { ATCBoardFlight } from "./board/flight"; export interface FlightInterface { - id : string; - name : string; - status : "unknown"; + id : string; + name : string; + status : "unknown"; + takeoffTime : number; } @@ -73,6 +74,7 @@ export class ATC { #boards:ATCBoard[] = []; #dataHandler:ATCDataHandler; + #initDate:Date = new Date(); constructor() { @@ -97,9 +99,24 @@ export class ATC { } + 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-atc-board" ).forEach( board => { + document.querySelectorAll( ".ol-strip-board" ).forEach( board => { if ( board instanceof HTMLElement ) { this.addBoard( new ATCBoardFlight( this, board ) ); diff --git a/client/src/atc/atcboard.ts b/client/src/atc/atcboard.ts index 780cfed6..50766884 100644 --- a/client/src/atc/atcboard.ts +++ b/client/src/atc/atcboard.ts @@ -1,3 +1,4 @@ +import { zeroAppend } from "../other/utils"; import { ATC } from "./atc"; export interface ATCTemplateInterface { @@ -11,6 +12,7 @@ export abstract class ATCBoard { // Elements #boardElement:HTMLElement; + #clockElement:HTMLElement; #stripBoardElement:HTMLElement; // Update timing @@ -22,7 +24,34 @@ export abstract class ATCBoard { this.#atc = atc; this.#boardElement = boardElement; - this.#stripBoardElement = this.getBoardElement().querySelector( ".atc-strip-board" ); + this.#stripBoardElement = this.getBoardElement().querySelector( ".ol-strip-board-strips" ); + this.#clockElement = this.getBoardElement().querySelector( ".ol-strip-board-clock" ); + + + setInterval( () => { + this.updateClock(); + }, 1000 ); + + } + + + calculateTimeToGo( fromTimestamp:number, toTimestamp:number ) { + + let timestamp = ( toTimestamp - fromTimestamp ) / 1000; + + const hours = zeroAppend( Math.floor( timestamp / 3600 ), 2 ); + const rMinutes = timestamp % 3600; + + const minutes = zeroAppend( Math.floor( rMinutes / 60 ), 2 ); + const seconds = zeroAppend( Math.floor( rMinutes % 60 ), 2 ); + + return { + "hours": hours, + "minutes": minutes, + "seconds": seconds, + "time": `${hours}:${minutes}:${seconds}`, + "totalSeconds": timestamp + }; } @@ -76,5 +105,27 @@ export abstract class ATCBoard { } + + timestampToLocaleTime( timestamp:number ) { + + return ( timestamp === -1 ) ? "-" : new Date( timestamp ).toLocaleTimeString(); + + } + + + timeToGo( timestamp:number ) { + + return ( timestamp === -1 ) ? "-" : this.calculateTimeToGo( this.getATC().getMissionDateTime().getTime(), timestamp ).time; + + } + + + updateClock() { + + const now = this.#atc.getMissionDateTime(); + this.#clockElement.innerText = now.toLocaleTimeString(); + + } + } \ No newline at end of file diff --git a/client/src/atc/board/flight.ts b/client/src/atc/board/flight.ts index 8bd4b1ba..a348f38c 100644 --- a/client/src/atc/board/flight.ts +++ b/client/src/atc/board/flight.ts @@ -1,15 +1,15 @@ import { getMissionData } from "../.."; +import { Dropdown } from "../../controls/dropdown"; import { ATC } from "../atc"; import { ATCBoard } from "../atcboard"; + export class ATCBoardFlight extends ATCBoard { constructor( atc:ATC, element:HTMLElement ) { super( atc, element ); - console.log( getMissionData() ); - document.addEventListener( "deleteFlightStrip", ( ev:CustomEventInit ) => { if ( ev.detail.id ) { @@ -26,6 +26,46 @@ export class ATCBoardFlight extends ATCBoard { }); + + 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(); + + } + + }); + + } + + }); + } @@ -34,6 +74,7 @@ export class ATCBoardFlight extends ATCBoard { 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 ); @@ -46,9 +87,18 @@ export class ATCBoardFlight extends ATCBoard { if ( !strip ) { - const template = `
+ const template = `
${flight.name}
-
${flight.status}
+ +
+
${flight.status}
+
+
+ +
+ +
${this.timeToGo( flight.takeoffTime )}
+
`; @@ -56,6 +106,115 @@ export class ATCBoardFlight extends ATCBoard { strip = stripBoard.lastElementChild; + strip.querySelectorAll( ".ol-select" ).forEach( select => { + + switch( select.getAttribute( "data-point" ) ) { + + case "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.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 + + } + + } + + }); + + } + + }); + + } else { + + // TODO: change status dropdown if status is different + strip.setAttribute( "data-flight-status", flight.status ); + + strip.querySelectorAll( `input[name="takeoffTime"]:not(:focus)` ).forEach( el => { + if ( el instanceof HTMLInputElement ) { + el.value = this.timestampToLocaleTime( flight.takeoffTime ); + el.dataset.previousValue = el.value; + } + }); + + strip.querySelectorAll( `[data-point="timeToGo"]` ).forEach( el => { + + if ( flight.takeoffTime > 0 && this.calculateTimeToGo( missionTime, flight.takeoffTime ).totalSeconds <= 120 ) { + strip?.setAttribute( "data-time-warning", "level-1" ); + } + + if ( el instanceof HTMLElement ) { + el.innerText = this.timeToGo( flight.takeoffTime ); + } + + }); + + } strip.toggleAttribute( "data-updating", false ); @@ -68,4 +227,22 @@ export class ATCBoardFlight extends ATCBoard { } + + + + #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/controls/dropdown.ts b/client/src/controls/dropdown.ts index 52716b01..04f656d8 100644 --- a/client/src/controls/dropdown.ts +++ b/client/src/controls/dropdown.ts @@ -13,8 +13,29 @@ export class Dropdown { this.#value = this.#element.querySelector(".ol-select-value"); this.#defaultValue = this.#value.innerText; this.#callback = callback; - if (options != null) + if (options != null) { this.setOptions(options); + } + + + + // Do open/close toggle + this.#element.addEventListener("click", ev => { + + if ( ev.target instanceof HTMLElement && ev.target.nodeName !== "A" ) { + ev.preventDefault(); + } + + ev.stopPropagation(); + this.#element.classList.toggle("is-open"); + + }); + + // Autoclose on mouseleave + this.#element.addEventListener("mouseleave", ev => { + this.#element.classList.remove("is-open"); + }); + } setOptions(optionsList: string[]) @@ -27,7 +48,7 @@ export class Dropdown { div.appendChild(button); button.addEventListener("click", (e: MouseEvent) => { this.#value.innerText = option; - this.#callback(option); + this.#callback( option, e ); }); return div; })); diff --git a/client/src/index.ts b/client/src/index.ts index da4940ba..1e8e02e0 100644 --- a/client/src/index.ts +++ b/client/src/index.ts @@ -124,6 +124,7 @@ function checkSessionHash(newSessionHash: string) { function setupEvents() { /* Generic clicks */ document.addEventListener("click", (ev) => { + if (ev instanceof PointerEvent && ev.target instanceof HTMLElement) { const target = ev.target; if (target.classList.contains("olympus-dialog-close")) { @@ -194,26 +195,6 @@ function setupEvents() { }) }); - /** Olympus UI ***/ - document.querySelectorAll(".ol-select").forEach(select => { - - // Do open/close toggle - select.addEventListener("click", ev => { - - if ( ev.target instanceof HTMLElement && ev.target.nodeName !== "A" ) { - ev.preventDefault(); - } - - ev.stopPropagation(); - select.classList.toggle("is-open"); - }); - - // Autoclose on mouseleave - select.addEventListener("mouseleave", ev => { - select.classList.remove("is-open"); - }); - - }); } export function getMap() { diff --git a/client/views/atc.ejs b/client/views/atc.ejs index 66c36e5f..7f94557a 100644 --- a/client/views/atc.ejs +++ b/client/views/atc.ejs @@ -1,5 +1,26 @@ -
+
+ +
-
+
+
+
+ +
+
+
Flight
+
Status
+
T/O Time
+
TTG
+
+
+
+ +
\ No newline at end of file