├── .gitignore ├── LICENSE.txt ├── README.md ├── data ├── knobstone-sites.gpx └── knobstone-trail-kt.gpx ├── favicon.ico ├── img ├── icon.png └── markers │ ├── campground.png │ ├── feature.png │ ├── mile.png │ ├── parking.png │ └── water.png ├── index.html ├── manifest.webmanifest ├── pace.html ├── scripts ├── app.js └── pace.js ├── styles ├── app.css └── pace.css └── sw.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Thumbs.db 3 | *.log 4 | node_modules/ 5 | public/ 6 | .deploy*/ 7 | *.sublime-project 8 | *.sublime-workspace 9 | 10 | .vercel 11 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jameal Ghaznawi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KT Companion 2 | 3 | A knowledge-of-the-trail companion app, originally developed for my backpacking trip on the Knobstone Trail in my home state of southern Indiana. Consider it a fully customizable alternative to something like *AllTrails* or *HikingProject*. 4 | 5 | 🔗 [Try it out here](https://kt-companion.vercel.app/). 6 | 7 | ![Animated screenshot demo of the app](https://prj.jameals.com/kt/kt-app-demo.gif) 8 | 9 | This project was rapidly developed over the course of two weeks. Although it served me well on my trip, it is not advisable to rely on it as your only resource. You should always research your trip ahead of time, plan training hikes, become familiar with the terrain, carry a physical compass and map and know how to use them. 10 | 11 | It relies heavily on the wonderful open-source map library [Leaflet](https://leafletjs.com/) and many more plugins from the Leaflet community. 12 | 13 | ## ⭐️ Features 14 | - **Toggle location marker** - Save battery life by only turning on location when you need it. 15 | - **Offline-ready including map tiles** - Hold your device in landscape mode and press the 💾 button to download visible map tiles. 16 | - **Fullscreen app experience** - For Android devices (not sure about Safari iOS). 17 | - **Elevation profile** - Double-tap the elevation profile to zoom. 18 | - **Manual pace calulator** - Start/stop the timer and input your miles to see your pace and expected completion time. 19 | - **No complicated build configuration** - All third-party libraries are loaded via CDN. Bring your own build tools if you need it. 20 | - **Tap the map to copy coordinates** - Then paste into your notes app. 21 | 22 | ## 🎨 Customization and deployment 23 | 1. Fork the project or download the code directly 24 | 2. Replace the contents of `knobstone-trail-kt.gpx` with your own GPX data *(you can often find GPX files for popular trails online, download the GPX from your smartwatch, or use a tool like [the excellent GPS Visualizer](https://www.gpsvisualizer.com/) to draw your trail directly on a map)* 25 | 3. Replace the content of `knobstone-sites.gpx` to customize markers 26 | 5. Deploy to any web server 27 | 6. Customize map tiles - [A list of map tile providers can be found here](https://leaflet-extras.github.io/leaflet-providers/preview/) 28 | 29 | ## Room for improvement 30 | There are a few areas that need polish: 31 | - Receiving updates requires reinstalling the app or clearing your cache 32 | - Not so modular - most Javascript and styles are written in a single file 33 | - The pace calculator was written with in an imperative style and needs refactoring 34 | 35 | 36 | ## Cool features you could add 37 | - Track location over time and display it on the map. Turn that into a downloadable GPX 38 | - Make the pace calculator automatic based on location tracking 39 | - Tap to add notes and markers 40 | -------------------------------------------------------------------------------- /data/knobstone-sites.gpx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Pixely Knob Rd Trailhead 5 | Parking Area 6 | 7 | 8 | New Chapel Trailhead 9 | Parking Area 10 | 11 | 12 | Jackson Rd Trailhead 13 | Parking Area 14 | 15 | 16 | Leota Trailhead 17 | Parking Area 18 | 19 | 20 | Elk Creek Trailhead 21 | Parking Area 22 | 23 | 24 | Oxley Memorial Trailhead 25 | Parking Area 26 | 27 | 28 | Delaney Park Trailhead 29 | Parking Area 30 | 31 | 32 | Spurgeon Hollow Trailhead 33 | Parking Area 34 | 35 | 36 | Camp 10.5 37 | Campground 38 | 39 | 40 | Camp 18+ 41 | Campground 42 | 43 | 44 | Camp 19.4 45 | Campground 46 | 47 | 48 | Camp ~31 49 | Campground 50 | 51 | 52 | Camp 39.3 53 | Campground 54 | 55 | 56 | Camp ~41 (before 41) 57 | Campground 58 | 59 | 60 | Water Stash 61 | Water Source 62 | 63 | 64 | Possible Pond (40.8) 65 | Water Source 66 | 67 | 68 | 1 69 | Mile Marker 1 70 | 71 | 72 | 2 73 | Mile Marker 2 74 | 75 | 76 | 3 77 | Mile Marker 3 78 | 79 | 80 | 4 81 | Mile Marker 4 82 | 83 | 84 | 5 85 | Mile Marker 5 86 | 87 | 88 | 6 89 | Mile Marker 6 90 | 91 | 92 | 7 93 | Mile Marker 7 94 | 95 | 96 | 8 97 | Mile Marker 8 98 | 99 | 100 | 9 101 | Mile Marker 9 102 | 103 | 104 | 10 105 | Mile Marker 10 106 | 107 | 108 | 11 109 | Mile Marker 11 110 | 111 | 112 | 12 113 | Mile Marker 12 114 | 115 | 116 | 13 117 | Mile Marker 13 118 | 119 | 120 | 14 121 | Mile Marker 14 122 | 123 | 124 | 15 125 | Mile Marker 15 126 | 127 | 128 | 16 129 | Mile Marker 16 130 | 131 | 132 | 17 133 | Mile Marker 17 134 | 135 | 136 | 18 137 | Mile Marker 18 138 | 139 | 140 | 19 141 | Mile Marker 19 142 | 143 | 144 | 20 145 | Mile Marker 20 146 | 147 | 148 | 21 149 | Mile Marker 21 150 | 151 | 152 | 22 153 | Mile Marker 22 154 | 155 | 156 | 23 157 | Mile Marker 23 158 | 159 | 160 | 24 161 | Mile Marker 24 162 | 163 | 164 | 25 165 | Mile Marker 25 166 | 167 | 168 | 26 169 | Mile Marker 26 170 | 171 | 172 | 27 173 | Mile Marker 27 174 | 175 | 176 | 28 177 | Mile Marker 28 178 | 179 | 180 | 29 181 | Mile Marker 29 182 | 183 | 184 | 30 185 | Mile Marker 30 186 | 187 | 188 | 31 189 | Mile Marker 31 190 | 191 | 192 | 32 193 | Mile Marker 32 194 | 195 | 196 | 33 197 | Mile Marker 33 198 | 199 | 200 | 34 201 | Mile Marker 34 202 | 203 | 204 | 35 205 | Mile Marker 35 206 | 207 | 208 | 36 209 | Mile Marker 36 210 | 211 | 212 | 37 213 | Mile Marker 37 214 | 215 | 216 | 38 217 | Mile Marker 38 218 | 219 | 220 | 39 221 | Mile Marker 39 222 | 223 | 224 | 40 225 | Mile Marker 40 226 | 227 | 228 | 42 229 | Mile Marker 42 230 | 231 | 232 | 41 233 | Mile Marker 41 234 | 235 | 236 | 43 237 | Mile Marker 43 238 | 239 | 240 | 44 241 | Mile Marker 44 242 | 243 | 244 | 45 245 | Mile Marker 45 246 | 247 | 248 | 46 249 | Mile Marker 46 250 | 251 | 252 | 47 253 | Mile Marker 47 254 | 255 | 256 | 48 257 | Mile Marker 48 258 | 259 | 260 | 49 261 | Mile Marker 49 262 | 263 | 264 | 50 265 | Mile Marker 50 266 | 267 | 268 | 269 | 270 | Feature 271 | 272 | 273 | 274 | Feature 275 | 276 | 277 | 278 | Feature 279 | 280 | 281 | Tree line opens up to an established campsite with 360 view. Down the east side of the hill secondary trails lead to large overhanging rocks with more carvings 282 | Campground 283 | 284 | 285 | The trail gradualy leads downhill, immediately meeting a camp north of the trail, located along a cliff 286 | Campground 287 | 288 | 289 | Camp 14- 290 | Campground 291 | 292 | 293 | Horse trail joins, may be difficult to traverse 294 | Feature 295 | 296 | 297 | Large shale wall. May be water 298 | Feature 299 | 300 | 301 | Campsite 24+ 302 | Campground 303 | 304 | 305 | Campsite 23.4 306 | Campground 307 | 308 | 309 | Large campsite right before the uphill 310 | Campground 311 | 312 | 313 | Camp 29.6 314 | Campground 315 | 316 | 317 | Camp 25.8+ 318 | Campground 319 | 320 | 321 | Camp 33.7 322 | Campground 323 | 324 | 325 | Campsite (approx loc) 326 | Campground 327 | 328 | 329 | Campsite (approx loc.) 330 | Campground 331 | 332 | 333 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamealg/KT-companion/2761ccefbf751ad5ccc71a64e69418450e5d521c/favicon.ico -------------------------------------------------------------------------------- /img/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamealg/KT-companion/2761ccefbf751ad5ccc71a64e69418450e5d521c/img/icon.png -------------------------------------------------------------------------------- /img/markers/campground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamealg/KT-companion/2761ccefbf751ad5ccc71a64e69418450e5d521c/img/markers/campground.png -------------------------------------------------------------------------------- /img/markers/feature.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamealg/KT-companion/2761ccefbf751ad5ccc71a64e69418450e5d521c/img/markers/feature.png -------------------------------------------------------------------------------- /img/markers/mile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamealg/KT-companion/2761ccefbf751ad5ccc71a64e69418450e5d521c/img/markers/mile.png -------------------------------------------------------------------------------- /img/markers/parking.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamealg/KT-companion/2761ccefbf751ad5ccc71a64e69418450e5d521c/img/markers/parking.png -------------------------------------------------------------------------------- /img/markers/water.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamealg/KT-companion/2761ccefbf751ad5ccc71a64e69418450e5d521c/img/markers/water.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | KT Companion 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |
18 |
19 | 20 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "KT Companion", 3 | "short_name": "KT", 4 | "description": "A companion app for navigating the Knobstone Trail in southern Indiana", 5 | "display": "fullscreen", 6 | "background_color": "#58613e", 7 | "theme_color": "#58613e", 8 | "icons": [ 9 | { 10 | "src": "img/icon.png", 11 | "sizes": "192x192", 12 | "type": "image/png" 13 | } 14 | ], 15 | "start_url": "/index.html" 16 | } 17 | -------------------------------------------------------------------------------- /pace.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | KT Companion – Pace Calculator 6 | 7 | 8 | 9 | 10 | 11 |
12 |

