Compare commits
10 Commits
4bf0b69363
...
a42f1686f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a42f1686f1 | ||
|
|
c0e70b225f | ||
|
|
f35e9675bb | ||
|
|
9f25a63c80 | ||
|
|
0613d8c07f | ||
|
|
498a64c4a2 | ||
|
|
710c13d1e6 | ||
|
|
9be1d80635 | ||
|
|
f18527a25a | ||
|
|
e8a5d4abfd |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -21,4 +21,7 @@ dist-ssr
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.sw?
|
||||
|
||||
# Data used for testing
|
||||
test-files
|
||||
|
||||
30
README.md
30
README.md
@@ -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.
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
BIN
docs/screenshots/browser-screenshot.png
Normal file
BIN
docs/screenshots/browser-screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
158
src/App.vue
158
src/App.vue
@@ -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>
|
||||
53
src/components/FileUpload.vue
Normal file
53
src/components/FileUpload.vue
Normal 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>
|
||||
@@ -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: '© <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()
|
||||
})
|
||||
|
||||
144
src/components/TrackList.vue
Normal file
144
src/components/TrackList.vue
Normal 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>
|
||||
71
src/composables/useTracks.js
Normal file
71
src/composables/useTracks.js
Normal 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
174
src/utils/gpxParser.js
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user