From 36a50d1ceec0df236b7c874f61a43c150cb8e726 Mon Sep 17 00:00:00 2001 From: Rob Pilling Date: Mon, 9 Sep 2024 21:43:56 +0100 Subject: [PATCH 1/2] Layout: fix differing numeric types With a slight hack --- typescript/types/layout.d.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/typescript/types/layout.d.ts b/typescript/types/layout.d.ts index 55ddd7135a..4329d08313 100644 --- a/typescript/types/layout.d.ts +++ b/typescript/types/layout.d.ts @@ -7,7 +7,16 @@ type ExtractIds = [Depth] extends [never] ? never : (T extends { id?: infer Id extends string } - ? { [k in Id]: { -readonly [P in keyof T]: T[P] extends string ? string : T[P] } } + ? { + [k in Id]: { + -readonly [P in keyof T]: + T[P] extends string + ? string + : T[P] extends number + ? number | undefined + : T[P] + } + } : never) | ( From d447b5c2e69e2f11d0f432b3975a7f17c8651df6 Mon Sep 17 00:00:00 2001 From: Rob Pilling Date: Mon, 9 Sep 2024 22:01:07 +0100 Subject: [PATCH 2/2] pace: initial app --- apps/pace/ChangeLog | 1 + apps/pace/README.md | 6 ++ apps/pace/app-icon.js | 1 + apps/pace/app.js | 179 +++++++++++++++++++++++++++++++++ apps/pace/app.png | Bin 0 -> 715 bytes apps/pace/app.ts | 217 ++++++++++++++++++++++++++++++++++++++++ apps/pace/metadata.json | 14 +++ 7 files changed, 418 insertions(+) create mode 100644 apps/pace/ChangeLog create mode 100644 apps/pace/README.md create mode 100644 apps/pace/app-icon.js create mode 100644 apps/pace/app.js create mode 100644 apps/pace/app.png create mode 100644 apps/pace/app.ts create mode 100644 apps/pace/metadata.json diff --git a/apps/pace/ChangeLog b/apps/pace/ChangeLog new file mode 100644 index 0000000000..1a3bc17573 --- /dev/null +++ b/apps/pace/ChangeLog @@ -0,0 +1 @@ +0.01: New app! diff --git a/apps/pace/README.md b/apps/pace/README.md new file mode 100644 index 0000000000..f6c0ce941a --- /dev/null +++ b/apps/pace/README.md @@ -0,0 +1,6 @@ +# Description + +A running pace app, useful for races. Will also record your splits and display them to you on the pause menu. + +Drag up/down on the pause menu to scroll through your splits. +Press the button to pause/resume - when resumed, pressing the button will pause instantly, regardless of whether the screen is locked. diff --git a/apps/pace/app-icon.js b/apps/pace/app-icon.js new file mode 100644 index 0000000000..b2b8b4c17a --- /dev/null +++ b/apps/pace/app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwkBiIA/AH8QRYoX/AAcAZqwXuMIQYCiAcOO456OCwwACF5orEDghzOE4oZCLxw+GDAKsXeSIqEGBKKGBwIRFGBCfIC7p5RC7oGHC8RxFC453JAw4Xda5AIEC5IAPO6AXmO5BuHC67OIUA4aERyIXEIxAAJiAuFC5gTEcgpDNBwoWCIxp4CCwp1OCIYAEOiDFNDBwVQAH4AvA=")) diff --git a/apps/pace/app.js b/apps/pace/app.js new file mode 100644 index 0000000000..5acad0cee5 --- /dev/null +++ b/apps/pace/app.js @@ -0,0 +1,179 @@ +{ + var Layout_1 = require("Layout"); + var state_1 = 1; + var drawTimeout_1; + var lastUnlazy_1 = 0; + var lastResumeTime_1 = Date.now(); + var splitTime_1 = 0; + var totalTime_1 = 0; + var splits_1 = []; + var splitDist_1 = 0; + var splitOffset_1 = 0, splitOffsetPx_1 = 0; + var lastGPS_1 = 0; + var GPS_TIMEOUT_MS_1 = 30000; + var layout_1 = new Layout_1({ + type: "v", + c: [ + { + type: "txt", + font: "6x8:2", + label: "Pace", + id: "paceLabel", + pad: 4 + }, + { + type: "txt", + font: "Vector:40", + label: "", + id: "pace", + halign: 0 + }, + { + type: "txt", + font: "6x8:2", + label: "Time", + id: "timeLabel", + pad: 4 + }, + { + type: "txt", + font: "Vector:40", + label: "", + id: "time", + halign: 0 + }, + ] + }, { + lazy: true + }); + var formatTime_1 = function (ms) { + var totalSeconds = Math.floor(ms / 1000); + var minutes = Math.floor(totalSeconds / 60); + var seconds = totalSeconds % 60; + return "".concat(minutes, ":").concat(seconds < 10 ? '0' : '').concat(seconds); + }; + var calculatePace_1 = function (time, dist) { + if (dist === 0) + return 0; + return time / dist / 1000 / 60; + }; + var draw_1 = function () { + if (state_1 === 1) { + drawSplits_1(); + return; + } + if (drawTimeout_1) + clearTimeout(drawTimeout_1); + drawTimeout_1 = setTimeout(draw_1, 1000); + var now = Date.now(); + var elapsedTime = formatTime_1(totalTime_1 + (state_1 === 0 ? now - lastResumeTime_1 : 0)); + var pace; + if (now - lastGPS_1 <= GPS_TIMEOUT_MS_1) { + pace = calculatePace_1(thisSplitTime_1(), splitDist_1).toFixed(2); + } + else { + pace = "No GPS"; + } + layout_1["time"].label = elapsedTime; + layout_1["pace"].label = pace; + layout_1.render(); + if (now - lastUnlazy_1 > 30000) + layout_1.forgetLazyState(), lastUnlazy_1 = now; + }; + var drawSplits_1 = function () { + g.clearRect(Bangle.appRect); + var barSize = 20; + var barSpacing = 10; + var w = g.getWidth(); + var h = g.getHeight(); + var max = splits_1.reduce(function (a, x) { return Math.max(a, x); }, 0); + g.setFont("6x8", 2).setFontAlign(-1, -1); + var i = 0; + for (;; i++) { + var split = splits_1[i + splitOffset_1]; + if (split == null) + break; + var y = Bangle.appRect.y + i * (barSize + barSpacing) + barSpacing / 2; + if (y > h) + break; + var size = w * split / max; + g.setColor("#00f").fillRect(0, y, size, y + barSize); + var splitPace = calculatePace_1(split, 1); + g.setColor("#fff").drawString("".concat(i + 1 + splitOffset_1, " @ ").concat(splitPace.toFixed(2)), 0, y); + } + var splitTime = thisSplitTime_1(); + var pace = calculatePace_1(splitTime, splitDist_1); + g.setColor("#fff").drawString("".concat(i + 1 + splitOffset_1, " @ ").concat(pace, " (").concat((splitTime / 1000).toFixed(2), ")"), 0, Bangle.appRect.y + i * (barSize + barSpacing) + barSpacing / 2); + }; + var thisSplitTime_1 = function () { + if (state_1 === 1) + return splitTime_1; + return Date.now() - lastResumeTime_1 + splitTime_1; + }; + var pauseRun_1 = function () { + state_1 = 1; + var now = Date.now(); + totalTime_1 += now - lastResumeTime_1; + splitTime_1 += now - lastResumeTime_1; + Bangle.setGPSPower(0, "pace"); + Bangle.removeListener('GPS', onGPS_1); + draw_1(); + }; + var resumeRun_1 = function () { + state_1 = 0; + lastResumeTime_1 = Date.now(); + Bangle.setGPSPower(1, "pace"); + Bangle.on('GPS', onGPS_1); + g.clearRect(Bangle.appRect); + layout_1.forgetLazyState(); + draw_1(); + }; + var onGPS_1 = function (fix) { + if (fix && fix.speed && state_1 === 0) { + var now = Date.now(); + var elapsedTime = now - lastGPS_1; + splitDist_1 += fix.speed * elapsedTime / 3600000; + while (splitDist_1 >= 1) { + splits_1.push(thisSplitTime_1()); + splitDist_1 -= 1; + splitTime_1 = 0; + } + lastGPS_1 = now; + } + }; + var onButton_1 = function () { + switch (state_1) { + case 0: + pauseRun_1(); + break; + case 1: + resumeRun_1(); + break; + } + }; + Bangle.on('lock', function (locked) { + if (!locked && state_1 == 0) + onButton_1(); + }); + setWatch(function () { return onButton_1(); }, BTN1, { repeat: true }); + Bangle.on('drag', function (e) { + if (state_1 !== 1 || e.b === 0) + return; + splitOffsetPx_1 -= e.dy; + if (splitOffsetPx_1 > 20) { + if (splitOffset_1 < splits_1.length - 3) + splitOffset_1++, Bangle.buzz(30); + splitOffsetPx_1 = 0; + } + else if (splitOffsetPx_1 < -20) { + if (splitOffset_1 > 0) + splitOffset_1--, Bangle.buzz(30); + splitOffsetPx_1 = 0; + } + draw_1(); + }); + Bangle.loadWidgets(); + Bangle.drawWidgets(); + g.clearRect(Bangle.appRect); + draw_1(); +} diff --git a/apps/pace/app.png b/apps/pace/app.png new file mode 100644 index 0000000000000000000000000000000000000000..ff8f29cdca8941f3a4d051ddba2b1469ea81a7a0 GIT binary patch literal 715 zcmV;+0yO=JP)6>ziQ3~@QX)^IJls6$MFG`9s z#e;I2(lls7@-iMwn7p`@2T~q9;6V|2a4AYDmq$jU6b;j0rd{jQYIo*4-`QvEvrlt= zwO-D5);{aMzqR&dK}ku;c+w2Cq_m)T4veI(OU=pwnI1F5| z1l$8o1Dk;vmfsF}rw^E(N8k&9bHL{sm(O3o4Vm>O;Jf$7Xhm*zoisRKppT! z`E3s+g-#~;R+Qfp<##NUB2Kav<#$s(@5xiZH%9?i)$`swH?l3jW9e8VS+nwcpah(u zfV@=#_EIqB$bl{ru$F?+u7V$wV|Q*voR&J^nHukw>p@x*T6pB2Ec4?@FC!Giap}LGgUSs)1`YK xlU`9H_l_LUDg=&MN#+78fF(YQv!rCAFbdFK++ROgi)sJ>002ovPDHLkV1i~1KimKS literal 0 HcmV?d00001 diff --git a/apps/pace/app.ts b/apps/pace/app.ts new file mode 100644 index 0000000000..d7928504ac --- /dev/null +++ b/apps/pace/app.ts @@ -0,0 +1,217 @@ +{ +const Layout = require("Layout"); + +const enum RunState { + RUNNING, + PAUSED +} + +let state = RunState.PAUSED; +let drawTimeout: TimeoutId | undefined; +let lastUnlazy = 0; + +let lastResumeTime = Date.now(); +let splitTime = 0; +let totalTime = 0; + +const splits: number[] = []; +let splitDist = 0; +let splitOffset = 0, splitOffsetPx = 0; + +let lastGPS = 0; +const GPS_TIMEOUT_MS = 30000; + +const layout = new Layout({ + type: "v", + c: [ + { + type: "txt", + font: "6x8:2", + label: "Pace", + id: "paceLabel", + pad: 4 + }, + { + type: "txt", + font: "Vector:40", + label: "", + id: "pace", + halign: 0 + }, + { + type: "txt", + font: "6x8:2", + label: "Time", + id: "timeLabel", + pad: 4 + }, + { + type: "txt", + font: "Vector:40", + label: "", + id: "time", + halign: 0 + }, + ] +}, { + lazy: true +}); + +const formatTime = (ms: number) => { + let totalSeconds = Math.floor(ms / 1000); + let minutes = Math.floor(totalSeconds / 60); + let seconds = totalSeconds % 60; + return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`; +}; + +const calculatePace = (time: number, dist: number) => { + if (dist === 0) return 0; + return time / dist / 1000 / 60; +}; + +const draw = () => { + if (state === RunState.PAUSED) { + // no draw-timeout here, only on user interaction + drawSplits(); + return; + } + + if (drawTimeout) clearTimeout(drawTimeout); + drawTimeout = setTimeout(draw, 1000); + + const now = Date.now(); + + const elapsedTime = formatTime(totalTime + (state === RunState.RUNNING ? now - lastResumeTime : 0)); + + let pace: string; + if (now - lastGPS <= GPS_TIMEOUT_MS) { + pace = calculatePace(thisSplitTime(), splitDist).toFixed(2); + }else{ + pace = "No GPS"; + } + + layout["time"]!.label = elapsedTime; + layout["pace"]!.label = pace; + layout.render(); + + if (now - lastUnlazy > 30000) + layout.forgetLazyState(), lastUnlazy = now; +}; + +const drawSplits = () => { + g.clearRect(Bangle.appRect); + + const barSize = 20; + const barSpacing = 10; + const w = g.getWidth(); + const h = g.getHeight(); + + const max = splits.reduce((a, x) => Math.max(a, x), 0); + + g.setFont("6x8", 2).setFontAlign(-1, -1); + + let i = 0; + for(; ; i++) { + const split = splits[i + splitOffset]; + if (split == null) break; + + const y = Bangle.appRect.y + i * (barSize + barSpacing) + barSpacing / 2; + if (y > h) break; + + const size = w * split / max; // Scale bar height based on pace + g.setColor("#00f").fillRect(0, y, size, y + barSize); + + const splitPace = calculatePace(split, 1); // Pace per km + g.setColor("#fff").drawString(`${i + 1 + splitOffset} @ ${splitPace.toFixed(2)}`, 0, y); + } + + const splitTime = thisSplitTime(); + const pace = calculatePace(splitTime, splitDist); + g.setColor("#fff").drawString( + `${i + 1 + splitOffset} @ ${pace} (${(splitTime / 1000).toFixed(2)})`, + 0, + Bangle.appRect.y + i * (barSize + barSpacing) + barSpacing / 2, + ); +}; + +const thisSplitTime = () => { + if (state === RunState.PAUSED) return splitTime; + return Date.now() - lastResumeTime + splitTime; +}; + +const pauseRun = () => { + state = RunState.PAUSED; + const now = Date.now(); + totalTime += now - lastResumeTime; + splitTime += now - lastResumeTime; + Bangle.setGPSPower(0, "pace") + Bangle.removeListener('GPS', onGPS); + draw(); +}; + +const resumeRun = () => { + state = RunState.RUNNING; + lastResumeTime = Date.now(); + Bangle.setGPSPower(1, "pace"); + Bangle.on('GPS', onGPS); + + g.clearRect(Bangle.appRect); // splits -> layout, clear. layout -> splits, fine + layout.forgetLazyState(); + draw(); +}; + +const onGPS = (fix: GPSFix) => { + if (fix && fix.speed && state === RunState.RUNNING) { + const now = Date.now(); + + const elapsedTime = now - lastGPS; // ms + splitDist += fix.speed * elapsedTime / 3600000; // ms in one hour (fix.speed is in km/h) + + while (splitDist >= 1) { + splits.push(thisSplitTime()); + splitDist -= 1; + splitTime = 0; + } + + lastGPS = now; + } +}; + +const onButton = () => { + switch (state) { + case RunState.RUNNING: + pauseRun(); + break; + case RunState.PAUSED: + resumeRun(); + break; + } +}; + +Bangle.on('lock', locked => { + // treat an unlock (while running) as a pause + if(!locked && state == RunState.RUNNING) onButton(); +}); + +setWatch(() => onButton(), BTN1, { repeat: true }); + +Bangle.on('drag', e => { + if (state !== RunState.PAUSED || e.b === 0) return; + + splitOffsetPx -= e.dy; + if (splitOffsetPx > 20) { + if (splitOffset < splits.length-3) splitOffset++, Bangle.buzz(30); + splitOffsetPx = 0; + } else if (splitOffsetPx < -20) { + if (splitOffset > 0) splitOffset--, Bangle.buzz(30); + splitOffsetPx = 0; + } + draw(); +}); + +Bangle.loadWidgets(); +Bangle.drawWidgets(); + +g.clearRect(Bangle.appRect); +draw(); +} diff --git a/apps/pace/metadata.json b/apps/pace/metadata.json new file mode 100644 index 0000000000..e7066c9586 --- /dev/null +++ b/apps/pace/metadata.json @@ -0,0 +1,14 @@ +{ + "id": "pace", + "name": "Pace", + "version": "0.01", + "description": "Show pace and time running splits", + "icon": "app.png", + "tags": "run,running,fitness,outdoors", + "supports" : ["BANGLEJS2"], + "readme": "README.md", + "storage": [ + { "name": "pace.app.js","url": "app.js" }, + { "name": "pace.img","url": "app-icon.js","evaluate": true } + ] +}