Add map view toggle to activity feed
Adds a List/Map toggle to the feed and @user profile pages. The map view plots all filtered activities as sport-coloured tracks on a MapLibre map with no extra requests (uses preview_coords already in memory). Clicking a track or list row selects it: pans the map to fit, expands the list item with key stats, and scrolls it into view.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
|
||||
import { formatDistance, formatDuration, formatElevation, formatDate, isUnlisted, sportIcon, sportColor, sportLabel } from '../lib/format';
|
||||
import { loadIndexPaged, loadShardActivities, loadCombinedFeed } from '../lib/dataloader';
|
||||
import FeedMapView from './FeedMapView.svelte';
|
||||
|
||||
/** Render preview_coords as an SVG polyline path string. */
|
||||
function trackPath(coords: [number, number][] | null, w: number, h: number): string {
|
||||
@@ -50,6 +51,7 @@
|
||||
let loading = true;
|
||||
let loadingMore = false;
|
||||
let error = '';
|
||||
let viewMode: 'list' | 'map' = 'list';
|
||||
let mounted = false;
|
||||
let pendingShards: string[] = [];
|
||||
/** Grand total from feed.json — shows instance-wide count even before all pages are loaded. */
|
||||
@@ -247,7 +249,7 @@
|
||||
bind:value={query}
|
||||
class="flex-1 px-4 py-2 rounded-lg bg-zinc-900 border border-zinc-700 text-white placeholder-zinc-500 text-sm focus:outline-none focus:border-zinc-500 transition-colors"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-zinc-900 border border-zinc-700 focus-within:border-zinc-500 transition-colors">
|
||||
<span class="text-xs text-zinc-500 whitespace-nowrap select-none">From</span>
|
||||
<input
|
||||
@@ -269,6 +271,22 @@
|
||||
class="bg-transparent text-white text-sm focus:outline-none [color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex rounded-lg border border-zinc-700 overflow-hidden text-sm shrink-0">
|
||||
<button
|
||||
class="px-3 py-2 transition-colors"
|
||||
class:bg-zinc-800={viewMode === 'list'}
|
||||
class:text-white={viewMode === 'list'}
|
||||
class:text-zinc-400={viewMode !== 'list'}
|
||||
on:click={() => viewMode = 'list'}
|
||||
>List</button>
|
||||
<button
|
||||
class="px-3 py-2 border-l border-zinc-700 transition-colors"
|
||||
class:bg-zinc-800={viewMode === 'map'}
|
||||
class:text-white={viewMode === 'map'}
|
||||
class:text-zinc-400={viewMode !== 'map'}
|
||||
on:click={() => viewMode = 'map'}
|
||||
>Map</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -324,6 +342,25 @@
|
||||
</div>
|
||||
{:else if error}
|
||||
<p class="text-red-400 text-center py-12">Could not load activities: {error}</p>
|
||||
{:else if viewMode === 'map'}
|
||||
<FeedMapView activities={withSearch} base={base} />
|
||||
{#if hasMore}
|
||||
<div class="text-center mt-4">
|
||||
<button
|
||||
class="px-6 py-2 rounded-full border border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-white disabled:opacity-40 transition-colors text-sm"
|
||||
disabled={loadingMore}
|
||||
on:click={loadMore}
|
||||
>
|
||||
{#if loadingMore}
|
||||
Loading…
|
||||
{:else if canShowMore}
|
||||
Load more ({filtered.length - shown} remaining)
|
||||
{:else}
|
||||
Load older activities ({pendingShards.length} more {pendingShards.length === 1 ? 'period' : 'periods'})
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if withSearch.length === 0}
|
||||
<p class="text-zinc-500 text-center py-12">
|
||||
{#if loadingAllShards}Loading…{:else if query.trim()}No activities match "{query.trim()}".{:else}No activities found.{/if}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import type { ActivitySummary } from '../lib/types';
|
||||
import { formatDistance, formatDuration, formatElevation, formatDate, sportIcon, sportColor } from '../lib/format';
|
||||
|
||||
export let activities: ActivitySummary[] = [];
|
||||
export let base: string = '/';
|
||||
|
||||
let mapEl: HTMLDivElement;
|
||||
let map: any;
|
||||
let sourceReady = false;
|
||||
let selectedId: string | null = null;
|
||||
|
||||
const TILE_STYLE = 'https://tiles.openfreemap.org/styles/positron';
|
||||
|
||||
function bboxFromCoords(coords: [number, number][]): [[number, number], [number, number]] {
|
||||
const lons = coords.map(([, lon]) => lon);
|
||||
const lats = coords.map(([lat]) => lat);
|
||||
return [[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]];
|
||||
}
|
||||
|
||||
function toGeoJSON(acts: ActivitySummary[]) {
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: acts
|
||||
.filter(a => a.preview_coords?.length)
|
||||
.map(a => ({
|
||||
type: 'Feature' as const,
|
||||
geometry: {
|
||||
type: 'LineString' as const,
|
||||
coordinates: a.preview_coords!.map(([lat, lon]) => [lon, lat]),
|
||||
},
|
||||
properties: { id: a.id, color: sportColor(a.sport) },
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Update GeoJSON source whenever activities or sourceReady changes
|
||||
$: if (sourceReady && activities) {
|
||||
(map?.getSource('activities') as any)?.setData(toGeoJSON(activities));
|
||||
}
|
||||
|
||||
// Update paint whenever selectedId changes (after map layers exist)
|
||||
$: if (map?.getLayer('act-line')) {
|
||||
const sel = selectedId ?? '';
|
||||
map.setPaintProperty('act-line', 'line-width',
|
||||
['case', ['==', ['get', 'id'], sel], 4, 2]);
|
||||
map.setPaintProperty('act-line', 'line-opacity',
|
||||
['case', ['==', ['get', 'id'], sel], 1.0, 0.55]);
|
||||
}
|
||||
|
||||
async function selectActivity(id: string) {
|
||||
selectedId = selectedId === id ? null : id;
|
||||
if (!selectedId) return;
|
||||
const act = activities.find(a => a.id === selectedId);
|
||||
if (act?.preview_coords?.length && map) {
|
||||
map.fitBounds(bboxFromCoords(act.preview_coords), { padding: 60, maxZoom: 15, duration: 500 });
|
||||
}
|
||||
await tick();
|
||||
document.getElementById(`fmv-${selectedId}`)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
map = new maplibregl.Map({
|
||||
container: mapEl,
|
||||
style: TILE_STYLE,
|
||||
center: [0, 30],
|
||||
zoom: 2,
|
||||
attributionControl: false,
|
||||
});
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
|
||||
map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
|
||||
|
||||
map.on('load', () => {
|
||||
map.addSource('activities', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
});
|
||||
|
||||
// Wide transparent hit area for easier clicking
|
||||
map.addLayer({ id: 'act-hit', type: 'line', source: 'activities',
|
||||
paint: { 'line-color': 'transparent', 'line-width': 14 } });
|
||||
|
||||
map.addLayer({ id: 'act-line', type: 'line', source: 'activities',
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': 2,
|
||||
'line-opacity': 0.55,
|
||||
},
|
||||
});
|
||||
|
||||
map.on('click', 'act-hit', (e: any) => {
|
||||
const id = e.features?.[0]?.properties?.id;
|
||||
if (id) selectActivity(id);
|
||||
});
|
||||
map.on('mouseenter', 'act-hit', () => { map.getCanvas().style.cursor = 'pointer'; });
|
||||
map.on('mouseleave', 'act-hit', () => { map.getCanvas().style.cursor = ''; });
|
||||
|
||||
sourceReady = true;
|
||||
|
||||
// Fit to all loaded tracks on initial render
|
||||
const geo = toGeoJSON(activities);
|
||||
if (geo.features.length > 0) {
|
||||
const allCoords = geo.features.flatMap(f => (f.geometry as any).coordinates as [number, number][]);
|
||||
const lons = allCoords.map(c => c[0]);
|
||||
const lats = allCoords.map(c => c[1]);
|
||||
map.fitBounds(
|
||||
[[Math.min(...lons), Math.min(...lats)], [Math.max(...lons), Math.max(...lats)]],
|
||||
{ padding: 40, maxZoom: 14, duration: 0 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => { map?.remove(); });
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<!-- Map -->
|
||||
<div style="height: 420px;" class="relative">
|
||||
<div bind:this={mapEl} class="w-full h-full"></div>
|
||||
</div>
|
||||
|
||||
<!-- Activity list -->
|
||||
<div class="border-t border-zinc-800 max-h-[360px] overflow-y-auto">
|
||||
{#if activities.length === 0}
|
||||
<p class="text-zinc-500 text-sm text-center py-8">No activities to display.</p>
|
||||
{:else}
|
||||
<div class="divide-y divide-zinc-800/60">
|
||||
{#each activities as a (a.id)}
|
||||
<div
|
||||
id="fmv-{a.id}"
|
||||
class="px-4 py-3 cursor-pointer transition-colors hover:bg-zinc-800/40"
|
||||
class:bg-zinc-800={selectedId === a.id}
|
||||
on:click={() => selectActivity(a.id)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:keydown={e => e.key === 'Enter' && selectActivity(a.id)}
|
||||
>
|
||||
<!-- Summary row -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="shrink-0 text-base">{sportIcon(a.sport)}</span>
|
||||
<div class="min-w-0">
|
||||
<a
|
||||
href={a.detail_url ? `${base}activity/${a.id}/` : `${base}activity/local/?id=${a.id}`}
|
||||
class="text-sm font-medium text-white hover:text-[--accent] transition-colors truncate block"
|
||||
on:click|stopPropagation
|
||||
>{a.title}</a>
|
||||
<p class="text-xs text-zinc-500">
|
||||
{formatDate(a.started_at)}{#if a.handle} · @{a.handle}{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 text-xs shrink-0">
|
||||
{#if a.distance_m != null}
|
||||
<span class="text-white">{formatDistance(a.distance_m)}</span>
|
||||
{/if}
|
||||
{#if a.elevation_gain_m != null}
|
||||
<span class="text-zinc-400">↑{formatElevation(a.elevation_gain_m)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded stats when selected -->
|
||||
{#if selectedId === a.id}
|
||||
<div class="mt-2.5 pl-8 grid grid-cols-2 sm:grid-cols-4 gap-x-6 gap-y-1 text-xs">
|
||||
{#if a.moving_time_s != null}
|
||||
<div><span class="text-white font-medium">{formatDuration(a.moving_time_s)}</span> <span class="text-zinc-500">time</span></div>
|
||||
{/if}
|
||||
{#if a.avg_speed_kmh != null}
|
||||
<div><span class="text-white font-medium">{a.avg_speed_kmh.toFixed(1)} km/h</span> <span class="text-zinc-500">avg speed</span></div>
|
||||
{/if}
|
||||
{#if a.avg_hr_bpm != null}
|
||||
<div><span class="text-white font-medium">{a.avg_hr_bpm} bpm</span> <span class="text-zinc-500">avg HR</span></div>
|
||||
{/if}
|
||||
{#if a.avg_power_w != null}
|
||||
<div><span class="text-white font-medium">{a.avg_power_w} W</span> <span class="text-zinc-500">avg power</span></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user