Files
bincio-activity/site/src/components/SegmentDetail.svelte
T

302 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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',
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-color': '#f59e0b', '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>