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:
@@ -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)} · {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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user