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:
Davide Scaini
2026-05-22 11:47:47 +02:00
parent 7f2a751065
commit df025873c6
2 changed files with 228 additions and 1 deletions
+38 -1
View File
@@ -3,6 +3,7 @@
import type { ActivitySummary, BASIndex, Sport } from '../lib/types'; import type { ActivitySummary, BASIndex, Sport } from '../lib/types';
import { formatDistance, formatDuration, formatElevation, formatDate, isUnlisted, sportIcon, sportColor, sportLabel } from '../lib/format'; import { formatDistance, formatDuration, formatElevation, formatDate, isUnlisted, sportIcon, sportColor, sportLabel } from '../lib/format';
import { loadIndexPaged, loadShardActivities, loadCombinedFeed } from '../lib/dataloader'; import { loadIndexPaged, loadShardActivities, loadCombinedFeed } from '../lib/dataloader';
import FeedMapView from './FeedMapView.svelte';
/** Render preview_coords as an SVG polyline path string. */ /** Render preview_coords as an SVG polyline path string. */
function trackPath(coords: [number, number][] | null, w: number, h: number): string { function trackPath(coords: [number, number][] | null, w: number, h: number): string {
@@ -50,6 +51,7 @@
let loading = true; let loading = true;
let loadingMore = false; let loadingMore = false;
let error = ''; let error = '';
let viewMode: 'list' | 'map' = 'list';
let mounted = false; let mounted = false;
let pendingShards: string[] = []; let pendingShards: string[] = [];
/** Grand total from feed.json — shows instance-wide count even before all pages are loaded. */ /** Grand total from feed.json — shows instance-wide count even before all pages are loaded. */
@@ -247,7 +249,7 @@
bind:value={query} 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" 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"> <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> <span class="text-xs text-zinc-500 whitespace-nowrap select-none">From</span>
<input <input
@@ -269,6 +271,22 @@
class="bg-transparent text-white text-sm focus:outline-none [color-scheme:dark]" class="bg-transparent text-white text-sm focus:outline-none [color-scheme:dark]"
/> />
</div> </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>
</div> </div>
@@ -324,6 +342,25 @@
</div> </div>
{:else if error} {:else if error}
<p class="text-red-400 text-center py-12">Could not load activities: {error}</p> <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} {:else if withSearch.length === 0}
<p class="text-zinc-500 text-center py-12"> <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} {#if loadingAllShards}Loading…{:else if query.trim()}No activities match "{query.trim()}".{:else}No activities found.{/if}
+190
View File
@@ -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>