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
+39
View File
@@ -2,11 +2,45 @@
the BAS timeseries object (parallel arrays).""" the BAS timeseries object (parallel arrays)."""
from datetime import datetime from datetime import datetime
from math import atan2, cos, radians, sin, sqrt
from typing import Optional from typing import Optional
from bincio.extract.models import DataPoint from bincio.extract.models import DataPoint
def _gps_speed_kmh(
lat_vals: list[Optional[float]],
lon_vals: list[Optional[float]],
ts_vals: list[int],
) -> list[Optional[float]]:
"""Compute speed (km/h) from consecutive GPS coordinates via haversine.
Applies a 5-point centred moving-average to reduce GPS noise.
"""
n = len(ts_vals)
raw: list[Optional[float]] = [None] * n
for i in range(1, n):
la0, lo0 = lat_vals[i - 1], lon_vals[i - 1]
la1, lo1 = lat_vals[i], lon_vals[i]
dt = ts_vals[i] - ts_vals[i - 1]
if la0 is None or lo0 is None or la1 is None or lo1 is None or dt <= 0:
continue
dlat = radians(la1 - la0)
dlon = radians(lo1 - lo0)
a = sin(dlat / 2) ** 2 + cos(radians(la0)) * cos(radians(la1)) * sin(dlon / 2) ** 2
d_km = 2 * 6371.0 * atan2(sqrt(a), sqrt(1 - a))
raw[i] = d_km / dt * 3600.0
# 5-point centred moving average (skip None anchors)
half = 2
smoothed: list[Optional[float]] = [None] * n
for i in range(n):
vals = [raw[j] for j in range(max(0, i - half), min(n, i + half + 1)) if raw[j] is not None]
if vals:
smoothed[i] = round(sum(vals) / len(vals), 2)
return smoothed
def build_timeseries( def build_timeseries(
points: list[DataPoint], points: list[DataPoint],
started_at: datetime, started_at: datetime,
@@ -40,6 +74,11 @@ def build_timeseries(
lon_vals = [round(p.lon, 7) if p.lon is not None else None for p in sampled] if include_gps else None lon_vals = [round(p.lon, 7) if p.lon is not None else None for p in sampled] if include_gps else None
ele_vals = [round(p.elevation_m, 1) if p.elevation_m is not None else None for p in sampled] ele_vals = [round(p.elevation_m, 1) if p.elevation_m is not None else None for p in sampled]
spd_vals = [round(p.speed_kmh, 2) if p.speed_kmh is not None else None for p in sampled] spd_vals = [round(p.speed_kmh, 2) if p.speed_kmh is not None else None for p in sampled]
# Derive speed from GPS when the device didn't record per-second speed.
if include_gps and lat_vals and lon_vals and all(v is None for v in spd_vals):
spd_vals = _gps_speed_kmh(lat_vals, lon_vals, ts_vals)
hr_vals = [p.hr_bpm for p in sampled] hr_vals = [p.hr_bpm for p in sampled]
cad_vals = [p.cadence_rpm for p in sampled] cad_vals = [p.cadence_rpm for p in sampled]
pwr_vals = [p.power_w for p in sampled] pwr_vals = [p.power_w for p in sampled]
+42
View File
@@ -418,6 +418,40 @@ def _backfill_vam_summary(data: Path, handle: str | None = None) -> None:
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} summary(ies) updated") console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} summary(ies) updated")
def _backfill_speed(data: Path, handle: str | None = None) -> None:
"""Compute GPS-derived speed for timeseries files where speed_kmh is all null.
Reads each *.timeseries.json, fills speed_kmh from haversine distances when
the device did not record per-second speed, and writes the file back.
"""
import json
from bincio.extract.timeseries import _gps_speed_kmh
targets = [data / handle] if handle else _user_dirs(data)
for user_dir in targets:
acts_dir = user_dir / "activities"
if not acts_dir.exists():
continue
updated = 0
for ts_path in sorted(acts_dir.glob("*.timeseries.json")):
try:
ts = json.loads(ts_path.read_text(encoding="utf-8"))
except Exception:
continue
spd = ts.get("speed_kmh", [])
if not spd or any(v is not None for v in spd):
continue # already has speed data
lat_vals = ts.get("lat") or []
lon_vals = ts.get("lon") or []
t_vals = ts.get("t") or []
if not lat_vals or not lon_vals or not t_vals:
continue
ts["speed_kmh"] = _gps_speed_kmh(lat_vals, lon_vals, t_vals)
ts_path.write_text(json.dumps(ts, indent=2, ensure_ascii=False), encoding="utf-8")
updated += 1
console.print(f" [cyan]{user_dir.name}[/cyan]: {updated} timeseries updated with GPS speed")
@click.command() @click.command()
@click.option("--config", "config_path", default=None, @click.option("--config", "config_path", default=None,
help="Path to extract_config.yaml (reads output.dir from it).") help="Path to extract_config.yaml (reads output.dir from it).")
@@ -444,6 +478,9 @@ def _backfill_vam_summary(data: Path, handle: str | None = None) -> None:
@click.option("--backfill-vam-summary", "backfill_vam_summary", is_flag=True, @click.option("--backfill-vam-summary", "backfill_vam_summary", is_flag=True,
help="Copy climbing_vam_mh from detail JSONs into index.json summaries " help="Copy climbing_vam_mh from detail JSONs into index.json summaries "
"(run once after the VAM curve → summary migration).") "(run once after the VAM curve → summary migration).")
@click.option("--backfill-speed", "backfill_speed", is_flag=True,
help="Compute GPS-derived speed for timeseries where the device didn't record "
"per-second speed (run once to enable speed map coloring on older activities).")
def render( def render(
config_path: Optional[str], config_path: Optional[str],
data_dir: Optional[str], data_dir: Optional[str],
@@ -456,6 +493,7 @@ def render(
recompute_climbs: bool, recompute_climbs: bool,
recompute_elevation: bool, recompute_elevation: bool,
backfill_vam_summary: bool, backfill_vam_summary: bool,
backfill_speed: bool,
) -> None: ) -> None:
"""Build (or serve) the BincioActivity static site from a BAS data store.""" """Build (or serve) the BincioActivity static site from a BAS data store."""
@@ -477,6 +515,10 @@ def render(
console.print("Backfilling climbing_vam_mh into summaries…") console.print("Backfilling climbing_vam_mh into summaries…")
_backfill_vam_summary(data, handle=handle) _backfill_vam_summary(data, handle=handle)
if backfill_speed:
console.print("Backfilling GPS-derived speed into timeseries…")
_backfill_speed(data, handle=handle)
_merge_edits(data, handle=handle) _merge_edits(data, handle=handle)
_rebuild_athlete_json(data, handle=handle) _rebuild_athlete_json(data, handle=handle)
_bake_tracks(data, handle=handle) _bake_tracks(data, handle=handle)
+41 -38
View File
@@ -235,11 +235,11 @@
stat('Max speed', formatSpeed(activity.max_speed_kmh), 'speed'), stat('Max speed', formatSpeed(activity.max_speed_kmh), 'speed'),
stat('Avg HR', activity.avg_hr_bpm ? `${activity.avg_hr_bpm} bpm` : '—', 'heart_rate'), 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('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 ? [ ...(activity.avg_power_w != null ? [
stat('Avg power', `${activity.avg_power_w} W`, 'power'), stat('Avg power', `${activity.avg_power_w} W`, 'power'),
stat('NP', npPower != null ? `${npPower} 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)); ].filter(s => !s.key || !hiddenStats.has(s.key));
</script> </script>
@@ -375,37 +375,50 @@
</div> </div>
{/if} {/if}
<!-- Map + Stats split --> <!-- Map + Charts (left) / Stats (right) -->
<div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-4 mb-4"> <div class="grid grid-cols-1 lg:grid-cols-[1fr_280px] gap-4 mb-4 items-start">
<!-- Map -->
<div class="relative h-[400px] lg:h-[420px] rounded-xl overflow-hidden bg-zinc-800"> <!-- Left column: map stacked above charts -->
{#if trackUrl} <div class="flex flex-col gap-4 min-w-0">
<ActivityMap <div class="relative h-[360px] rounded-xl overflow-hidden bg-zinc-800">
{trackUrl} {#if trackUrl}
{timeseries} <ActivityMap
bbox={detail?.bbox ?? null} {trackUrl}
initialCoords={activity.preview_coords} {timeseries}
accentColor={color} bbox={detail?.bbox ?? null}
{colorMode} initialCoords={activity.preview_coords}
bind:hoveredIdx accentColor={color}
/> {colorMode}
{#if legendInfo} bind:hoveredIdx
<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> {#if legendInfo}
<div class="w-14 h-1.5 rounded-full flex-shrink-0" style="background:linear-gradient(to right,#3b82f6,#4ade80,#facc15,#ef4444)"></div> <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-red-400 tabular-nums">{legendInfo.max}</span> <span class="text-blue-400 tabular-nums">{legendInfo.min}</span>
<span class="text-zinc-500 ml-0.5">{legendInfo.label}</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> </div>
{/if} {/if}
{:else} </div>
<div class="w-full h-full flex items-center justify-center text-zinc-600 text-sm">
No GPS track {#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> </div>
{:else if !detail || timeseriesLoading}
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-4 h-32 animate-pulse"></div>
{/if} {/if}
</div> </div>
<!-- Stats panel --> <!-- Right column: stats summary -->
<div class="grid grid-cols-2 lg:grid-cols-1 gap-px bg-zinc-800 rounded-xl overflow-hidden"> <div class="grid grid-cols-2 gap-px bg-zinc-800 rounded-xl overflow-hidden">
{#each stats as s} {#each stats as s}
{@const cm = {@const cm =
s.key === 'speed' && hasSpeedTrack ? 'speed' : s.key === 'speed' && hasSpeedTrack ? 'speed' :
@@ -426,24 +439,14 @@
</div> </div>
{/each} {/each}
{#if detail?.gear} {#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-sm font-medium text-zinc-300">{detail.gear}</p>
<p class="text-xs text-zinc-500">Gear</p> <p class="text-xs text-zinc-500">Gear</p>
</div> </div>
{/if} {/if}
</div> </div>
</div>
<!-- Charts --> </div>
{#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}
<!-- Laps --> <!-- Laps -->
{#if detail?.laps?.length} {#if detail?.laps?.length}