diff --git a/CLAUDE.md b/CLAUDE.md index 3267b2b..62d3e9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,10 +45,31 @@ The bincio_activity server must allow CORS from `planner.bincio.org` — this is - [x] Auth gate with redirect to bincio login - [x] Deployed to `https://planner.bincio.org` with HTTPS +## Activity-year colour palette + +Year-coded colours used for GPX reference tracks (and potentially other per-year data). Defined as `GPX_PALETTE` in `Planner.svelte`. + +| Year | Color | Description | +|------|-------|-------------| +| 2014 | `hsl(265, 38%, 52%)` | muted purple | +| 2015 | `hsl(248, 40%, 53%)` | slate-indigo | +| 2016 | `hsl(232, 43%, 54%)` | slate-blue | +| 2017 | `hsl(208, 48%, 52%)` | steel blue | +| 2018 | `hsl(185, 52%, 50%)` | teal | +| 2019 | `hsl(165, 50%, 49%)` | green-teal | +| 2020 | `hsl(145, 48%, 47%)` | green | +| 2021 | `hsl(100, 57%, 49%)` | yellow-green | +| 2022 | `hsl(50, 72%, 52%)` | amber | +| 2023 | `hsl(34, 75%, 53%)` | orange | +| 2024 | `hsl(16, 77%, 56%)` | red-orange | +| 2025 | `hsl(5, 70%, 57%)` | warm coral | +| 2026 | `#60a5fa` | bright blue | +| — | `#71717a` | grey (undated) | + ## What's missing (from original plan) - [ ] **Map tile switcher** — toggle between OSM and OpenCycleMap (Thunderforest, requires free API key). OpenCycleMap is significantly better for route planning. -- [ ] **Drag-to-modify route polyline** — click and drag a point on the route line to insert a new waypoint. Deferred: requires hit-testing which segment was dragged, then inserting a waypoint at the right index. Doable but fiddly. +- [x] **Drag-to-modify route polyline** — click and drag a point on the route line to insert a new waypoint. Deferred: requires hit-testing which segment was dragged, then inserting a waypoint at the right index. Doable but fiddly. - [ ] **Heatmap overlay** — nice-to-have. Our own data is too sparse (~20 users). Public options: Strava global heatmap (legally gray), Waymarked Trails (clean, OSM-based). - [ ] **Palette flash** — the race-calendar palette runs in `onMount`, so there's a brief flash from the default CSS vars to the correct palette. In bincio_activity this is avoided by an inline ` - - {#if profile} - - - - - - {profile.maxE.toFixed(0)}m - {profile.minE.toFixed(0)}m - - {(profile.totalDist / 1000).toFixed(1)} km + + + {#if profile} + + {#each profile.slopeStops as stop} + + {/each} + + {/if} + + + {#if labelProfile} + + {#if gpxProfile} + + + {/if} + + + {#if profile} + + + {/if} + + + {labelProfile.maxE.toFixed(0)}m + {labelProfile.minE.toFixed(0)}m + + + {#if profile} + {(profile.totalDist / 1000).toFixed(1)} km + {/if} + {#if gpxProfile && !profile} + {(gpxProfile.totalDist / 1000).toFixed(1)} km + {/if} + {#if gpxProfile && profile} + gpx {(gpxProfile.totalDist / 1000).toFixed(1)} km + {/if} + + + {#if cursorFrac !== null} + {@const cX = PAD.left + cursorFrac * iW} + {@const cDist = cursorFrac * (profile ?? gpxProfile).totalDist} + + {#if profile} + {@const cEle = elevAtDist(profile.pts, cDist)} + {@const cSlope = slopeAtDist(profile.pts, cDist)} + {@const cY = profile.toY(cEle)} + {@const tipLeft = cursorFrac > 0.72} + {@const tipX = tipLeft ? cX - 62 : cX + 6} + + + + {cSlope > 0.05 ? '+' : ''}{cSlope.toFixed(1)}% + + {cEle.toFixed(0)} m + {:else if gpxProfile} + {@const cY = gpxProfile.toY(elevAtDist(gpxProfile.pts, cDist))} + + {/if} + {/if} + + + {#if hoverFrac !== null && profile} + {@const hDist = hoverFrac * profile.totalDist} + {@const hX = profile.toX(hDist)} + {@const hY = profile.toY(elevAtDist(profile.pts, hDist))} + + + {/if} {/if} diff --git a/src/Planner.svelte b/src/Planner.svelte index c70d059..d06a302 100644 --- a/src/Planner.svelte +++ b/src/Planner.svelte @@ -3,7 +3,7 @@ import maplibregl from 'maplibre-gl'; import ElevationChart from './ElevationChart.svelte'; - let { activityUrl } = $props(); + let { activityUrl, activityAccess = false } = $props(); const API = `${import.meta.env.VITE_PLANNER_API_URL ?? ''}/api`; @@ -24,9 +24,88 @@ let saveName = $state(''); let saving = $state(false); + // Collections + let collections = $state([]); + let browseOpen = $state(false); + let browseCollId = $state(null); // null=all, 'unsorted', or a collection id + let browseSearch = $state(''); + let newColInput = $state(false); + let newColName = $state(''); + let deleteColConfirm = $state(null); // null | {id, name} + let saveCollId = $state(''); // '' | collection-id | '__new__' | '__shared__' + let saveNewColName = $state(''); + + // BincioShared + let sharedPlans = $state([]); + let activePlanShared = $state(false); // true when the active plan lives in /api/shared + let sharedLoadConfirm = $state(null); // null | plan object — "edit original / copy" dialog + // Route insertion (hover snap) - let hoveringRoute = false; // plain JS — no need to render - let snapMarker; // maplibre Marker used as snap indicator + let hoveringRoute = false; + let snapMarker; + + // GPX track snap (hover → click places a gpxSnapped waypoint) + let hoveringGPX = false; + let gpxSnapEl; + let gpxSnapMarker; + + // Bidirectional elevation ↔ map indicator + let chartHighlightFrac = $state(null); // 0-1 dist frac set from map hover → drives chart bar + let chartDotMarker; // maplibre Marker shown on map when hovering chart + + // GPX reference track + let gpxTrack = $state(null); // GeoJSON Feature + let gpxName = $state(''); // original filename for display + + // Activity picker + let activityPickerOpen = $state(false); + let activityList = $state([]); + let activitiesLoading = $state(false); + let activitySearch = $state(''); + + // Activity-year colour palette — year-coded colours used across bincio tooling. + // Order matches GPX_PALETTE index 0-13; index 13 is the "undated/grey" fallback. + const GPX_PALETTE = [ + { year: 2014, color: 'hsl(265, 38%, 52%)' }, // muted purple + { year: 2015, color: 'hsl(248, 40%, 53%)' }, // slate-indigo + { year: 2016, color: 'hsl(232, 43%, 54%)' }, // slate-blue + { year: 2017, color: 'hsl(208, 48%, 52%)' }, // steel blue + { year: 2018, color: 'hsl(185, 52%, 50%)' }, // teal + { year: 2019, color: 'hsl(165, 50%, 49%)' }, // green-teal + { year: 2020, color: 'hsl(145, 48%, 47%)' }, // green + { year: 2021, color: 'hsl(100, 57%, 49%)' }, // yellow-green + { year: 2022, color: 'hsl(50, 72%, 52%)' }, // amber + { year: 2023, color: 'hsl(34, 75%, 53%)' }, // orange + { year: 2024, color: 'hsl(16, 77%, 56%)' }, // red-orange + { year: 2025, color: 'hsl(5, 70%, 57%)' }, // warm coral + { year: 2026, color: '#60a5fa' }, // bright blue + { year: null, color: '#71717a' }, // grey — undated + ]; + + let gpxColorIdx = $state(12); // default: bright blue (2026) + let gpxOpacity = $state(0.8); + let gpxShowElevation = $state(false); + let gpxColor = $derived(GPX_PALETTE[gpxColorIdx].color); + let lineWidth = $state(4); // track line width for both layers (3–10) + + // Keep the map layer in sync whenever color or opacity changes. + $effect(() => { + const color = gpxColor; // read before the guard so they're always tracked + const opacity = gpxOpacity; + if (!map?.getLayer('gpx-line')) return; + map.setPaintProperty('gpx-line', 'line-color', color); + map.setPaintProperty('gpx-line', 'line-opacity', opacity); + }); + + // Sync line width to both map layers. + $effect(() => { + const w = lineWidth; // read before the guard + if (!map?.getLayer('gpx-line')) return; + map.setPaintProperty('gpx-line', 'line-width', w); + map.setPaintProperty('route-line', 'line-width', + ['case', ['==', ['get', 'segType'], 'gpx'], Math.max(1, w - 1), w + 1] + ); + }); const PROFILES = [ { id: 'fastbike', label: 'Road' }, @@ -101,12 +180,24 @@ map.addControl(new maplibregl.NavigationControl(), 'top-right'); map.on('load', () => { + map.addSource('gpx-track', { type: 'geojson', data: emptyGeoJSON() }); + map.addLayer({ + id: 'gpx-line', + type: 'line', + source: 'gpx-track', + paint: { 'line-color': '#60a5fa', 'line-width': lineWidth, 'line-opacity': 0.8 }, + }); + map.addSource('route', { type: 'geojson', data: emptyGeoJSON() }); map.addLayer({ id: 'route-line', type: 'line', source: 'route', - paint: { 'line-color': '#e879a0', 'line-width': 4, 'line-opacity': 0.9 }, + paint: { + 'line-color': '#e879a0', + 'line-width': ['case', ['==', ['get', 'segType'], 'gpx'], Math.max(1, lineWidth - 1), lineWidth + 1], + 'line-opacity': ['case', ['==', ['get', 'segType'], 'gpx'], 0.45, 0.9], + }, }); }); @@ -114,6 +205,14 @@ snapEl.className = 'snap-marker'; snapMarker = new maplibregl.Marker({ element: snapEl, draggable: false }); + gpxSnapEl = document.createElement('div'); + gpxSnapEl.className = 'gpx-snap-marker'; + gpxSnapMarker = new maplibregl.Marker({ element: gpxSnapEl, draggable: false }); + + const chartDotEl = document.createElement('div'); + chartDotEl.className = 'chart-dot-marker'; + chartDotMarker = new maplibregl.Marker({ element: chartDotEl, draggable: false }); + map.on('click', onMapClick); map.on('mousemove', onMapMouseMove); loadPlans(); @@ -123,36 +222,63 @@ // ── Waypoint management ──────────────────────────────────────────────────── function onMapClick(e) { + if (hoveringGPX && gpxTrack) { + const snapped = nearestOnLine(gpxTrack.geometry.coordinates, [e.lngLat.lng, e.lngLat.lat]); + activePlanId = null; + addWaypoint(snapped.lng, snapped.lat, { gpxSnapped: true, gpxFrac: snapped.frac }); + return; + } if (hoveringRoute && route && waypoints.length >= 2) { const snapped = nearestOnLine(route.geometry.coordinates, [e.lngLat.lng, e.lngLat.lat]); const idx = insertionIndex(snapped.frac, route.geometry.coordinates); insertWaypointAt(snapped.lng, snapped.lat, idx); - } else { - activePlanId = null; - addWaypoint(e.lngLat.lng, e.lngLat.lat); + return; } + activePlanId = null; + addWaypoint(e.lngLat.lng, e.lngLat.lat); } function onMapMouseMove(e) { - if (!route || waypoints.length < 2) { - if (hoveringRoute) { hoveringRoute = false; snapMarker.remove(); map.getCanvas().style.cursor = ''; } - return; - } const b = 14; - const features = map.queryRenderedFeatures( - [[e.point.x - b, e.point.y - b], [e.point.x + b, e.point.y + b]], - { layers: ['route-line'] } - ); - if (features.length > 0) { - hoveringRoute = true; - map.getCanvas().style.cursor = 'crosshair'; - const snapped = nearestOnLine(route.geometry.coordinates, [e.lngLat.lng, e.lngLat.lat]); - snapMarker.setLngLat([snapped.lng, snapped.lat]).addTo(map); - } else { - hoveringRoute = false; - snapMarker.remove(); - map.getCanvas().style.cursor = ''; + + // Route hover takes priority (insert-on-click) + if (route && waypoints.length >= 2) { + const feats = map.queryRenderedFeatures( + [[e.point.x - b, e.point.y - b], [e.point.x + b, e.point.y + b]], + { layers: ['route-line'] } + ); + if (feats.length > 0) { + hoveringRoute = true; + map.getCanvas().style.cursor = 'crosshair'; + const snapped = nearestOnLine(route.geometry.coordinates, [e.lngLat.lng, e.lngLat.lat]); + snapMarker.setLngLat([snapped.lng, snapped.lat]).addTo(map); + chartHighlightFrac = routeCumDist + ? coordFracToDistFrac(snapped.frac, route.geometry.coordinates, routeCumDist) + : null; + if (hoveringGPX) { hoveringGPX = false; gpxSnapMarker.remove(); } + return; + } } + if (hoveringRoute) { hoveringRoute = false; snapMarker.remove(); chartHighlightFrac = null; } + + // GPX track hover (snap-on-click) + if (gpxTrack) { + const gpxFeats = map.queryRenderedFeatures( + [[e.point.x - b, e.point.y - b], [e.point.x + b, e.point.y + b]], + { layers: ['gpx-line'] } + ); + if (gpxFeats.length > 0) { + hoveringGPX = true; + map.getCanvas().style.cursor = 'crosshair'; + const snapped = nearestOnLine(gpxTrack.geometry.coordinates, [e.lngLat.lng, e.lngLat.lat]); + gpxSnapEl.style.borderColor = gpxColor; + gpxSnapEl.style.background = gpxColor + '40'; + gpxSnapMarker.setLngLat([snapped.lng, snapped.lat]).addTo(map); + return; + } + } + if (hoveringGPX) { hoveringGPX = false; gpxSnapMarker.remove(); } + map.getCanvas().style.cursor = ''; } // ── Route insertion helpers ──────────────────────────────────────────────── @@ -192,28 +318,35 @@ activePlanId = null; fetchRoute(); }); - waypoints = [...waypoints.slice(0, idx), { lng, lat, marker }, ...waypoints.slice(idx)]; + waypoints = [...waypoints.slice(0, idx), { lng, lat, marker, gpxSnapped: false, gpxFrac: null }, ...waypoints.slice(idx)]; waypoints.forEach((w, i) => { w.marker.getElement().textContent = i + 1; }); activePlanId = null; fetchRoute(); } - function addWaypoint(lng, lat) { + function addWaypoint(lng, lat, opts = {}) { + const gpxSnapped = opts.gpxSnapped ?? false; + const gpxFrac = opts.gpxFrac ?? null; + const el = document.createElement('div'); el.className = 'wp-marker'; + if (gpxSnapped) el.style.borderColor = gpxColor; el.textContent = waypoints.length + 1; const marker = new maplibregl.Marker({ element: el, draggable: true }) .setLngLat([lng, lat]) .addTo(map); - const wp = { lng, lat, marker }; + const wp = { lng, lat, marker, gpxSnapped, gpxFrac }; marker.on('dragend', () => { const { lng: newLng, lat: newLat } = marker.getLngLat(); const i = waypoints.findIndex(w => w.marker === marker); if (i >= 0) { waypoints[i].lng = newLng; waypoints[i].lat = newLat; + waypoints[i].gpxSnapped = false; + waypoints[i].gpxFrac = null; + waypoints[i].marker.getElement().style.borderColor = ''; } activePlanId = null; fetchRoute(); @@ -223,6 +356,17 @@ fetchRoute(); } + function moveWaypoint(i, dir) { + const j = i + dir; + if (j < 0 || j >= waypoints.length) return; + const arr = [...waypoints]; + [arr[i], arr[j]] = [arr[j], arr[i]]; + waypoints = arr; + waypoints.forEach((wp, idx) => { wp.marker.getElement().textContent = idx + 1; }); + activePlanId = null; + fetchRoute(); + } + function removeWaypoint(i) { waypoints[i].marker.remove(); waypoints = waypoints.filter((_, idx) => idx !== i); @@ -231,6 +375,14 @@ fetchRoute(); } + function reverseRoute() { + if (waypoints.length < 2) return; + waypoints = [...waypoints].reverse(); + waypoints.forEach((wp, i) => { wp.marker.getElement().textContent = i + 1; }); + activePlanId = null; + fetchRoute(); + } + function closeLoop() { if (waypoints.length < 2) return; const first = waypoints[0]; @@ -242,8 +394,13 @@ waypoints = []; route = null; error = ''; + activePlanShared = false; hoveringRoute = false; snapMarker?.remove(); + hoveringGPX = false; + gpxSnapMarker?.remove(); + chartDotMarker?.remove(); + chartHighlightFrac = null; map.getCanvas().style.cursor = ''; if (map.getSource('route')) map.getSource('route').setData(emptyGeoJSON()); } @@ -261,17 +418,101 @@ routeTimer = setTimeout(doFetchRoute, 400); } + // Interpolate a coordinate (with elevation) at a fractional index along a coord array. + function lerpCoord(coords, frac) { + const i = Math.min(Math.floor(frac), coords.length - 2); + const t = frac - Math.floor(frac); + return [ + coords[i][0] + t * (coords[i+1][0] - coords[i][0]), + coords[i][1] + t * (coords[i+1][1] - coords[i][1]), + (coords[i][2] ?? 0) + t * ((coords[i+1][2] ?? 0) - (coords[i][2] ?? 0)), + ]; + } + + // Slice GPX coords between two fractional indices. Takes the shorter direction. + function sliceGPXCoords(coords, fracA, fracB) { + const startPt = lerpCoord(coords, fracA); + const endPt = lerpCoord(coords, fracB); + const iA = Math.floor(fracA), iB = Math.floor(fracB); + if (fracA <= fracB) { + return [startPt, ...coords.slice(iA + 1, iB + 1), endPt]; + } else { + // Reverse direction (shorter path when fracA > fracB) + return [startPt, ...coords.slice(iB + 1, iA + 1).reverse(), endPt]; + } + } + async function doFetchRoute() { loading = true; error = ''; - const lonlats = waypoints.map(wp => `${wp.lng.toFixed(6)},${wp.lat.toFixed(6)}`).join('|'); - const url = `https://brouter.de/brouter?lonlats=${lonlats}&profile=${profile}&alternativeidx=0&format=geojson`; try { - const r = await fetch(url); - if (!r.ok) throw new Error(`Brouter error ${r.status}`); - const gj = await r.json(); - route = gj.features?.[0] ?? null; - if (map.getSource('route')) map.getSource('route').setData(route ?? emptyGeoJSON()); + // Classify each segment: gpx if both endpoints are gpxSnapped, else brouter + const segTypes = []; + for (let i = 0; i < waypoints.length - 1; i++) { + segTypes.push( + gpxTrack && waypoints[i].gpxSnapped && waypoints[i+1].gpxSnapped + ? 'gpx' : 'brouter' + ); + } + + // Group into runs of the same type, batching consecutive brouter segments + const runs = []; + let i = 0; + while (i < segTypes.length) { + if (segTypes[i] === 'gpx') { + runs.push({ type: 'gpx', from: i, to: i + 1 }); + i++; + } else { + let j = i; + while (j + 1 < segTypes.length && segTypes[j + 1] === 'brouter') j++; + runs.push({ type: 'brouter', from: i, to: j + 1 }); + i = j + 1; + } + } + + // Resolve each run (Brouter calls run in parallel) + const results = await Promise.all(runs.map(async run => { + if (run.type === 'gpx') { + const snapA = nearestOnLine(gpxTrack.geometry.coordinates, [waypoints[run.from].lng, waypoints[run.from].lat]); + const snapB = nearestOnLine(gpxTrack.geometry.coordinates, [waypoints[run.to].lng, waypoints[run.to].lat]); + return { type: 'gpx', coords: sliceGPXCoords(gpxTrack.geometry.coordinates, snapA.frac, snapB.frac), props: {} }; + } else { + const wps = waypoints.slice(run.from, run.to + 1); + const lonlats = wps.map(wp => `${wp.lng.toFixed(6)},${wp.lat.toFixed(6)}`).join('|'); + const url = `https://brouter.de/brouter?lonlats=${lonlats}&profile=${profile}&alternativeidx=0&format=geojson`; + const r = await fetch(url); + if (!r.ok) throw new Error(`Brouter error ${r.status}`); + const gj = await r.json(); + const feat = gj.features?.[0]; + if (!feat) throw new Error('No route from Brouter'); + return { type: 'brouter', coords: feat.geometry.coordinates, props: feat.properties }; + } + })); + + // Concatenate coords (skip first point of each subsequent run to avoid duplicates) + let allCoords = []; + const mapFeatures = []; + for (let r = 0; r < results.length; r++) { + const coords = r === 0 ? results[r].coords : results[r].coords.slice(1); + allCoords = allCoords.concat(coords); + mapFeatures.push({ + type: 'Feature', + geometry: { type: 'LineString', coordinates: results[r].coords }, + properties: { segType: results[r].type }, + }); + } + + // route = single Feature for elevation chart / stats / export + route = { + type: 'Feature', + geometry: { type: 'LineString', coordinates: allCoords }, + // Preserve Brouter properties only for a pure single-brouter-segment route + properties: results.length === 1 && results[0].type === 'brouter' ? results[0].props : {}, + }; + + if (map.getSource('route')) { + map.getSource('route').setData({ type: 'FeatureCollection', features: mapFeatures }); + } } catch (e) { error = e.message ?? 'Routing failed'; route = null; @@ -306,35 +547,109 @@ ${trkpts} async function loadPlans() { plansLoading = true; try { - const r = await fetch(`${API}/plans`, { credentials: 'include' }); - if (r.ok) plans = (await r.json()).plans ?? []; + const [pr, cr, sr] = await Promise.all([ + fetch(`${API}/plans`, { credentials: 'include' }), + fetch(`${API}/collections`, { credentials: 'include' }), + fetch(`${API}/shared`, { credentials: 'include' }), + ]); + if (pr.ok) plans = (await pr.json()).plans ?? []; + if (cr.ok) collections = (await cr.json()).collections ?? []; + if (sr.ok) sharedPlans = (await sr.json()).plans ?? []; } catch {} plansLoading = false; + + // Handle ?shared= deep-link: show the pick dialog for that plan. + const sharedId = new URLSearchParams(window.location.search).get('shared'); + if (sharedId) { + // Remove the query param from the URL without reloading + history.replaceState(null, '', window.location.pathname); + const plan = sharedPlans.find(p => p.id === sharedId); + if (plan) { + sharedLoadConfirm = plan; + } else { + // Plan not in the cached list (perhaps freshly shared); fetch its summary directly + try { + const r = await fetch(`${API}/shared/${sharedId}`, { credentials: 'include' }); + if (r.ok) { + const data = await r.json(); + sharedLoadConfirm = { id: data.id, name: data.name, author: data.author, profile: data.profile }; + } + } catch {} + } + } } async function savePlan() { if (!saveName.trim() || waypoints.length < 2) return; saving = true; + + // Create a new collection on-the-fly if the user typed one in + let collId = (saveCollId && saveCollId !== '__new__' && saveCollId !== '__shared__') ? saveCollId : null; + if (saveCollId === '__new__' && saveNewColName.trim()) { + const cr = await fetch(`${API}/collections`, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: saveNewColName.trim() }), + }); + if (cr.ok) { + const { id } = await cr.json(); + collId = id; + saveCollId = id; + saveNewColName = ''; + collections = (await (await fetch(`${API}/collections`, { credentials: 'include' })).json()).collections ?? []; + } + } + + const s = routeStats(); + const hasGPXSegs = gpxTrack && waypoints.some(wp => wp.gpxSnapped); const body = { - name: saveName.trim(), - waypoints: waypoints.map(({ lng, lat }) => ({ lng, lat })), + name: saveName.trim(), + waypoints: waypoints.map(({ lng, lat, gpxSnapped, gpxFrac }) => ({ lng, lat, gpxSnapped: gpxSnapped ?? false, gpxFrac: gpxFrac ?? null })), profile, - geojson: route ?? undefined, + geojson: route ?? undefined, + collection_id: collId, + dist_km: s ? parseFloat(s.dist) : null, + elevation_gain: s ? s.up : null, + ...(hasGPXSegs ? { gpxTrack, gpxColorIdx, gpxOpacity } : {}), }; try { - if (activePlanId) { + const isShared = saveCollId === '__shared__'; + if (isShared && activePlanShared && activePlanId) { + // Update existing shared plan + await fetch(`${API}/shared/${activePlanId}`, { + method: 'PUT', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } else if (isShared) { + // Create new shared plan + const r = await fetch(`${API}/shared`, { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (r.ok) { + activePlanId = (await r.json()).id; + activePlanShared = true; + } + } else if (activePlanId && !activePlanShared) { + // Update existing personal plan await fetch(`${API}/plans/${activePlanId}`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); } else { + // Create new personal plan const r = await fetch(`${API}/plans`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); - if (r.ok) activePlanId = (await r.json()).id; + if (r.ok) { + activePlanId = (await r.json()).id; + activePlanShared = false; + } } savePanel = false; await loadPlans(); @@ -347,14 +662,39 @@ ${trkpts} profile = plan.profile ?? 'gravel'; activePlanId = plan.id; saveName = plan.name; - // If the full plan (with geojson) is needed, fetch it; list endpoint omits geojson. + saveCollId = plan.collection_id ?? ''; + saveNewColName = ''; const r = await fetch(`${API}/plans/${plan.id}`, { credentials: 'include' }); if (!r.ok) return; const full = await r.json(); - for (const { lng, lat } of (full.waypoints ?? [])) addWaypoint(lng, lat); + + // Restore GPX reference track if the plan used one + if (full.gpxTrack) { + gpxTrack = full.gpxTrack; + if (full.gpxColorIdx != null) gpxColorIdx = full.gpxColorIdx; + if (full.gpxOpacity != null) gpxOpacity = full.gpxOpacity; + if (map?.getSource('gpx-track')) map.getSource('gpx-track').setData(gpxTrack); + } + + // Add waypoints with their gpxSnapped flags + for (const wp of (full.waypoints ?? [])) { + addWaypoint(wp.lng, wp.lat, { gpxSnapped: wp.gpxSnapped ?? false, gpxFrac: wp.gpxFrac ?? null }); + } + + // Show stored geojson immediately — cancel the re-route timer addWaypoint started if (full.geojson) { + clearTimeout(routeTimer); route = full.geojson; - if (map.getSource('route')) map.getSource('route').setData(route); + const segType = full.gpxTrack ? undefined : 'brouter'; + if (map.getSource('route')) { + map.getSource('route').setData({ + type: 'FeatureCollection', + features: [{ ...route, properties: { ...route.properties, ...(segType ? { segType } : {}) } }], + }); + } + fitToCoords(full.geojson.geometry.coordinates); + } else { + fitToCoords(waypoints.map(w => [w.lng, w.lat])); } } @@ -365,20 +705,262 @@ ${trkpts} await loadPlans(); } + async function deleteCollection(colId, mode) { + await fetch(`${API}/collections/${colId}?mode=${mode}`, { + method: 'DELETE', credentials: 'include', + }); + deleteColConfirm = null; + if (browseCollId === colId) browseCollId = null; + if (saveCollId === colId) saveCollId = ''; + await loadPlans(); + } + + async function loadSharedPlan(plan, asCopy) { + sharedLoadConfirm = null; + clearAll(); + profile = plan.profile ?? 'gravel'; + saveName = plan.name; + + if (asCopy) { + activePlanId = null; + activePlanShared = false; + saveCollId = ''; + } else { + activePlanId = plan.id; + activePlanShared = true; + saveCollId = '__shared__'; + } + saveNewColName = ''; + + const r = await fetch(`${API}/shared/${plan.id}`, { credentials: 'include' }); + if (!r.ok) return; + const full = await r.json(); + + if (full.gpxTrack) { + gpxTrack = full.gpxTrack; + if (full.gpxColorIdx != null) gpxColorIdx = full.gpxColorIdx; + if (full.gpxOpacity != null) gpxOpacity = full.gpxOpacity; + if (map?.getSource('gpx-track')) map.getSource('gpx-track').setData(gpxTrack); + } + for (const wp of (full.waypoints ?? [])) { + addWaypoint(wp.lng, wp.lat, { gpxSnapped: wp.gpxSnapped ?? false, gpxFrac: wp.gpxFrac ?? null }); + } + if (full.geojson) { + clearTimeout(routeTimer); + route = full.geojson; + const segType = full.gpxTrack ? undefined : 'brouter'; + if (map.getSource('route')) { + map.getSource('route').setData({ + type: 'FeatureCollection', + features: [{ ...route, properties: { ...route.properties, ...(segType ? { segType } : {}) } }], + }); + } + fitToCoords(full.geojson.geometry.coordinates); + } else { + fitToCoords(waypoints.map(w => [w.lng, w.lat])); + } + } + + async function deleteSharedPlan(plan, e) { + e.stopPropagation(); + await fetch(`${API}/shared/${plan.id}`, { method: 'DELETE', credentials: 'include' }); + if (activePlanId === plan.id && activePlanShared) { + activePlanId = null; + activePlanShared = false; + } + await loadPlans(); + } + + function closePlan() { + activePlanId = null; + activePlanShared = false; + saveCollId = ''; + saveName = ''; + savePanel = false; + saveNewColName = ''; + } + // ── Helpers ──────────────────────────────────────────────────────────────── function emptyGeoJSON() { return { type: 'FeatureCollection', features: [] }; } + function fitToCoords(coords) { + if (!coords?.length || !map) return; + const lngs = coords.map(c => c[0]); + const lats = coords.map(c => c[1]); + map.fitBounds( + [[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]], + { padding: 60, maxZoom: 15 } + ); + } + + // ── GPX reference track ─────────────────────────────────────────────────── + function parseGPX(text) { + const doc = new DOMParser().parseFromString(text, 'application/xml'); + const trkpts = [...doc.querySelectorAll('trkpt')]; + if (trkpts.length < 2) return null; + const coords = trkpts.map(pt => { + const lon = parseFloat(pt.getAttribute('lon')); + const lat = parseFloat(pt.getAttribute('lat')); + const eleEl = pt.querySelector('ele'); + return [lon, lat, eleEl ? parseFloat(eleEl.textContent) : 0]; + }); + return { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} }; + } + + function onGpxFile(e) { + const file = e.target.files[0]; + if (!file) return; + e.target.value = ''; + const reader = new FileReader(); + reader.onload = ev => { + const parsed = parseGPX(ev.target.result); + if (!parsed) return; + gpxTrack = parsed; + gpxName = file.name.replace(/\.gpx$/i, ''); + if (map?.getSource('gpx-track')) map.getSource('gpx-track').setData(gpxTrack); + const lngs = parsed.geometry.coordinates.map(c => c[0]); + const lats = parsed.geometry.coordinates.map(c => c[1]); + map.fitBounds([[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]], { padding: 40 }); + }; + reader.readAsText(file); + } + + function clearGpx() { + gpxTrack = null; + gpxName = ''; + if (map?.getSource('gpx-track')) map.getSource('gpx-track').setData(emptyGeoJSON()); + } + + const SPORT_ICON = { + cycling: '🚴', running: '🏃', walking: '🚶', + hiking: '🥾', swimming: '🏊', skiing: '⛷', + }; + function sportIcon(sport) { return SPORT_ICON[sport] ?? '●'; } + function fmtDate(iso) { + return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); + } + + async function loadActivityList() { + if (activityList.length || activitiesLoading) return; + activitiesLoading = true; + try { + const r = await fetch(`${activityUrl}/api/feed`, { credentials: 'include' }); + if (r.ok) activityList = (await r.json()).activities ?? []; + } catch {} + activitiesLoading = false; + } + + async function loadActivityAsTrack(activity) { + const r = await fetch(`${activityUrl}/api/activity/${activity.id}/geojson`, { credentials: 'include' }); + if (!r.ok) return; + const geojson = await r.json(); + gpxTrack = geojson; + gpxName = activity.title; + // Auto-assign palette colour from activity year + const year = new Date(activity.started_at).getFullYear(); + const idx = GPX_PALETTE.findIndex(p => p.year === year); + gpxColorIdx = idx >= 0 ? idx : GPX_PALETTE.length - 1; + if (map?.getSource('gpx-track')) map.getSource('gpx-track').setData(gpxTrack); + const coords = geojson.geometry.coordinates; + const lngs = coords.map(c => c[0]); + const lats = coords.map(c => c[1]); + map.fitBounds([[Math.min(...lngs), Math.min(...lats)], [Math.max(...lngs), Math.max(...lats)]], { padding: 40 }); + activityPickerOpen = false; + } + + let filteredActivities = $derived.by(() => { + const q = activitySearch.trim().toLowerCase(); + if (!q) return activityList; + return activityList.filter(a => a.title.toLowerCase().includes(q)); + }); + function routeStats() { if (!route) return null; const p = route.properties ?? {}; - const dist = parseFloat(p['track-length'] ?? 0) / 1000; - const up = parseInt(p['filtered ascend'] ?? p.ascend ?? 0); - return { dist: dist.toFixed(1), up }; + if (p['track-length']) { + return { + dist: (parseFloat(p['track-length']) / 1000).toFixed(1), + up: parseInt(p['filtered ascend'] ?? p.ascend ?? 0), + }; + } + // Mixed or GPX-only route: compute from coordinates + const coords = route.geometry?.coordinates ?? []; + if (coords.length < 2) return null; + let dist = 0, up = 0; + for (let i = 1; i < coords.length; i++) { + const dx = coords[i][0] - coords[i-1][0]; + const dy = coords[i][1] - coords[i-1][1]; + dist += Math.sqrt(dx*dx + dy*dy) * 111320; + const de = (coords[i][2] ?? 0) - (coords[i-1][2] ?? 0); + if (de > 0) up += de; + } + return { dist: (dist / 1000).toFixed(1), up: Math.round(up) }; } let stats = $derived(routeStats()); + + let filteredPlans = $derived.by(() => { + if (browseCollId === '__shared__') { + const q = browseSearch.trim().toLowerCase(); + return q ? sharedPlans.filter(p => p.name.toLowerCase().includes(q)) : [...sharedPlans]; + } + let result = [...plans]; + if (browseCollId === 'unsorted') { + result = result.filter(p => !p.collection_id); + } else if (browseCollId !== null) { + result = result.filter(p => p.collection_id === browseCollId); + } + const q = browseSearch.trim().toLowerCase(); + if (q) result = result.filter(p => p.name.toLowerCase().includes(q)); + return result; + }); + + // ── Elevation ↔ map linking ──────────────────────────────────────────────── + // Cumulative distances along the route (metres), one entry per coordinate. + let routeCumDist = $derived.by(() => { + if (!route) return null; + const coords = route.geometry.coordinates; + const d = [0]; + for (let i = 1; i < coords.length; i++) { + const dx = coords[i][0] - coords[i-1][0]; + const dy = coords[i][1] - coords[i-1][1]; + d.push(d[i-1] + Math.sqrt(dx*dx + dy*dy) * 111320); + } + return d; + }); + + // Convert nearestOnLine's fractional coord index (e.g. 3.7) to a 0-1 dist fraction. + function coordFracToDistFrac(frac, coords, dists) { + if (frac >= coords.length - 1) return 1; + const i = Math.floor(frac); + const t = frac - i; + const d = dists[i] + t * (dists[i + 1] - dists[i]); + return dists[dists.length - 1] > 0 ? d / dists[dists.length - 1] : 0; + } + + // Find [lng, lat] at a given 0-1 dist fraction along the route. + function distFracToLngLat(f, coords, dists) { + const target = f * dists[dists.length - 1]; + let lo = dists.length - 2; + for (let i = 0; i < dists.length - 1; i++) { + if (dists[i + 1] >= target) { lo = i; break; } + } + const seg = dists[lo + 1] - dists[lo]; + const t = seg > 0 ? Math.min(1, (target - dists[lo]) / seg) : 0; + return [ + coords[lo][0] + t * (coords[lo + 1][0] - coords[lo][0]), + coords[lo][1] + t * (coords[lo + 1][1] - coords[lo][1]), + ]; + } + + // Called by ElevationChart when user hovers the chart — shows a dot on the map. + function onChartHover(f) { + if (f === null || !route || !routeCumDist) { chartDotMarker?.remove(); return; } + const [lng, lat] = distFracToLngLat(f, route.geometry.coordinates, routeCumDist); + chartDotMarker.setLngLat([lng, lat]).addTo(map); + }
@@ -417,6 +999,63 @@ ${trkpts}
+ +
+