...

13 |
14 | 15 | 16 |
17 |
18 | 19 |
20 | 22 |
23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 |
31 | 32 | 33 |
34 |
35 |
36 | Hiking: 37 |
38 |
39 | Breaks: 40 |
41 |
42 |
43 | Total: 44 |
45 |
46 | 47 |
48 |

49 | 61 | 62 | 63 |

64 |

65 | Overall Pace: 66 | 67 |

68 |

69 | Hiking Pace: 70 | 71 |

72 |
73 | 74 |
75 |

76 | 88 | 89 | 90 |

91 |

92 | Finish Time (Overall): 93 | 94 |

95 |

96 | Finish Time (Hiking): 97 | 98 |

99 |
100 | 101 | 102 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /scripts/app.js: -------------------------------------------------------------------------------- 1 | // Service worker 2 | let loc = document.location.hostname.toString(); 3 | if ('serviceWorker' in navigator && !loc.includes('localhost') && !loc.includes('192.')) { 4 | const x = navigator.serviceWorker.register('/sw.js').then((registration) => { 5 | console.log('Service worker registration succeeded:', registration); 6 | }, /*catch*/ (error) => { 7 | console.error(`Service worker registration failed: ${error}`); 8 | }); 9 | } 10 | 11 | 12 | 13 | // util 14 | function flash(key) { 15 | let $s = document.querySelector('[data-jam]'); 16 | if (!$s) { 17 | let s = document.createElement('span'); 18 | s.setAttribute('data-jam', true); 19 | s.style = ` 20 | position: absolute; 21 | top: 25%; 22 | left: 50%; 23 | background: rgba(0,0,0,0.75); 24 | border-radius: 4px; 25 | padding: 1em; 26 | font-size: 1rem; 27 | color: #fff; 28 | transform: translateX(-50%); 29 | transition: opacity .3s ease-out; 30 | font-family: sans-serif; 31 | `; 32 | $s = document.body.appendChild(s); 33 | 34 | setTimeout(()=>{ 35 | $s.style.opacity = 0; 36 | }, 1000); 37 | 38 | setTimeout(()=>{ 39 | $s.remove(); 40 | }, 1500); 41 | } 42 | $s.innerText = key; 43 | } 44 | 45 | 46 | 47 | // Map 48 | var map = L.map('map', { 49 | attributionControl: true, 50 | zoomControl: false, 51 | minZoom: 9, 52 | maxZoom: 16, 53 | zoomSnap: 0.1, 54 | }); 55 | map.getRenderer(map).options.padding = 100; 56 | map.on('zoomend', () => { 57 | // flash(`Zoom ${Math.round(map.getZoom())}`); 58 | console.log(`Zoom ${Math.round(map.getZoom())}`); 59 | if (map.getZoom() > 12) { 60 | document.body.classList.remove('is-zoom-mid'); 61 | document.body.classList.add('is-zoom-strong'); 62 | } else if (map.getZoom() > 10.5) { 63 | document.body.classList.add('is-zoom-mid'); 64 | document.body.classList.remove('is-zoom-strong'); 65 | } else { 66 | document.body.classList.remove('is-zoom-strong'); 67 | document.body.classList.remove('is-zoom-mid'); 68 | } 69 | }); 70 | 71 | // Write geo coordinates to clipboard on tap 72 | map.on('click', function(e){ 73 | // var marker = new L.marker(e.latlng).addTo(map); 74 | let lat = e.latlng.lat.toFixed(7); 75 | let lng = e.latlng.lng.toFixed(7); 76 | let latlng = `${lat}, ${lng}`; 77 | navigator.clipboard.writeText(latlng); 78 | }); 79 | 80 | // Add locate control 81 | L.control.locate().addTo(map); 82 | 83 | // Tiles 84 | let urlTemplate = 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}.jpg'; 85 | let baseLayer = L.tileLayer 86 | .offline(urlTemplate, { 87 | attribution: 'Relief maps from ESRI/ArcGIS' 88 | // minZoom: 11, 89 | }) 90 | .addTo(map); 91 | 92 | 93 | // Elevation control 94 | var elevation_options = { 95 | 96 | // Default chart colors: theme lime-theme, magenta-theme, ... 97 | theme: "lightblue-theme", 98 | 99 | // Chart container outside/inside map container 100 | detached: true, 101 | height: window.innerHeight * .3, 102 | 103 | // if (detached), the elevation chart container 104 | elevationDiv: ".elevation-wrapper > div", 105 | 106 | // if (!detached) autohide chart profile on chart mouseleave 107 | autohide: false, 108 | 109 | // if (!detached) initial state of chart profile control 110 | collapsed: false, 111 | 112 | // if (!detached) control position on one of map corners 113 | position: "topright", 114 | 115 | // Toggle close icon visibility 116 | closeBtn: false, 117 | 118 | // Autoupdate map center on chart mouseover. 119 | followMarker: true, 120 | 121 | // Autoupdate map bounds on chart update. 122 | autofitBounds: true, 123 | 124 | // Chart distance/elevation units. 125 | imperial: true, 126 | 127 | // [Lat, Long] vs [Long, Lat] points. (leaflet default: [Lat, Long]) 128 | reverseCoords: false, 129 | 130 | // Acceleration chart profile: true || "summary" || "disabled" || false 131 | acceleration: false, 132 | 133 | // Slope chart profile: true || "summary" || "disabled" || false 134 | slope: false, 135 | 136 | // Speed chart profile: true || "summary" || "disabled" || false 137 | speed: false, 138 | 139 | // Altitude chart profile: true || "summary" || "disabled" || false 140 | altitude: true, 141 | 142 | // Display time info: true || "summary" || false 143 | time: false, 144 | 145 | // Display distance info: true || "summary" || false 146 | distance: true, 147 | 148 | // Summary track info style: "inline" || "multiline" || false 149 | summary: false, 150 | 151 | // Download link: "link" || false || "modal" 152 | downloadLink: false, 153 | 154 | // Toggle chart ruler filter 155 | ruler: true, 156 | 157 | // Toggle chart legend filter 158 | legend: false, 159 | 160 | // Toggle "leaflet-almostover" integration 161 | almostOver: true, 162 | 163 | // Toggle "leaflet-distance-markers" integration 164 | distanceMarkers: false, 165 | 166 | // Toggle "leaflet-hotline" integration 167 | hotline: true, 168 | 169 | // Display track datetimes: true || false 170 | timestamps: false, 171 | 172 | // Display track waypoints: true || "markers" || "dots" || false 173 | waypoints: false, 174 | 175 | // Toggle custom waypoint icons: true || { associative array of tags } || false 176 | wptIcons: { 177 | '': L.divIcon({ 178 | className: 'elevation-waypoint-marker', 179 | html: '', 180 | iconSize: [30, 30], 181 | iconAnchor: [8, 30], 182 | }), 183 | }, 184 | 185 | // Toggle waypoint labels: true || "markers" || "dots" || false 186 | wptLabels: true, 187 | 188 | // Render chart profiles as Canvas or SVG Paths 189 | preferCanvas: true, 190 | }; 191 | 192 | // Instantiate elevation control. 193 | var controlElevation = L.control.elevation(elevation_options).addTo(map); 194 | // Overwrite fn to prevent map from being reset in PWAs (for some reason resize event fires on app blur) 195 | controlElevation._resetView = () => null; 196 | 197 | // Load track from url (allowed data types: "*.geojson", "*.gpx", "*.tcx") 198 | controlElevation.load("/data/knobstone-trail-kt.gpx"); 199 | 200 | // Double tapping elevation map toggles zoom level 201 | let zoomLevels = [100, 200, 400, 800]; 202 | let curZoom = 100; 203 | 204 | let $elDiv = document.querySelector('.elevation-wrapper > div'); 205 | let $elWrapper = document.querySelector('.elevation-wrapper'); 206 | function handleZoomToggle() { 207 | let i = zoomLevels.indexOf(curZoom); 208 | curZoom = zoomLevels[(i+1)%zoomLevels.length] 209 | $elDiv.style.width = `${curZoom}vw` 210 | // e.target.innerText = `Toggle Elevation (${curZoom}%)` 211 | controlElevation.redraw(); 212 | if(curZoom !== 100) { 213 | $elWrapper.classList.add('is-scrollable'); 214 | } else { 215 | $elWrapper.classList.remove('is-scrollable'); 216 | } 217 | } 218 | 219 | let lastTap; 220 | $elWrapper.addEventListener('click', () => { 221 | let now = new Date().getTime(); 222 | let timesince = now - lastTap; 223 | if ((timesince < 400) && (timesince > 0)){ 224 | // double tap 225 | handleZoomToggle(); 226 | } 227 | lastTap = new Date().getTime(); 228 | }); 229 | 230 | 231 | // GPX 232 | let wptIcons = {}; 233 | for(let i=0; i <= 50; i++) { 234 | wptIcons[`Mile Marker ${i}`] = L.divIcon({ 235 | html: `${i}`, 236 | className: 'mile-marker', 237 | iconSize: [12.6, 19], 238 | iconAnchor: [6.3, 19], 239 | popupAnchor: null, 240 | }); 241 | } 242 | const gpxFile = 'data/knobstone-sites.gpx'; 243 | const gif = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; 244 | const g = new L.GPX(gpxFile, { 245 | async: true, 246 | marker_options: { 247 | startIconUrl: gif, 248 | endIconUrl: gif, 249 | shadowUrl: null, 250 | wptIconUrls: { 251 | 'Parking Area': 'img/markers/parking.png', 252 | 'Campground': 'img/markers/campground.png', 253 | 'Water Source': 'img/markers/water.png', 254 | 'Feature': 'img/markers/feature.png', 255 | }, 256 | wptIcons: wptIcons, 257 | iconSize: [16, 16], 258 | shadowSize: [0, 0], 259 | iconAnchor: [8, 16], 260 | popupAnchor: [0, -16], 261 | }, 262 | }) 263 | .on('loaded', () => {}) 264 | .on('addpoint', function(e) { 265 | // console.log('addpoint', e); 266 | }) 267 | .on('addline', function(e) { 268 | // console.log('addline', e); 269 | // setTimeout(()=>map.fitBounds(e.line.getBounds()), 1000); 270 | }) 271 | .addTo(map); 272 | 273 | 274 | 275 | // Pace Modal 276 | $modalCloseBtn = document.querySelector('[data-modal-close-btn]'); 277 | $modalCloseBtn.addEventListener('click', (e) => { 278 | toggleModal() 279 | }) 280 | 281 | function handleTimerBtnClick(evt) { 282 | L.DomEvent.stopPropagation(evt); 283 | toggleModal(); 284 | } 285 | 286 | function toggleModal() { 287 | if(document.body.classList.contains('modal-is-open')) { 288 | document.body.classList.remove('modal-is-open') 289 | } else { 290 | document.body.classList.add('modal-is-open') 291 | } 292 | } 293 | 294 | L.Control.Pace = L.Control.extend({ 295 | onAdd: function(map) { 296 | 297 | var $div = L.DomUtil.create('div', 'leaflet-bar leaflet-control'); 298 | this.$div = $div; 299 | $div.innerHTML = ` 300 | 301 | 302 | ⏱ 303 | 304 | 305 | ` 306 | L.DomEvent.on($div, 'click', handleTimerBtnClick); 307 | return $div; 308 | }, 309 | 310 | onRemove: function(map) { 311 | L.DomEvent.off(this.$div, 'click', handleTimerBtnClick); 312 | } 313 | }); 314 | L.control.pace = function(opts) { 315 | return new L.Control.Pace(opts); 316 | } 317 | L.control.pace({ position: 'topleft' }).addTo(map); 318 | 319 | 320 | 321 | // Offline control for tiles 322 | const savetilesControl = L.control.savetiles(baseLayer, { 323 | // zoomlevels: [11, 12, 13, 14, 15, 16], // optional zoomlevels to save, default current zoomlevel 324 | zoomlevels: [10, 11, 13, 14, 15], 325 | confirm(layer, successCallback) { 326 | // eslint-disable-next-line no-alert 327 | if (window.confirm(`Save ${layer._tilesforSave.length} tiles?`)) { 328 | successCallback(); 329 | } 330 | }, 331 | confirmRemoval(layer, successCallback) { 332 | // eslint-disable-next-line no-alert 333 | if (window.confirm('Remove all the tiles?')) { 334 | successCallback(); 335 | } 336 | }, 337 | saveText: '💾', 338 | rmText: '🗑', 339 | }); 340 | savetilesControl.addTo(map); 341 | 342 | // events while saving a tile layer 343 | let progress, total; 344 | const showProgress = () => { 345 | let progressPct = Math.round(progress/total*100); 346 | if(progress === total) { 347 | flash("Tiles have been saved") 348 | } else if(progressPct % 5 === 0) { 349 | // show progress every 5% 350 | flash(`Saving tiles ${progressPct}%`) 351 | } 352 | }; 353 | 354 | baseLayer.on('savestart', (e) => { 355 | progress = 0; 356 | total = e._tilesforSave.length; 357 | flash(`Saving tiles 0%`) 358 | }); 359 | 360 | baseLayer.on('savetileend', () => { 361 | progress += 1; 362 | showProgress(); 363 | }); 364 | -------------------------------------------------------------------------------- /scripts/pace.js: -------------------------------------------------------------------------------- 1 | dayjs.extend(dayjs_plugin_duration) 2 | 3 | class SimpleStore { 4 | constructor() { 5 | this.store = {}; 6 | } 7 | 8 | get(key) { 9 | let item = this.store[key]; 10 | if(!item) { 11 | item = localStorage.getItem(key); 12 | this.store[key] = item; 13 | } 14 | 15 | try { 16 | return JSON.parse(item); 17 | } catch (e) { 18 | return item; 19 | } 20 | } 21 | 22 | set(key, val) { 23 | if(typeof val == "object") { 24 | let newVal = JSON.stringify(val); 25 | this.store[key] = newVal; 26 | localStorage.setItem(key, newVal); 27 | } else if(typeof val !== "string") { 28 | this.store[key] = val; 29 | localStorage.setItem(key, val); 30 | } else { 31 | console.error("Store only takes strings or objects. Got: ", typeof val) 32 | } 33 | } 34 | 35 | unset(key) { 36 | delete this.store[key]; 37 | localStorage.removeItem(key); 38 | } 39 | } 40 | let store = new SimpleStore() 41 | 42 | // Const 43 | const headerFormat = 'dddd, MMMM DD'; 44 | const eventFormat = 'h:mm A'; 45 | const rowDataTpl = { 46 | event: "begin", 47 | latlng: "", 48 | date: dayjs(), 49 | }; 50 | const rowTpl = (label, row1, row2) => { 51 | if(row1 && row2) { 52 | return rowTpl2(label, row1, row2); 53 | } else { 54 | return rowTpl1(label, row1); 55 | } 56 | } 57 | const rowTpl1 = (label, row) => (` 58 |
  • 59 |
    ${label}
    60 |
    ${dayjs(row.date).format(eventFormat)}
    61 |
  • 62 | `); 63 | const rowTpl2 = (label, row1, row2) => (` 64 |
  • 65 |
    ${label}
    66 |
    67 | ${dayjs(row1.date).format(eventFormat)} - ${dayjs(row2.date).format(eventFormat)} 68 | (${dayjs.duration(dayjs(row2.date).diff(row1.date)).format('HH:mm:ss')}) 69 |
    70 |
  • 71 | `); 72 | const opMetaDataTpl = { 73 | milesHiked: 0.001, 74 | milesRemaining: 10, 75 | }; 76 | 77 | // Elements 78 | let $h = document.querySelector('h2'); 79 | let $prevBtn = document.querySelector('[data-previous-btn]'); 80 | let $nextBtn = document.querySelector('[data-next-btn]'); 81 | 82 | let $eventsTable = document.querySelector('[data-events-table]'); 83 | let $estimateRow = document.querySelector('[data-estimate-row]'); 84 | let $paceRow = document.querySelector('[data-pace-row]'); 85 | 86 | let $beginBtn = document.querySelector('[data-begin-btn]'); 87 | let $pauseBtn = document.querySelector('[data-pause-btn]'); 88 | let $resumeBtn = document.querySelector('[data-resume-btn]'); 89 | let $endBtn = document.querySelector('[data-end-btn]'); 90 | let $resetBtn = document.querySelector('[data-reset-btn]'); 91 | 92 | let $hikeTime = document.querySelector('[data-hike-time]'); 93 | let $breaksTime = document.querySelector('[data-breaks-time]'); 94 | let $totalTime = document.querySelector('[data-total-time]'); 95 | 96 | let $overallPace = document.querySelector('[data-overall-pace]'); 97 | let $hikingPace = document.querySelector('[data-hiking-pace]'); 98 | let $estCompletionOverall = document.querySelector('[data-est-completion-overall]'); 99 | let $estCompletionHiking = document.querySelector('[data-est-completion-hiking]'); 100 | 101 | let $milesHikedInput = document.querySelector('[data-miles-hiked-input]'); 102 | let $milesRemainingInput = document.querySelector('[data-miles-remaining-input]'); 103 | let $stepperPlusBtn = document.querySelectorAll('[data-stepper-plus-btn]'); 104 | let $stepperMinusBtn = document.querySelectorAll('[data-stepper-minus-btn]'); 105 | 106 | // Global State 107 | let day = dayjs(); 108 | let dayKey = day.format(headerFormat); 109 | let dayMetaKey = `${dayKey}-meta`; 110 | let opData = []; 111 | let opMetaData = opMetaDataTpl; 112 | 113 | // Controls 114 | $nextBtn.addEventListener('click', () => { 115 | day = day.add('1', 'day'); 116 | changeDay(day); 117 | }); 118 | $prevBtn.addEventListener('click', () => { 119 | day = day.add('-1', 'day'); 120 | changeDay(day); 121 | }); 122 | $beginBtn.addEventListener('click', () => { 123 | applyEvent('begin'); 124 | }) 125 | $pauseBtn.addEventListener('click', () => { 126 | applyEvent('pause'); 127 | }) 128 | $resumeBtn.addEventListener('click', () => { 129 | applyEvent('resume'); 130 | }) 131 | $endBtn.addEventListener('click', () => { 132 | applyEvent('end'); 133 | }) 134 | $resetBtn.addEventListener('click', () => { 135 | if(confirm("R u sure?")) { 136 | opData = []; 137 | store.unset(dayKey); 138 | opMetaData = opMetaDataTpl; 139 | store.unset(`${dayKey}-meta`); 140 | populateTable(opData); 141 | updateControls(); 142 | updateStats(); 143 | updateMeta(); 144 | resetTable(); 145 | } 146 | }) 147 | 148 | // Fns 149 | function applyEvent(event) { 150 | opData.push({ 151 | event, 152 | latlng: null, 153 | date: dayjs(), 154 | }); 155 | store.set(dayKey, opData); 156 | populateTable(opData); 157 | updateControls(); 158 | updateStats(); 159 | } 160 | 161 | function changeDay(newDate) { 162 | dayKey = newDate.format(headerFormat); 163 | $h.innerText = dayKey; 164 | 165 | dayMetaKey = `${dayKey}-meta`; 166 | opMetaData = store.get(dayMetaKey) || opMetaDataTpl; 167 | updateMeta(); 168 | 169 | opData = store.get(dayKey) || []; 170 | if(opData.length) { 171 | populateTable(opData); 172 | } else { 173 | // No data found 174 | resetTable(); 175 | } 176 | updateControls(); 177 | updateStats(); 178 | } 179 | 180 | function updateMeta() { 181 | // Inputs 182 | let milesHiked = opMetaData["milesHiked"] || opMetaDataTpl["milesHiked"]; 183 | $milesHikedInput.value = milesHiked; 184 | let milesRemaining = opMetaData["milesRemaining"] || opMetaDataTpl["milesRemaining"]; 185 | $milesRemainingInput.value = milesRemaining; 186 | } 187 | 188 | function resetTable() { 189 | $eventsTable.innerHTML = ` 190 |
  • 191 | No records yet. 192 |
  • 193 | `; 194 | } 195 | 196 | function populateTable(data) { 197 | let rowsHtml = ""; 198 | for(let i=0; i 0 && lastEvent !== "end") { 259 | $estimateRow.style.display = "block"; 260 | } else { 261 | $estimateRow.style.display = "none"; 262 | } 263 | if(opData.length > 0) { 264 | $paceRow.style.display = "block"; 265 | } else { 266 | $paceRow.style.display = "none"; 267 | } 268 | } 269 | 270 | function updateStats() { 271 | let events = opData; 272 | let totalSeconds = 0; 273 | let hikeSeconds = 0; 274 | let breakSeconds = 0; 275 | if(events.length > 1) { 276 | let lastDate = events[0].date; 277 | for(let i=1; i handleStepperClick(evt, 1)); 344 | $stepperPlusBtn[1].addEventListener('click', (evt) => handleStepperClick(evt, 1)); 345 | $stepperMinusBtn[0].addEventListener('click', (evt) => handleStepperClick(evt, -1)); 346 | $stepperMinusBtn[1].addEventListener('click', (evt) => handleStepperClick(evt, -1)); 347 | 348 | const handleStepperClick = (evt, dir) => { 349 | evt.stopPropagation(); 350 | let $input = evt.target.parentNode.querySelector('input[type="number"]'); 351 | let step = parseFloat($input.getAttribute('step')) * dir; 352 | let newVal = parseFloat($input.value) + step; 353 | newVal = newVal < 0 ? 0 : newVal; 354 | $input.value = newVal.toFixed(2); 355 | 356 | // Adjust the other input in the opposite direction 357 | if($input.hasAttribute('data-miles-hiked-input')) { 358 | $milesRemainingInput.value = (parseFloat($milesRemainingInput.value) - step).toFixed(2); 359 | } 360 | 361 | // Trigger change 362 | handleInputChange(); 363 | }; 364 | 365 | 366 | // Init with today's date 367 | function init() { 368 | changeDay(dayjs()); 369 | } 370 | init(); 371 | -------------------------------------------------------------------------------- /styles/app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --black: #393730; 3 | --white: #DDE6EF; 4 | --primary-dark: #58613E; 5 | --primary-light: #8C8F5C; 6 | --secondary: #A6BBC8; 7 | --secondary-dark: #869CAF; 8 | --roundness: 2px; 9 | } 10 | 11 | * { 12 | box-sizing: border-box; 13 | } 14 | 15 | html, body { 16 | margin: 0; 17 | font-family: sans-serif; 18 | color: var(--black); 19 | } 20 | 21 | #map { 22 | width: 100%; 23 | height: 70vh; 24 | } 25 | 26 | /* elevation */ 27 | .elevation-wrapper { 28 | width: 100%; 29 | height: 30vh; 30 | overflow: scroll; 31 | overflow-x: scroll; 32 | overflow-y: hidden; 33 | 34 | } 35 | 36 | .elevation-wrapper > div { 37 | background: #fff; 38 | width: 100vw; 39 | padding-top: 2em; 40 | } 41 | 42 | .elevation-wrapper.is-scrollable > div { 43 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='7.2' height='19.8' style='overflow:visible;enable-background:new 0 0 7.2 19.8' xml:space='preserve'%3E%3Cstyle%3E.st0%7Bopacity:.15%7D%3C/style%3E%3Cpath class='st0' d='M0 8.1h3.6v3.6H0zM0 16.2h3.6v3.6H0zM0 0h3.6v3.6H0z'/%3E%3C/svg%3E"); 44 | background-repeat: repeat-x; 45 | background-size: 5px; 46 | background-position: 0 9px; 47 | } 48 | 49 | .elevation-control .background { 50 | border-radius: 0; 51 | } 52 | 53 | /* markers */ 54 | .leaflet-marker-icon { 55 | opacity: 0; 56 | } 57 | /*.is-zoom-mid .leaflet-marker-icon,*/ 58 | .is-zoom-strong .leaflet-marker-icon { 59 | opacity: 1; 60 | } 61 | img.leaflet-marker-icon { 62 | width: 12px !important; 63 | height: 12px !important; 64 | margin-left: -6px !important; 65 | margin-top: -12px !important; 66 | } 67 | .is-zoom-strong img.leaflet-marker-icon { 68 | width: 16px !important; 69 | height: 16px !important; 70 | margin-left: -8px !important; 71 | margin-top: -16px !important; 72 | } 73 | 74 | /* mile markers */ 75 | .mile-marker { 76 | opacity: 1; 77 | display: none; 78 | align-items: flex-end; 79 | justify-content: center; 80 | width: 12.6701px; 81 | height: 19px; 82 | font-size: 8px; 83 | font-weight: bold; 84 | } 85 | 86 | .is-zoom-mid .mile-marker { 87 | display: flex; 88 | text-shadow: 1px 1px 0 #fff, -1px -1px #fff, -1px 1px #fff, 1px -1px #fff; 89 | } 90 | 91 | .is-zoom-strong .mile-marker { 92 | display: flex; 93 | background: url('/img/markers/mile.png'); 94 | background-size: 100%; 95 | text-shadow: none; 96 | align-items: flex-start; 97 | } 98 | 99 | /* modal */ 100 | .modal { 101 | position: fixed; 102 | top: 0; 103 | left: 0; 104 | right: 0; 105 | bottom: 0; 106 | background: rgba(0, 0, 0, 0.8); 107 | z-index: 100; 108 | display: none; 109 | padding: 3em 1em 1em; 110 | align-items: stretch; 111 | justify-content: stretch; 112 | } 113 | .modal iframe { 114 | width: 100%; 115 | height: 100%; 116 | border: 0; 117 | } 118 | .modal-is-open .modal { 119 | display: flex; 120 | } 121 | .modal__dialog { 122 | position: relative; 123 | display: block; 124 | overflow: hidden; 125 | flex: 1 0 100%; 126 | background: #fff; 127 | border-radius: var(--roundness); 128 | } 129 | .modal__close-btn { 130 | border: 0; 131 | background: var(--primary-light); 132 | border-radius: var(--roundness); 133 | position: absolute; 134 | top: 0; 135 | right: 0; 136 | width: 40px; 137 | height: 40px; 138 | } 139 | .modal__close-btn span { 140 | position: absolute; 141 | top: 50%; 142 | left: 50%; 143 | display: block; 144 | width: 13px; 145 | height: 1px; 146 | background: #fff; 147 | } 148 | .modal__close-btn span:nth-child(1) { 149 | transform: translate(-50%, -50%) rotateZ(-45deg); 150 | } 151 | .modal__close-btn span:nth-child(2) { 152 | transform: translate(-50%, -50%) rotateZ(45deg); 153 | } 154 | 155 | /* Leaflet savetiles */ 156 | .leaflet-control.savetiles { 157 | display: none; 158 | } 159 | @media screen and (orientation: landscape) { 160 | .leaflet-control.savetiles { 161 | display: block; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /styles/pace.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0 0.5em; 3 | font-size: 12px; 4 | color: var(--black); 5 | } 6 | 7 | header { 8 | display: flex; 9 | justify-content: space-between; 10 | align-items: center; 11 | padding: 1ch 0; 12 | } 13 | 14 | h2 { 15 | font-size: 1.2rem; 16 | } 17 | 18 | button { 19 | padding: 1ch 1.25ch; 20 | background: var(--primary-light); 21 | border: none; 22 | font-weight: bold; 23 | color: #fff; 24 | border-radius: var(--roundness); 25 | } 26 | input[type="number"] { 27 | padding: 4px; 28 | appearance:textfield; 29 | } 30 | 31 | button, input[type="number"] { 32 | font-size: 1rem; 33 | } 34 | 35 | .events { 36 | width: calc(100% + 2em - 4px); 37 | margin-left: calc(-1em + 2px); 38 | height: 25vh; 39 | overflow: scroll; 40 | background: var(--white); 41 | border-radius: var(--roundness); 42 | } 43 | .events ul { 44 | padding: 0; 45 | margin: 0; 46 | } 47 | .events__row { 48 | display: flex; 49 | align-items: center; 50 | justify-content: flex-start; 51 | padding: 1ch; 52 | border-bottom: 1px dotted gray; 53 | } 54 | .events__row__label { 55 | flex: 1 0 5%; 56 | font-weight: bold; 57 | } 58 | .events__row__val { 59 | flex: 1 0 50%; 60 | } 61 | 62 | .controls { 63 | text-align: center; 64 | margin: 1em 0 0; 65 | } 66 | 67 | .stats { 68 | margin: 1em 0 1em; 69 | border-bottom: 1px dotted gray; 70 | } 71 | .stats:last-of-type { 72 | border-bottom: none; 73 | margin-bottom: 0; 74 | } 75 | .stats>div { 76 | padding-bottom: 2ch; 77 | } 78 | -------------------------------------------------------------------------------- /sw.js: -------------------------------------------------------------------------------- 1 | const cacheName = 'ktc-v1'; 2 | const contentToCache = [ 3 | '/', 4 | '/index.html', 5 | '/pace.html', 6 | '/data/knobstone-trail-kt.gpx', 7 | '/data/knobstone-sites.gpx', 8 | '/img/icon.png', 9 | '/favicon.ico', 10 | '/img/markers/andre.png', 11 | '/img/markers/campground.png', 12 | '/img/markers/chris.png', 13 | '/img/markers/feature.png', 14 | '/img/markers/mile.png', 15 | '/img/markers/parking.png', 16 | '/img/markers/sarah.png', 17 | '/img/markers/water.png', 18 | '/scripts/app.js', 19 | '/scripts/pace.js', 20 | '/styles/app.css', 21 | '/styles/pace.css', 22 | 'https://cdn.jsdelivr.net/npm/leaflet@1.7.1/dist/leaflet-src.min.js', 23 | 'https://cdn.jsdelivr.net/npm/leaflet.locatecontrol@0.76.1/src/L.Control.Locate.min.js', 24 | 'https://cdn.jsdelivr.net/npm/idb@7/build/umd.js', 25 | 'https://cdn.jsdelivr.net/npm/leaflet.offline@2.2.0/dist/bundle.min.js', 26 | 'https://cdn.jsdelivr.net/npm/leaflet@1.7.1/dist/leaflet.css', 27 | 'https://cdn.jsdelivr.net/npm/leaflet.locatecontrol@0.76.1/dist/L.Control.Locate.css', 28 | 'https://cdn.jsdelivr.net/npm/@raruto/leaflet-elevation@2.2.6/dist/leaflet-elevation.min.js', 29 | 'https://cdn.jsdelivr.net/npm/@raruto/leaflet-elevation@2.2.6/dist/leaflet-elevation.css', 30 | 'https://cdn.jsdelivr.net/npm/@raruto/leaflet-elevation@2.2.6/src/handlers/distance.js', 31 | 'https://cdn.jsdelivr.net/npm/@raruto/leaflet-elevation@2.2.6/src/handlers/time.js', 32 | 'https://cdn.jsdelivr.net/npm/@raruto/leaflet-elevation@2.2.6/src/handlers/altitude.js', 33 | 'https://cdn.jsdelivr.net/npm/@raruto/leaflet-elevation@2.2.6/src/handlers/slope.js', 34 | 'https://cdn.jsdelivr.net/npm/@raruto/leaflet-elevation@2.2.6/src/handlers/speed.js', 35 | 'https://cdn.jsdelivr.net/npm/@raruto/leaflet-elevation@2.2.6/src/handlers/acceleration.js', 36 | 'https://cdn.jsdelivr.net/npm/@raruto/leaflet-elevation@2.2.6/src/components/chart.js', 37 | 'https://cdn.jsdelivr.net/npm/@raruto/leaflet-elevation@2.2.6/src/components/summary.js', 38 | 'https://cdn.jsdelivr.net/npm/@raruto/leaflet-elevation@2.2.6/src/components/marker.js', 39 | 'https://cdn.jsdelivr.net/npm/@raruto/leaflet-elevation@2.2.6/src/components/d3.js', 40 | 'https://cdn.jsdelivr.net/npm/@raruto/leaflet-elevation@2.2.6/libs/leaflet-hotline.min.js', 41 | 'https://cdn.jsdelivr.net/npm/leaflet-gpx@1.7.0/gpx.min.js', 42 | ]; 43 | 44 | self.addEventListener('install', (e) => { 45 | console.log('[Service Worker] Install'); 46 | 47 | e.waitUntil((async () => { 48 | const cache = await caches.open(cacheName); 49 | console.log('[Service Worker] Caching all: app shell and content'); 50 | await cache.addAll(contentToCache); 51 | })()); 52 | }); 53 | 54 | self.addEventListener('fetch', (e) => { 55 | e.respondWith((async () => { 56 | // Look for a match in the cache 57 | const r = await caches.match(e.request); 58 | console.log(`[Service Worker] Fetching resource: ${e.request.url}`); 59 | if (r) { 60 | // Return the match if found 61 | return r; 62 | } 63 | 64 | // If not found, fetch and add to cache 65 | const response = await fetch(e.request); 66 | const cache = await caches.open(cacheName); 67 | console.log(`[Service Worker] Caching new resource: ${e.request.url}`); 68 | cache.put(e.request, response.clone()); 69 | return response; 70 | })()); 71 | }); 72 | 73 | self.addEventListener('activate', (e) => { 74 | // Clear out old cache 75 | e.waitUntil(caches.keys().then((keyList) => { 76 | return Promise.all(keyList.map((key) => { 77 | if (key === cacheName) { return; } 78 | return caches.delete(key); 79 | })); 80 | })); 81 | }); 82 | --------------------------------------------------------------------------------