Initial scaffold: Vite + Svelte, MapLibre, Brouter routing, GPX export

This commit is contained in:
Davide Scaini
2026-05-13 22:36:53 +02:00
commit 42e7c20fc1
15 changed files with 2113 additions and 0 deletions
+66
View File
@@ -0,0 +1,66 @@
<script>
import { onMount } from 'svelte';
import Planner from './Planner.svelte';
const ACTIVITY_URL = import.meta.env.VITE_ACTIVITY_URL ?? 'https://activity.bincio.org';
let authed = $state(false);
let checking = $state(true);
onMount(async () => {
try {
const r = await fetch(`${ACTIVITY_URL}/api/me`, { credentials: 'include' });
if (r.ok) {
authed = true;
} else {
window.location.href = `${ACTIVITY_URL}/login/?next=${encodeURIComponent(window.location.href)}`;
}
} catch {
window.location.href = `${ACTIVITY_URL}/login/?next=${encodeURIComponent(window.location.href)}`;
} finally {
checking = false;
}
});
</script>
<svelte:head>
<title>Planner — Bincio</title>
</svelte:head>
{#if checking}
<div class="splash">Checking login…</div>
{:else if authed}
<Planner activityUrl={ACTIVITY_URL} />
{/if}
<style>
:global(*, *::before, *::after) { box-sizing: border-box; }
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #09090b;
color: #e4e4e7;
}
:global(:root) {
--bg: #09090b;
--bg-card: #18181b;
--bg-elevated: #27272a;
--border: #3f3f46;
--border-sub: #27272a;
--text-primary:#e4e4e7;
--text-4: #a1a1aa;
--text-5: #71717a;
--accent: #e879a0;
--accent-dim: #3d1a2b;
}
.splash {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-5);
font-size: 0.875rem;
}
</style>
+66
View File
@@ -0,0 +1,66 @@
<script>
// Renders a simple SVG elevation profile from a Brouter GeoJSON route feature.
let { route } = $props();
const PAD = { top: 8, right: 8, bottom: 20, left: 36 };
const H = 140;
let svgEl;
let W = $state(400);
$effect(() => {
if (!svgEl) return;
const ro = new ResizeObserver(e => { W = e[0].contentRect.width; });
ro.observe(svgEl);
return () => ro.disconnect();
});
let profile = $derived.by(() => {
if (!route) return null;
const coords = route.geometry?.coordinates ?? [];
if (coords.length < 2) return null;
// Build cumulative distance + elevation arrays
const pts = [];
let dist = 0;
for (let i = 0; i < coords.length; i++) {
if (i > 0) {
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; // rough metres
}
pts.push({ d: dist, e: coords[i][2] ?? 0 });
}
const totalDist = pts[pts.length - 1].d;
const eles = pts.map(p => p.e);
const minE = Math.min(...eles);
const maxE = Math.max(...eles);
const rangeE = maxE - minE || 1;
const iW = W - PAD.left - PAD.right;
const iH = H - PAD.top - PAD.bottom;
const toX = d => PAD.left + (d / totalDist) * iW;
const toY = e => PAD.top + iH - ((e - minE) / rangeE) * iH;
const polyline = pts.map(p => `${toX(p.d).toFixed(1)},${toY(p.e).toFixed(1)}`).join(' ');
const area = `${toX(0)},${(PAD.top + iH).toFixed(1)} ` + polyline + ` ${toX(totalDist)},${(PAD.top + iH).toFixed(1)}`;
return { polyline, area, minE, maxE, totalDist, iW, iH };
});
</script>
<svg bind:this={svgEl} width="100%" height={H} style="display:block">
{#if profile}
<!-- area fill -->
<polygon points={profile.area} fill="#e879a022" />
<!-- line -->
<polyline points={profile.polyline} fill="none" stroke="#e879a0" stroke-width="1.5" />
<!-- y labels -->
<text x={PAD.left - 4} y={PAD.top + 4} text-anchor="end" font-size="9" fill="#71717a">{profile.maxE.toFixed(0)}m</text>
<text x={PAD.left - 4} y={PAD.top + profile.iH} text-anchor="end" font-size="9" fill="#71717a">{profile.minE.toFixed(0)}m</text>
<!-- x label -->
<text x={PAD.left + profile.iW / 2} y={H - 4} text-anchor="middle" font-size="9" fill="#71717a">{(profile.totalDist / 1000).toFixed(1)} km</text>
{/if}
</svg>
+379
View File
@@ -0,0 +1,379 @@
<script>
import { onMount, onDestroy } from 'svelte';
import maplibregl from 'maplibre-gl';
import ElevationChart from './ElevationChart.svelte';
let { activityUrl } = $props();
// ── 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('');
const PROFILES = [
{ id: 'fastbike', label: 'Road' },
{ id: 'gravel', label: 'Gravel' },
{ id: 'mtb', label: 'MTB' },
{ id: 'trekking', label: 'Hiking' },
];
// ── Map init ───────────────────────────────────────────────────────────────
onMount(() => {
map = new maplibregl.Map({
container: mapEl,
style: {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
},
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': 3, 'line-opacity': 0.9 },
});
});
map.on('click', onMapClick);
});
onDestroy(() => { if (map) map.remove(); });
// ── Waypoint management ────────────────────────────────────────────────────
function onMapClick(e) {
addWaypoint(e.lngLat.lng, e.lngLat.lat);
}
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();
wp.lng = newLng;
wp.lat = newLat;
waypoints = waypoints; // trigger reactivity
fetchRoute();
});
waypoints = [...waypoints, wp];
fetchRoute();
}
function removeWaypoint(i) {
waypoints[i].marker.remove();
waypoints = waypoints.filter((_, idx) => idx !== i);
// renumber remaining markers
waypoints.forEach((wp, idx) => { wp.marker.getElement().textContent = idx + 1; });
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 = '';
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();
}
// ── 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 ascent'] ?? p.ascent ?? 0);
return { dist: dist.toFixed(1), up };
}
$derived: let stats = routeStats();
</script>
<div class="layout">
<!-- Sidebar -->
<aside class="sidebar">
<header class="brand">
<span class="logo"><b>Bincio</b><span class="accent">Planner</span></span>
<a href={activityUrl} class="back-link">← Activity</a>
</header>
<!-- 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}
</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; }
.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); }
: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>
+8
View File
@@ -0,0 +1,8 @@
import { mount } from 'svelte'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app'),
})
export default app