Sprint 11: Freunde & Chat + Phosphor-Icon-Vollmigration
- Freundschaften (pending/accepted), Nutzersuche, Anfragen per Push - Direktnachrichten mit Polling, iMessage-Stil, Deep-Links aus Push - Alle Seiten (map, places, diary, health, dog-profile, sitting, knigge, forum, wiki, walks) vollständig auf Phosphor-Icons migriert - Wikidata-Rassen-Scraper (~833 neue Rassen, lokal gespiegelte Fotos) - TheDogAPI lokal gespiegelt (169 Rassen + Fotos) - Quiz-Result-Cards horizontal (korrekte Bildproportionen) - SW by-v89
This commit is contained in:
parent
96bd57f0ad
commit
097295c628
44 changed files with 9980 additions and 300 deletions
|
|
@ -17,6 +17,11 @@ window.Page_routes = (() => {
|
|||
let _sortBy = 'newest';
|
||||
let _onlyMine = false;
|
||||
|
||||
// Ansichts-Modus: 'list' | 'map'
|
||||
let _viewMode = 'list';
|
||||
let _searchMap = null; // L.map Instanz der Suchkarte
|
||||
let _searchLines = new Map(); // routeId → { line, route }
|
||||
|
||||
// Mini-Karten auf den Route-Cards
|
||||
let _miniMaps = new Map(); // routeId → L.map
|
||||
let _leafletReady = false;
|
||||
|
|
@ -74,11 +79,15 @@ window.Page_routes = (() => {
|
|||
<div class="rk-search-row">
|
||||
<input class="rk-search" id="rk-search" type="search"
|
||||
placeholder="🔍 Route suchen…" autocomplete="off">
|
||||
<div class="rk-view-toggle">
|
||||
<button class="rk-view-btn${_viewMode==='list'?' active':''}" id="rk-view-list" title="Liste">${UI.icon('list')}</button>
|
||||
<button class="rk-view-btn${_viewMode==='map'?' active':''}" id="rk-view-map" title="Karte">${UI.icon('map-trifold')}</button>
|
||||
</div>
|
||||
<label class="btn btn-secondary btn-sm rk-imp-btn" title="GPX / KML / TCX importieren">
|
||||
📥 Import
|
||||
${UI.icon('download-simple')} Import
|
||||
<input type="file" id="rk-import-input" accept=".gpx,.kml,.tcx" style="display:none">
|
||||
</label>
|
||||
<button class="btn btn-primary btn-sm rk-rec-btn" id="rk-rec-btn">🔴 Aufzeichnen</button>
|
||||
<button class="btn btn-primary btn-sm rk-rec-btn" id="rk-rec-btn">${UI.icon('path')} Aufzeichnen</button>
|
||||
</div>
|
||||
<div class="rk-filters" id="rk-filters">
|
||||
<div class="rk-filter-group">
|
||||
|
|
@ -114,6 +123,8 @@ window.Page_routes = (() => {
|
|||
document.getElementById('rk-search').addEventListener('input', e => {
|
||||
_search = e.target.value.toLowerCase(); _applyFilter();
|
||||
});
|
||||
document.getElementById('rk-view-list').addEventListener('click', () => _switchView('list'));
|
||||
document.getElementById('rk-view-map').addEventListener('click', () => _switchView('map'));
|
||||
document.getElementById('rk-rec-btn').addEventListener('click', () => {
|
||||
App.navigate('map');
|
||||
setTimeout(() => window.Page_map?.startRecording?.(), 600);
|
||||
|
|
@ -138,6 +149,177 @@ window.Page_routes = (() => {
|
|||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// View-Toggle
|
||||
// ----------------------------------------------------------
|
||||
function _switchView(mode) {
|
||||
_viewMode = mode;
|
||||
document.getElementById('rk-view-list')?.classList.toggle('active', mode === 'list');
|
||||
document.getElementById('rk-view-map')?.classList.toggle('active', mode === 'map');
|
||||
|
||||
const layout = document.querySelector('.rk-layout');
|
||||
const grid = document.getElementById('rk-grid');
|
||||
|
||||
if (mode === 'map') {
|
||||
if (grid) grid.style.display = 'none';
|
||||
|
||||
// Alten Map-Container entfernen falls vorhanden
|
||||
document.getElementById('rk-map-section')?.remove();
|
||||
if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); }
|
||||
|
||||
// Als fixed Overlay direkt in <body> — kein Konflikt mit .rk-layout overflow:hidden
|
||||
const mapH = window.innerHeight - 160;
|
||||
const sec = document.createElement('div');
|
||||
sec.id = 'rk-map-section';
|
||||
sec.className = 'rk-map-section';
|
||||
sec.innerHTML = `
|
||||
<div class="rk-map-bar">
|
||||
<input class="rk-map-loc-input" id="rk-map-loc" type="search"
|
||||
placeholder="🔍 Ort suchen…" autocomplete="off">
|
||||
<button class="btn btn-secondary btn-sm" id="rk-map-gps" title="Mein Standort">📍</button>
|
||||
</div>
|
||||
<div id="rk-search-map" style="height:${mapH}px;width:100%"></div>
|
||||
<div id="rk-map-hint" class="rk-map-hint">Route antippen um Details zu sehen</div>
|
||||
`;
|
||||
document.body.appendChild(sec);
|
||||
|
||||
// Wie _initMiniMaps: pollen bis window.L bereit ist
|
||||
_pollAndInitSearchMap();
|
||||
|
||||
} else {
|
||||
document.getElementById('rk-map-section')?.remove();
|
||||
if (_searchMap) { _searchMap.remove(); _searchMap = null; _searchLines.clear(); }
|
||||
if (grid) grid.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Suchkarte
|
||||
// ----------------------------------------------------------
|
||||
function _pollAndInitSearchMap() {
|
||||
if (window.L) { _initSearchMap(); return; }
|
||||
let tries = 0;
|
||||
const poll = setInterval(() => {
|
||||
if (window.L || ++tries > 40) {
|
||||
clearInterval(poll);
|
||||
if (window.L) _initSearchMap();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function _initSearchMap() {
|
||||
if (!document.getElementById('rk-search-map')) return;
|
||||
|
||||
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1, 10.4];
|
||||
const zoom = _userPos ? 13 : 6;
|
||||
|
||||
_searchMap = L.map('rk-search-map', { zoomControl: true, attributionControl: false })
|
||||
.setView(center, zoom);
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_searchMap);
|
||||
setTimeout(() => _searchMap?.invalidateSize(), 100);
|
||||
setTimeout(() => _searchMap?.invalidateSize(), 600);
|
||||
_renderRoutesOnMap();
|
||||
|
||||
// Standort-Button
|
||||
document.getElementById('rk-map-gps')?.addEventListener('click', async () => {
|
||||
try {
|
||||
const pos = await API.getLocation();
|
||||
_userPos = pos;
|
||||
_searchMap.setView([pos.lat, pos.lon], 14);
|
||||
} catch { UI.toast.warning('Standort nicht verfügbar.'); }
|
||||
});
|
||||
|
||||
// Geocoding-Suche
|
||||
const locInput = document.getElementById('rk-map-loc');
|
||||
let _geoDebounce;
|
||||
locInput?.addEventListener('keydown', e => {
|
||||
if (e.key !== 'Enter') return;
|
||||
clearTimeout(_geoDebounce);
|
||||
_geocodeAndFly(locInput.value.trim());
|
||||
});
|
||||
locInput?.addEventListener('input', () => {
|
||||
clearTimeout(_geoDebounce);
|
||||
const q = locInput.value.trim();
|
||||
if (q.length < 3) return;
|
||||
_geoDebounce = setTimeout(() => _geocodeAndFly(q), 800);
|
||||
});
|
||||
}
|
||||
|
||||
async function _geocodeAndFly(query) {
|
||||
if (!query || !_searchMap) return;
|
||||
try {
|
||||
const r = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(query)}&format=json&limit=1&accept-language=de`,
|
||||
{ cache: 'no-store' }
|
||||
);
|
||||
const data = await r.json();
|
||||
if (!data.length) { UI.toast.info('Ort nicht gefunden.'); return; }
|
||||
const { lat, lon, boundingbox } = data[0];
|
||||
if (boundingbox) {
|
||||
_searchMap.fitBounds([[+boundingbox[0], +boundingbox[2]], [+boundingbox[1], +boundingbox[3]]],
|
||||
{ maxZoom: 14 });
|
||||
} else {
|
||||
_searchMap.setView([+lat, +lon], 13);
|
||||
}
|
||||
} catch { UI.toast.warning('Suche fehlgeschlagen.'); }
|
||||
}
|
||||
|
||||
function _renderRoutesOnMap() {
|
||||
if (!_searchMap || !window.L) return;
|
||||
|
||||
// Alte Linien entfernen
|
||||
_searchLines.forEach(({ line }) => line.remove());
|
||||
_searchLines.clear();
|
||||
|
||||
const hint = document.getElementById('rk-map-hint');
|
||||
|
||||
_data.forEach(route => {
|
||||
const pts = (route.preview_track || []).map(p => [p.lat, p.lon]);
|
||||
if (pts.length < 2) return;
|
||||
|
||||
const line = L.polyline(pts, {
|
||||
color: '#C4843A', weight: 4, opacity: 0.75,
|
||||
}).addTo(_searchMap);
|
||||
|
||||
// Start-/End-Marker
|
||||
const startM = L.circleMarker(pts[0], {
|
||||
radius: 6, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5
|
||||
}).addTo(_searchMap);
|
||||
const endM = L.circleMarker(pts[pts.length - 1], {
|
||||
radius: 6, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5
|
||||
}).addTo(_searchMap);
|
||||
|
||||
// Tooltip mit Namen und Distanz
|
||||
const tip = `<b>${_esc(route.name)}</b>${route.distanz_km ? ` · ${route.distanz_km.toFixed(1)} km` : ''}`;
|
||||
line.bindTooltip(tip, { sticky: true, className: 'rk-map-tooltip' });
|
||||
|
||||
// Hover-Highlight
|
||||
line.on('mouseover', () => line.setStyle({ color: '#e67e22', weight: 6, opacity: 1 }));
|
||||
line.on('mouseout', () => line.setStyle({ color: '#C4843A', weight: 4, opacity: 0.75 }));
|
||||
|
||||
// Klick → Detail-Modal (Karte bleibt im Hintergrund erhalten)
|
||||
const onClick = () => {
|
||||
if (hint) hint.textContent = `Lädt „${route.name}"…`;
|
||||
_openDetail(route.id).finally(() => {
|
||||
if (hint) hint.textContent = 'Route antippen um Details zu sehen';
|
||||
});
|
||||
};
|
||||
line.on('click', onClick);
|
||||
startM.on('click', onClick);
|
||||
|
||||
_searchLines.set(route.id, { line, startM, endM });
|
||||
});
|
||||
|
||||
// Wenn Routen vorhanden: Karte auf alle Routes zoomen (nur beim ersten Mal)
|
||||
if (_data.length && _searchLines.size && !_userPos) {
|
||||
const allPts = [..._searchLines.values()].flatMap(({ line }) => line.getLatLngs());
|
||||
if (allPts.length) {
|
||||
try { _searchMap.fitBounds(L.latLngBounds(allPts), { padding: [20, 20], maxZoom: 14 }); }
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Daten
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -178,6 +360,7 @@ window.Page_routes = (() => {
|
|||
|
||||
_filtered = list;
|
||||
_renderGrid();
|
||||
if (_viewMode === 'map' && _searchMap) _renderRoutesOnMap();
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -511,12 +694,27 @@ window.Page_routes = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// Nearby POIs
|
||||
// ----------------------------------------------------------
|
||||
|
||||
// Gibt true zurück wenn poi.lat/lon innerhalb maxMeters eines Track-Punkts liegt
|
||||
function _isNearTrack(poi, track, maxMeters) {
|
||||
const R = 6371000;
|
||||
const plat = poi.lat * Math.PI / 180;
|
||||
const plon = poi.lon * Math.PI / 180;
|
||||
for (const pt of track) {
|
||||
const dlat = plat - pt.lat * Math.PI / 180;
|
||||
const dlon = plon - pt.lon * Math.PI / 180;
|
||||
const a = dlat*dlat + Math.cos(plat) * Math.cos(pt.lat * Math.PI/180) * dlon*dlon;
|
||||
if (R * Math.sqrt(a) <= maxMeters) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function _loadNearbyPois(track) {
|
||||
const lats = track.map(p => p.lat), lons = track.map(p => p.lon);
|
||||
const south = Math.min(...lats), north = Math.max(...lats);
|
||||
const west = Math.min(...lons), east = Math.max(...lons);
|
||||
// Etwas aufweiten (ca. 300m)
|
||||
const pad = 0.003;
|
||||
// Bbox-Padding zum Abrufen (ca. 150m) — echte Distanzfilterung danach
|
||||
const pad = 0.0015;
|
||||
const bbox = { south: south-pad, north: north+pad, west: west-pad, east: east+pad };
|
||||
|
||||
const results = [];
|
||||
|
|
@ -524,7 +722,9 @@ window.Page_routes = (() => {
|
|||
try {
|
||||
const params = new URLSearchParams({ type, fast: 'true', ...bbox });
|
||||
const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json());
|
||||
pois.forEach(p => results.push({ ...p, _icon: icon, _label: label }));
|
||||
pois
|
||||
.filter(p => _isNearTrack(p, track, 100)) // max 100m vom Track-Verlauf
|
||||
.forEach(p => results.push({ ...p, _icon: icon, _label: label }));
|
||||
} catch {}
|
||||
}));
|
||||
return results;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue