persistent map with animated transitions and dimmed overview on detail pages

This commit is contained in:
Vincent van der Wal
2026-07-22 12:34:47 +02:00
parent cc9037c922
commit 5f526a46ce
10 changed files with 437 additions and 112 deletions
+24
View File
@@ -99,6 +99,30 @@
box-sizing: border-box;
}
/* page transitions: quick cross-fade for content; the map morphs separately */
@media not (prefers-reduced-motion: reduce) {
::view-transition-old(root) {
animation: 110ms ease both vt-fade-out;
}
::view-transition-new(root) {
animation: 220ms ease 40ms both vt-fade-in;
}
::view-transition-group(streba-map) {
animation-duration: 320ms;
animation-timing-function: cubic-bezier(0.3, 0, 0.2, 1);
}
}
@keyframes vt-fade-out {
to {
opacity: 0;
}
}
@keyframes vt-fade-in {
from {
opacity: 0;
}
}
body {
margin: 0;
background: var(--page);
+132 -65
View File
@@ -38,12 +38,16 @@
let {
tracks = [],
peaks = [],
height = '420px',
highlightId = null,
focus = null,
height = '100%',
onpeakclick,
onviewport
}: {
tracks?: MapTrack[];
peaks?: MapPeak[];
highlightId?: number | null;
focus?: [[number, number], [number, number]] | null;
height?: string;
onpeakclick?: (id: number) => void;
onviewport?: (view: Viewport) => void;
@@ -51,7 +55,7 @@
let container: HTMLDivElement;
let map: import('maplibre-gl').Map | undefined;
let lib: typeof import('maplibre-gl') | undefined;
let loaded = $state(false);
let currentDark: boolean | undefined;
let pendingTerrain: unknown = null;
@@ -59,6 +63,12 @@
map?.flyTo({ center: [lon, lat], zoom });
}
let reportFn: (() => void) | null = null;
/** Ask the map to re-announce its current viewport (used when a page starts listening). */
export function reportViewport() {
if (loaded) reportFn?.();
}
function peaksGeojson(): FeatureCollection {
return {
type: 'FeatureCollection',
@@ -76,11 +86,96 @@
};
}
// push new data into the peaks layer when peaks/climbed state changes
function tracksGeojson(dark: boolean): FeatureCollection {
return {
type: 'FeatureCollection',
features: tracks.map((track) => {
const color = typeColor(track.type ?? 'outdoor');
return {
type: 'Feature' as const,
properties: {
id: track.id ?? null,
name: track.name ?? '',
username: track.username ?? '',
type: track.type ?? '',
date: track.date ?? '',
distance_m: track.distance_m ?? 0,
color: dark ? color.dark : color.light,
dim: highlightId !== null && track.id !== highlightId
},
geometry: {
type: 'LineString' as const,
coordinates: track.latlngs.map(([lat, lon]) => [lon, lat])
}
};
})
};
}
function overviewBounds(): [[number, number], [number, number]] | null {
let minLat = Infinity,
minLon = Infinity,
maxLat = -Infinity,
maxLon = -Infinity;
for (const track of tracks) {
for (const [lat, lon] of track.latlngs) {
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
}
}
return Number.isFinite(minLat)
? [
[minLat, minLon],
[maxLat, maxLon]
]
: null;
}
function applyCamera(animate: boolean) {
if (!map) return;
const target = focus ?? overviewBounds();
if (!target) return;
map.fitBounds(
[
[target[0][1], target[0][0]],
[target[1][1], target[1][0]]
],
{ padding: 48, maxZoom: 14, duration: animate ? 1100 : 0 }
);
}
// keep sources in sync when data, highlight, or theme changes
$effect(() => {
void peaks;
const source = map?.getSource('peaks') as import('maplibre-gl').GeoJSONSource | undefined;
source?.setData(peaksGeojson());
if (!loaded) return;
(map?.getSource('peaks') as import('maplibre-gl').GeoJSONSource | undefined)?.setData(
peaksGeojson()
);
});
$effect(() => {
void tracks;
void highlightId;
if (!loaded) return;
(map?.getSource('tracks') as import('maplibre-gl').GeoJSONSource | undefined)?.setData(
tracksGeojson(currentDark ?? false)
);
});
// animate the camera when a page requests a new focus
let lastFocusKey: string | undefined;
$effect(() => {
const key = JSON.stringify(focus);
if (!loaded) return;
if (lastFocusKey === undefined) {
lastFocusKey = key;
return;
}
if (key !== lastFocusKey) {
lastFocusKey = key;
applyCamera(true);
}
});
// swap map style when the theme changes
@@ -117,44 +212,28 @@
firstSymbol
);
map.addSource('tracks', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: tracks.map((track) => {
const color = typeColor(track.type ?? 'outdoor');
return {
type: 'Feature' as const,
properties: {
id: track.id ?? null,
name: track.name ?? '',
username: track.username ?? '',
type: track.type ?? '',
date: track.date ?? '',
distance_m: track.distance_m ?? 0,
color: dark ? color.dark : color.light
},
geometry: {
type: 'LineString' as const,
coordinates: track.latlngs.map(([lat, lon]) => [lon, lat])
}
};
})
}
});
map.addSource('tracks', { type: 'geojson', data: tracksGeojson(dark) });
map.addLayer({
id: 'tracks-casing',
type: 'line',
source: 'tracks',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': dark ? '#1a1a19' : '#ffffff', 'line-width': 5, 'line-opacity': 0.6 }
paint: {
'line-color': dark ? '#1a1a19' : '#ffffff',
'line-width': 5,
'line-opacity': ['case', ['get', 'dim'], 0.08, 0.6]
}
});
map.addLayer({
id: 'tracks-line',
type: 'line',
source: 'tracks',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': ['get', 'color'], 'line-width': 2.5 }
paint: {
'line-color': ['get', 'color'],
'line-width': 2.5,
'line-opacity': ['case', ['get', 'dim'], 0.15, 1]
}
});
// invisible wide line so thin routes are easy to click
map.addLayer({
@@ -173,14 +252,9 @@
source: 'peaks',
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 5, 5, 12, 9],
'circle-color': [
'case',
['get', 'climbed'],
'#0ca30c',
dark ? '#1a1a19' : '#fcfcfb'
],
'circle-color': ['case', ['get', 'climbed'], '#0ca30c', dark ? '#1a1a19' : '#fcfcfb'],
'circle-stroke-width': 1.5,
'circle-stroke-color': ['case', ['get', 'climbed'], '#0ca30c', dark ? '#898781' : '#898781']
'circle-stroke-color': ['case', ['get', 'climbed'], '#0ca30c', '#898781']
}
});
map.addLayer({
@@ -210,7 +284,6 @@
(async () => {
const maplibre = await import('maplibre-gl');
if (cancelled) return;
lib = maplibre;
currentDark = theme.isDark;
map = new maplibre.Map({
@@ -228,6 +301,12 @@
);
map.on('style.load', () => addLayers(currentDark ?? false));
map.once('load', () => {
loaded = true;
lastFocusKey = JSON.stringify(focus);
applyCamera(false);
report();
});
// peak interaction: click to toggle, hover for tooltip
const popup = new maplibre.Popup({
@@ -303,28 +382,19 @@
if (map) map.getCanvas().style.cursor = '';
});
if (onviewport) {
const report = () => {
if (!map) return;
const b = map.getBounds();
onviewport({
minLat: b.getSouth(),
minLon: b.getWest(),
maxLat: b.getNorth(),
maxLon: b.getEast(),
zoom: map.getZoom()
});
};
map.on('moveend', report);
map.once('load', report);
}
const bounds = new maplibre.LngLatBounds();
for (const track of tracks) for (const [lat, lon] of track.latlngs) bounds.extend([lon, lat]);
for (const p of peaks) bounds.extend([p.lon, p.lat]);
if (!bounds.isEmpty()) {
map.fitBounds(bounds, { padding: 40, maxZoom: 14, animate: false });
}
const report = () => {
if (!map) return;
const b = map.getBounds();
onviewport?.({
minLat: b.getSouth(),
minLon: b.getWest(),
maxLat: b.getNorth(),
maxLon: b.getEast(),
zoom: map.getZoom()
});
};
reportFn = report;
map.on('moveend', report);
})();
return () => {
cancelled = true;
@@ -348,9 +418,6 @@
border-radius: 0.5rem;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
:global(.maplibregl-ctrl-group:not(:empty)) {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
:global(.maplibregl-ctrl-group button + button) {
border-top: 1px solid var(--border);
}
+23
View File
@@ -0,0 +1,23 @@
<script lang="ts">
import { mapState } from '$lib/map-state.svelte';
let { height = '440px' }: { height?: string } = $props();
let el: HTMLDivElement;
// move the persistent map into this page's slot; return it on leave
$effect(() => {
if (mapState.carrier && el) {
mapState.attach(el);
return () => mapState.detach();
}
});
</script>
<div class="map-slot" bind:this={el} style:height></div>
<style>
.map-slot {
width: 100%;
border-radius: 0.875rem;
}
</style>
+107
View File
@@ -0,0 +1,107 @@
// Shared state for the single persistent map instance hosted by the layout.
// Pages configure what the map shows; the map itself never remounts, so
// navigation animates changes instead of rebuilding the canvas.
import type { MapPeak, MapTrack, Viewport } from '$lib/components/Map.svelte';
type Bounds = [[number, number], [number, number]];
let peaks = $state<MapPeak[]>([]);
let highlightId = $state<number | null>(null);
let focus = $state<Bounds | null>(null);
let detailTrack = $state<MapTrack | null>(null);
let carrier = $state<HTMLElement | null>(null);
let active = $state(false);
let home: HTMLElement | null = null;
let peakClickHandler: ((id: number) => void) | null = null;
let viewportHandler: ((view: Viewport) => void) | null = null;
let flyToFn: ((lat: number, lon: number, zoom?: number) => void) | null = null;
let reportFn: (() => void) | null = null;
export const mapState = {
get peaks() {
return peaks;
},
get highlightId() {
return highlightId;
},
get focus() {
return focus;
},
get detailTrack() {
return detailTrack;
},
get carrier() {
return carrier;
},
get active() {
return active;
},
setPeaks(value: MapPeak[]) {
peaks = value;
},
setHighlight(id: number | null) {
highlightId = id;
},
setFocus(bounds: Bounds | null) {
focus = bounds;
},
setDetailTrack(track: MapTrack | null) {
detailTrack = track;
},
setHandlers(handlers: {
onpeakclick?: (id: number) => void;
onviewport?: (view: Viewport) => void;
}) {
peakClickHandler = handlers.onpeakclick ?? null;
viewportHandler = handlers.onviewport ?? null;
},
reset() {
peaks = [];
highlightId = null;
focus = null;
detailTrack = null;
peakClickHandler = null;
viewportHandler = null;
},
handlePeakClick(id: number) {
peakClickHandler?.(id);
},
handleViewport(view: Viewport) {
viewportHandler?.(view);
},
registerCarrier(el: HTMLElement, homeEl: HTMLElement) {
carrier = el;
home = homeEl;
},
registerFlyTo(fn: (lat: number, lon: number, zoom?: number) => void) {
flyToFn = fn;
},
flyTo(lat: number, lon: number, zoom?: number) {
flyToFn?.(lat, lon, zoom);
},
registerReport(fn: () => void) {
reportFn = fn;
},
/** Ask the map to announce its viewport to the current handler. */
requestViewport() {
reportFn?.();
},
attach(slot: HTMLElement) {
if (carrier) {
slot.appendChild(carrier);
active = true;
}
},
detach() {
if (carrier && home) {
home.appendChild(carrier);
active = false;
}
}
};
+34 -1
View File
@@ -1,5 +1,38 @@
import { db } from '$lib/server/db';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => {
return { user: locals.user };
// the overview: every user's tracks, thinned for the persistent map
const rows = db
.prepare(
`SELECT a.id, a.name, a.type, a.date, a.distance_m, a.points, u.username
FROM activities a JOIN users u ON u.id = a.user_id`
)
.all() as {
id: number;
name: string;
type: string;
date: string | null;
distance_m: number;
points: string;
username: string;
}[];
const tracks = rows.map((row) => {
const points = JSON.parse(row.points) as { lat: number; lon: number }[];
const step = Math.max(1, Math.floor(points.length / 300));
const latlngs = points
.filter((_, i) => i % step === 0 || i === points.length - 1)
.map((p) => [p.lat, p.lon] as [number, number]);
return {
id: row.id,
name: row.name,
username: row.username,
type: row.type,
date: row.date,
distance_m: row.distance_m,
latlngs
};
});
return { user: locals.user, tracks };
};
+78
View File
@@ -2,7 +2,51 @@
import '@fontsource-variable/inter';
import '../app.css';
import { page } from '$app/state';
import { onNavigate } from '$app/navigation';
import { theme } from '$lib/theme.svelte';
import { mapState } from '$lib/map-state.svelte';
import Map from '$lib/components/Map.svelte';
let mapHome: HTMLDivElement;
let mapCarrier: HTMLDivElement;
$effect(() => {
if (mapCarrier && mapHome) mapState.registerCarrier(mapCarrier, mapHome);
});
let mapComponent: Map | undefined = $state();
$effect(() => {
if (mapComponent) {
mapState.registerFlyTo((lat, lon, zoom) => mapComponent!.flyTo(lat, lon, zoom));
mapState.registerReport(() => mapComponent!.reportViewport());
}
});
// the current activity's full-resolution track replaces its thinned overview twin
const mapTracks = $derived.by(() => {
const detail = mapState.detailTrack;
if (!detail) return data.tracks;
let replaced = false;
const merged = data.tracks.map((t) => {
if (t.id === detail.id) {
replaced = true;
return detail;
}
return t;
});
return replaced ? merged : [...merged, detail];
});
// cross-fade page content on navigation (the map morphs on its own)
onNavigate((navigation) => {
if (!document.startViewTransition) return;
return new Promise((resolve) => {
document.startViewTransition(async () => {
resolve();
await navigation.complete;
});
});
});
const themeTitle = $derived(
theme.pref === 'auto'
@@ -95,6 +139,25 @@
{@render children()}
</main>
<!-- the persistent map lives here when no page is showing it -->
<div class="map-home" bind:this={mapHome} aria-hidden={!mapState.active}>
<div
class="map-carrier"
bind:this={mapCarrier}
style:view-transition-name={mapState.active ? 'streba-map' : 'none'}
>
<Map
bind:this={mapComponent}
tracks={mapTracks}
peaks={mapState.peaks}
highlightId={mapState.highlightId}
focus={mapState.focus}
onpeakclick={(id) => mapState.handlePeakClick(id)}
onviewport={(view) => mapState.handleViewport(view)}
/>
</div>
</div>
<footer class="footer">Streba - the GPX analyser</footer>
<style>
@@ -232,4 +295,19 @@
font-size: 0.8rem;
border-top: 1px solid var(--border);
}
/* parking spot for the map while no page shows it: kept alive, out of sight */
.map-home {
position: fixed;
top: 0;
left: 0;
width: 720px;
height: 440px;
visibility: hidden;
pointer-events: none;
z-index: -1;
}
.map-carrier {
width: 100%;
height: 100%;
}
</style>
+1 -34
View File
@@ -1,43 +1,10 @@
import { communityTotals, countAscents, db, listAllActivities, listAscents } from '$lib/server/db';
import { communityTotals, countAscents, listAllActivities, listAscents } from '$lib/server/db';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals }) => {
// every user's tracks, thinned for the overview map
const rows = db
.prepare(
`SELECT a.id, a.name, a.type, a.date, a.distance_m, a.points, u.username
FROM activities a JOIN users u ON u.id = a.user_id`
)
.all() as {
id: number;
name: string;
type: string;
date: string | null;
distance_m: number;
points: string;
username: string;
}[];
const tracks = rows.map((row) => {
const points = JSON.parse(row.points) as { lat: number; lon: number }[];
const step = Math.max(1, Math.floor(points.length / 300));
const latlngs = points
.filter((_, i) => i % step === 0 || i === points.length - 1)
.map((p) => [p.lat, p.lon] as [number, number]);
return {
id: row.id,
name: row.name,
username: row.username,
type: row.type,
date: row.date,
distance_m: row.distance_m,
latlngs
};
});
const user = locals.user;
return {
activities: listAllActivities(),
tracks,
totals: communityTotals(),
mine: user
? {
+2 -2
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import Map from '$lib/components/Map.svelte';
import MapSlot from '$lib/components/MapSlot.svelte';
import StatTile from '$lib/components/StatTile.svelte';
import ActivityItem from '$lib/components/ActivityItem.svelte';
import { fmtDistance, fmtElevation } from '$lib/format';
@@ -37,7 +37,7 @@
</div>
<h2>Worldmap</h2>
<Map tracks={data.tracks} height="440px" />
<MapSlot height="440px" />
<h2>Recent activities</h2>
{#if data.activities.length === 0}
+21 -6
View File
@@ -1,9 +1,10 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Map from '$lib/components/Map.svelte';
import MapSlot from '$lib/components/MapSlot.svelte';
import ElevationChart from '$lib/components/ElevationChart.svelte';
import SpeedChart from '$lib/components/SpeedChart.svelte';
import StatTile from '$lib/components/StatTile.svelte';
import { mapState } from '$lib/map-state.svelte';
import { fmtDate, fmtDistance, fmtDuration, fmtElevation, fmtSpeed } from '$lib/format';
let { data } = $props();
@@ -12,6 +13,24 @@
const avgSpeed = $derived(
a.moving_s && a.moving_s > 0 ? fmtSpeed(a.distance_m / a.moving_s) : null
);
// the persistent map keeps the overview visible, dims the rest,
// and animates toward this activity
$effect(() => {
mapState.setDetailTrack({
id: a.id,
name: a.name,
username: a.username,
type: a.type,
date: a.date,
distance_m: a.distance_m,
latlngs: data.latlngs
});
mapState.setHighlight(a.id);
mapState.setPeaks(data.bagged);
mapState.setFocus(JSON.parse(a.bounds));
return () => mapState.reset();
});
</script>
<svelte:head>
@@ -62,11 +81,7 @@
{/if}
<h2>Map</h2>
<Map
tracks={[{ name: a.name, type: a.type, latlngs: data.latlngs }]}
peaks={data.bagged}
height="440px"
/>
<MapSlot height="440px" />
{#if data.profile.length > 1}
<h2>Elevation profile</h2>
+15 -4
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import Map, { type Viewport } from '$lib/components/Map.svelte';
import MapSlot from '$lib/components/MapSlot.svelte';
import type { Viewport } from '$lib/components/Map.svelte';
import { mapState } from '$lib/map-state.svelte';
import { fmtDate } from '$lib/format';
let { data } = $props();
@@ -22,7 +24,6 @@
let viewPeaks: ViewPeak[] = $state([]);
let tab: 'view' | 'mine' = $state('view');
let loading = $state(false);
let mapRef: Map | undefined = $state();
// search
let query = $state('');
@@ -49,7 +50,7 @@
function goTo(peak: ViewPeak) {
searchOpen = false;
query = peak.name;
mapRef?.flyTo(peak.lat, peak.lon, 12.5);
mapState.flyTo(peak.lat, peak.lon, 12.5);
}
const LIST_CAP = 150;
@@ -69,6 +70,16 @@
confirmPeak = null;
}
// drive the persistent map: our peaks, our handlers, viewport-fed fetching
$effect(() => {
mapState.setPeaks(mapPeaks);
});
$effect(() => {
mapState.setHandlers({ onpeakclick: onMapPeakClick, onviewport });
mapState.requestViewport();
return () => mapState.reset();
});
const highest = $derived(ascents[0] ?? null);
const mapPeaks = $derived(
@@ -147,7 +158,7 @@
Zoom in to reveal less prominent summits; click a peak to cross it off.
</p>
<Map bind:this={mapRef} peaks={mapPeaks} height="480px" onpeakclick={onMapPeakClick} {onviewport} />
<MapSlot height="480px" />
<div class="search">
<input