From 86dda5da788f7a03b914318e9900bea7294ebc82 Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Tue, 29 Oct 2024 15:13:33 +0100 Subject: [PATCH 01/18] Add files via upload --- apps/rungps/ChangeLog | 1 + apps/rungps/README.md | 11 ++ apps/rungps/app.js | 223 +++++++++++++++++++++++++++++++++++++ apps/rungps/app.png | Bin 0 -> 1337 bytes apps/rungps/interface.html | 50 +++++++++ apps/rungps/metadata.json | 18 +++ apps/rungps/rungps-icon.js | 1 + 7 files changed, 304 insertions(+) create mode 100644 apps/rungps/ChangeLog create mode 100644 apps/rungps/README.md create mode 100644 apps/rungps/app.js create mode 100644 apps/rungps/app.png create mode 100644 apps/rungps/interface.html create mode 100644 apps/rungps/metadata.json create mode 100644 apps/rungps/rungps-icon.js diff --git a/apps/rungps/ChangeLog b/apps/rungps/ChangeLog new file mode 100644 index 0000000000..5560f00bce --- /dev/null +++ b/apps/rungps/ChangeLog @@ -0,0 +1 @@ +0.01: New App! diff --git a/apps/rungps/README.md b/apps/rungps/README.md new file mode 100644 index 0000000000..7393867f28 --- /dev/null +++ b/apps/rungps/README.md @@ -0,0 +1,11 @@ +# Running + +This app is designed to be easy to use. +I tried to make it as clear as possible. + +## How to use? +--- + +When the app starts you have two options: +- **Run** this is if you go run. +- **Results** use this to see your prestations. diff --git a/apps/rungps/app.js b/apps/rungps/app.js new file mode 100644 index 0000000000..d83d582ef2 --- /dev/null +++ b/apps/rungps/app.js @@ -0,0 +1,223 @@ +var storage = require("Storage"); +var width = g.getWidth(); +var height = g.getHeight(); + +var gps = {}; +var heart_rate = {bpm: 0, confidence: 0}; +var running = false; +var al_fix = false; +var al_rate = false; +var typen = false; +var vorig = null; +var total_distance = 0; +var start_time = 0|getTime(); +var watch; +var interval; + +var mainMenu; + +function central(text, x, x2, y) { + g.drawString(text, (x2 - g.stringWidth(text))/2+x, y); +} + +function getDistanceFromLatLonInKm(lat1, lon1, lat2, lon2) { + var R = 6371; + var dLat = deg2rad(lat2-lat1); + var dLon = deg2rad(lon2-lon1); + var a = Math.sin(dLat/2) * Math.sin(dLat/2) + + Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) * + Math.sin(dLon/2) * Math.sin(dLon/2); + var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + var d = R * c; + return d; +} + +function deg2rad(deg) { + return deg * (Math.PI/180); +} + +function write(f) { + var csv = [ + 0|getTime(), + gps.lat, + gps.lon, + gps.alt, + gps.speed, + gps.course, + heart_rate.bpm + ]; + f.write(csv.join(", ")+"\n"); +} + +function getTimediffrence() { + var diffrence = 0|getTime() - start_time; + return [Math.floor(diffrence / 60), diffrence % 60]; +} + +function scherm() { + var speed = 0; + var diffrence = getTimediffrence(); + if (gps.fix) { + if (gps.speed != 0) { + speed = 60/gps.speed; + } + } + g.setColor(0, 0, 0); + g.clear(); + g.setFont("Vector", 15); + g.drawLine(width/2, 0, width/2, height); + g.drawLine(0, height/2, width, height/2); + central("Speed", 0, width/2, 5); + central("Distance", width/2, width/2, 5); + central("Heart rate", 0, width/2, height/2+5); + central("Time", width/2, width/2, height/2+5); + g.setFont("Vector", 30); + if (gps.fix) { + central(Math.floor(speed)+":"+Math.floor((speed * 60) % 60), 0, width/2, 20); + if (vorig) { + total_distance += getDistanceFromLatLonInKm(vorig.lat, vorig.lon, gps.lat, gps.lon); + central(Math.floor(total_distance*100)/100, width/2, width/2, 20); + } + } + central(heart_rate.bpm, 0, width/2, height/2+20); + central(diffrence[0]+":"+diffrence[1], width/2, width/2, height/2+20); + if (running) { + g.setColor(0, 1, 0); + } else { + g.setColor(1, 0, 0); + } + g.fillRect(width/2-8, height/2-8, width/2+8, height/2+8); +} + +function maakklaar(f) { + if (!typen) { + var fix = Bangle.getGPSFix(); + gps = fix; + scherm(); + if (fix.fix) { + if (!al_fix) { + Bangle.buzz(); + console.log(fix.satellites); + al_fix = true; + } + if (!al_rate) { + al_rate = true; + } + if (running) { + write(f); + } + vorig = gps; + } + } +} + +function run() { + var f = storage.open("rungps."+require("locale").date(new Date())+".csv","a"); + watch = setWatch(() => {running = !running; + if (running === false) { + f.write("]"); + stopRun(); + } else { + start_time = 0|getTime(); + } + }, BTN1, {repeat: true}); + interval = setInterval(maakklaar, 2000, [f]); + + Bangle.setGPSPower(1, "rungps"); + Bangle.setHRMPower(1, "rungps"); + Bangle.on("HRM", (r) => { heart_rate = r; }); +} + +function stopRun() { + clearWatch(watch); + clearInterval(interval); + + Bangle.setGPSPower(0, "rungps"); + Bangle.setHRMPower(0, "rungps"); + Bangle.removeListener("HRM", (r) => { heart_rate = r; }); +} + +function parseFile(file) { + var totalHeart_rate = 0; + var distance = 0; + var points = 0; + var endTime = 0; + + var l = file.readLine(); + var all = l.split(", "); + const startTime = all[0]; + var lat = all[1]; + var lon = all[2]; + totalHeart_rate += all[6]/10000; + points += 1; + + var vorig = {lat: lat, lon: lon}; + + l = file.readLine(); + while (l[0] !== "]") { + all = l.split(", "); + var time = all[0]; + lat = all[1]; + lon = all[2]; + + distance += getDistanceFromLatLonInKm(vorig.lat, vorig.lon, lat, lon); + totalHeart_rate += all[6]/10000; + points += 1; + vorig = {lat: lat, lon: lon}; + l = file.readLine(); + if (l[0] === "]") { + endTime = time; + } + } + var timeDifference = endTime - startTime; + var speed = Math.floor(timeDifference/distance); + distance = Math.floor(distance*100)/100; + return [Math.floor(totalHeart_rate/points*10000), distance.toString()+"km", convertSecondsToTime(timeDifference), + Math.floor(speed/60)+":"+Math.floor(speed % 60)+"min/km"]; +} + +function convertSecondsToTime(given_seconds) { + var seconds = given_seconds % 60; + var minutes = Math.floor(given_seconds/60) % 60; + var hours = Math.floor(given_seconds/60/60); + return hours+":"+minutes+":"+seconds; +} + +function showData(file) { + var title = file.replace("rungps.","").replace(".csv",""); + E.showMessage(/*LANG*/"Loading...", title); + var f = storage.open(file, "r"); + var all = parseFile(f); + var heart_rate = all[0]; + var distance = all[1]; + var timeDifference = all[2]; + var speed = all[3]; + E.showMenu({ + "": {title: file.replace("rungps.","").replace(".csv","")}, + "Speed": {value: speed}, + "Distance": {value: distance}, + "Time": {value: timeDifference}, + "Heart rate": {value: heart_rate}, + "< Back": () => E.showMenu(mainMenu) + }); +} + +function results() { + var allFiles = storage.list(/rungps\..+\.csv$/, {sf: true}); + E.showScroller({ + h : 40, c : allFiles.length, + draw : (idx, r) => { + g.setBgColor((idx&1)?"#666":"#CCC").clearRect(r.x,r.y,r.x+r.w-1,r.y+r.h-1); + g.setFont("6x8:2").drawString(allFiles[idx].replace("rungps.","").replace(".csv",""), r.x+10,r.y+4); + }, + select : (idx) => showData(allFiles[idx]) + }); +} + +mainMenu = { + "": { title: "-- Running --" }, // options + "Run": () => run(), + "Results": () => results() +}; +E.showMenu(mainMenu); + diff --git a/apps/rungps/app.png b/apps/rungps/app.png new file mode 100644 index 0000000000000000000000000000000000000000..883245b8149e267ed9cf6b07ef296ef4cbba93f5 GIT binary patch literal 1337 zcmV-91;+Y`P)q$gGRCt{2nQ4q(Qy9m8GiJ0*RU(~kDI>JBOe(Qd2)adMFAXZF zSOyj13$a8A5>)vh5e;fd8(Jj9z63?ZRz_)g;^DFIV}kG0`O;4R=L zV3L$G4dXTcsf_Uj8b7u`y4e|cNE=!E3HZZ32Zmk+rU4D^-O(EVG-K_>8Xr>>@#BE+ zwaH3gvii3J4g@X-o{bnXCEZ8!>YbQOkJdih9f)VTD(C~u0y?$H)4;$A^G^dla@OTg zjhz5=ssANlCh)wTt^bs26W^*+v<=n+7o^x0rgyV&KJd3Tco!Jq#`*(u+-YtA9swo*bvRz zb*FB+FvN{bcV2c`v|gPoQ=;~c;vkRV^3JyYk?NbH`4@JVfhY9h%l26vZ%Q?=M%z$b1~3%Z7;%s)!@zca^@n6d`~?{I4_ z&~qZoWyK>?L@&?sm&~#s-(D| z}~z;VC~DXiUCDOe`r%!Fp0eqi!MY77&c6Iz_1173WV%d6G|`YKpUT->u%`ECr8 z;}=~-c?dOt8$^&*7X|@uI|7f$kn#ImSTcnzh>;4^HaM5x$CX4j=0uG%BzB#V$1M$Y z&b?ba@6oIfA(+A08CJ)l)lKpXrCFOe(O4eUbQ)Zozeu6B{eKM@kh0FT8n>*gG&D9>s;vsF|tVSSYqzcl990$Zu8ef ziS;POH)*AiK`zdZ74{bOKTx*KKR2@P!4uz>;=o>UnU=>%Qed-W`<89bkE?%S zN*H~elW`xA9j+2hm#T18gAsIbN>rI<^HJDq1pbgxog1$cHbVDv{jJVAT}_oS)G6ZV z3O0YD6M=(M)(T?ft9QEsKdS#u3c^fnutEwrHO1!d=F-Bryckl#U^;y&`MLTZ%gL*I zjC$4V6V>@cl5M3G#6+ms+@G;VlR}C061f3dD>$`KDk%*8+y{%$+QeLlb(W-jS@F2b zl@GgIq-x(3N + + + + +
+ + + + + + diff --git a/apps/rungps/metadata.json b/apps/rungps/metadata.json new file mode 100644 index 0000000000..7562159cae --- /dev/null +++ b/apps/rungps/metadata.json @@ -0,0 +1,18 @@ +{ "id": "rungps", + "name": "Running", + "shortName":"Running", + "version":"0.01", + "description": "This app is a app for running and see your prestasion!", + "icon": "app.png", + "tags": "tool,outdoors,gps", + "supports" : ["BANGLEJS2"], + "readme": "README.md", + "interface": "interface.html", + "storage": [ + {"name":"rungps.app.js","url":"app.js"}, + {"name":"rungps.img","url":"app-icon.js","evaluate":true} + ], + "data": [ + {"wildcard":"rungps..?.csv","storageFile":true} + ] +} diff --git a/apps/rungps/rungps-icon.js b/apps/rungps/rungps-icon.js new file mode 100644 index 0000000000..e19e2ba696 --- /dev/null +++ b/apps/rungps/rungps-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEw4UA///IfsVqALJqtUBREBqEVBZNAgoIFgofBBZFVqtQBY4rBgNVBY8VoAlBoBHGAoRGCqAYDA4I7CB4VVDwIfCioGBE4VRBYUUgNUDQQ/BoILDDAJFBB4MFBYcFqoCBGgJjBCQI7DDQINBDAR4FIoIIBqAQBPApvDBYopCOwTMFFIR3BooLFDoJUCOwYLGag5SCcBDqBM4bXGFoI4FLwVRBILJFBYVAO4YAFYASpCBYwwBEgIwGZgNQQI43BEAJVBBYzgBDQZGGCoIaCIwwtCKYzSCCoLIFXIIJBBwJ3FqkUBIL5BHQsVDAILBU40FNAUFqqZGQAUBFwoKBAALHHBZYAj")) From 4b2755b5b99d7b0ea14f932a3cf606b46b1a0240 Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Tue, 29 Oct 2024 15:22:56 +0100 Subject: [PATCH 02/18] Rename rungps-icon.js to app-icon.js --- apps/rungps/{rungps-icon.js => app-icon.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename apps/rungps/{rungps-icon.js => app-icon.js} (100%) diff --git a/apps/rungps/rungps-icon.js b/apps/rungps/app-icon.js similarity index 100% rename from apps/rungps/rungps-icon.js rename to apps/rungps/app-icon.js From b0d9f1e619338faec608739ab1ffc0ddd3d4254d Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Tue, 29 Oct 2024 15:26:23 +0100 Subject: [PATCH 03/18] Update interface.html --- apps/rungps/interface.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/rungps/interface.html b/apps/rungps/interface.html index 9f12949efc..004dbb709c 100644 --- a/apps/rungps/interface.html +++ b/apps/rungps/interface.html @@ -13,8 +13,9 @@ function getData() { dataElement.innerHTML = ""; - Puck.eval("require(Storage).list(/rungps\\..+\\.csv$/, {sf: true});", list=> { + Puck.eval(`require("Storage").list(/rungps\\..+\\.csv$/, {sf: true});`, list => { dataElement.innerHTML = "
    "; + console.log(list); for (let file of list) { dataElement.innerHTML += "
  • "; } From 0a0c53fad5cd0f70eed77a7c2853c5d064fbfa3b Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Tue, 29 Oct 2024 15:36:02 +0100 Subject: [PATCH 04/18] Update README.md --- apps/rungps/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/rungps/README.md b/apps/rungps/README.md index 7393867f28..294daf1343 100644 --- a/apps/rungps/README.md +++ b/apps/rungps/README.md @@ -4,7 +4,6 @@ This app is designed to be easy to use. I tried to make it as clear as possible. ## How to use? ---- When the app starts you have two options: - **Run** this is if you go run. From 39a9c8584e2251dbc68eb0ed5c9857120434308b Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Tue, 29 Oct 2024 15:41:53 +0100 Subject: [PATCH 05/18] Update interface.html --- apps/rungps/interface.html | 183 +++++++++++++++++++++++++++++-------- 1 file changed, 143 insertions(+), 40 deletions(-) diff --git a/apps/rungps/interface.html b/apps/rungps/interface.html index 004dbb709c..3117c5ae1a 100644 --- a/apps/rungps/interface.html +++ b/apps/rungps/interface.html @@ -1,51 +1,154 @@ - + + - + + Home rungps loader + + + + + + -
    - + + +
    +
    + + + +
    +
    +

    heart rate: 0 bpm

    +

    speed: 0:0 min/km

    +

    altitude: 0m

    +

    time: 0:00:00

    +
    +
    +

    0km

    +

    0:0min/km

    +
    +
    + + +
    - From 6708b81d9b5890341256dbcc3958e0c7ca52c110 Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Sun, 5 Oct 2025 11:00:07 +0200 Subject: [PATCH 06/18] Add files via upload --- apps/rungps/Home.css | 27 ++++++++++++++++++ apps/rungps/calculations.js | 56 +++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 apps/rungps/Home.css create mode 100644 apps/rungps/calculations.js diff --git a/apps/rungps/Home.css b/apps/rungps/Home.css new file mode 100644 index 0000000000..b4879e1676 --- /dev/null +++ b/apps/rungps/Home.css @@ -0,0 +1,27 @@ +.Chart { + width: 100%; +} + +#charts { + width: 50%; +} + +#altitudeChart { + position: absolute; + left: 50%; + top: 1%; +} + +.data { + border-bottom: #838383 solid 2px; + border-top: #838383 solid 2px; + width: 50%; +} + +#map { + height: 50%; + width: 50%; + position: absolute; + left: 50%; + top: 50%; +} diff --git a/apps/rungps/calculations.js b/apps/rungps/calculations.js new file mode 100644 index 0000000000..b047b7ab96 --- /dev/null +++ b/apps/rungps/calculations.js @@ -0,0 +1,56 @@ +window.calculations = { + "translateData": function (text) { + var points = [] + for (var line of text.split("\n")) { + var all = line.split(", "); + if (all.length === 7) { + let [time_seconds, lat, lon, alt, speed, course, heart_rate] = all; + points.push({lat: parseFloat(lat), long: parseFloat(lon), time: time_seconds, heart_rate: heart_rate, speed: speed, alt: alt}); + } + } + return points; + }, + "deg2rad": function (deg) { + return deg * (Math.PI/180); + }, + "getDistanceFromLatLonInKm": function (lat1, lon1, lat2, lon2) { + const R = 6371; // Radius of the earth in km + var dLat = window.calculations.deg2rad(lat2-lat1); // deg2rad below + var dLon = window.calculations.deg2rad(lon2-lon1); + var a = Math.sin(dLat/2) * Math.sin(dLat/2) + + Math.cos(window.calculations.deg2rad(lat1)) * Math.cos(window.calculations.deg2rad(lat2)) * + Math.sin(dLon/2) * Math.sin(dLon/2); + var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + return R * c; + }, + "getAverageSpeedAndDistance": function (points) { + var timeDifference = points[points.length-1].time - points[0].time; + var distance = 0; + var vorig = points[0]; + for (let i = 1; i < points.length; i++) { + distance += window.calculations.getDistanceFromLatLonInKm(vorig.lat, vorig.long, points[i].lat, points[i].long); + vorig = points[i] + } + distance = Math.floor(distance*100)/100; + var speed = Math.floor(timeDifference/distance) + return [Math.floor(speed/60)+":"+Math.floor(speed % 60)+"min/km", distance.toString()+"km"]; + }, + "convertSecondstoTime": function (given_seconds) { + let dateObj = new Date(given_seconds * 1000); + let hours = dateObj.getUTCHours(); + let minutes = dateObj.getUTCMinutes(); + let seconds = dateObj.getSeconds(); + + return hours.toString().padStart(2, '0') + + ':' + minutes.toString().padStart(2, '0') + + ':' + seconds.toString().padStart(2, '0'); + }, + "getIndexByTime": function (time, points) { + for (let i = 0; i < points.length; i++) { + if (window.calculations.convertSecondstoTime(points[i].time) === time) { + return i; + } + } + return -1; + } +} From e7ccce6d3aea091dadee6c344e9802ffd4876073 Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Sun, 5 Oct 2025 12:10:08 +0200 Subject: [PATCH 07/18] Update Home.css --- apps/rungps/Home.css | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/apps/rungps/Home.css b/apps/rungps/Home.css index b4879e1676..8dfaf2bf21 100644 --- a/apps/rungps/Home.css +++ b/apps/rungps/Home.css @@ -1,27 +1,20 @@ -.Chart { - width: 100%; -} - -#charts { - width: 50%; -} - -#altitudeChart { - position: absolute; - left: 50%; - top: 1%; -} - .data { border-bottom: #838383 solid 2px; border-top: #838383 solid 2px; width: 50%; + position: absolute; + top: 50%; } #map { height: 50%; width: 50%; position: absolute; - left: 50%; - top: 50%; + left: 0; + top: 0; +} + +#inputDiv { + position: absolute; + top: 60%; } From 3344ce25c1c6d9df6bdbc6cd48c7d3d41b2d9052 Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Sun, 5 Oct 2025 12:10:35 +0200 Subject: [PATCH 08/18] Update interface.html --- apps/rungps/interface.html | 88 +++----------------------------------- 1 file changed, 6 insertions(+), 82 deletions(-) diff --git a/apps/rungps/interface.html b/apps/rungps/interface.html index 3117c5ae1a..9c871d06ea 100644 --- a/apps/rungps/interface.html +++ b/apps/rungps/interface.html @@ -11,29 +11,18 @@ crossorigin=""> -
    -
    - - - -
    -
    -

    heart rate: 0 bpm

    -

    speed: 0:0 min/km

    -

    altitude: 0m

    -

    time: 0:00:00

    -

    0km

    0:0min/km

    +
    @@ -42,14 +31,8 @@ let text = ""; let points = []; let place; -let xTime; const groep = new L.featureGroup(); -const heart_rateData = document.getElementById("heart_rate"); -const speedData = document.getElementById("speed"); -const altData = document.getElementById("altitude"); -const timeData = document.getElementById("time"); - // --- Initialize Map --- let map = L.map('map').setView([0, 0], 0); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { @@ -57,27 +40,8 @@ attribution: '© OpenStreetMap' }).addTo(map); -// --- Initialize Charts --- -function createChart(canvasId, color, labelText) { - return new Chart(document.getElementById(canvasId).getContext("2d"), { - type: "line", - data: { labels: [], datasets: [{ borderColor: color, data: [], label: labelText, pointBackgroundColor: [], pointRadius: [] }] }, - options: { - animation: false, - plugins: { - title: { display: true, text: labelText }, - tooltip: { intersect: false, mode: "index" } - } - } - }); -} - -const heart_rateChart = createChart("heart_rateChart", "rgb(255,0,0)", "Heart rate"); -const speedChart = createChart("speedChart", "rgb(0,0,255)", "Speed"); -const altChart = createChart("altitudeChart", "rgb(112,112,112)", "Altitude"); - // --- Display points & update charts --- -function show(map, heart_rateChart, speedChart, altChart) { +function show(map) { points = window.calculations.translateData(text); let vorig = points[0]; @@ -91,30 +55,6 @@ groep.addLayer(line); map.addLayer(line); - const time = window.calculations.convertSecondstoTime(point.time); - - if (point.heart_rate) { - heart_rateChart.data.datasets[0].pointBackgroundColor.push("red"); - heart_rateChart.data.datasets[0].pointRadius.push(1); - heart_rateChart.data.datasets[0].data.push(point.heart_rate); - heart_rateChart.data.labels.push(time); - heart_rateChart.update(); - } - if (point.speed !== 0) { - speedChart.data.datasets[0].pointBackgroundColor.push("blue"); - speedChart.data.datasets[0].pointRadius.push(1); - speedChart.data.datasets[0].data.push(60 / point.speed); - speedChart.data.labels.push(time); - speedChart.update(); - } - if (point.alt) { - altChart.data.datasets[0].pointBackgroundColor.push("grey"); - altChart.data.datasets[0].pointRadius.push(1); - altChart.data.datasets[0].data.push(point.alt); - altChart.data.labels.push(time); - altChart.update(); - } - vorig = point; } @@ -122,32 +62,16 @@ [document.getElementById("averageSpeed").innerText, document.getElementById("distance").innerText] = window.calculations.getAverageSpeedAndDistance(points); } -// --- Tooltip update --- -function labelMaker(tooltipData, chart) { - const values = tooltipData.dataset.data[tooltipData.dataIndex]; - const xLabel = tooltipData.label.toString(); - const index = window.calculations.getIndexByTime(xLabel, points); - const point = points[index]; - - heart_rateData.innerText = "heart rate: "+point.heart_rate+" bpm"; - speedData.innerText = "speed: "+Math.floor(60 / point.speed)+":"+Math.floor(60 / point.speed * 60 % 60)+" min/km"; - altData.innerText = "altitude: "+point.alt+"m"; - timeData.innerText = "time: "+window.calculations.convertSecondstoTime(point.time); - - if (place) place.removeFrom(map); - place = L.circle([point.lat, point.long], { color: 'blue', fillColor: '#0000ff', fillOpacity: 1, radius: 1 }).addTo(map); - - return [values, tooltipData.dataset.label]; -} - -// --- Read GPS file --- document.getElementById("Read").addEventListener("click", () => { const fileName = "rungps."+document.getElementById("input").value+".csv" || "gpspoilog.csv"; Util.readStorageFile(fileName, data => { if (!data) return console.log("File empty or not found:", fileName); text = data; - show(map, heart_rateChart, speedChart, altChart); + show(map); }); +}); +document.getElementById("download").addEventListener("click", () => { + }); From 660c3dbebbafd5579a73cc864ecd4ef540d8c9e0 Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Sun, 5 Oct 2025 14:00:07 +0200 Subject: [PATCH 09/18] Update Home.css --- apps/rungps/Home.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/rungps/Home.css b/apps/rungps/Home.css index 8dfaf2bf21..9e94fbff4b 100644 --- a/apps/rungps/Home.css +++ b/apps/rungps/Home.css @@ -16,5 +16,5 @@ #inputDiv { position: absolute; - top: 60%; + top: calc(50% + 100px); } From 1683d1bdd6b2a1b8df07c98f0fd1dc7f9eac5dc5 Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Sun, 5 Oct 2025 14:00:41 +0200 Subject: [PATCH 10/18] Add files via upload --- apps/rungps/gpx_maker.js | 109 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 apps/rungps/gpx_maker.js diff --git a/apps/rungps/gpx_maker.js b/apps/rungps/gpx_maker.js new file mode 100644 index 0000000000..7427e0cecc --- /dev/null +++ b/apps/rungps/gpx_maker.js @@ -0,0 +1,109 @@ +const createXmlString = lines => { + // Normalize input into an array of segments, each a list of point objects + // Supported inputs: + // 1) [[lon,lat,ele], ...] -> single segment + // 2) [ [ [lon,lat,ele], ... ], [ [lon,lat,ele] ] ] -> multiple segments + // 3) [{lat, long, time, heart_rate, speed, alt}, ...] -> single segment (new format) + // 4) [ [ {..}, {..} ], [ {..} ] ] -> multiple segments (new format) + const toPointObj = (p) => { + if (p && typeof p === 'object' && !Array.isArray(p)) { + // already object with possible fields + return { + lat: typeof p.lat === 'string' ? parseFloat(p.lat) : p.lat, + long: typeof p.long === 'string' ? parseFloat(p.long) : p.long, + alt: p.alt != null ? (typeof p.alt === 'string' ? parseFloat(p.alt) : p.alt) : undefined, + time: p.time != null ? (typeof p.time === 'string' ? parseFloat(p.time) : p.time) : undefined, + heart_rate: p.heart_rate != null ? (typeof p.heart_rate === 'string' ? parseFloat(p.heart_rate) : p.heart_rate) : undefined, + speed: p.speed != null ? (typeof p.speed === 'string' ? parseFloat(p.speed) : p.speed) : undefined, + }; + } + // legacy array [lon, lat, ele] + if (Array.isArray(p)) { + return { lat: parseFloat(p[1]), long: parseFloat(p[0]), alt: p[2] != null ? parseFloat(p[2]) : undefined }; + } + return undefined; + }; + + let segments = []; + if (!Array.isArray(lines)) { + segments = []; + } else if (lines.length && Array.isArray(lines[0])) { + // Could be [[lon,lat,ele], ...] or [ [ [lon,lat,ele], ... ], ...] + if (lines[0].length && Array.isArray(lines[0][0])) { + // multiple segments of arrays + segments = lines.map(seg => seg.map(toPointObj)); + } else if (lines[0].length && (typeof lines[0][0] === 'number' || typeof lines[0][0] === 'string')) { + // single segment of arrays + segments = [lines.map(toPointObj)]; + } else if (lines[0] && typeof lines[0] === 'object' && !Array.isArray(lines[0])) { + // single segment of objects nested in an array wrapper + segments = [lines.map(toPointObj)]; + } else { + segments = []; + } + } else if (lines.length && typeof lines[0] === 'object') { + // single segment of objects + segments = [lines.map(toPointObj)]; + } else { + segments = []; + } + + // Compute base time if any time values exist (seconds, relative). We'll base on current time. + let firstTime = undefined; + for (const seg of segments) { + for (const p of seg) { + if (p && typeof p.time === 'number' && !isNaN(p.time)) { + firstTime = (firstTime === undefined) ? p.time : Math.min(firstTime, p.time); + } + } + } + const baseDate = new Date(); + + let result = ''; + result += segments.reduce((accum, curr) => { + let segmentTag = ''; + segmentTag += curr.map((point) => { + if (!point) return ''; + const lat = (point.lat != null) ? point.lat : undefined; + const lon = (point.long != null) ? point.long : undefined; + if (lat == null || lon == null || isNaN(lat) || isNaN(lon)) return ''; + let s = ``; + if (point.alt != null && !isNaN(point.alt)) s += `${point.alt}`; + if (point.time != null && firstTime != null && !isNaN(point.time)) { + const dt = new Date(baseDate.getTime() + (point.time - firstTime) * 1000); + s += ``; + } + // Extensions for HR and speed if provided + const hrOk = point.heart_rate != null && !isNaN(point.heart_rate); + const spOk = point.speed != null && !isNaN(point.speed); + if (hrOk || spOk) { + s += ''; + if (hrOk) s += `${Math.round(point.heart_rate)}`; + if (spOk) s += `${point.speed}`; + s += ''; + } + s += ''; + return s; + }).join(''); + segmentTag += ''; + return accum + segmentTag; + }, ''); + result += ''; + return result; +} + +window.gpx_manager = { + "downloadGpxFile": function ( + lines, + distance, + units + ) { + const xml = createXmlString(lines); + const url = 'data:application/gpx+xml;charset=utf-8,' + encodeURIComponent(xml); + const link = document.createElement('a'); + link.download = `${distance[distance.length - 1]}-${units}.gpx`; + link.href = url; + document.body.appendChild(link); + link.click(); + } +}; \ No newline at end of file From dc0de0c67e21b174c651b043295d9f7e9ef275f7 Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Sun, 5 Oct 2025 14:15:50 +0200 Subject: [PATCH 11/18] Update interface.html --- apps/rungps/interface.html | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/rungps/interface.html b/apps/rungps/interface.html index 9c871d06ea..579646d248 100644 --- a/apps/rungps/interface.html +++ b/apps/rungps/interface.html @@ -15,6 +15,7 @@ +
    @@ -22,7 +23,8 @@

    0:0min/km

    - + +
    @@ -70,8 +72,20 @@ show(map); }); }); -document.getElementById("download").addEventListener("click", () => { +document.getElementById("download_gpx").addEventListener("click", () => { + if (!points || !points.length) { + points = window.calculations.translateData(text); + } + const distanceStr = (document.getElementById("distance").innerText || "0km"); + window.gpx_manager.downloadGpxFile(points, [distanceStr], "km"); +}); +document.getElementById("download_tcx").addEventListener("click", () => { + if (!points || !points.length) { + points = window.calculations.translateData(text); + } + const distanceStr = (document.getElementById("distance").innerText || "0km"); + window.gpx_manager.downloadTcxFile(points, [distanceStr], "km"); }); From d6cae8f1b048982348561449bc36f97515b7f52f Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Sun, 5 Oct 2025 16:57:50 +0200 Subject: [PATCH 12/18] Update gpx_maker.js --- apps/rungps/gpx_maker.js | 144 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/apps/rungps/gpx_maker.js b/apps/rungps/gpx_maker.js index 7427e0cecc..2e0c433f9d 100644 --- a/apps/rungps/gpx_maker.js +++ b/apps/rungps/gpx_maker.js @@ -92,6 +92,133 @@ const createXmlString = lines => { return result; } +const createTcxString = (lines, totalDistanceMeters) => { + // Normalize input into segments of point objects using the same helper from GPX + const toPointObj = (p) => { + if (p && typeof p === 'object' && !Array.isArray(p)) { + return { + lat: typeof p.lat === 'string' ? parseFloat(p.lat) : p.lat, + long: typeof p.long === 'string' ? parseFloat(p.long) : p.long, + alt: p.alt != null ? (typeof p.alt === 'string' ? parseFloat(p.alt) : p.alt) : undefined, + time: p.time != null ? (typeof p.time === 'string' ? parseFloat(p.time) : p.time) : undefined, + heart_rate: p.heart_rate != null ? (typeof p.heart_rate === 'string' ? parseFloat(p.heart_rate) : p.heart_rate) : undefined, + speed: p.speed != null ? (typeof p.speed === 'string' ? parseFloat(p.speed) : p.speed) : undefined, + }; + } + if (Array.isArray(p)) { + return { lat: parseFloat(p[1]), long: parseFloat(p[0]), alt: p[2] != null ? parseFloat(p[2]) : undefined }; + } + return undefined; + }; + + let segments = []; + if (!Array.isArray(lines)) { + segments = []; + } else if (lines.length && Array.isArray(lines[0])) { + if (lines[0].length && Array.isArray(lines[0][0])) { + segments = lines.map(seg => seg.map(toPointObj)); + } else if (lines[0].length && (typeof lines[0][0] === 'number' || typeof lines[0][0] === 'string')) { + segments = [lines.map(toPointObj)]; + } else if (lines[0] && typeof lines[0] === 'object' && !Array.isArray(lines[0])) { + segments = [lines.map(toPointObj)]; + } else { + segments = []; + } + } else if (lines.length && typeof lines[0] === 'object') { + segments = [lines.map(toPointObj)]; + } else { + segments = []; + } + + // Determine timing + let firstTime = undefined; + let lastTime = undefined; + for (const seg of segments) { + for (const p of seg) { + if (p && typeof p.time === 'number' && !isNaN(p.time)) { + firstTime = (firstTime === undefined) ? p.time : Math.min(firstTime, p.time); + lastTime = (lastTime === undefined) ? p.time : Math.max(lastTime, p.time); + } + } + } + const baseDate = new Date(); + const totalTimeSeconds = (firstTime != null && lastTime != null && lastTime >= firstTime) ? (lastTime - firstTime) : 0; + + const startIso = baseDate.toISOString(); + + let tcx = '' + + '' + + '' + + '' + + '' + + `${startIso}` + + `` + + `${totalTimeSeconds}` + + `${isFinite(totalDistanceMeters) ? totalDistanceMeters : 0}` + + '0' + + 'Active' + + 'Manual' + + ''; + + for (const seg of segments) { + for (const point of seg) { + if (!point) continue; + const lat = (point.lat != null) ? point.lat : undefined; + const lon = (point.long != null) ? point.long : undefined; + if (lat == null || lon == null || isNaN(lat) || isNaN(lon)) continue; + + let timeIso = startIso; + if (point.time != null && firstTime != null && !isNaN(point.time)) { + const dt = new Date(baseDate.getTime() + (point.time - firstTime) * 1000); + timeIso = dt.toISOString(); + } + tcx += '' + + `` + + '' + + `${lat}` + + `${lon}` + + ''; + if (point.alt != null && !isNaN(point.alt)) tcx += `${point.alt}`; + if (point.heart_rate != null && !isNaN(point.heart_rate)) tcx += `${Math.round(point.heart_rate)}`; + if (point.speed != null && !isNaN(point.speed)) { + tcx += '' + + '' + + `${point.speed}` + + '' + + ''; + } + tcx += ''; + } + } + + tcx += ''; + return tcx; +}; + +function parseDistanceMeters(distanceStr) { + if (!distanceStr || typeof distanceStr !== 'string') return 0; + const m = distanceStr.trim().match(/^\s*([0-9]+(?:\.[0-9]+)?)\s*([a-zA-Z]+)?\s*$/); + if (!m) return 0; + const value = parseFloat(m[1]); + const unit = (m[2] || '').toLowerCase(); + if (!isFinite(value)) return 0; + switch (unit) { + case 'km': + return Math.round(value * 1000); + case 'm': + return Math.round(value); + case 'mi': + case 'mile': + case 'miles': + return Math.round(value * 1609.344); + default: + return Math.round(value); // assume meters if unknown + } +} + window.gpx_manager = { "downloadGpxFile": function ( lines, @@ -105,5 +232,20 @@ window.gpx_manager = { link.href = url; document.body.appendChild(link); link.click(); + }, + "downloadTcxFile": function ( + lines, + distance, + units + ) { + const distanceStr = Array.isArray(distance) ? distance[distance.length - 1] : String(distance || '0m'); + const totalMeters = parseDistanceMeters(distanceStr); + const tcx = createTcxString(lines, totalMeters); + const url = 'data:application/vnd.garmin.tcx+xml;charset=utf-8,' + encodeURIComponent(tcx); + const link = document.createElement('a'); + link.download = `${distanceStr}-${units}.tcx`; + link.href = url; + document.body.appendChild(link); + link.click(); } -}; \ No newline at end of file +}; From 2991cdeaf5cbcfa6602dac1e2ebb9160993cb2b1 Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Sun, 5 Oct 2025 17:31:28 +0200 Subject: [PATCH 13/18] Update app.js --- apps/rungps/app.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/rungps/app.js b/apps/rungps/app.js index d83d582ef2..5277698e1a 100644 --- a/apps/rungps/app.js +++ b/apps/rungps/app.js @@ -125,7 +125,7 @@ function run() { Bangle.setGPSPower(1, "rungps"); Bangle.setHRMPower(1, "rungps"); - Bangle.on("HRM", (r) => { heart_rate = r; }); + Bangle.on("HRM", (r) => { heart_rate = Number(r); }); } function stopRun() { @@ -134,7 +134,7 @@ function stopRun() { Bangle.setGPSPower(0, "rungps"); Bangle.setHRMPower(0, "rungps"); - Bangle.removeListener("HRM", (r) => { heart_rate = r; }); + Bangle.removeListener("HRM", (r) => { heart_rate = Number(r); }); } function parseFile(file) { From 652d233e6162d6eb49a1d032a19f18d442b6ddec Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Sun, 5 Oct 2025 19:20:41 +0200 Subject: [PATCH 14/18] Update ChangeLog --- apps/rungps/ChangeLog | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/rungps/ChangeLog b/apps/rungps/ChangeLog index 5560f00bce..a54add3cf8 100644 --- a/apps/rungps/ChangeLog +++ b/apps/rungps/ChangeLog @@ -1 +1,2 @@ 0.01: New App! +0.02: Download capebillity and nan heart rate error fixed From 90c1732166a8f9c2c470206ef6d00d97def4940c Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Mon, 6 Oct 2025 10:31:14 +0200 Subject: [PATCH 15/18] Update README.md --- apps/rungps/README.md | 44 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/rungps/README.md b/apps/rungps/README.md index 294daf1343..1954421037 100644 --- a/apps/rungps/README.md +++ b/apps/rungps/README.md @@ -1,10 +1,42 @@ # Running -This app is designed to be easy to use. -I tried to make it as clear as possible. +This app is designed to be simple and intuitive. +I’ve tried to make everything as clear as possible. -## How to use? +## How to Use -When the app starts you have two options: -- **Run** this is if you go run. -- **Results** use this to see your prestations. +When you open the app, you have two options: + +- **Run** – start a new running session. +- **Results** – view your previous runs. + +--- + +## Run + +When you tap **Run**, you’ll see four squares with a red one in the middle. + +- To start running, press **Button 1** on the side of your **Bangle.js**. + The middle square will turn **green**, indicating that recording has started. + +- To stop, press the same button again. + The screen will freeze, showing that the session has ended. + +To view your results, reopen the app and choose **Results**. + +--- + +## Results + +In the **Results** screen, you’ll see a list of your runs sorted by date. +Tap any date to view detailed data for that session. + +You can also view or download your results directly from the **App Loader**. + +--- + +## Notes + +- Make sure your **GPS** is active before starting a run. It is ready when the distance and speed starts changing. +- Recorded data includes time, distance, heart rate, ... +- Results are saved on the watch and can be exported for analysis. From a91284db5213d55436c6689f69c88111d752d357 Mon Sep 17 00:00:00 2001 From: kasperrey <76810629+kasperrey@users.noreply.github.com> Date: Mon, 6 Oct 2025 10:31:40 +0200 Subject: [PATCH 16/18] Update metadata.json --- apps/rungps/metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/rungps/metadata.json b/apps/rungps/metadata.json index 7562159cae..4463e511b5 100644 --- a/apps/rungps/metadata.json +++ b/apps/rungps/metadata.json @@ -1,7 +1,7 @@ { "id": "rungps", "name": "Running", "shortName":"Running", - "version":"0.01", + "version":"0.02", "description": "This app is a app for running and see your prestasion!", "icon": "app.png", "tags": "tool,outdoors,gps", From 75a24d18798dd923c72c1dd6f86ff3d86e069441 Mon Sep 17 00:00:00 2001 From: Rob Pilling Date: Tue, 7 Oct 2025 19:22:12 +0100 Subject: [PATCH 17/18] Disable linter for calcs & gpx_maker --- apps/rungps/calculations.js | 1 + apps/rungps/gpx_maker.js | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/rungps/calculations.js b/apps/rungps/calculations.js index b047b7ab96..440ef40291 100644 --- a/apps/rungps/calculations.js +++ b/apps/rungps/calculations.js @@ -1,3 +1,4 @@ +/* eslint-disable */ window.calculations = { "translateData": function (text) { var points = [] diff --git a/apps/rungps/gpx_maker.js b/apps/rungps/gpx_maker.js index 2e0c433f9d..5475e28162 100644 --- a/apps/rungps/gpx_maker.js +++ b/apps/rungps/gpx_maker.js @@ -1,3 +1,4 @@ +/* eslint-disable */ const createXmlString = lines => { // Normalize input into an array of segments, each a list of point objects // Supported inputs: From 23df8dd99996cc344502f6c2830ff5287505ab87 Mon Sep 17 00:00:00 2001 From: kasperrey Date: Sat, 11 Oct 2025 12:59:31 +0200 Subject: [PATCH 18/18] Added screenshot + adjustments --- apps/rungps/README.md | 7 +++++++ apps/rungps/interface.html | 27 +++++++++++++++++++++++++++ apps/rungps/metadata.json | 19 +++++++++++-------- apps/rungps/screenshot.png | Bin 0 -> 1251 bytes 4 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 apps/rungps/screenshot.png diff --git a/apps/rungps/README.md b/apps/rungps/README.md index 1954421037..9b68767fc8 100644 --- a/apps/rungps/README.md +++ b/apps/rungps/README.md @@ -40,3 +40,10 @@ You can also view or download your results directly from the **App Loader**. - Make sure your **GPS** is active before starting a run. It is ready when the distance and speed starts changing. - Recorded data includes time, distance, heart rate, ... - Results are saved on the watch and can be exported for analysis. + +--- + +## See Also + +If you’re looking for more advanced running features like pace tracking, auto-pausing, or customizable screens, +check out the official **[Run+](https://banglejs.com/apps/?id=runplus)** app in the App Loader. diff --git a/apps/rungps/interface.html b/apps/rungps/interface.html index 579646d248..5f601bcd86 100644 --- a/apps/rungps/interface.html +++ b/apps/rungps/interface.html @@ -26,6 +26,7 @@ + @@ -87,6 +88,32 @@ const distanceStr = (document.getElementById("distance").innerText || "0km"); window.gpx_manager.downloadTcxFile(points, [distanceStr], "km"); }); + +// Delete selected file from watch storage +// Uses same filename pattern as Read: "rungps..csv" with fallback to default +// After delete, clear UI state +function getSelectedFileName() { + const val = (document.getElementById("input").value || "").trim(); + return val ? `rungps.${val}.csv` : "gpspoilog.csv"; +} + +document.getElementById("Delete").addEventListener("click", () => { + const fileName = getSelectedFileName(); + Util.eraseStorageFile(fileName, function() { + console.log("Deleted:", fileName); + try { alert(`Deleted ${fileName}`); } catch (e) { /* ignore if alert not available */ } + // reset state + text = ""; + points = []; + // clear polylines group from map + try { + groep.eachLayer(l => map.removeLayer(l)); + if (groep.clearLayers) groep.clearLayers(); + } catch(e) {} + document.getElementById("distance").innerText = "0km"; + document.getElementById("averageSpeed").innerText = "0:0min/km"; + }); +}); diff --git a/apps/rungps/metadata.json b/apps/rungps/metadata.json index 4463e511b5..cfbbc3b964 100644 --- a/apps/rungps/metadata.json +++ b/apps/rungps/metadata.json @@ -1,18 +1,21 @@ { "id": "rungps", "name": "Running", - "shortName":"Running", - "version":"0.02", - "description": "This app is a app for running and see your prestasion!", + "shortName": "Running", + "version": "0.02", + "description": "This app is an app for running and viewing your performance!", "icon": "app.png", "tags": "tool,outdoors,gps", - "supports" : ["BANGLEJS2"], + "supports": ["BANGLEJS2"], "readme": "README.md", "interface": "interface.html", "storage": [ - {"name":"rungps.app.js","url":"app.js"}, - {"name":"rungps.img","url":"app-icon.js","evaluate":true} + { "name": "rungps.app.js", "url": "app.js" }, + { "name": "rungps.img", "url": "app-icon.js", "evaluate": true } ], "data": [ - {"wildcard":"rungps..?.csv","storageFile":true} + { "wildcard": "rungps..?.csv", "storageFile": true } + ], + "screenshots": [ + { "url": "screenshot.png" } ] -} +} \ No newline at end of file diff --git a/apps/rungps/screenshot.png b/apps/rungps/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..e9e4cc26a4c2b1f5166cbfcf3914000bff0051d9 GIT binary patch literal 1251 zcmeAS@N?(olHy`uVBq!ia0vp^8$g(a8A!&?{F%$Zz}S-M>>S|f?5t2wl%JNFlghxL zF|l@{t;gX2kyiiUr9s+!WdemA0dwChQj*OU?Fvx5!nM|F#-17QniPI8Khm?%+~v{vUA0wgquq`@{h#ky-YJ(?FwyeaT=l47+3hr|nF5=4 zh8lk8QuwtpNn-NM)H9wzT(Q@$+t$?V@BXyr-S>Yn%vtx68vIrIj1O<=H19|^&u#u} zS2$y8%G4ioDlRQy>y+QQuHw(Joi8>WFg~27X6rAbCe|&xro=#drqoBCLYJ#0r^GkO zt)8>pB73S-_Sci~5~p5kpN?5BNSo@LEw@%U-jOlv5s43Yn++k7TAZNO5r8yp--f>FI&`#;a@$ zIp$8WlbEJvy=_>(UT1Yo)AlS06Z@8*Qa^nrtm`kyjv70mRVkW;(id%Miyn<>20BNwh?eaLfcfxv+S>o&A` z>M?$2<-UGRR*v=lfdeNNw`x|*P*3FAb@2S5+@wFwA0p-V^BitZ2>Lo-#-`Zco^$=R zuJXj+5fyyfx^HhbsAnnDp4RSpcuN~&&B3c%PcO4tuel>t__?NgBj3Yqi@2(G$*pGZ zjrw%`yiM47Hok;+%X4CHb7xnanPH~1JHDuxH(OY|^dW=Y?GJ8uTZH{A9-LG4{-lO(qX!PIR7Qdhy17 zpIPM_R{DBNa9-KoqA|;DtH29`&7n&qo-rC1TRdm?$>a0L?Mti^epLo2mkzS%|zY&Z%+8dGH>H&y;*z-vO$-NY_BnXepqFbKjCsr)LVHO-MmcR`Ohv@ s?cX)^&H7f3b1_yJ!3YW3f2=?B-xg`;a35IJ1x(Zop00i_>zopr0GCKZumAu6 literal 0 HcmV?d00001