Files
bincio-planner/src/Planner.svelte
T

695 lines
24 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount, onDestroy } from 'svelte';
import maplibregl from 'maplibre-gl';
import ElevationChart from './ElevationChart.svelte';
let { activityUrl } = $props();
const API = `${import.meta.env.VITE_PLANNER_API_URL ?? ''}/api`;
// ── State ──────────────────────────────────────────────────────────────────
let mapEl;
let map;
let waypoints = $state([]); // [{lng, lat, marker}]
let profile = $state('gravel');
let route = $state(null); // GeoJSON Feature from Brouter
let loading = $state(false);
let error = $state('');
// Plans
let plans = $state([]);
let plansLoading = $state(false);
let activePlanId = $state(null); // id of currently loaded plan (null = unsaved)
let savePanel = $state(false); // show save form
let saveName = $state('');
let saving = $state(false);
// Route insertion (hover snap)
let hoveringRoute = false; // plain JS — no need to render
let snapMarker; // maplibre Marker used as snap indicator
const PROFILES = [
{ id: 'fastbike', label: 'Road' },
{ id: 'gravel', label: 'Gravel' },
{ id: 'mtb', label: 'MTB' },
{ id: 'trekking', label: 'Trekking' },
{ id: 'safety', label: 'Safety' },
];
// ── Tile layers ────────────────────────────────────────────────────────────
const TILES = {
cyclosm: {
tiles: [
'https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
'https://b.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
'https://c.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
],
attribution: '© <a href="https://www.cyclosm.org">CyclOSM</a> | © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
label: 'Cycle',
},
osm: {
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
label: 'OSM',
},
topo: {
tiles: [
'https://a.tile.opentopomap.org/{z}/{x}/{y}.png',
'https://b.tile.opentopomap.org/{z}/{x}/{y}.png',
'https://c.tile.opentopomap.org/{z}/{x}/{y}.png',
],
attribution: '© <a href="https://opentopomap.org">OpenTopoMap</a> | © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
label: 'Topo',
},
satellite: {
tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'],
attribution: '© <a href="https://www.esri.com">Esri</a> World Imagery',
label: 'Sat',
},
};
const TILE_ORDER = ['cyclosm', 'osm', 'topo', 'satellite'];
let tileLayer = $state('cyclosm');
function setTileLayer(key) {
tileLayer = key;
if (!map) return;
const src = map.getSource('base');
if (src) src.setTiles(TILES[key].tiles);
}
// ── Map init ───────────────────────────────────────────────────────────────
onMount(() => {
map = new maplibregl.Map({
container: mapEl,
style: {
version: 8,
sources: {
base: {
type: 'raster',
tiles: TILES.cyclosm.tiles,
tileSize: 256,
attribution: TILES.cyclosm.attribution,
},
},
layers: [{ id: 'base', type: 'raster', source: 'base' }],
},
center: [12, 42],
zoom: 5,
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
map.on('load', () => {
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 },
});
});
const snapEl = document.createElement('div');
snapEl.className = 'snap-marker';
snapMarker = new maplibregl.Marker({ element: snapEl, draggable: false });
map.on('click', onMapClick);
map.on('mousemove', onMapMouseMove);
loadPlans();
});
onDestroy(() => { if (map) map.remove(); });
// ── Waypoint management ────────────────────────────────────────────────────
function onMapClick(e) {
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);
}
}
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 insertion helpers ────────────────────────────────────────────────
function nearestOnLine(coords, [px, py]) {
let bestDist = Infinity, bestLng = px, bestLat = py, bestFrac = 0;
for (let i = 0; i < coords.length - 1; i++) {
const [x1, y1] = coords[i];
const [x2, y2] = coords[i + 1];
const dx = x2 - x1, dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
const t = lenSq > 0 ? Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lenSq)) : 0;
const nx = x1 + t * dx, ny = y1 + t * dy;
const d = Math.hypot(px - nx, py - ny);
if (d < bestDist) { bestDist = d; bestLng = nx; bestLat = ny; bestFrac = i + t; }
}
return { lng: bestLng, lat: bestLat, frac: bestFrac };
}
function insertionIndex(clickFrac, coords) {
const fracs = waypoints.map(wp => nearestOnLine(coords, [wp.lng, wp.lat]).frac);
for (let i = 0; i < fracs.length - 1; i++) {
if (clickFrac >= fracs[i] && clickFrac <= fracs[i + 1]) return i + 1;
}
return waypoints.length;
}
function insertWaypointAt(lng, lat, idx) {
const el = document.createElement('div');
el.className = 'wp-marker';
const marker = new maplibregl.Marker({ element: el, draggable: true })
.setLngLat([lng, lat])
.addTo(map);
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; }
activePlanId = null;
fetchRoute();
});
waypoints = [...waypoints.slice(0, idx), { lng, lat, marker }, ...waypoints.slice(idx)];
waypoints.forEach((w, i) => { w.marker.getElement().textContent = i + 1; });
activePlanId = null;
fetchRoute();
}
function addWaypoint(lng, lat) {
const el = document.createElement('div');
el.className = 'wp-marker';
el.textContent = waypoints.length + 1;
const marker = new maplibregl.Marker({ element: el, draggable: true })
.setLngLat([lng, lat])
.addTo(map);
const wp = { lng, lat, marker };
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;
}
activePlanId = null;
fetchRoute();
});
waypoints = [...waypoints, wp];
fetchRoute();
}
function removeWaypoint(i) {
waypoints[i].marker.remove();
waypoints = waypoints.filter((_, idx) => idx !== i);
waypoints.forEach((wp, idx) => { wp.marker.getElement().textContent = idx + 1; });
activePlanId = null;
fetchRoute();
}
function closeLoop() {
if (waypoints.length < 2) return;
const first = waypoints[0];
addWaypoint(first.lng, first.lat);
}
function clearAll() {
waypoints.forEach(wp => wp.marker.remove());
waypoints = [];
route = null;
error = '';
hoveringRoute = false;
snapMarker?.remove();
map.getCanvas().style.cursor = '';
if (map.getSource('route')) map.getSource('route').setData(emptyGeoJSON());
}
// ── Routing ────────────────────────────────────────────────────────────────
let routeTimer;
function fetchRoute() {
clearTimeout(routeTimer);
if (waypoints.length < 2) {
route = null;
error = '';
if (map.getSource('route')) map.getSource('route').setData(emptyGeoJSON());
return;
}
routeTimer = setTimeout(doFetchRoute, 400);
}
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());
} catch (e) {
error = e.message ?? 'Routing failed';
route = null;
} finally {
loading = false;
}
}
// Re-route when profile changes
$effect(() => { profile; fetchRoute(); });
// ── GPX export ────────────────────────────────────────────────────────────
function downloadGPX() {
if (!route) return;
const coords = route.geometry.coordinates;
const trkpts = coords.map(([lon, lat, ele]) =>
` <trkpt lat="${lat.toFixed(7)}" lon="${lon.toFixed(7)}">${ele != null ? `<ele>${ele.toFixed(1)}</ele>` : ''}</trkpt>`
).join('\n');
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="BincioPlanner" xmlns="http://www.topografix.com/GPX/1/1">
<trk><name>Planned route</name><trkseg>
${trkpts}
</trkseg></trk>
</gpx>`;
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([gpx], { type: 'application/gpx+xml' }));
a.download = 'route.gpx';
a.click();
}
// ── Plans API ─────────────────────────────────────────────────────────────
async function loadPlans() {
plansLoading = true;
try {
const r = await fetch(`${API}/plans`, { credentials: 'include' });
if (r.ok) plans = (await r.json()).plans ?? [];
} catch {}
plansLoading = false;
}
async function savePlan() {
if (!saveName.trim() || waypoints.length < 2) return;
saving = true;
const body = {
name: saveName.trim(),
waypoints: waypoints.map(({ lng, lat }) => ({ lng, lat })),
profile,
geojson: route ?? undefined,
};
try {
if (activePlanId) {
await fetch(`${API}/plans/${activePlanId}`, {
method: 'PUT', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} else {
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;
}
savePanel = false;
await loadPlans();
} catch {}
saving = false;
}
async function loadPlan(plan) {
clearAll();
profile = plan.profile ?? 'gravel';
activePlanId = plan.id;
saveName = plan.name;
// If the full plan (with geojson) is needed, fetch it; list endpoint omits geojson.
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);
if (full.geojson) {
route = full.geojson;
if (map.getSource('route')) map.getSource('route').setData(route);
}
}
async function deletePlan(plan, e) {
e.stopPropagation();
await fetch(`${API}/plans/${plan.id}`, { method: 'DELETE', credentials: 'include' });
if (activePlanId === plan.id) activePlanId = null;
await loadPlans();
}
// ── Helpers ────────────────────────────────────────────────────────────────
function emptyGeoJSON() {
return { type: 'FeatureCollection', features: [] };
}
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 };
}
let stats = $derived(routeStats());
</script>
<div class="layout">
<!-- Sidebar -->
<aside class="sidebar">
<header class="brand">
<span class="logo">Bincio<span class="accent">Planner</span></span>
<a href={activityUrl} class="back-link">← Activity</a>
</header>
<!-- Map layer selector -->
<section class="section">
<p class="label">Map</p>
<div class="pills">
{#each TILE_ORDER as key}
<button
class="pill"
class:active={tileLayer === key}
onclick={() => setTileLayer(key)}
>{TILES[key].label}</button>
{/each}
</div>
</section>
<!-- Profile selector -->
<section class="section">
<p class="label">Profile</p>
<div class="pills">
{#each PROFILES as p}
<button
class="pill"
class:active={profile === p.id}
onclick={() => { profile = p.id; }}
>{p.label}</button>
{/each}
</div>
</section>
<!-- Waypoints -->
<section class="section">
<p class="label">Waypoints <span class="muted">— click map to add</span></p>
{#if waypoints.length === 0}
<p class="muted small">No waypoints yet.</p>
{:else}
<ul class="wp-list">
{#each waypoints as wp, i}
<li>
<span class="wp-num">{i + 1}</span>
<span class="wp-coord">{wp.lat.toFixed(4)}, {wp.lng.toFixed(4)}</span>
<button class="remove-btn" onclick={() => removeWaypoint(i)} title="Remove">×</button>
</li>
{/each}
</ul>
{/if}
<div class="wp-actions">
<button class="btn-secondary" onclick={closeLoop} disabled={waypoints.length < 2}>Close loop</button>
<button class="btn-secondary danger" onclick={clearAll} disabled={waypoints.length === 0}>Clear all</button>
</div>
</section>
<!-- Route stats -->
{#if route}
{@const s = routeStats()}
<section class="section">
<p class="label">Route</p>
<div class="stats">
<div class="stat"><span class="stat-val">{s.dist} km</span><span class="muted">distance</span></div>
<div class="stat"><span class="stat-val">{s.up} m</span><span class="muted">elevation</span></div>
</div>
<button class="btn-primary" onclick={downloadGPX}>Download GPX</button>
</section>
{/if}
{#if loading}<p class="status">Routing…</p>{/if}
{#if error}<p class="status error">{error}</p>{/if}
<!-- Save panel -->
{#if route && waypoints.length >= 2}
<section class="section">
<p class="label">Save route</p>
{#if savePanel}
<div class="save-form">
<input
class="save-input"
type="text"
placeholder="Route name…"
bind:value={saveName}
onkeydown={(e) => e.key === 'Enter' && savePlan()}
/>
<div class="wp-actions">
<button class="btn-primary" onclick={savePlan} disabled={saving || !saveName.trim()}>
{saving ? 'Saving…' : (activePlanId ? 'Update' : 'Save')}
</button>
<button class="btn-secondary" onclick={() => savePanel = false}>Cancel</button>
</div>
</div>
{:else}
<button class="btn-secondary full-width" onclick={() => { savePanel = true; }}>
{activePlanId ? '✎ Update plan' : '+ Save plan'}
</button>
{/if}
</section>
{/if}
<!-- Plans list -->
<section class="section">
<p class="label">My plans {#if plansLoading}<span class="muted">(loading…)</span>{/if}</p>
{#if plans.length === 0 && !plansLoading}
<p class="muted small">No saved plans yet.</p>
{:else}
<ul class="plan-list">
{#each plans as plan}
<li class="plan-item" class:active={activePlanId === plan.id}>
<button class="plan-load-btn" onclick={() => loadPlan(plan)}>
<span class="plan-name">{plan.name}</span>
<span class="plan-meta">{plan.waypoints?.length ?? '?'} pts · {plan.profile}</span>
</button>
<button class="remove-btn" onclick={(e) => deletePlan(plan, e)} title="Delete">×</button>
</li>
{/each}
</ul>
{/if}
</section>
</aside>
<!-- Map + elevation -->
<main class="map-area">
<div class="map-wrap" bind:this={mapEl}></div>
{#if route}
<div class="elevation-wrap">
<ElevationChart {route} />
</div>
{/if}
</main>
</div>
<style>
.layout {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 260px;
flex-shrink: 0;
background: var(--bg-card);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 0 0 1rem;
}
.brand {
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.logo { font-size: 1rem; font-weight: 700; letter-spacing: -0.02em; color: #fff; }
.accent { color: var(--accent); }
.back-link { font-size: 0.75rem; color: var(--text-5); text-decoration: none; }
.back-link:hover { color: var(--text-4); }
.section {
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--border-sub);
}
.label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-5);
margin: 0 0 0.5rem;
}
.pills { display: flex; gap: 0.375rem; flex-wrap: wrap; }
.pill {
padding: 0.25rem 0.625rem;
border-radius: 999px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-4);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
.pill:hover { border-color: var(--accent); color: var(--accent); }
.pill.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
.wp-list { list-style: none; margin: 0 0 0.5rem; padding: 0; display: flex; flex-direction: column; gap: 0.25rem; }
.wp-list li { display: flex; align-items: center; gap: 0.375rem; font-size: 0.75rem; }
.wp-num { width: 1.25rem; height: 1.25rem; border-radius: 50%; background: var(--accent); color: #000; font-size: 0.65rem; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.wp-coord { flex: 1; color: var(--text-4); font-variant-numeric: tabular-nums; }
.remove-btn { background: none; border: none; color: var(--text-5); cursor: pointer; font-size: 1rem; line-height: 1; padding: 0 0.125rem; }
.remove-btn:hover { color: #f87171; }
.wp-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
.btn-secondary {
flex: 1;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid var(--border);
background: transparent;
color: var(--text-4);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
.btn-secondary:hover:not(:disabled) { border-color: var(--text-4); color: var(--text-primary); }
.btn-secondary.danger:hover:not(:disabled) { border-color: #f87171; color: #f87171; }
.btn-secondary:disabled { opacity: 0.4; cursor: default; }
.btn-primary {
width: 100%;
margin-top: 0.625rem;
padding: 0.5rem;
border-radius: 0.375rem;
border: none;
background: var(--accent);
color: #000;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn-primary:hover { opacity: 0.85; }
.stats { display: flex; gap: 1rem; margin-bottom: 0.25rem; }
.stat { display: flex; flex-direction: column; }
.stat-val { font-size: 0.9rem; font-weight: 600; color: var(--text-primary); }
.status { font-size: 0.75rem; color: var(--text-5); padding: 0.5rem 1rem 0; margin: 0; }
.status.error { color: #f87171; }
.muted { color: var(--text-5); }
.small { font-size: 0.75rem; margin: 0; }
.map-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.map-wrap { flex: 1; }
.elevation-wrap { height: 140px; flex-shrink: 0; background: var(--bg-card); border-top: 1px solid var(--border); }
.save-form { display: flex; flex-direction: column; gap: 0.5rem; }
.save-input {
width: 100%;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text-primary);
font-size: 0.8rem;
box-sizing: border-box;
}
.save-input:focus { outline: none; border-color: var(--accent); }
.full-width { width: 100%; }
.plan-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.25rem; }
.plan-item {
display: flex;
align-items: center;
border-radius: 0.375rem;
border: 1px solid transparent;
position: relative;
}
.plan-item:hover { background: var(--bg-elevated); border-color: var(--border); }
.plan-item.active { background: var(--accent-dim); border-color: var(--accent); }
.plan-load-btn {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.4rem 0.5rem;
background: none;
border: none;
text-align: left;
cursor: pointer;
min-width: 0;
}
.plan-name { font-size: 0.8rem; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.plan-meta { font-size: 0.7rem; color: var(--text-5); }
:global(.snap-marker) {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2.5px solid var(--accent);
background: rgba(232, 121, 160, 0.25);
box-shadow: 0 0 0 2px rgba(232, 121, 160, 0.15);
pointer-events: none;
}
:global(.wp-marker) {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--accent);
color: #000;
font-size: 0.65rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
}
</style>