feat: add elevation chart and gain/loss stats to GPS workout detail
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.25.0",
|
"version": "1.25.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ const translations: Translations = {
|
|||||||
pace: { en: 'PACE', de: 'TEMPO' },
|
pace: { en: 'PACE', de: 'TEMPO' },
|
||||||
upload_gpx: { en: 'Upload GPX', de: 'GPX hochladen' },
|
upload_gpx: { en: 'Upload GPX', de: 'GPX hochladen' },
|
||||||
uploading: { en: 'Uploading...', de: 'Hochladen...' },
|
uploading: { en: 'Uploading...', de: 'Hochladen...' },
|
||||||
|
elevation: { en: 'Elevation', de: 'Höhenprofil' },
|
||||||
|
elevation_unit: { en: 'm', de: 'm' },
|
||||||
|
elevation_gain: { en: 'Gain', de: 'Anstieg' },
|
||||||
|
elevation_loss: { en: 'Loss', de: 'Abstieg' },
|
||||||
personal_records: { en: 'Personal Records', de: 'Persönliche Rekorde' },
|
personal_records: { en: 'Personal Records', de: 'Persönliche Rekorde' },
|
||||||
delete_session_confirm: { en: 'Delete this workout session?', de: 'Dieses Training löschen?' },
|
delete_session_confirm: { en: 'Delete this workout session?', de: 'Dieses Training löschen?' },
|
||||||
remove_gps_confirm: { en: 'Remove GPS track from this exercise?', de: 'GPS-Track von dieser Übung entfernen?' },
|
remove_gps_confirm: { en: 'Remove GPS track from this exercise?', de: 'GPS-Track von dieser Übung entfernen?' },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { goto, invalidateAll } from '$app/navigation';
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X, RefreshCw, Gauge, Flame, Info } from '@lucide/svelte';
|
import { Clock, Weight, Trophy, Trash2, Pencil, Plus, Upload, Route, X, RefreshCw, Gauge, Flame, Info, Mountain } from '@lucide/svelte';
|
||||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||||
import { confirm } from '$lib/js/confirmDialog.svelte';
|
import { confirm } from '$lib/js/confirmDialog.svelte';
|
||||||
import { toast } from '$lib/js/toast.svelte';
|
import { toast } from '$lib/js/toast.svelte';
|
||||||
@@ -392,6 +392,38 @@
|
|||||||
return samples;
|
return samples;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute elevation samples over distance from GPS track.
|
||||||
|
* Returns array of { dist (km), altitude (m) }.
|
||||||
|
* @param {any[]} track
|
||||||
|
*/
|
||||||
|
function computeElevationSamples(track) {
|
||||||
|
/** @type {Array<{dist: number, altitude: number}>} */
|
||||||
|
const samples = [];
|
||||||
|
let cumDist = 0;
|
||||||
|
for (let i = 0; i < track.length; i++) {
|
||||||
|
if (track[i].altitude == null) continue;
|
||||||
|
if (i > 0) cumDist += haversine(track[i - 1], track[i]);
|
||||||
|
samples.push({ dist: cumDist, altitude: track[i].altitude });
|
||||||
|
}
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute elevation gain and loss from altitude samples.
|
||||||
|
* Uses a 5m threshold to filter GPS noise.
|
||||||
|
* @param {Array<{dist: number, altitude: number}>} samples
|
||||||
|
*/
|
||||||
|
function computeElevationStats(samples) {
|
||||||
|
let gain = 0, loss = 0;
|
||||||
|
for (let i = 1; i < samples.length; i++) {
|
||||||
|
const diff = samples[i].altitude - samples[i - 1].altitude;
|
||||||
|
if (diff > 0) gain += diff;
|
||||||
|
else loss -= diff;
|
||||||
|
}
|
||||||
|
return { gain: Math.round(gain), loss: Math.round(loss) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build Chart.js data for pace over distance
|
* Build Chart.js data for pace over distance
|
||||||
* @param {Array<{dist: number, pace: number}>} samples
|
* @param {Array<{dist: number, pace: number}>} samples
|
||||||
@@ -417,6 +449,30 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Chart.js data for elevation over distance
|
||||||
|
* @param {Array<{dist: number, altitude: number}>} samples
|
||||||
|
*/
|
||||||
|
function buildElevationChartData(samples) {
|
||||||
|
const step = Math.max(1, Math.floor(samples.length / 80));
|
||||||
|
const filtered = samples.filter((_, i) => i % step === 0 || i === samples.length - 1);
|
||||||
|
const color = dark ? '#A3BE8C' : '#8FBCBB';
|
||||||
|
const fill = dark ? 'rgba(163, 190, 140, 0.18)' : 'rgba(143, 188, 187, 0.18)';
|
||||||
|
return {
|
||||||
|
labels: filtered.map(s => s.dist.toFixed(2)),
|
||||||
|
datasets: [{
|
||||||
|
label: t('elevation', lang),
|
||||||
|
data: filtered.map(s => Math.round(s.altitude)),
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: fill,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.3,
|
||||||
|
fill: true
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** @param {number} exIdx */
|
/** @param {number} exIdx */
|
||||||
async function uploadGpx(exIdx) {
|
async function uploadGpx(exIdx) {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
@@ -658,20 +714,38 @@
|
|||||||
{@const dist = ex.totalDistance ?? trackDistance(ex.gpsTrack)}
|
{@const dist = ex.totalDistance ?? trackDistance(ex.gpsTrack)}
|
||||||
{@const elapsed = (ex.gpsTrack[ex.gpsTrack.length - 1].timestamp - ex.gpsTrack[0].timestamp) / 60000}
|
{@const elapsed = (ex.gpsTrack[ex.gpsTrack.length - 1].timestamp - ex.gpsTrack[0].timestamp) / 60000}
|
||||||
{@const pace = dist > 0 && elapsed > 0 ? elapsed / dist : 0}
|
{@const pace = dist > 0 && elapsed > 0 ? elapsed / dist : 0}
|
||||||
|
{@const elevSamples = computeElevationSamples(ex.gpsTrack)}
|
||||||
|
{@const elevStats = elevSamples.length > 1 ? computeElevationStats(elevSamples) : null}
|
||||||
<div class="gps-track-section">
|
<div class="gps-track-section">
|
||||||
<div class="gps-stats">
|
<div class="gps-stats">
|
||||||
<span class="gps-stat accent"><Route size={14} /> {dist.toFixed(2)} km</span>
|
<span class="gps-stat accent"><Route size={14} /> {dist.toFixed(2)} km</span>
|
||||||
{#if pace > 0}
|
{#if pace > 0}
|
||||||
<span class="gps-stat accent"><Gauge size={14} /> {formatPace(pace)}</span>
|
<span class="gps-stat accent"><Gauge size={14} /> {formatPace(pace)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if elevStats}
|
||||||
|
<span class="gps-stat elev-gain"><Mountain size={14} /> +{elevStats.gain}{t('elevation_unit', lang)}</span>
|
||||||
|
<span class="gps-stat elev-loss">-{elevStats.loss}{t('elevation_unit', lang)}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="track-map" use:renderMap={{ track: ex.gpsTrack, idx: exIdx }}></div>
|
<div class="track-map" use:renderMap={{ track: ex.gpsTrack, idx: exIdx }}></div>
|
||||||
|
|
||||||
{#if ex.gpsTrack.length >= 2}
|
{#if ex.gpsTrack.length >= 2}
|
||||||
{@const samples = computePaceSamples(ex.gpsTrack)}
|
{@const samples = computePaceSamples(ex.gpsTrack)}
|
||||||
{@const splits = computeSplits(ex.gpsTrack)}
|
{@const splits = computeSplits(ex.gpsTrack)}
|
||||||
|
|
||||||
|
{#if elevSamples.length > 1}
|
||||||
|
<div class="chart-section">
|
||||||
|
<FitnessChart
|
||||||
|
data={buildElevationChartData(elevSamples)}
|
||||||
|
title="{t('elevation', lang)} ({t('elevation_unit', lang)})"
|
||||||
|
height="160px"
|
||||||
|
yUnit="m"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if samples.length > 0}
|
{#if samples.length > 0}
|
||||||
<div class="pace-chart-section">
|
<div class="chart-section">
|
||||||
<FitnessChart
|
<FitnessChart
|
||||||
data={buildPaceChartData(samples)}
|
data={buildPaceChartData(samples)}
|
||||||
title="Pace (min/km)"
|
title="Pace (min/km)"
|
||||||
@@ -1218,10 +1292,19 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pace chart */
|
/* GPS charts */
|
||||||
.pace-chart-section {
|
.chart-section {
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
.gps-stat.elev-gain {
|
||||||
|
color: var(--nord14);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.gps-stat.elev-loss {
|
||||||
|
color: var(--nord11);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
.splits-section h4 {
|
.splits-section h4 {
|
||||||
margin: 0 0 0.4rem;
|
margin: 0 0 0.4rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user