Compare commits

...

10 Commits

Author SHA1 Message Date
Atharva Sawant
a42f1686f1 Updated README 2025-08-27 01:51:51 +05:30
Atharva Sawant
c0e70b225f Improvement to popup and zoom behavior 2025-08-27 01:37:28 +05:30
Atharva Sawant
f35e9675bb Modified the UI layout 2025-08-27 01:31:44 +05:30
Atharva Sawant
9f25a63c80 Improved individual track selection behaviour.
Added track highlighting and automatic popup on selection.
2025-08-27 01:20:30 +05:30
Atharva Sawant
0613d8c07f Fix track name parsing to show full date strings 2025-08-27 01:11:51 +05:30
Atharva Sawant
498a64c4a2 Added track list sidebar with individual track selection
- Can view statistics for all tracks individually
2025-08-27 01:07:31 +05:30
Atharva Sawant
710c13d1e6 Fixed GPX parsing for time and elevation data 2025-08-27 01:01:30 +05:30
Atharva Sawant
9be1d80635 Added track visualization
- Render GPX tracks on map
- Statistics dashboard with total distance, duration, elevation
- Reactive updates when tracks are added
2025-08-27 00:56:28 +05:30
Atharva Sawant
f18527a25a Implemented GPX file parsing with track statistics
- Using gpx-parser-builder for parsing GPX files
- Calculating some preliminary statistics
- Some error handling
2025-08-27 00:52:24 +05:30
Atharva Sawant
e8a5d4abfd Added file upload support for GPX files 2025-08-27 00:37:57 +05:30
9 changed files with 743 additions and 18 deletions

5
.gitignore vendored
View File

@@ -21,4 +21,7 @@ dist-ssr
*.ntvs*
*.njsproj
*.sln
*.sw?
*.sw?
# Data used for testing
test-files

View File

