diff --git a/src/app.css b/src/app.css index e426277..0b15fa1 100644 --- a/src/app.css +++ b/src/app.css @@ -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); diff --git a/src/lib/components/Map.svelte b/src/lib/components/Map.svelte index 73a238d..7c9b787 100644 --- a/src/lib/components/Map.svelte +++ b/src/lib/components/Map.svelte @@ -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); } diff --git a/src/lib/components/MapSlot.svelte b/src/lib/components/MapSlot.svelte new file mode 100644 index 0000000..460b3df --- /dev/null +++ b/src/lib/components/MapSlot.svelte @@ -0,0 +1,23 @@ + + +
+ + diff --git a/src/lib/map-state.svelte.ts b/src/lib/map-state.svelte.ts new file mode 100644 index 0000000..40bb60c --- /dev/null +++ b/src/lib/map-state.svelte.ts @@ -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([]); +let highlightId = $state(null); +let focus = $state(null); +let detailTrack = $state(null); +let carrier = $state(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; + } + } +}; diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index 45f4fcc..d91a00a 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -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 }; }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2e030ea..037a8ff 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -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()} + +
+
+ mapState.handlePeakClick(id)} + onviewport={(view) => mapState.handleViewport(view)} + /> +
+
+
Streba - the GPX analyser
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index f6ba458..7552dd6 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -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 ? { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 08ee63f..a7fbb5d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,5 @@ @@ -62,11 +81,7 @@ {/if}

Map

- + {#if data.profile.length > 1}

Elevation profile

diff --git a/src/routes/peaks/+page.svelte b/src/routes/peaks/+page.svelte index 38b99c8..2ab12ec 100644 --- a/src/routes/peaks/+page.svelte +++ b/src/routes/peaks/+page.svelte @@ -1,5 +1,7 @@