diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte index e29bb86..52bbe16 100644 --- a/site/src/components/ActivityDetail.svelte +++ b/site/src/components/ActivityDetail.svelte @@ -177,6 +177,9 @@ $: npPower = detail?.np_power_w ?? (timeseries ? computeNpFromTimeseries(timeseries) : null); + let colorMode: 'default' | 'speed' = 'default'; + $: hasSpeedTrack = !!trackUrl && !!timeseries?.speed_kmh?.some(v => v != null); + const stat = (label: string, value: string, key?: string) => ({ label, value, key }); $: hiddenStats = new Set((detail?.custom as any)?.hide_stats ?? []); $: stats = [ @@ -341,6 +344,7 @@ bbox={detail?.bbox ?? null} initialCoords={activity.preview_coords} accentColor={color} + {colorMode} bind:hoveredIdx /> {:else} @@ -353,9 +357,19 @@
{#each stats as s} -
+ {@const speedHoverable = s.key === 'speed' && hasSpeedTrack} +
{ if (speedHoverable) colorMode = 'speed'; }} + on:mouseleave={() => colorMode = 'default'} + >

{s.value}

-

{s.label}

+

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

{/each} {#if detail?.gear} diff --git a/site/src/components/ActivityMap.svelte b/site/src/components/ActivityMap.svelte index 7bcb14b..bd0135e 100644 --- a/site/src/components/ActivityMap.svelte +++ b/site/src/components/ActivityMap.svelte @@ -13,15 +13,92 @@ 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'; let mapEl: HTMLDivElement; let map: any; const MarkerClass = maplibregl.Marker; let hoverMarker: any; let markersAdded = false; + let mapLoaded = false; const TILE_STYLE = 'https://tiles.openfreemap.org/styles/positron'; + const defaultGradient = [ + 'interpolate', ['linear'], ['line-progress'], + 0, accentColor, + 0.5, '#ff6b35', + 1, accentColor, + ]; + + // Blue → green → yellow → red scale, t ∈ [0, 1] + function _speedColor(t: number): string { + const stops: [number, [number, number, number]][] = [ + [0, [59, 130, 246]], // blue-500 + [0.33, [74, 222, 128]], // green-400 + [0.66, [250, 204, 21 ]], // yellow-400 + [1, [239, 68, 68 ]], // red-500 + ]; + for (let i = 0; i < stops.length - 1; i++) { + const [t0, c0] = stops[i], [t1, c1] = stops[i + 1]; + if (t >= t0 && t <= t1) { + const f = (t - t0) / (t1 - t0); + return `rgb(${Math.round(c0[0] + (c1[0] - c0[0]) * f)},${Math.round(c0[1] + (c1[1] - c0[1]) * f)},${Math.round(c0[2] + (c1[2] - c0[2]) * f)})`; + } + } + 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])); + } + const total = cumDist[n - 1]; + if (!total) return 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; + + const expr: any[] = ['interpolate', ['linear'], ['line-progress']]; + let prev = -1; + let lastFill = minSpd; + 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)); + } + if (prev < 1) expr.push(1, expr[expr.length - 1]); // close at 1.0 + return expr.length >= 6 ? expr : null; // need ≥ 2 stops + } + + function _applyGradient() { + if (!map?.getLayer('track-line')) return; + const gradient = colorMode === 'speed' && timeseries + ? (buildSpeedGradient(timeseries) ?? defaultGradient) + : defaultGradient; + map.setPaintProperty('track-line', 'line-gradient', gradient); + } + + // Re-apply whenever colorMode or timeseries changes (once map is loaded). + $: if (mapLoaded) { colorMode; timeseries; _applyGradient(); } + onMount(() => { // Derive initial center and zoom from preview_coords so the map starts at // the right location without waiting for the async detail JSON / bbox load. @@ -59,6 +136,7 @@ .addTo(map); map.on('load', () => { + mapLoaded = true; map.addSource('track', { type: 'geojson', data: trackUrl,