Prechádzať zdrojové kódy

Initial mirror from https://github.com/knrdl/bicimon.git

This repository was automatically mirrored.
mitch donaberger 3 mesiacov pred
commit
af3aadbabd

+ 17 - 0
.github/workflows/github-page.yml

@@ -0,0 +1,17 @@
+name: Github Page Publish
+
+on:
+  push:
+    branches:
+      - main
+
+jobs:
+  github-page:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v2
+      - name: Deploy
+        uses: peaceiris/actions-gh-pages@v3
+        with:
+          github_token: ${{ secrets.GITHUB_TOKEN }}
+          publish_dir: ./www

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+.idea

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 knrdl
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 12 - 0
README.md

@@ -0,0 +1,12 @@
+![Notice, this repository was mirrored to here from Github](https://m1s5.c20.e2-5.dev/files/images/mirror-notice.svg)
+
+# BiCiMon
+
+## Bike Speedometer
+
+* No tracking (except geolocation obviously)
+* No data storage
+* No bullshit
+* PWA ready
+
+[Click logo to open<br>![Logo](www/images/logo.png)](https://knrdl.github.io/bicimon/)

BIN
www/images/icons/icon-128x128.png


BIN
www/images/icons/icon-144x144.png


BIN
www/images/icons/icon-152x152.png


BIN
www/images/icons/icon-192x192.png


BIN
www/images/icons/icon-384x384.png


BIN
www/images/icons/icon-512x512.png


BIN
www/images/icons/icon-72x72.png


BIN
www/images/icons/icon-96x96.png


BIN
www/images/logo.png


+ 27 - 0
www/index.css

@@ -0,0 +1,27 @@
+.level {
+    border-top-color: #dbdbdb;
+    border-top-style: solid;
+    border-top-width: 1px;
+    padding-top: .75rem;
+    padding-bottom: .5rem;
+    margin-bottom: 0 !important;
+}
+
+.level.dark-bg {
+    background: #0001;
+}
+
+.level-item .title small {
+    font-size: 60%;
+}
+
+math.formula {
+    font-family: sans-serif;
+    font-size: x-small;
+}
+
+.scale {
+    border-left: 1px solid black;
+    border-right: 1px solid black;
+    border-bottom: 1px solid #ccc;
+}

+ 202 - 0
www/index.html

@@ -0,0 +1,202 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>BiCiMon</title>
+    <link rel="shortcut icon" type="image/png" href="images/logo.png">
+    <script src="https://unpkg.com/alpinejs@3.9.5/dist/cdn.min.js" defer
+            integrity="sha384-uvrjP84ugVTfHtrnvF3/rM9lWkcO9x7yPo/Pnr9JjHbWjj6dbv5aL1Wmsu6HadlI"
+            crossorigin="anonymous"></script>
+    <script src="https://unpkg.com/nosleep.js@0.12.0/dist/NoSleep.min.js" defer
+            integrity="sha384-lp0XiAMtqqqjyVLBYjAcDQzxc2XyGj3sGESoiWbccHdpXjNUPUjWrq5GMqaGHGp9"
+            crossorigin="anonymous"></script>
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css"
+          integrity="sha384-IJLmUY0f1ePPX6uSCJ9Bxik64/meJmjSYD7dHaJqTXXEBE4y+Oe9P2KBZa/z7p0Q" crossorigin="anonymous">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <link rel="manifest" href="./manifest.json">
+    <meta name="mobile-web-app-capable" content="yes"/>
+    <meta name="mobile-web-app-status-bar-style" content="black"/>
+    <meta name="mobile-web-app-title" content="BiCiMon">
+    <meta name="theme-color" content="#2A3443"/>
+    <meta name="description" content="Bike Speedometer">
+    <link rel="stylesheet" href="./index.css">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+</head>
+<body>
+<div class="container" x-data="main">
+    <div class="notification is-danger m-1" x-show="error" x-transition>
+        <span x-text="error"></span>
+        <button class="delete" @click="error = ''"></button>
+    </div>
+    <nav class="tabs is-boxed pb-3 mb-0" style="top: 0; padding-top: 3px; position: sticky; background: white">
+        <ul>
+            <li x-bind:class="page === 'dashboard' ? 'is-active' : ''" style="margin-left: 1rem"
+                @click="page = 'dashboard'">
+                <a>Dashboard</a>
+            </li>
+            <li x-bind:class="page === 'course' ? 'is-active' : ''" @click="page = 'course'">
+                <a>Course</a>
+            </li>
+            <li x-bind:class="page === 'settings' ? 'is-active' : ''" @click="page = 'settings'">
+                <a>🔧</a>
+            </li>
+            <li style="margin-left: auto; margin-right: 1rem" x-show="heading">
+                <span>🧭</span> <span x-text="fmtHeading()"></span>
+            </li>
+        </ul>
+    </nav>
+
+    <div x-show="page === 'dashboard'" x-transition>
+        <template x-if="!time.update">
+            <div>
+                <div class="has-text-centered">Awaiting geolocation ...</div>
+                <progress class="progress is-small is-dark" max="100"></progress>
+            </div>
+        </template>
+        <div class="has-text-centered">
+            <div>
+                <div class="title" style="font-size: 15rem; color: black; margin-bottom: 0; line-height: .8"
+                     x-text="fmtCurrentSpeed()"></div>
+                <div class="heading" style="font-weight: bold">
+                    km/h <span style="font-weight: normal; text-transform: initial"
+                               x-show="time.updateDelay !== null">&mdash;
+                        <span x-text="fmtUpdateDelay()"></span></span>
+                </div>
+            </div>
+        </div>
+
+        <div class="level is-mobile dark-bg">
+            <div class="level-item has-text-centered">
+                <div>
+                    <p class="heading">🏔 Altitude</p>
+                    <p class="title">
+                        <span x-text="round(altitude.current)"></span><small>±<span
+                            x-text="round(altitude.accuracy)"></span>m</small>
+                    </p>
+                </div>
+            </div>
+            <div class="level-item has-text-centered">
+                <div>
+                    <p class="heading">📍 Accuracy</p>
+                    <p class="title">
+                        <small>±</small><span x-text="round(positionAccuracy)"></span><small>m</small>
+                    </p>
+                </div>
+            </div>
+        </div>
+
+        <div class="level is-mobile">
+            <div class="level-item has-text-centered">
+                <div>
+                    <p class="heading">🚴 Max</p>
+                    <p class="title" x-text="fmtMaxSpeed()"></p>
+                    <p class="heading">km/h</p>
+                </div>
+            </div>
+            <div class="level-item has-text-centered">
+                <div>
+                    <p class="heading">Ø Avg</p>
+                    <p class="title" x-text="fmtAvgSpeed()"></p>
+                    <p class="heading">km/h</p>
+                </div>
+            </div>
+            <div class="level-item has-text-centered">
+                <div>
+                    <p class="heading">⌛ Avg-Wait</p>
+                    <p class="title" x-text="fmtAvgNoWaitSpeed()"></p>
+                    <p class="heading">km/h</p>
+                </div>
+            </div>
+        </div>
+
+        <div class="level is-mobile dark-bg">
+            <div class="level-item has-text-centered">
+                <div>
+                    <p class="heading">⌚ Start</p>
+                    <p class="title" x-text="fmtStartTime()"></p>
+                </div>
+            </div>
+            <div class="level-item has-text-centered">
+                <div>
+                    <p class="heading">⏱ Trip</p>
+                    <p class="title" x-text="fmtTripTime()"></p>
+                </div>
+            </div>
+            <div class="level-item has-text-centered">
+                <div>
+                    <p class="heading">⌛ Wait</p>
+                    <p class="title" x-text="fmtWaitTime()"></p>
+                </div>
+            </div>
+        </div>
+    </div>
+    <template x-if="page === 'course'">
+        <div class="mx-1 mb-5">
+            <div class="is-flex is-justify-content-space-between px-2 scale">
+                <span>0
+                    <math class="formula">
+                        <mfrac>
+                            <mn>km</mn>
+                            <mn>h</mn>
+                        </mfrac>
+                    </math>
+                </span>
+                <span>
+                    <span x-text="fmtMaxSpeed()"></span>
+                    <math class="formula">
+                        <mfrac>
+                            <mn>km</mn>
+                            <mn>h</mn>
+                        </mfrac>
+                    </math>
+                </span>
+            </div>
+            <template x-for="(sp, idx) in speed.timeseries.values">
+                <div>
+                    <div
+                            :class="{'has-background-danger': sp < speed.minRide, 'has-background-dark': sp >= speed.minRide}"
+                            :style="{width: (sp * 100 / speed.max) + '%', height: clamp(1000 / speed.timeseries.values.length, 1, 10) + 'px'}">
+                    </div>
+                </div>
+            </template>
+        </div>
+    </template>
+    <div x-show="page === 'settings'" x-transition>
+        <section class="hero is-link" style="position: relative">
+            <div class="hero-body">
+                <p class="title">
+                    BiCiMon
+                </p>
+                <p class="subtitle">
+                    Bike Speedometer
+                </p>
+            </div>
+
+
+            <a href="https://github.com/knrdl/bicimon#readme" target="_blank" rel="noopener noreferrer"
+               aria-label="View source on GitHub" style="position: absolute; right:0; top: 0">
+                <svg width="80" height="80" viewBox="0 0 250 250"
+                     style="fill:#151513; color:#fff;"
+                     aria-hidden="true">
+                    <path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path>
+                    <path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2"
+                          fill="currentColor" style="transform-origin: 130px 106px;"></path>
+                    <path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z"
+                          fill="currentColor"></path>
+                </svg>
+            </a>
+        </section>
+
+        <div class="is-flex is-justify-content-center mt-5">
+            <button @click="toggleNoSleep" class="is-rounded button"
+                    :class="{'is-outlined': !noSleep, 'is-success': noSleep, 'is-info': !noSleep}">
+                <span x-show="noSleep" class="is-size-3">✓</span>
+                Try to keep screen on
+            </button>
+        </div>
+    </div>
+</div>
+
+<script src="./index.js"></script>
+</body>
+</html>

+ 181 - 0
www/index.js

@@ -0,0 +1,181 @@
+window.addEventListener('error', ((event, source, lineno, colno, error) => {
+    alert(source + ' ' + lineno + ',' + colno + ': ' + error.message)
+}))
+
+const MIN_SPEED = 3 // km/h
+
+function round(value, decimals = 0) {
+    return Math.round(value * (10 ** decimals)) / (10 ** decimals)
+}
+
+function fmtHhMm(h, m) {
+    return (h < 10 ? '0' + h : h) + ':' + (m < 10 ? '0' + m : m)
+}
+
+function fmtDuration(seconds, resolution = null) {
+    const divmod = (x, y) => [Math.floor(x / y), x % y];
+    let days = 0, hours = 0, mins = 0, secs = seconds;
+    [mins, secs] = divmod(secs, 60);
+    [hours, mins] = divmod(mins, 60);
+    [days, hours] = divmod(hours, 24);
+    switch (resolution) {
+        case "d":
+            hours = mins = secs = 0
+            break
+        case "h":
+            mins = secs = 0
+            break
+        case "m":
+            secs = 0
+            break
+        case "s":
+            secs = Math.floor(secs)
+            break
+    }
+    const fmtToken = (value, unit) => ((value > 0 ? value + unit + ' ' : ''))
+    const result = fmtToken(days, 'd') + fmtToken(hours, 'h') + fmtToken(mins, 'm') + fmtToken(secs, 's')
+    if (result.length > 0) {
+        return result.trim()
+    } else {
+        return '0' + (resolution || 's')
+    }
+}
+
+document.addEventListener('alpine:init', () => {
+    Alpine.data('main', () => ({
+        page: 'dashboard',
+        error: '',
+        speed: {
+            minRide: MIN_SPEED, // km/h
+            current: 0, // km/h
+            max: 0, // km/h
+            avg: 0, // km/h
+            nowaitAvg: 0, // km/h
+            timeseries: {
+                cache: [],
+                values: []
+            }
+        },
+        time: {
+            start: new Date(), // date
+            update: null, // epoch number
+            waitDuration: 0,  // secs
+            tripDuration: 0,  // secs
+            updateDelay: null, //secs
+        },
+        altitude: {
+            current: null, // meter
+            accuracy: null // meter
+        },
+        heading: null,  // 0.0-360.0 degrees
+        positionAccuracy: null, // meter
+
+        noSleep: null,
+
+        init() {
+            if (navigator.geolocation) {
+                setInterval(() => {
+                    const now = new Date()
+                    this.time.tripDuration = (now.getTime() - this.time.start.getTime()) / 1000
+                    if (this.time.update)
+                        this.time.updateDelay = Math.floor((now.getTime() - this.time.update) / 1000)
+
+                    if (!isNaN(this.speed.current)) {
+                        this.speed.timeseries.cache.push(this.speed.current)
+
+                        if (Math.floor(now.getTime() / 1000) % 5 === 0) {
+                            if (this.speed.timeseries.cache.length > 0) {
+                                const currentAvg = this.speed.timeseries.cache.reduce((sum, val) => sum + val, 0) / this.speed.timeseries.cache.length
+                                this.speed.timeseries.values.push(currentAvg)
+                                this.speed.timeseries.cache = []
+                            }
+                            if (this.speed.timeseries.values.length > 0) {
+                                let sumTotal = 0, sumNoWait = 0, countNoWait = 0
+                                for (const sp of this.speed.timeseries.values) {
+                                    sumTotal += sp
+                                    if (sp >= MIN_SPEED) {
+                                        sumNoWait += sp
+                                        countNoWait++
+                                    }
+                                }
+                                this.speed.avg = sumTotal / this.speed.timeseries.values.length
+                                this.speed.nowaitAvg = (sumNoWait / countNoWait) || 0
+                            }
+                        }
+
+                        if (this.speed.current < MIN_SPEED) {
+                            this.time.waitDuration++
+                        }
+                    }
+                }, 1000)
+
+                navigator.geolocation.watchPosition(({timestamp, coords}) => {
+                    this.time.update = timestamp
+                    this.speed.current = coords.speed * 3.6  // m/s to km/h
+                    if (!this.speed.max || this.speed.current > this.speed.max) this.speed.max = this.speed.current
+                    this.altitude.current = coords.altitude
+                    this.altitude.accuracy = coords.altitudeAccuracy
+                    this.heading = coords.heading
+                    this.positionAccuracy = coords.accuracy
+                }, err => {
+                    this.error = err.message
+                }, {
+                    enableHighAccuracy: true,
+                    timeout: 15_000
+                })
+            } else {
+                alert("Geolocation is not supported by this browser.")
+            }
+        },
+        fmtStartTime() {
+            return fmtHhMm(this.time.start.getHours(), this.time.start.getMinutes())
+        },
+        fmtTripTime() {
+            return fmtDuration(this.time.tripDuration, 'm')
+        },
+        fmtWaitTime() {
+            return fmtDuration(this.time.waitDuration)
+        },
+        fmtUpdateDelay() {
+            return fmtDuration(this.time.updateDelay) + ' ago'
+        },
+        fmtHeading() {
+            const directions = ['N', 'NE', 'NE', 'NE', 'E', 'SE', 'SE', 'SE', 'S', 'SW', 'SW', 'SW', 'W', 'NW', 'NW', 'NW', 'N']
+            return directions[round(this.heading / 22.5)]
+        },
+        fmtCurrentSpeed() {
+            return round(this.speed.current)
+        },
+        fmtMaxSpeed() {
+            return round(this.speed.max, 1).toLocaleString(undefined, {minimumFractionDigits: 1})
+        },
+        fmtAvgSpeed() {
+            return round(this.speed.avg || 0, 1).toLocaleString(undefined, {minimumFractionDigits: 1})
+        },
+        fmtAvgNoWaitSpeed() {
+            return round(this.speed.nowaitAvg || 0, 1).toLocaleString(undefined, {minimumFractionDigits: 1})
+        },
+        clamp(value, min, max) {
+            return Math.min(Math.max(value, min), max)
+        },
+        toggleNoSleep() {
+            if (this.noSleep) {
+                this.noSleep.disable()
+                this.noSleep = null
+            } else {
+                this.noSleep = new NoSleep()
+                this.noSleep.enable()
+            }
+        }
+    }))
+})
+
+window.addEventListener('load', async () => {
+    if ('serviceWorker' in navigator) {
+        try {
+            await navigator.serviceWorker.register('./sw.js');
+        } catch (e) {
+            console.error(e)
+        }
+    }
+})

+ 53 - 0
www/manifest.json

@@ -0,0 +1,53 @@
+{
+  "name": "BiciMon",
+  "short_name": "BiciMon",
+  "theme_color": "#4d6de3",
+  "background_color": "#363636",
+  "display": "standalone",
+  "description": "Bike Speedometer",
+  "orientation": "portrait",
+  "scope": ".",
+  "start_url": ".",
+  "icons": [
+    {
+      "src": "images/icons/icon-72x72.png",
+      "sizes": "72x72",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-96x96.png",
+      "sizes": "96x96",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-128x128.png",
+      "sizes": "128x128",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-144x144.png",
+      "sizes": "144x144",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-152x152.png",
+      "sizes": "152x152",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-192x192.png",
+      "sizes": "192x192",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-384x384.png",
+      "sizes": "384x384",
+      "type": "image/png"
+    },
+    {
+      "src": "images/icons/icon-512x512.png",
+      "sizes": "512x512",
+      "type": "image/png"
+    }
+  ]
+}

+ 10 - 0
www/sw.js

@@ -0,0 +1,10 @@
+self.addEventListener('fetch', async event => {
+    const cache = await caches.open('sw-cache')
+    try {
+        const fresh = await fetch(event.request)
+        cache.put(event.request, fresh.clone()).catch(e => console.error(e))
+        event.respondWith(fresh)
+    } catch (e) {
+        event.respondWith(cache.match(event.request))
+    }
+})