├── .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 | 
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 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
29 |
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 |
18 |
19 |
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 |
--------------------------------------------------------------------------------