mirror of https://github.com/espruino/BangleApps
move changes to run to new app runplus
parent
9824460add
commit
026b266686
|
@ -0,0 +1,17 @@
|
|||
0.01: New App!
|
||||
0.02: Set pace format to mm:ss, time format to h:mm:ss,
|
||||
added settings to opt out of GPS and HRM
|
||||
0.03: Fixed distance calculation, tested against Garmin Etrex, Amazfit GTS 2
|
||||
0.04: Use the exstats module, and make what is displayed configurable
|
||||
0.05: exstats updated so update 'distance' label is updated, option for 'speed'
|
||||
0.06: Add option to record a run using the recorder app automatically
|
||||
0.07: Fix crash if an odd number of active boxes are configured (fix #1473)
|
||||
0.08: Added support for notifications from exstats. Support all stats from exstats
|
||||
0.09: Fix broken start/stop if recording not enabled (fix #1561)
|
||||
0.10: Don't allow the same setting to be chosen for 2 boxes (fix #1578)
|
||||
0.11: Notifications fixes
|
||||
0.12: Fix for recorder not stopping at end of run. Bug introduced in 0.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: (beta) Swipe to intensity interface a la Karvonnen (curtesy of
|
||||
FTeacher at https://github.com/f-teacher)
|
|
@ -0,0 +1,69 @@
|
|||
# Run App
|
||||
|
||||
This app allows you to display the status of your run, it
|
||||
shows distance, time, steps, cadence, pace and more.
|
||||
|
||||
To use it, start the app and press the middle button so that
|
||||
the red `STOP` in the bottom right turns to a green `RUN`.
|
||||
|
||||
## Display
|
||||
|
||||
* `DIST` - the distance travelled based on the GPS (if you have a GPS lock).
|
||||
* NOTE: this is based on the GPS coordinates which are not 100% accurate, especially initially. As
|
||||
the GPS updates your position as it gets more satellites your position changes and the distance
|
||||
shown will increase, even if you are standing still.
|
||||
* `TIME` - the elapsed time for your run
|
||||
* `PACE` - the number of minutes it takes you to run a given distance, configured in settings (default 1km) **based on your run so far**
|
||||
* `HEART (BPM)` - Your current heart rate
|
||||
* `Max BPM` - Your maximum heart rate reached during the run
|
||||
* `STEPS` - Steps since you started exercising
|
||||
* `CADENCE` - Steps per second based on your step rate *over the last minute*
|
||||
* `GPS` - this is green if you have a GPS lock. GPS is turned on automatically
|
||||
so if you have no GPS lock you just need to wait.
|
||||
* The current time is displayed right at the bottom of the screen
|
||||
* `RUN/STOP` - whether the distance for your run is being displayed or not
|
||||
|
||||
## Recording Tracks
|
||||
|
||||
When the `Recorder` app is installed, `Run` will automatically start and stop tracks
|
||||
as needed, prompting you to overwrite or begin a new track if necessary.
|
||||
|
||||
## Settings
|
||||
|
||||
Under `Settings` -> `App` -> `Run` you can change settings for this app.
|
||||
|
||||
* `Record Run` (only displayed if `Recorder` app installed) should the Run app automatically
|
||||
record GPS/HRM/etc data every time you start a run?
|
||||
* `Pace` is the distance that pace should be shown over - 1km, 1 mile, 1/2 Marathon or 1 Marathon
|
||||
* `Boxes` leads to a submenu where you can configure what is shown in each of the 6 boxes on the display.
|
||||
Available stats are "Time", "Distance", "Steps", "Heart (BPM)", "Max BPM", "Pace (avg)", "Pace (curr)", "Speed", and "Cadence".
|
||||
Any box set to "-" will display no information.
|
||||
* Box 1 is the top left (defaults to "Distance")
|
||||
* Box 2 is the top right (defaults to "Time")
|
||||
* Box 3 is the middle left (defaults to "Pace (avg)")
|
||||
* Box 4 is the middle right (defaults to "Heart (BPM)")
|
||||
* Box 5 is the bottom left (defaults to "Steps")
|
||||
* Box 6 is the bottom right (defaults to "Cadence")
|
||||
* `Notifications` leads to a submenu where you can configure if the app will notify you after
|
||||
your distance, steps, or time repeatedly pass your configured thresholds
|
||||
* `Ntfy Dist`: The distance that you must pass before you are notified. Follows the `Pace` options
|
||||
* "Off" (default), "1km", "1 mile", "1/2 Marathon", "1 Marathon"
|
||||
* `Ntfy Steps`: The number of steps that must pass before you are notified.
|
||||
* "Off" (default), 100, 500, 1000, 5000, 10000
|
||||
* `Ntfy Time`: The amount of time that must pass before you are notified.
|
||||
* "Off" (default), "30 sec", "1 min", "2 min", "5 min", "10 min", "30 min", "1 hour"
|
||||
* `Dist Pattern`: The vibration pattern to use to notify you about meeting your distance threshold
|
||||
* `Step Pattern`: The vibration pattern to use to notify you about meeting your step threshold
|
||||
* `Time Pattern`: The vibration pattern to use to notify you about meeting your time threshold
|
||||
|
||||
## TODO
|
||||
|
||||
* Keep a log of each run's stats (distance/steps/etc)
|
||||
|
||||
## Development
|
||||
|
||||
This app uses the [`exstats` module](https://github.com/espruino/BangleApps/blob/master/modules/exstats.js). When uploaded via the
|
||||
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)
|
|
@ -0,0 +1 @@
|
|||
require("heatshrink").decompress(atob("mEw4UA///pH9vEFt9TIW0FqALJitUBZNVqoLqgo4BHZAUBtBTHgILB1XAEREV1WsEQ9AgWq1ALHgEO1WtBYxCBhWq0pdInWq2tABY8q1WVBZGq1XFBZS/IKQRvCDIsP9WsBZP60CTCBYs//+wLxALBTQ4AB///+AKHgYLB/gLK/4LHh//AIIwFitVr/8DIIwFLANXBAILIqogBn7DBEYrXBeQRgIBYKmHDgYLLZRBACBZYKJZIILKKRZeWgJGKAFQA=="))
|
|
@ -0,0 +1,194 @@
|
|||
// Use widget utils to show/hide widgets
|
||||
let wu = require("widget_utils");
|
||||
|
||||
let runInterval;
|
||||
let karvonnenActive = 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({
|
||||
record: true,
|
||||
B1: "dist",
|
||||
B2: "time",
|
||||
B3: "pacea",
|
||||
B4: "bpm",
|
||||
B5: "step",
|
||||
B6: "caden",
|
||||
paceLength: 1000,
|
||||
notify: {
|
||||
dist: {
|
||||
value: 0,
|
||||
notifications: [],
|
||||
},
|
||||
step: {
|
||||
value: 0,
|
||||
notifications: [],
|
||||
},
|
||||
time: {
|
||||
value: 0,
|
||||
notifications: [],
|
||||
},
|
||||
HRM: {
|
||||
min: 65,
|
||||
max: 170,
|
||||
}
|
||||
},
|
||||
}, require("Storage").readJSON("run.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);
|
||||
// ---------------------------
|
||||
|
||||
// Called to start/stop running
|
||||
function onStartStop() {
|
||||
let running = !exs.state.active;
|
||||
let prepPromises = [];
|
||||
|
||||
// start/stop recording
|
||||
// Do this first in case recorder needs to prompt for
|
||||
// an overwrite before we start tracking exstats
|
||||
if (settings.record && WIDGETS["recorder"]) {
|
||||
if (running) {
|
||||
isMenuDisplayed = true;
|
||||
prepPromises.push(
|
||||
WIDGETS["recorder"].setRecording(true).then(() => {
|
||||
isMenuDisplayed = false;
|
||||
layout.setUI(); // grab our input handling again
|
||||
layout.forgetLazyState();
|
||||
layout.render();
|
||||
})
|
||||
);
|
||||
} else {
|
||||
prepPromises.push(
|
||||
WIDGETS["recorder"].setRecording(false)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!prepPromises.length) // fix for Promise.all bug in 2v12
|
||||
prepPromises.push(Promise.resolve());
|
||||
|
||||
Promise.all(prepPromises)
|
||||
.then(() => {
|
||||
if (running) {
|
||||
exs.start();
|
||||
} else {
|
||||
exs.stop();
|
||||
}
|
||||
layout.button.label = running ? "STOP" : "START";
|
||||
layout.status.label = running ? "RUN" : "STOP";
|
||||
layout.status.bgCol = running ? "#0f0" : "#f00";
|
||||
// if stopping running, don't clear state
|
||||
// so we can at least refer to what we've done
|
||||
layout.render();
|
||||
});
|
||||
}
|
||||
|
||||
let lc = [];
|
||||
// Load stats in pair by pair
|
||||
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 }:{}
|
||||
]}, { type:"h", filly:1, c:[
|
||||
sa?{type:"txt", font:fontValue, label:sa.getString(), id:sa.id, fillx:1 }:{},
|
||||
sb?{type:"txt", font:fontValue, label:sb.getString(), id:sb.id, fillx:1 }:{}
|
||||
]});
|
||||
if (sa) sa.on('changed', e=>layout[e.id].label = e.getString());
|
||||
if (sb) sb.on('changed', e=>layout[e.id].label = e.getString());
|
||||
}
|
||||
// At the bottom put time/GPS state/etc
|
||||
lc.push({ type:"h", filly:1, c:[
|
||||
{type:"txt", font:fontHeading, label:"GPS", id:"gps", fillx:1, bgCol:"#f00" },
|
||||
{type:"txt", font:fontHeading, label:"00:00", id:"clock", fillx:1, bgCol:g.theme.fg, col:g.theme.bg },
|
||||
{type:"txt", font:fontHeading, label:"STOP", id:"status", fillx:1 }
|
||||
]});
|
||||
// Now calculate the layout
|
||||
let layout = new Layout( {
|
||||
type:"v", c: lc
|
||||
},{lazy:true, btns:[{ label:"START", cb: ()=>{if (karvonnenActive) {stopKarvonnenUI();run();} onStartStop();}, id:"button"}]});
|
||||
delete lc;
|
||||
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]);
|
||||
});
|
||||
}, Promise.resolve());
|
||||
});
|
||||
}
|
||||
|
||||
Object.keys(settings.notify).forEach((statType) => {
|
||||
if (settings.notify[statType].increment > 0 && exs.stats[statType]) {
|
||||
configureNotification(exs.stats[statType]);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle GPS state change for icon
|
||||
Bangle.on("GPS", function(fix) {
|
||||
layout.gps.bgCol = fix.fix ? "#0f0" : "#f00";
|
||||
if (!fix.fix) return; // only process actual fixes
|
||||
if (fixCount++ === 0) {
|
||||
Bangle.buzz(); // first fix, does not need to respect quiet mode
|
||||
}
|
||||
});
|
||||
|
||||
// run() function used to switch between traditional run UI and karvonnen 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 && !karvonnenActive) layout.render();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
run();
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// Karvonnen
|
||||
///////////////////////////////////////////////
|
||||
|
||||
function stopRunUI() {
|
||||
// stop updating and drawing the traditional run app UI
|
||||
clearInterval(runInterval);
|
||||
runInterval = undefined;
|
||||
karvonnenActive = true;
|
||||
}
|
||||
|
||||
function stopKarvonnenUI() {
|
||||
g.reset().clear();
|
||||
clearInterval(karvonnenInterval);
|
||||
karvonnenInterval = undefined;
|
||||
karvonnenActive = false;
|
||||
}
|
||||
|
||||
let karvonnenInterval;
|
||||
// Define the function to go back and forth between the different UI's
|
||||
function swipeHandler(LR,_) {
|
||||
if (LR==-1 && karvonnenActive && !isMenuDisplayed) {stopKarvonnenUI(); run();}
|
||||
if (LR==1 && !karvonnenActive && !isMenuDisplayed) {stopRunUI(); karvonnenInterval = eval(require("Storage").read("run_karvonnen"))(settings.HRM, exs.stats.bpm);}
|
||||
}
|
||||
// Listen for swipes with the swipeHandler
|
||||
Bangle.on("swipe", swipeHandler);
|
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
|
@ -0,0 +1,216 @@
|
|||
(function karvonnen(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 Karvonnen 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 Karvonnen 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,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), 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();
|
||||
drawWaitHR();
|
||||
drawBgArc();
|
||||
//drawZones();
|
||||
}
|
||||
|
||||
let hrLast;
|
||||
//h = 0; // Used to force hr update to trigger draws, together with `if (h!=0) hr = h;` below.
|
||||
function updateUI() { // Update UI, only draw if warranted by change in HR.
|
||||
hrLast = hr;
|
||||
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.
|
||||
karvonnenInterval = setInterval(function() {
|
||||
if (!isMenuDisplayed && karvonnenActive) updateUI();
|
||||
}, 1000);
|
||||
|
||||
return karvonnenInterval;
|
||||
})
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"id": "run",
|
||||
"name": "Run",
|
||||
"version": "0.15",
|
||||
"description": "Displays distance, time, steps, cadence, pace and more for runners.",
|
||||
"icon": "app.png",
|
||||
"tags": "run,running,fitness,outdoors,gps",
|
||||
"supports": [
|
||||
"BANGLEJS",
|
||||
"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": "run_karvonnen",
|
||||
"url": "karvonnen.js"
|
||||
}
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"name": "run.json"
|
||||
}
|
||||
]
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 3.6 KiB |
|
@ -0,0 +1,157 @@
|
|||
(function(back) {
|
||||
const SETTINGS_FILE = "run.json";
|
||||
var ExStats = require("exstats");
|
||||
var statsList = ExStats.getList();
|
||||
statsList.unshift({name:"-",id:""}); // add blank menu item
|
||||
var statsIDs = statsList.map(s=>s.id);
|
||||
|
||||
// ...and overwrite them with any saved values
|
||||
// This way saved values are preserved if a new version adds more settings
|
||||
const storage = require('Storage')
|
||||
let settings = Object.assign({
|
||||
record: true,
|
||||
B1: "dist",
|
||||
B2: "time",
|
||||
B3: "pacea",
|
||||
B4: "bpm",
|
||||
B5: "step",
|
||||
B6: "caden",
|
||||
paceLength: 1000, // TODO: Default to either 1km or 1mi based on locale
|
||||
notify: {
|
||||
dist: {
|
||||
increment: 0,
|
||||
notifications: [],
|
||||
},
|
||||
step: {
|
||||
increment: 0,
|
||||
notifications: [],
|
||||
},
|
||||
time: {
|
||||
increment: 0,
|
||||
notifications: [],
|
||||
},
|
||||
},
|
||||
HRM: {
|
||||
min: 65,
|
||||
max: 165,
|
||||
},
|
||||
}, storage.readJSON(SETTINGS_FILE, 1) || {});
|
||||
function saveSettings() {
|
||||
storage.write(SETTINGS_FILE, settings)
|
||||
}
|
||||
|
||||
function getBoxChooser(boxID) {
|
||||
return {
|
||||
min: 0, max: statsIDs.length-1,
|
||||
value: Math.max(statsIDs.indexOf(settings[boxID]),0),
|
||||
format: v => statsList[v].name,
|
||||
onchange: v => {
|
||||
settings[boxID] = statsIDs[v];
|
||||
saveSettings();
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function sampleBuzz(buzzPatterns) {
|
||||
return buzzPatterns.reduce(function (promise, buzzPattern) {
|
||||
return promise.then(function () {
|
||||
return Bangle.buzz(buzzPattern[0], buzzPattern[1]);
|
||||
});
|
||||
}, Promise.resolve());
|
||||
}
|
||||
|
||||
var menu = {
|
||||
'': { 'title': 'Run' },
|
||||
'< Back': back,
|
||||
};
|
||||
if (global.WIDGETS&&WIDGETS["recorder"])
|
||||
menu[/*LANG*/"Record Run"] = {
|
||||
value : !!settings.record,
|
||||
onchange : v => {
|
||||
settings.record = v;
|
||||
saveSettings();
|
||||
}
|
||||
};
|
||||
var notificationsMenu = {
|
||||
'< Back': function() { E.showMenu(menu) },
|
||||
}
|
||||
menu[/*LANG*/"Notifications"] = function() { E.showMenu(notificationsMenu)};
|
||||
ExStats.appendMenuItems(menu, settings, saveSettings);
|
||||
ExStats.appendNotifyMenuItems(notificationsMenu, settings, saveSettings);
|
||||
var vibPatterns = [/*LANG*/"Off", ".", "-", "--", "-.-", "---"];
|
||||
var vibTimes = [
|
||||
[],
|
||||
[[100, 1]],
|
||||
[[300, 1]],
|
||||
[[300, 1], [300, 0], [300, 1]],
|
||||
[[300, 1],[300, 0], [100, 1], [300, 0], [300, 1]],
|
||||
[[300, 1],[300, 0],[300, 1],[300, 0],[300, 1]],
|
||||
];
|
||||
notificationsMenu[/*LANG*/"Dist Pattern"] = {
|
||||
value: Math.max(0,vibTimes.findIndex((p) => JSON.stringify(p) === JSON.stringify(settings.notify.dist.notifications))),
|
||||
min: 0, max: vibTimes.length - 1,
|
||||
format: v => vibPatterns[v]||/*LANG*/"Off",
|
||||
onchange: v => {
|
||||
settings.notify.dist.notifications = vibTimes[v];
|
||||
sampleBuzz(vibTimes[v]);
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
notificationsMenu[/*LANG*/"Step Pattern"] = {
|
||||
value: Math.max(0,vibTimes.findIndex((p) => JSON.stringify(p) === JSON.stringify(settings.notify.step.notifications))),
|
||||
min: 0, max: vibTimes.length - 1,
|
||||
format: v => vibPatterns[v]||/*LANG*/"Off",
|
||||
onchange: v => {
|
||||
settings.notify.step.notifications = vibTimes[v];
|
||||
sampleBuzz(vibTimes[v]);
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
notificationsMenu[/*LANG*/"Time Pattern"] = {
|
||||
value: Math.max(0,vibTimes.findIndex((p) => JSON.stringify(p) === JSON.stringify(settings.notify.time.notifications))),
|
||||
min: 0, max: vibTimes.length - 1,
|
||||
format: v => vibPatterns[v]||/*LANG*/"Off",
|
||||
onchange: v => {
|
||||
settings.notify.time.notifications = vibTimes[v];
|
||||
sampleBuzz(vibTimes[v]);
|
||||
saveSettings();
|
||||
}
|
||||
}
|
||||
var boxMenu = {
|
||||
'< Back': function() { E.showMenu(menu) },
|
||||
}
|
||||
Object.assign(boxMenu,{
|
||||
'Box 1': getBoxChooser("B1"),
|
||||
'Box 2': getBoxChooser("B2"),
|
||||
'Box 3': getBoxChooser("B3"),
|
||||
'Box 4': getBoxChooser("B4"),
|
||||
'Box 5': getBoxChooser("B5"),
|
||||
'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: 120, max: 190,
|
||||
value: settings.HRM.max,
|
||||
format: v => v,
|
||||
onchange: v => {
|
||||
settings.HRM.max = v;
|
||||
saveSettings();
|
||||
},
|
||||
}
|
||||
E.showMenu(menu);
|
||||
})
|
Loading…
Reference in New Issue