Activity map: color track by speed when hovering speed stats
Hovering avg/max speed in the stats panel switches the track from the default gradient to a blue→green→yellow→red speed gradient. Progress along the track is computed from cumulative distance (speed × time) so fast sections appear proportionally long rather than time-weighted. Reverts to default on mouse-leave. No-op when timeseries has no speed data or the activity has no GPS track.
This commit is contained in:
@@ -177,6 +177,9 @@
|
|||||||
|
|
||||||
$: npPower = detail?.np_power_w ?? (timeseries ? computeNpFromTimeseries(timeseries) : null);
|
$: 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 });
|
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 ?? []);
|
||||||
$: stats = [
|
$: stats = [
|
||||||
@@ -341,6 +344,7 @@
|
|||||||
bbox={detail?.bbox ?? null}
|
bbox={detail?.bbox ?? null}
|
||||||
initialCoords={activity.preview_coords}
|
initialCoords={activity.preview_coords}
|
||||||
accentColor={color}
|
accentColor={color}
|
||||||
|
{colorMode}
|
||||||
bind:hoveredIdx
|
bind:hoveredIdx
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -353,9 +357,19 @@
|
|||||||
<!-- 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}
|
||||||
<div class="bg-zinc-900 px-4 py-3">
|
{@const speedHoverable = s.key === 'speed' && hasSpeedTrack}
|
||||||
|
<div
|
||||||
|
class="bg-zinc-900 px-4 py-3 transition-colors"
|
||||||
|
class:hover:bg-zinc-800={speedHoverable}
|
||||||
|
class:cursor-default={speedHoverable}
|
||||||
|
role="none"
|
||||||
|
on:mouseenter={() => { if (speedHoverable) colorMode = 'speed'; }}
|
||||||
|
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">{s.label}</p>
|
<p class="text-xs text-zinc-500">
|
||||||
|
{s.label}{#if speedHoverable}<span class="ml-1 text-zinc-600">·</span>{/if}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if detail?.gear}
|
{#if detail?.gear}
|
||||||
|
|||||||
@@ -13,15 +13,92 @@
|
|||||||
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. */
|
||||||
|
export let colorMode: 'default' | 'speed' = 'default';
|
||||||
|
|
||||||
let mapEl: HTMLDivElement;
|
let mapEl: HTMLDivElement;
|
||||||
let map: any;
|
let map: any;
|
||||||
const MarkerClass = maplibregl.Marker;
|
const MarkerClass = maplibregl.Marker;
|
||||||
let hoverMarker: any;
|
let hoverMarker: any;
|
||||||
let markersAdded = false;
|
let markersAdded = false;
|
||||||
|
let mapLoaded = false;
|
||||||
|
|
||||||
const TILE_STYLE = 'https://tiles.openfreemap.org/styles/positron';
|
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(() => {
|
onMount(() => {
|
||||||
// Derive initial center and zoom from preview_coords so the map starts at
|
// 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.
|
// the right location without waiting for the async detail JSON / bbox load.
|
||||||
@@ -59,6 +136,7 @@
|
|||||||
.addTo(map);
|
.addTo(map);
|
||||||
|
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
|
mapLoaded = true;
|
||||||
map.addSource('track', {
|
map.addSource('track', {
|
||||||
type: 'geojson',
|
type: 'geojson',
|
||||||
data: trackUrl,
|
data: trackUrl,
|
||||||
|
|||||||
Reference in New Issue
Block a user