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