diff --git a/apps/rungps/ChangeLog b/apps/rungps/ChangeLog new file mode 100644 index 0000000000..a54add3cf8 --- /dev/null +++ b/apps/rungps/ChangeLog @@ -0,0 +1,2 @@ +0.01: New App! +0.02: Download capebillity and nan heart rate error fixed diff --git a/apps/rungps/Home.css b/apps/rungps/Home.css new file mode 100644 index 0000000000..9e94fbff4b --- /dev/null +++ b/apps/rungps/Home.css @@ -0,0 +1,20 @@ +.data { + border-bottom: #838383 solid 2px; + border-top: #838383 solid 2px; + width: 50%; + position: absolute; + top: 50%; +} + +#map { + height: 50%; + width: 50%; + position: absolute; + left: 0; + top: 0; +} + +#inputDiv { + position: absolute; + top: calc(50% + 100px); +} diff --git a/apps/rungps/README.md b/apps/rungps/README.md new file mode 100644 index 0000000000..1954421037 --- /dev/null +++ b/apps/rungps/README.md @@ -0,0 +1,42 @@ +# Running + +This app is designed to be simple and intuitive. +I’ve tried to make everything as clear as possible. + +## How to Use + +When you open the app, you have two options: + +- **Run** – start a new running session. +- **Results** – view your previous runs. + +--- + +## Run + +When you tap **Run**, you’ll see four squares with a red one in the middle. + +- To start running, press **Button 1** on the side of your **Bangle.js**. + The middle square will turn **green**, indicating that recording has started. + +- To stop, press the same button again. + The screen will freeze, showing that the session has ended. + +To view your results, reopen the app and choose **Results**. + +--- + +## Results + +In the **Results** screen, you’ll see a list of your runs sorted by date. +Tap any date to view detailed data for that session. + +You can also view or download your results directly from the **App Loader**. + +--- + +## Notes + +- Make sure your **GPS** is active before starting a run. It is ready when the distance and speed starts changing. +- Recorded data includes time, distance, heart rate, ... +- Results are saved on the watch and can be exported for analysis. diff --git a/apps/rungps/app-icon.js b/apps/rungps/app-icon.js new file mode 100644 index 0000000000..e19e2ba696 --- /dev/null +++ b/apps/rungps/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEw4UA///IfsVqALJqtUBREBqEVBZNAgoIFgofBBZFVqtQBY4rBgNVBY8VoAlBoBHGAoRGCqAYDA4I7CB4VVDwIfCioGBE4VRBYUUgNUDQQ/BoILDDAJFBB4MFBYcFqoCBGgJjBCQI7DDQINBDAR4FIoIIBqAQBPApvDBYopCOwTMFFIR3BooLFDoJUCOwYLGag5SCcBDqBM4bXGFoI4FLwVRBILJFBYVAO4YAFYASpCBYwwBEgIwGZgNQQI43BEAJVBBYzgBDQZGGCoIaCIwwtCKYzSCCoLIFXIIJBBwJ3FqkUBIL5BHQsVDAILBU40FNAUFqqZGQAUBFwoKBAALHHBZYAj")) diff --git a/apps/rungps/app.js b/apps/rungps/app.js new file mode 100644 index 0000000000..5277698e1a --- /dev/null +++ b/apps/rungps/app.js @@ -0,0 +1,223 @@ +var storage = require("Storage"); +var width = g.getWidth(); +var height = g.getHeight(); + +var gps = {}; +var heart_rate = {bpm: 0, confidence: 0}; +var running = false; +var al_fix = false; +var al_rate = false; +var typen = false; +var vorig = null; +var total_distance = 0; +var start_time = 0|getTime(); +var watch; +var interval; + +var mainMenu; + +function central(text, x, x2, y) { + g.drawString(text, (x2 - g.stringWidth(text))/2+x, y); +} + +function getDistanceFromLatLonInKm(lat1, lon1, lat2, lon2) { + var R = 6371; + var dLat = deg2rad(lat2-lat1); + var dLon = deg2rad(lon2-lon1); + var a = Math.sin(dLat/2) * Math.sin(dLat/2) + + Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * + Math.sin(dLon/2) * Math.sin(dLon/2); + var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + var d = R * c; + return d; +} + +function deg2rad(deg) { + return deg * (Math.PI/180); +} + +function write(f) { + var csv = [ + 0|getTime(), + gps.lat, + gps.lon, + gps.alt, + gps.speed, + gps.course, + heart_rate.bpm + ]; + f.write(csv.join(", ")+"\n"); +} + +function getTimediffrence() { + var diffrence = 0|getTime() - start_time; + return [Math.floor(diffrence / 60), diffrence % 60]; +} + +function scherm() { + var speed = 0; + var diffrence = getTimediffrence(); + if (gps.fix) { + if (gps.speed != 0) { + speed = 60/gps.speed; + } + } + g.setColor(0, 0, 0); + g.clear(); + g.setFont("Vector", 15); + g.drawLine(width/2, 0, width/2, height); + g.drawLine(0, height/2, width, height/2); + central("Speed", 0, width/2, 5); + central("Distance", width/2, width/2, 5); + central("Heart rate", 0, width/2, height/2+5); + central("Time", width/2, width/2, height/2+5); + g.setFont("Vector", 30); + if (gps.fix) { + central(Math.floor(speed)+":"+Math.floor((speed * 60) % 60), 0, width/2, 20); + if (vorig) { + total_distance += getDistanceFromLatLonInKm(vorig.lat, vorig.lon, gps.lat, gps.lon); + central(Math.floor(total_distance*100)/100, width/2, width/2, 20); + } + } + central(heart_rate.bpm, 0, width/2, height/2+20); + central(diffrence[0]+":"+diffrence[1], width/2, width/2, height/2+20); + if (running) { + g.setColor(0, 1, 0); + } else { + g.setColor(1, 0, 0); + } + g.fillRect(width/2-8, height/2-8, width/2+8, height/2+8); +} + +function maakklaar(f) { + if (!typen) { + var fix = Bangle.getGPSFix(); + gps = fix; + scherm(); + if (fix.fix) { + if (!al_fix) { + Bangle.buzz(); + console.log(fix.satellites); + al_fix = true; + } + if (!al_rate) { + al_rate = true; + } + if (running) { + write(f); + } + vorig = gps; + } + } +} + +function run() { + var f = storage.open("rungps."+require("locale").date(new Date())+".csv","a"); + watch = setWatch(() => {running = !running; + if (running === false) { + f.write("]"); + stopRun(); + } else { + start_time = 0|getTime(); + } + }, BTN1, {repeat: true}); + interval = setInterval(maakklaar, 2000, [f]); + + Bangle.setGPSPower(1, "rungps"); + Bangle.setHRMPower(1, "rungps"); + Bangle.on("HRM", (r) => { heart_rate = Number(r); }); +} + +function stopRun() { + clearWatch(watch); + clearInterval(interval); + + Bangle.setGPSPower(0, "rungps"); + Bangle.setHRMPower(0, "rungps"); + Bangle.removeListener("HRM", (r) => { heart_rate = Number(r); }); +} + +function parseFile(file) { + var totalHeart_rate = 0; + var distance = 0; + var points = 0; + var endTime = 0; + + var l = file.readLine(); + var all = l.split(", "); + const startTime = all[0]; + var lat = all[1]; + var lon = all[2]; + totalHeart_rate += all[6]/10000; + points += 1; + + var vorig = {lat: lat, lon: lon}; + + l = file.readLine(); + while (l[0] !== "]") { + all = l.split(", "); + var time = all[0]; + lat = all[1]; + lon = all[2]; + + distance += getDistanceFromLatLonInKm(vorig.lat, vorig.lon, lat, lon); + totalHeart_rate += all[6]/10000; + points += 1; + vorig = {lat: lat, lon: lon}; + l = file.readLine(); + if (l[0] === "]") { + endTime = time; + } + } + var timeDifference = endTime - startTime; + var speed = Math.floor(timeDifference/distance); + distance = Math.floor(distance*100)/100; + return [Math.floor(totalHeart_rate/points*10000), distance.toString()+"km", convertSecondsToTime(timeDifference), + Math.floor(speed/60)+":"+Math.floor(speed % 60)+"min/km"]; +} + +function convertSecondsToTime(given_seconds) { + var seconds = given_seconds % 60; + var minutes = Math.floor(given_seconds/60) % 60; + var hours = Math.floor(given_seconds/60/60); + return hours+":"+minutes+":"+seconds; +} + +function showData(file) { + var title = file.replace("rungps.","").replace(".csv",""); + E.showMessage(/*LANG*/"Loading...", title); + var f = storage.open(file, "r"); + var all = parseFile(f); + var heart_rate = all[0]; + var distance = all[1]; + var timeDifference = all[2]; + var speed = all[3]; + E.showMenu({ + "": {title: file.replace("rungps.","").replace(".csv","")}, + "Speed": {value: speed}, + "Distance": {value: distance}, + "Time": {value: timeDifference}, + "Heart rate": {value: heart_rate}, + "< Back": () => E.showMenu(mainMenu) + }); +} + +function results() { + var allFiles = storage.list(/rungps\..+\.csv$/, {sf: true}); + E.showScroller({ + h : 40, c : allFiles.length, + draw : (idx, r) => { + g.setBgColor((idx&1)?"#666":"#CCC").clearRect(r.x,r.y,r.x+r.w-1,r.y+r.h-1); + g.setFont("6x8:2").drawString(allFiles[idx].replace("rungps.","").replace(".csv",""), r.x+10,r.y+4); + }, + select : (idx) => showData(allFiles[idx]) + }); +} + +mainMenu = { + "": { title: "-- Running --" }, // options + "Run": () => run(), + "Results": () => results() +}; +E.showMenu(mainMenu); + diff --git a/apps/rungps/app.png b/apps/rungps/app.png new file mode 100644 index 0000000000..883245b814 Binary files /dev/null and b/apps/rungps/app.png differ diff --git a/apps/rungps/calculations.js b/apps/rungps/calculations.js new file mode 100644 index 0000000000..440ef40291 --- /dev/null +++ b/apps/rungps/calculations.js @@ -0,0 +1,57 @@ +/* eslint-disable */ +window.calculations = { + "translateData": function (text) { + var points = [] + for (var line of text.split("\n")) { + var all = line.split(", "); + if (all.length === 7) { + let [time_seconds, lat, lon, alt, speed, course, heart_rate] = all; + points.push({lat: parseFloat(lat), long: parseFloat(lon), time: time_seconds, heart_rate: heart_rate, speed: speed, alt: alt}); + } + } + return points; + }, + "deg2rad": function (deg) { + return deg * (Math.PI/180); + }, + "getDistanceFromLatLonInKm": function (lat1, lon1, lat2, lon2) { + const R = 6371; // Radius of the earth in km + var dLat = window.calculations.deg2rad(lat2-lat1); // deg2rad below + var dLon = window.calculations.deg2rad(lon2-lon1); + var a = Math.sin(dLat/2) * Math.sin(dLat/2) + + Math.cos(window.calculations.deg2rad(lat1)) * Math.cos(window.calculations.deg2rad(lat2)) * + Math.sin(dLon/2) * Math.sin(dLon/2); + var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + return R * c; + }, + "getAverageSpeedAndDistance": function (points) { + var timeDifference = points[points.length-1].time - points[0].time; + var distance = 0; + var vorig = points[0]; + for (let i = 1; i < points.length; i++) { + distance += window.calculations.getDistanceFromLatLonInKm(vorig.lat, vorig.long, points[i].lat, points[i].long); + vorig = points[i] + } + distance = Math.floor(distance*100)/100; + var speed = Math.floor(timeDifference/distance) + return [Math.floor(speed/60)+":"+Math.floor(speed % 60)+"min/km", distance.toString()+"km"]; + }, + "convertSecondstoTime": function (given_seconds) { + let dateObj = new Date(given_seconds * 1000); + let hours = dateObj.getUTCHours(); + let minutes = dateObj.getUTCMinutes(); + let seconds = dateObj.getSeconds(); + + return hours.toString().padStart(2, '0') + + ':' + minutes.toString().padStart(2, '0') + + ':' + seconds.toString().padStart(2, '0'); + }, + "getIndexByTime": function (time, points) { + for (let i = 0; i < points.length; i++) { + if (window.calculations.convertSecondstoTime(points[i].time) === time) { + return i; + } + } + return -1; + } +} diff --git a/apps/rungps/gpx_maker.js b/apps/rungps/gpx_maker.js new file mode 100644 index 0000000000..5475e28162 --- /dev/null +++ b/apps/rungps/gpx_maker.js @@ -0,0 +1,252 @@ +/* eslint-disable */ +const createXmlString = lines => { + // Normalize input into an array of segments, each a list of point objects + // Supported inputs: + // 1) [[lon,lat,ele], ...] -> single segment + // 2) [ [ [lon,lat,ele], ... ], [ [lon,lat,ele] ] ] -> multiple segments + // 3) [{lat, long, time, heart_rate, speed, alt}, ...] -> single segment (new format) + // 4) [ [ {..}, {..} ], [ {..} ] ] -> multiple segments (new format) + const toPointObj = (p) => { + if (p && typeof p === 'object' && !Array.isArray(p)) { + // already object with possible fields + return { + lat: typeof p.lat === 'string' ? parseFloat(p.lat) : p.lat, + long: typeof p.long === 'string' ? parseFloat(p.long) : p.long, + alt: p.alt != null ? (typeof p.alt === 'string' ? parseFloat(p.alt) : p.alt) : undefined, + time: p.time != null ? (typeof p.time === 'string' ? parseFloat(p.time) : p.time) : undefined, + heart_rate: p.heart_rate != null ? (typeof p.heart_rate === 'string' ? parseFloat(p.heart_rate) : p.heart_rate) : undefined, + speed: p.speed != null ? (typeof p.speed === 'string' ? parseFloat(p.speed) : p.speed) : undefined, + }; + } + // legacy array [lon, lat, ele] + if (Array.isArray(p)) { + return { lat: parseFloat(p[1]), long: parseFloat(p[0]), alt: p[2] != null ? parseFloat(p[2]) : undefined }; + } + return undefined; + }; + + let segments = []; + if (!Array.isArray(lines)) { + segments = []; + } else if (lines.length && Array.isArray(lines[0])) { + // Could be [[lon,lat,ele], ...] or [ [ [lon,lat,ele], ... ], ...] + if (lines[0].length && Array.isArray(lines[0][0])) { + // multiple segments of arrays + segments = lines.map(seg => seg.map(toPointObj)); + } else if (lines[0].length && (typeof lines[0][0] === 'number' || typeof lines[0][0] === 'string')) { + // single segment of arrays + segments = [lines.map(toPointObj)]; + } else if (lines[0] && typeof lines[0] === 'object' && !Array.isArray(lines[0])) { + // single segment of objects nested in an array wrapper + segments = [lines.map(toPointObj)]; + } else { + segments = []; + } + } else if (lines.length && typeof lines[0] === 'object') { + // single segment of objects + segments = [lines.map(toPointObj)]; + } else { + segments = []; + } + + // Compute base time if any time values exist (seconds, relative). We'll base on current time. + let firstTime = undefined; + for (const seg of segments) { + for (const p of seg) { + if (p && typeof p.time === 'number' && !isNaN(p.time)) { + firstTime = (firstTime === undefined) ? p.time : Math.min(firstTime, p.time); + } + } + } + const baseDate = new Date(); + + let result = ''; + result += segments.reduce((accum, curr) => { + let segmentTag = ''; + segmentTag += curr.map((point) => { + if (!point) return ''; + const lat = (point.lat != null) ? point.lat : undefined; + const lon = (point.long != null) ? point.long : undefined; + if (lat == null || lon == null || isNaN(lat) || isNaN(lon)) return ''; + let s = ``; + if (point.alt != null && !isNaN(point.alt)) s += `${point.alt}`; + if (point.time != null && firstTime != null && !isNaN(point.time)) { + const dt = new Date(baseDate.getTime() + (point.time - firstTime) * 1000); + s += ``; + } + // Extensions for HR and speed if provided + const hrOk = point.heart_rate != null && !isNaN(point.heart_rate); + const spOk = point.speed != null && !isNaN(point.speed); + if (hrOk || spOk) { + s += ''; + if (hrOk) s += `${Math.round(point.heart_rate)}`; + if (spOk) s += `${point.speed}`; + s += ''; + } + s += ''; + return s; + }).join(''); + segmentTag += ''; + return accum + segmentTag; + }, ''); + result += ''; + return result; +} + +const createTcxString = (lines, totalDistanceMeters) => { + // Normalize input into segments of point objects using the same helper from GPX + const toPointObj = (p) => { + if (p && typeof p === 'object' && !Array.isArray(p)) { + return { + lat: typeof p.lat === 'string' ? parseFloat(p.lat) : p.lat, + long: typeof p.long === 'string' ? parseFloat(p.long) : p.long, + alt: p.alt != null ? (typeof p.alt === 'string' ? parseFloat(p.alt) : p.alt) : undefined, + time: p.time != null ? (typeof p.time === 'string' ? parseFloat(p.time) : p.time) : undefined, + heart_rate: p.heart_rate != null ? (typeof p.heart_rate === 'string' ? parseFloat(p.heart_rate) : p.heart_rate) : undefined, + speed: p.speed != null ? (typeof p.speed === 'string' ? parseFloat(p.speed) : p.speed) : undefined, + }; + } + if (Array.isArray(p)) { + return { lat: parseFloat(p[1]), long: parseFloat(p[0]), alt: p[2] != null ? parseFloat(p[2]) : undefined }; + } + return undefined; + }; + + let segments = []; + if (!Array.isArray(lines)) { + segments = []; + } else if (lines.length && Array.isArray(lines[0])) { + if (lines[0].length && Array.isArray(lines[0][0])) { + segments = lines.map(seg => seg.map(toPointObj)); + } else if (lines[0].length && (typeof lines[0][0] === 'number' || typeof lines[0][0] === 'string')) { + segments = [lines.map(toPointObj)]; + } else if (lines[0] && typeof lines[0] === 'object' && !Array.isArray(lines[0])) { + segments = [lines.map(toPointObj)]; + } else { + segments = []; + } + } else if (lines.length && typeof lines[0] === 'object') { + segments = [lines.map(toPointObj)]; + } else { + segments = []; + } + + // Determine timing + let firstTime = undefined; + let lastTime = undefined; + for (const seg of segments) { + for (const p of seg) { + if (p && typeof p.time === 'number' && !isNaN(p.time)) { + firstTime = (firstTime === undefined) ? p.time : Math.min(firstTime, p.time); + lastTime = (lastTime === undefined) ? p.time : Math.max(lastTime, p.time); + } + } + } + const baseDate = new Date(); + const totalTimeSeconds = (firstTime != null && lastTime != null && lastTime >= firstTime) ? (lastTime - firstTime) : 0; + + const startIso = baseDate.toISOString(); + + let tcx = '' + + '' + + '' + + '' + + '' + + `${startIso}` + + `` + + `${totalTimeSeconds}` + + `${isFinite(totalDistanceMeters) ? totalDistanceMeters : 0}` + + '0' + + 'Active' + + 'Manual' + + ''; + + for (const seg of segments) { + for (const point of seg) { + if (!point) continue; + const lat = (point.lat != null) ? point.lat : undefined; + const lon = (point.long != null) ? point.long : undefined; + if (lat == null || lon == null || isNaN(lat) || isNaN(lon)) continue; + + let timeIso = startIso; + if (point.time != null && firstTime != null && !isNaN(point.time)) { + const dt = new Date(baseDate.getTime() + (point.time - firstTime) * 1000); + timeIso = dt.toISOString(); + } + tcx += '' + + `` + + '' + + `${lat}` + + `${lon}` + + ''; + if (point.alt != null && !isNaN(point.alt)) tcx += `${point.alt}`; + if (point.heart_rate != null && !isNaN(point.heart_rate)) tcx += `${Math.round(point.heart_rate)}`; + if (point.speed != null && !isNaN(point.speed)) { + tcx += '' + + '' + + `${point.speed}` + + '' + + ''; + } + tcx += ''; + } + } + + tcx += ''; + return tcx; +}; + +function parseDistanceMeters(distanceStr) { + if (!distanceStr || typeof distanceStr !== 'string') return 0; + const m = distanceStr.trim().match(/^\s*([0-9]+(?:\.[0-9]+)?)\s*([a-zA-Z]+)?\s*$/); + if (!m) return 0; + const value = parseFloat(m[1]); + const unit = (m[2] || '').toLowerCase(); + if (!isFinite(value)) return 0; + switch (unit) { + case 'km': + return Math.round(value * 1000); + case 'm': + return Math.round(value); + case 'mi': + case 'mile': + case 'miles': + return Math.round(value * 1609.344); + default: + return Math.round(value); // assume meters if unknown + } +} + +window.gpx_manager = { + "downloadGpxFile": function ( + lines, + distance, + units + ) { + const xml = createXmlString(lines); + const url = 'data:application/gpx+xml;charset=utf-8,' + encodeURIComponent(xml); + const link = document.createElement('a'); + link.download = `${distance[distance.length - 1]}-${units}.gpx`; + link.href = url; + document.body.appendChild(link); + link.click(); + }, + "downloadTcxFile": function ( + lines, + distance, + units + ) { + const distanceStr = Array.isArray(distance) ? distance[distance.length - 1] : String(distance || '0m'); + const totalMeters = parseDistanceMeters(distanceStr); + const tcx = createTcxString(lines, totalMeters); + const url = 'data:application/vnd.garmin.tcx+xml;charset=utf-8,' + encodeURIComponent(tcx); + const link = document.createElement('a'); + link.download = `${distanceStr}-${units}.tcx`; + link.href = url; + document.body.appendChild(link); + link.click(); + } +}; diff --git a/apps/rungps/interface.html b/apps/rungps/interface.html new file mode 100644 index 0000000000..579646d248 --- /dev/null +++ b/apps/rungps/interface.html @@ -0,0 +1,92 @@ + + + + + Home rungps loader + + + + + + + + + + +
+
+

0km

+

0:0min/km

+
+
+ + + + +
+ + + + diff --git a/apps/rungps/metadata.json b/apps/rungps/metadata.json new file mode 100644 index 0000000000..4463e511b5 --- /dev/null +++ b/apps/rungps/metadata.json @@ -0,0 +1,18 @@ +{ "id": "rungps", + "name": "Running", + "shortName":"Running", + "version":"0.02", + "description": "This app is a app for running and see your prestasion!", + "icon": "app.png", + "tags": "tool,outdoors,gps", + "supports" : ["BANGLEJS2"], + "readme": "README.md", + "interface": "interface.html", + "storage": [ + {"name":"rungps.app.js","url":"app.js"}, + {"name":"rungps.img","url":"app-icon.js","evaluate":true} + ], + "data": [ + {"wildcard":"rungps..?.csv","storageFile":true} + ] +}