1cca485062
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.
255 lines
9.0 KiB
Svelte
255 lines
9.0 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import maplibregl from 'maplibre-gl';
|
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
import type { Timeseries } from '../lib/types';
|
|
|
|
export let trackUrl: string;
|
|
export let timeseries: Timeseries | null = null;
|
|
export let bbox: [number, number, number, number] | null = null;
|
|
/** ~20 [lat, lon] preview coords from the activity summary — used to position
|
|
* the map immediately on mount so there's no flash of world view before the
|
|
* detail JSON loads and bbox arrives. */
|
|
export let initialCoords: [number, number][] | null = null;
|
|
export let accentColor: string = '#00c8ff';
|
|
export let hoveredIdx: number | null = null;
|
|
/** 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;
|
|
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,
|
|
];
|
|
|
|
// 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 (low)
|
|
[0.33, [74, 222, 128]], // green-400
|
|
[0.66, [250, 204, 21 ]], // yellow-400
|
|
[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];
|
|
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';
|
|
}
|
|
|
|
// 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));
|
|
}
|
|
// Fallback: time-based progress
|
|
const t0 = t[0], span = t[n - 1] - t[0];
|
|
return span > 0 ? t.map(ti => (ti - t0) / span) : null;
|
|
}
|
|
|
|
// 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, lastFill = minV;
|
|
for (let i = 0; i < n; i++) {
|
|
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]);
|
|
return expr.length >= 6 ? expr : null;
|
|
}
|
|
|
|
function _applyGradient() {
|
|
if (!map?.getLayer('track-line')) return;
|
|
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);
|
|
}
|
|
|
|
// 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.
|
|
let initCenter: [number, number] = [0, 0];
|
|
let initZoom = 1;
|
|
if (initialCoords && initialCoords.length > 0) {
|
|
const lats = initialCoords.map(c => c[0]);
|
|
const lons = initialCoords.map(c => c[1]);
|
|
initCenter = [
|
|
(Math.min(...lons) + Math.max(...lons)) / 2,
|
|
(Math.min(...lats) + Math.max(...lats)) / 2,
|
|
];
|
|
initZoom = 10; // rough default; fitBounds will correct this when bbox arrives
|
|
}
|
|
|
|
map = new maplibregl.Map({
|
|
container: mapEl,
|
|
style: TILE_STYLE,
|
|
center: initCenter,
|
|
zoom: initZoom,
|
|
attributionControl: false,
|
|
});
|
|
|
|
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
|
|
|
|
// Hover dot marker — must set lngLat before addTo in MapLibre v5
|
|
const el = document.createElement('div');
|
|
el.style.cssText = `
|
|
width:12px;height:12px;border-radius:50%;
|
|
background:white;border:2px solid ${accentColor};
|
|
box-shadow:0 0 6px ${accentColor};display:none;pointer-events:none;
|
|
`;
|
|
hoverMarker = new maplibregl.Marker({ element: el, anchor: 'center' })
|
|
.setLngLat([0, 0])
|
|
.addTo(map);
|
|
|
|
map.on('load', () => {
|
|
mapLoaded = true;
|
|
map.addSource('track', {
|
|
type: 'geojson',
|
|
data: trackUrl,
|
|
lineMetrics: true,
|
|
});
|
|
|
|
map.addLayer({
|
|
id: 'track-shadow',
|
|
type: 'line',
|
|
source: 'track',
|
|
paint: { 'line-color': 'rgba(0,0,0,0.3)', 'line-width': 5, 'line-blur': 2 },
|
|
});
|
|
|
|
map.addLayer({
|
|
id: 'track-line',
|
|
type: 'line',
|
|
source: 'track',
|
|
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
|
paint: {
|
|
'line-width': 3,
|
|
'line-gradient': [
|
|
'interpolate', ['linear'], ['line-progress'],
|
|
0, accentColor,
|
|
0.5, '#ff6b35',
|
|
1, accentColor,
|
|
],
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
// Fit to bbox when detail JSON loads (bbox is null at map init).
|
|
// Always resize first so MapLibre knows the real container dimensions,
|
|
// and defer with rAF so the browser has finished laying out the container.
|
|
$: if (map && bbox) {
|
|
const fit = () => requestAnimationFrame(() => {
|
|
map.resize();
|
|
map.fitBounds(
|
|
[[bbox![0], bbox![1]], [bbox![2], bbox![3]]],
|
|
{ padding: 40, animate: false },
|
|
);
|
|
});
|
|
map.loaded() ? fit() : map.once('load', fit);
|
|
}
|
|
|
|
// Add start/end markers when timeseries arrives
|
|
$: if (map && MarkerClass && timeseries && !markersAdded) {
|
|
markersAdded = true;
|
|
const add = () => {
|
|
// Filter lat/lon together so indices stay aligned
|
|
const pts = (timeseries!.lat ?? [])
|
|
.map((lat, i) => ({ lat, lon: (timeseries!.lon ?? [])[i] }))
|
|
.filter(p => p.lat != null && p.lon != null) as { lat: number; lon: number }[];
|
|
if (!pts.length) return;
|
|
new MarkerClass({ element: makeDot('#4ade80'), anchor: 'center' })
|
|
.setLngLat([pts[0].lon, pts[0].lat]).addTo(map);
|
|
new MarkerClass({ element: makeDot('#f87171'), anchor: 'center' })
|
|
.setLngLat([pts[pts.length - 1].lon, pts[pts.length - 1].lat]).addTo(map);
|
|
};
|
|
map.loaded() ? add() : map.once('load', add);
|
|
}
|
|
|
|
// Hover dot linked to chart crosshair
|
|
$: if (hoverMarker && timeseries && hoveredIdx != null) {
|
|
const lat = timeseries.lat?.[hoveredIdx];
|
|
const lon = timeseries.lon?.[hoveredIdx];
|
|
if (lat != null && lon != null) {
|
|
hoverMarker.getElement().style.display = 'block';
|
|
hoverMarker.setLngLat([lon, lat]);
|
|
}
|
|
} else if (hoverMarker) {
|
|
hoverMarker.getElement().style.display = 'none';
|
|
}
|
|
|
|
function makeDot(color: string): HTMLDivElement {
|
|
const el = document.createElement('div');
|
|
el.style.cssText = `
|
|
width:10px;height:10px;border-radius:50%;
|
|
background:${color};border:2px solid white;
|
|
box-shadow:0 0 4px rgba(0,0,0,0.5);
|
|
`;
|
|
return el;
|
|
}
|
|
|
|
onDestroy(() => {
|
|
resizeObserver?.disconnect();
|
|
map?.remove();
|
|
});
|
|
|
|
let resizeObserver: ResizeObserver;
|
|
$: if (mapEl && map) {
|
|
resizeObserver?.disconnect();
|
|
resizeObserver = new ResizeObserver(() => map?.resize());
|
|
resizeObserver.observe(mapEl);
|
|
}
|
|
</script>
|
|
|
|
<div bind:this={mapEl} class="w-full h-full"></div>
|