1
0
Fork 0

add changes to runplus

changes to differentiate runplus from run app

fix default settings fallback for HRM

fixes to draw correctly and in a timely manner when swiping to the karvonnen ui depending on if we have hrm data or not

Update ChangeLog

small ui tweak

run: Keep run state between runs (allowing you to exit and restart the app)

tweak clearRect width to not clear the indicator segment

bump version

write to correct settings file

add tag karvonen

change spelling karvonnen -> karvonen in app.js, karvonen.js

spelling karvonnen -> karvonen in metadata

runplus - Fix typo in variable name preventing starting a run

tweak and align HRM min/max defaults, change allowed min/max interval in settings

bump version, fix typo

follow the preferred metadata.json style

Readd contributors to README.md

Tweak ChangeLog
master
thyttan 2023-02-22 22:25:56 +01:00
parent 6822d8ed70
commit d5f114762f
7 changed files with 352 additions and 42 deletions

View File

@ -14,4 +14,4 @@
0.13: Revert #1578 (stop duplicate entries) as with 2v12 menus it causes other boxes to be wiped (fix #1643)
0.14: Fix Bangle.js 1 issue where after the 'overwrite track' menu, the start/stop button stopped working
0.15: Keep run state between runs (allowing you to exit and restart the app)
0.16: Added ability to resume a run that was stopped previously (fix #1907)
0.16: Added ability to resume a run that was stopped previously (fix #1907)

View File

@ -14,4 +14,11 @@
0.13: Revert #1578 (stop duplicate entries) as with 2v12 menus it causes other boxes to be wiped (fix #1643)
0.14: Fix Bangle.js 1 issue where after the 'overwrite track' menu, the start/stop button stopped working
0.15: Keep run state between runs (allowing you to exit and restart the app)
0.16: Added ability to resume a run that was stopped previously (fix #1907)
0.16: Added ability to resume a run that was stopped previously (fix #1907)
0.17: Diverge from the standard "Run" app. Swipe to intensity interface a la Karvonen (curtesy of FTeacher at https://github.com/f-teacher)
0.18: Don't clear zone 2b indicator segment when updating HRM reading.
Write to correct settings file, fixing settings not working.
0.19: Fix typo in variable name preventing starting a run
0.20: Tweak HRM min/max defaults. Extend min/max intervals in settings. Fix
another typo.
0.21: Rebase on "Run" app ver. 0.16.

View File

@ -67,3 +67,10 @@ app loader, the module is automatically included in the app's source. However
when developing via the IDE the module won't get pulled in by default.
There are some options to fix this easily - please check out the [modules README.md file](https://github.com/espruino/BangleApps/blob/master/modules/README.md)
## Contributors (Run and Run+)
gfwilliams
hughbarney
GrandVizierOlaf
BartS23
f-teacher
thyttan

View File

@ -1,16 +1,23 @@
var ExStats = require("exstats");
var B2 = process.env.HWVERSION===2;
var Layout = require("Layout");
var locale = require("locale");
var fontHeading = "6x8:2";
var fontValue = B2 ? "6x15:2" : "6x8:3";
var headingCol = "#888";
var fixCount = 0;
var isMenuDisplayed = false;
// Use widget utils to show/hide widgets
let wu = require("widget_utils");
g.clear();
let runInterval;
let karvonenActive = false;
// Run interface wrapped in a function
let ExStats = require("exstats");
let B2 = process.env.HWVERSION===2;
let Layout = require("Layout");
let locale = require("locale");
let fontHeading = "6x8:2";
let fontValue = B2 ? "6x15:2" : "6x8:3";
let headingCol = "#888";
let fixCount = 0;
let isMenuDisplayed = false;
g.reset().clear();
Bangle.loadWidgets();
Bangle.drawWidgets();
wu.show();
// ---------------------------
let settings = Object.assign({
@ -36,9 +43,13 @@ let settings = Object.assign({
notifications: [],
},
},
}, require("Storage").readJSON("run.json", 1) || {});
var statIDs = [settings.B1,settings.B2,settings.B3,settings.B4,settings.B5,settings.B6].filter(s=>s!=="");
var exs = ExStats.getStats(statIDs, settings);
HRM: {
min: 55,
max: 185,
},
}, require("Storage").readJSON("runplus.json", 1) || {});
let statIDs = [settings.B1,settings.B2,settings.B3,settings.B4,settings.B5,settings.B6].filter(s=>s!=="");
let exs = ExStats.getStats(statIDs, settings);
// ---------------------------
function setStatus(running) {
@ -100,11 +111,11 @@ function onStartStop() {
});
}
var lc = [];
let lc = [];
// Load stats in pair by pair
for (var i=0;i<statIDs.length;i+=2) {
var sa = exs.stats[statIDs[i+0]];
var sb = exs.stats[statIDs[i+1]];
for (let i=0;i<statIDs.length;i+=2) {
let sa = exs.stats[statIDs[i+0]];
let sb = exs.stats[statIDs[i+1]];
lc.push({ type:"h", filly:1, c:[
sa?{type:"txt", font:fontHeading, label:sa.title.toUpperCase(), fillx:1, col:headingCol }:{},
sb?{type:"txt", font:fontHeading, label:sb.title.toUpperCase(), fillx:1, col:headingCol }:{}
@ -122,9 +133,9 @@ lc.push({ type:"h", filly:1, c:[
{type:"txt", font:fontHeading, label:"---", id:"status", fillx:1 }
]});
// Now calculate the layout
var layout = new Layout( {
let layout = new Layout( {
type:"v", c: lc
},{lazy:true, btns:[{ label:"---", cb: onStartStop, id:"button"}]});
},{lazy:true, btns:[{ label:"---", cb: (()=>{if (karvonenActive) {stopKarvonenUI();run();} onStartStop();}), id:"button"}]});
delete lc;
setStatus(exs.state.active);
layout.render();
@ -132,16 +143,16 @@ layout.render();
function configureNotification(stat) {
stat.on('notify', (e)=>{
settings.notify[e.id].notifications.reduce(function (promise, buzzPattern) {
return promise.then(function () {
return Bangle.buzz(buzzPattern[0], buzzPattern[1]);
});
return promise.then(function () {
return Bangle.buzz(buzzPattern[0], buzzPattern[1]);
});
}, Promise.resolve());
});
}
Object.keys(settings.notify).forEach((statType) => {
if (settings.notify[statType].increment > 0 && exs.stats[statType]) {
configureNotification(exs.stats[statType]);
configureNotification(exs.stats[statType]);
}
});
@ -153,8 +164,46 @@ Bangle.on("GPS", function(fix) {
Bangle.buzz(); // first fix, does not need to respect quiet mode
}
});
// We always call ourselves once a second to update
setInterval(function() {
layout.clock.label = locale.time(new Date(),1);
if (!isMenuDisplayed) layout.render();
}, 1000);
// run() function used to switch between traditional run UI and karvonen UI
function run() {
wu.show();
layout.lazy = false;
layout.render();
layout.lazy = true;
// We always call ourselves once a second to update
if (!runInterval){
runInterval = setInterval(function() {
layout.clock.label = locale.time(new Date(),1);
if (!isMenuDisplayed && !karvonenActive) layout.render();
}, 1000);
}
}
run();
///////////////////////////////////////////////
// Karvonen
///////////////////////////////////////////////
function stopRunUI() {
// stop updating and drawing the traditional run app UI
clearInterval(runInterval);
runInterval = undefined;
karvonenActive = true;
}
function stopKarvonenUI() {
g.reset().clear();
clearInterval(karvonenInterval);
karvonenInterval = undefined;
karvonenActive = false;
}
let karvonenInterval;
// Define the function to go back and forth between the different UI's
function swipeHandler(LR,_) {
if (LR==-1 && karvonenActive && !isMenuDisplayed) {stopKarvonenUI(); run();}
if (LR==1 && !karvonenActive && !isMenuDisplayed) {stopRunUI(); karvonenInterval = eval(require("Storage").read("runplus_karvonen"))(settings.HRM, exs.stats.bpm);}
}
// Listen for swipes with the swipeHandler
Bangle.on("swipe", swipeHandler);

215
apps/runplus/karvonen.js Normal file
View File

@ -0,0 +1,215 @@
(function karvonen(hrmSettings, exsHrmStats) {
//This app is an extra feature implementation for the Run.app of the bangle.js. It's called run+
//The calculation of the Heart Rate Zones is based on the Karvonen method. It requires to know maximum and minimum heart rates. More precise calculation methods require a lab.
//Other methods are even more approximative.
let wu = require("widget_utils");
wu.hide();
let R = Bangle.appRect;
g.reset().clearRect(R).setFontAlign(0,0,0);
const x = "x"; const y = "y";
function Rdiv(axis, divisor) { // Used when placing things on the screen
return axis=="x" ? (R.x + (R.w-1)/divisor):(R.y + (R.h-1)/divisor);
}
let linePoints = { //Not lists of points, but used to update points in the drawArrows function.
x: [
175/40,
2,
175/135,
],
y: [
175/64,
175/52,
175/110,
175/122,
],
};
function drawArrows() {
g.setColor(g.theme.fg);
// Upper
g.drawLine(Rdiv(x,linePoints.x[0]), Rdiv(y,linePoints.y[0]), Rdiv(x,linePoints.x[1]), Rdiv(y,linePoints.y[1]));
g.drawLine(Rdiv(x,linePoints.x[1]), Rdiv(y,linePoints.y[1]), Rdiv(x,linePoints.x[2]), Rdiv(y,linePoints.y[0]));
// Lower
g.drawLine(Rdiv(x,linePoints.x[0]), Rdiv(y,linePoints.y[2]), Rdiv(x,linePoints.x[1]), Rdiv(y,linePoints.y[3]));
g.drawLine(Rdiv(x,linePoints.x[1]), Rdiv(y,linePoints.y[3]), Rdiv(x,linePoints.x[2]), Rdiv(y,linePoints.y[2]));
}
//To calculate Heart rate zones, we need to know the heart rate reserve (HRR)
// HRR = maximum HR - Minimum HR. minhr is minimum hr, maxhr is maximum hr.
//get the hrr (heart rate reserve).
// I put random data here, but this has to come as a menu in the settings section so that users can change it.
let minhr = hrmSettings.min;
let maxhr = hrmSettings.max;
function calculatehrr(minhr, maxhr) {
return maxhr - minhr;}
//test input for hrr (it works).
let hrr = calculatehrr(minhr, maxhr);
console.log(hrr);
//Test input to verify the zones work. The following value for "hr" has to be deleted and replaced with the Heart Rate Monitor input.
let hr = exsHrmStats.getValue();
let hr1 = hr;
// These letiables display next and previous HR zone.
//get the hrzones right. The calculation of the Heart rate zones here is based on the Karvonen method
//60-70% of HRR+minHR = zone2. //70-80% of HRR+minHR = zone3. //80-90% of HRR+minHR = zone4. //90-99% of HRR+minHR = zone5. //=>99% of HRR+minHR = serious risk of heart attack
let minzone2 = hrr * 0.6 + minhr;
let maxzone2 = hrr * 0.7 + minhr;
let maxzone3 = hrr * 0.8 + minhr;
let maxzone4 = hrr * 0.9 + minhr;
let maxzone5 = hrr * 0.99 + minhr;
// HR data: large, readable, in the middle of the screen
function drawHR() {
g.setFontAlign(-1,0,0);
g.clearRect(Rdiv(x,11/4),Rdiv(y,2)-25,Rdiv(x,11/4)+50*2-14,Rdiv(y,2)+25);
g.setColor(g.theme.fg);
g.setFont("Vector",50);
g.drawString(hr, Rdiv(x,11/4), Rdiv(y,2)+4);
}
function drawWaitHR() {
g.setColor(g.theme.fg);
// Waiting for HRM
g.setFontAlign(0,0,0);
g.setFont("Vector",50);
g.drawString("--", Rdiv(x,2)+4, Rdiv(y,2)+4);
// Waiting for current Zone
g.setFont("Vector",24);
g.drawString("Z-", Rdiv(x,4.3)-3, Rdiv(y,2)+2);
// waiting for upper and lower limit of current zone
g.setFont("Vector",20);
g.drawString("--", Rdiv(x,2)+2, Rdiv(y,9/2));
g.drawString("--", Rdiv(x,2)+2, Rdiv(y,9/7));
}
//These functions call arcs to show different HR zones.
//To shorten the code, I'll reference some letiables and reuse them.
let centreX = R.x + 0.5 * R.w;
let centreY = R.y + 0.5 * R.h;
let minRadius = 0.38 * R.h;
let maxRadius = 0.50 * R.h;
//draw background image (dithered green zones)(I should draw different zones in different dithered colors)
const HRzones= require("graphics_utils");
let minRadiusz = 0.44 * R.h;
let startAngle = HRzones.degreesToRadians(-88.5);
let endAngle = HRzones.degreesToRadians(268.5);
function drawBgArc() {
g.setColor(g.theme.dark==false?0xC618:"#002200");
HRzones.fillArc(g, centreX, centreY, minRadiusz, maxRadius, startAngle, endAngle);
}
const zones = require("graphics_utils");
//####### A function to simplify a bit the code ######
function simplify (sA, eA, Z, currentZone, lastZone) {
let startAngle = zones.degreesToRadians(sA);
let endAngle = zones.degreesToRadians(eA);
if (currentZone == lastZone) zones.fillArc(g, centreX, centreY, minRadius, maxRadius, startAngle, endAngle);
else zones.fillArc(g, centreX, centreY, minRadiusz, maxRadius, startAngle, endAngle);
g.setFont("Vector",24);
g.clearRect(Rdiv(x,4.3)-12, Rdiv(y,2)+2-12,Rdiv(x,4.3)+12, Rdiv(y,2)+2+12);
g.setFontAlign(0,0,0);
g.drawString(Z, Rdiv(x,4.3), Rdiv(y,2)+2);
}
function zoning (max, min) { // draw values of upper and lower limit of current zone
g.setFont("Vector",20);
g.setColor(g.theme.fg);
g.clearRect(Rdiv(x,2)-20*2, Rdiv(y,9/2)-10,Rdiv(x,2)+20*2, Rdiv(y,9/2)+10);
g.clearRect(Rdiv(x,2)-20*2, Rdiv(y,9/7)-10,Rdiv(x,2)+20*2, Rdiv(y,9/7)+10);
g.setFontAlign(0,0,0);
g.drawString(max, Rdiv(x,2), Rdiv(y,9/2));
g.drawString(min, Rdiv(x,2), Rdiv(y,9/7));
}
function clearCurrentZone() { // Clears the extension of the current zone by painting the extension area in background color
g.setColor(g.theme.bg);
HRzones.fillArc(g, centreX, centreY, minRadius-1, minRadiusz, startAngle, endAngle);
}
function getZone(zone) {
drawBgArc();
clearCurrentZone();
if (zone >= 0) {zoning(minzone2, minhr);g.setColor("#00ffff");simplify(-88.5, -45, "Z1", 0, zone);}
if (zone >= 1) {zoning(maxzone2, minzone2);g.setColor("#00ff00");simplify(-43.5, -21.5, "Z2", 1, zone);}
if (zone >= 2) {zoning(maxzone2, minzone2);g.setColor("#00ff00");simplify(-20, 1.5, "Z2", 2, zone);}
if (zone >= 3) {zoning(maxzone2, minzone2);g.setColor("#00ff00");simplify(3, 24, "Z2", 3, zone);}
if (zone >= 4) {zoning(maxzone3, maxzone2);g.setColor("#ffff00");simplify(25.5, 46.5, "Z3", 4, zone);}
if (zone >= 5) {zoning(maxzone3, maxzone2);g.setColor("#ffff00");simplify(48, 69, "Z3", 5, zone);}
if (zone >= 6) {zoning(maxzone3, maxzone2);g.setColor("#ffff00");simplify(70.5, 91.5, "Z3", 6, zone);}
if (zone >= 7) {zoning(maxzone4, maxzone3);g.setColor("#ff8000");simplify(93, 114.5, "Z4", 7, zone);}
if (zone >= 8) {zoning(maxzone4, maxzone3);g.setColor("#ff8000");simplify(116, 137.5, "Z4", 8, zone);}
if (zone >= 9) {zoning(maxzone4, maxzone3);g.setColor("#ff8000");simplify(139, 160, "Z4", 9, zone);}
if (zone >= 10) {zoning(maxzone5, maxzone4);g.setColor("#ff0000");simplify(161.5, 182.5, "Z5", 10, zone);}
if (zone >= 11) {zoning(maxzone5, maxzone4);g.setColor("#ff0000");simplify(184, 205, "Z5", 11, zone);}
if (zone == 12) {zoning(maxzone5, maxzone4);g.setColor("#ff0000");simplify(206.5, 227.5, "Z5", 12, zone);}
}
function getZoneAlert() {
const HRzonemax = require("graphics_utils");
let centreX1,centreY1,maxRadius1 = 1;
let minRadius = 0.40 * R.h;
let startAngle1 = HRzonemax.degreesToRadians(-90);
let endAngle1 = HRzonemax.degreesToRadians(270);
g.setFont("Vector",38);g.setColor("#ff0000");
HRzonemax.fillArc(g, centreX, centreY, minRadius, maxRadius, startAngle1, endAngle1);
g.drawString("ALERT", 26,66);
}
//Subdivided zones for better readability of zones when calling the images. //Changing HR zones will trigger the function with the image and previous&next HR zones.
let subZoneLast;
function drawZones() {
if ((hr < maxhr - 2) && subZoneLast==13) {g.clear(); drawArrows(); drawHR();} // Reset UI when coming down from zone alert.
if (hr <= hrr * 0.6 + minhr) {if (subZoneLast!=0) {subZoneLast=0; getZone(subZoneLast);}} // Z1
else if (hr <= hrr * 0.64 + minhr) {if (subZoneLast!=1) {subZoneLast=1; getZone(subZoneLast);}} // Z2a
else if (hr <= hrr * 0.67 + minhr) {if (subZoneLast!=2) {subZoneLast=2; getZone(subZoneLast);}} // Z2b
else if (hr <= hrr * 0.70 + minhr) {if (subZoneLast!=3) {subZoneLast=3; getZone(subZoneLast);}} // Z2c
else if (hr <= hrr * 0.74 + minhr) {if (subZoneLast!=4) {subZoneLast=4; getZone(subZoneLast);}} // Z3a
else if (hr <= hrr * 0.77 + minhr) {if (subZoneLast!=5) {subZoneLast=5; getZone(subZoneLast);}} // Z3b
else if (hr <= hrr * 0.80 + minhr) {if (subZoneLast!=6) {subZoneLast=6; getZone(subZoneLast);}} // Z3c
else if (hr <= hrr * 0.84 + minhr) {if (subZoneLast!=7) {subZoneLast=7; getZone(subZoneLast);}} // Z4a
else if (hr <= hrr * 0.87 + minhr) {if (subZoneLast!=8) {subZoneLast=8; getZone(subZoneLast);}} // Z4b
else if (hr <= hrr * 0.90 + minhr) {if (subZoneLast!=9) {subZoneLast=9; getZone(subZoneLast);}} // Z4c
else if (hr <= hrr * 0.94 + minhr) {if (subZoneLast!=10) {subZoneLast=10; getZone(subZoneLast);}} // Z5a
else if (hr <= hrr * 0.96 + minhr) {if (subZoneLast!=11) {subZoneLast=11; getZone(subZoneLast);}} // Z5b
else if (hr <= hrr * 0.98 + minhr) {if (subZoneLast!=12) {subZoneLast=12; getZone(subZoneLast);}} // Z5c
else if (hr >= maxhr - 2) {subZoneLast=13; g.clear();getZoneAlert();} // Alert
}
function initDraw() {
drawArrows();
if (hr!=0) updateUI(true); else {drawWaitHR(); drawBgArc();}
//drawZones();
}
let hrLast;
//h = 0; // Used to force hr update via web ui console field to trigger draws, together with `if (h!=0) hr = h;` below.
function updateUI(resetHrLast) { // Update UI, only draw if warranted by change in HR.
hrLast = resetHrLast?0:hr; // Handles correct updating on init depending on if we've got HRM readings yet or not.
hr = exsHrmStats.getValue();
//if (h!=0) hr = h;
if (hr!=hrLast) {
drawHR();
drawZones();
} //g.setColor(g.theme.fg).drawLine(175/2,0,175/2,175).drawLine(0,175/2,175,175/2); // Used to align UI elements.
}
initDraw();
// check for updates every second.
karvonenInterval = setInterval(function() {
if (!isMenuDisplayed && karvonenActive) updateUI();
}, 1000);
return karvonenInterval;
})

View File

@ -1,16 +1,20 @@
{ "id": "run",
"name": "Run",
"version":"0.16",
"description": "Displays distance, time, steps, cadence, pace and more for runners.",
{
"id": "runplus",
"name": "Run+",
"version": "0.21",
"description": "Displays distance, time, steps, cadence, pace and more for runners. Based on the Run app, but extended with additional screen for heart rate interval training.",
"icon": "app.png",
"tags": "run,running,fitness,outdoors,gps",
"supports" : ["BANGLEJS","BANGLEJS2"],
"screenshots": [{"url":"screenshot.png"}],
"tags": "run,running,fitness,outdoors,gps,karvonen,karvonnen",
"supports": ["BANGLEJS2"],
"screenshots": [{"url": "screenshot.png"}],
"readme": "README.md",
"storage": [
{"name":"run.app.js","url":"app.js"},
{"name":"run.img","url":"app-icon.js","evaluate":true},
{"name":"run.settings.js","url":"settings.js"}
{"name": "runplus.app.js", "url": "app.js"},
{"name": "runplus.img", "url": "app-icon.js", "evaluate": true},
{"name": "runplus.settings.js", "url": "settings.js"},
{"name": "runplus_karvonen", "url": "karvonen.js"}
],
"data": [{"name":"run.json"}]
"data": [
{"name": "runplus.json"}
]
}

View File

@ -1,5 +1,5 @@
(function(back) {
const SETTINGS_FILE = "run.json";
const SETTINGS_FILE = "runplus.json";
var ExStats = require("exstats");
var statsList = ExStats.getList();
statsList.unshift({name:"-",id:""}); // add blank menu item
@ -31,6 +31,10 @@
notifications: [],
},
},
HRM: {
min: 55,
max: 185,
},
}, storage.readJSON(SETTINGS_FILE, 1) || {});
function saveSettings() {
storage.write(SETTINGS_FILE, settings)
@ -125,5 +129,29 @@
'Box 6': getBoxChooser("B6"),
});
menu[/*LANG*/"Boxes"] = function() { E.showMenu(boxMenu)};
var hrmMenu = {
'< Back': function() { E.showMenu(menu) },
}
menu[/*LANG*/"HRM min/max"] = function() { E.showMenu(hrmMenu)};
hrmMenu[/*LANG*/"min"] = {
min: 1, max: 100,
value: settings.HRM.min,
format: w => w,
onchange: w => {
settings.HRM.min = w;
saveSettings();
},
}
hrmMenu[/*LANG*/"max"] = {
min: 101, max: 220,
value: settings.HRM.max,
format: v => v,
onchange: v => {
settings.HRM.max = v;
saveSettings();
},
}
E.showMenu(menu);
})