306 lines
12 KiB
Svelte
306 lines
12 KiB
Svelte
<script lang="ts">
|
||
import { onMount, onDestroy } from 'svelte';
|
||
import maplibregl from 'maplibre-gl';
|
||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||
import { formatDistance, formatDate, formatElapsed, sportIcon } from '../lib/format';
|
||
|
||
export let base: string = '/';
|
||
export let segmentId: string = '';
|
||
|
||
interface Segment {
|
||
id: string;
|
||
name: string;
|
||
sport: string | null;
|
||
distance_m: number;
|
||
polyline: [number, number][];
|
||
created_by: string;
|
||
created_at: string;
|
||
}
|
||
|
||
interface Effort {
|
||
activity_id: string;
|
||
started_at: string;
|
||
elapsed_s: number;
|
||
avg_speed_kmh: number | null;
|
||
avg_hr_bpm: number | null;
|
||
avg_power_w: number | null;
|
||
np_power_w: number | null;
|
||
}
|
||
|
||
type SortKey = 'started_at' | 'elapsed_s' | 'avg_speed_kmh' | 'avg_hr_bpm' | 'avg_power_w' | 'np_power_w';
|
||
|
||
let segment: Segment | null = null;
|
||
let efforts: Effort[] = [];
|
||
let loading = true;
|
||
let loggedIn = true;
|
||
let mapEl: HTMLDivElement;
|
||
let map: any;
|
||
let mapReady = false;
|
||
let retriggering = false;
|
||
let retriggerMsg: string | null = null;
|
||
let sortKey: SortKey = 'elapsed_s';
|
||
let sortDir: 1 | -1 = 1;
|
||
|
||
$: prElapsed = efforts.length ? Math.min(...efforts.map(e => e.elapsed_s)) : null;
|
||
|
||
$: sortedEfforts = [...efforts].sort((a, b) => {
|
||
const av = a[sortKey], bv = b[sortKey];
|
||
if (av == null && bv == null) return 0;
|
||
if (av == null) return 1;
|
||
if (bv == null) return -1;
|
||
return av < bv ? -sortDir : av > bv ? sortDir : 0;
|
||
});
|
||
|
||
function setSort(key: SortKey) {
|
||
if (sortKey === key) sortDir = sortDir === 1 ? -1 : 1;
|
||
else { sortKey = key; sortDir = 1; }
|
||
}
|
||
|
||
function delta(elapsed: number): number {
|
||
return prElapsed != null ? elapsed - prElapsed : 0;
|
||
}
|
||
|
||
function formatDelta(d: number): string {
|
||
if (d === 0) return 'PR';
|
||
const abs = Math.abs(d);
|
||
const m = Math.floor(abs / 60);
|
||
const s = abs % 60;
|
||
const sign = d > 0 ? '+' : '−';
|
||
return m > 0 ? `${sign}${m}m${s.toString().padStart(2, '0')}s` : `${sign}${s}s`;
|
||
}
|
||
|
||
onMount(async () => {
|
||
const [segRes, effRes] = await Promise.allSettled([
|
||
fetch(`/api/segments/${segmentId}`, { credentials: 'include' }),
|
||
fetch(`/api/segments/${segmentId}/efforts`, { credentials: 'include' }),
|
||
]);
|
||
|
||
if (segRes.status === 'fulfilled' && segRes.value.ok) {
|
||
segment = await segRes.value.json();
|
||
}
|
||
if (effRes.status === 'fulfilled') {
|
||
if (effRes.value.status === 401) {
|
||
loggedIn = false;
|
||
} else if (effRes.value.ok) {
|
||
efforts = await effRes.value.json();
|
||
}
|
||
}
|
||
|
||
loading = false;
|
||
if (segment) setTimeout(initMap, 0);
|
||
});
|
||
|
||
onDestroy(() => map?.remove());
|
||
|
||
function initMap() {
|
||
if (!mapEl || !segment) return;
|
||
const lons = segment.polyline.map(([, lon]) => lon);
|
||
const lats = segment.polyline.map(([lat]) => lat);
|
||
|
||
map = new maplibregl.Map({
|
||
container: mapEl,
|
||
style: 'https://tiles.openfreemap.org/styles/positron',
|
||
bounds: [[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]],
|
||
fitBoundsOptions: { padding: 60 },
|
||
attributionControl: false,
|
||
});
|
||
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
|
||
|
||
map.on('load', () => {
|
||
map.addSource('seg', {
|
||
type: 'geojson',
|
||
lineMetrics: true,
|
||
data: {
|
||
type: 'Feature',
|
||
geometry: {
|
||
type: 'LineString',
|
||
coordinates: segment!.polyline.map(([lat, lon]) => [lon, lat]),
|
||
},
|
||
},
|
||
});
|
||
map.addLayer({ id: 'seg-line', type: 'line', source: 'seg',
|
||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||
paint: {
|
||
'line-gradient': ['interpolate', ['linear'], ['line-progress'], 0, '#22c55e', 1, '#ef4444'],
|
||
'line-width': 5,
|
||
} });
|
||
|
||
// Start/end markers
|
||
const start = segment!.polyline[0];
|
||
const end = segment!.polyline[segment!.polyline.length - 1];
|
||
for (const [pt, label, color] of [[start, 'S', '#22c55e'], [end, 'E', '#ef4444']] as const) {
|
||
const el = document.createElement('div');
|
||
el.style.cssText = `width:20px;height:20px;border-radius:50%;background:${color};color:#fff;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.4)`;
|
||
el.textContent = label;
|
||
new maplibregl.Marker({ element: el }).setLngLat([pt[1], pt[0]]).addTo(map);
|
||
}
|
||
|
||
mapReady = true;
|
||
});
|
||
}
|
||
|
||
async function retrigger() {
|
||
retriggering = true;
|
||
retriggerMsg = null;
|
||
try {
|
||
const r = await fetch(`/api/segments/${segmentId}/detect`, {
|
||
method: 'POST', credentials: 'include',
|
||
});
|
||
const d = await r.json();
|
||
if (r.ok) {
|
||
retriggerMsg = `Found ${d.efforts_found} effort${d.efforts_found !== 1 ? 's' : ''}.`;
|
||
// Reload efforts
|
||
const er = await fetch(`/api/segments/${segmentId}/efforts`, { credentials: 'include' });
|
||
if (er.ok) efforts = await er.json();
|
||
} else {
|
||
retriggerMsg = d.detail ?? 'Detection failed.';
|
||
}
|
||
} catch {
|
||
retriggerMsg = 'Could not reach server.';
|
||
}
|
||
retriggering = false;
|
||
}
|
||
</script>
|
||
|
||
<div class="max-w-3xl mx-auto px-4 py-6">
|
||
<!-- Back -->
|
||
<a href="{base}segments/" class="text-zinc-500 hover:text-white transition-colors text-sm">← Segments</a>
|
||
|
||
{#if loading}
|
||
<div class="mt-6 space-y-4">
|
||
<div class="h-7 w-48 bg-zinc-800 rounded animate-pulse"></div>
|
||
<div class="h-64 bg-zinc-800 rounded-xl animate-pulse"></div>
|
||
</div>
|
||
|
||
{:else if !segment}
|
||
<p class="mt-6 text-red-400 text-sm">Segment not found.</p>
|
||
|
||
{:else}
|
||
<!-- Title + metadata -->
|
||
<div class="mt-4 mb-4 flex items-start gap-3">
|
||
{#if segment.sport}
|
||
<span class="text-2xl mt-0.5">{sportIcon(segment.sport as any)}</span>
|
||
{/if}
|
||
<div>
|
||
<h1 class="text-2xl font-bold text-white">{segment.name}</h1>
|
||
<p class="text-sm text-zinc-500 mt-0.5">
|
||
{formatDistance(segment.distance_m)}
|
||
{#if segment.sport} · {segment.sport}{/if}
|
||
· by {segment.created_by}
|
||
· {formatDate(segment.created_at)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Map -->
|
||
<div class="rounded-xl overflow-hidden mb-6" style="height: 280px;">
|
||
<div bind:this={mapEl} class="w-full h-full"></div>
|
||
</div>
|
||
|
||
<!-- Efforts -->
|
||
<div class="flex items-center justify-between mb-3">
|
||
<h2 class="text-base font-semibold text-white">Your efforts</h2>
|
||
{#if loggedIn}
|
||
<div class="flex items-center gap-3">
|
||
{#if retriggerMsg}
|
||
<span class="text-xs text-zinc-400">{retriggerMsg}</span>
|
||
{/if}
|
||
<button
|
||
on:click={retrigger}
|
||
disabled={retriggering}
|
||
class="text-xs px-3 py-1.5 rounded-lg border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-white transition-colors disabled:opacity-40"
|
||
>{retriggering ? 'Scanning…' : 'Rescan activities'}</button>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if !loggedIn}
|
||
<p class="text-zinc-500 text-sm">
|
||
<a href="{base}login/" class="text-blue-400 hover:text-blue-300">Log in</a> to see your efforts on this segment.
|
||
</p>
|
||
|
||
{:else if efforts.length === 0}
|
||
<div class="bg-zinc-900 rounded-xl border border-zinc-800 p-6 text-center">
|
||
<p class="text-zinc-500 text-sm">No efforts yet.</p>
|
||
<p class="text-zinc-600 text-xs mt-1">Upload an activity that passes through this segment, or use "Rescan activities" to check existing ones.</p>
|
||
</div>
|
||
|
||
{:else}
|
||
<div class="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||
<table class="w-full text-sm">
|
||
<thead class="border-b border-zinc-800">
|
||
<tr class="text-left text-zinc-500 text-xs">
|
||
<th class="px-4 py-2">
|
||
<button class="hover:text-white transition-colors" class:text-white={sortKey==='started_at'} on:click={() => setSort('started_at')}>
|
||
Date{sortKey==='started_at' ? (sortDir===1 ? ' ↑' : ' ↓') : ''}
|
||
</button>
|
||
</th>
|
||
<th class="px-4 py-2">
|
||
<button class="hover:text-white transition-colors" class:text-white={sortKey==='elapsed_s'} on:click={() => setSort('elapsed_s')}>
|
||
Time{sortKey==='elapsed_s' ? (sortDir===1 ? ' ↑' : ' ↓') : ''}
|
||
</button>
|
||
</th>
|
||
<th class="px-4 py-2">Δ PR</th>
|
||
<th class="px-4 py-2 hidden sm:table-cell">
|
||
<button class="hover:text-white transition-colors" class:text-white={sortKey==='avg_speed_kmh'} on:click={() => setSort('avg_speed_kmh')}>
|
||
Avg speed{sortKey==='avg_speed_kmh' ? (sortDir===1 ? ' ↑' : ' ↓') : ''}
|
||
</button>
|
||
</th>
|
||
<th class="px-4 py-2 hidden sm:table-cell">
|
||
<button class="hover:text-white transition-colors" class:text-white={sortKey==='avg_hr_bpm'} on:click={() => setSort('avg_hr_bpm')}>
|
||
Avg HR{sortKey==='avg_hr_bpm' ? (sortDir===1 ? ' ↑' : ' ↓') : ''}
|
||
</button>
|
||
</th>
|
||
<th class="px-4 py-2 hidden md:table-cell">
|
||
<button class="hover:text-white transition-colors" class:text-white={sortKey==='avg_power_w'} on:click={() => setSort('avg_power_w')}>
|
||
Avg power{sortKey==='avg_power_w' ? (sortDir===1 ? ' ↑' : ' ↓') : ''}
|
||
</button>
|
||
</th>
|
||
<th class="px-4 py-2 hidden md:table-cell">
|
||
<button class="hover:text-white transition-colors" class:text-white={sortKey==='np_power_w'} on:click={() => setSort('np_power_w')}>
|
||
NP{sortKey==='np_power_w' ? (sortDir===1 ? ' ↑' : ' ↓') : ''}
|
||
</button>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each sortedEfforts as e (e.activity_id + e.started_at)}
|
||
{@const d = delta(e.elapsed_s)}
|
||
{@const isPR = d === 0}
|
||
<tr
|
||
class="border-b border-zinc-800/50 hover:bg-zinc-800/50 transition-colors"
|
||
class:bg-green-950={isPR}
|
||
class:hover:bg-green-900={isPR}
|
||
>
|
||
<td class="px-4 py-2">
|
||
<a href="{base}activity/{e.activity_id}/" class="text-blue-400 hover:text-blue-300 transition-colors">
|
||
{formatDate(e.started_at)}
|
||
</a>
|
||
</td>
|
||
<td class="px-4 py-2 text-white font-mono">{formatElapsed(e.elapsed_s)}</td>
|
||
<td class="px-4 py-2 font-medium text-xs"
|
||
class:text-green-400={isPR}
|
||
class:text-zinc-400={!isPR}>
|
||
{formatDelta(d)}
|
||
</td>
|
||
<td class="px-4 py-2 text-zinc-300 hidden sm:table-cell">
|
||
{e.avg_speed_kmh != null ? `${e.avg_speed_kmh.toFixed(1)} km/h` : '—'}
|
||
</td>
|
||
<td class="px-4 py-2 text-zinc-300 hidden sm:table-cell">
|
||
{e.avg_hr_bpm != null ? `${e.avg_hr_bpm} bpm` : '—'}
|
||
</td>
|
||
<td class="px-4 py-2 text-zinc-300 hidden md:table-cell">
|
||
{e.avg_power_w != null ? `${e.avg_power_w} W` : '—'}
|
||
</td>
|
||
<td class="px-4 py-2 text-zinc-300 hidden md:table-cell">
|
||
{e.np_power_w != null ? `${e.np_power_w} W` : '—'}
|
||
</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{/if}
|
||
{/if}
|
||
</div>
|