Merge branch 'master' of github.com:espruino/BangleApps
commit
87d12d72a3
|
|
@ -4,3 +4,4 @@ core/lib/qrcode.min.js
|
|||
core/lib/heatshrink.js
|
||||
core/lib/marked.min.js
|
||||
apps/animclk/V29.LBM.js
|
||||
apps/banglerun/rollup.config.js
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@
|
|||
{ "id": "gpsnav",
|
||||
"name": "GPS Navigation",
|
||||
"icon": "icon.png",
|
||||
"version":"0.03",
|
||||
"version":"0.04",
|
||||
"description": "Displays GPS Course and Speed, + Directions to waypoint and waypoint recording, now with waypoint editor",
|
||||
"tags": "tool,outdoors,gps",
|
||||
"readme": "README.md",
|
||||
|
|
@ -1465,7 +1465,7 @@
|
|||
"name": "BangleRun",
|
||||
"shortName": "BangleRun",
|
||||
"icon": "banglerun.png",
|
||||
"version": "0.04",
|
||||
"version": "0.05",
|
||||
"description": "An app for running sessions.",
|
||||
"tags": "run,running,fitness,outdoors",
|
||||
"allow_emulator": false,
|
||||
|
|
@ -2176,11 +2176,12 @@
|
|||
"name": "World Clock - 4 time zones",
|
||||
"shortName":"World Clock",
|
||||
"icon": "app.png",
|
||||
"version":"0.01",
|
||||
"version":"0.02",
|
||||
"description": "Current time zone plus up to four others",
|
||||
"tags": "clock",
|
||||
"type" : "clock",
|
||||
"custom": "custom.html",
|
||||
"readme": "README.md",
|
||||
"storage": [
|
||||
{"name":"worldclock.app.js","url":"app.js"},
|
||||
{"name":"worldclock.settings.json"},
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
|
|
@ -2,3 +2,4 @@
|
|||
0.02: Bugfix time: Reset minutes to 0 when hitting 60
|
||||
0.03: Fix distance >=10 km (fix #529)
|
||||
0.04: Use offscreen buffer for flickerless updates
|
||||
0.05: Complete rewrite. New UI, GPS & HRM Kalman filters, activity logging
|
||||
|
|
|
|||
|
|
@ -1,319 +1 @@
|
|||
/** 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;
|
||||
|
||||
let b = Graphics.createArrayBuffer(240,210,2,{msb:true});
|
||||
let bpal = new Uint16Array([0,0xF800,0x07E0,0xFFFF]);
|
||||
let COL = { RED:1,GREEN:2,WHITE:3 };
|
||||
let bimg = {width:240,height:210,bpp:2,buffer:b.buffer,palette:bpal};
|
||||
|
||||
function formatClock(date) {
|
||||
return ('0' + date.getHours()).substr(-2) + ':' + ('0' + date.getMinutes()).substr(-2);
|
||||
}
|
||||
|
||||
function formatDistance(m) {
|
||||
return (m / 1000).toFixed(2) + ' km';
|
||||
}
|
||||
|
||||
function formatTime(s) {
|
||||
const hrs = Math.floor(s / 3600);
|
||||
const min = Math.floor(s / 60) % 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() {
|
||||
b.clear();
|
||||
|
||||
b.setColor(COL.WHITE);
|
||||
b.setFontAlign(0, -1, 0);
|
||||
b.setFont('6x8', 2);
|
||||
|
||||
b.drawString('DISTANCE', 120, 20);
|
||||
b.drawString('TIME', 60, 70);
|
||||
b.drawString('PACE', 180, 70);
|
||||
b.drawString('STEPS', 60, 120);
|
||||
b.drawString('STP/m', 180, 120);
|
||||
b.drawString('SPEED', 40, 170);
|
||||
b.drawString('HEART', 120, 170);
|
||||
b.drawString('CADENCE', 200, 170);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const totSpeed = totTime ? 3.6 * totDist / totTime : 0;
|
||||
const totCadence = totTime ? Math.round(60 * totSteps / totTime) : 0;
|
||||
|
||||
b.clearRect(0, 00, 240, 20);
|
||||
b.clearRect(0, 40, 240, 70);
|
||||
b.clearRect(0, 90, 240, 120);
|
||||
b.clearRect(0, 140, 240, 170);
|
||||
b.clearRect(0, 190, 240, 210);
|
||||
|
||||
b.setFont('6x8', 2);
|
||||
|
||||
b.setFontAlign(-1, -1, 0);
|
||||
b.setColor(gpsReady ? COL.GREEN : COL.RED);
|
||||
b.drawString(' GPS', 6, 0);
|
||||
|
||||
b.setFontAlign(1, -1, 0);
|
||||
b.setColor(COL.WHITE);
|
||||
b.drawString(formatClock(new Date()), 234, 0);
|
||||
|
||||
b.setFontAlign(0, -1, 0);
|
||||
b.setFontVector(20);
|
||||
b.drawString(formatDistance(totDist), 120, 40);
|
||||
b.drawString(formatTime(totTime), 60, 90);
|
||||
b.drawString(formatSpeed(totSpeed), 180, 90);
|
||||
b.drawString(totSteps, 60, 140);
|
||||
b.drawString(totCadence, 180, 140);
|
||||
|
||||
b.setFont('6x8', 2);
|
||||
b.drawString(formatSpeed(speed), 40,190);
|
||||
|
||||
b.setColor(hrmReady ? COL.GREEN : COL.RED);
|
||||
b.drawString(heartRate, 120, 190);
|
||||
|
||||
b.setColor(COL.WHITE);
|
||||
b.drawString(cadence, 200, 190);
|
||||
|
||||
g.drawImage(bimg,0,30);
|
||||
}
|
||||
|
||||
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 });
|
||||
!function(){"use strict";const t={STOP:63488,PAUSE:65504,RUN:2016};function n(t,n,r){g.setColor(0),g.fillRect(n-60,r,n+60,r+30),g.setColor(65535),g.drawString(t,n,r)}function r(r){var e;g.setFontVector(30),g.setFontAlign(0,-1,0),n((r.distance/1e3).toFixed(2),60,55),n(function(t){const n=Math.round(t),r=Math.floor(n/3600),e=Math.floor(n/60)%60,a=n%60;return(r?r+":":"")+("0"+e).substr(-2)+":"+("0"+a).substr(-2)}(r.duration),180,55),n(function(t){if(t<.1667)return"__'__\"";const n=Math.round(1e3/t),r=Math.floor(n/60),e=n%60;return("0"+r).substr(-2)+"'"+("0"+e).substr(-2)+'"'}(r.speed),60,115),n(r.hr.toFixed(0),180,115),n(r.steps.toFixed(0),60,175),n(r.cadence.toFixed(0),180,175),g.setFont("6x8",2),g.setColor(r.gpsValid?2016:63488),g.fillRect(0,216,80,240),g.setColor(0),g.drawString("GPS",40,220),g.setColor(65535),g.fillRect(80,216,160,240),g.setColor(0),g.drawString(("0"+(e=new Date).getHours()).substr(-2)+":"+("0"+e.getMinutes()).substr(-2),120,220),g.setColor(t[r.status]),g.fillRect(160,216,240,240),g.setColor(0),g.drawString(r.status,200,220)}function e(t){g.clear(),g.setColor(50712),g.setFont("6x8",2),g.setFontAlign(0,-1,0),g.drawString("DIST (KM)",60,32),g.drawString("TIME",180,32),g.drawString("PACE",60,92),g.drawString("HEART",180,92),g.drawString("STEPS",60,152),g.drawString("CADENCE",180,152),r(t),Bangle.drawWidgets()}var a;function o(t){t.status===a.Stopped&&function(t){const n=(new Date).toISOString().replace(/[-:]/g,""),r=`banglerun_${n.substr(2,6)}_${n.substr(9,6)}`;t.file=require("Storage").open(r,"w"),t.file.write(["timestamp","latitude","longitude","altitude","duration","distance","heartrate","steps"].join(","))}(t),t.status===a.Running?t.status=a.Paused:t.status=a.Running,r(t)}!function(t){t.Stopped="STOP",t.Paused="PAUSE",t.Running="RUN"}(a||(a={}));function s(t){const n=t.indexOf(".")-2;return(parseInt(t.substr(0,n))+parseFloat(t.substr(n))/60)*Math.PI/180}const i={fix:NaN,lat:NaN,lon:NaN,alt:NaN,vel:NaN,dop:NaN,gpsValid:!1,x:NaN,y:NaN,z:NaN,v:NaN,t:NaN,dt:NaN,pError:NaN,vError:NaN,hr:60,hrError:100,file:null,drawing:!1,status:a.Stopped,duration:0,distance:0,speed:0,steps:0,cadence:0};var d;d=i,Bangle.on("GPS-raw",t=>function(t,n){const e=n.split(",");switch(e[0].substr(3,3)){case"GGA":t.lat=s(e[2])*("N"===e[3]?1:-1),t.lon=s(e[4])*("E"===e[5]?1:-1),t.alt=parseFloat(e[9]);break;case"VTG":t.vel=parseFloat(e[7])/3.6;break;case"GSA":t.fix=parseInt(e[2]),t.dop=parseFloat(e[15]);break;case"GLL":t.gpsValid=3===t.fix&&t.dop<=5,function(t){const n=Date.now(),r=(n-t.t)/1e3;if(t.t=n,t.dt+=r,t.status===a.Running&&(t.duration+=r),!t.gpsValid)return;const e=6371008.8+t.alt,o=e*Math.cos(t.lat)*Math.cos(t.lon),s=e*Math.cos(t.lat)*Math.sin(t.lon),i=e*Math.sin(t.lat),d=t.vel;if(!t.x)return t.x=o,t.y=s,t.z=i,t.v=d,t.pError=2.5*t.dop,void(t.vError=.05*t.dop);const u=o-t.x,l=s-t.y,g=i-t.z,c=d-t.v,p=Math.sqrt(u*u+l*l+g*g),f=Math.abs(c);t.pError+=t.v*t.dt,t.dt=0;const N=p+2.5*t.dop,h=f+.05*t.dop,S=t.pError/(t.pError+N),E=t.vError/(t.vError+h);t.x+=u*S,t.y+=l*S,t.z+=g*S,t.v+=c*E,t.pError+=(N-t.pError)*S,t.vError+=(h-t.vError)*E;const w=Math.sqrt(t.x*t.x+t.y*t.y+t.z*t.z);t.lat=180*Math.asin(t.z/w)/Math.PI,t.lon=180*Math.atan2(t.y,t.x)/Math.PI||0,t.alt=w-6371008.8,t.status===a.Running&&(t.distance+=p*S,t.speed=t.distance/t.duration||0,t.cadence=60*t.steps/t.duration||0)}(t),r(t),t.gpsValid&&t.status===a.Running&&function(t){t.file.write("\n"),t.file.write([Date.now().toFixed(0),t.lat.toFixed(6),t.lon.toFixed(6),t.alt.toFixed(2),t.duration.toFixed(0),t.distance.toFixed(2),t.hr.toFixed(0),t.steps.toFixed(0)].join(","))}(t)}}(d,t)),Bangle.setGPSPower(1),function(t){Bangle.on("HRM",n=>function(t,n){if(0===n.confidence)return;const r=n.bpm-t.hr,e=Math.abs(r)+101-n.confidence,a=t.hrError/(t.hrError+e);t.hr+=r*a,t.hrError+=(e-t.hrError)*a}(t,n)),Bangle.setHRMPower(1)}(i),function(t){Bangle.on("step",()=>function(t){t.status===a.Running&&(t.steps+=1)}(t))}(i),function(t){Bangle.loadWidgets(),Bangle.on("lcdPower",n=>{t.drawing=n,n&&e(t)}),e(t)}(i),setWatch(()=>o(i),BTN1,{repeat:!0,edge:"falling"}),setWatch(()=>function(t){t.status===a.Paused&&function(t){t.duration=0,t.distance=0,t.speed=0,t.steps=0,t.cadence=0}(t),t.status===a.Running?t.status=a.Paused:t.status=a.Stopped,r(t)}(i),BTN3,{repeat:!0,edge:"falling"})}();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"spec_dir": "test",
|
||||
"spec_files": [
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "banglerun",
|
||||
"version": "0.5.0",
|
||||
"description": "Bangle.js app for running sessions",
|
||||
"main": "app.js",
|
||||
"types": "app.d.ts",
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"test": "ts-node -P tsconfig.spec.json node_modules/jasmine/bin/jasmine --config=jasmine.json"
|
||||
},
|
||||
"author": "Stefano Baldan",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-typescript": "^4.1.1",
|
||||
"@types/jasmine": "^3.5.10",
|
||||
"jasmine": "^3.5.0",
|
||||
"rollup": "^2.10.2",
|
||||
"rollup-plugin-terser": "^5.3.0",
|
||||
"terser": "^4.7.0",
|
||||
"ts-node": "^8.10.2",
|
||||
"tslib": "^2.0.0",
|
||||
"typescript": "^3.9.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import typescript from '@rollup/plugin-typescript';
|
||||
import { terser } from 'rollup-plugin-terser';
|
||||
|
||||
export default {
|
||||
input: './src/app.ts',
|
||||
output: {
|
||||
dir: '.',
|
||||
format: 'iife',
|
||||
name: 'banglerun'
|
||||
},
|
||||
plugins: [
|
||||
typescript(),
|
||||
terser(),
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { draw } from './display';
|
||||
import { initLog } from './log';
|
||||
import { ActivityStatus, AppState } from './state';
|
||||
|
||||
function startActivity(state: AppState): void {
|
||||
if (state.status === ActivityStatus.Stopped) {
|
||||
initLog(state);
|
||||
}
|
||||
|
||||
if (state.status === ActivityStatus.Running) {
|
||||
state.status = ActivityStatus.Paused;
|
||||
} else {
|
||||
state.status = ActivityStatus.Running;
|
||||
}
|
||||
|
||||
draw(state);
|
||||
}
|
||||
|
||||
function stopActivity(state: AppState): void {
|
||||
if (state.status === ActivityStatus.Paused) {
|
||||
clearActivity(state);
|
||||
}
|
||||
|
||||
if (state.status === ActivityStatus.Running) {
|
||||
state.status = ActivityStatus.Paused;
|
||||
} else {
|
||||
state.status = ActivityStatus.Stopped;
|
||||
}
|
||||
|
||||
draw(state);
|
||||
}
|
||||
|
||||
function clearActivity(state: AppState): void {
|
||||
state.duration = 0;
|
||||
state.distance = 0;
|
||||
state.speed = 0;
|
||||
state.steps = 0;
|
||||
state.cadence = 0;
|
||||
}
|
||||
|
||||
export { clearActivity, startActivity, stopActivity };
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { startActivity, stopActivity } from './activity';
|
||||
import { initDisplay } from './display';
|
||||
import { initGps } from './gps';
|
||||
import { initHrm } from './hrm';
|
||||
import { initState } from './state';
|
||||
import { initStep } from './step';
|
||||
|
||||
declare var BTN1: any;
|
||||
declare var BTN3: any;
|
||||
declare var setWatch: any;
|
||||
|
||||
const appState = initState();
|
||||
|
||||
initGps(appState);
|
||||
initHrm(appState);
|
||||
initStep(appState);
|
||||
initDisplay(appState);
|
||||
|
||||
setWatch(() => startActivity(appState), BTN1, { repeat: true, edge: 'falling' });
|
||||
setWatch(() => stopActivity(appState), BTN3, { repeat: true, edge: 'falling' });
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { AppState } from './state';
|
||||
|
||||
declare var Bangle: any;
|
||||
declare var g: any;
|
||||
|
||||
const STATUS_COLORS = {
|
||||
'STOP': 0xF800,
|
||||
'PAUSE': 0xFFE0,
|
||||
'RUN': 0x07E0,
|
||||
}
|
||||
|
||||
function initDisplay(state: AppState): void {
|
||||
Bangle.loadWidgets();
|
||||
Bangle.on('lcdPower', (on: boolean) => {
|
||||
state.drawing = on;
|
||||
if (on) {
|
||||
drawAll(state);
|
||||
}
|
||||
});
|
||||
drawAll(state);
|
||||
}
|
||||
|
||||
function drawBackground(): void {
|
||||
g.clear();
|
||||
g.setColor(0xC618);
|
||||
g.setFont('6x8', 2);
|
||||
g.setFontAlign(0, -1, 0);
|
||||
g.drawString('DIST (KM)', 60, 32);
|
||||
g.drawString('TIME', 180, 32);
|
||||
g.drawString('PACE', 60, 92);
|
||||
g.drawString('HEART', 180, 92);
|
||||
g.drawString('STEPS', 60, 152);
|
||||
g.drawString('CADENCE', 180, 152);
|
||||
}
|
||||
|
||||
function drawValue(value: string, x: number, y: number) {
|
||||
g.setColor(0x0000);
|
||||
g.fillRect(x - 60, y, x + 60, y + 30);
|
||||
g.setColor(0xFFFF);
|
||||
g.drawString(value, x, y);
|
||||
}
|
||||
|
||||
function draw(state: AppState): void {
|
||||
g.setFontVector(30);
|
||||
g.setFontAlign(0, -1, 0);
|
||||
|
||||
drawValue(formatDistance(state.distance), 60, 55);
|
||||
drawValue(formatTime(state.duration), 180, 55);
|
||||
drawValue(formatPace(state.speed), 60, 115);
|
||||
drawValue(state.hr.toFixed(0), 180, 115);
|
||||
drawValue(state.steps.toFixed(0), 60, 175);
|
||||
drawValue(state.cadence.toFixed(0), 180, 175);
|
||||
|
||||
g.setFont('6x8', 2);
|
||||
|
||||
g.setColor(state.gpsValid ? 0x07E0 : 0xF800);
|
||||
g.fillRect(0, 216, 80, 240);
|
||||
g.setColor(0x0000);
|
||||
g.drawString('GPS', 40, 220);
|
||||
|
||||
g.setColor(0xFFFF);
|
||||
g.fillRect(80, 216, 160, 240);
|
||||
g.setColor(0x0000);
|
||||
g.drawString(formatClock(new Date()), 120, 220);
|
||||
|
||||
g.setColor(STATUS_COLORS[state.status]);
|
||||
g.fillRect(160, 216, 240, 240);
|
||||
g.setColor(0x0000);
|
||||
g.drawString(state.status, 200, 220);
|
||||
}
|
||||
|
||||
function drawAll(state: AppState) {
|
||||
drawBackground();
|
||||
draw(state);
|
||||
Bangle.drawWidgets();
|
||||
}
|
||||
|
||||
function formatClock(date: Date): string {
|
||||
return ('0' + date.getHours()).substr(-2) + ':' + ('0' + date.getMinutes()).substr(-2);
|
||||
}
|
||||
|
||||
function formatDistance(meters: number): string {
|
||||
return (meters / 1000).toFixed(2);
|
||||
}
|
||||
|
||||
function formatPace(speed: number): string {
|
||||
if (speed < 0.1667) {
|
||||
return `__'__"`;
|
||||
}
|
||||
const pace = Math.round(1000 / speed);
|
||||
const min = Math.floor(pace / 60);
|
||||
const sec = pace % 60;
|
||||
return ('0' + min).substr(-2) + `'` + ('0' + sec).substr(-2) + `"`;
|
||||
}
|
||||
|
||||
function formatTime(time: number): string {
|
||||
const seconds = Math.round(time);
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
const min = Math.floor(seconds / 60) % 60;
|
||||
const sec = seconds % 60;
|
||||
return (hrs ? hrs + ':' : '') + ('0' + min).substr(-2) + `:` + ('0' + sec).substr(-2);
|
||||
}
|
||||
|
||||
export {
|
||||
draw,
|
||||
drawAll,
|
||||
drawBackground,
|
||||
drawValue,
|
||||
formatClock,
|
||||
formatDistance,
|
||||
formatPace,
|
||||
formatTime,
|
||||
initDisplay,
|
||||
};
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import { draw } from './display';
|
||||
import { updateLog } from './log';
|
||||
import { ActivityStatus, AppState } from './state';
|
||||
|
||||
declare var Bangle: any;
|
||||
|
||||
const EARTH_RADIUS = 6371008.8;
|
||||
const POS_ACCURACY = 2.5;
|
||||
const VEL_ACCURACY = 0.05;
|
||||
|
||||
function initGps(state: AppState): void {
|
||||
Bangle.on('GPS-raw', (nmea: string) => parseNmea(state, nmea));
|
||||
Bangle.setGPSPower(1);
|
||||
}
|
||||
|
||||
function parseCoordinate(coordinate: string): number {
|
||||
const pivot = coordinate.indexOf('.') - 2;
|
||||
const degrees = parseInt(coordinate.substr(0, pivot));
|
||||
const minutes = parseFloat(coordinate.substr(pivot)) / 60;
|
||||
return (degrees + minutes) * Math.PI / 180;
|
||||
}
|
||||
|
||||
function parseNmea(state: AppState, nmea: string): void {
|
||||
const tokens = nmea.split(',');
|
||||
const sentence = tokens[0].substr(3, 3);
|
||||
|
||||
switch (sentence) {
|
||||
case 'GGA':
|
||||
state.lat = parseCoordinate(tokens[2]) * (tokens[3] === 'N' ? 1 : -1);
|
||||
state.lon = parseCoordinate(tokens[4]) * (tokens[5] === 'E' ? 1 : -1);
|
||||
state.alt = parseFloat(tokens[9]);
|
||||
break;
|
||||
case 'VTG':
|
||||
state.vel = parseFloat(tokens[7]) / 3.6;
|
||||
break;
|
||||
case 'GSA':
|
||||
state.fix = parseInt(tokens[2]);
|
||||
state.dop = parseFloat(tokens[15]);
|
||||
break;
|
||||
case 'GLL':
|
||||
state.gpsValid = state.fix === 3 && state.dop <= 5;
|
||||
updateGps(state);
|
||||
draw(state);
|
||||
if (state.gpsValid && state.status === ActivityStatus.Running) {
|
||||
updateLog(state);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function updateGps(state: AppState): void {
|
||||
const t = Date.now();
|
||||
const dt = (t - state.t) / 1000;
|
||||
|
||||
state.t = t;
|
||||
state.dt += dt;
|
||||
|
||||
if (state.status === ActivityStatus.Running) {
|
||||
state.duration += dt;
|
||||
}
|
||||
|
||||
if (!state.gpsValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const r = EARTH_RADIUS + state.alt;
|
||||
const x = r * Math.cos(state.lat) * Math.cos(state.lon);
|
||||
const y = r * Math.cos(state.lat) * Math.sin(state.lon);
|
||||
const z = r * Math.sin(state.lat);
|
||||
const v = state.vel;
|
||||
|
||||
if (!state.x) {
|
||||
state.x = x;
|
||||
state.y = y;
|
||||
state.z = z;
|
||||
state.v = v;
|
||||
state.pError = state.dop * POS_ACCURACY;
|
||||
state.vError = state.dop * VEL_ACCURACY;
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = x - state.x;
|
||||
const dy = y - state.y;
|
||||
const dz = z - state.z;
|
||||
const dv = v - state.v;
|
||||
const dpMag = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
const dvMag = Math.abs(dv);
|
||||
|
||||
state.pError += state.v * state.dt;
|
||||
state.dt = 0;
|
||||
|
||||
const pError = dpMag + state.dop * POS_ACCURACY;
|
||||
const vError = dvMag + state.dop * VEL_ACCURACY;
|
||||
|
||||
const pGain = state.pError / (state.pError + pError);
|
||||
const vGain = state.vError / (state.vError + vError);
|
||||
|
||||
state.x += dx * pGain;
|
||||
state.y += dy * pGain;
|
||||
state.z += dz * pGain;
|
||||
state.v += dv * vGain;
|
||||
state.pError += (pError - state.pError) * pGain;
|
||||
state.vError += (vError - state.vError) * vGain;
|
||||
|
||||
const pMag = Math.sqrt(state.x * state.x + state.y * state.y + state.z * state.z);
|
||||
|
||||
state.lat = Math.asin(state.z / pMag) * 180 / Math.PI;
|
||||
state.lon = (Math.atan2(state.y, state.x) * 180 / Math.PI) || 0;
|
||||
state.alt = pMag - EARTH_RADIUS;
|
||||
|
||||
if (state.status === ActivityStatus.Running) {
|
||||
state.distance += dpMag * pGain;
|
||||
state.speed = (state.distance / state.duration) || 0;
|
||||
state.cadence = (60 * state.steps / state.duration) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
export { initGps, parseCoordinate, parseNmea, updateGps };
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { AppState } from './state';
|
||||
|
||||
interface HrmData {
|
||||
bpm: number;
|
||||
confidence: number;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
declare var Bangle: any;
|
||||
|
||||
function initHrm(state: AppState) {
|
||||
Bangle.on('HRM', (hrm: HrmData) => updateHrm(state, hrm));
|
||||
Bangle.setHRMPower(1);
|
||||
}
|
||||
|
||||
function updateHrm(state: AppState, hrm: HrmData) {
|
||||
if (hrm.confidence === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dHr = hrm.bpm - state.hr;
|
||||
const hrError = Math.abs(dHr) + 101 - hrm.confidence;
|
||||
const hrGain = state.hrError / (state.hrError + hrError);
|
||||
|
||||
state.hr += dHr * hrGain;
|
||||
state.hrError += (hrError - state.hrError) * hrGain;
|
||||
}
|
||||
|
||||
export { initHrm, updateHrm };
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { AppState } from './state';
|
||||
|
||||
declare var require: any;
|
||||
|
||||
function initLog(state: AppState): void {
|
||||
const datetime = new Date().toISOString().replace(/[-:]/g, '');
|
||||
const date = datetime.substr(2, 6);
|
||||
const time = datetime.substr(9, 6);
|
||||
const filename = `banglerun_${date}_${time}`;
|
||||
state.file = require('Storage').open(filename, 'w');
|
||||
state.file.write([
|
||||
'timestamp',
|
||||
'latitude',
|
||||
'longitude',
|
||||
'altitude',
|
||||
'duration',
|
||||
'distance',
|
||||
'heartrate',
|
||||
'steps',
|
||||
].join(','));
|
||||
}
|
||||
|
||||
function updateLog(state: AppState): void {
|
||||
state.file.write('\n');
|
||||
state.file.write([
|
||||
Date.now().toFixed(0),
|
||||
state.lat.toFixed(6),
|
||||
state.lon.toFixed(6),
|
||||
state.alt.toFixed(2),
|
||||
state.duration.toFixed(0),
|
||||
state.distance.toFixed(2),
|
||||
state.hr.toFixed(0),
|
||||
state.steps.toFixed(0),
|
||||
].join(','));
|
||||
}
|
||||
|
||||
export { initLog, updateLog };
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
enum ActivityStatus {
|
||||
Stopped = 'STOP',
|
||||
Paused = 'PAUSE',
|
||||
Running = 'RUN',
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
// GPS NMEA data
|
||||
fix: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
alt: number;
|
||||
vel: number;
|
||||
dop: number;
|
||||
gpsValid: boolean;
|
||||
|
||||
// GPS Kalman data
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
v: number;
|
||||
t: number;
|
||||
dt: number;
|
||||
pError: number;
|
||||
vError: number;
|
||||
|
||||
// HRM data
|
||||
hr: number,
|
||||
hrError: number,
|
||||
|
||||
// Logger data
|
||||
file: File;
|
||||
|
||||
// Drawing data
|
||||
drawing: boolean;
|
||||
|
||||
// Activity data
|
||||
status: ActivityStatus;
|
||||
duration: number;
|
||||
distance: number;
|
||||
speed: number;
|
||||
steps: number;
|
||||
cadence: number;
|
||||
}
|
||||
|
||||
interface File {
|
||||
read: Function;
|
||||
write: Function;
|
||||
erase: Function;
|
||||
}
|
||||
|
||||
function initState(): AppState {
|
||||
return {
|
||||
fix: NaN,
|
||||
lat: NaN,
|
||||
lon: NaN,
|
||||
alt: NaN,
|
||||
vel: NaN,
|
||||
dop: NaN,
|
||||
gpsValid: false,
|
||||
|
||||
x: NaN,
|
||||
y: NaN,
|
||||
z: NaN,
|
||||
v: NaN,
|
||||
t: NaN,
|
||||
dt: NaN,
|
||||
pError: NaN,
|
||||
vError: NaN,
|
||||
|
||||
hr: 60,
|
||||
hrError: 100,
|
||||
|
||||
file: null,
|
||||
|
||||
drawing: false,
|
||||
|
||||
status: ActivityStatus.Stopped,
|
||||
duration: 0,
|
||||
distance: 0,
|
||||
speed: 0,
|
||||
steps: 0,
|
||||
cadence: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export { ActivityStatus, AppState, File, initState };
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { ActivityStatus, AppState } from './state';
|
||||
|
||||
declare var Bangle: any;
|
||||
|
||||
function initStep(state: AppState) {
|
||||
Bangle.on('step', () => updateStep(state));
|
||||
}
|
||||
|
||||
function updateStep(state: AppState) {
|
||||
if (state.status === ActivityStatus.Running) {
|
||||
state.steps += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export { initStep, updateStep };
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "es2015",
|
||||
"noImplicitAny": true,
|
||||
"target": "es2015"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"noImplicitAny": true,
|
||||
"target": "es2015"
|
||||
},
|
||||
"include": [
|
||||
"test"
|
||||
]
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
0.01: New App!
|
||||
0.02: Add SCREENACCESS interface
|
||||
0.03: Add Waypoint Editor
|
||||
|
||||
0.04: Fix great circle formula
|
||||
|
|
|
|||
|
|
@ -93,9 +93,8 @@ function bearing(a,b){
|
|||
}
|
||||
|
||||
function distance(a,b){
|
||||
var x = radians(a.lon-b.lon) * Math.cos(radians((a.lat+b.lat)/2));
|
||||
var y = radians(b.lat-a.lat);
|
||||
return Math.round(Math.sqrt(x*x + y*y) * 6371000);
|
||||
var dsigma = Math.acos(Math.sin(radians(a.lat))*Math.sin(radians(b.lat))+Math.cos(radians(a.lat))*Math.cos(radians(b.lat))*Math.cos(radians(a.lon-b.lon)));
|
||||
return Math.round(dsigma*6371000);
|
||||
}
|
||||
|
||||
var selected = false;
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
0.01: First try
|
||||
0.02: Update custom.html for refactor; add README
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
# World Clock - See the time in five locations
|
||||
|
||||
In addition to the main clock and date in your current location, you can add up to four other locations. Great for travel or remote working.
|
||||
|
||||

|
||||
|
||||
## Usage
|
||||
|
||||
Provide names and the UTC offsets for up to four other timezones in the app store. These are stored in a json file on your watch. UTC offsets can be decimal (e.g., 5.5 for India).
|
||||
|
||||
The clock does not handle summer time / daylight saving time changes automatically. If one of your four locations changes its UTC offset, you can simply change the setting in the app store and update. Currently the clock only supports 24 hour time format.
|
||||
|
||||
|
||||
## Requests
|
||||
|
||||
[Reach out to Scott](https://www.github.com/computermacgyver) if you have feature requests or notice bugs.
|
||||
|
||||
## Creator
|
||||
|
||||
Made by [Scott Hale](https://www.github.com/computermacgyver), based upon the [Simple Clock](https://github.com/espruino/BangleApps/tree/master/apps/sclock).
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
<p>Click <button id="upload" class="btn btn-primary">Upload</button></p>
|
||||
|
||||
<script src="../../lib/customize.js"></script>
|
||||
<script src="../../core/lib/customize.js"></script>
|
||||
|
||||
<script>
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Loading…
Reference in New Issue