banyaro/backend/static/js/pages/routes.js
rene b9df636535 Sprint 6: Karte / Orte / Routen mit GPS-Aufzeichnung
- backend/routes/places.py: CRUD für hundefreundliche Orte (6 Typen)
- backend/routes/routen.py: CRUD für Gassi-Routen mit GPS-Track (JSON)
- main.py: beide Router eingehängt (/api/places, /api/routes)
- api.js: places + routes erweitert (list, update, delete)
- pages/places.js: Karte + Liste, Typ-Filter, Ort anlegen/bearbeiten
- pages/routes.js: Routen entdecken + GPS-Aufzeichnung mit Stoppuhr
- pages/map.js: zentrale Übersichtskarte (Orte + Giftköder, Layer-Toggle)
- components.css: Styles für alle drei neuen Seiten
- sw.js: by-v19 → by-v20
2026-04-14 06:03:37 +02:00

583 lines
23 KiB
JavaScript
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.

/* ============================================================
BAN YARO — Gassi-Routen
Routen entdecken (Karte + Liste) + GPS-Aufzeichnung
============================================================ */
window.Page_routes = (() => {
let _container = null;
let _appState = null;
let _map = null;
let _polylines = [];
let _data = [];
let _activeTab = 'entdecken'; // 'entdecken' | 'aufzeichnen'
let _leafletLoaded = false;
let _userPos = null;
// Aufzeichnung
let _recording = false;
let _watchId = null;
let _track = []; // [{lat, lon}]
let _distanceKm = 0;
let _startTime = null;
let _timerInt = null;
let _recPolyline = null;
let _recMarker = null;
const SCHWIERIGKEIT = ['leicht', 'mittel', 'anspruchsvoll'];
const UNTERGRUND = { wald: '🌲 Wald', asphalt: '🛣️ Asphalt', wiese: '🌿 Wiese', mix: '🔀 Mix' };
function _esc(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _haversine(lat1, lon1, lat2, lon2) {
const R = 6371000;
const p1 = lat1 * Math.PI / 180, p2 = lat2 * Math.PI / 180;
const dp = (lat2 - lat1) * Math.PI / 180;
const dl = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dp/2)**2 + Math.cos(p1)*Math.cos(p2)*Math.sin(dl/2)**2;
return 2 * R * Math.asin(Math.sqrt(a));
}
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
_loadData();
try { _userPos = await API.getLocation(); } catch {}
}
function refresh() { _loadData(); }
function onDogChange() {}
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="routes-layout">
<!-- Tabs -->
<div class="routes-tabs" id="routes-tabs">
<button class="routes-tab active" data-tab="entdecken">🥾 Routen entdecken</button>
<button class="routes-tab" data-tab="aufzeichnen">🔴 Aufzeichnen</button>
</div>
<!-- TAB: Entdecken -->
<div id="tab-entdecken" class="routes-tab-content">
<div id="routes-map" class="routes-map"></div>
<div id="routes-list" class="routes-list">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">Lädt…</p>
</div>
</div>
<!-- TAB: Aufzeichnen -->
<div id="tab-aufzeichnen" class="routes-tab-content" style="display:none">
<div id="rec-map" class="routes-map"></div>
<div class="routes-rec-panel" id="rec-panel">
<div class="routes-rec-stats" id="rec-stats" style="display:none">
<div class="routes-rec-stat">
<span class="routes-rec-stat-val" id="rec-dist">0.00</span>
<span class="routes-rec-stat-lbl">km</span>
</div>
<div class="routes-rec-stat">
<span class="routes-rec-stat-val" id="rec-time">00:00</span>
<span class="routes-rec-stat-lbl">Zeit</span>
</div>
<div class="routes-rec-stat">
<span class="routes-rec-stat-val" id="rec-pts">0</span>
<span class="routes-rec-stat-lbl">Punkte</span>
</div>
</div>
<div id="rec-idle">
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4);text-align:center">
Starte die Aufzeichnung und geh mit deinem Hund los.<br>
Der GPS-Track wird automatisch gespeichert.
</p>
<button class="btn btn-primary btn-lg" id="rec-start-btn" style="width:100%">
🔴 Aufzeichnung starten
</button>
</div>
<div id="rec-active" style="display:none">
<div style="display:flex;gap:var(--space-3)">
<button class="btn btn-secondary flex-1" id="rec-pause-btn">⏸ Pause</button>
<button class="btn btn-danger flex-1" id="rec-stop-btn">⏹ Stop & Speichern</button>
</div>
</div>
</div>
</div>
</div>
`;
// Tab-Switching
document.getElementById('routes-tabs').addEventListener('click', e => {
const btn = e.target.closest('.routes-tab');
if (!btn) return;
_switchTab(btn.dataset.tab);
});
document.getElementById('rec-start-btn')?.addEventListener('click', _startRecording);
_loadLeaflet().then(() => {
_initDiscoverMap();
_initRecordMap();
});
}
function _switchTab(tab) {
_activeTab = tab;
document.querySelectorAll('.routes-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === tab));
document.getElementById('tab-entdecken').style.display = tab === 'entdecken' ? '' : 'none';
document.getElementById('tab-aufzeichnen').style.display = tab === 'aufzeichnen' ? '' : 'none';
if (tab === 'entdecken' && _map) setTimeout(() => _map.invalidateSize(), 50);
if (tab === 'aufzeichnen' && _recMap) setTimeout(() => _recMap.invalidateSize(), 50);
}
// ----------------------------------------------------------
// Leaflet laden
// ----------------------------------------------------------
async function _loadLeaflet() {
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/css/leaflet.css';
document.head.appendChild(link);
await new Promise(resolve => {
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
document.head.appendChild(s);
});
_leafletLoaded = true;
}
// ----------------------------------------------------------
// Entdecken-Karte
// ----------------------------------------------------------
function _initDiscoverMap() {
const el = document.getElementById('routes-map');
if (!el || !window.L || _map) return;
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515];
const zoom = _userPos ? 12 : 6;
_map = L.map('routes-map', { zoomControl: true, attributionControl: false })
.setView(center, zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
_renderPolylines();
}
let _recMap = null;
function _initRecordMap() {
const el = document.getElementById('rec-map');
if (!el || !window.L || _recMap) return;
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515];
_recMap = L.map('rec-map', { zoomControl: false, attributionControl: false })
.setView(center, _userPos ? 15 : 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_recMap);
}
// ----------------------------------------------------------
// Daten laden
// ----------------------------------------------------------
async function _loadData() {
try {
_data = await API.routes.list();
_renderList();
_renderPolylines();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Laden der Routen.');
}
}
// ----------------------------------------------------------
// Polylines auf Entdecken-Karte
// ----------------------------------------------------------
function _renderPolylines() {
if (!_map || !window.L) return;
_polylines.forEach(p => p.remove());
_polylines = [];
// Nur Routen mit bekanntem Startpunkt (List-Response hat start_lat/lon)
_data.forEach((route, i) => {
if (!route.start_lat) return;
// Startpunkt-Marker
const colors = ['#C4843A','#3B82F6','#22C55E','#EF4444','#8B5CF6','#F97316'];
const color = colors[i % colors.length];
const icon = L.divIcon({
className: '',
html: `<div style="background:${color};color:#fff;font-size:11px;font-weight:700;
width:28px;height:28px;border-radius:50%;display:flex;align-items:center;
justify-content:center;box-shadow:0 2px 5px rgba(0,0,0,0.3)">${i + 1}</div>`,
iconSize: [28, 28], iconAnchor: [14, 14],
});
const m = L.marker([route.start_lat, route.start_lon], { icon })
.addTo(_map)
.on('click', () => _openDetail(route.id));
_polylines.push(m);
});
}
// ----------------------------------------------------------
// Listen-Karten
// ----------------------------------------------------------
function _renderList() {
const list = document.getElementById('routes-list');
if (!list) return;
if (!_data.length) {
list.innerHTML = `<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">
Noch keine Routen vorhanden.<br>
<button class="btn btn-primary btn-sm" style="margin-top:var(--space-3)"
onclick="document.querySelector('[data-tab=aufzeichnen]').click()">
Erste Route aufzeichnen
</button>
</p>`;
return;
}
list.innerHTML = _data.map((r, i) => _routeCardHTML(r, i)).join('');
list.querySelectorAll('.routes-card').forEach(card => {
card.addEventListener('click', () => _openDetail(parseInt(card.dataset.id)));
});
}
function _routeCardHTML(r, i) {
const colors = ['#C4843A','#3B82F6','#22C55E','#EF4444','#8B5CF6','#F97316'];
const color = colors[i % colors.length];
const schwTag = r.schwierigkeit
? `<span class="routes-badge routes-badge--${r.schwierigkeit}">${r.schwierigkeit}</span>` : '';
const ug = r.untergrund ? (UNTERGRUND[r.untergrund] || r.untergrund) : null;
return `
<div class="routes-card" data-id="${r.id}" style="--route-color:${color}">
<div class="routes-card-num" style="background:${color}">${i + 1}</div>
<div class="routes-card-body">
<div class="routes-card-name">${_esc(r.name)}</div>
<div class="routes-card-meta">
${r.distanz_km ? `🗺️ ${r.distanz_km.toFixed(1)} km` : ''}
${r.dauer_min ? `· ⏱ ${_formatDuration(r.dauer_min)}` : ''}
${ug ? `· ${ug}` : ''}
</div>
<div class="routes-card-tags">
${schwTag}
${r.schatten ? '<span class="routes-badge">🌳 Schatten</span>' : ''}
${r.leine_empfohlen ? '<span class="routes-badge">🔗 Leine empfohlen</span>' : ''}
</div>
<div style="color:var(--c-text-muted);font-size:0.75rem;margin-top:var(--space-1)">
von ${_esc(r.user_name || 'Unbekannt')}
</div>
</div>
</div>`;
}
function _formatDuration(min) {
if (min < 60) return `${min} min`;
return `${Math.floor(min / 60)}h ${min % 60 ? (min % 60) + 'min' : ''}`.trim();
}
// ----------------------------------------------------------
// Detail-Modal mit vollständiger Polyline
// ----------------------------------------------------------
async function _openDetail(routeId) {
let route;
try {
route = await API.routes.get(routeId);
} catch (err) {
UI.toast.error(err.message);
return;
}
const isOwn = _appState.user?.id === route.user_id;
const ug = route.untergrund ? (UNTERGRUND[route.untergrund] || route.untergrund) : null;
const track = route.gps_track || [];
// Mini-Map als div mit ID, wird nach open() befüllt
const body = `
<div id="route-detail-map" style="height:220px;border-radius:var(--radius-md);margin-bottom:var(--space-4);background:var(--c-surface-2)"></div>
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-3)">
${route.distanz_km ? `<span class="routes-badge routes-badge--info">🗺️ ${route.distanz_km.toFixed(2)} km</span>` : ''}
${route.dauer_min ? `<span class="routes-badge routes-badge--info">⏱ ${_formatDuration(route.dauer_min)}</span>` : ''}
${route.schwierigkeit ? `<span class="routes-badge routes-badge--${route.schwierigkeit}">${route.schwierigkeit}</span>` : ''}
${ug ? `<span class="routes-badge">${ug}</span>` : ''}
${route.schatten ? '<span class="routes-badge">🌳 Schatten</span>' : ''}
${route.leine_empfohlen ? '<span class="routes-badge">🔗 Leine empfohlen</span>' : ''}
</div>
${route.beschreibung ? `<p style="color:var(--c-text-secondary)">${_esc(route.beschreibung)}</p>` : ''}
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-3)">
${track.length} GPS-Punkte · von ${_esc(route.user_name || 'Unbekannt')}
</p>
`;
const footer = isOwn ? `
<button type="button" class="btn btn-secondary flex-1" id="rd-close">Schließen</button>
<button type="button" class="btn btn-ghost btn-sm" id="rd-delete" style="color:var(--c-danger)">Löschen</button>
` : `
<button type="button" class="btn btn-primary flex-1" id="rd-close">Schließen</button>
`;
UI.modal.open({ title: `🥾 ${route.name}`, body, footer });
document.getElementById('rd-close')?.addEventListener('click', UI.modal.close);
document.getElementById('rd-delete')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Route löschen?', message: `${route.name}" wird dauerhaft entfernt.`,
confirmText: 'Löschen', danger: true,
});
if (!ok) return;
try {
await API.routes.delete(route.id);
_data = _data.filter(r => r.id !== route.id);
UI.modal.close();
_renderList();
_renderPolylines();
UI.toast.success('Route gelöscht.');
} catch (err) { UI.toast.error(err.message); }
});
// Mini-Map mit Polyline nach DOM-Einfüge
setTimeout(() => {
const el = document.getElementById('route-detail-map');
if (!el || !window.L || !track.length) return;
const detailMap = L.map('route-detail-map', { zoomControl: false, attributionControl: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(detailMap);
const latlngs = track.map(p => [p.lat, p.lon]);
const polyline = L.polyline(latlngs, { color: '#C4843A', weight: 4, opacity: 0.85 }).addTo(detailMap);
// Start/End-Marker
L.circleMarker(latlngs[0], { radius: 7, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1 }).addTo(detailMap);
L.circleMarker(latlngs[latlngs.length-1], { radius: 7, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1 }).addTo(detailMap);
detailMap.fitBounds(polyline.getBounds(), { padding: [10, 10] });
}, 100);
}
// ----------------------------------------------------------
// GPS-AUFZEICHNUNG
// ----------------------------------------------------------
function _startRecording() {
if (!_appState.user) {
UI.toast.warning('Bitte zuerst anmelden.');
App.navigate('settings');
return;
}
if (!navigator.geolocation) {
UI.toast.error('GPS wird von diesem Browser nicht unterstützt.');
return;
}
_recording = true;
_track = [];
_distanceKm = 0;
_startTime = Date.now();
// UI umschalten
document.getElementById('rec-idle').style.display = 'none';
document.getElementById('rec-active').style.display = '';
document.getElementById('rec-stats').style.display = '';
// Stopuhr
_timerInt = setInterval(_updateRecTimer, 1000);
// GPS-Tracking
_watchId = navigator.geolocation.watchPosition(
pos => _onGpsPoint(pos.coords.latitude, pos.coords.longitude),
err => UI.toast.warning('GPS-Fehler: ' + err.message),
{ enableHighAccuracy: true, maximumAge: 0, timeout: 15000 }
);
// Buttons binden
document.getElementById('rec-stop-btn').onclick = _stopRecording;
document.getElementById('rec-pause-btn').onclick = _togglePause;
UI.toast.success('Aufzeichnung gestartet — los geht\'s!');
}
let _paused = false;
function _togglePause() {
_paused = !_paused;
const btn = document.getElementById('rec-pause-btn');
if (btn) btn.textContent = _paused ? '▶ Weiter' : '⏸ Pause';
if (_paused && _timerInt) clearInterval(_timerInt);
if (!_paused) _timerInt = setInterval(_updateRecTimer, 1000);
}
function _onGpsPoint(lat, lon) {
if (_paused) return;
const pt = { lat, lon };
if (_track.length > 0) {
const prev = _track[_track.length - 1];
_distanceKm += _haversine(prev.lat, prev.lon, lat, lon) / 1000;
}
_track.push(pt);
_updateRecStats();
_updateRecMap(lat, lon);
}
function _updateRecMap(lat, lon) {
if (!_recMap || !window.L) return;
const latlng = [lat, lon];
if (!_recPolyline) {
_recPolyline = L.polyline([latlng], { color: '#EF4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
} else {
_recPolyline.addLatLng(latlng);
}
if (!_recMarker) {
_recMarker = L.circleMarker(latlng, {
radius: 8, color: '#EF4444', fillColor: '#fff', fillOpacity: 1, weight: 3,
}).addTo(_recMap);
} else {
_recMarker.setLatLng(latlng);
}
_recMap.setView(latlng);
}
function _updateRecStats() {
const dist = document.getElementById('rec-dist');
const pts = document.getElementById('rec-pts');
if (dist) dist.textContent = _distanceKm.toFixed(2);
if (pts) pts.textContent = _track.length;
}
function _updateRecTimer() {
const el = document.getElementById('rec-time');
if (!el) return;
const secs = Math.floor((Date.now() - _startTime) / 1000);
const mm = String(Math.floor(secs / 60)).padStart(2, '0');
const ss = String(secs % 60).padStart(2, '0');
el.textContent = `${mm}:${ss}`;
}
function _stopRecording() {
if (_watchId !== null) { navigator.geolocation.clearWatch(_watchId); _watchId = null; }
if (_timerInt) { clearInterval(_timerInt); _timerInt = null; }
_recording = false;
_paused = false;
if (_track.length < 2) {
UI.toast.warning('Zu wenige GPS-Punkte — bitte etwas länger gehen.');
_resetRecUI();
return;
}
const dauer = Math.floor((Date.now() - _startTime) / 1000 / 60);
_showSaveForm(_track, _distanceKm, dauer);
}
function _resetRecUI() {
document.getElementById('rec-idle').style.display = '';
document.getElementById('rec-active').style.display = 'none';
document.getElementById('rec-stats').style.display = 'none';
if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; }
if (_recMarker) { _recMarker.remove(); _recMarker = null; }
_track = []; _distanceKm = 0;
}
// ----------------------------------------------------------
// Speicher-Formular nach Aufzeichnung
// ----------------------------------------------------------
function _showSaveForm(track, distKm, dauMin) {
const schwOpts = SCHWIERIGKEIT
.map(s => `<option value="${s}">${s.charAt(0).toUpperCase() + s.slice(1)}</option>`)
.join('');
const ugOpts = Object.entries(UNTERGRUND)
.map(([v, l]) => `<option value="${v}">${l}</option>`)
.join('');
const body = `
<p style="color:var(--c-text-secondary);margin-bottom:var(--space-4)">
🎉 ${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min
</p>
<form id="route-save-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Name der Route *</label>
<input class="form-control" type="text" name="name"
placeholder="z. B. Waldspaziergang am See" required>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="form-group">
<label class="form-label">Schwierigkeit</label>
<select class="form-control" name="schwierigkeit">${schwOpts}</select>
</div>
<div class="form-group">
<label class="form-label">Untergrund</label>
<select class="form-control" name="untergrund">
<option value=""> unbekannt </option>
${ugOpts}
</select>
</div>
</div>
<div class="form-group" style="display:flex;gap:var(--space-4)">
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="schatten"> 🌳 Viel Schatten
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="leine_empfohlen"> 🔗 Leine empfohlen
</label>
</div>
<div class="form-group">
<label class="form-label">Beschreibung <span style="color:var(--c-text-secondary)">(optional)</span></label>
<textarea class="form-control" name="beschreibung" rows="2"
placeholder="Besonderheiten, Highlights, Tipps…"></textarea>
</div>
</form>
`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" id="rs-discard">Verwerfen</button>
<button type="submit" form="route-save-form" class="btn btn-primary flex-1">💾 Route speichern</button>
`;
UI.modal.open({ title: '🥾 Route benennen', body, footer });
document.getElementById('rs-discard')?.addEventListener('click', () => {
UI.modal.close();
_resetRecUI();
});
document.getElementById('route-save-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="route-save-form"][type="submit"]') || e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
await UI.asyncButton(btn, async () => {
const payload = {
name: fd.name?.trim(),
beschreibung: fd.beschreibung || null,
gps_track: track,
distanz_km: Math.round(distKm * 100) / 100,
dauer_min: dauMin,
schwierigkeit: fd.schwierigkeit || 'leicht',
untergrund: fd.untergrund || null,
schatten: 'schatten' in fd,
leine_empfohlen: 'leine_empfohlen' in fd,
};
const saved = await API.routes.create(payload);
// Für die Liste: start_lat/lon aus Track ergänzen
saved.start_lat = track[0].lat;
saved.start_lon = track[0].lon;
_data.unshift(saved);
UI.modal.close();
_resetRecUI();
_renderList();
_renderPolylines();
_switchTab('entdecken');
UI.toast.success(`Route „${saved.name}" gespeichert! 🎉`);
});
});
}
return { init, refresh, onDogChange };
})();