Added track list sidebar with individual track selection
- Can view statistics for all tracks individually
This commit is contained in:
155
src/App.vue
155
src/App.vue
@@ -1,65 +1,110 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app" class="h-screen flex flex-col">
|
||||||
<header class="bg-blue-600 text-white p-4">
|
<header class="bg-blue-600 text-white p-4 flex-shrink-0">
|
||||||
<h1 class="text-2xl font-bold">TrackMap</h1>
|
<h1 class="text-2xl font-bold">TrackMap</h1>
|
||||||
<p class="text-blue-100">Analyze your cycling tracks</p>
|
<p class="text-blue-100">Analyze your cycling tracks</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="container mx-auto p-4">
|
<div class="flex flex-1 overflow-hidden">
|
||||||
<div class="mb-6">
|
<!-- Left Sidebar -->
|
||||||
<h2 class="text-xl mb-4">Your Cycling Tracks</h2>
|
<TrackList
|
||||||
<MapView :tracks="tracks" />
|
:tracks="tracks"
|
||||||
</div>
|
:selectedTrackIndex="selectedTrackIndex"
|
||||||
|
@track-selected="handleTrackSelected"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="mb-6">
|
<!-- Main Content -->
|
||||||
<FileUpload @files-uploaded="handleFilesUploaded" />
|
<div class="flex-1 flex flex-col overflow-hidden">
|
||||||
</div>
|
<!-- Map -->
|
||||||
|
<div class="flex-1 p-4">
|
||||||
<!-- Track Statistics -->
|
<MapView :tracks="tracks" :selectedTrackIndex="selectedTrackIndex" />
|
||||||
<div v-if="tracks.length > 0" class="mb-6">
|
</div>
|
||||||
<h3 class="text-lg font-medium mb-3">Statistics</h3>
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
<!-- Upload Area -->
|
||||||
<div class="bg-gray-50 p-4 rounded-lg text-center">
|
<div class="p-4 border-t border-gray-200 bg-gray-50">
|
||||||
<div class="text-2xl font-bold text-blue-600">{{ totalStats.totalTracks }}</div>
|
<FileUpload @files-uploaded="handleFilesUploaded" />
|
||||||
<div class="text-sm text-gray-600">Total Tracks</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Selected Track Details -->
|
||||||
|
<div v-if="selectedTrack" class="p-4 border-t border-gray-200 bg-white">
|
||||||
|
<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>
|
||||||
<div class="bg-gray-50 p-4 rounded-lg text-center">
|
</div>
|
||||||
<div class="text-2xl font-bold text-green-600">{{ totalStats.totalDistance }}</div>
|
|
||||||
<div class="text-sm text-gray-600">Total Distance (km)</div>
|
<!-- Total Statistics -->
|
||||||
</div>
|
<div v-else-if="tracks.length > 0" class="p-4 border-t border-gray-200 bg-white">
|
||||||
<div class="bg-gray-50 p-4 rounded-lg text-center">
|
<h3 class="text-lg font-medium mb-3">Total Statistics</h3>
|
||||||
<div class="text-2xl font-bold text-purple-600">{{ totalStats.totalDuration }}</div>
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
<div class="text-sm text-gray-600">Total Hours</div>
|
<div class="text-center">
|
||||||
</div>
|
<div class="text-xl font-bold text-blue-600">{{ totalStats.totalTracks }}</div>
|
||||||
<div class="bg-gray-50 p-4 rounded-lg text-center">
|
<div class="text-sm text-gray-600">Total Tracks</div>
|
||||||
<div class="text-2xl font-bold text-orange-600">{{ totalStats.avgSpeed }}</div>
|
</div>
|
||||||
<div class="text-sm text-gray-600">Avg Speed (km/h)</div>
|
<div class="text-center">
|
||||||
</div>
|
<div class="text-xl font-bold text-green-600">{{ totalStats.totalDistance }}</div>
|
||||||
<div class="bg-gray-50 p-4 rounded-lg text-center">
|
<div class="text-sm text-gray-600">Total Distance (km)</div>
|
||||||
<div class="text-2xl font-bold text-red-600">{{ totalStats.totalElevationGain }}</div>
|
</div>
|
||||||
<div class="text-sm text-gray-600">Elevation Gain (m)</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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import MapView from './components/MapView.vue'
|
import MapView from './components/MapView.vue'
|
||||||
import FileUpload from './components/FileUpload.vue'
|
import FileUpload from './components/FileUpload.vue'
|
||||||
|
import TrackList from './components/TrackList.vue'
|
||||||
import { useTracks } from './composables/useTracks.js'
|
import { useTracks } from './composables/useTracks.js'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'App',
|
name: 'App',
|
||||||
components: {
|
components: {
|
||||||
MapView,
|
MapView,
|
||||||
FileUpload
|
FileUpload,
|
||||||
|
TrackList
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { tracks, addTracks, totalStats } = useTracks()
|
const { tracks, addTracks, totalStats } = useTracks()
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
const selectedTrackIndex = ref(null)
|
||||||
|
|
||||||
|
const selectedTrack = computed(() => {
|
||||||
|
return selectedTrackIndex.value !== null ? tracks[selectedTrackIndex.value] : null
|
||||||
|
})
|
||||||
|
|
||||||
const handleFilesUploaded = async (files) => {
|
const handleFilesUploaded = async (files) => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
@@ -74,11 +119,47 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
tracks,
|
tracks,
|
||||||
totalStats,
|
totalStats,
|
||||||
isLoading,
|
isLoading,
|
||||||
handleFilesUploaded
|
selectedTrackIndex,
|
||||||
|
selectedTrack,
|
||||||
|
handleFilesUploaded,
|
||||||
|
handleTrackSelected,
|
||||||
|
formatDistance,
|
||||||
|
formatDuration,
|
||||||
|
formatDate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/components/TrackList.vue
Normal file
134
src/components/TrackList.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'TrackList',
|
||||||
|
props: {
|
||||||
|
tracks: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
},
|
||||||
|
selectedTrackIndex: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['track-selected'],
|
||||||
|
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>
|
||||||
Reference in New Issue
Block a user