Compare commits

...
10 Commits
19 changed files with 1403 additions and 252 deletions
+58
View File
@@ -20,6 +20,14 @@
--logo-wash-2: #96d6c6;
--logo-text: #56555a;
--map-icon-filter: none;
--cat-0: #2a78d6;
--cat-1: #eb6834;
--cat-2: #1baf7a;
--cat-3: #eda100;
--cat-4: #e87ba4;
--cat-5: #008300;
--cat-6: #4a3aa7;
--cat-7: #e34948;
}
/* dark tokens apply when the OS prefers dark (unless the user forced light),
@@ -46,6 +54,14 @@
--logo-wash-2: #1e6355;
--logo-text: #eef2f0;
--map-icon-filter: invert(1) brightness(1.1);
--cat-0: #3987e5;
--cat-1: #d95926;
--cat-2: #199e70;
--cat-3: #c98500;
--cat-4: #d55181;
--cat-5: #008300;
--cat-6: #9085e9;
--cat-7: #e66767;
}
}
:root[data-theme='dark'] {
@@ -69,12 +85,51 @@
--logo-wash-2: #1e6355;
--logo-text: #eef2f0;
--map-icon-filter: invert(1) brightness(1.1);
--cat-0: #3987e5;
--cat-1: #d95926;
--cat-2: #199e70;
--cat-3: #c98500;
--cat-4: #d55181;
--cat-5: #008300;
--cat-6: #9085e9;
--cat-7: #e66767;
}
* {
box-sizing: border-box;
}
/* page transitions: gentle simultaneous cross-fade for content;
the map morphs separately; topbar and footer are pinned and never fade */
@media not (prefers-reduced-motion: reduce) {
::view-transition-old(root) {
animation: 160ms ease both vt-fade-out;
}
::view-transition-new(root) {
animation: 160ms ease both vt-fade-in;
}
::view-transition-group(streba-map) {
animation-duration: 320ms;
animation-timing-function: cubic-bezier(0.3, 0, 0.2, 1);
}
::view-transition-old(topbar),
::view-transition-new(topbar),
::view-transition-old(site-footer),
::view-transition-new(site-footer) {
animation: none;
}
}
@keyframes vt-fade-out {
to {
opacity: 0;
}
}
@keyframes vt-fade-in {
from {
opacity: 0;
}
}
body {
margin: 0;
background: var(--page);
@@ -83,6 +138,9 @@ body {
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
display: flex;
flex-direction: column;
min-height: 100vh;
}
h1 {
+53
View File
@@ -0,0 +1,53 @@
// Categorical palette slots (validated for light and dark surfaces).
// Activity types map to fixed slots so a type keeps its color everywhere.
export const CAT_SLOTS = [
{ light: '#2a78d6', dark: '#3987e5' }, // blue
{ light: '#eb6834', dark: '#d95926' }, // orange
{ light: '#1baf7a', dark: '#199e70' }, // aqua
{ light: '#eda100', dark: '#c98500' }, // yellow
{ light: '#e87ba4', dark: '#d55181' }, // magenta
{ light: '#008300', dark: '#008300' }, // green
{ light: '#4a3aa7', dark: '#9085e9' }, // violet
{ light: '#e34948', dark: '#e66767' } // red
];
const TYPE_SLOT: Record<string, number> = {
hiking: 0,
hike: 0,
walk: 0,
walking: 0,
run: 1,
running: 1,
'trail run': 1,
ride: 2,
cycling: 2,
bike: 2,
biking: 2,
'virtual ride': 2,
'mountain bike': 2,
climb: 3,
climbing: 3,
'via ferrata': 3,
alpinism: 3,
ski: 6,
'backcountry ski': 6,
'nordic ski': 6,
snowboard: 6,
snowshoe: 6
};
function hash(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
export function typeSlot(type: string): number {
const t = type.toLowerCase().trim();
return TYPE_SLOT[t] ?? hash(t) % CAT_SLOTS.length;
}
export function typeColor(type: string): { light: string; dark: string } {
return CAT_SLOTS[typeSlot(type)];
}
+5 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { fmtDate, fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
import { typeSlot } from '$lib/activity-colors';
let {
activity
@@ -21,7 +22,7 @@
<a class="item" href="/activities/{activity.id}">
<div class="head">
<span class="name">{activity.name}</span>
<span class="type">{activity.type}</span>
<span class="type" style="--tc: var(--cat-{typeSlot(activity.type)})">{activity.type}</span>
</div>
<div class="meta">
{#if activity.username}
@@ -61,9 +62,9 @@
}
.type {
font-size: 0.75rem;
font-weight: 500;
color: var(--accent-strong);
background: var(--accent-wash);
font-weight: 600;
color: var(--tc, var(--accent-strong));
background: color-mix(in srgb, var(--tc, var(--accent)) 13%, transparent);
padding: 0.1rem 0.5rem;
border-radius: 999px;
text-transform: capitalize;
+25 -11
View File
@@ -1,5 +1,16 @@
<script lang="ts">
let { profile }: { profile: { d: number; ele: number }[] } = $props();
let {
profile,
hoverD = null,
onhover,
onselect
}: {
profile: { d: number; ele: number }[];
/** shared hover distance controlled by the page, so charts stay in sync */
hoverD?: number | null;
onhover?: (d: number | null) => void;
onselect?: (d: number) => void;
} = $props();
let width = $state(720);
const height = 220;
@@ -41,21 +52,23 @@
`${linePath}L${x(totalDist).toFixed(1)},${height - pad.bottom}L${pad.left},${height - pad.bottom}Z`
);
let hover = $state<{ d: number; ele: number } | null>(null);
function onmove(event: PointerEvent) {
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
const target = frac * totalDist;
// binary search closest point
// snap the shared hover distance to this chart's nearest point
const hover = $derived.by(() => {
if (hoverD === null || profile.length === 0) return null;
let lo = 0;
let hi = profile.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (profile[mid].d < target) lo = mid;
if (profile[mid].d < hoverD) lo = mid;
else hi = mid;
}
hover = target - profile[lo].d < profile[hi].d - target ? profile[lo] : profile[hi];
return hoverD - profile[lo].d < profile[hi].d - hoverD ? profile[lo] : profile[hi];
});
function onmove(event: PointerEvent) {
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
onhover?.(frac * totalDist);
}
const tooltipLeft = $derived(hover ? Math.min(Math.max(x(hover.d), 70), width - 70) : 0);
@@ -95,7 +108,8 @@
height={height - pad.top - pad.bottom}
fill="transparent"
onpointermove={onmove}
onpointerleave={() => (hover = null)}
onclick={() => hover && onselect?.(hover.d)}
onpointerleave={() => onhover?.(null)}
/>
</svg>
{#if hover}
+284 -56
View File
@@ -2,11 +2,17 @@
import { onMount } from 'svelte';
import 'maplibre-gl/dist/maplibre-gl.css';
import { theme } from '$lib/theme.svelte';
import { typeColor } from '$lib/activity-colors';
import { fmtDate, fmtDistance } from '$lib/format';
import type { FeatureCollection, Point } from 'geojson';
export interface MapTrack {
id?: number;
name?: string;
username?: string;
type?: string;
date?: string | null;
distance_m?: number;
latlngs: [number, number][];
}
export interface MapPeak {
@@ -32,12 +38,18 @@
let {
tracks = [],
peaks = [],
height = '420px',
highlightId = null,
focus = null,
hoverPoint = null,
height = '100%',
onpeakclick,
onviewport
}: {
tracks?: MapTrack[];
peaks?: MapPeak[];
highlightId?: number | null;
focus?: [[number, number], [number, number]] | null;
hoverPoint?: { lat: number; lon: number } | null;
height?: string;
onpeakclick?: (id: number) => void;
onviewport?: (view: Viewport) => void;
@@ -45,7 +57,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;
@@ -53,6 +65,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',
@@ -70,11 +88,132 @@
};
}
// 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)
);
});
function hoverGeojson(): FeatureCollection {
// the marker wears the highlighted activity's type color
const track = highlightId !== null ? tracks.find((t) => t.id === highlightId) : undefined;
const color = typeColor(track?.type ?? 'outdoor');
return {
type: 'FeatureCollection',
features: hoverPoint
? [
{
type: 'Feature',
properties: { color: currentDark ? color.dark : color.light },
geometry: { type: 'Point', coordinates: [hoverPoint.lon, hoverPoint.lat] }
}
]
: []
};
}
$effect(() => {
void hoverPoint;
if (!loaded) return;
(map?.getSource('hover-point') as import('maplibre-gl').GeoJSONSource | undefined)?.setData(
hoverGeojson()
);
});
// animate the camera when a page requests a new focus; remember the
// unfocused camera so returning to the overview restores it instead of
// zooming all the way back out
let lastFocusKey: string | undefined;
let savedCamera: { center: import('maplibre-gl').LngLat; zoom: number } | null = null;
$effect(() => {
const focused = focus !== null;
const key = JSON.stringify(focus);
if (!loaded || !map) return;
if (lastFocusKey === undefined) {
lastFocusKey = key;
return;
}
if (key === lastFocusKey) return;
const wasFocused = lastFocusKey !== 'null';
lastFocusKey = key;
if (focused) {
if (!wasFocused) savedCamera = { center: map.getCenter(), zoom: map.getZoom() };
applyCamera(true);
} else if (savedCamera) {
map.flyTo({ center: savedCamera.center, zoom: savedCamera.zoom, duration: 1100 });
savedCamera = null;
} else {
applyCamera(true);
}
});
// swap map style when the theme changes
@@ -111,33 +250,36 @@
firstSymbol
);
map.addSource('tracks', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: tracks.map((track) => ({
type: 'Feature',
properties: { name: track.name ?? '' },
geometry: {
type: 'LineString',
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': dark ? '#3987e5' : '#2a78d6', '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({
id: 'tracks-hit',
type: 'line',
source: 'tracks',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': '#000', 'line-width': 16, 'line-opacity': 0.001 }
});
// peaks as native layers - scales to thousands of points
@@ -148,14 +290,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({
@@ -173,6 +310,20 @@
}
});
// chart-hover position marker, matching the chart's crosshair dot
map.addSource('hover-point', { type: 'geojson', data: hoverGeojson() });
map.addLayer({
id: 'hover-point',
type: 'circle',
source: 'hover-point',
paint: {
'circle-radius': 7,
'circle-color': ['get', 'color'],
'circle-stroke-width': 2.5,
'circle-stroke-color': dark ? '#1a1a19' : '#ffffff'
}
});
// restore 3D terrain across style swaps
if (pendingTerrain) {
map.setTerrain(pendingTerrain as import('maplibre-gl').TerrainSpecification);
@@ -185,7 +336,6 @@
(async () => {
const maplibre = await import('maplibre-gl');
if (cancelled) return;
lib = maplibre;
currentDark = theme.isDark;
map = new maplibre.Map({
@@ -203,6 +353,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({
@@ -231,28 +387,66 @@
popup.remove();
});
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);
}
// track interaction: click a route for an info pane
const trackPane = new maplibre.Popup({
closeButton: true,
closeOnClick: true,
offset: 10,
maxWidth: '280px',
className: 'track-pane'
});
map.on('click', 'tracks-hit', (e) => {
if (!map) return;
// a click on a peak wins over the route underneath
if (map.queryRenderedFeatures(e.point, { layers: ['peaks-circles'] }).length > 0) return;
const feature = e.features?.[0];
if (!feature) return;
const p = feature.properties;
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 pane = document.createElement('div');
const title = document.createElement('strong');
title.textContent = p.name || 'Activity';
pane.appendChild(title);
const meta = document.createElement('div');
meta.className = 'tp-meta';
meta.textContent = [
p.username || null,
p.date ? fmtDate(p.date) : null,
p.distance_m ? fmtDistance(p.distance_m) : null,
p.type || null
]
.filter(Boolean)
.join(' · ');
pane.appendChild(meta);
if (p.id) {
const link = document.createElement('a');
link.href = `/activities/${p.id}`;
link.textContent = 'Open activity →';
link.className = 'tp-link';
pane.appendChild(link);
}
trackPane.setLngLat(e.lngLat).setDOMContent(pane).addTo(map);
});
map.on('mouseenter', 'tracks-hit', () => {
if (map) map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'tracks-hit', () => {
if (map) map.getCanvas().style.cursor = '';
});
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;
@@ -276,9 +470,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);
}
@@ -288,12 +479,16 @@
:global(.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon) {
filter: none;
}
:global(.maplibregl-ctrl-attrib) {
background: color-mix(in srgb, var(--surface-1) 80%, transparent);
:global(.maplibregl-ctrl-attrib),
:global(.maplibregl-ctrl-attrib.maplibregl-compact) {
background: color-mix(in srgb, var(--surface-1) 85%, transparent) !important;
color: var(--text-muted);
}
:global(.maplibregl-ctrl-attrib a) {
color: var(--text-secondary);
color: var(--text-secondary) !important;
}
:global(.maplibregl-ctrl-attrib-button) {
filter: var(--map-icon-filter);
}
:global(.maplibregl-popup.peak-tip .maplibregl-popup-content) {
background: var(--surface-1);
@@ -309,4 +504,37 @@
border-top-color: var(--surface-1);
border-bottom-color: var(--surface-1);
}
:global(.maplibregl-popup.track-pane .maplibregl-popup-content) {
background: var(--surface-1);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 0.6rem;
padding: 0.7rem 0.9rem;
font-size: 0.88rem;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);
}
:global(.maplibregl-popup.track-pane .maplibregl-popup-tip) {
border-top-color: var(--surface-1);
border-bottom-color: var(--surface-1);
}
:global(.maplibregl-popup.track-pane .maplibregl-popup-close-button) {
color: var(--text-muted);
font-size: 1.1rem;
padding: 0 0.35rem;
}
:global(.track-pane .tp-meta) {
color: var(--text-secondary);
font-size: 0.8rem;
margin: 0.15rem 0 0.4rem;
text-transform: capitalize;
}
:global(.track-pane .tp-link) {
color: var(--accent-strong);
font-weight: 600;
text-decoration: none;
font-size: 0.85rem;
}
:global(.track-pane .tp-link:hover) {
text-decoration: underline;
}
</style>
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import { mapState } from '$lib/map-state.svelte';
// Marker component: pages render this to request the persistent map,
// which the layout shows in its fixed spot below the topbar - the same
// position on every page, so navigation never moves it.
$effect(() => {
mapState.setWanted(true);
return () => mapState.setWanted(false);
});
</script>
+25 -10
View File
@@ -1,7 +1,18 @@
<script lang="ts">
import { browser } from '$app/environment';
let { timed }: { timed: { d: number; t: number }[] } = $props();
let {
timed,
hoverD = null,
onhover,
onselect
}: {
timed: { d: number; t: number }[];
/** shared hover distance controlled by the page, so charts stay in sync */
hoverD?: number | null;
onhover?: (d: number | null) => void;
onselect?: (d: number) => void;
} = $props();
const WINDOWS = [
{ label: '10 s', w: 10 },
@@ -74,20 +85,23 @@
: ''
);
let hover = $state<{ d: number; v: number } | null>(null);
function onmove(event: PointerEvent) {
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
const target = frac * totalDist;
// snap the shared hover distance to this chart's nearest point
const hover = $derived.by(() => {
if (hoverD === null || series.length === 0) return null;
let lo = 0;
let hi = series.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (series[mid].d < target) lo = mid;
if (series[mid].d < hoverD) lo = mid;
else hi = mid;
}
hover = target - series[lo].d < series[hi].d - target ? series[lo] : series[hi];
return hoverD - series[lo].d < series[hi].d - hoverD ? series[lo] : series[hi];
});
function onmove(event: PointerEvent) {
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
onhover?.(frac * totalDist);
}
const tooltipLeft = $derived(hover ? Math.min(Math.max(x(hover.d), 70), width - 70) : 0);
@@ -134,7 +148,8 @@
height={height - pad.top - pad.bottom}
fill="transparent"
onpointermove={onmove}
onpointerleave={() => (hover = null)}
onclick={() => hover && onselect?.(hover.d)}
onpointerleave={() => onhover?.(null)}
/>
</svg>
{#if hover}
+122
View File
@@ -0,0 +1,122 @@
// 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 hoverPoint = $state<{ lat: number; lon: number } | null>(null);
let carrier = $state<HTMLElement | null>(null);
let active = $state(false);
let wanted = $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 hoverPoint() {
return hoverPoint;
},
get carrier() {
return carrier;
},
get active() {
return active;
},
get wanted() {
return wanted;
},
setWanted(value: boolean) {
wanted = value;
},
setPeaks(value: MapPeak[]) {
peaks = value;
},
setHighlight(id: number | null) {
highlightId = id;
},
setFocus(bounds: Bounds | null) {
focus = bounds;
},
setDetailTrack(track: MapTrack | null) {
detailTrack = track;
},
setHoverPoint(point: { lat: number; lon: number } | null) {
hoverPoint = point;
},
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;
hoverPoint = 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;
}
}
};
+3
View File
@@ -12,6 +12,8 @@ export interface ParsedGpx {
name: string | null;
type: string | null;
date: string | null;
/** ISO timestamp of the first trackpoint, when the GPX has times */
start: string | null;
points: TrackPoint[];
distance_m: number;
duration_s: number | null;
@@ -149,6 +151,7 @@ export function parseGpx(xml: string): ParsedGpx {
name: trk0?.name ? String(trk0.name) : meta?.name ? String(meta.name) : null,
type: trk0?.type ? String(trk0.type).toLowerCase() : null,
date,
start: firstTime !== null ? new Date(firstTime).toISOString() : null,
points: simplify(points, 2500),
distance_m: distance,
duration_s: duration,
+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 };
};
+168 -4
View File
@@ -2,7 +2,60 @@
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;
let layoutSlot: HTMLDivElement;
$effect(() => {
if (mapCarrier && mapHome) mapState.registerCarrier(mapCarrier, mapHome);
});
// show the persistent map in its fixed spot whenever the page asks for it
$effect(() => {
if (mapState.wanted && layoutSlot && mapState.carrier) {
mapState.attach(layoutSlot);
return () => mapState.detach();
}
});
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'
@@ -92,10 +145,54 @@
</header>
<main>
<div
class="layout-map-slot"
bind:this={layoutSlot}
style:display={mapState.wanted ? 'block' : 'none'}
></div>
{@render children()}
</main>
<footer class="footer">Streba - the GPX analyser</footer>
<!-- 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}
hoverPoint={mapState.hoverPoint}
onpeakclick={(id) => mapState.handlePeakClick(id)}
onviewport={(view) => mapState.handleViewport(view)}
/>
</div>
</div>
<footer class="footer">
<div class="footer-inner">
<div class="footer-brand">
<span class="footer-name">Streba</span>
<span class="footer-tag">The GPX analyser</span>
</div>
{#if data.user}
<nav class="footer-links" aria-label="Footer">
{#each links as link (link.href)}
<a href={link.href}>{link.label}</a>
{/each}
</nav>
{/if}
</div>
<div class="footer-credits">
© {new Date().getFullYear()} Streba · Terrain
<a href="https://mapterhorn.com">© Mapterhorn</a> · Peak data
<a href="https://www.openstreetmap.org/copyright">© OpenStreetMap contributors</a>
</div>
</footer>
<style>
.topbar {
@@ -105,6 +202,7 @@
background: color-mix(in srgb, var(--surface-1) 82%, transparent);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
view-transition-name: topbar;
}
.topbar-inner {
max-width: 1080px;
@@ -221,15 +319,81 @@
}
main {
max-width: 1080px;
width: 100%;
margin: 0 auto;
padding: 2rem 1.25rem 4rem;
flex: 1;
}
.footer {
border-top: 1px solid var(--border);
background: var(--surface-1);
view-transition-name: site-footer;
}
.footer-inner {
max-width: 1080px;
margin: 0 auto;
padding: 1.5rem 1.25rem 2.5rem;
padding: 1.5rem 1.25rem 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.footer-brand {
display: flex;
align-items: baseline;
gap: 0.6rem;
}
.footer-name {
font-weight: 700;
letter-spacing: -0.01em;
}
.footer-tag {
color: var(--text-muted);
font-size: 0.8rem;
border-top: 1px solid var(--border);
font-size: 0.85rem;
}
.footer-links {
display: flex;
gap: 1rem;
}
.footer-links a {
color: var(--text-secondary);
font-size: 0.85rem;
text-decoration: none;
}
.footer-links a:hover {
color: var(--text-primary);
}
.footer-credits {
max-width: 1080px;
margin: 0 auto;
padding: 0.75rem 1.25rem 1.75rem;
color: var(--text-muted);
font-size: 0.78rem;
}
.footer-credits a {
color: var(--text-muted);
}
/* 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 alone leaks maplibre children that set their own visibility */
visibility: hidden;
opacity: 0;
overflow: hidden;
pointer-events: none;
z-index: -1;
}
.map-carrier {
width: 100%;
height: 100%;
}
.layout-map-slot {
height: min(440px, 52vh);
margin-bottom: 1.75rem;
}
</style>
+1 -18
View File
@@ -1,27 +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.points, u.username
FROM activities a JOIN users u ON u.id = a.user_id`
)
.all() as { id: number; name: string; 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} · ${row.username}`, latlngs };
});
const user = locals.user;
return {
activities: listAllActivities(),
tracks,
totals: communityTotals(),
mine: user
? {
+6 -25
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';
@@ -16,16 +16,10 @@
<p class="page-sub">What everyone on Streba has been up to.</p>
{:else}
<div class="hero">
<div>
<h1>The GPX analyser</h1>
<p class="page-sub">
Upload your tracks, analyse every climb - and tick off Alpine peaks along the way.
</p>
</div>
<div class="hero-actions">
<a class="btn" href="/signup">Sign up</a>
<a class="btn ghost" href="/login">Sign in</a>
</div>
<h1>The GPX analyser</h1>
<p class="page-sub">
Upload your tracks, analyse every climb - and tick off Alpine peaks along the way.
</p>
</div>
{/if}
@@ -36,8 +30,7 @@
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
</div>
<h2>Worldmap</h2>
<Map tracks={data.tracks} height="440px" />
<MapSlot />
<h2>Recent activities</h2>
{#if data.activities.length === 0}
@@ -66,18 +59,6 @@
{/if}
<style>
.hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1.5rem;
flex-wrap: wrap;
}
.hero-actions {
display: flex;
gap: 0.6rem;
padding-top: 0.35rem;
}
.kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+21 -1
View File
@@ -1,10 +1,30 @@
import { listActivities } from '$lib/server/db';
import { db, listActivities } from '$lib/server/db';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals }) => {
const activities = listActivities(locals.user!.id);
const years = db
.prepare(
`SELECT substr(coalesce(date, substr(created_at, 1, 10)), 1, 4) year,
count(*) count,
sum(distance_m) distance_m,
sum(elev_gain_m) elev_gain_m,
sum(coalesce(moving_s, duration_s, 0)) moving_s
FROM activities WHERE user_id = ?
GROUP BY year ORDER BY year DESC`
)
.all(locals.user!.id) as {
year: string;
count: number;
distance_m: number;
elev_gain_m: number;
moving_s: number;
}[];
return {
activities,
years,
totals: {
count: activities.length,
distance_m: activities.reduce((sum, a) => sum + a.distance_m, 0),
+119 -6
View File
@@ -4,6 +4,29 @@
import { fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
let { data } = $props();
let selectedYear = $state<string | null>(null);
function activityYear(a: { date: string | null; created_at: string }): string {
return (a.date ?? a.created_at).slice(0, 4);
}
const filtered = $derived(
selectedYear === null
? data.activities
: data.activities.filter((a) => activityYear(a) === selectedYear)
);
const shown = $derived({
count: filtered.length,
distance_m: filtered.reduce((sum, a) => sum + a.distance_m, 0),
elev_gain_m: filtered.reduce((sum, a) => sum + a.elev_gain_m, 0),
moving_s: filtered.reduce((sum, a) => sum + (a.moving_s ?? a.duration_s ?? 0), 0)
});
const suffix = $derived(selectedYear === null ? '' : ` (${selectedYear})`);
function pickYear(year: string) {
selectedYear = selectedYear === year ? null : year;
}
</script>
<svelte:head>
@@ -12,25 +35,65 @@
<h1>Your activities</h1>
<p class="page-sub">
{data.activities.length} recorded {data.activities.length === 1 ? 'activity' : 'activities'}.
{shown.count} recorded {shown.count === 1 ? 'activity' : 'activities'}{suffix}.
{#if selectedYear}
<button class="clear-year" onclick={() => (selectedYear = null)}>Show all years</button>
{/if}
</p>
{#if data.activities.length > 0}
<div class="kpis">
<StatTile label="Total distance" value={fmtDistance(data.totals.distance_m)} />
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
<StatTile label="Moving time" value={fmtDuration(data.totals.moving_s)} />
<StatTile label="Distance{suffix}" value={fmtDistance(shown.distance_m)} />
<StatTile label="Ascent{suffix}" value={fmtElevation(shown.elev_gain_m)} />
<StatTile label="Moving time{suffix}" value={fmtDuration(shown.moving_s)} />
</div>
{#if data.years.length > 1}
<h2>Per year</h2>
<div class="card year-table-wrap">
<table class="year-table">
<thead>
<tr>
<th>Year</th>
<th>Activities</th>
<th>Distance</th>
<th>Ascent</th>
<th>Moving time</th>
</tr>
</thead>
<tbody>
{#each data.years as y (y.year)}
<tr
class="year-row"
class:on={selectedYear === y.year}
onclick={() => pickYear(y.year)}
title={selectedYear === y.year ? 'Show all years' : `Show only ${y.year}`}
>
<td>{y.year}</td>
<td>{y.count}</td>
<td>{fmtDistance(y.distance_m)}</td>
<td>{fmtElevation(y.elev_gain_m)}</td>
<td>{fmtDuration(y.moving_s)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
{#if data.activities.length === 0}
{#if filtered.length === 0 && data.activities.length > 0}
<div class="card empty">
<p>No activities in {selectedYear}.</p>
</div>
{:else if data.activities.length === 0}
<div class="card empty">
<p>No activities yet.</p>
<a class="btn" href="/upload">Upload a GPX file</a>
</div>
{:else}
<div class="card list">
{#each data.activities as activity (activity.id)}
{#each filtered as activity (activity.id)}
<ActivityItem {activity} />
{/each}
</div>
@@ -43,6 +106,56 @@
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.year-table-wrap {
margin-bottom: 1.25rem;
overflow-x: auto;
}
.year-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
.year-table th {
text-align: left;
font-weight: 500;
font-size: 0.78rem;
color: var(--text-muted);
padding: 0.6rem 1rem 0.35rem;
border-bottom: 1px solid var(--border);
}
.year-table td {
padding: 0.5rem 1rem;
font-variant-numeric: tabular-nums;
}
.year-table tbody tr:not(:last-child) td {
border-bottom: 1px solid var(--border);
}
.year-table td:first-child {
font-weight: 600;
}
.year-row {
cursor: pointer;
}
.year-row:hover td {
background: var(--wash);
}
.year-row.on td {
background: var(--accent-wash);
}
.year-row.on td:first-child {
color: var(--accent-strong);
}
.clear-year {
border: none;
background: none;
color: var(--accent-strong);
font: inherit;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.list :global(.item:not(:last-child)) {
border-bottom: 1px solid var(--border);
}
+22 -3
View File
@@ -1,5 +1,5 @@
import { error, redirect } from '@sveltejs/kit';
import { deleteActivity, getActivity, reachedPeaks } from '$lib/server/db';
import { error, fail, redirect } from '@sveltejs/kit';
import { db, deleteActivity, getActivity, reachedPeaks } from '$lib/server/db';
import { haversine } from '$lib/server/gpx';
import type { Actions, PageServerLoad } from './$types';
@@ -16,14 +16,17 @@ export const load: PageServerLoad = ({ params, locals }) => {
const latlngs = points.map((p) => [p.lat, p.lon] as [number, number]);
// cumulative-distance profiles for the elevation and speed charts
// cumulative-distance profiles for the elevation and speed charts,
// plus per-point distance so chart hover can find the map position
const profile: { d: number; ele: number }[] = [];
const timed: { d: number; t: number }[] = [];
const trackD: number[] = [];
let dist = 0;
for (let i = 0; i < points.length; i++) {
if (i > 0) {
dist += haversine(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon);
}
trackD.push(dist);
if (points[i].ele !== null) profile.push({ d: dist, ele: points[i].ele! });
if (points[i].t !== null) timed.push({ d: dist, t: points[i].t! });
}
@@ -34,6 +37,7 @@ export const load: PageServerLoad = ({ params, locals }) => {
activity: { ...activity, points: undefined },
canDelete: locals.user?.id === activity.user_id,
latlngs,
trackD,
profile,
timed: timed.length > 2 ? timed : [],
bagged: bagged.map((p) => ({
@@ -52,5 +56,20 @@ export const actions: Actions = {
if (!locals.user) error(401, 'Not signed in');
deleteActivity(Number(params.id), locals.user.id);
redirect(303, '/activities');
},
update: async ({ params, locals, request }) => {
if (!locals.user) error(401, 'Not signed in');
const form = await request.formData();
const name = String(form.get('name') ?? '').trim().slice(0, 120);
const type = String(form.get('type') ?? '').trim().toLowerCase().slice(0, 40);
if (!name) return fail(400, { error: 'Name cannot be empty.' });
if (!type) return fail(400, { error: 'Type cannot be empty.' });
db.prepare('UPDATE activities SET name = ?, type = ? WHERE id = ? AND user_id = ?').run(
name,
type,
Number(params.id),
locals.user.id
);
return { updated: true };
}
};
+302 -101
View File
@@ -1,127 +1,328 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Map from '$lib/components/Map.svelte';
import ElevationChart from '$lib/components/ElevationChart.svelte';
import SpeedChart from '$lib/components/SpeedChart.svelte';
import StatTile from '$lib/components/StatTile.svelte';
import { fmtDate, fmtDistance, fmtDuration, fmtElevation, fmtSpeed } from '$lib/format';
import { enhance } from "$app/forms";
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();
const a = $derived(data.activity);
let { data, form } = $props();
const a = $derived(data.activity);
const avgSpeed = $derived(
a.moving_s && a.moving_s > 0 ? fmtSpeed(a.distance_m / a.moving_s) : null
);
const avgSpeed = $derived(
a.moving_s && a.moving_s > 0 ? fmtSpeed(a.distance_m / a.moving_s) : null,
);
let editing = $state(false);
let saving = $state(false);
// chart hover -> marker on the map at the matching track position
function pointAt(d: number): [number, number] {
const trackD = data.trackD;
let lo = 0;
let hi = trackD.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (trackD[mid] < d) lo = mid;
else hi = mid;
}
return data.latlngs[d - trackD[lo] < trackD[hi] - d ? lo : hi];
}
let hoverD = $state<number | null>(null);
function chartHover(d: number | null) {
hoverD = d;
if (d === null) {
mapState.setHoverPoint(null);
return;
}
const [lat, lon] = pointAt(d);
mapState.setHoverPoint({ lat, lon });
}
// chart click -> fly the map to that spot
function chartSelect(d: number) {
const [lat, lon] = pointAt(d);
mapState.flyTo(lat, lon);
}
// 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>
<title>{a.name} · Streba</title>
<title>{a.name} · Streba</title>
</svelte:head>
<a class="back" href="/">← Overview</a>
<div class="head">
<div>
<h1>{a.name}</h1>
<p class="page-sub">
<a class="who" href="/users/{a.username}">{a.username}</a> · {fmtDate(a.date)} ·
<span class="type">{a.type}</span>
</p>
</div>
{#if data.canDelete}
<form
method="POST"
action="?/delete"
use:enhance={({ cancel }) => {
if (!confirm('Delete this activity? Peaks it reached stay in your list.')) cancel();
}}
>
<button class="btn danger" type="submit">Delete</button>
</form>
{/if}
{#if editing}
<form
class="edit-form"
method="POST"
action="?/update"
use:enhance={() => {
saving = true;
return async ({ update, result }) => {
saving = false;
if (result.type === "success") editing = false;
await update();
};
}}
>
<input
class="edit-name"
name="name"
required
maxlength="120"
value={a.name}
aria-label="Activity name"
/>
<input
class="edit-type"
name="type"
required
maxlength="40"
value={a.type}
list="activity-types"
aria-label="Activity type"
/>
<datalist id="activity-types">
{#each ["hike", "run", "ride", "ski", "climbing", "walk", "snowshoe"] as t (t)}
<option value={t}></option>
{/each}
</datalist>
<button class="btn" type="submit" disabled={saving}
>{saving ? "Saving…" : "Save"}</button
>
<button class="btn ghost" type="button" onclick={() => (editing = false)}
>Cancel</button
>
{#if form?.error}<span class="edit-error">{form.error}</span>{/if}
</form>
{:else}
<div>
<h1>
{a.name}
{#if data.canDelete}
<button
class="edit-btn"
title="Edit name and type"
aria-label="Edit name and type"
onclick={() => (editing = true)}
>
</button>
{/if}
</h1>
<p class="page-sub">
<a class="who" href="/users/{a.username}">{a.username}</a> · {fmtDate(
a.date,
)} ·
<span class="type">{a.type}</span>
</p>
</div>
{/if}
{#if data.canDelete && !editing}
<form
method="POST"
action="?/delete"
use:enhance={({ cancel }) => {
if (
!confirm("Delete this activity? Peaks it reached stay in your list.")
)
cancel();
}}
>
<button class="btn danger" type="submit">Delete</button>
</form>
{/if}
</div>
<div class="kpis">
<StatTile label="Distance" value={fmtDistance(a.distance_m)} />
<StatTile label="Ascent" value={fmtElevation(a.elev_gain_m)} detail="↓ {fmtElevation(a.elev_loss_m)}" />
<StatTile
label="Moving time"
value={fmtDuration(a.moving_s ?? a.duration_s)}
detail={a.duration_s ? `total ${fmtDuration(a.duration_s)}` : ''}
/>
{#if avgSpeed}
<StatTile label="Avg moving speed" value={avgSpeed} />
{:else}
<StatTile label="Highest point" value={fmtElevation(a.elev_max_m)} />
{/if}
<StatTile label="Distance" value={fmtDistance(a.distance_m)} />
<StatTile
label="Ascent"
value={fmtElevation(a.elev_gain_m)}
detail="↓ {fmtElevation(a.elev_loss_m)}"
/>
<StatTile
label="Moving time"
value={fmtDuration(a.moving_s ?? a.duration_s)}
detail={a.duration_s ? `total ${fmtDuration(a.duration_s)}` : ""}
/>
{#if avgSpeed}
<StatTile label="Avg moving speed" value={avgSpeed} />
{:else}
<StatTile label="Highest point" value={fmtElevation(a.elev_max_m)} />
{/if}
</div>
{#if data.bagged.length > 0}
<div class="card bagged">
⛰ Peaks reached:
{#each data.bagged as peak, i (peak.id)}{i > 0 ? ', ' : ' '}<strong>{peak.name}</strong> ({peak.elevation_m} m){/each}
</div>
<div class="card bagged">
⛰ Peaks reached:
{#each data.bagged as peak, i (peak.id)}{i > 0 ? ", " : " "}<strong
>{peak.name}</strong
>
({peak.elevation_m} m){/each}
</div>
{/if}
<h2>Map</h2>
<Map tracks={[{ id: a.id, name: a.name, latlngs: data.latlngs }]} peaks={data.bagged} height="440px" />
<MapSlot />
{#if data.profile.length > 1}
<h2>Elevation profile</h2>
<div class="card chart">
<ElevationChart profile={data.profile} />
<div class="chart-meta">
<span>Low {fmtElevation(a.elev_min_m)}</span>
<span>High {fmtElevation(a.elev_max_m)}</span>
</div>
</div>
<h2>Elevation profile</h2>
<div class="card chart">
<ElevationChart
profile={data.profile}
{hoverD}
onhover={chartHover}
onselect={chartSelect}
/>
<div class="chart-meta">
<span>Low {fmtElevation(a.elev_min_m)}</span>
<span>High {fmtElevation(a.elev_max_m)}</span>
</div>
</div>
{/if}
{#if data.timed.length > 2}
<h2>Speed</h2>
<div class="card chart">
<SpeedChart timed={data.timed} />
</div>
<h2>Speed</h2>
<div class="card chart">
<SpeedChart
timed={data.timed}
{hoverD}
onhover={chartHover}
onselect={chartSelect}
/>
</div>
{/if}
<style>
.head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.type {
text-transform: capitalize;
}
.who {
font-weight: 600;
color: var(--accent-strong);
text-decoration: none;
}
.who:hover {
text-decoration: underline;
}
.kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 0.75rem;
}
.bagged {
margin-top: 1rem;
padding: 0.85rem 1.15rem;
border-left: 3px solid var(--good);
color: var(--text-secondary);
}
.bagged strong {
color: var(--text-primary);
}
.chart {
padding: 1rem 1rem 0.5rem;
}
.chart-meta {
display: flex;
gap: 1.25rem;
padding: 0.5rem 0.25rem 0.6rem;
font-size: 0.8rem;
color: var(--text-muted);
}
.back {
display: inline-block;
margin-bottom: 0.75rem;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary);
text-decoration: none;
padding: 0.25rem 0.6rem;
margin-left: -0.6rem;
border-radius: 0.45rem;
}
.back:hover {
background: var(--wash);
color: var(--text-primary);
}
.head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.edit-btn {
border: none;
background: none;
font-size: 1rem;
color: var(--text-muted);
cursor: pointer;
padding: 0.15rem 0.4rem;
border-radius: 0.4rem;
vertical-align: middle;
}
.edit-btn:hover {
background: var(--wash);
color: var(--text-primary);
}
.edit-form {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
width: 100%;
}
.edit-form input {
padding: 0.5rem 0.7rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--surface-1);
color: var(--text-primary);
font: inherit;
}
.edit-name {
flex: 1;
min-width: 200px;
font-weight: 600;
}
.edit-type {
width: 120px;
text-transform: capitalize;
}
.edit-form input:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.edit-error {
color: var(--critical);
font-size: 0.85rem;
}
.type {
text-transform: capitalize;
}
.who {
font-weight: 600;
color: var(--accent-strong);
text-decoration: none;
}
.who:hover {
text-decoration: underline;
}
.kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 0.75rem;
}
.bagged {
margin-top: 1rem;
padding: 0.85rem 1.15rem;
border-left: 3px solid var(--good);
color: var(--text-secondary);
}
.bagged strong {
color: var(--text-primary);
}
.chart {
padding: 1rem 1rem 0.5rem;
}
.chart-meta {
display: flex;
gap: 1.25rem;
padding: 0.5rem 0.25rem 0.6rem;
font-size: 0.8rem;
color: var(--text-muted);
}
</style>
+88 -5
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,13 +50,36 @@
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;
// the "In view" list only offers peaks still to climb; climbed ones live in "My ascents"
const toClimb = $derived(viewPeaks.filter((p) => !p.climbed_at));
// map clicks confirm via a small dialog - easy to hit a peak while panning/zooming
let confirmPeak: ViewPeak | null = $state(null);
function onMapPeakClick(id: number) {
confirmPeak = viewPeaks.find((p) => p.id === id) ?? null;
}
async function confirmToggle() {
if (!confirmPeak) return;
await toggle(confirmPeak.id);
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(
@@ -134,6 +158,8 @@
Zoom in to reveal less prominent summits; click a peak to cross it off.
</p>
<MapSlot />
<div class="search">
<input
type="search"
@@ -159,7 +185,35 @@
{/if}
</div>
<Map bind:this={mapRef} peaks={mapPeaks} height="480px" onpeakclick={toggle} {onviewport} />
{#if confirmPeak}
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget) confirmPeak = null;
}}
>
<div class="dialog card" role="dialog" aria-modal="true" aria-label="Peak">
<p class="d-name">{confirmPeak.name}</p>
<p class="d-sub">
{confirmPeak.elevation_m
? `${Math.round(confirmPeak.elevation_m).toLocaleString('en-US')} m`
: 'elevation unknown'}
{#if confirmPeak.climbed_at}
· reached {fmtDate(confirmPeak.climbed_at)}
{/if}
</p>
<div class="d-actions">
{#if confirmPeak.climbed_at}
<button class="btn danger" onclick={confirmToggle}>Remove ascent</button>
{:else}
<button class="btn" onclick={confirmToggle}>Mark as reached</button>
{/if}
<button class="btn ghost" onclick={() => (confirmPeak = null)}>Cancel</button>
</div>
</div>
</div>
{/if}
<div class="tabs" role="group" aria-label="Peak lists">
<button class="filter-btn" class:on={tab === 'view'} onclick={() => (tab = 'view')}>
@@ -225,7 +279,7 @@
<style>
.search {
position: relative;
margin-bottom: 0.75rem;
margin-top: 0.75rem;
max-width: 420px;
}
.search input {
@@ -288,6 +342,35 @@
font-size: 0.8rem;
color: var(--text-muted);
}
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: 1200;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.dialog {
padding: 1.25rem 1.4rem;
min-width: 260px;
max-width: 360px;
}
.d-name {
margin: 0;
font-weight: 700;
font-size: 1.05rem;
}
.d-sub {
margin: 0.2rem 0 1rem;
color: var(--text-secondary);
font-size: 0.88rem;
}
.d-actions {
display: flex;
gap: 0.5rem;
}
.tabs {
display: flex;
gap: 0.4rem;
+56 -7
View File
@@ -3,6 +3,47 @@ import { db, peaksNearBounds, recordAscent } from '$lib/server/db';
import { matchPeaks, parseGpx } from '$lib/server/gpx';
import type { Actions } from './$types';
/** A "name" that is really just a date or timestamp, e.g. "2023-10-03 17:39:35". */
const DATE_ONLY_NAME =
/^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}([ T](\d{1,2}[:.]\d{2}([:.]\d{2})?|\d{4,6}))?Z?$/;
/** Infer an activity type from average moving speed when the GPX doesn't say. */
function inferType(gpx: { distance_m: number; moving_s: number | null }): string | null {
if (!gpx.moving_s || gpx.moving_s < 60) return null;
const kmh = (gpx.distance_m / gpx.moving_s) * 3.6;
if (kmh >= 13) return 'ride';
if (kmh >= 7) return 'run';
return 'hike';
}
/** "Morning Hike", "Evening Run", or "Hike on 3 Oct 2023" when there's no start time. */
function generateName(type: string, start: string | null, date: string | null): string {
const capitalized = type.charAt(0).toUpperCase() + type.slice(1);
if (start) {
const hour = new Date(start).getUTCHours();
const period =
hour >= 4 && hour < 11
? 'Morning'
: hour >= 11 && hour < 14
? 'Lunch'
: hour >= 14 && hour < 18
? 'Afternoon'
: hour >= 18 && hour < 22
? 'Evening'
: 'Night';
return `${period} ${capitalized}`;
}
if (date) {
const nice = new Date(date + 'T12:00:00').toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
return `${capitalized} on ${nice}`;
}
return capitalized;
}
/**
* Parse export-style filenames like
* "2018-01-11 141322 - Run - Afternoon Run.gpx" (Date - Type - Name).
@@ -45,10 +86,22 @@ export const actions: Actions = {
try {
const gpx = parseGpx(await file.text());
const parsed = parseFilename(file.name);
// a missing, "outdoor", or numeric (old Strava code) type gets inferred from pace
let type = parsed?.type ?? gpx.type ?? '';
if (!type || type === 'outdoor' || /^\d+$/.test(type)) {
type = inferType(gpx) ?? 'outdoor';
}
// a name that is just a date/timestamp gets a friendly generated one
let name = parsed?.name ?? gpx.name ?? file.name.replace(/\.gpx$/i, '');
if (!name.trim() || DATE_ONLY_NAME.test(name.trim())) {
name = generateName(type, gpx.start, gpx.date ?? parsed?.date ?? null);
}
const result = insert.run({
user_id: userId,
name: parsed?.name ?? gpx.name ?? file.name.replace(/\.gpx$/i, ''),
type: parsed?.type ?? gpx.type ?? 'outdoor',
name,
type,
date: gpx.date ?? parsed?.date ?? null,
distance_m: gpx.distance_m,
duration_s: gpx.duration_s,
@@ -69,11 +122,7 @@ export const actions: Actions = {
if (recordAscent(userId, peak.id, activityId, gpx.date)) newPeaks.push(peak.name);
}
uploaded.push({
id: activityId,
name: parsed?.name ?? gpx.name ?? file.name,
newPeaks
});
uploaded.push({ id: activityId, name, newPeaks });
} catch (err) {
errors.push(`${file.name}: ${err instanceof Error ? err.message : 'could not parse'}`);
}