Activity map: extend track coloring to HR, power, elevation, cadence

Refactor the metric gradient code into shared _computeProgress +
_buildGradient helpers. Hovering any of speed/HR/power/elevation/cadence
stats switches the track to a blue→green→yellow→red gradient for that
metric. A small legend (min ·gradient bar· max + label) appears in the
bottom-left corner of the map while active. Absolute elevation used
(not slope), so blue=valleys, red=peaks.
This commit is contained in:
Davide Scaini
2026-05-16 22:50:24 +02:00
parent f71fe2ddf5
commit 1cca485062
2 changed files with 114 additions and 49 deletions
+57 -9
View File
@@ -177,8 +177,50 @@
$: npPower = detail?.np_power_w ?? (timeseries ? computeNpFromTimeseries(timeseries) : null); $: npPower = detail?.np_power_w ?? (timeseries ? computeNpFromTimeseries(timeseries) : null);
let colorMode: 'default' | 'speed' = 'default'; type ColorMode = 'default' | 'speed' | 'hr' | 'power' | 'elevation' | 'cadence';
let colorMode: ColorMode = 'default';
// Per-metric: is there data AND a GPS track to colour?
$: hasSpeedTrack = !!trackUrl && !!timeseries?.speed_kmh?.some(v => v != null); $: hasSpeedTrack = !!trackUrl && !!timeseries?.speed_kmh?.some(v => v != null);
$: hasHrTrack = !!trackUrl && !!timeseries?.hr_bpm?.some(v => v != null);
$: hasPowerTrack = !!trackUrl && !!timeseries?.power_w?.some(v => v != null);
$: hasElevTrack = !!trackUrl && !!timeseries?.elevation_m?.some(v => v != null);
$: hasCadenceTrack = !!trackUrl && !!timeseries?.cadence_rpm?.some(v => v != null);
function statColorMode(key: string | undefined): ColorMode | null {
if (key === 'speed' && hasSpeedTrack) return 'speed';
if (key === 'heart_rate' && hasHrTrack) return 'hr';
if (key === 'power' && hasPowerTrack) return 'power';
if (key === 'elevation' && hasElevTrack) return 'elevation';
if (key === 'cadence' && hasCadenceTrack) return 'cadence';
return null;
}
// Legend shown on the map when a metric is active.
const MODE_LABEL: Record<string, string> = {
speed: 'Speed', hr: 'Heart rate', power: 'Power',
elevation: 'Elevation', cadence: 'Cadence',
};
$: legendInfo = (() => {
if (colorMode === 'default' || !timeseries) return null;
const values: (number | null)[] | null =
colorMode === 'speed' ? timeseries.speed_kmh :
colorMode === 'hr' ? timeseries.hr_bpm :
colorMode === 'power' ? timeseries.power_w :
colorMode === 'elevation' ? timeseries.elevation_m :
colorMode === 'cadence' ? timeseries.cadence_rpm : null;
if (!values) return null;
let minV = Infinity, maxV = -Infinity;
for (const v of values) { if (v != null) { if (v < minV) minV = v; if (v > maxV) maxV = v; } }
if (!isFinite(minV)) return null;
const fmt =
colorMode === 'speed' ? (v: number) => formatSpeed(v) :
colorMode === 'hr' ? (v: number) => `${Math.round(v)} bpm` :
colorMode === 'power' ? (v: number) => `${Math.round(v)} W` :
colorMode === 'elevation' ? (v: number) => formatElevation(v) :
(v: number) => `${Math.round(v)} rpm`;
return { label: MODE_LABEL[colorMode], min: fmt(minV), max: fmt(maxV) };
})();
const stat = (label: string, value: string, key?: string) => ({ label, value, key }); const stat = (label: string, value: string, key?: string) => ({ label, value, key });
$: hiddenStats = new Set<string>((detail?.custom as any)?.hide_stats ?? []); $: hiddenStats = new Set<string>((detail?.custom as any)?.hide_stats ?? []);
@@ -336,7 +378,7 @@
<!-- Map + Stats split --> <!-- Map + Stats split -->
<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">
<!-- Map --> <!-- Map -->
<div class="h-[400px] lg:h-[420px] rounded-xl overflow-hidden bg-zinc-800"> <div class="relative h-[400px] lg:h-[420px] rounded-xl overflow-hidden bg-zinc-800">
{#if trackUrl} {#if trackUrl}
<ActivityMap <ActivityMap
{trackUrl} {trackUrl}
@@ -347,6 +389,14 @@
{colorMode} {colorMode}
bind:hoveredIdx 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} {:else}
<div class="w-full h-full flex items-center justify-center text-zinc-600 text-sm"> <div class="w-full h-full flex items-center justify-center text-zinc-600 text-sm">
No GPS track No GPS track
@@ -357,19 +407,17 @@
<!-- Stats panel --> <!-- Stats panel -->
<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 lg:grid-cols-1 gap-px bg-zinc-800 rounded-xl overflow-hidden">
{#each stats as s} {#each stats as s}
{@const speedHoverable = s.key === 'speed' && hasSpeedTrack} {@const cm = statColorMode(s.key)}
<div <div
class="bg-zinc-900 px-4 py-3 transition-colors" class="bg-zinc-900 px-4 py-3 transition-colors"
class:hover:bg-zinc-800={speedHoverable} class:hover:bg-zinc-800={cm !== null}
class:cursor-default={speedHoverable} class:cursor-default={cm !== null}
role="none" role="none"
on:mouseenter={() => { if (speedHoverable) colorMode = 'speed'; }} on:mouseenter={() => { if (cm) colorMode = cm; }}
on:mouseleave={() => colorMode = 'default'} on:mouseleave={() => colorMode = 'default'}
> >
<p class="text-2xl font-bold text-white">{s.value}</p> <p class="text-2xl font-bold text-white">{s.value}</p>
<p class="text-xs text-zinc-500"> <p class="text-xs text-zinc-500">{s.label}</p>
{s.label}{#if speedHoverable}<span class="ml-1 text-zinc-600">·</span>{/if}
</p>
</div> </div>
{/each} {/each}
{#if detail?.gear} {#if detail?.gear}
+55 -38
View File
@@ -13,8 +13,8 @@
export let initialCoords: [number, number][] | null = null; export let initialCoords: [number, number][] | null = null;
export let accentColor: string = '#00c8ff'; export let accentColor: string = '#00c8ff';
export let hoveredIdx: number | null = null; export let hoveredIdx: number | null = null;
/** When 'speed', colors the track by speed using a blue→green→yellow→red gradient. */ /** Colors the track by a metric value using a blue→green→yellow→red gradient. */
export let colorMode: 'default' | 'speed' = 'default'; export let colorMode: 'default' | 'speed' | 'hr' | 'power' | 'elevation' | 'cadence' = 'default';
let mapEl: HTMLDivElement; let mapEl: HTMLDivElement;
let map: any; let map: any;
@@ -32,13 +32,13 @@
1, accentColor, 1, accentColor,
]; ];
// Blue → green → yellow → red scale, t ∈ [0, 1] // Shared blue → green → yellow → red scale, t ∈ [0, 1]
function _speedColor(t: number): string { function _linearColor(t: number): string {
const stops: [number, [number, number, number]][] = [ const stops: [number, [number, number, number]][] = [
[0, [59, 130, 246]], // blue-500 [0, [59, 130, 246]], // blue-500 (low)
[0.33, [74, 222, 128]], // green-400 [0.33, [74, 222, 128]], // green-400
[0.66, [250, 204, 21 ]], // yellow-400 [0.66, [250, 204, 21 ]], // yellow-400
[1, [239, 68, 68 ]], // red-500 [1, [239, 68, 68 ]], // red-500 (high)
]; ];
for (let i = 0; i < stops.length - 1; i++) { for (let i = 0; i < stops.length - 1; i++) {
const [t0, c0] = stops[i], [t1, c1] = stops[i + 1]; const [t0, c0] = stops[i], [t1, c1] = stops[i + 1];
@@ -50,49 +50,66 @@
return '#60a5fa'; return '#60a5fa';
} }
function buildSpeedGradient(ts: Timeseries): any[] | null { // Cumulative-distance progress array from speed integration.
const t = ts.t, speeds = ts.speed_kmh; // Fast sections occupy proportionally more of [0,1] than slow ones,
if (!t?.length || !speeds?.length) return null; // matching their visual length on the track.
const n = Math.min(t.length, speeds.length); function _computeProgress(ts: Timeseries): number[] | null {
const { t, speed_kmh } = ts;
// Compute cumulative distance via speed integration so fast sections if (!t?.length) return null;
// map to proportionally longer segments on the track (vs time-based). const n = t.length;
const cumDist: number[] = [0]; if (speed_kmh?.length) {
let lastSpd = 0; const cum: number[] = [0];
let last = 0;
for (let i = 1; i < n; i++) { for (let i = 1; i < n; i++) {
const spd = speeds[i] != null ? speeds[i]! : lastSpd; const s = speed_kmh[i] != null ? speed_kmh[i]! : last;
if (speeds[i] != null) lastSpd = spd; if (speed_kmh[i] != null) last = s;
cumDist.push(cumDist[i - 1] + (spd / 3.6) * (t[i] - t[i - 1])); cum.push(cum[i - 1] + (s / 3.6) * (t[i] - t[i - 1]));
}
const total = cum[n - 1];
if (total > 0) return cum.map(d => Math.min(d / total, 1));
}
// Fallback: time-based progress
const t0 = t[0], span = t[n - 1] - t[0];
return span > 0 ? t.map(ti => (ti - t0) / span) : null;
} }
const total = cumDist[n - 1];
if (!total) return null;
const valid = speeds.filter((s): s is number => s != null); // Generic gradient builder: progress array + any nullable metric array → MapLibre expression.
if (!valid.length) return null; function _buildGradient(ts: Timeseries, values: (number | null)[]): any[] | null {
const minSpd = Math.min(...valid); const progress = _computeProgress(ts);
const maxSpd = Math.max(...valid); if (!progress) return null;
const range = maxSpd - minSpd; const n = Math.min(progress.length, values.length);
let minV = Infinity, maxV = -Infinity;
for (const v of values) { if (v != null) { if (v < minV) minV = v; if (v > maxV) maxV = v; } }
if (!isFinite(minV)) return null;
const range = maxV - minV;
const expr: any[] = ['interpolate', ['linear'], ['line-progress']]; const expr: any[] = ['interpolate', ['linear'], ['line-progress']];
let prev = -1; let prev = -1, lastFill = minV;
let lastFill = minSpd;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const progress = Math.min(cumDist[i] / total, 1); const p = Math.min(progress[i], 1);
if (progress <= prev + 0.001) continue; if (p <= prev + 0.001) continue;
prev = progress; prev = p;
const spd = speeds[i] != null ? speeds[i]! : lastFill; const v = values[i] != null ? values[i]! : lastFill;
if (speeds[i] != null) lastFill = spd; if (values[i] != null) lastFill = v;
expr.push(progress, _speedColor(range > 0 ? (spd - minSpd) / range : 0.5)); expr.push(p, _linearColor(range > 0 ? (v - minV) / range : 0.5));
} }
if (prev < 1) expr.push(1, expr[expr.length - 1]); // close at 1.0 if (prev < 1) expr.push(1, expr[expr.length - 1]);
return expr.length >= 6 ? expr : null; // need ≥ 2 stops return expr.length >= 6 ? expr : null;
} }
function _applyGradient() { function _applyGradient() {
if (!map?.getLayer('track-line')) return; if (!map?.getLayer('track-line')) return;
const gradient = colorMode === 'speed' && timeseries let gradient = defaultGradient;
? (buildSpeedGradient(timeseries) ?? defaultGradient) if (timeseries && colorMode !== 'default') {
: defaultGradient; const field: (number | null)[] | null =
colorMode === 'speed' ? timeseries.speed_kmh :
colorMode === 'hr' ? timeseries.hr_bpm :
colorMode === 'power' ? timeseries.power_w :
colorMode === 'elevation' ? timeseries.elevation_m :
colorMode === 'cadence' ? timeseries.cadence_rpm : null;
if (field) gradient = _buildGradient(timeseries, field) ?? defaultGradient;
}
map.setPaintProperty('track-line', 'line-gradient', gradient); map.setPaintProperty('track-line', 'line-gradient', gradient);
} }