persistent map with animated transitions and dimmed overview on detail pages
This commit is contained in:
+24
@@ -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);
|
||||
|
||||
+121
-54
@@ -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,11 +382,10 @@
|
||||
if (map) map.getCanvas().style.cursor = '';
|
||||
});
|
||||
|
||||
if (onviewport) {
|
||||
const report = () => {
|
||||
if (!map) return;
|
||||
const b = map.getBounds();
|
||||
onviewport({
|
||||
onviewport?.({
|
||||
minLat: b.getSouth(),
|
||||
minLon: b.getWest(),
|
||||
maxLat: b.getNorth(),
|
||||
@@ -315,16 +393,8 @@
|
||||
zoom: map.getZoom()
|
||||
});
|
||||
};
|
||||
reportFn = report;
|
||||
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 });
|
||||
}
|
||||
})();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,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
|
||||
? {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user