@glandais/vcyclist-elevation 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/demo.js ADDED
@@ -0,0 +1,613 @@
1
+ /* eslint-env browser */
2
+ /* global L, Chart, gpxParser, FileReader */
3
+
4
+ // Adapted from /elevation/demo.js — same UI, same library API, but `window.Elevation` is now
5
+ // backed by the Kotlin/Wasm bundle (see ElevationJsApi.kt + the bootstrap script in index.html).
6
+
7
+ // Initialize the map
8
+ const map = L.map('map').setView([45.8, 8.6], 7);
9
+
10
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
11
+ attribution: '© OpenStreetMap contributors',
12
+ }).addTo(map);
13
+
14
+ // Initialize elevation provider (backed by Kotlin/Wasm)
15
+ const elevationProvider = new window.Elevation.ElevationProvider();
16
+
17
+ // Elements
18
+ const elevationDisplay = document.getElementById('elevation-display');
19
+ const coordinatesDisplay = document.getElementById('coordinates');
20
+ const chartContainer = document.getElementById('chart-container');
21
+ const elevationStats = document.getElementById('elevation-stats');
22
+ const modeInfo = document.getElementById('mode-info');
23
+
24
+ // Buttons
25
+ const pointModeBtn = document.getElementById('point-mode');
26
+ const pathModeBtn = document.getElementById('path-mode');
27
+ const clearPathBtn = document.getElementById('clear-path');
28
+
29
+ // GPX Upload elements
30
+ const gpxFileInput = document.getElementById('gpx-file-input');
31
+ const gpxUploadBtn = document.getElementById('gpx-upload-btn');
32
+ const loadSampleBtn = document.getElementById('load-sample-btn');
33
+ const gpxStatus = document.getElementById('gpx-status');
34
+
35
+ // Relief controls
36
+ const reliefOffBtn = document.getElementById('relief-off');
37
+ const reliefHillshadeBtn = document.getElementById('relief-hillshade');
38
+ const reliefSlopeBtn = document.getElementById('relief-slope');
39
+
40
+ // Smoothing controls
41
+ const enableSmoothingCheckbox = document.getElementById('enable-smoothing');
42
+ const smoothingWindowSlider = document.getElementById('smoothing-window-slider');
43
+ const smoothingWindowInput = document.getElementById('smoothing-window-input');
44
+ // Filter controls
45
+ const enableFilteringCheckbox = document.getElementById('enable-filtering');
46
+ const toleranceSlider = document.getElementById('tolerance-slider');
47
+ const toleranceInput = document.getElementById('tolerance-input');
48
+ const zExaggerationSlider = document.getElementById('z-exaggeration-slider');
49
+ const zExaggerationInput = document.getElementById('z-exaggeration-input');
50
+
51
+ // State
52
+ let currentReliefLayer = null;
53
+ let currentMode = 'point';
54
+ let currentMarker = null;
55
+ let pathPoints = [];
56
+ let pathPolyline = null;
57
+ let pathMarkers = [];
58
+ let elevationChart = null;
59
+
60
+ function formatElevation(elevation) {
61
+ if (elevation === null || elevation === undefined) {
62
+ return 'No data available';
63
+ }
64
+ return `${Math.round(elevation)} m`;
65
+ }
66
+
67
+ function formatCoordinates(lat, lng) {
68
+ return `Lat: ${lat.toFixed(6)}, Lng: ${lng.toFixed(6)}`;
69
+ }
70
+
71
+ function formatDistance(meters) {
72
+ if (meters < 1000) {
73
+ return `${Math.round(meters)} m`;
74
+ }
75
+ return `${(meters / 1000).toFixed(2)} km`;
76
+ }
77
+
78
+ function syncSliderAndInput(slider, input) {
79
+ slider.value = input.value;
80
+ }
81
+
82
+ function syncInputAndSlider(input, slider) {
83
+ input.value = slider.value;
84
+ }
85
+
86
+ function getSmoothingOptions() {
87
+ return {
88
+ enabled: enableSmoothingCheckbox.checked,
89
+ windowSize: parseFloat(smoothingWindowInput.value),
90
+ };
91
+ }
92
+
93
+ function getFilterOptions() {
94
+ return {
95
+ enabled: enableFilteringCheckbox.checked,
96
+ tolerance: parseFloat(toleranceInput.value),
97
+ zExaggeration: parseFloat(zExaggerationInput.value),
98
+ };
99
+ }
100
+
101
+ function updateGPXStatus(message, type = '') {
102
+ gpxStatus.textContent = message;
103
+ gpxStatus.className = `gpx-status ${type}`;
104
+ }
105
+
106
+ function handleGPXUpload() {
107
+ gpxFileInput.click();
108
+ }
109
+
110
+ function parseGPXContent(gpxContent) {
111
+ const gpx = new gpxParser();
112
+ gpx.parse(gpxContent);
113
+
114
+ if (!gpx.tracks || gpx.tracks.length === 0) {
115
+ throw new Error('No tracks found in GPX file');
116
+ }
117
+
118
+ const trackPoints = [];
119
+ gpx.tracks.forEach(track => {
120
+ if (track.points && track.points.length > 0) {
121
+ track.points.forEach(point => {
122
+ trackPoints.push({
123
+ latitude: point.lat,
124
+ longitude: point.lon,
125
+ });
126
+ });
127
+ }
128
+ });
129
+
130
+ if (trackPoints.length === 0) {
131
+ throw new Error('No track points found in GPX file');
132
+ }
133
+
134
+ return trackPoints;
135
+ }
136
+
137
+ async function loadSampleGPX() {
138
+ try {
139
+ updateGPXStatus('Loading sample GPX...', 'loading');
140
+
141
+ const response = await fetch('./sample.gpx');
142
+ if (!response.ok) {
143
+ throw new Error(`Failed to fetch sample GPX: ${response.status}`);
144
+ }
145
+
146
+ const gpxContent = await response.text();
147
+ updateGPXStatus('Parsing sample GPX...', 'loading');
148
+
149
+ const trackPoints = parseGPXContent(gpxContent);
150
+
151
+ updateGPXStatus(`Loaded sample GPX with ${trackPoints.length} points`, 'success');
152
+ loadGPXTrack(trackPoints);
153
+ } catch (error) {
154
+ console.error('Sample GPX load error:', error);
155
+ updateGPXStatus(`Error loading sample: ${error.message}`, 'error');
156
+ }
157
+ }
158
+
159
+ function parseGPXFile(file) {
160
+ return new Promise((resolve, reject) => {
161
+ if (!file.name.toLowerCase().endsWith('.gpx')) {
162
+ reject(new Error('Please select a valid GPX file'));
163
+ return;
164
+ }
165
+
166
+ updateGPXStatus('Reading GPX file...', 'loading');
167
+
168
+ const reader = new FileReader();
169
+ reader.onload = function (e) {
170
+ try {
171
+ updateGPXStatus('Parsing GPX data...', 'loading');
172
+ const trackPoints = parseGPXContent(e.target.result);
173
+ updateGPXStatus(`Loaded ${trackPoints.length} points from GPX`, 'success');
174
+ resolve(trackPoints);
175
+ } catch (error) {
176
+ reject(new Error(`Failed to parse GPX file: ${error.message}`));
177
+ }
178
+ };
179
+
180
+ reader.onerror = function () {
181
+ reject(new Error('Failed to read GPX file'));
182
+ };
183
+
184
+ reader.readAsText(file);
185
+ });
186
+ }
187
+
188
+ function loadGPXTrack(trackPoints) {
189
+ setMode('path');
190
+ clearPath();
191
+
192
+ pathPoints = trackPoints;
193
+
194
+ pathPolyline = L.polyline(
195
+ pathPoints.map(p => [p.latitude, p.longitude]),
196
+ {
197
+ color: '#2c5aa0',
198
+ weight: 3,
199
+ }
200
+ ).addTo(map);
201
+
202
+ clearPathBtn.disabled = false;
203
+
204
+ map.fitBounds(pathPolyline.getBounds(), { padding: [20, 20] });
205
+
206
+ coordinatesDisplay.textContent = `GPX track with ${pathPoints.length} points`;
207
+ elevationDisplay.textContent = 'Calculating elevation profile...';
208
+ elevationDisplay.className = 'elevation-display loading';
209
+
210
+ updateElevationProfile();
211
+ }
212
+
213
+ function setReliefMode(mode) {
214
+ if (currentReliefLayer) {
215
+ map.removeLayer(currentReliefLayer);
216
+ currentReliefLayer = null;
217
+ }
218
+
219
+ reliefOffBtn.className = mode === 'off' ? 'primary' : 'secondary';
220
+ reliefHillshadeBtn.className = mode === 'hillshade' ? 'primary' : 'secondary';
221
+ reliefSlopeBtn.className = mode === 'slope' ? 'primary' : 'secondary';
222
+
223
+ if (mode === 'hillshade' || mode === 'slope') {
224
+ currentReliefLayer = L.gridLayer
225
+ .relief({
226
+ mode,
227
+ opacity: 0.6,
228
+ })
229
+ .addTo(map);
230
+ }
231
+ }
232
+
233
+ function setMode(mode) {
234
+ currentMode = mode;
235
+
236
+ if (mode === 'point') {
237
+ pointModeBtn.className = 'primary';
238
+ pathModeBtn.className = 'secondary';
239
+ modeInfo.textContent = 'Point Mode: Click anywhere to get elevation at that location';
240
+ clearPath();
241
+ } else {
242
+ pointModeBtn.className = 'secondary';
243
+ pathModeBtn.className = 'primary';
244
+ modeInfo.textContent =
245
+ 'Path Mode: Click to add points to path, chart will show when you have 2+ points';
246
+ }
247
+ }
248
+
249
+ function clearPath() {
250
+ pathPoints = [];
251
+
252
+ if (pathPolyline) {
253
+ map.removeLayer(pathPolyline);
254
+ pathPolyline = null;
255
+ }
256
+
257
+ pathMarkers.forEach(marker => map.removeLayer(marker));
258
+ pathMarkers = [];
259
+
260
+ chartContainer.classList.remove('visible');
261
+
262
+ clearPathBtn.disabled = true;
263
+
264
+ updateGPXStatus('');
265
+
266
+ if (currentMode === 'path') {
267
+ elevationDisplay.textContent = 'Click on the map to start creating a path';
268
+ coordinatesDisplay.textContent = 'Path coordinates will appear here';
269
+ }
270
+ }
271
+
272
+ function debounce(func, wait) {
273
+ let timeout;
274
+ return function executedFunction(...args) {
275
+ const later = () => {
276
+ clearTimeout(timeout);
277
+ func(...args);
278
+ };
279
+ clearTimeout(timeout);
280
+ timeout = setTimeout(later, wait);
281
+ };
282
+ }
283
+
284
+ async function updateElevationProfile() {
285
+ if (!pathPoints || pathPoints.length < 2) {
286
+ return;
287
+ }
288
+
289
+ try {
290
+ elevationDisplay.textContent = 'Updating elevation profile...';
291
+ elevationDisplay.className = 'elevation-display loading';
292
+
293
+ const smoothingOptions = getSmoothingOptions();
294
+ const filterOptions = getFilterOptions();
295
+
296
+ const elevationProfile = await elevationProvider.getElevationsAlong(pathPoints, {
297
+ step: 25,
298
+ interpolation: true,
299
+ smoothingOptions: smoothingOptions.enabled ? smoothingOptions : undefined,
300
+ filterOptions: filterOptions.enabled ? filterOptions : undefined,
301
+ });
302
+
303
+ elevationDisplay.textContent = `Elevation profile: ${elevationProfile.length} points`;
304
+ elevationDisplay.className = 'elevation-display success';
305
+
306
+ createElevationChart(elevationProfile);
307
+ } catch (error) {
308
+ console.error('Error updating elevation profile:', error);
309
+ elevationDisplay.textContent = `Error: ${error.message}`;
310
+ elevationDisplay.className = 'elevation-display error';
311
+ }
312
+ }
313
+
314
+ const debouncedUpdateElevationProfile = debounce(updateElevationProfile, 300);
315
+
316
+ function calculateStats(elevationProfile) {
317
+ const elevations = elevationProfile.map(p => p.elevation);
318
+ const minElevation = Math.min(...elevations);
319
+ const maxElevation = Math.max(...elevations);
320
+ const totalDistance = elevationProfile.reduce((total, point, index) => {
321
+ if (index === 0) {
322
+ return 0;
323
+ }
324
+ const prev = elevationProfile[index - 1];
325
+ const dlat = point.latitude - prev.latitude;
326
+ const dlng = point.longitude - prev.longitude;
327
+ const distance = Math.sqrt(dlat * dlat + dlng * dlng) * 111000;
328
+ return total + distance;
329
+ }, 0);
330
+
331
+ let totalAscent = 0;
332
+ let totalDescent = 0;
333
+ for (let i = 1; i < elevations.length; i++) {
334
+ const diff = elevations[i] - elevations[i - 1];
335
+ if (diff > 0) {
336
+ totalAscent += diff;
337
+ } else {
338
+ totalDescent += Math.abs(diff);
339
+ }
340
+ }
341
+
342
+ return {
343
+ minElevation,
344
+ maxElevation,
345
+ elevationGain: maxElevation - minElevation,
346
+ totalDistance,
347
+ totalAscent,
348
+ totalDescent,
349
+ pointCount: elevations.length,
350
+ };
351
+ }
352
+
353
+ function createElevationChart(elevationProfile) {
354
+ const ctx = document.getElementById('elevation-chart').getContext('2d');
355
+
356
+ if (elevationChart) {
357
+ elevationChart.destroy();
358
+ }
359
+
360
+ let cumulativeDistance = 0;
361
+ const chartData = elevationProfile.map((point, index) => {
362
+ if (index > 0) {
363
+ const prev = elevationProfile[index - 1];
364
+ const dlat = point.latitude - prev.latitude;
365
+ const dlng = point.longitude - prev.longitude;
366
+ const distance = Math.sqrt(dlat * dlat + dlng * dlng) * 111000;
367
+ cumulativeDistance += distance;
368
+ }
369
+ return {
370
+ x: cumulativeDistance,
371
+ y: point.elevation,
372
+ };
373
+ });
374
+
375
+ elevationChart = new Chart(ctx, {
376
+ type: 'line',
377
+ data: {
378
+ datasets: [
379
+ {
380
+ label: 'Elevation (m)',
381
+ data: chartData,
382
+ borderColor: '#2c5aa0',
383
+ backgroundColor: 'rgba(44, 90, 160, 0.1)',
384
+ borderWidth: 2,
385
+ fill: true,
386
+ tension: 0,
387
+ },
388
+ ],
389
+ },
390
+ options: {
391
+ responsive: true,
392
+ animation: false,
393
+ scales: {
394
+ x: {
395
+ type: 'linear',
396
+ title: {
397
+ display: true,
398
+ text: 'Distance (m)',
399
+ },
400
+ ticks: {
401
+ callback: function (value) {
402
+ return formatDistance(value);
403
+ },
404
+ },
405
+ },
406
+ y: {
407
+ title: {
408
+ display: true,
409
+ text: 'Elevation (m)',
410
+ },
411
+ },
412
+ },
413
+ plugins: {
414
+ tooltip: {
415
+ callbacks: {
416
+ label: function (context) {
417
+ return `Elevation: ${Math.round(context.parsed.y)}m at ${formatDistance(context.parsed.x)}`;
418
+ },
419
+ },
420
+ },
421
+ },
422
+ },
423
+ });
424
+
425
+ const stats = calculateStats(elevationProfile);
426
+ elevationStats.innerHTML = `
427
+ <div class="stat">
428
+ <div class="stat-value">${formatDistance(stats.totalDistance)}</div>
429
+ <div class="stat-label">Total Distance</div>
430
+ </div>
431
+ <div class="stat">
432
+ <div class="stat-value">${stats.pointCount}</div>
433
+ <div class="stat-label">Data Points</div>
434
+ </div>
435
+ <div class="stat">
436
+ <div class="stat-value">${formatElevation(stats.minElevation)}</div>
437
+ <div class="stat-label">Min Elevation</div>
438
+ </div>
439
+ <div class="stat">
440
+ <div class="stat-value">${formatElevation(stats.maxElevation)}</div>
441
+ <div class="stat-label">Max Elevation</div>
442
+ </div>
443
+ <div class="stat">
444
+ <div class="stat-value">${formatElevation(stats.totalAscent)}</div>
445
+ <div class="stat-label">Total Ascent</div>
446
+ </div>
447
+ <div class="stat">
448
+ <div class="stat-value">${formatElevation(stats.totalDescent)}</div>
449
+ <div class="stat-label">Total Descent</div>
450
+ </div>
451
+ `;
452
+
453
+ chartContainer.classList.add('visible');
454
+ }
455
+
456
+ async function handlePointClick(e) {
457
+ const lat = e.latlng.lat;
458
+ const lng = e.latlng.lng;
459
+
460
+ if (currentMarker) {
461
+ map.removeLayer(currentMarker);
462
+ }
463
+
464
+ currentMarker = L.marker([lat, lng])
465
+ .addTo(map)
466
+ .bindPopup('Getting elevation...', { autoClose: false })
467
+ .openPopup();
468
+
469
+ coordinatesDisplay.textContent = formatCoordinates(lat, lng);
470
+ elevationDisplay.textContent = 'Loading elevation data...';
471
+ elevationDisplay.className = 'elevation-display loading';
472
+
473
+ try {
474
+ const elevation = await elevationProvider.getElevation(lat, lng);
475
+ elevationDisplay.textContent = `Elevation: ${formatElevation(elevation)}`;
476
+ elevationDisplay.className = 'elevation-display success';
477
+ currentMarker.setPopupContent(`
478
+ <strong>Elevation: ${formatElevation(elevation)}</strong><br>
479
+ ${formatCoordinates(lat, lng)}
480
+ `);
481
+ } catch (error) {
482
+ console.error('Error getting elevation:', error);
483
+ elevationDisplay.textContent = `Error: ${error.message}`;
484
+ elevationDisplay.className = 'elevation-display error';
485
+ currentMarker.setPopupContent(`
486
+ <strong>Error getting elevation</strong><br>
487
+ ${formatCoordinates(lat, lng)}
488
+ `);
489
+ }
490
+ }
491
+
492
+ async function handlePathClick(e) {
493
+ const lat = e.latlng.lat;
494
+ const lng = e.latlng.lng;
495
+
496
+ pathPoints.push({ latitude: lat, longitude: lng });
497
+
498
+ const marker = L.marker([lat, lng])
499
+ .addTo(map)
500
+ .bindPopup(`Point ${pathPoints.length}: Getting elevation...`, { autoClose: false });
501
+ pathMarkers.push(marker);
502
+
503
+ if (pathPolyline) {
504
+ map.removeLayer(pathPolyline);
505
+ }
506
+ pathPolyline = L.polyline(
507
+ pathPoints.map(p => [p.latitude, p.longitude]),
508
+ {
509
+ color: '#2c5aa0',
510
+ weight: 3,
511
+ }
512
+ ).addTo(map);
513
+
514
+ clearPathBtn.disabled = false;
515
+
516
+ coordinatesDisplay.textContent = `Path with ${pathPoints.length} points`;
517
+ elevationDisplay.textContent = 'Calculating elevation profile...';
518
+ elevationDisplay.className = 'elevation-display loading';
519
+
520
+ try {
521
+ const elevation = await elevationProvider.getElevation(lat, lng);
522
+ marker.setPopupContent(`
523
+ <strong>Point ${pathPoints.length}</strong><br>
524
+ Elevation: ${formatElevation(elevation)}<br>
525
+ ${formatCoordinates(lat, lng)}
526
+ `);
527
+
528
+ if (pathPoints.length >= 2) {
529
+ await updateElevationProfile();
530
+ } else {
531
+ elevationDisplay.textContent = `Point ${pathPoints.length} added - add more points to see elevation profile`;
532
+ elevationDisplay.className = 'elevation-display success';
533
+ }
534
+ } catch (error) {
535
+ console.error('Error getting elevation:', error);
536
+ elevationDisplay.textContent = `Error: ${error.message}`;
537
+ elevationDisplay.className = 'elevation-display error';
538
+ marker.setPopupContent(`
539
+ <strong>Point ${pathPoints.length} - Error</strong><br>
540
+ ${formatCoordinates(lat, lng)}
541
+ `);
542
+ }
543
+ }
544
+
545
+ reliefOffBtn.addEventListener('click', () => setReliefMode('off'));
546
+ reliefHillshadeBtn.addEventListener('click', () => setReliefMode('hillshade'));
547
+ reliefSlopeBtn.addEventListener('click', () => setReliefMode('slope'));
548
+
549
+ pointModeBtn.addEventListener('click', () => setMode('point'));
550
+ pathModeBtn.addEventListener('click', () => setMode('path'));
551
+ clearPathBtn.addEventListener('click', clearPath);
552
+
553
+ gpxUploadBtn.addEventListener('click', handleGPXUpload);
554
+ loadSampleBtn.addEventListener('click', loadSampleGPX);
555
+ gpxFileInput.addEventListener('change', async function (e) {
556
+ const file = e.target.files[0];
557
+ if (!file) {
558
+ return;
559
+ }
560
+
561
+ try {
562
+ const trackPoints = await parseGPXFile(file);
563
+ loadGPXTrack(trackPoints);
564
+ } catch (error) {
565
+ console.error('GPX upload error:', error);
566
+ updateGPXStatus(error.message, 'error');
567
+ }
568
+
569
+ gpxFileInput.value = '';
570
+ });
571
+
572
+ enableSmoothingCheckbox.addEventListener('change', updateElevationProfile);
573
+ enableFilteringCheckbox.addEventListener('change', updateElevationProfile);
574
+
575
+ smoothingWindowSlider.addEventListener('input', () => {
576
+ syncInputAndSlider(smoothingWindowInput, smoothingWindowSlider);
577
+ debouncedUpdateElevationProfile();
578
+ });
579
+ smoothingWindowInput.addEventListener('input', () => {
580
+ syncSliderAndInput(smoothingWindowSlider, smoothingWindowInput);
581
+ debouncedUpdateElevationProfile();
582
+ });
583
+
584
+ toleranceSlider.addEventListener('input', () => {
585
+ syncInputAndSlider(toleranceInput, toleranceSlider);
586
+ debouncedUpdateElevationProfile();
587
+ });
588
+ toleranceInput.addEventListener('input', () => {
589
+ syncSliderAndInput(toleranceSlider, toleranceInput);
590
+ debouncedUpdateElevationProfile();
591
+ });
592
+
593
+ zExaggerationSlider.addEventListener('input', () => {
594
+ syncInputAndSlider(zExaggerationInput, zExaggerationSlider);
595
+ debouncedUpdateElevationProfile();
596
+ });
597
+ zExaggerationInput.addEventListener('input', () => {
598
+ syncSliderAndInput(zExaggerationSlider, zExaggerationInput);
599
+ debouncedUpdateElevationProfile();
600
+ });
601
+
602
+ map.on('click', function (e) {
603
+ if (currentMode === 'point') {
604
+ handlePointClick(e);
605
+ } else {
606
+ handlePathClick(e);
607
+ }
608
+ });
609
+
610
+ elevationDisplay.textContent = 'Click on the map to get elevation data';
611
+ elevationDisplay.className = 'elevation-display';
612
+ setMode('path');
613
+ setReliefMode('hillshade');