@@ -1,5 +1,31 @@
# TrackMap
A web-based tool for me to analyze my daily cycling tracks.
A web-based tool for analyzing daily walking/cycling tracks with interactive map visualization and detailed statistics.
Can upload tracks as GPX files and visualize them to see daily progress.
![TrackMap Screenshot](docs/screenshots/browser-screenshot.png)
## Features
- **GPX File Upload**: Import cycling tracks from GPS devices and apps
- **Interactive Map**: View all tracks overlaid on an OpenStreetMap with color-coded routes
- **Track Selection**: Click tracks in sidebar to highlight and focus on individual rides
- **Detailed Statistics**: Distance, duration, average speed, elevation gain for each track
- **Automatic Popups**: View track stats by clicking tracks on map
- **Smart Zoom**: Automatic focusing on selected tracks with popup-aware positioning
- **Responsive Design**: Clean interface that works on desktop and mobile
## Usage
1. **Upload GPX Files**: Click "Upload GPX Files" button in the sidebar
2. **Browse Tracks**: Scroll through the track list showing dates and basic stats
3. **Select Tracks**: Click any track to highlight it on the map and view detailed statistics
4. **Explore Map**: Use map controls to zoom and pan around your routes
5. **View Statistics**: See individual track details or total summary at bottom
## Technical Details
- **Client-Side Only**: All processing happens in your browser
- **Vue 3**: Modern reactive JavaScript framework
- **Leaflet**: Interactive maps with OpenStreetMap tiles
- **Tailwind CSS**: Clean, responsive styling
- **GPX Parsing**: Automatic extraction of coordinates, timestamps, and elevation data

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@@ -1,30 +1,164 @@
<template>
<div id="app">
<header class="bg-blue-600 text-white p-4">
<div id="app" class="h-screen flex flex-col">
<header class="bg-blue-600 text-white p-4 flex-shrink-0">
<h1 class="text-2xl font-bold">TrackMap</h1>
<p class="text-blue-100">Analyze your cycling tracks</p>
</header>
<main class="container mx-auto p-4">
<div class="mb-6">
<h2 class="text-xl mb-4">Your Cycling Tracks</h2>
<MapView />
</div>
<div class="flex flex-1 overflow-hidden">
<!-- Left Sidebar -->
<TrackList
:tracks="tracks"
:selectedTrackIndex="selectedTrackIndex"
@track-selected="handleTrackSelected"
@files-uploaded="handleFilesUploaded"
/>
<div class="text-center py-8 text-gray-500">
<p>Upload GPX files to see your tracks on the map</p>
<!-- Main Content -->
<div class="flex-1 flex flex-col overflow-hidden">
<!-- Map - takes full space when no tracks, or flexible space when tracks exist -->
<div :class="tracks.length > 0 ? 'flex-1' : 'flex-1'" class="p-4">
<MapView :tracks="tracks" :selectedTrackIndex="selectedTrackIndex" />
</div>
<!-- Statistics Section - only shows when tracks exist -->
<div v-if="tracks.length > 0" class="border-t border-gray-200 bg-white">
<!-- Selected Track Details -->
<div v-if="selectedTrack" class="p-4">
<h3 class="text-lg font-medium mb-3">{{ selectedTrack.name }}</h3>
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
<div class="text-center">
<div class="text-xl font-bold text-blue-600">{{ formatDistance(selectedTrack.stats?.distance || 0) }}</div>
<div class="text-sm text-gray-600">Distance</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-green-600">{{ formatDuration(selectedTrack.stats?.duration || 0) }}</div>
<div class="text-sm text-gray-600">Duration</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-orange-600">{{ selectedTrack.stats?.avgSpeed || 0 }} km/h</div>
<div class="text-sm text-gray-600">Avg Speed</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-red-600">{{ selectedTrack.stats?.elevationGain || 0 }}m</div>
<div class="text-sm text-gray-600">Elevation Gain</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-purple-600">{{ formatDate(selectedTrack.stats?.startTime) }}</div>
<div class="text-sm text-gray-600">Date</div>
</div>
</div>
</div>
<!-- Total Statistics -->
<div v-else class="p-4">
<h3 class="text-lg font-medium mb-3">Total Statistics</h3>
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
<div class="text-center">
<div class="text-xl font-bold text-blue-600">{{ totalStats.totalTracks }}</div>
<div class="text-sm text-gray-600">Total Tracks</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-green-600">{{ totalStats.totalDistance }}</div>
<div class="text-sm text-gray-600">Total Distance (km)</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-purple-600">{{ totalStats.totalDuration }}</div>
<div class="text-sm text-gray-600">Total Hours</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-orange-600">{{ totalStats.avgSpeed }}</div>
<div class="text-sm text-gray-600">Avg Speed (km/h)</div>
</div>
<div class="text-center">
<div class="text-xl font-bold text-red-600">{{ totalStats.totalElevationGain }}</div>
<div class="text-sm text-gray-600">Elevation Gain (m)</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
</div>
</template>
<script>
import { ref, computed } from 'vue'
import MapView from './components/MapView.vue'
import FileUpload from './components/FileUpload.vue'
import TrackList from './components/TrackList.vue'
import { useTracks } from './composables/useTracks.js'
export default {
name: 'App',
components: {
MapView
MapView,
FileUpload,
TrackList
},
setup() {
const { tracks, addTracks, totalStats } = useTracks()
const isLoading = ref(false)
const selectedTrackIndex = ref(null)
const selectedTrack = computed(() => {
return selectedTrackIndex.value !== null ? tracks[selectedTrackIndex.value] : null
})
const handleFilesUploaded = async (files) => {
isLoading.value = true
try {
const newTracks = await addTracks(files)
console.log('Parsed tracks:', newTracks)
console.log('Total stats:', totalStats.value)
} catch (error) {
console.error('Error processing files:', error)
} finally {
isLoading.value = false
}
}
const handleTrackSelected = (index) => {
selectedTrackIndex.value = selectedTrackIndex.value === index ? null : index
}
const formatDistance = (distance) => {
const km = distance / 1000
return km < 10 ? km.toFixed(2) + ' km' : km.toFixed(1) + ' km'
}
const formatDuration = (duration) => {
if (!duration) return '0m'
const hours = Math.floor(duration / 3600)
const minutes = Math.floor((duration % 3600) / 60)
if (hours > 0) {
return `${hours}h ${minutes}m`
}
return `${minutes}m`
}
const formatDate = (dateString) => {
if (!dateString) return 'Unknown date'
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
return {
tracks,
totalStats,
isLoading,
selectedTrackIndex,
selectedTrack,
handleFilesUploaded,
handleTrackSelected,
formatDistance,
formatDuration,
formatDate
}
}
}
</script>

View File

@@ -0,0 +1,53 @@
<template>
<div class="flex items-center justify-center">
<button
@click="$refs.fileInput.click()"
class="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors"
>
Upload GPX Files
</button>
<input
ref="fileInput"
type="file"
multiple
accept=".gpx"
class="hidden"
@change="handleFileSelect"
/>
<!-- File list -->
<div v-if="uploadedFiles.length > 0" class="ml-4 text-sm text-gray-600">
{{ uploadedFiles.length }} file{{ uploadedFiles.length > 1 ? 's' : '' }} uploaded
</div>
</div>
</template>
<script>
import { ref } from 'vue'
export default {
name: 'FileUpload',
emits: ['files-uploaded'],
setup(props, { emit }) {
const uploadedFiles = ref([])
const handleFileSelect = (event) => {
const files = Array.from(event.target.files)
processFiles(files)
}
const processFiles = (files) => {
if (files.length === 0) return
uploadedFiles.value = [...uploadedFiles.value, ...files]
emit('files-uploaded', files)
}
return {
uploadedFiles,
handleFileSelect
}
}
}
</script>

View File

@@ -1,15 +1,27 @@
<template>
<div id="map" class="w-full h-96 rounded-lg shadow-md"></div>
<div id="map" class="w-full h-full rounded-lg shadow-md"></div>
</template>
<script>
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import L from 'leaflet'
export default {
name: 'MapView',
setup() {
props: {
tracks: {
type: Array,
default: () => []
},
selectedTrackIndex: {
type: Number,
default: null
}
},
setup(props) {
const map = ref(null)
const trackLayers = ref([])
const hasAutoFitted = ref(false)
const getLocationFromIP = async () => {
try {
@@ -23,6 +35,107 @@ export default {
}
}
const getTrackColor = (index) => {
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9'
]
return colors[index % colors.length]
}
const renderTracks = () => {
// Clear existing track layers and popups
trackLayers.value.forEach(layer => {
map.value.removeLayer(layer)
})
trackLayers.value = []
// Close any existing popups
map.value.closePopup()
if (!props.tracks || props.tracks.length === 0) return
const allBounds = []
let selectedTrackBounds = []
let selectedTrackPopupOpened = false
props.tracks.forEach((track, trackIndex) => {
const color = getTrackColor(trackIndex)
const isSelected = props.selectedTrackIndex === trackIndex
track.segments.forEach(segment => {
if (segment.points && segment.points.length > 0) {
const latLngs = segment.points.map(point => [point.lat, point.lng])
const polyline = L.polyline(latLngs, {
color: color,
weight: isSelected ? 8 : 3,
opacity: isSelected ? 1.0 : 0.6,
zIndexOffset: isSelected ? 1000 : 0
}).bindPopup(`
<div class="font-medium">${track.name}</div>
<div class="text-sm text-gray-600 mt-1">
Distance: ${(track.stats?.distance / 1000 || 0).toFixed(1)} km<br>
Duration: ${Math.floor((track.stats?.duration || 0) / 3600)}:${Math.floor(((track.stats?.duration || 0) % 3600) / 60).toString().padStart(2, '0')}<br>
Avg Speed: ${track.stats?.avgSpeed || 0} km/h
</div>
`)
polyline.addTo(map.value)
trackLayers.value.push({ polyline, trackIndex })
// Open popup for selected track (only for first segment)
if (isSelected && !selectedTrackPopupOpened) {
// Find the northernmost point (highest latitude)
const northernmostPoint = latLngs.reduce((north, current) =>
current[0] > north[0] ? current : north
)
// Create popup at northernmost point
const popup = L.popup()
.setLatLng(northernmostPoint)
.setContent(`
<div class="font-medium">${track.name}</div>
<div class="text-sm text-gray-600 mt-1">
Distance: ${(track.stats?.distance / 1000 || 0).toFixed(1)} km<br>
Duration: ${Math.floor((track.stats?.duration || 0) / 3600)}:${Math.floor(((track.stats?.duration || 0) % 3600) / 60).toString().padStart(2, '0')}<br>
Avg Speed: ${track.stats?.avgSpeed || 0} km/h
</div>
`)
.openOn(map.value)
selectedTrackPopupOpened = true
}
// Add bounds for this segment
allBounds.push(...latLngs)
// Collect bounds for selected track
if (isSelected) {
selectedTrackBounds.push(...latLngs)
}
}
})
})
// Focus on selected track if one is selected
if (selectedTrackBounds.length > 0) {
const selectedGroup = L.latLngBounds(selectedTrackBounds)
// Add extra padding at the top to accommodate popup
// Popup is roughly 80-100px tall, so add more top padding
map.value.fitBounds(selectedGroup, {
paddingTopLeft: [50, 120], // Extra top padding for popup
paddingBottomRight: [50, 50]
})
} else if (allBounds.length > 0 && !hasAutoFitted.value) {
// Only auto-fit to all tracks on initial load, not when deselecting
const group = new L.featureGroup(trackLayers.value.map(item => item.polyline))
map.value.fitBounds(group.getBounds(), { padding: [20, 20] })
hasAutoFitted.value = true
}
}
const initMap = async () => {
const coords = await getLocationFromIP()
@@ -31,8 +144,15 @@ export default {
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map.value)
// Initial track rendering
renderTracks()
}
// Watch for track changes and selection changes
watch(() => props.tracks, renderTracks, { deep: true })
watch(() => props.selectedTrackIndex, renderTracks)
onMounted(() => {
initMap()
})

View File

@@ -0,0 +1,144 @@
<template>
<div class="w-80 bg-white border-r border-gray-200 h-full overflow-hidden flex flex-col">
<!-- Header -->
<div class="p-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">Your Tracks</h2>
<p class="text-sm text-gray-500">{{ tracks.length }} total tracks</p>
</div>
<!-- Track List -->
<div class="flex-1 overflow-y-auto">
<div v-if="tracks.length === 0" class="p-4 text-center text-gray-500">
No tracks uploaded yet
</div>
<div v-else class="space-y-1 p-2">
<div
v-for="(track, index) in tracks"
:key="index"
@click="selectTrack(index)"
:class="[
'p-3 rounded-lg cursor-pointer transition-colors border-l-4',
selectedTrackIndex === index
? 'bg-blue-50 border-blue-500'
: 'bg-gray-50 hover:bg-gray-100 border-gray-300'
]"
>
<!-- Track Header -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center">
<div
class="w-4 h-4 rounded-full mr-2"
:style="{ backgroundColor: getTrackColor(index) }"
></div>
<div class="font-medium text-gray-900 truncate">
{{ formatTrackName(track.name) }}
</div>
</div>
</div>
<!-- Track Stats -->
<div class="grid grid-cols-2 gap-2 text-xs text-gray-600">
<div>
<span class="text-gray-500">Distance:</span>
<span class="font-medium ml-1">{{ formatDistance(track.stats?.distance || 0) }}</span>
</div>
<div>
<span class="text-gray-500">Duration:</span>
<span class="font-medium ml-1">{{ formatDuration(track.stats?.duration || 0) }}</span>
</div>
<div>
<span class="text-gray-500">Speed:</span>
<span class="font-medium ml-1">{{ track.stats?.avgSpeed || 0 }} km/h</span>
</div>
<div>
<span class="text-gray-500">Elevation:</span>
<span class="font-medium ml-1">{{ track.stats?.elevationGain || 0 }}m</span>
</div>
</div>
<!-- Date -->
<div class="mt-2 text-xs text-gray-500">
{{ formatDate(track.stats?.startTime) }}
</div>
</div>
</div>
</div>
<!-- Sticky Upload Button at Bottom -->
<div class="p-4 border-t border-gray-200 bg-white">
<FileUpload @files-uploaded="$emit('files-uploaded', $event)" />
</div>
</div>
</template>
<script>
import FileUpload from './FileUpload.vue'
export default {
name: 'TrackList',
components: {
FileUpload
},
props: {
tracks: {
type: Array,
default: () => []
},
selectedTrackIndex: {
type: Number,
default: null
}
},
emits: ['track-selected', 'files-uploaded'],
methods: {
selectTrack(index) {
this.$emit('track-selected', index)
},
getTrackColor(index) {
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9'
]
return colors[index % colors.length]
},
formatTrackName(name) {
if (!name) return 'Unnamed Track'
// If name is a date format, try to make it more readable
if (name.includes(' at ')) {
return name
}
// Truncate very long names
return name.length > 25 ? name.substring(0, 25) + '...' : name
},
formatDistance(distance) {
const km = distance / 1000
return km < 10 ? km.toFixed(2) + ' km' : km.toFixed(1) + ' km'
},
formatDuration(duration) {
if (!duration) return '0m'
const hours = Math.floor(duration / 3600)
const minutes = Math.floor((duration % 3600) / 60)
if (hours > 0) {
return `${hours}h ${minutes}m`
}
return `${minutes}m`
},
formatDate(dateString) {
if (!dateString) return 'Unknown date'
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
}
}
</script>

View File

@@ -0,0 +1,71 @@
import { ref, computed } from 'vue'
import { parseGPXFile } from '../utils/gpxParser.js'
const tracks = ref([])
export const useTracks = () => {
const addTracks = async (files) => {
const newTracks = []
for (const file of files) {
try {
const parsedTracks = await parseGPXFile(file)
newTracks.push(...parsedTracks)
} catch (error) {
console.error(`Error parsing ${file.name}:`, error)
}
}
tracks.value.push(...newTracks)
return newTracks
}
const removeTracks = (trackIndices) => {
trackIndices.sort((a, b) => b - a) // Sort descending to remove from end first
trackIndices.forEach(index => {
tracks.value.splice(index, 1)
})
}
const clearAllTracks = () => {
tracks.value = []
}
const totalStats = computed(() => {
if (tracks.value.length === 0) {
return {
totalTracks: 0,
totalDistance: 0,
totalDuration: 0,
totalElevationGain: 0,
avgSpeed: 0
}
}
const totals = tracks.value.reduce((acc, track) => {
if (track.stats) {
acc.distance += track.stats.distance || 0
acc.duration += track.stats.duration || 0
acc.elevationGain += track.stats.elevationGain || 0
}
return acc
}, { distance: 0, duration: 0, elevationGain: 0 })
return {
totalTracks: tracks.value.length,
totalDistance: Math.round(totals.distance / 1000 * 10) / 10, // km
totalDuration: Math.round(totals.duration / 3600 * 10) / 10, // hours
totalElevationGain: Math.round(totals.elevationGain), // meters
avgSpeed: totals.duration > 0 ?
Math.round((totals.distance / totals.duration) * 3.6 * 10) / 10 : 0 // km/h
}
})
return {
tracks: tracks.value,
addTracks,
removeTracks,
clearAllTracks,
totalStats
}
}

174
src/utils/gpxParser.js Normal file
View File

@@ -0,0 +1,174 @@
import gpxParserBuilder from 'gpx-parser-builder'
export const parseGPXFile = async (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const gpxContent = e.target.result
const gpx = gpxParserBuilder.parse(gpxContent)
console.log('Parsed GPX structure:', gpx)
// Extract track data - handle both tracks and routes
let tracks = []
// Handle tracks (trk property)
if (gpx.trk && Array.isArray(gpx.trk)) {
tracks = gpx.trk.map(track => {
const segments = track.trkseg?.map(segment => {
const points = segment.trkpt?.map(point => ({
lat: parseFloat(point.$.lat),
lng: parseFloat(point.$.lon),
elevation: point.ele ? parseFloat(point.ele) : null,
time: point.time ? point.time : null
})) || []
return { points }
}) || []
return {
name: track.name || `Track ${tracks.length + 1}`,
segments,
metadata: {
fileName: file.name,
fileSize: file.size,
uploadDate: new Date().toISOString()
}
}
})
}
// Handle routes if no tracks (rte property)
if (tracks.length === 0 && gpx.rte && Array.isArray(gpx.rte)) {
tracks = gpx.rte.map(route => {
const points = route.rtept?.map(point => ({
lat: parseFloat(point.$.lat),
lng: parseFloat(point.$.lon),
elevation: point.ele ? parseFloat(point.ele[0]) : null,
time: point.time ? point.time[0] : null
})) || []
return {
name: route.name || file.name,
segments: [{ points }],
metadata: {
fileName: file.name,
fileSize: file.size,
uploadDate: new Date().toISOString()
}
}
})
}
// Handle waypoints if no tracks or routes (wpt property)
if (tracks.length === 0 && gpx.wpt && Array.isArray(gpx.wpt)) {
const points = gpx.wpt.map(waypoint => ({
lat: parseFloat(waypoint.$.lat),
lng: parseFloat(waypoint.$.lon),
elevation: waypoint.ele ? parseFloat(waypoint.ele[0]) : null,
time: waypoint.time ? waypoint.time[0] : null
}))
tracks = [{
name: gpx.metadata?.name || file.name,
segments: [{ points }],
metadata: {
fileName: file.name,
fileSize: file.size,
uploadDate: new Date().toISOString()
}
}]
}
// Calculate track statistics
const processedTracks = tracks.map(track => {
const stats = calculateTrackStats(track)
return { ...track, stats }
})
resolve(processedTracks)
} catch (error) {
reject(new Error(`Failed to parse GPX file: ${error.message}`))
}
}
reader.onerror = () => {
reject(new Error('Failed to read file'))
}
reader.readAsText(file)
})
}
const calculateTrackStats = (track) => {
if (!track.segments || track.segments.length === 0) {
return {
distance: 0,
duration: 0,
avgSpeed: 0,
elevationGain: 0,
startTime: null,
endTime: null
}
}
let totalDistance = 0
let totalElevationGain = 0
let startTime = null
let endTime = null
let prevPoint = null
track.segments.forEach(segment => {
segment.points.forEach(point => {
// Track time bounds
if (point.time) {
const pointTime = point.time instanceof Date ? point.time : new Date(point.time)
if (!startTime || pointTime < startTime) startTime = pointTime
if (!endTime || pointTime > endTime) endTime = pointTime
}
// Calculate distance and elevation
if (prevPoint) {
totalDistance += calculateDistance(prevPoint, point)
if (point.elevation && prevPoint.elevation) {
const elevDiff = point.elevation - prevPoint.elevation
if (elevDiff > 0) totalElevationGain += elevDiff
}
}
prevPoint = point
})
})
const duration = startTime && endTime ?
(endTime.getTime() - startTime.getTime()) / 1000 : 0 // seconds
const avgSpeed = duration > 0 ? (totalDistance / duration) * 3.6 : 0 // km/h
return {
distance: Math.round(totalDistance), // meters
duration: Math.round(duration), // seconds
avgSpeed: Math.round(avgSpeed * 10) / 10, // km/h
elevationGain: Math.round(totalElevationGain), // meters
startTime: startTime ? startTime.toISOString() : null,
endTime: endTime ? endTime.toISOString() : null
}
}
// Haversine formula for distance between two points
const calculateDistance = (point1, point2) => {
const R = 6371e3 // Earth's radius in meters
const φ1 = point1.lat * Math.PI / 180
const φ2 = point2.lat * Math.PI / 180
const Δφ = (point2.lat - point1.lat) * Math.PI / 180
const Δλ = (point2.lng - point1.lng) * Math.PI / 180
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ/2) * Math.sin(Δλ/2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
return R * c
}