From 1cca485062221603ba3194b5b50f8ef13aa48931 Mon Sep 17 00:00:00 2001 From: Davide Scaini Date: Sat, 16 May 2026 22:50:24 +0200 Subject: [PATCH] Activity map: extend track coloring to HR, power, elevation, cadence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- site/src/components/ActivityDetail.svelte | 68 +++++++++++++--- site/src/components/ActivityMap.svelte | 95 +++++++++++++---------- 2 files changed, 114 insertions(+), 49 deletions(-) diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte index 52bbe16..cf34b87 100644 --- a/site/src/components/ActivityDetail.svelte +++ b/site/src/components/ActivityDetail.svelte @@ -177,8 +177,50 @@ $: npPower = detail?.np_power_w ?? (timeseries ? computeNpFromTimeseries(timeseries) : null); - let colorMode: 'default' | 'speed' = 'default'; - $: hasSpeedTrack = !!trackUrl && !!timeseries?.speed_kmh?.some(v => v != null); + 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); + $: 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 = { + 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 }); $: hiddenStats = new Set((detail?.custom as any)?.hide_stats ?? []); @@ -336,7 +378,7 @@
-
+
{#if trackUrl} + {#if legendInfo} +
+ {legendInfo.min} +
+ {legendInfo.max} + {legendInfo.label} +
+ {/if} {:else}
No GPS track @@ -357,19 +407,17 @@
{#each stats as s} - {@const speedHoverable = s.key === 'speed' && hasSpeedTrack} + {@const cm = statColorMode(s.key)}
{ if (speedHoverable) colorMode = 'speed'; }} + on:mouseenter={() => { if (cm) colorMode = cm; }} on:mouseleave={() => colorMode = 'default'} >

{s.value}

-

- {s.label}{#if speedHoverable}·{/if} -

+

{s.label}

{/each} {#if detail?.gear} diff --git a/site/src/components/ActivityMap.svelte b/site/src/components/ActivityMap.svelte index bd0135e..3b5b054 100644 --- a/site/src/components/ActivityMap.svelte +++ b/site/src/components/ActivityMap.svelte @@ -13,8 +13,8 @@ export let initialCoords: [number, number][] | null = null; export let accentColor: string = '#00c8ff'; export let hoveredIdx: number | null = null; - /** When 'speed', colors the track by speed using a blue→green→yellow→red gradient. */ - export let colorMode: 'default' | 'speed' = 'default'; + /** Colors the track by a metric value using a blue→green→yellow→red gradient. */ + export let colorMode: 'default' | 'speed' | 'hr' | 'power' | 'elevation' | 'cadence' = 'default'; let mapEl: HTMLDivElement; let map: any; @@ -32,13 +32,13 @@ 1, accentColor, ]; - // Blue → green → yellow → red scale, t ∈ [0, 1] - function _speedColor(t: number): string { + // Shared blue → green → yellow → red scale, t ∈ [0, 1] + function _linearColor(t: number): string { 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.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++) { const [t0, c0] = stops[i], [t1, c1] = stops[i + 1]; @@ -50,49 +50,66 @@ return '#60a5fa'; } - function buildSpeedGradient(ts: Timeseries): any[] | null { - const t = ts.t, speeds = ts.speed_kmh; - if (!t?.length || !speeds?.length) return null; - const n = Math.min(t.length, speeds.length); - - // Compute cumulative distance via speed integration so fast sections - // map to proportionally longer segments on the track (vs time-based). - const cumDist: number[] = [0]; - let lastSpd = 0; - for (let i = 1; i < n; i++) { - const spd = speeds[i] != null ? speeds[i]! : lastSpd; - if (speeds[i] != null) lastSpd = spd; - cumDist.push(cumDist[i - 1] + (spd / 3.6) * (t[i] - t[i - 1])); + // Cumulative-distance progress array from speed integration. + // Fast sections occupy proportionally more of [0,1] than slow ones, + // matching their visual length on the track. + function _computeProgress(ts: Timeseries): number[] | null { + const { t, speed_kmh } = ts; + if (!t?.length) return null; + const n = t.length; + if (speed_kmh?.length) { + const cum: number[] = [0]; + let last = 0; + for (let i = 1; i < n; i++) { + const s = speed_kmh[i] != null ? speed_kmh[i]! : last; + if (speed_kmh[i] != null) last = s; + 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)); } - const total = cumDist[n - 1]; - if (!total) return null; + // 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 valid = speeds.filter((s): s is number => s != null); - if (!valid.length) return null; - const minSpd = Math.min(...valid); - const maxSpd = Math.max(...valid); - const range = maxSpd - minSpd; + // Generic gradient builder: progress array + any nullable metric array → MapLibre expression. + function _buildGradient(ts: Timeseries, values: (number | null)[]): any[] | null { + const progress = _computeProgress(ts); + if (!progress) return null; + 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']]; - let prev = -1; - let lastFill = minSpd; + let prev = -1, lastFill = minV; for (let i = 0; i < n; i++) { - const progress = Math.min(cumDist[i] / total, 1); - if (progress <= prev + 0.001) continue; - prev = progress; - const spd = speeds[i] != null ? speeds[i]! : lastFill; - if (speeds[i] != null) lastFill = spd; - expr.push(progress, _speedColor(range > 0 ? (spd - minSpd) / range : 0.5)); + const p = Math.min(progress[i], 1); + if (p <= prev + 0.001) continue; + prev = p; + const v = values[i] != null ? values[i]! : lastFill; + if (values[i] != null) lastFill = v; + 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 - return expr.length >= 6 ? expr : null; // need ≥ 2 stops + if (prev < 1) expr.push(1, expr[expr.length - 1]); + return expr.length >= 6 ? expr : null; } function _applyGradient() { if (!map?.getLayer('track-line')) return; - const gradient = colorMode === 'speed' && timeseries - ? (buildSpeedGradient(timeseries) ?? defaultGradient) - : defaultGradient; + let gradient = defaultGradient; + if (timeseries && colorMode !== 'default') { + 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); }