diff --git a/apps/flightdash/ChangeLog b/apps/flightdash/ChangeLog new file mode 100644 index 000000000..971e5b97e --- /dev/null +++ b/apps/flightdash/ChangeLog @@ -0,0 +1 @@ +1.00: initial release diff --git a/apps/flightdash/README.md b/apps/flightdash/README.md new file mode 100644 index 000000000..07b753178 --- /dev/null +++ b/apps/flightdash/README.md @@ -0,0 +1,76 @@ +# Flight Dashboard + +Shows basic flight and navigation instruments. + +![](screenshot.png) + +Basic flight data includes: + +- Ground speed +- Track +- Altimeter +- VSI +- Local time + +You can also set a destination to get nav guidance: + +- Distance from destination +- Bearing to destination +- Estimated Time En-route (minutes and seconds) +- Estimated Time of Arrival (in UTC) + +The speed/distance and altitude units are configurable. + +Altitude data can be derived from GPS or the Bangle's barometer. + + +## DISCLAIMER + +Remember to Aviate - Navigate - Communicate! Do NOT get distracted by your +gadgets, keep your eyes looking outside and do NOT rely on this app for actual +navigation! + + +## Usage + +After installing the app, use the "interface" page (floppy disk icon) in the +App Loader to filter and upload a list of airports (to be used as navigation +destinations). Due to memory constraints, only up to about 500 airports can be +stored on the Bangle itself (recommended is around 100 - 150 airports max.). + +Then, on the Bangle, access the Flight-Dash settings, either through the +Settings app (Settings -> Apps -> Flight-Dash) or a tap anywhere in the +Flight-Dash app itself. The following settings are available: + +- **Nav Dest.**: Choose the navigation destination: + - Nearest airports (from the uploaded list) + - Search the uploaded list of airports + - User waypoints (which can be set/edited through the settings) + - Nearest airports (queried online through AVWX - requires Internet connection at the time) +- **Speed** and **Altitude**: Set the preferred units of measurements. +- **Use Baro**: If enabled, altitude information is derived from the Bangle's barometer (instead of using GPS altitude). + +If the barometer is used for altitude information, the current QNH value is +also displayed. It can be adjusted by swiping up/down in the app. + +To query the nearest airports online through AVWX, you have to install - and +configure - the [avwx](?id=avwx) module. + +The app requires a text input method (to set user waypoint names, and search +for airports), and if not already installed will automatically install the +default "textinput" app as a dependency. + + +## Hint + +Under the bearing "band", the current nav destination is displayed. Next to +that, you'll also find the cardinal direction you are approaching **from**. +This can be useful for inbound radio calls. Together with the distance, the +current altitude and the ETA, you have all the information required to make +radio calls like a pro! + + +## Author + +Flaparoo [github](https://github.com/flaparoo) + diff --git a/apps/flightdash/flightdash-icon.js b/apps/flightdash/flightdash-icon.js new file mode 100644 index 000000000..3a2e2757c --- /dev/null +++ b/apps/flightdash/flightdash-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwhC/AH4A/AHcCkAX/C/4X9kUiC/4XcgczmcwRSArBkYWBAAMyA4KUMC4QWDAAIXOAAUziMTmMRmZdRmQXDkZhIHQJ1IAAYXGBgoNDgQJFLoQhFDQ84wQFDlGDBwxBInGIDAUoxAXFJosDOIIXDAAgXCPoJkGBAKfBmc6C4ujBIINBiYXIEIMK1AWDxWgHoQXMgGqC4eqKoYXHL4QFChQYC1QuBEwbcHZo7hHBpYA/AH4A/AH4")) diff --git a/apps/flightdash/flightdash.app.js b/apps/flightdash/flightdash.app.js new file mode 100644 index 000000000..f612836c6 --- /dev/null +++ b/apps/flightdash/flightdash.app.js @@ -0,0 +1,527 @@ +/* + * Flight Dashboard - Bangle.js + */ + +const COLOUR_BLACK = 0x0000; // same as: g.setColor(0, 0, 0) +const COLOUR_WHITE = 0xffff; // same as: g.setColor(1, 1, 1) +const COLOUR_GREEN = 0x07e0; // same as: g.setColor(0, 1, 0) +const COLOUR_YELLOW = 0xffe0; // same as: g.setColor(1, 1, 0) +const COLOUR_MAGENTA = 0xf81f; // same as: g.setColor(1, 0, 1) +const COLOUR_CYAN = 0x07ff; // same as: g.setColor(0, 1, 1) +const COLOUR_LIGHT_BLUE = 0x841f; // same as: g.setColor(0.5, 0.5, 1) + +const APP_NAME = 'flightdash'; + +const horizontalCenter = g.getWidth() / 2; +const verticalCenter = g.getHeight() / 2; + +const dataFontHeight = 22; +const secondaryFontHeight = 18; +const labelFontHeight = 12; + + +//globals +var settings = {}; + +var updateInterval; + +var speed = '-'; var speedPrev = -1; +var track = '-'; var trackPrev = -1; +var lat = 0; var lon = 0; +var distance = '-'; var distancePrev = -1; +var bearing = '-'; var bearingPrev = -1; +var relativeBearing = 0; var relativeBearingPrev = -1; +var fromCardinal = '-'; +var ETAdate = new Date(); +var ETA = '-'; var ETAPrev = ''; + +var QNH = Math.round(Bangle.getOptions().seaLevelPressure); var QNHPrev = -1; + +var altitude = '-'; var altitudePrev = -1; + +var VSI = '-'; var VSIPrev = -1; +var VSIraw = 0; +var VSIprevTimestamp = Date.now(); +var VSIprevAltitude; +var VSIsamples = 0; var VSIsamplesCount = 0; + +var speedUnit = 'N/A'; +var distanceUnit = 'N/A'; +var altUnit = 'N/A'; + + +// date object to time string in format (HH:MM[:SS]) +function timeStr(date, seconds) { + let timeStr = date.getHours().toString(); + if (timeStr.length == 1) timeStr = '0' + timeStr; + let minutes = date.getMinutes().toString(); + if (minutes.length == 1) minutes = '0' + minutes; + timeStr += ':' + minutes; + if (seconds) { + let seconds = date.getSeconds().toString(); + if (seconds.length == 1) seconds = '0' + seconds; + timeStr += ':' + seconds; + } + return timeStr; +} + +// add thousands separator to number +function addThousandSeparator(n) { + let s = n.toString(); + if (s.length > 3) { + return s.substr(0, s.length - 3) + ',' + s.substr(s.length - 3, 3); + } else { + return s; + } +} + + +// update VSI +function updateVSI(alt) { + VSIsamples += alt; VSIsamplesCount += 1; + let VSInewTimestamp = Date.now(); + if (VSIprevTimestamp + 1000 <= VSInewTimestamp) { // update VSI every 1 second + let VSInewAltitude = VSIsamples / VSIsamplesCount; + if (VSIprevAltitude) { + let VSIinterval = (VSInewTimestamp - VSIprevTimestamp) / 1000; + VSIraw = (VSInewAltitude - VSIprevAltitude) * 60 / VSIinterval; // extrapolate to change / minute + } + VSIprevTimestamp = VSInewTimestamp; + VSIprevAltitude = VSInewAltitude; + VSIsamples = 0; VSIsamplesCount = 0; + } + + VSI = Math.floor(VSIraw / 10) * 10; // "smooth" VSI value + if (settings.altimeterUnits == 0) { // Feet + VSI = Math.round(VSI * 3.28084); + } // nothing else required since VSI is already in meters ("smoothed") + + if (VSI > 9999) VSI = 9999; + else if (VSI < -9999) VSI = -9999; +} + +// update GPS-derived information +function updateGPS(fix) { + if (!('fix' in fix) || fix.fix == 0 || fix.satellites < 4) return; + + speed = 'N/A'; + if (settings.speedUnits == 0) { // Knots + speed = Math.round(fix.speed * 0.539957); + } else if (settings.speedUnits == 1) { // km/h + speed = Math.round(fix.speed); + } else if (settings.speedUnits == 2) { // MPH + speed = Math.round(fix.speed * 0.621371); + } + if (speed > 9999) speed = 9999; + + if (! settings.useBaro) { // use GPS altitude + altitude = 'N/A'; + if (settings.altimeterUnits == 0) { // Feet + altitude = Math.round(fix.alt * 3.28084); + } else if (settings.altimeterUnits == 1) { // Meters + altitude = Math.round(fix.alt); + } + if (altitude > 99999) altitude = 99999; + + updateVSI(fix.alt); + } + + track = Math.round(fix.course); + if (isNaN(track)) track = '-'; + else if (track < 10) track = '00'+track; + else if (track < 100) track = '0'+track; + + lat = fix.lat; + lon = fix.lon; + + // calculation from https://www.movable-type.co.uk/scripts/latlong.html + const latRad1 = lat * Math.PI/180; + const latRad2 = settings.destLat * Math.PI/180; + const lonRad1 = lon * Math.PI/180; + const lonRad2 = settings.destLon * Math.PI/180; + + // distance (using "Equirectangular approximation") + let x = (lonRad2 - lonRad1) * Math.cos((latRad1 + latRad2) / 2); + let y = (latRad2 - latRad1); + let distanceNumber = Math.sqrt(x*x + y*y) * 6371; // in km - 6371 = mean Earth radius + if (settings.speedUnits == 0) { // NM + distanceNumber = distanceNumber * 0.539957; + } else if (settings.speedUnits == 2) { // miles + distanceNumber = distanceNumber * 0.621371; + } + if (distanceNumber > 99.9) { + distance = '>100'; + } else { + distance = (Math.round(distanceNumber * 10) / 10).toString(); + if (! distance.includes('.')) + distance += '.0'; + } + + // bearing + y = Math.sin(lonRad2 - lonRad1) * Math.cos(latRad2); + x = Math.cos(latRad1) * Math.sin(latRad2) - + Math.sin(latRad1) * Math.cos(latRad2) * Math.cos(lonRad2 - lonRad1); + let nonNormalisedBearing = Math.atan2(y, x); + bearing = Math.round((nonNormalisedBearing * 180 / Math.PI + 360) % 360); + + if (bearing > 337 || bearing < 23) { + fromCardinal = 'S'; + } else if (bearing < 68) { + fromCardinal = 'SW'; + } else if (bearing < 113) { + fromCardinal = 'W'; + } else if (bearing < 158) { + fromCardinal = 'NW'; + } else if (bearing < 203) { + fromCardinal = 'N'; + } else if (bearing < 248) { + fromCardinal = 'NE'; + } else if (bearing < 293) { + fromCardinal = 'E'; + } else{ + fromCardinal = 'SE'; + } + + if (bearing < 10) bearing = '00'+bearing; + else if (bearing < 100) bearing = '0'+bearing; + + relativeBearing = parseInt(bearing) - parseInt(track); + if (isNaN(relativeBearing)) relativeBearing = 0; + if (relativeBearing > 180) relativeBearing -= 360; + else if (relativeBearing < -180) relativeBearing += 360; + + // ETA + if (speed) { + let ETE = distanceNumber * 3600 / speed; + let now = new Date(); + ETAdate = new Date(now + (now.getTimezoneOffset() * 1000 * 60) + ETE*1000); + if (ETE < 86400) { + ETA = timeStr(ETAdate, false); + } else { + ETA = '>24h'; + } + } else { + ETAdate = new Date(); + ETA = '-'; + } +} + + +// update barometric information +function updatePressure(e) { + altitude = 'N/A'; + if (settings.altimeterUnits == 0) { // Feet + altitude = Math.round(e.altitude * 3.28084); + } else if (settings.altimeterUnits == 1) { // Meters + altitude = Math.round(e.altitude); // altitude is given in meters + } + if (altitude > 99999) altitude = 99999; + + updateVSI(e.altitude); +} + + +// (re-)draw all read-outs +function draw(initial) { + + g.setBgColor(COLOUR_BLACK); + + // speed + if (speed != speedPrev || initial) { + g.setFontAlign(-1, -1).setFont("Vector", dataFontHeight).setColor(COLOUR_GREEN); + g.clearRect(0, 0, 55, dataFontHeight); + g.drawString(speed.toString(), 0, 0, false); + if (initial) { + g.setFontAlign(-1, -1).setFont("Vector", labelFontHeight).setColor(COLOUR_CYAN); + g.drawString(speedUnit, 0, dataFontHeight, false); + } + speedPrev = speed; + } + + + // distance + if (distance != distancePrev || initial) { + g.setFontAlign(1, -1).setFont("Vector", dataFontHeight).setColor(COLOUR_WHITE); + g.clearRect(g.getWidth() - 58, 0, g.getWidth(), dataFontHeight); + g.drawString(distance, g.getWidth(), 0, false); + if (initial) { + g.setFontAlign(1, -1).setFont("Vector", labelFontHeight).setColor(COLOUR_CYAN); + g.drawString(distanceUnit, g.getWidth(), dataFontHeight, false); + } + distancePrev = distance; + } + + + // track (+ static track/bearing content) + let trackY = 18; + let destInfoY = trackY + 53; + if (track != trackPrev || initial) { + g.setFontAlign(0, -1).setFont("Vector", dataFontHeight).setColor(COLOUR_WHITE); + g.clearRect(horizontalCenter - 29, trackY, horizontalCenter + 28, trackY + dataFontHeight); + g.drawString(track.toString() + "\xB0", horizontalCenter + 3, trackY, false); + if (initial) { + let y = trackY + dataFontHeight + 1; + g.setColor(COLOUR_YELLOW); + g.drawRect(horizontalCenter - 30, trackY - 3, horizontalCenter + 29, y); + g.drawLine(0, y, g.getWidth(), y); + y += dataFontHeight + 5; + g.drawLine(0, y, g.getWidth(), y); + + g.setFontAlign(1, -1).setFont("Vector", secondaryFontHeight).setColor(COLOUR_MAGENTA); + g.drawString(settings.destID, horizontalCenter, destInfoY, false); + } + trackPrev = track; + } + + + // bearing + if (bearing != bearingPrev || relativeBearing != relativeBearingPrev || initial) { + let bearingY = trackY + 27; + + g.clearRect(0, bearingY, g.getWidth(), bearingY + dataFontHeight); + + g.setColor(COLOUR_YELLOW); + for (let i = Math.floor(relativeBearing * 2.5) % 25; i <= g.getWidth(); i += 25) { + g.drawLine(i, bearingY + 3, i, bearingY + 16); + } + + let bearingX = horizontalCenter + relativeBearing * 2.5; + if (bearingX > g.getWidth() - 26) bearingX = g.getWidth() - 26; + else if (bearingX < 26) bearingX = 26; + g.setFontAlign(0, -1).setFont("Vector", dataFontHeight).setColor(COLOUR_MAGENTA); + g.drawString(bearing.toString() + "\xB0", bearingX + 3, bearingY, false); + + g.clearRect(horizontalCenter + 42, destInfoY, horizontalCenter + 69, destInfoY + secondaryFontHeight); + g.setFontAlign(-1, -1).setFont("Vector", secondaryFontHeight).setColor(COLOUR_MAGENTA); + g.drawString(fromCardinal, horizontalCenter + 42, destInfoY, false); + if (initial) { + g.setFontAlign(-1, -1).setFont("Vector", labelFontHeight).setColor(COLOUR_CYAN); + g.drawString(' from', horizontalCenter, destInfoY, false); + } + + bearingPrev = bearing; + relativeBearingPrev = relativeBearing; + } + + + let row3y = g.getHeight() - 48; + + // QNH + if (settings.useBaro) { + if (QNH != QNHPrev || initial) { + let QNHy = row3y - secondaryFontHeight - 2; + g.setFontAlign(0, 1).setFont("Vector", secondaryFontHeight).setColor(COLOUR_WHITE); + g.clearRect(horizontalCenter - 29, QNHy - secondaryFontHeight, horizontalCenter + 22, QNHy); + g.drawString(QNH.toString(), horizontalCenter - 3, QNHy, false); + if (initial) { + g.setFontAlign(0, -1).setFont("Vector", labelFontHeight).setColor(COLOUR_CYAN); + g.drawString('QNH', horizontalCenter - 3, QNHy, false); + } + QNHPrev = QNH; + } + } + + + // VSI + if (VSI != VSIPrev || initial) { + g.setFontAlign(-1, 1).setFont("Vector", secondaryFontHeight).setColor(COLOUR_WHITE); + g.clearRect(0, row3y - secondaryFontHeight, 51, row3y); + g.drawString(VSI.toString(), 0, row3y, false); + if (initial) { + g.setFontAlign(-1, 1).setFont("Vector", labelFontHeight).setColor(COLOUR_CYAN); + g.drawString(altUnit + '/min', 0, row3y - secondaryFontHeight, false); + } + + let VSIarrowX = 6; + let VSIarrowY = row3y - 42; + g.clearRect(VSIarrowX - 7, VSIarrowY - 10, VSIarrowX + 6, VSIarrowY + 10); + g.setColor(COLOUR_WHITE); + if (VSIraw > 30) { // climbing + g.fillRect(VSIarrowX - 1, VSIarrowY, VSIarrowX + 1, VSIarrowY + 10); + g.fillPoly([ VSIarrowX , VSIarrowY - 11, + VSIarrowX + 7, VSIarrowY, + VSIarrowX - 7, VSIarrowY]); + } else if (VSIraw < -30) { // descending + g.fillRect(VSIarrowX - 1, VSIarrowY - 10, VSIarrowX + 1, VSIarrowY); + g.fillPoly([ VSIarrowX , VSIarrowY + 11, + VSIarrowX + 7, VSIarrowY, + VSIarrowX - 7, VSIarrowY ]); + } + } + + + // altitude + if (altitude != altitudePrev || initial) { + g.setFontAlign(1, 1).setFont("Vector", secondaryFontHeight).setColor(COLOUR_WHITE); + g.clearRect(g.getWidth() - 65, row3y - secondaryFontHeight, g.getWidth(), row3y); + g.drawString(addThousandSeparator(altitude), g.getWidth(), row3y, false); + if (initial) { + g.setFontAlign(1, 1).setFont("Vector", labelFontHeight).setColor(COLOUR_CYAN); + g.drawString(altUnit, g.getWidth(), row3y - secondaryFontHeight, false); + } + altitudePrev = altitude; + } + + + // time + let now = new Date(); + let nowUTC = new Date(now + (now.getTimezoneOffset() * 1000 * 60)); + g.setFontAlign(-1, 1).setFont("Vector", dataFontHeight).setColor(COLOUR_LIGHT_BLUE); + let timeStrMetrics = g.stringMetrics(timeStr(now, false)); + g.drawString(timeStr(now, false), 0, g.getHeight(), true); + + let seconds = now.getSeconds().toString(); + if (seconds.length == 1) seconds = '0' + seconds; + g.setFontAlign(-1, 1).setFont("Vector", secondaryFontHeight); + g.drawString(seconds, timeStrMetrics.width + 2, g.getHeight() - 1, true); + + if (initial) { + g.setFontAlign(-1, 1).setFont("Vector", labelFontHeight).setColor(COLOUR_CYAN); + g.drawString('LOCAL', 0, g.getHeight() - dataFontHeight, false); + } + + + // ETE + let ETEy = g.getHeight() - dataFontHeight; + let ETE = '-'; + if (ETA != '-') { + let ETEseconds = Math.floor((ETAdate - nowUTC) / 1000); + if (ETEseconds < 0) ETEseconds = 0; + ETE = ETEseconds % 60; + if (ETE < 10) ETE = '0' + ETE; + ETE = Math.floor(ETEseconds / 60) + ':' + ETE; + if (ETE.length > 6) ETE = '>999m'; + } + g.clearRect(horizontalCenter - 35, ETEy - secondaryFontHeight, horizontalCenter + 29, ETEy); + g.setFontAlign(0, 1).setFont("Vector", secondaryFontHeight).setColor(COLOUR_WHITE); + g.drawString(ETE, horizontalCenter - 3, ETEy, false); + if (initial) { + g.setFontAlign(0, 1).setFont("Vector", labelFontHeight).setColor(COLOUR_CYAN); + g.drawString('ETE', horizontalCenter - 3, ETEy - secondaryFontHeight, false); + } + + + // ETA + if (ETA != ETAPrev || initial) { + g.clearRect(g.getWidth() - 63, g.getHeight() - dataFontHeight, g.getWidth(), g.getHeight()); + g.setFontAlign(1, 1).setFont("Vector", dataFontHeight).setColor(COLOUR_WHITE); + g.drawString(ETA, g.getWidth(), g.getHeight(), false); + if (initial) { + g.setFontAlign(1, 1).setFont("Vector", labelFontHeight).setColor(COLOUR_CYAN); + g.drawString('UTC ETA', g.getWidth(), g.getHeight() - dataFontHeight, false); + } + ETAPrev = ETA; + } +} + + +function handleSwipes(directionLR, directionUD) { + if (directionUD == -1) { // up -> increase QNH + QNH = Math.round(Bangle.getOptions().seaLevelPressure); + QNH++; + Bangle.setOptions({'seaLevelPressure': QNH}); + } else if (directionUD == 1) { // down -> decrease QNH + QNH = Math.round(Bangle.getOptions().seaLevelPressure); + QNH--; + Bangle.setOptions({'seaLevelPressure': QNH}); + } +} + +function handleTouch(button, xy) { + if ('handled' in xy && xy.handled) return; + Bangle.removeListener('touch', handleTouch); + if (settings.useBaro) { + Bangle.removeListener('swipe', handleSwipes); + } + + // any touch -> show settings + clearInterval(updateTimeInterval); + Bangle.setGPSPower(false, APP_NAME); + if (settings.useBaro) + Bangle.setBarometerPower(false, APP_NAME); + + eval(require("Storage").read(APP_NAME+'.settings.js'))( () => { + E.showMenu(); + // "clear" values potentially affected by a settings change + speed = '-'; distance = '-'; + altitude = '-'; VSI = '-'; + // re-launch + start(); + }); +} + + +/* + * main + */ +function start() { + + // read in the settings + settings = Object.assign({ + useBaro: false, + speedUnits: 0, // KTS + altimeterUnits: 0, // FT + destID: 'KOSH', + destLat: 43.9844, + destLon: -88.5570, + }, require('Storage').readJSON(APP_NAME+'.json', true) || {}); + + // set units + if (settings.speedUnits == 0) { // Knots + speedUnit = 'KTS'; + distanceUnit = 'NM'; + } else if (settings.speedUnits == 1) { // km/h + speedUnit = 'KPH'; + distanceUnit = 'KM'; + } else if (settings.speedUnits == 2) { // MPH + speedUnit = 'MPH'; + distanceUnit = 'SM'; + } + + if (settings.altimeterUnits == 0) { // Feet + altUnit = 'FT'; + } else if (settings.altimeterUnits == 1) { // Meters + altUnit = 'M'; + } + + // initialise + g.reset(); + g.setBgColor(COLOUR_BLACK); + g.clear(); + + // draw incl. static components + draw(true); + + // enable timeout/interval and sensors + setTimeout(function() { + draw(); + updateTimeInterval = setInterval(draw, 1000); + }, 1000 - (Date.now() % 1000)); + + Bangle.setGPSPower(true, APP_NAME); + Bangle.on('GPS', updateGPS); + + if (settings.useBaro) { + Bangle.setBarometerPower(true, APP_NAME); + Bangle.on('pressure', updatePressure); + } + + // handle interaction + if (settings.useBaro) { + Bangle.on('swipe', handleSwipes); + } + Bangle.on('touch', handleTouch); + setWatch(e => { Bangle.showClock(); }, BTN1); // exit on button press +} + +start(); + + +/* +// TMP for testing: +//settings.speedUnits = 1; +//settings.altimeterUnits = 1; +QNH = 1013; +updateGPS({"fix":1,"speed":228,"alt":3763,"course":329,"lat":36.0182,"lon":-75.6713}); +updatePressure({"altitude":3700}); +*/ diff --git a/apps/flightdash/flightdash.png b/apps/flightdash/flightdash.png new file mode 100644 index 000000000..8230bc0c1 Binary files /dev/null and b/apps/flightdash/flightdash.png differ diff --git a/apps/flightdash/flightdash.settings.js b/apps/flightdash/flightdash.settings.js new file mode 100644 index 000000000..a1356449b --- /dev/null +++ b/apps/flightdash/flightdash.settings.js @@ -0,0 +1,344 @@ +(function(back) { + const APP_NAME = 'flightdash'; + const FILE = APP_NAME+'.json'; + + // if the avwx module is available, include an extra menu item to query nearest airports via AVWX + var avwx; + try { + avwx = require('avwx'); + } catch (error) { + // avwx module not installed + } + + // Load settings + var settings = Object.assign({ + useBaro: false, + speedUnits: 0, // KTS + altimeterUnits: 0, // FT + destID: 'KOSH', + destLat: 43.9844, + destLon: -88.5570, + }, require('Storage').readJSON(FILE, true) || {}); + + function writeSettings() { + require('Storage').writeJSON(FILE, settings); + } + + var airports; // cache list of airports + function readAirportsList(empty_cb) { + if (airports) { // airport list has already been read in + return true; + } + airports = require('Storage').readJSON(APP_NAME+'.airports.json', true); + if (! airports) { + E.showPrompt('No airports stored - download from the Bangle Apps Loader!', + {title: 'Flight-Dash', buttons: {OK: true} }).then((v) => { + empty_cb(); + }); + return false; + } + return true; + } + + // use GPS fix + var afterGPSfixMenu = 'destNearest'; + function getLatLon(fix) { + if (!('fix' in fix) || fix.fix == 0 || fix.satellites < 4) return; + Bangle.setGPSPower(false, APP_NAME+'-settings'); + Bangle.removeListener('GPS', getLatLon); + switch (afterGPSfixMenu) { + case 'destNearest': + loadNearest(fix.lat, fix.lon); + break; + case 'createUserWaypoint': + if (!('userWaypoints' in settings)) + settings.userWaypoints = []; + let newIdx = settings.userWaypoints.length; + settings.userWaypoints[newIdx] = { + 'ID': 'USER'+(newIdx + 1), + 'lat': fix.lat, + 'lon': fix.lon, + }; + writeSettings(); + showUserWaypoints(); + break; + case 'destAVWX': + // the free ("hobby") account of AVWX is limited to 10 nearest stations + avwx.request('station/near/'+fix.lat+','+fix.lon, 'n=10&airport=true&reporting=false', data => { + loadAVWX(data); + }, error => { + console.log(error); + E.showPrompt('AVWX query failed: '+error, {title: 'Flight-Dash', buttons: {OK: true} }).then((v) => { + createDestMainMenu(); + }); + }); + break; + default: + back(); + } + } + + // find nearest airports + function loadNearest(lat, lon) { + if (! readAirportsList(createDestMainMenu)) + return; + + const latRad1 = lat * Math.PI/180; + const lonRad1 = lon * Math.PI/180; + for (let i = 0; i < airports.length; i++) { + const latRad2 = airports[i].la * Math.PI/180; + const lonRad2 = airports[i].lo * Math.PI/180; + let x = (lonRad2 - lonRad1) * Math.cos((latRad1 + latRad2) / 2); + let y = (latRad2 - latRad1); + airports[i].distance = Math.sqrt(x*x + y*y) * 6371; + } + let nearest = airports.sort((a, b) => a.distance - b.distance).slice(0, 14); + + let destNearest = { + '' : { 'title' : 'Nearest' }, + '< Back' : () => createDestMainMenu(), + }; + let airportCBs = []; + for (let i in nearest) { + let airport = nearest[i]; + eval('airportCBs['+i+'] = function() { '+ + 'settings.destID = "'+airport.i+'"; '+ + 'settings.destLat = "'+airport.la+'"; '+ + 'settings.destLon = "'+airport.lo+'"; '+ + 'writeSettings(); '+ + 'createDestMainMenu(); '+ + '}'); + destNearest[airport.i+' - '+airport.n] = airportCBs[i]; + } + + E.showMenu(destNearest); + } + + // process the data returned by AVWX + function loadAVWX(data) { + let AVWXairports = JSON.parse(data.resp); + + let destAVWX = { + '' : { 'title' : 'Nearest (AVWX)' }, + '< Back' : () => createDestMainMenu(), + }; + let airportCBs = []; + for (let i in AVWXairports) { + let airport = AVWXairports[i].station; + let airport_id = ( airport.icao ? airport.icao : airport.gps ); + eval('airportCBs['+i+'] = function() { '+ + 'settings.destID = "'+airport_id+'"; '+ + 'settings.destLat = "'+airport.latitude+'"; '+ + 'settings.destLon = "'+airport.longitude+'"; '+ + 'writeSettings(); '+ + 'createDestMainMenu(); '+ + '}'); + destAVWX[airport_id+' - '+airport.name] = airportCBs[i]; + } + + E.showMenu(destAVWX); + } + + // individual user waypoint menu + function showUserWaypoint(idx) { + let wayptID = settings.userWaypoints[idx].ID; + let wayptLat = settings.userWaypoints[idx].lat; + let wayptLon = settings.userWaypoints[idx].lon; + let destUser = { + '' : { 'title' : wayptID }, + '< Back' : () => showUserWaypoints(), + }; + eval('let wayptUseCB = function() { '+ + 'settings.destID = "'+wayptID+'"; '+ + 'settings.destLat = "'+wayptLat+'"; '+ + 'settings.destLon = "'+wayptLon+'"; '+ + 'writeSettings(); '+ + 'createDestMainMenu(); '+ + '}'); + destUser['Set as Dest.'] = wayptUseCB; + destUser['Edit ID'] = function() { + require('textinput').input({text: wayptID}).then(result => { + if (result) { + if (result.length > 7) { + console.log('test'); + E.showPrompt('ID is too long!\n(max. 7 chars)', + {title: 'Flight-Dash', buttons: {OK: true} }).then((v) => { + showUserWaypoint(idx); + }); + } else { + settings.userWaypoints[idx].ID = result; + writeSettings(); + showUserWaypoint(idx); + } + } else { + showUserWaypoint(idx); + } + }); + }; + destUser['Delete'] = function() { + E.showPrompt('Delete user waypoint '+wayptID+'?', + {'title': 'Flight-Dash'}).then((v) => { + if (v) { + settings.userWaypoints.splice(idx, 1); + writeSettings(); + showUserWaypoints(); + } else { + showUserWaypoint(idx); + } + }); + }; + + E.showMenu(destUser); + } + + // user waypoints menu + function showUserWaypoints() { + let destUser = { + '' : { 'title' : 'User Waypoints' }, + '< Back' : () => createDestMainMenu(), + }; + let wayptCBs = []; + for (let i in settings.userWaypoints) { + let waypt = settings.userWaypoints[i]; + eval('wayptCBs['+i+'] = function() { showUserWaypoint('+i+'); }'); + destUser[waypt.ID] = wayptCBs[i]; + } + destUser['Create New'] = function() { + E.showMessage('Waiting for GPS fix', {title: 'Flight-Dash'}); + afterGPSfixMenu = 'createUserWaypoint'; + Bangle.setGPSPower(true, APP_NAME+'-settings'); + Bangle.on('GPS', getLatLon); + }; + + E.showMenu(destUser); + } + + // destination main menu + function createDestMainMenu() { + let destMainMenu = { + '' : { 'title' : 'Nav Dest.' }, + '< Back' : () => E.showMenu(mainMenu), + }; + destMainMenu['Is: '+settings.destID] = {}; + destMainMenu['Nearest'] = function() { + E.showMessage('Waiting for GPS fix', {title: 'Flight-Dash'}); + afterGPSfixMenu = 'destNearest'; + Bangle.setGPSPower(true, APP_NAME+'-settings'); + Bangle.on('GPS', getLatLon); + }; + destMainMenu['Search'] = function() { + require('textinput').input({text: ''}).then(result => { + if (result) { + if (! readAirportsList(createDestMainMenu)) + return; + + result = result.toUpperCase(); + let matches = []; + let tooManyFound = false; + for (let i in airports) { + if (airports[i].i.toUpperCase().includes(result) || + airports[i].n.toUpperCase().includes(result)) { + matches.push(airports[i]); + if (matches.length >= 15) { + tooManyFound = true; + break; + } + } + } + if (! matches.length) { + E.showPrompt('No airports found!', {title: 'Flight-Dash', buttons: {OK: true} }).then((v) => { + createDestMainMenu(); + }); + return; + } + + let destSearch = { + '' : { 'title' : 'Search Results' }, + '< Back' : () => createDestMainMenu(), + }; + let airportCBs = []; + for (let i in matches) { + let airport = matches[i]; + eval('airportCBs['+i+'] = function() { '+ + 'settings.destID = "'+airport.i+'"; '+ + 'settings.destLat = "'+airport.la+'"; '+ + 'settings.destLon = "'+airport.lo+'"; '+ + 'writeSettings(); '+ + 'createDestMainMenu(); '+ + '}'); + destSearch[airport.i+' - '+airport.n] = airportCBs[i]; + } + if (tooManyFound) { + destSearch['More than 15 airports found!'] = {}; + } + + E.showMenu(destSearch); + } else { + createDestMainMenu(); + } + }); + }; + destMainMenu['User waypts'] = function() { showUserWaypoints(); }; + if (avwx) { + destMainMenu['Nearest (AVWX)'] = function() { + E.showMessage('Waiting for GPS fix', {title: 'Flight-Dash'}); + afterGPSfixMenu = 'destAVWX'; + Bangle.setGPSPower(true, APP_NAME+'-settings'); + Bangle.on('GPS', getLatLon); + }; + } + E.showMenu(destMainMenu); + } + + // main menu + mainMenu = { + '' : { 'title' : 'Flight-Dash' }, + '< Back' : () => { + Bangle.setGPSPower(false, APP_NAME+'-settings'); + Bangle.removeListener('GPS', getLatLon); + back(); + }, + 'Nav Dest.': () => createDestMainMenu(), + 'Speed': { + value: parseInt(settings.speedUnits) || 0, + min: 0, + max: 2, + format: v => { + switch (v) { + case 0: return 'Knots'; + case 1: return 'km/h'; + case 2: return 'MPH'; + } + }, + onchange: v => { + settings.speedUnits = v; + writeSettings(); + } + }, + 'Altitude': { + value: parseInt(settings.altimeterUnits) || 0, + min: 0, + max: 1, + format: v => { + switch (v) { + case 0: return 'Feet'; + case 1: return 'Meters'; + } + }, + onchange: v => { + settings.altimeterUnits = v; + writeSettings(); + } + }, + 'Use Baro': { + value: !!settings.useBaro, // !! converts undefined to false + format: v => v ? 'On' : 'Off', + onchange: v => { + settings.useBaro = v; + writeSettings(); + } + }, + }; + + E.showMenu(mainMenu); +}) diff --git a/apps/flightdash/interface.html b/apps/flightdash/interface.html new file mode 100644 index 000000000..d0f57f316 --- /dev/null +++ b/apps/flightdash/interface.html @@ -0,0 +1,186 @@ + + + + + + + + +

