695 lines
24 KiB
Svelte
695 lines
24 KiB
Svelte
<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>
|