diff --git a/apps.json b/apps.json index 51cca784b..b1ad57074 100644 --- a/apps.json +++ b/apps.json @@ -4037,5 +4037,20 @@ {"name":"vernierrespirate.img","url":"app-icon.js","evaluate":true} ], "data": [{"name":"vernierrespirate.json"}] + }, + { + "id": "gpstouch", + "name": "GPS Touch", + "version": "0.01", + "description": "A touch based GPS watch, shows OS map reference", + "icon": "gpstouch.png", + "tags": "tools,app", + "supports": ["BANGLEJS2"], + "readme": "README.md", + "storage": [ + {"name":"geotools","url":"geotools.js"}, + {"name":"gpstouch.app.js","url":"gpstouch.app.js"}, + {"name":"gpstouch.img","url":"gpstouch.icon.js","evaluate":true} + ] } ] diff --git a/apps/gpstouch/README.md b/apps/gpstouch/README.md new file mode 100644 index 000000000..1d6bb5d17 --- /dev/null +++ b/apps/gpstouch/README.md @@ -0,0 +1,5 @@ +# GPS Touch + +## Screenshots + + diff --git a/apps/gpstouch/geotools.js b/apps/gpstouch/geotools.js new file mode 100644 index 000000000..5adc57872 --- /dev/null +++ b/apps/gpstouch/geotools.js @@ -0,0 +1,128 @@ +/** + * + * A module of Geo functions for use with gps fixes + * + * let geo = require("geotools"); + * let os = geo.gpsToOSGrid(fix); + * let ref = geo.gpsToOSMapRef(fix); + * + */ + +Number.prototype.toRad = function() { return this*Math.PI/180; }; +/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ +/* Ordnance Survey Grid Reference functions (c) Chris Veness 2005-2014 */ +/* - www.movable-type.co.uk/scripts/gridref.js */ +/* - www.movable-type.co.uk/scripts/latlon-gridref.html */ +/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ +function OsGridRef(easting, northing) { + this.easting = 0|easting; + this.northing = 0|northing; +} +OsGridRef.latLongToOsGrid = function(point) { + var lat = point.lat.toRad(); + var lon = point.lon.toRad(); + + var a = 6377563.396, b = 6356256.909; // Airy 1830 major & minor semi-axes + var F0 = 0.9996012717; // NatGrid scale factor on central meridian + var lat0 = (49).toRad(), lon0 = (-2).toRad(); // NatGrid true origin is 49�N,2�W + var N0 = -100000, E0 = 400000; // northing & easting of true origin, metres + var e2 = 1 - (b*b)/(a*a); // eccentricity squared + var n = (a-b)/(a+b), n2 = n*n, n3 = n*n*n; + + var cosLat = Math.cos(lat), sinLat = Math.sin(lat); + var nu = a*F0/Math.sqrt(1-e2*sinLat*sinLat); // transverse radius of curvature + var rho = a*F0*(1-e2)/Math.pow(1-e2*sinLat*sinLat, 1.5); // meridional radius of curvature + var eta2 = nu/rho-1; + + var Ma = (1 + n + (5/4)*n2 + (5/4)*n3) * (lat-lat0); + var Mb = (3*n + 3*n*n + (21/8)*n3) * Math.sin(lat-lat0) * Math.cos(lat+lat0); + var Mc = ((15/8)*n2 + (15/8)*n3) * Math.sin(2*(lat-lat0)) * Math.cos(2*(lat+lat0)); + var Md = (35/24)*n3 * Math.sin(3*(lat-lat0)) * Math.cos(3*(lat+lat0)); + var M = b * F0 * (Ma - Mb + Mc - Md); // meridional arc + + var cos3lat = cosLat*cosLat*cosLat; + var cos5lat = cos3lat*cosLat*cosLat; + var tan2lat = Math.tan(lat)*Math.tan(lat); + var tan4lat = tan2lat*tan2lat; + + var I = M + N0; + var II = (nu/2)*sinLat*cosLat; + var III = (nu/24)*sinLat*cos3lat*(5-tan2lat+9*eta2); + var IIIA = (nu/720)*sinLat*cos5lat*(61-58*tan2lat+tan4lat); + var IV = nu*cosLat; + var V = (nu/6)*cos3lat*(nu/rho-tan2lat); + var VI = (nu/120) * cos5lat * (5 - 18*tan2lat + tan4lat + 14*eta2 - 58*tan2lat*eta2); + + var dLon = lon-lon0; + var dLon2 = dLon*dLon, dLon3 = dLon2*dLon, dLon4 = dLon3*dLon, dLon5 = dLon4*dLon, dLon6 = dLon5*dLon; + + var N = I + II*dLon2 + III*dLon4 + IIIA*dLon6; + var E = E0 + IV*dLon + V*dLon3 + VI*dLon5; + + return new OsGridRef(E, N); +}; + +/* + * converts northing, easting to standard OS grid reference. + * + * [digits=10] - precision (10 digits = metres) + * to_map_ref(8, 651409, 313177); => 'TG 5140 1317' + * to_map_ref(0, 651409, 313177); => '651409,313177' + * + */ +function to_map_ref(digits, easting, northing) { + if (![ 0,2,4,6,8,10,12,14,16 ].includes(Number(digits))) throw new RangeError(`invalid precision '${digits}'`); // eslint-disable-line comma-spacing + + let e = easting; + let n = northing; + + // use digits = 0 to return numeric format (in metres) - note northing may be >= 1e7 + if (digits == 0) { + const format = { useGrouping: false, minimumIntegerDigits: 6, maximumFractionDigits: 3 }; + const ePad = e.toLocaleString('en', format); + const nPad = n.toLocaleString('en', format); + return `${ePad},${nPad}`; + } + + // get the 100km-grid indices + const e100km = Math.floor(e / 100000), n100km = Math.floor(n / 100000); + + // translate those into numeric equivalents of the grid letters + let l1 = (19 - n100km) - (19 - n100km) % 5 + Math.floor((e100km + 10) / 5); + let l2 = (19 - n100km) * 5 % 25 + e100km % 5; + + // compensate for skipped 'I' and build grid letter-pairs + if (l1 > 7) l1++; + if (l2 > 7) l2++; + const letterPair = String.fromCharCode(l1 + 'A'.charCodeAt(0), l2 + 'A'.charCodeAt(0)); + + // strip 100km-grid indices from easting & northing, and reduce precision + e = Math.floor((e % 100000) / Math.pow(10, 5 - digits / 2)); + n = Math.floor((n % 100000) / Math.pow(10, 5 - digits / 2)); + + // pad eastings & northings with leading zeros + e = e.toString().padStart(digits/2, '0'); + n = n.toString().padStart(digits/2, '0'); + + return `${letterPair} ${e} ${n}`; +} + +/** + * + * Module exports section, example code below + * + * let geo = require("geotools"); + * let os = geo.gpsToOSGrid(fix); + * let ref = geo.gpsToOSMapRef(fix); + */ + +// get easting and northings +exports.gpsToOSGrid = function(gps_fix) { + return OsGridRef.latLongToOsGrid(gps_fix); +} + +// string with an OS Map grid reference +exports.gpsToOSMapRef = function(gps_fix) { + let os = OsGridRef.latLongToOsGrid(last_fix); + return to_map_ref(6, os.easting, os.northing); +} diff --git a/apps/gpstouch/gpstouch.app.js b/apps/gpstouch/gpstouch.app.js new file mode 100644 index 000000000..3b2c54569 --- /dev/null +++ b/apps/gpstouch/gpstouch.app.js @@ -0,0 +1,222 @@ +const h = g.getHeight(); +const w = g.getWidth(); +let last_fix; + +function resetLastFix() { + last_fix = { + fix: 0, + alt: 0, + lat: 0, + lon: 0, + speed: 0, + time: 0, + course: 0, + satellites: 0 + }; +} + +function processFix(fix) { + last_fix.time = fix.time; + + if (fix.fix) { + if (!last_fix.fix) { + // we dont need to suppress this in quiet mode as it is user initiated + Bangle.buzz(); // buzz on first position + } + last_fix = fix; + } +} + +function draw() { + var d = new Date(); + var da = d.toString().split(" "); + var time = da[4].substr(0,5); + var hh = da[4].substr(0,2); + var mm = da[4].substr(3,2); + + g.reset(); + drawTop(d,hh,mm); + drawInfo(); +} + +function drawTop(d,hh,mm) { + g.setFont("Vector", w/3); + g.setFontAlign(0, 0); + g.setColor(g.theme.bg); + g.fillRect(0, 24, w, ((h-24)/2) + 24); + g.setColor(g.theme.fg); + + g.setFontAlign(1,0); // right aligned + g.drawString(hh, (w/2) - 6, ((h-24)/4) + 24); + g.setFontAlign(-1,0); // left aligned + g.drawString(mm, (w/2) + 6, ((h-24)/4) + 24); + + // for the colon + g.setFontAlign(0,0); // centre aligned + if (d.getSeconds()&1) g.drawString(":", w/2, ((h-24)/4) + 24); +} + +function drawInfo() { + if (infoData[infoMode] && infoData[infoMode].calc) { + g.setFont("Vector", w/7); + g.setFontAlign(0, 0); + + if (infoData[infoMode].get_color) + g.setColor(infoData[infoMode].get_color()); + else + g.setColor(g.theme.bgH); + g.fillRect(0, ((h-24)/2) + 24 + 1, w, h); + + if (infoData[infoMode].is_control) + g.setColor("#fff"); + else + g.setColor(g.theme.fg); + + g.drawString((infoData[infoMode].calc()), w/2, (3*(h-24)/4) + 24); + } +} + +const infoData = { + ID_LAT: { + calc: () => 'Lat: ' + last_fix.lat.toFixed(4), + }, + ID_LON: { + calc: () => 'Lon: ' + last_fix.lon.toFixed(4), + }, + ID_SPEED: { + calc: () => 'Speed: ' + last_fix.speed.toFixed(1), + }, + ID_ALT: { + calc: () => 'Alt: ' + last_fix.alt.toFixed(0), + }, + ID_COURSE: { + calc: () => 'Course: '+ last_fix.course.toFixed(0), + }, + ID_SATS: { + calc: () => 'Satelites: ' + last_fix.satellites, + }, + ID_TIME: { + calc: () => formatTime(last_fix.time), + }, + OS_REF: { + calc: () => 'NZ 208 987', + }, + GPS_POWER: { + calc: () => (Bangle.isGPSOn()) ? 'GPS On' : 'GPS Off', + action: () => toggleGPS(), + get_color: () => Bangle.isGPSOn() ? '#f00' : '#00f', + is_control: true, + }, + GPS_LOGGER: { + calc: () => 'Logger ' + loggerStatus(), + action: () => toggleLogger(), + get_color: () => loggerStatus() == "ON" ? '#f00' : '#00f', + is_control: true, + }, +}; + +function toggleGPS() { + Bangle.setGPSPower(Bangle.isGPSOn() ? 0 : 1, 'gpstouch'); + // add or remove listenner + if (Bangle.isGPSOn()) + Bangle.on('GPS', processFix); + else + Bangle.removeListener("GPS", processFix); + resetLastFix(); +} + +function loggerStatus() { + var settings = require("Storage").readJSON("gpsrec.json",1)||{}; + if (settings == {}) return "Install"; + return settings.recording ? "ON" : "OFF"; +} + +function toggleLogger() { + var settings = require("Storage").readJSON("gpsrec.json",1)||{}; + if (settings == {}) return; + + settings.recording = !settings.recording; + require("Storage").write("gpsrec.json", settings); + + if (WIDGETS["gpsrec"]) + WIDGETS["gpsrec"].reload(); + + // not sure if safe to register a listenner again + if (Bangle.isGPSOn()) + Bangle.on('GPS', processFix); +} + +function formatTime(now) { + try { + var fd = now.toUTCString().split(" "); + return fd[4]; + } catch (e) { + return "00:00:00"; + } +} + +const infoList = Object.keys(infoData).sort(); +let infoMode = infoList[0]; + +function nextInfo() { + let idx = infoList.indexOf(infoMode); + if (idx > -1) { + if (idx === infoList.length - 1) infoMode = infoList[0]; + else infoMode = infoList[idx + 1]; + } +} + +function prevInfo() { + let idx = infoList.indexOf(infoMode); + if (idx > -1) { + if (idx === 0) infoMode = infoList[infoList.length - 1]; + else infoMode = infoList[idx - 1]; + } +} + +Bangle.on('swipe', dir => { + if (dir == 1) prevInfo() else nextInfo(); + draw(); +}); + +let prevTouch = 0; + +Bangle.on('touch', function(button, xy) { + let dur = 1000*(getTime() - prevTouch); + prevTouch = getTime(); + + if (dur <= 1000 && xy.y < h/2 && infoData[infoMode].is_control) { + Bangle.buzz(); + if (infoData[infoMode] && infoData[infoMode].action) { + infoData[infoMode].action(); + draw(); + } + } +}); + +// Stop updates when LCD is off, restart when on +Bangle.on('lcdPower', on => { + if (secondInterval) + clearInterval(secondInterval); + secondInterval = undefined; + if (on) + secondInterval = setInterval(draw, 1000); + draw(); +}); + + +resetLastFix(); + +// add listenner if already powered on, plus tag app +if (Bangle.isGPSOn()) { + Bangle.setGPSPower(1, 'gpstouch'); + Bangle.on('GPS', processFix); +} + +g.clear(); +var secondInterval = setInterval(draw, 1000); +draw(); +// Show launcher when button pressed +Bangle.setUI("clock"); +Bangle.loadWidgets(); +Bangle.drawWidgets(); diff --git a/apps/gpstouch/gpstouch.icon.js b/apps/gpstouch/gpstouch.icon.js new file mode 100644 index 000000000..c4cf85676 --- /dev/null +++ b/apps/gpstouch/gpstouch.icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEw4UA///j+EAYO/uYDB//wCYcPBA4AFh/ABZMDBbkX6gLIgtX6tQBY9VBYNVBY0BBYdABYsFqoACEgQLDitVtWpqtUBYtVq2q1WVGAQLErQLB0oLFHQNqBYIkBHgMDIwYKBAAJIDIweqz/2BYJtDBYI6Bv/9HgILHYwILGh4gBBYWfbooLF6AjPBYW//wLGL4Wv/RfGNZaDIBYibEBYizIBYjLDBYzXBd4TXCBZ60BBYRqEBZpUBBYRSFJAQLCA4b7BHgQLFgYLGIwYLEgoLBHQYLEgILBHQYLEgALBAoYLFi/UBZMHBZUD6ALKApQAFBbHwBZMP/4ABBwgIDA=")) diff --git a/apps/gpstouch/gpstouch.png b/apps/gpstouch/gpstouch.png new file mode 100644 index 000000000..c411356ae Binary files /dev/null and b/apps/gpstouch/gpstouch.png differ