You can upload a list of airports, which can then be used as the + navigation destinations in the Flight-Dash. It is recommended to only + upload up to 100 - 150 airports max. Due to memory contraints on the + Bangle, no more than 500 airports can be uploaded.

+ +

The database of airports is based on OurAirports. + +

Filter Airports

+
+ + nm of + + / + + +
+ This is using a simple lat/lon "block" - and + not within a proper radius around the given lat/lon position. An easy + way to find a lat/lon pair is to search for an airport based on ident + or name, and then use the found coordinates. +
+
+

- or -

+

+ + +

+

- or -

+

+ + +

+

Only 1 of the above filters is applied, with higher up in the list taking precedence.

+
+ + +
+ Use the + ISO-3166 2-letter code, + eg. "AU" +
+
+ +

+ + +

+ +
+ +

Results:

+

+
+ + + + + + + diff --git a/apps/flightdash/jquery-csv.min.js b/apps/flightdash/jquery-csv.min.js new file mode 100644 index 000000000..cbaefa6b8 --- /dev/null +++ b/apps/flightdash/jquery-csv.min.js @@ -0,0 +1 @@ +RegExp.escape=function(r){return r.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&")},function(){"use strict";var p;(p="undefined"!=typeof jQuery&&jQuery?jQuery:{}).csv={defaults:{separator:",",delimiter:'"',headers:!0},hooks:{castToScalar:function(r,e){if(isNaN(r))return r;if(/\./.test(r))return parseFloat(r);var a=parseInt(r);return isNaN(a)?null:a}},parsers:{parse:function(r,e){var a=e.separator,t=e.delimiter;e.state.rowNum||(e.state.rowNum=1),e.state.colNum||(e.state.colNum=1);var o=[],s=[],n=0,i="",l=!1;function u(){if(n=0,i="",e.start&&e.state.rowNum=e.end&&(l=!0),e.state.rowNum++,e.state.colNum=1}function c(){if(void 0===e.onParseValue)s.push(i);else if(e.headers&&1===e.state.rowNum)s.push(i);else{var r=e.onParseValue(i,e.state);!1!==r&&s.push(r)}i="",n=0,e.state.colNum++}var f=RegExp.escape(a),d=RegExp.escape(t),m=/(D|S|\r\n|\n|\r|[^DS\r\n]+)/,p=m.source;return p=(p=p.replace(/S/g,f)).replace(/D/g,d),m=new RegExp(p,"gm"),r.replace(m,function(r){if(!l)switch(n){case 0:if(r===a){i+="",c();break}if(r===t){n=1;break}if(/^(\r\n|\n|\r)$/.test(r)){c(),u();break}i+=r,n=3;break;case 1:if(r===t){n=2;break}i+=r,n=1;break;case 2:if(r===t){i+=r,n=1;break}if(r===a){c();break}if(/^(\r\n|\n|\r)$/.test(r)){c(),u();break}throw Error("CSVDataError: Illegal State [Row:"+e.state.rowNum+"][Col:"+e.state.colNum+"]");case 3:if(r===a){c();break}if(/^(\r\n|\n|\r)$/.test(r)){c(),u();break}if(r===t)throw Error("CSVDataError: Illegal Quote [Row:"+e.state.rowNum+"][Col:"+e.state.colNum+"]");throw Error("CSVDataError: Illegal Data [Row:"+e.state.rowNum+"][Col:"+e.state.colNum+"]");default:throw Error("CSVDataError: Unknown State [Row:"+e.state.rowNum+"][Col:"+e.state.colNum+"]")}}),0!==s.length&&(c(),u()),o},splitLines:function(r,a){if(r){var t=(a=a||{}).separator||p.csv.defaults.separator,o=a.delimiter||p.csv.defaults.delimiter;a.state=a.state||{},a.state.rowNum||(a.state.rowNum=1);var e=[],s=0,n="",i=!1,l=RegExp.escape(t),u=RegExp.escape(o),c=/(D|S|\n|\r|[^DS\r\n]+)/,f=c.source;return f=(f=f.replace(/S/g,l)).replace(/D/g,u),c=new RegExp(f,"gm"),r.replace(c,function(r){if(!i)switch(s){case 0:if(r===t){n+=r,s=0;break}if(r===o){n+=r,s=1;break}if("\n"===r){d();break}if(/^\r$/.test(r))break;n+=r,s=3;break;case 1:if(r===o){n+=r,s=2;break}n+=r,s=1;break;case 2:var e=n.substr(n.length-1);if(r===o&&e===o){n+=r,s=1;break}if(r===t){n+=r,s=0;break}if("\n"===r){d();break}if("\r"===r)break;throw Error("CSVDataError: Illegal state [Row:"+a.state.rowNum+"]");case 3:if(r===t){n+=r,s=0;break}if("\n"===r){d();break}if("\r"===r)break;if(r===o)throw Error("CSVDataError: Illegal quote [Row:"+a.state.rowNum+"]");throw Error("CSVDataError: Illegal state [Row:"+a.state.rowNum+"]");default:throw Error("CSVDataError: Unknown state [Row:"+a.state.rowNum+"]")}}),""!==n&&d(),e}function d(){if(s=0,a.start&&a.state.rowNum=a.end&&(i=!0),a.state.rowNum++}},parseEntry:function(r,e){var a=e.separator,t=e.delimiter;e.state.rowNum||(e.state.rowNum=1),e.state.colNum||(e.state.colNum=1);var o=[],s=0,n="";function i(){if(void 0===e.onParseValue)o.push(n);else{var r=e.onParseValue(n,e.state);!1!==r&&o.push(r)}n="",s=0,e.state.colNum++}if(!e.match){var l=RegExp.escape(a),u=RegExp.escape(t),c=/(D|S|\n|\r|[^DS\r\n]+)/.source;c=(c=c.replace(/S/g,l)).replace(/D/g,u),e.match=new RegExp(c,"gm")}return r.replace(e.match,function(r){switch(s){case 0:if(r===a){n+="",i();break}if(r===t){s=1;break}if("\n"===r||"\r"===r)break;n+=r,s=3;break;case 1:if(r===t){s=2;break}n+=r,s=1;break;case 2:if(r===t){n+=r,s=1;break}if(r===a){i();break}if("\n"===r||"\r"===r)break;throw Error("CSVDataError: Illegal State [Row:"+e.state.rowNum+"][Col:"+e.state.colNum+"]");case 3:if(r===a){i();break}if("\n"===r||"\r"===r)break;if(r===t)throw Error("CSVDataError: Illegal Quote [Row:"+e.state.rowNum+"][Col:"+e.state.colNum+"]");throw Error("CSVDataError: Illegal Data [Row:"+e.state.rowNum+"][Col:"+e.state.colNum+"]");default:throw Error("CSVDataError: Unknown State [Row:"+e.state.rowNum+"][Col:"+e.state.colNum+"]")}}),i(),o}},helpers:{collectPropertyNames:function(r){var e=[],a=[],t=[];for(e in r)for(a in r[e])r[e].hasOwnProperty(a)&&t.indexOf(a)<0&&"function"!=typeof r[e][a]&&t.push(a);return t}},toArray:function(r,e,a){if(void 0!==e&&"function"==typeof e){if(void 0!==a)return console.error("You cannot 3 arguments with the 2nd argument being a function");a=e,e={}}e=void 0!==e?e:{};var t={};t.callback=void 0!==a&&"function"==typeof a&&a,t.separator="separator"in e?e.separator:p.csv.defaults.separator,t.delimiter="delimiter"in e?e.delimiter:p.csv.defaults.delimiter;var o=void 0!==e.state?e.state:{};e={delimiter:t.delimiter,separator:t.separator,onParseEntry:e.onParseEntry,onParseValue:e.onParseValue,state:o};var s=p.csv.parsers.parseEntry(r,e);if(!t.callback)return s;t.callback("",s)},toArrays:function(r,e,a){if(void 0!==e&&"function"==typeof e){if(void 0!==a)return console.error("You cannot 3 arguments with the 2nd argument being a function");a=e,e={}}e=void 0!==e?e:{};var t={};t.callback=void 0!==a&&"function"==typeof a&&a,t.separator="separator"in e?e.separator:p.csv.defaults.separator,t.delimiter="delimiter"in e?e.delimiter:p.csv.defaults.delimiter;var o=[];if(void 0!==(e={delimiter:t.delimiter,separator:t.separator,onPreParse:e.onPreParse,onParseEntry:e.onParseEntry,onParseValue:e.onParseValue,onPostParse:e.onPostParse,start:e.start,end:e.end,state:{rowNum:1,colNum:1}}).onPreParse&&(r=e.onPreParse(r,e.state)),o=p.csv.parsers.parse(r,e),void 0!==e.onPostParse&&(o=e.onPostParse(o,e.state)),!t.callback)return o;t.callback("",o)},toObjects:function(r,e,a){if(void 0!==e&&"function"==typeof e){if(void 0!==a)return console.error("You cannot 3 arguments with the 2nd argument being a function");a=e,e={}}e=void 0!==e?e:{};var t={};t.callback=void 0!==a&&"function"==typeof a&&a,t.separator="separator"in e?e.separator:p.csv.defaults.separator,t.delimiter="delimiter"in e?e.delimiter:p.csv.defaults.delimiter,t.headers="headers"in e?e.headers:p.csv.defaults.headers,e.start="start"in e?e.start:1,t.headers&&e.start++,e.end&&t.headers&&e.end++;var o,s=[];e={delimiter:t.delimiter,separator:t.separator,onPreParse:e.onPreParse,onParseEntry:e.onParseEntry,onParseValue:e.onParseValue,onPostParse:e.onPostParse,start:e.start,end:e.end,state:{rowNum:1,colNum:1},match:!1,transform:e.transform};var n={delimiter:t.delimiter,separator:t.separator,start:1,end:1,state:{rowNum:1,colNum:1},headers:!0};void 0!==e.onPreParse&&(r=e.onPreParse(r,e.state));var i=p.csv.parsers.splitLines(r,n),l=p.csv.toArray(i[0],n);o=p.csv.parsers.splitLines(r,e),e.state.colNum=1,e.state.rowNum=l?2:1;for(var u=0,c=o.length;u