Track width

+
+ + + {lineWidth} +
+
+ + +
+

Reference track

+ {#if gpxTrack} +
+ {gpxName} + +
+
+
+ + + {GPX_PALETTE[gpxColorIdx].year ?? '—'} +
+
+ + + {Math.round(gpxOpacity * 100)}% +
+ +
+ {:else} + + {#if activityAccess} + + {/if} + {/if} +
+

Waypoints — click map to add

@@ -426,8 +1065,12 @@ ${trkpts}
@@ -466,13 +1110,38 @@ ${trkpts} type="text" placeholder="Route name…" bind:value={saveName} - onkeydown={(e) => e.key === 'Enter' && savePlan()} + onkeydown={(e) => e.key === 'Enter' && saveCollId !== '__new__' && savePlan()} /> + + {#if saveCollId === '__new__'} + e.key === 'Enter' && savePlan()} + /> + {/if}
- +
{:else} @@ -483,23 +1152,27 @@ ${trkpts} {/if} - +
-

My plans {#if plansLoading}(loading…){/if}

- {#if plans.length === 0 && !plansLoading} -

No saved plans yet.

- {:else} - +
+

Plans {#if plansLoading}(…){/if}

+ +
+ {#if activePlanId} + {@const ap = activePlanShared ? sharedPlans.find(p => p.id === activePlanId) : plans.find(p => p.id === activePlanId)} +
+ + {ap?.name ?? saveName}{activePlanShared ? ' 🌐' : ''} + {#if activePlanShared} + + {/if} + +
{/if}
@@ -507,14 +1180,191 @@ ${trkpts}
- {#if route} + {#if route || gpxTrack}
- +
{/if}
+ +{#if browseOpen} + + +{/if} + + +{#if activityPickerOpen} + + +{/if} + + +{#if sharedLoadConfirm} + + +{/if} + + +{#if deleteColConfirm} + + +{/if} +