map now working
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
|
||||
import { formatDistance, formatDuration, formatElevation, formatDate, sportIcon, sportColor, sportLabel } from '../lib/format';
|
||||
|
||||
/** Render preview_coords as an SVG polyline path string. */
|
||||
function trackPath(coords: [number, number][] | null, w: number, h: number): string {
|
||||
if (!coords || coords.length < 2) return '';
|
||||
const lats = coords.map(c => c[0]);
|
||||
const lons = coords.map(c => c[1]);
|
||||
const minLat = Math.min(...lats), maxLat = Math.max(...lats);
|
||||
const minLon = Math.min(...lons), maxLon = Math.max(...lons);
|
||||
const latR = maxLat - minLat || 0.001;
|
||||
const lonR = maxLon - minLon || 0.001;
|
||||
const pad = 4;
|
||||
const scaleX = (w - pad * 2) / lonR;
|
||||
const scaleY = (h - pad * 2) / latR;
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
const offX = pad + (w - pad * 2 - lonR * scale) / 2;
|
||||
const offY = pad + (h - pad * 2 - latR * scale) / 2;
|
||||
return coords
|
||||
.map(([lat, lon], i) => {
|
||||
const x = (lon - minLon) * scale + offX;
|
||||
const y = h - ((lat - minLat) * scale + offY); // flip: SVG y↓, lat↑
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 60;
|
||||
|
||||
let all: ActivitySummary[] = [];
|
||||
let sport: Sport | 'all' = 'all';
|
||||
let shown = PAGE_SIZE;
|
||||
let loading = true;
|
||||
let error = '';
|
||||
|
||||
$: filtered = sport === 'all' ? all : all.filter(a => a.sport === sport);
|
||||
$: visible = filtered.slice(0, shown);
|
||||
$: hasMore = shown < filtered.length;
|
||||
|
||||
$: if (sport) shown = PAGE_SIZE; // reset pagination on filter change
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const index: BASIndex = await res.json();
|
||||
all = index.activities.filter(a => a.privacy !== 'private');
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
const sports: Array<{ value: Sport | 'all'; label: string }> = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'cycling', label: '🚴 Cycling' },
|
||||
{ value: 'running', label: '🏃 Running' },
|
||||
{ value: 'hiking', label: '🥾 Hiking' },
|
||||
{ value: 'walking', label: '🚶 Walking' },
|
||||
{ value: 'swimming', label: '🏊 Swimming' },
|
||||
{ value: 'other', label: '⚡ Other' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<!-- Filter bar -->
|
||||
<div class="flex gap-2 mb-6 flex-wrap">
|
||||
{#each sports as s}
|
||||
<button
|
||||
class="px-3 py-1 rounded-full text-sm font-medium border transition-colors"
|
||||
class:border-zinc-700={sport !== s.value}
|
||||
class:text-zinc-400={sport !== s.value}
|
||||
class:border-[--accent]={sport === s.value}
|
||||
class:text-white={sport === s.value}
|
||||
style={sport === s.value ? 'background:var(--accent-dim)' : ''}
|
||||
on:click={() => sport = s.value}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
{/each}
|
||||
{#if all.length > 0}
|
||||
<span class="ml-auto text-sm text-zinc-500 self-center">
|
||||
{filtered.length} {filtered.length === 1 ? 'activity' : 'activities'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each Array(12) as _}
|
||||
<div class="h-36 rounded-xl bg-zinc-800 animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if error}
|
||||
<p class="text-red-400 text-center py-12">Could not load activities: {error}</p>
|
||||
{:else if filtered.length === 0}
|
||||
<p class="text-zinc-500 text-center py-12">No activities found.</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each visible as a (a.id)}
|
||||
<a
|
||||
href={`${import.meta.env.BASE_URL}activity/${a.id}/`}
|
||||
class="block rounded-xl bg-zinc-900 border border-zinc-800 p-4 hover:border-zinc-600 hover:bg-zinc-800/80 transition-all group"
|
||||
>
|
||||
<!-- header -->
|
||||
<div class="flex items-start justify-between gap-2 mb-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-zinc-500 mb-0.5">{formatDate(a.started_at)}</p>
|
||||
<h3 class="font-semibold text-white truncate group-hover:text-[--accent] transition-colors">
|
||||
{a.title}
|
||||
</h3>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
|
||||
style="background:{sportColor(a.sport)}22; color:{sportColor(a.sport)}"
|
||||
>
|
||||
{sportIcon(a.sport)} {sportLabel(a.sport, a.sub_sport)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- track thumbnail -->
|
||||
{#if a.preview_coords}
|
||||
<svg viewBox="0 0 120 70" class="w-full mt-2 mb-3 rounded overflow-hidden bg-zinc-800/60" style="height:70px">
|
||||
<path
|
||||
d={trackPath(a.preview_coords, 120, 70)}
|
||||
fill="none"
|
||||
stroke={sportColor(a.sport)}
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
opacity="0.9"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<!-- stats row -->
|
||||
<div class="grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<p class="text-lg font-bold text-white">{formatDistance(a.distance_m)}</p>
|
||||
<p class="text-xs text-zinc-500">Distance</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg font-bold text-white">{formatDuration(a.moving_time_s ?? a.duration_s)}</p>
|
||||
<p class="text-xs text-zinc-500">Moving time</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-lg font-bold text-white">{formatElevation(a.elevation_gain_m)}</p>
|
||||
<p class="text-xs text-zinc-500">Elevation</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- secondary stats -->
|
||||
{#if a.avg_speed_kmh || a.avg_hr_bpm}
|
||||
<div class="flex gap-4 mt-3 pt-3 border-t border-zinc-800 text-xs text-zinc-400">
|
||||
{#if a.avg_speed_kmh}
|
||||
<span>⚡ {a.avg_speed_kmh.toFixed(1)} km/h</span>
|
||||
{/if}
|
||||
{#if a.avg_hr_bpm}
|
||||
<span>♥ {a.avg_hr_bpm} bpm</span>
|
||||
{/if}
|
||||
{#if a.avg_cadence_rpm}
|
||||
<span>↻ {a.avg_cadence_rpm} rpm</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if hasMore}
|
||||
<div class="text-center mt-8">
|
||||
<button
|
||||
class="px-6 py-2 rounded-full border border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-white transition-colors text-sm"
|
||||
on:click={() => shown += PAGE_SIZE}
|
||||
>
|
||||
Load more ({filtered.length - shown} remaining)
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
Reference in New Issue
Block a user