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;
|
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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--page);
|
background: var(--page);
|
||||||
|
|||||||
+121
-54
@@ -38,12 +38,16 @@
|
|||||||
let {
|
let {
|
||||||
tracks = [],
|
tracks = [],
|
||||||
peaks = [],
|
peaks = [],
|
||||||
height = '420px',
|
highlightId = null,
|
||||||
|
focus = null,
|
||||||
|
height = '100%',
|
||||||
onpeakclick,
|
onpeakclick,
|
||||||
onviewport
|
onviewport
|
||||||
}: {
|
}: {
|
||||||
tracks?: MapTrack[];
|
tracks?: MapTrack[];
|
||||||
peaks?: MapPeak[];
|
peaks?: MapPeak[];
|
||||||
|
highlightId?: number | null;
|
||||||
|
focus?: [[number, number], [number, number]] | null;
|
||||||
height?: string;
|
height?: string;
|
||||||
onpeakclick?: (id: number) => void;
|
onpeakclick?: (id: number) => void;
|
||||||
onviewport?: (view: Viewport) => void;
|
onviewport?: (view: Viewport) => void;
|
||||||
@@ -51,7 +55,7 @@
|
|||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let map: import('maplibre-gl').Map | undefined;
|
let map: import('maplibre-gl').Map | undefined;
|
||||||
let lib: typeof import('maplibre-gl') | undefined;
|
let loaded = $state(false);
|
||||||
let currentDark: boolean | undefined;
|
let currentDark: boolean | undefined;
|
||||||
let pendingTerrain: unknown = null;
|
let pendingTerrain: unknown = null;
|
||||||
|
|
||||||
@@ -59,6 +63,12 @@
|
|||||||
map?.flyTo({ center: [lon, lat], zoom });
|
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 {
|
function peaksGeojson(): FeatureCollection {
|
||||||
return {
|
return {
|
||||||
type: 'FeatureCollection',
|
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(() => {
|
$effect(() => {
|
||||||
void peaks;
|
void peaks;
|
||||||
const source = map?.getSource('peaks') as import('maplibre-gl').GeoJSONSource | undefined;
|
if (!loaded) return;
|
||||||
source?.setData(peaksGeojson());
|
(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
|
// swap map style when the theme changes
|
||||||
@@ -117,44 +212,28 @@
|
|||||||
firstSymbol
|
firstSymbol
|
||||||
);
|
);
|
||||||
|
|
||||||
map.addSource('tracks', {
|
map.addSource('tracks', { type: 'geojson', data: tracksGeojson(dark) });
|
||||||
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.addLayer({
|
map.addLayer({
|
||||||
id: 'tracks-casing',
|
id: 'tracks-casing',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'tracks',
|
source: 'tracks',
|
||||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
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({
|
map.addLayer({
|
||||||
id: 'tracks-line',
|
id: 'tracks-line',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'tracks',
|
source: 'tracks',
|
||||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
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
|
// invisible wide line so thin routes are easy to click
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
@@ -173,14 +252,9 @@
|
|||||||
source: 'peaks',
|
source: 'peaks',
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 5, 5, 12, 9],
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 5, 5, 12, 9],
|
||||||
'circle-color': [
|
'circle-color': ['case', ['get', 'climbed'], '#0ca30c', dark ? '#1a1a19' : '#fcfcfb'],
|
||||||
'case',
|
|
||||||
['get', 'climbed'],
|
|
||||||
'#0ca30c',
|
|
||||||
dark ? '#1a1a19' : '#fcfcfb'
|
|
||||||
],
|
|
||||||
'circle-stroke-width': 1.5,
|
'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({
|
map.addLayer({
|
||||||
@@ -210,7 +284,6 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
const maplibre = await import('maplibre-gl');
|
const maplibre = await import('maplibre-gl');
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
lib = maplibre;
|
|
||||||
|
|
||||||
currentDark = theme.isDark;
|
currentDark = theme.isDark;
|
||||||
map = new maplibre.Map({
|
map = new maplibre.Map({
|
||||||
@@ -228,6 +301,12 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
map.on('style.load', () => addLayers(currentDark ?? false));
|
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
|
// peak interaction: click to toggle, hover for tooltip
|
||||||
const popup = new maplibre.Popup({
|
const popup = new maplibre.Popup({
|
||||||
@@ -303,11 +382,10 @@
|
|||||||
if (map) map.getCanvas().style.cursor = '';
|
if (map) map.getCanvas().style.cursor = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
if (onviewport) {
|
|
||||||
const report = () => {
|
const report = () => {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
const b = map.getBounds();
|
const b = map.getBounds();
|
||||||
onviewport({
|
onviewport?.({
|
||||||
minLat: b.getSouth(),
|
minLat: b.getSouth(),
|
||||||
minLon: b.getWest(),
|
minLon: b.getWest(),
|
||||||
maxLat: b.getNorth(),
|
maxLat: b.getNorth(),
|
||||||
@@ -315,16 +393,8 @@
|
|||||||
zoom: map.getZoom()
|
zoom: map.getZoom()
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
reportFn = report;
|
||||||
map.on('moveend', 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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -348,9 +418,6 @@
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
|
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) {
|
:global(.maplibregl-ctrl-group button + button) {
|
||||||
border-top: 1px solid var(--border);
|
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';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = ({ locals }) => {
|
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 '@fontsource-variable/inter';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { onNavigate } from '$app/navigation';
|
||||||
import { theme } from '$lib/theme.svelte';
|
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(
|
const themeTitle = $derived(
|
||||||
theme.pref === 'auto'
|
theme.pref === 'auto'
|
||||||
@@ -95,6 +139,25 @@
|
|||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</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>
|
<footer class="footer">Streba - the GPX analyser</footer>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -232,4 +295,19 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
border-top: 1px solid var(--border);
|
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>
|
</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';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = ({ locals }) => {
|
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;
|
const user = locals.user;
|
||||||
return {
|
return {
|
||||||
activities: listAllActivities(),
|
activities: listAllActivities(),
|
||||||
tracks,
|
|
||||||
totals: communityTotals(),
|
totals: communityTotals(),
|
||||||
mine: user
|
mine: user
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 StatTile from '$lib/components/StatTile.svelte';
|
||||||
import ActivityItem from '$lib/components/ActivityItem.svelte';
|
import ActivityItem from '$lib/components/ActivityItem.svelte';
|
||||||
import { fmtDistance, fmtElevation } from '$lib/format';
|
import { fmtDistance, fmtElevation } from '$lib/format';
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Worldmap</h2>
|
<h2>Worldmap</h2>
|
||||||
<Map tracks={data.tracks} height="440px" />
|
<MapSlot height="440px" />
|
||||||
|
|
||||||
<h2>Recent activities</h2>
|
<h2>Recent activities</h2>
|
||||||
{#if data.activities.length === 0}
|
{#if data.activities.length === 0}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
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 ElevationChart from '$lib/components/ElevationChart.svelte';
|
||||||
import SpeedChart from '$lib/components/SpeedChart.svelte';
|
import SpeedChart from '$lib/components/SpeedChart.svelte';
|
||||||
import StatTile from '$lib/components/StatTile.svelte';
|
import StatTile from '$lib/components/StatTile.svelte';
|
||||||
|
import { mapState } from '$lib/map-state.svelte';
|
||||||
import { fmtDate, fmtDistance, fmtDuration, fmtElevation, fmtSpeed } from '$lib/format';
|
import { fmtDate, fmtDistance, fmtDuration, fmtElevation, fmtSpeed } from '$lib/format';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -12,6 +13,24 @@
|
|||||||
const avgSpeed = $derived(
|
const avgSpeed = $derived(
|
||||||
a.moving_s && a.moving_s > 0 ? fmtSpeed(a.distance_m / a.moving_s) : null
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -62,11 +81,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<h2>Map</h2>
|
<h2>Map</h2>
|
||||||
<Map
|
<MapSlot height="440px" />
|
||||||
tracks={[{ name: a.name, type: a.type, latlngs: data.latlngs }]}
|
|
||||||
peaks={data.bagged}
|
|
||||||
height="440px"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if data.profile.length > 1}
|
{#if data.profile.length > 1}
|
||||||
<h2>Elevation profile</h2>
|
<h2>Elevation profile</h2>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<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';
|
import { fmtDate } from '$lib/format';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -22,7 +24,6 @@
|
|||||||
let viewPeaks: ViewPeak[] = $state([]);
|
let viewPeaks: ViewPeak[] = $state([]);
|
||||||
let tab: 'view' | 'mine' = $state('view');
|
let tab: 'view' | 'mine' = $state('view');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let mapRef: Map | undefined = $state();
|
|
||||||
|
|
||||||
// search
|
// search
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
@@ -49,7 +50,7 @@
|
|||||||
function goTo(peak: ViewPeak) {
|
function goTo(peak: ViewPeak) {
|
||||||
searchOpen = false;
|
searchOpen = false;
|
||||||
query = peak.name;
|
query = peak.name;
|
||||||
mapRef?.flyTo(peak.lat, peak.lon, 12.5);
|
mapState.flyTo(peak.lat, peak.lon, 12.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIST_CAP = 150;
|
const LIST_CAP = 150;
|
||||||
@@ -69,6 +70,16 @@
|
|||||||
confirmPeak = null;
|
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 highest = $derived(ascents[0] ?? null);
|
||||||
|
|
||||||
const mapPeaks = $derived(
|
const mapPeaks = $derived(
|
||||||
@@ -147,7 +158,7 @@
|
|||||||
Zoom in to reveal less prominent summits; click a peak to cross it off.
|
Zoom in to reveal less prominent summits; click a peak to cross it off.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Map bind:this={mapRef} peaks={mapPeaks} height="480px" onpeakclick={onMapPeakClick} {onviewport} />
|
<MapSlot height="480px" />
|
||||||
|
|
||||||
<div class="search">
|
<div class="search">
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user