fitness: fix GPS preview aspect ratio, theme-reactive colors, and UI polish

- SessionCard SVG: cosine-corrected coordinates with proper aspect ratio (xMidYMid meet)
- SessionCard: use --color-primary for track/distance/pace, add Gauge icon for pace
- History detail: theme-reactive pace chart colors via MutationObserver + matchMedia
- History detail: add Gauge icon, accent color for distance/pace stats, remove "avg" label
- Move GPS remove button from info view to edit screen
- Add Leaflet map preview to edit screen
- Remove data points count from GPS indicators
This commit is contained in:
2026-03-20 14:59:30 +01:00
parent 3778f53115
commit 014640d82b
8 changed files with 556 additions and 26 deletions
+107 -12
View File
@@ -1,6 +1,6 @@
<script>
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { Clock, Weight, Trophy } from 'lucide-svelte';
import { Clock, Weight, Trophy, Route, Gauge } from 'lucide-svelte';
/**
* @type {{
@@ -10,9 +10,12 @@
* startTime: string,
* duration?: number,
* totalVolume?: number,
* totalDistance?: number,
* prs?: Array<any>,
* exercises: Array<{
* exerciseId: string,
* totalDistance?: number,
* gpsPreview?: number[][],
* sets: Array<{ reps?: number, weight?: number, rpe?: number, distance?: number, duration?: number }>
* }>
* }
@@ -40,6 +43,13 @@
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
}
/** @param {number} minPerKm */
function formatPace(minPerKm) {
const m = Math.floor(minPerKm);
const s = Math.round((minPerKm - m) * 60);
return `${m}:${s.toString().padStart(2, '0')} /km`;
}
/**
* @param {Array<Record<string, any>>} sets
* @param {string} exerciseId
@@ -57,7 +67,6 @@
const parts = [];
if (best.distance) parts.push(`${best.distance} km`);
if (best.duration) parts.push(`${best.duration} min`);
if (best.rpe) parts.push(`@ ${best.rpe}`);
return parts.join(' · ') || null;
}
@@ -71,6 +80,62 @@
if (best.rpe) label += ` @ ${best.rpe}`;
return label;
}
/** Find first GPS preview from cardio exercises */
const gpsPreview = $derived.by(() => {
for (const ex of session.exercises) {
if (ex.gpsPreview && ex.gpsPreview.length >= 2) return ex.gpsPreview;
}
return null;
});
/** Build SVG polyline with cosine-corrected coordinates */
const svgData = $derived.by(() => {
if (!gpsPreview) return null;
const lats = gpsPreview.map(p => p[0]);
const lngs = gpsPreview.map(p => p[1]);
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
const minLng = Math.min(...lngs), maxLng = Math.max(...lngs);
const cosLat = Math.cos(((minLat + maxLat) / 2) * Math.PI / 180);
const geoW = (maxLng - minLng) * cosLat || 0.001;
const geoH = maxLat - minLat || 0.001;
const pad = Math.max(geoW, geoH) * 0.1;
const vbW = geoW + pad * 2;
const vbH = geoH + pad * 2;
const points = gpsPreview.map(p => {
const x = (p[1] - minLng) * cosLat + pad;
const y = (maxLat - p[0]) + pad;
return `${x},${y}`;
}).join(' ');
return { points, viewBox: `0 0 ${vbW} ${vbH}` };
});
/** Check if this session has any cardio exercise with GPS data */
const hasGpsCardio = $derived(session.exercises.some(ex => {
const exercise = getExerciseById(ex.exerciseId);
return exercise?.bodyPart === 'cardio' && ex.totalDistance;
}));
/** Get cardio summary: total distance and average pace */
const cardioSummary = $derived.by(() => {
let dist = 0;
let dur = 0;
for (const ex of session.exercises) {
const exercise = getExerciseById(ex.exerciseId);
if (exercise?.bodyPart !== 'cardio') continue;
if (ex.totalDistance) {
dist += ex.totalDistance;
}
for (const s of ex.sets) {
if (s.distance) dist += 0; // already counted via totalDistance
if (s.duration) dur += s.duration;
}
}
// Use session-level totalDistance if available
if (session.totalDistance) dist = session.totalDistance;
const pace = dist > 0 && dur > 0 ? dur / dist : 0;
return { distance: dist, duration: dur, pace };
});
</script>
<a href="/fitness/history/{session._id}" class="session-card">
@@ -79,8 +144,24 @@
<span class="session-date">{formatDate(session.startTime)} &middot; {formatTime(session.startTime)}</span>
</div>
{#if svgData}
<div class="map-preview">
<svg viewBox={svgData.viewBox} preserveAspectRatio="xMidYMid meet">
<polyline
points={svgData.points}
fill="none"
stroke="var(--color-primary)"
stroke-width="2.5"
stroke-linejoin="round"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
</svg>
</div>
{/if}
<div class="exercise-list">
{#each session.exercises.slice(0, 4) as ex (ex.exerciseId)}
{#each session.exercises as ex (ex.exerciseId)}
{@const exercise = getExerciseById(ex.exerciseId)}
{@const label = bestSetLabel(ex.sets, ex.exerciseId)}
<div class="exercise-row">
@@ -90,17 +171,19 @@
{/if}
</div>
{/each}
{#if session.exercises.length > 4}
<div class="exercise-row more">+{session.exercises.length - 4} more exercises</div>
{/if}
</div>
<div class="card-footer">
{#if session.duration}
<span class="stat"><Clock size={14} /> {formatDuration(session.duration)}</span>
{/if}
{#if session.totalVolume}
<span class="stat"><Weight size={14} /> {Math.round(session.totalVolume).toLocaleString()} kg</span>
{#if hasGpsCardio && cardioSummary.distance > 0}
<span class="stat accent"><Route size={14} /> {cardioSummary.distance.toFixed(1)} km</span>
{#if cardioSummary.pace > 0}
<span class="stat accent"><Gauge size={14} /> {formatPace(cardioSummary.pace)}</span>
{/if}
{:else if session.totalVolume}
<span class="stat"><Weight size={14} /> {session.totalVolume >= 1000 ? `${(session.totalVolume / 1000).toFixed(1)}t` : `${Math.round(session.totalVolume).toLocaleString()} kg`}</span>
{/if}
{#if session.prs && session.prs.length > 0}
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
@@ -139,6 +222,17 @@
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.map-preview {
height: 80px;
margin-bottom: 0.5rem;
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
border-radius: 6px;
overflow: hidden;
}
.map-preview svg {
width: 100%;
height: 100%;
}
.exercise-list {
font-size: 0.8rem;
margin-bottom: 0.6rem;
@@ -155,10 +249,6 @@
font-weight: 600;
font-size: 0.78rem;
}
.more {
color: var(--color-primary);
font-style: italic;
}
.card-footer {
display: flex;
gap: 1rem;
@@ -166,12 +256,17 @@
color: var(--color-text-secondary);
border-top: 1px solid var(--color-border);
padding-top: 0.5rem;
flex-wrap: wrap;
}
.stat {
display: flex;
align-items: center;
gap: 0.25rem;
}
.stat.accent {
color: var(--color-primary);
font-weight: 700;
}
.stat.pr {
color: var(--nord13);
}
+103
View File
@@ -0,0 +1,103 @@
/**
* Ramer-Douglas-Peucker line simplification.
* Reduces a GPS track to a visually faithful preview with far fewer points.
* Preserves curves and corners, drops redundant points on straights.
*/
interface Point {
lat: number;
lng: number;
}
/** Perpendicular distance from point P to line segment A-B (in degrees, good enough for simplification) */
function perpendicularDistance(p: Point, a: Point, b: Point): number {
const dx = b.lng - a.lng;
const dy = b.lat - a.lat;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) {
// A and B are the same point
const ex = p.lng - a.lng;
const ey = p.lat - a.lat;
return Math.sqrt(ex * ex + ey * ey);
}
const t = Math.max(0, Math.min(1, ((p.lng - a.lng) * dx + (p.lat - a.lat) * dy) / lenSq));
const projLng = a.lng + t * dx;
const projLat = a.lat + t * dy;
const ex = p.lng - projLng;
const ey = p.lat - projLat;
return Math.sqrt(ex * ex + ey * ey);
}
function rdpSimplify(points: Point[], epsilon: number): Point[] {
if (points.length <= 2) return points;
let maxDist = 0;
let maxIdx = 0;
const first = points[0];
const last = points[points.length - 1];
for (let i = 1; i < points.length - 1; i++) {
const d = perpendicularDistance(points[i], first, last);
if (d > maxDist) {
maxDist = d;
maxIdx = i;
}
}
if (maxDist > epsilon) {
const left = rdpSimplify(points.slice(0, maxIdx + 1), epsilon);
const right = rdpSimplify(points.slice(maxIdx), epsilon);
return [...left.slice(0, -1), ...right];
}
return [first, last];
}
/**
* Simplify a GPS track to a preview polyline.
* @param track - Full track with lat/lng fields
* @param maxPoints - Target maximum number of points (default 30)
* @returns Array of [lat, lng] pairs
*/
export function simplifyTrack(track: Array<{ lat: number; lng: number }>, maxPoints = 30): number[][] {
if (track.length <= maxPoints) {
return track.map(p => [p.lat, p.lng]);
}
// Start with a generous epsilon, tighten until we're under maxPoints
// Estimate initial epsilon from bounding box
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const p of track) {
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
if (p.lng < minLng) minLng = p.lng;
if (p.lng > maxLng) maxLng = p.lng;
}
const extent = Math.max(maxLat - minLat, maxLng - minLng);
// Binary search for the right epsilon
let lo = 0;
let hi = extent * 0.1;
let result = rdpSimplify(track, hi);
// If even max epsilon gives too many points, increase hi
while (result.length > maxPoints && hi < extent) {
hi *= 2;
result = rdpSimplify(track, hi);
}
// Binary search between lo and hi
for (let iter = 0; iter < 20; iter++) {
const mid = (lo + hi) / 2;
result = rdpSimplify(track, mid);
if (result.length > maxPoints) {
lo = mid;
} else {
hi = mid;
}
// Close enough
if (result.length >= maxPoints - 5 && result.length <= maxPoints) break;
}
return result.map(p => [p.lat, p.lng]);
}