From b89de57967582cba763863e8a4fb0277d0c5d3e1 Mon Sep 17 00:00:00 2001 From: Vincent van der Wal Date: Wed, 22 Jul 2026 12:07:01 +0200 Subject: [PATCH] peak search, denser catalog, profiles, filename parsing, themed map controls --- src/app.css | 12 ++ src/hooks.server.ts | 8 +- src/lib/components/Avatar.svelte | 31 ++++ src/lib/components/Map.svelte | 161 +++++++++++++++----- src/lib/server/db.ts | 66 +++++++- src/lib/server/peak-score.js | 4 +- src/routes/+layout.svelte | 47 +++++- src/routes/activities/[id]/+page.svelte | 7 +- src/routes/api/peaks/search/+server.ts | 20 +++ src/routes/avatars/[file]/+server.ts | 24 +++ src/routes/peaks/+page.svelte | 145 ++++++++++++++++-- src/routes/profile/+page.server.ts | 45 ++++++ src/routes/profile/+page.svelte | 113 ++++++++++++++ src/routes/upload/+page.server.ts | 32 +++- src/routes/users/[username]/+page.server.ts | 17 +++ src/routes/users/[username]/+page.svelte | 99 ++++++++++++ static/logo.png | Bin 60654 -> 0 bytes 17 files changed, 767 insertions(+), 64 deletions(-) create mode 100644 src/lib/components/Avatar.svelte create mode 100644 src/routes/api/peaks/search/+server.ts create mode 100644 src/routes/avatars/[file]/+server.ts create mode 100644 src/routes/profile/+page.server.ts create mode 100644 src/routes/profile/+page.svelte create mode 100644 src/routes/users/[username]/+page.server.ts create mode 100644 src/routes/users/[username]/+page.svelte delete mode 100644 static/logo.png diff --git a/src/app.css b/src/app.css index b117cda..0f19908 100644 --- a/src/app.css +++ b/src/app.css @@ -16,6 +16,10 @@ --good: #0ca30c; --good-text: #006300; --critical: #d03b3b; + --logo-wash-1: #b3e0ec; + --logo-wash-2: #96d6c6; + --logo-text: #56555a; + --map-icon-filter: none; } /* dark tokens apply when the OS prefers dark (unless the user forced light), @@ -38,6 +42,10 @@ --accent-track: #184f95; --good: #0ca30c; --good-text: #0ca30c; + --logo-wash-1: #1d5766; + --logo-wash-2: #1e6355; + --logo-text: #eef2f0; + --map-icon-filter: invert(1) brightness(1.1); } } :root[data-theme='dark'] { @@ -57,6 +65,10 @@ --accent-track: #184f95; --good: #0ca30c; --good-text: #0ca30c; + --logo-wash-1: #1d5766; + --logo-wash-2: #1e6355; + --logo-text: #eef2f0; + --map-icon-filter: invert(1) brightness(1.1); } * { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 435eb99..70eccc4 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -4,7 +4,13 @@ import { validateSession } from '$lib/server/auth'; const AUTH_PATHS = new Set(['/login', '/signup']); function isPublic(path: string): boolean { - return path === '/' || AUTH_PATHS.has(path) || /^\/activities\/\d+$/.test(path); + return ( + path === '/' || + AUTH_PATHS.has(path) || + /^\/activities\/\d+$/.test(path) || + path.startsWith('/users/') || + path.startsWith('/avatars/') + ); } export const handle: Handle = async ({ event, resolve }) => { diff --git a/src/lib/components/Avatar.svelte b/src/lib/components/Avatar.svelte new file mode 100644 index 0000000..436656b --- /dev/null +++ b/src/lib/components/Avatar.svelte @@ -0,0 +1,31 @@ + + +{#if avatar} + {username}'s avatar +{:else} + +{/if} + + diff --git a/src/lib/components/Map.svelte b/src/lib/components/Map.svelte index 1de2d68..7449405 100644 --- a/src/lib/components/Map.svelte +++ b/src/lib/components/Map.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import 'maplibre-gl/dist/maplibre-gl.css'; import { theme } from '$lib/theme.svelte'; + import type { FeatureCollection, Point } from 'geojson'; export interface MapTrack { id?: number; @@ -45,30 +46,35 @@ let container: HTMLDivElement; let map: import('maplibre-gl').Map | undefined; let lib: typeof import('maplibre-gl') | undefined; - let markers: import('maplibre-gl').Marker[] = []; let currentDark: boolean | undefined; let pendingTerrain: unknown = null; - function renderPeaks() { - if (!lib || !map) return; - for (const m of markers) m.remove(); - markers = peaks.map((p) => { - const el = document.createElement('div'); - el.className = `peak-marker ${p.climbed ? 'climbed' : ''}`; - el.textContent = p.climbed ? '✓' : '▲'; - el.title = `${p.name} · ${p.elevation_m.toLocaleString('en-US')} m`; - el.addEventListener('click', (e) => { - e.stopPropagation(); - onpeakclick?.(p.id); - }); - return new lib!.Marker({ element: el }).setLngLat([p.lon, p.lat]).addTo(map!); - }); + export function flyTo(lat: number, lon: number, zoom = 13) { + map?.flyTo({ center: [lon, lat], zoom }); } - // re-render markers when climbed state changes + function peaksGeojson(): FeatureCollection { + return { + type: 'FeatureCollection', + features: peaks.map((p) => ({ + type: 'Feature', + id: p.id, + properties: { + id: p.id, + name: p.name, + elevation_m: p.elevation_m, + climbed: p.climbed + }, + geometry: { type: 'Point', coordinates: [p.lon, p.lat] } + })) + }; + } + + // push new data into the peaks layer when peaks/climbed state changes $effect(() => { void peaks; - renderPeaks(); + const source = map?.getSource('peaks') as import('maplibre-gl').GeoJSONSource | undefined; + source?.setData(peaksGeojson()); }); // swap map style when the theme changes @@ -134,6 +140,39 @@ paint: { 'line-color': dark ? '#3987e5' : '#2a78d6', 'line-width': 2.5 } }); + // peaks as native layers - scales to thousands of points + map.addSource('peaks', { type: 'geojson', data: peaksGeojson() }); + map.addLayer({ + id: 'peaks-circles', + type: 'circle', + source: 'peaks', + paint: { + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 5, 5, 12, 9], + 'circle-color': [ + 'case', + ['get', 'climbed'], + '#0ca30c', + dark ? '#1a1a19' : '#fcfcfb' + ], + 'circle-stroke-width': 1.5, + 'circle-stroke-color': ['case', ['get', 'climbed'], '#0ca30c', dark ? '#898781' : '#898781'] + } + }); + map.addLayer({ + id: 'peaks-glyphs', + type: 'symbol', + source: 'peaks', + layout: { + 'text-field': ['case', ['get', 'climbed'], '✓', '▲'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 5, 7, 12, 10], + 'text-allow-overlap': true, + 'text-ignore-placement': true + }, + paint: { + 'text-color': ['case', ['get', 'climbed'], '#ffffff', dark ? '#c3c2b7' : '#52514e'] + } + }); + // restore 3D terrain across style swaps if (pendingTerrain) { map.setTerrain(pendingTerrain as import('maplibre-gl').TerrainSpecification); @@ -165,6 +204,33 @@ map.on('style.load', () => addLayers(currentDark ?? false)); + // peak interaction: click to toggle, hover for tooltip + const popup = new maplibre.Popup({ + closeButton: false, + closeOnClick: false, + offset: 12, + className: 'peak-tip' + }); + map.on('click', 'peaks-circles', (e) => { + const feature = e.features?.[0]; + if (feature) onpeakclick?.(feature.properties.id as number); + }); + map.on('mousemove', 'peaks-circles', (e) => { + const feature = e.features?.[0]; + if (!feature || !map) return; + map.getCanvas().style.cursor = 'pointer'; + const p = feature.properties; + popup + .setLngLat((feature.geometry as Point).coordinates as [number, number]) + .setText(`${p.name} · ${Number(p.elevation_m).toLocaleString('en-US')} m`) + .addTo(map); + }); + map.on('mouseleave', 'peaks-circles', () => { + if (!map) return; + map.getCanvas().style.cursor = ''; + popup.remove(); + }); + if (onviewport) { const report = () => { if (!map) return; @@ -181,8 +247,6 @@ map.once('load', report); } - renderPeaks(); - 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]); @@ -203,27 +267,46 @@ .map { width: 100%; } - :global(.peak-marker) { - width: 26px; - height: 26px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - background: var(--surface-1); - border: 1.5px solid var(--baseline); - color: var(--text-secondary); - font-size: 12px; - font-weight: 700; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); - cursor: pointer; - } - :global(.peak-marker.climbed) { - background: var(--good); - border-color: var(--good); - color: #fff; - } :global(.maplibregl-map) { font: inherit; } + /* theme the maplibre controls */ + :global(.maplibregl-ctrl-group) { + background: var(--surface-1); + 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); + } + :global(.maplibregl-ctrl button .maplibregl-ctrl-icon) { + filter: var(--map-icon-filter); + } + :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); + color: var(--text-muted); + } + :global(.maplibregl-ctrl-attrib a) { + color: var(--text-secondary); + } + :global(.maplibregl-popup.peak-tip .maplibregl-popup-content) { + background: var(--surface-1); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 0.3rem 0.6rem; + font-size: 0.82rem; + font-weight: 500; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12); + } + :global(.maplibregl-popup.peak-tip .maplibregl-popup-tip) { + border-top-color: var(--surface-1); + border-bottom-color: var(--surface-1); + } diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index b948aad..9559e9e 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -72,6 +72,14 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_ascents_user ON ascents(user_id); `); +// lightweight migrations for columns added after the initial schema +const userCols = (db.prepare('PRAGMA table_info(users)').all() as { name: string }[]).map( + (c) => c.name +); +for (const col of ['bio', 'location', 'avatar']) { + if (!userCols.includes(col)) db.exec(`ALTER TABLE users ADD COLUMN ${col} TEXT`); +} + // Seed the catalog with the curated list while no OSM import has run. // Seed rows use osm_id "seed:"; scripts/import-peaks.js upgrades them // to real OSM nodes (preserving ascents) and fills in the rest of the Alps. @@ -181,6 +189,58 @@ export function listAllActivities(): (Omit & { username: .all() as (Omit & { username: string })[]; } +export interface ProfileRow { + id: number; + username: string; + created_at: string; + bio: string | null; + location: string | null; + avatar: string | null; +} + +export function getProfile(username: string): ProfileRow | undefined { + return db + .prepare('SELECT id, username, created_at, bio, location, avatar FROM users WHERE username = ?') + .get(username) as ProfileRow | undefined; +} + +export function updateProfile( + userId: number, + fields: { bio: string | null; location: string | null; avatar?: string } +): void { + if (fields.avatar !== undefined) { + db.prepare('UPDATE users SET bio = ?, location = ?, avatar = ? WHERE id = ?').run( + fields.bio, + fields.location, + fields.avatar, + userId + ); + } else { + db.prepare('UPDATE users SET bio = ?, location = ? WHERE id = ?').run( + fields.bio, + fields.location, + userId + ); + } +} + +export function userStats(userId: number): { + activities: number; + distance_m: number; + elev_gain_m: number; + moving_s: number; +} { + return db + .prepare( + `SELECT count(*) activities, + coalesce(sum(distance_m), 0) distance_m, + coalesce(sum(elev_gain_m), 0) elev_gain_m, + coalesce(sum(coalesce(moving_s, duration_s, 0)), 0) moving_s + FROM activities WHERE user_id = ?` + ) + .get(userId) as { activities: number; distance_m: number; elev_gain_m: number; moving_s: number }; +} + export function communityTotals(): { users: number; activities: number; @@ -207,17 +267,17 @@ export function peaksInView( userId: number, bbox: { minLat: number; minLon: number; maxLat: number; maxLon: number }, zoom: number, - limit = 300 + limit = 1000 ): (PeakRow & { climbed_at: string | null; ascent_activity_id: number | null })[] { return db .prepare( `SELECT p.*, a.climbed_at, a.activity_id ascent_activity_id FROM peaks p LEFT JOIN ascents a ON a.peak_id = p.id AND a.user_id = @userId - WHERE p.minzoom <= @zoom + WHERE (p.minzoom <= @zoom OR a.climbed_at IS NOT NULL) AND p.lat BETWEEN @minLat AND @maxLat AND p.lon BETWEEN @minLon AND @maxLon - ORDER BY p.score DESC + ORDER BY (a.climbed_at IS NOT NULL) DESC, p.score DESC LIMIT @limit` ) .all({ userId, zoom: Math.floor(zoom), ...bbox, limit }) as (PeakRow & { diff --git a/src/lib/server/peak-score.js b/src/lib/server/peak-score.js index 377eea3..55ae548 100644 --- a/src/lib/server/peak-score.js +++ b/src/lib/server/peak-score.js @@ -19,7 +19,7 @@ export const MAX_MINZOOM = 14; /** * Assign each peak the lowest zoom level at which it should appear. - * For every zoom 4..13 the map is divided into a grid (4 cells per tile + * For every zoom 4..13 the map is divided into a grid (8 cells per tile * width); the highest-scoring peak in a cell "wins" it and becomes visible * from that zoom on. Everything that never wins shows from zoom 14. * @template {{ lat: number, lon: number, score: number, minzoom?: number }} P @@ -30,7 +30,7 @@ export function assignMinzoom(peaks) { const sorted = [...peaks].sort((a, b) => b.score - a.score); for (const peak of sorted) peak.minzoom = MAX_MINZOOM; for (let z = 4; z <= 13; z++) { - const cell = 360 / (Math.pow(2, z) * 4); + const cell = 360 / (Math.pow(2, z) * 8); const occupied = new Set(); for (const peak of sorted) { const key = `${Math.floor(peak.lon / cell)}:${Math.floor(peak.lat / cell)}`; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index af6fde3..2e030ea 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -33,8 +33,25 @@