mirror of https://github.com/espruino/BangleApps
Added app for running
parent
745e4d38a9
commit
4d52de224c
21
apps.json
21
apps.json
|
@ -1293,5 +1293,26 @@
|
|||
"evaluate": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "banglerun",
|
||||
"name": "BangleRun",
|
||||
"shortName": "BangleRun",
|
||||
"icon": "banglerun.png",
|
||||
"version": "0.01",
|
||||
"description": "An app for running sessions.",
|
||||
"tags": "run,running,fitness,outdoors",
|
||||
"allow_emulator": false,
|
||||
"storage": [
|
||||
{
|
||||
"name": "banglerun.app.js",
|
||||
"url": "app.js"
|
||||
},
|
||||
{
|
||||
"name": "banglerun.img",
|
||||
"url": "app-icon.js",
|
||||
"evaluate": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
0.01: First release
|
|
@ -0,0 +1 @@
|
|||
require("heatshrink").decompress(atob("mEwwMB/4ACx4ED/0DApP8AqAXB84GDg/DAgXj/+DCAUABgIFB4EAv4FCwEAj0PAoJPBgwFEgEfDgMOAoM/AoMegFAAoP8jkA8F/AoM8gP4DgP4nBvD/F4KQfwuAFE+A/CAoPgAofx8A/CKYRwELIIFDLII6BAoZSBLIYeC/0BwAFDgfAGAQFBHgf8g4BBIIUH/wFBSYMPAoXwAog/Bj4FEv4FDDQQCBQoQFCZYYFi/6KE/+P/4A="))
|
|
@ -0,0 +1,314 @@
|
|||
/** Global constants */
|
||||
const DEG_TO_RAD = Math.PI / 180;
|
||||
const EARTH_RADIUS = 6371008.8;
|
||||
|
||||
/** Utilities for handling vectors */
|
||||
class Vector {
|
||||
static magnitude(a) {
|
||||
let sum = 0;
|
||||
for (const key of Object.keys(a)) {
|
||||
sum += a[key] * a[key];
|
||||
}
|
||||
return Math.sqrt(sum);
|
||||
}
|
||||
|
||||
static add(a, b) {
|
||||
const result = {};
|
||||
for (const key of Object.keys(a)) {
|
||||
result[key] = a[key] + b[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static sub(a, b) {
|
||||
const result = {};
|
||||
for (const key of Object.keys(a)) {
|
||||
result[key] = a[key] - b[key];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static multiplyScalar(a, x) {
|
||||
const result = {};
|
||||
for (const key of Object.keys(a)) {
|
||||
result[key] = a[key] * x;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static divideScalar(a, x) {
|
||||
const result = {};
|
||||
for (const key of Object.keys(a)) {
|
||||
result[key] = a[key] / x;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Interquartile range filter, to detect outliers */
|
||||
class IqrFilter {
|
||||
constructor(size, threshold) {
|
||||
const q = Math.floor(size / 4);
|
||||
this._buffer = [];
|
||||
this._size = 4 * q + 2;
|
||||
this._i1 = q;
|
||||
this._i3 = 3 * q + 1;
|
||||
this._threshold = threshold;
|
||||
}
|
||||
|
||||
isReady() {
|
||||
return this._buffer.length === this._size;
|
||||
}
|
||||
|
||||
isOutlier(point) {
|
||||
let result = true;
|
||||
if (this._buffer.length === this._size) {
|
||||
result = false;
|
||||
for (const key of Object.keys(point)) {
|
||||
const data = this._buffer.map(item => item[key]);
|
||||
data.sort((a, b) => (a - b) / Math.abs(a - b));
|
||||
const q1 = data[this._i1];
|
||||
const q3 = data[this._i3];
|
||||
const iqr = q3 - q1;
|
||||
const lower = q1 - this._threshold * iqr;
|
||||
const upper = q3 + this._threshold * iqr;
|
||||
if (point[key] < lower || point[key] > upper) {
|
||||
result = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
this._buffer.push(point);
|
||||
this._buffer = this._buffer.slice(-this._size);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Process GPS data */
|
||||
class Gps {
|
||||
constructor() {
|
||||
this._lastCall = Date.now();
|
||||
this._lastValid = 0;
|
||||
this._coords = null;
|
||||
this._filter = new IqrFilter(10, 1.5);
|
||||
this._shift = { x: 0, y: 0, z: 0 };
|
||||
}
|
||||
|
||||
isReady() {
|
||||
return this._filter.isReady();
|
||||
}
|
||||
|
||||
getDistance(gps) {
|
||||
const time = Date.now();
|
||||
const interval = (time - this._lastCall) / 1000;
|
||||
this._lastCall = time;
|
||||
|
||||
if (!gps.fix) {
|
||||
return { t: interval, d: 0 };
|
||||
}
|
||||
|
||||
const p = gps.lat * DEG_TO_RAD;
|
||||
const q = gps.lon * DEG_TO_RAD;
|
||||
const coords = {
|
||||
x: EARTH_RADIUS * Math.sin(p) * Math.cos(q),
|
||||
y: EARTH_RADIUS * Math.sin(p) * Math.sin(q),
|
||||
z: EARTH_RADIUS * Math.cos(p),
|
||||
};
|
||||
|
||||
if (!this._coords) {
|
||||
this._coords = coords;
|
||||
this._lastValid = time;
|
||||
return { t: interval, d: 0 };
|
||||
}
|
||||
|
||||
const ds = Vector.sub(coords, this._coords);
|
||||
const dt = (time - this._lastValid) / 1000;
|
||||
const v = Vector.divideScalar(ds, dt);
|
||||
|
||||
if (this._filter.isOutlier(v)) {
|
||||
return { t: interval, d: 0 };
|
||||
}
|
||||
|
||||
this._shift = Vector.add(this._shift, ds);
|
||||
const length = Vector.magnitude(this._shift);
|
||||
const remainder = length % 10;
|
||||
const distance = length - remainder;
|
||||
|
||||
this._coords = coords;
|
||||
this._lastValid = time;
|
||||
if (distance > 0) {
|
||||
this._shift = Vector.multiplyScalar(this._shift, remainder / length);
|
||||
}
|
||||
|
||||
return { t: interval, d: distance };
|
||||
}
|
||||
}
|
||||
|
||||
/** Process step counter data */
|
||||
class Step {
|
||||
constructor(size) {
|
||||
this._buffer = [];
|
||||
this._size = size;
|
||||
}
|
||||
|
||||
getCadence() {
|
||||
this._buffer.push(Date.now() / 1000);
|
||||
this._buffer = this._buffer.slice(-this._size);
|
||||
const interval = this._buffer[this._buffer.length - 1] - this._buffer[0];
|
||||
return interval ? Math.round(60 * (this._buffer.length - 1) / interval) : 0;
|
||||
}
|
||||
}
|
||||
|
||||
const gps = new Gps();
|
||||
const step = new Step(10);
|
||||
|
||||
let totDist = 0;
|
||||
let totTime = 0;
|
||||
let totSteps = 0;
|
||||
|
||||
let speed = 0;
|
||||
let cadence = 0;
|
||||
let heartRate = 0;
|
||||
|
||||
let gpsReady = false;
|
||||
let hrmReady = false;
|
||||
let running = false;
|
||||
|
||||
function formatClock(date) {
|
||||
return ('0' + date.getHours()).substr(-2) + ':' + ('0' + date.getMinutes()).substr(-2);
|
||||
}
|
||||
|
||||
function formatDistance(m) {
|
||||
return ('0' + (m / 1000).toFixed(2) + ' km').substr(-7);
|
||||
}
|
||||
|
||||
function formatTime(s) {
|
||||
const hrs = Math.floor(s / 3600);
|
||||
const min = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return (hrs ? hrs + ':' : '') + ('0' + min).substr(-2) + `:` + ('0' + sec).substr(-2);
|
||||
}
|
||||
|
||||
function formatSpeed(kmh) {
|
||||
if (kmh <= 0.6) {
|
||||
return `__'__"`;
|
||||
}
|
||||
const skm = 3600 / kmh;
|
||||
const min = Math.floor(skm / 60);
|
||||
const sec = Math.floor(skm % 60);
|
||||
return ('0' + min).substr(-2) + `'` + ('0' + sec).substr(-2) + `"`;
|
||||
}
|
||||
|
||||
function drawBackground() {
|
||||
g.setColor(running ? 0x00E0 : 0x0000);
|
||||
g.fillRect(0, 30, 240, 240);
|
||||
|
||||
g.setColor(0xFFFF);
|
||||
g.setFontAlign(0, -1, 0);
|
||||
g.setFont('6x8', 2);
|
||||
|
||||
g.drawString('DISTANCE', 120, 50);
|
||||
g.drawString('TIME', 60, 100);
|
||||
g.drawString('PACE', 180, 100);
|
||||
g.drawString('STEPS', 60, 150);
|
||||
g.drawString('STP/m', 180, 150);
|
||||
g.drawString('SPEED', 40, 200);
|
||||
g.drawString('HEART', 120, 200);
|
||||
g.drawString('CADENCE', 200, 200);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const totSpeed = totTime ? 3.6 * totDist / totTime : 0;
|
||||
const totCadence = totTime ? Math.round(60 * totSteps / totTime) : 0;
|
||||
|
||||
g.setColor(running ? 0x00E0 : 0x0000);
|
||||
g.fillRect(0, 30, 240, 50);
|
||||
g.fillRect(0, 70, 240, 100);
|
||||
g.fillRect(0, 120, 240, 150);
|
||||
g.fillRect(0, 170, 240, 200);
|
||||
g.fillRect(0, 220, 240, 240);
|
||||
|
||||
g.setFont('6x8', 2);
|
||||
|
||||
g.setFontAlign(-1, -1, 0);
|
||||
g.setColor(gpsReady ? 0x07E0 : 0xF800);
|
||||
g.drawString(' GPS', 6, 30);
|
||||
|
||||
g.setFontAlign(1, -1, 0);
|
||||
g.setColor(0xFFFF);
|
||||
g.drawString(formatClock(new Date()), 234, 30);
|
||||
|
||||
g.setFontAlign(0, -1, 0);
|
||||
g.setFontVector(20);
|
||||
g.drawString(formatDistance(totDist), 120, 70);
|
||||
g.drawString(formatTime(totTime), 60, 120);
|
||||
g.drawString(formatSpeed(totSpeed), 180, 120);
|
||||
g.drawString(totSteps, 60, 170);
|
||||
g.drawString(totCadence, 180, 170);
|
||||
|
||||
g.setFont('6x8', 2);
|
||||
g.drawString(formatSpeed(speed), 40, 220);
|
||||
|
||||
g.setColor(hrmReady ? 0x07E0 : 0xF800);
|
||||
g.drawString(heartRate, 120, 220);
|
||||
|
||||
g.setColor(0xFFFF);
|
||||
g.drawString(cadence, 200, 220);
|
||||
}
|
||||
|
||||
function handleGps(coords) {
|
||||
const step = gps.getDistance(coords);
|
||||
gpsReady = coords.fix > 0 && gps.isReady();
|
||||
speed = isFinite(gps.speed) ? gps.speed : 0;
|
||||
if (running) {
|
||||
totDist += step.d;
|
||||
totTime += step.t;
|
||||
}
|
||||
}
|
||||
|
||||
function handleHrm(hrm) {
|
||||
hrmReady = hrm.confidence > 50;
|
||||
heartRate = hrm.bpm;
|
||||
}
|
||||
|
||||
function handleStep() {
|
||||
cadence = step.getCadence();
|
||||
if (running) {
|
||||
totSteps += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
running = true;
|
||||
drawBackground();
|
||||
draw();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!running) {
|
||||
totDist = 0;
|
||||
totTime = 0;
|
||||
totSteps = 0;
|
||||
}
|
||||
running = false;
|
||||
drawBackground();
|
||||
draw();
|
||||
}
|
||||
|
||||
Bangle.on('GPS', handleGps);
|
||||
Bangle.on('HRM', handleHrm);
|
||||
Bangle.on('step', handleStep);
|
||||
|
||||
Bangle.setGPSPower(1);
|
||||
Bangle.setHRMPower(1);
|
||||
|
||||
g.clear();
|
||||
Bangle.loadWidgets();
|
||||
Bangle.drawWidgets();
|
||||
drawBackground();
|
||||
draw();
|
||||
|
||||
setInterval(draw, 500);
|
||||
|
||||
setWatch(start, BTN1, { repeat: true });
|
||||
setWatch(stop, BTN3, { repeat: true });
|
Binary file not shown.
After Width: | Height: | Size: 10 KiB |
Loading…
Reference in New Issue