Activity detail: layout refactor + GPS-derived speed for map coloring

Layout: map + charts stacked left, stats panel (2-col) on the right.
Cadence moved to last stat. Charts sit directly below the map.

Speed coloring: most FIT files don't record per-second speed, leaving
timeseries speed_kmh all-null and the hover link dead. Fix: derive speed
from consecutive GPS coordinates (haversine + 5-pt moving average) when
the device didn't record it. Add --backfill-speed render flag to retrofit
existing timeseries files.
This commit is contained in:
Davide Scaini
2026-05-16 23:24:29 +02:00
parent 0dc450ba30
commit 14a4a0b994
3 changed files with 122 additions and 38 deletions
+41 -38
View File
@@ -235,11 +235,11 @@
stat('Max speed', formatSpeed(activity.max_speed_kmh), 'speed'),
stat('Avg HR', activity.avg_hr_bpm ? `${activity.avg_hr_bpm} bpm` : '—', 'heart_rate'),
stat('Max HR', activity.max_hr_bpm ? `${activity.max_hr_bpm} bpm` : '—', 'heart_rate'),
stat('Cadence', activity.avg_cadence_rpm ? `${activity.avg_cadence_rpm} rpm` : '—', 'cadence'),
...(activity.avg_power_w != null ? [
stat('Avg power', `${activity.avg_power_w} W`, 'power'),
stat('NP', npPower != null ? `${npPower} W` : '—', 'power'),
] : []),
stat('Cadence', activity.avg_cadence_rpm ? `${activity.avg_cadence_rpm} rpm` : '—', 'cadence'),
].filter(s => !s.key || !hiddenStats.has(s.key));
</script>
@@ -375,37 +375,50 @@
</div>
{/if}
<!-- Map + Stats split -->
<div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-4 mb-4">
<!-- Map -->
<div class="relative h-[400px] lg:h-[420px] rounded-xl overflow-hidden bg-zinc-800">
{#if trackUrl}
<ActivityMap
{trackUrl}
{timeseries}
bbox={detail?.bbox ?? null}
initialCoords={activity.preview_coords}
accentColor={color}
{colorMode}
bind:hoveredIdx
/>
{#if legendInfo}
<div class="absolute bottom-3 left-3 flex items-center gap-2 bg-zinc-900/80 backdrop-blur-sm rounded-lg px-2.5 py-1.5 text-xs pointer-events-none select-none">
<span class="text-blue-400 tabular-nums">{legendInfo.min}</span>
<div class="w-14 h-1.5 rounded-full flex-shrink-0" style="background:linear-gradient(to right,#3b82f6,#4ade80,#facc15,#ef4444)"></div>
<span class="text-red-400 tabular-nums">{legendInfo.max}</span>
<span class="text-zinc-500 ml-0.5">{legendInfo.label}</span>
<!-- Map + Charts (left) / Stats (right) -->
<div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-4 mb-4 items-start">
<!-- Left column: map stacked above charts -->
<div class="flex flex-col gap-4 min-w-0">
<div class="relative h-[360px] rounded-xl overflow-hidden bg-zinc-800">
{#if trackUrl}
<ActivityMap
{trackUrl}
{timeseries}
bbox={detail?.bbox ?? null}
initialCoords={activity.preview_coords}
accentColor={color}
{colorMode}
bind:hoveredIdx
/>
{#if legendInfo}
<div class="absolute bottom-3 left-3 flex items-center gap-2 bg-zinc-900/80 backdrop-blur-sm rounded-lg px-2.5 py-1.5 text-xs pointer-events-none select-none">
<span class="text-blue-400 tabular-nums">{legendInfo.min}</span>
<div class="w-14 h-1.5 rounded-full flex-shrink-0" style="background:linear-gradient(to right,#3b82f6,#4ade80,#facc15,#ef4444)"></div>
<span class="text-red-400 tabular-nums">{legendInfo.max}</span>
<span class="text-zinc-500 ml-0.5">{legendInfo.label}</span>
</div>
{/if}
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600 text-sm">
No GPS track
</div>
{/if}
{:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600 text-sm">
No GPS track
</div>
{#if error}
<p class="text-red-400 text-sm">{error}</p>
{:else if timeseries && timeseries.t.length > 0}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
<ActivityCharts {timeseries} bind:hoveredIdx {athlete} />
</div>
{:else if !detail || timeseriesLoading}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4 h-32 animate-pulse"></div>
{/if}
</div>
<!-- Stats panel -->
<div class="grid grid-cols-2 lg:grid-cols-1 gap-px bg-zinc-800 rounded-xl overflow-hidden">
<!-- Right column: stats summary -->
<div class="grid grid-cols-2 gap-px bg-zinc-800 rounded-xl overflow-hidden">
{#each stats as s}
{@const cm =
s.key === 'speed' && hasSpeedTrack ? 'speed' :
@@ -426,24 +439,14 @@
</div>
{/each}
{#if detail?.gear}
<div class="bg-zinc-900 px-4 py-3 col-span-2 lg:col-span-1">
<div class="bg-zinc-900 px-4 py-3 col-span-2">
<p class="text-sm font-medium text-zinc-300">{detail.gear}</p>
<p class="text-xs text-zinc-500">Gear</p>
</div>
{/if}
</div>
</div>
<!-- Charts -->
{#if error}
<p class="text-red-400 text-sm mt-4">{error}</p>
{:else if timeseries && timeseries.t.length > 0}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
<ActivityCharts {timeseries} bind:hoveredIdx {athlete} />
</div>
{:else if !detail || timeseriesLoading}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4 h-32 animate-pulse"></div>
{/if}
</div>
<!-- Laps -->
{#if detail?.laps?.length}