diff --git a/VERSION b/VERSION index e90170d..1280674 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1242 \ No newline at end of file +1247 \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 5cbb96b..b473156 100644 --- a/backend/main.py +++ b/backend/main.py @@ -371,6 +371,7 @@ app.mount("/css", StaticFiles(directory=f"{STATIC_DIR}/css"), name="css") app.mount("/js", StaticFiles(directory=f"{STATIC_DIR}/js"), name="js") app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons") app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img") +app.mount("/sounds", StaticFiles(directory=f"{STATIC_DIR}/sounds"), name="sounds") # Yaro-Navi-Sounds # Selbst-gehostete Vektor-Tiles (.pmtiles) — liegen im data-Volume, NICHT im Image. # WICHTIG: Starlettes StaticFiles/FileResponse liefert hinter unserer BaseHTTPMiddleware diff --git a/backend/routes/routen.py b/backend/routes/routen.py index db57d55..ee3bac7 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -73,6 +73,31 @@ def _simplify_track(track: list, max_pts: int = 40) -> list: return [track[round(i * step)] for i in range(max_pts)] +def _decode_polyline3d(encoded: str) -> list: + """ORS-Polyline MIT Elevation: Tripel (lat, lon, ele), Präzision 1e5/1e5/1e2. + Das polyline-Paket kann nur 2D — es würde die Höhen-Deltas als nächste + Koordinate fehlinterpretieren und Müll liefern.""" + coords, idx, lat, lon, ele = [], 0, 0, 0, 0 + n = len(encoded) + while idx < n: + deltas = [] + for _ in range(3): + result, shift = 0, 0 + while True: + b = ord(encoded[idx]) - 63 + idx += 1 + result |= (b & 0x1F) << shift + shift += 5 + if b < 0x20: + break + deltas.append(~(result >> 1) if result & 1 else result >> 1) + lat += deltas[0] + lon += deltas[1] + ele += deltas[2] + coords.append((lat / 1e5, lon / 1e5, ele / 1e2)) + return coords + + def _parse(row) -> dict: d = dict(row) if isinstance(d.get('gps_track'), str): @@ -249,6 +274,7 @@ async def suggest_route(data: SuggestRequest, user=Depends(get_current_user)): }, "units": "m", "geometry": True, + "elevation": True, # Anstieg/Abstieg für ehrliche Schwierigkeit (René 2026-06-06) "instructions": False, } @@ -282,16 +308,20 @@ async def suggest_route(data: SuggestRequest, user=Depends(get_current_user)): except (KeyError, IndexError) as exc: raise HTTPException(502, f"Unerwartete ORS-Antwort: {exc}") - # encoded polyline → [[lat, lon], ...] - points = _polyline.decode(geometry) - gps_track = [{"lat": p[0], "lon": p[1]} for p in points] + # encoded polyline → Track. ACHTUNG: mit elevation=True codiert ORS 3D + # (lat, lon, ele) — der Standard-2D-Decoder würde Müll liefern. + points = _decode_polyline3d(geometry) + gps_track = [{"lat": p[0], "lon": p[1], "alt": round(p[2])} for p in points] distanz_km = round(distanz_m / 1000, 2) dauer_min = max(1, round(dauer_s / 60)) + ascent_m = round(summary.get("ascent", 0)) - if distanz_km < 3: + # Schwierigkeit aus Distanz UND Höhenmetern (René 2026-06-06) — vorher nur km: + # eine flache 6-km-Runde ist nicht „anspruchsvoll", 3 km steil bergauf nicht „leicht". + if distanz_km < 4 and ascent_m < 50: schwierigkeit = "leicht" - elif distanz_km <= 5: + elif distanz_km <= 7 and ascent_m < 150: schwierigkeit = "mittel" else: schwierigkeit = "anspruchsvoll" @@ -319,6 +349,7 @@ async def suggest_route(data: SuggestRequest, user=Depends(get_current_user)): "gps_track": gps_track, "distanz_km": distanz_km, "dauer_min": dauer_min, + "hoehenmeter": ascent_m, "schwierigkeit": schwierigkeit, "weekly_remaining": weekly_remaining, } diff --git a/backend/static/index.html b/backend/static/index.html index 88c1392..3227008 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -612,11 +612,11 @@ - - - - - + + + + + @@ -626,7 +626,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index f3a7937..7f96416 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1242'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1247'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index efa9c55..3932710 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -167,6 +167,7 @@ window.Page_map = (() => { await _loadLeaflet(); _initMap(); // Leaflet-Raster (Default), sofort mit Deutschland-Mitte starten } + _ensureFollowBtn(); // Crosshair-Button (Karte folgt Standort, wie in Routen) _startLocationTracking(); _loadAll(); _offerResume(); // unterbrochene Aufzeichnung anbieten @@ -368,6 +369,7 @@ window.Page_map = (() => { // Follow-Mode (René 2026-06-08): Karte wandert ab jetzt mit dem Standort; // manuelles Verschieben beendet das Folgen (dragstart-Listener im Map-Init). _followGps = true; + _updateFollowBtn(); UI.toast.info('Karte folgt deinem Standort — zum Beenden Karte verschieben.'); } else { UI.toast.error('Standort noch nicht verfügbar.'); @@ -788,8 +790,8 @@ window.Page_map = (() => { setTimeout(() => _map.invalidateSize(), 600); window.addEventListener('resize', () => _map.invalidateSize()); - _map.on('moveend zoomend', () => { _autoRetryCount = 0; _updateZoomDisplay(); _scheduleOsmLoad(); }); - _map.on('dragstart', () => { _followGps = false; }); // manuelles Verschieben beendet Follow + _map.on('moveend zoomend', () => { _autoRetryCount = 0; _updateZoomDisplay(); _scheduleOsmLoad(true); }); + _map.on('dragstart', () => { _followGps = false; _updateFollowBtn(); }); // manuelles Verschieben beendet Follow setTimeout(() => { _updateZoomDisplay(); _scheduleOsmLoad(); }, 800); // Fadenkreuz-Animation beim Kartenverschieben @@ -825,6 +827,39 @@ window.Page_map = (() => { try { return !!(window.BY && BY.offlineTiles()); } catch (e) { return false; } } + // ---------------------------------------------------------- + // Follow-Button auf der Karte (Wunsch René 2026-06-06: „wie in Routen") — + // Crosshair links unter den Zoom-Reglern (unterhalb des Offline-Puls-Icons). + // Ein Tipp = zentrieren + folgen; Karte ziehen = Folgen pausiert (Button grau). + // ---------------------------------------------------------- + function _updateFollowBtn() { + const b = document.getElementById('map-follow-btn'); + if (b) b.style.color = _followGps ? 'var(--c-primary)' : 'var(--c-text-secondary, #9ca3af)'; + } + function _ensureFollowBtn() { + const host = document.getElementById('central-map'); + if (!host || document.getElementById('map-follow-btn')) { _updateFollowBtn(); return; } + const b = document.createElement('button'); + b.id = 'map-follow-btn'; + b.type = 'button'; + b.title = 'Karte folgt deinem Standort'; + // FIXED + safe-area wie das Offline-Puls-Icon (+110px) — Crosshair sitzt in der + // Lücke ZWISCHEN Zoom-Reglern und Offline-Icon (René 2026-06-06). + b.style.cssText = 'position:fixed;left:10px;top:calc(env(safe-area-inset-top, 0px) + 70px);' + + 'z-index:500;width:36px;height:36px;border-radius:50%;border:none;' + + 'background:var(--c-surface,#fff);box-shadow:0 2px 8px rgba(0,0,0,.3);' + + 'display:flex;align-items:center;justify-content:center;cursor:pointer'; + b.innerHTML = ``; + b.addEventListener('click', () => { + if (!_userPos) { UI.toast.error('Standort noch nicht verfügbar.'); return; } + _followGps = true; + _mapSetView(_userPos.lat, _userPos.lon, Math.max(14, Math.round(_mapGetZoom()))); + _updateFollowBtn(); + }); + host.appendChild(b); + _updateFollowBtn(); + } + function loadMapLibre() { if (_maplibreLoaded) return Promise.resolve(); const v = '?v=' + (window.APP_VER || ''); @@ -961,13 +996,13 @@ window.Page_map = (() => { _scheduleOsmLoad(); }); _map.on('moveend', () => { - _autoRetryCount = 0; _updateZoomDisplay(); _scheduleOsmLoad(); + _autoRetryCount = 0; _updateZoomDisplay(); _scheduleOsmLoad(true); document.getElementById('map-crosshair')?.classList.remove('dragging'); }); _map.on('movestart', () => { document.getElementById('map-crosshair')?.classList.add('dragging'); }); - _map.on('dragstart', () => { _followGps = false; }); // manuelles Verschieben beendet Follow + _map.on('dragstart', () => { _followGps = false; _updateFollowBtn(); }); // manuelles Verschieben beendet Follow window.addEventListener('resize', _mapResize); setTimeout(_mapResize, 100); @@ -1434,9 +1469,34 @@ window.Page_map = (() => { // ---------------------------------------------------------- // OSM-Layer laden // ---------------------------------------------------------- - function _scheduleOsmLoad() { + // Bewegungs-Gate (René 2026-06-06): Der Follow-Mode pannt alle paar Sekunden → + // jedes moveend triggerte den Scanner LAUFEND. Scans aus Kartenbewegung laufen + // erst, wenn sich das Zentrum ≥ 20 % der Viewport-Breite bewegt hat oder der + // Zoom wechselt. Alle anderen Trigger (Marker gespeichert, Layer-Toggle, Retry, + // Init) scannen weiter ungebremst (fromMove=false). + let _lastScanCenter = null, _lastScanZoom = null; + function _viewChangedEnough() { + try { + const zoom = Math.round(_mapGetZoom()); + if (_lastScanZoom !== zoom) return true; + if (!_lastScanCenter) return true; + const c = _map.getCenter(); + const b = _map.getBounds(); + const viewM = _haversineRec(b.getSouth(), b.getWest(), b.getSouth(), b.getEast()); + const movedM = _haversineRec(_lastScanCenter.lat, _lastScanCenter.lng, c.lat, c.lng); + return movedM >= viewM * 0.2; + } catch (e) { return true; } + } + function _scheduleOsmLoad(fromMove = false) { clearTimeout(_overpassTimer); - _overpassTimer = setTimeout(_loadOsmLayers, 600); + _overpassTimer = setTimeout(() => { + if (fromMove && !_viewChangedEnough()) return; + try { + _lastScanCenter = _map.getCenter(); + _lastScanZoom = Math.round(_mapGetZoom()); + } catch (e) {} + _loadOsmLayers(); + }, 600); } // OSM-Marker-Zählung (ohne eigene Orte), engine-neutral. @@ -2618,6 +2678,7 @@ window.Page_map = (() => { _recTimerInt = setInterval(_updateRecStatus, 1000); _followGps = true; // Aufzeichnung startet im Follow-Mode (Drag pausiert, Standort-Button reaktiviert) + _updateFollowBtn(); _recWatchId = navigator.geolocation.watchPosition( pos => { diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index c458736..844bcdf 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -77,7 +77,9 @@ window.Page_routes = (() => { // ---------------------------------------------------------- const NavSound = (() => { let ctx = null; - let files = null; // { wuff, klaeffen } HTMLAudio | leer = Synthese + let bufs = null; // { wuff, klaeffen } AudioBuffer | leer = Synthese. + // HTMLAudio+preload taugt NICHT (iOS lädt lazy, canplaythrough feuert nie → + // es blieb beim Game-Boy-Sound, René 2026-06-06) → fetch + decodeAudioData. const enabled = () => { try { return localStorage.getItem('by_nav_sound') !== '0'; } catch (e) { return true; } }; function _ctx() { @@ -85,6 +87,17 @@ window.Page_routes = (() => { if (ctx.state === 'suspended') ctx.resume().catch(() => {}); return ctx; } + function _loadBufs() { + if (bufs !== null) return; + bufs = {}; + ['wuff', 'klaeffen'].forEach(name => { + fetch(`/sounds/${name}.mp3`) + .then(r => { if (!r.ok) throw new Error(String(r.status)); return r.arrayBuffer(); }) + .then(ab => _ctx().decodeAudioData(ab)) + .then(b => { bufs[name] = b; }) + .catch(() => {}); // 404/Decode-Fehler → Synthese bleibt + }); + } // Ein synthetischer „Wuff": Sägezahn-Sweep durch Tiefpass, kurzer Attack, schneller Decay. function _wuff(at, pitch = 1) { const c = _ctx(), t = c.currentTime + at; @@ -101,19 +114,23 @@ window.Page_routes = (() => { } function _barks(n, pitch, gap) { if (!enabled()) return; - const sample = files && (pitch > 1.3 ? files.klaeffen : files.wuff); - if (sample) { // echte Aufnahme: n-mal hintereinander - let i = 0; - const play = () => { - if (i++ >= n) return; - sample.currentTime = 0; - sample.play().catch(() => {}); - setTimeout(play, gap * 1000 + 200); - }; - play(); - return; - } - try { for (let i = 0; i < n; i++) _wuff(i * gap, pitch); } catch (e) {} + try { + const buf = bufs && (pitch > 1.3 ? bufs.klaeffen : bufs.wuff); + if (buf) { // echte Aufnahme (Schäferhund, /sounds/*.mp3) + // klaeffen.mp3 ist bereits eine ~2,8-s-Bell-SEQUENZ → nur 1× abspielen; + // wuff.mp3 ist ein einzelner Beller → n-mal mit Pause. + const c = _ctx(); + const reps = buf === bufs.klaeffen ? 1 : n; + for (let i = 0; i < reps; i++) { + const s = c.createBufferSource(); + s.buffer = buf; + s.connect(c.destination); + s.start(c.currentTime + i * (buf.duration + 0.22)); + } + return; + } + for (let i = 0; i < n; i++) _wuff(i * gap, pitch); // Fallback: Synthese + } catch (e) {} } return { enabled, @@ -123,16 +140,7 @@ window.Page_routes = (() => { const b = c.createBuffer(1, 1, 22050), s = c.createBufferSource(); s.buffer = b; s.connect(c.destination); s.start(0); } catch (e) {} - // Echte Samples einmalig anfragen (404 → Synthese bleibt) - if (files === null) { - files = {}; - ['wuff', 'klaeffen'].forEach(name => { - const a = new Audio(`/sounds/${name}.mp3`); - a.preload = 'auto'; - a.addEventListener('canplaythrough', () => { files[name] = a; }, { once: true }); - a.addEventListener('error', () => {}, { once: true }); - }); - } + _loadBufs(); // echte Samples laden (404 → Synthese bleibt) }, links() { _barks(2, 1.0, 0.30); }, // 2× Wuff rechts() { _barks(1, 1.0, 0.30); }, // 1× Wuff @@ -672,6 +680,9 @@ window.Page_routes = (() => { ${UI.icon('timer')} ${UI.escape(durStr)} + ${result.hoehenmeter != null ? ` + ${UI.icon('trend-up')} ${result.hoehenmeter} hm + ` : ''} ${diffLabel ? ` - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sounds/klaeffen.mp3 b/backend/static/sounds/klaeffen.mp3 new file mode 100644 index 0000000..3fe5bed Binary files /dev/null and b/backend/static/sounds/klaeffen.mp3 differ diff --git a/backend/static/sounds/wuff.mp3 b/backend/static/sounds/wuff.mp3 new file mode 100644 index 0000000..648ceeb Binary files /dev/null and b/backend/static/sounds/wuff.mp3 differ diff --git a/backend/static/sw.js b/backend/static/sw.js index 31f22ab..a86b58c 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1242'; +const VER = '1247'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten @@ -32,6 +32,9 @@ const PRIORITY_PAGES = [ '/js/map-offline.js', '/js/map-gl-markers.js', '/js/map-gl-mini.js', + // Yaro-Navi-Sounds — müssen auch im Funkloch bellen (zusammen ~40 KB) + '/sounds/wuff.mp3', + '/sounds/klaeffen.mp3', ]; // index.html wird NICHT pre-gecacht (immer Network-First)