Initial scaffold: Vite + Svelte, MapLibre, Brouter routing, GPX export
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { mount } from 'svelte'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app'),
|
||||
})
|
||||
|
||||
export default app
|
||||
Reference in New Issue
Block a user