From f7370028da3cedc4714bfb67fdefe42dd84c21c0 Mon Sep 17 00:00:00 2001 From: rene Date: Sun, 14 Jun 2026 20:23:21 +0200 Subject: [PATCH] KI-Vision-Model, Breed-Scraper, Karte/Routen + Release v1292 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallele Arbeit (auf Staging mitgetestet): KI-Vision-Model (VISION_MODEL in ki.py/routes, im KI-Status sichtbar), Breed-Scraper-Anpassungen (breed_enricher/breed_evaluator, evaluate_enrichment mit user_id), Karten-/Routen-Änderungen (map.js, routes.js), kleinere UI-Anpassungen (admin.js, components.css), docker-compose, MARKETING, nav-loop-Test. Version-Bump auf 1292 (VERSION, sw.js, app.js, index.html, landing.html). --- MARKETING.md | 6 +- VERSION | 2 +- backend/ki.py | 1 + backend/routes/ki.py | 2 +- backend/scraper/breed_enricher.py | 65 +++++++++------ backend/scraper/breed_evaluator.py | 49 +++++------ backend/static/css/components.css | 15 +++- backend/static/index.html | 26 +++--- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 5 +- backend/static/js/pages/map.js | 119 ++++++++++++++++++++------- backend/static/js/pages/routes.js | 19 ++++- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- docker-compose.yml | 1 + tests/js/README.md | 8 ++ tests/js/test-nav-loop-closestidx.js | 98 ++++++++++++++++++++++ 17 files changed, 322 insertions(+), 100 deletions(-) create mode 100644 tests/js/test-nav-loop-closestidx.js diff --git a/MARKETING.md b/MARKETING.md index 63fcbc0..735a061 100644 --- a/MARKETING.md +++ b/MARKETING.md @@ -18,7 +18,7 @@ _Stand: 2026-06-09_ | Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern — jetzt mit Partner-Paket als konkretem Angebot | | Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst | | Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE | -| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Backlinks (Blog-Testberichte) | +| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Rechtsseiten crawlbar (v1278) + 3 URLs (datenschutz/agb/impressum) am 09.06. in GSC zur Indexierung eingereicht — in ~Tagen auf „indexiert" prüfen; llms.txt aktuell. Nächster echter Hebel: Backlinks (Blog-Testberichte) | | Landing Page | 🟡 Redesign-Briefing da | 3 Einstiege, Outcomes statt Features | | App Store (iOS) | 🟢 **LIVE im App Store** (09.06., Apple-ID 6775012705) | Landing bewirbt „Ban Yaro Go" (Hero + iOS-Abschnitt `#ios-app`) + Profil-Hinweis (Settings → App installieren). Offizielles „Laden im App Store"-Badge nachgebaut als `/img/appstore-badge-de.svg` (brauner Rand #C4843A). **LIVE auf Produktion v1276** (banyaro.app/.de, 09.06.) — Hero-Badge bewusst weggelassen (sonst Eindruck: ganze App im Store) | | Play Store (Android) | 🔴 ON HOLD | 12 Closed-Tester / 14 Tage fehlen | @@ -41,7 +41,9 @@ Legende: 🟢 läuft/erledigt · 🟡 angefangen · ⬜ offen · 💡 Idee · ## ✅ Erledigt - [x] 1000 Flyer A5 (zweiseitig) gedruckt — 03.06.2026 - [x] iOS-App nativ gebaut + **im App Store freigegeben** (Ban Yaro Go, 09.06.) — Details im Repo `banyaro-ios` -- [x] Landing-Promotion für „Ban Yaro Go" gebaut (Hero-Badge + iOS-Abschnitt) — 09.06., develop (URL-Platzhalter offen) +- [x] Landing-Promotion für „Ban Yaro Go" LIVE (iOS-Abschnitt + Profil, eigenes braunes App-Store-Badge; Hero bewusst ohne Badge) — 09.06., Prod v1278 +- [x] Datenschutz v4 + AGB v3 (iOS-App-Verarbeitung, kein App-Store-IAP) — 09.06., Prod +- [x] Rechtsseiten crawlbar gemacht (/datenschutz /agb /impressum, einzige Quelle static/*.html) + 3 URLs in GSC zur Indexierung eingereicht — 09.06., Prod v1278 - [x] Influencer-Outreach Runde 1 (5) + Runde 2 (13) — Mai 2026 - [x] SEO-Grundlagen (llms.txt, Landing About-Section) diff --git a/VERSION b/VERSION index d1e5ab4..364aacb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1278 \ No newline at end of file +1292 \ No newline at end of file diff --git a/backend/ki.py b/backend/ki.py index 188056f..7e258c8 100644 --- a/backend/ki.py +++ b/backend/ki.py @@ -25,6 +25,7 @@ KI_MODE = os.getenv("KI_MODE", "local") # off | local | cl LOCAL_BASE_URL = os.getenv("KI_LOCAL_URL", "http://10.47.11.70:11435/v1") LOCAL_MODEL = os.getenv("KI_LOCAL_MODEL", "gemma-4-31b-it") CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-sonnet-4-6") +VISION_MODEL = os.getenv("KI_VISION_MODEL", "claude-opus-4-8") # Bild-Analyse (Rassenerkennung) ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") CLOUD_WEEKLY_LIMIT = int(os.getenv("KI_CLOUD_WEEKLY_LIMIT", "20")) diff --git a/backend/routes/ki.py b/backend/routes/ki.py index 5169634..a2a9bc3 100644 --- a/backend/routes/ki.py +++ b/backend/routes/ki.py @@ -298,7 +298,7 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array.""" def _sync_call(): client = anthropic.Anthropic(api_key=api_key) return client.messages.create( - model="claude-opus-4-7", + model=ki_module.VISION_MODEL, max_tokens=500, messages=[{ "role": "user", diff --git a/backend/scraper/breed_enricher.py b/backend/scraper/breed_enricher.py index 8aed584..9d0cb97 100644 --- a/backend/scraper/breed_enricher.py +++ b/backend/scraper/breed_enricher.py @@ -360,30 +360,47 @@ async def _fetch_wikimedia_photo(name: str) -> str | None: return None -async def _haiku_complete(prompt: str) -> str: - """Claude Haiku direkt aufrufen (immer Cloud, für maximale Genauigkeit).""" - import anthropic +async def _haiku_complete(prompt: str) -> tuple[str, str]: + """ + Fakten-Extraktion. Bevorzugt Claude Haiku (günstig + genau); ist kein + Cloud-Key gesetzt oder die Cloud nicht erreichbar, fällt es sauber auf das + lokale Modell (LM Studio) zurück, statt hart abzubrechen. + Returns (text, model) — model fließt in wiki_rassen.ki_model, damit der + Evaluator lokal-angereicherte Rassen weiterhin zur QC erkennt. + """ key = os.getenv("ANTHROPIC_API_KEY", "") - if not key: - raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt") - def _call(): - client = anthropic.Anthropic(api_key=key) - return client.messages.create( - model=_HAIKU_MODEL, - max_tokens=700, - system=[{ - "type": "text", - "text": _SYSTEM, - "cache_control": {"type": "ephemeral"}, - }], - messages=[{"role": "user", "content": prompt}], - ) + # 1. Bevorzugt: Claude Haiku direkt (günstigstes Cloud-Modell) + if key: + try: + import anthropic - loop = asyncio.get_event_loop() - resp = await loop.run_in_executor(None, _call) - return resp.content[0].text.strip() + def _call(): + client = anthropic.Anthropic(api_key=key) + return client.messages.create( + model=_HAIKU_MODEL, + max_tokens=700, + system=[{ + "type": "text", + "text": _SYSTEM, + "cache_control": {"type": "ephemeral"}, + }], + messages=[{"role": "user", "content": prompt}], + ) + + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor(None, _call) + return resp.content[0].text.strip(), _HAIKU_MODEL + except Exception as e: + logger.warning("Haiku (Cloud) nicht erreichbar, Fallback lokal: %s", e) + + # 2. Fallback: lokales Modell über die zentrale KI-Abstraktion + import ki + if ki.KI_MODE == "off": + raise RuntimeError("Kein Cloud-Key und KI_MODE=off — Anreicherung nicht möglich.") + text = await ki._local_complete(prompt, _SYSTEM, max_tokens=700, json_mode=False) + return text, ki.LOCAL_MODEL async def _enrich_one(rasse, dry_run: bool = False) -> bool: @@ -411,12 +428,12 @@ async def _enrich_one(rasse, dry_run: bool = False) -> bool: logger.info("[DRY-RUN] Gefunden: %s (WP-%s, %d Zeichen)", name, wiki_lang.upper(), len(wiki_text)) return True - # 2. Haiku extrahiert Fakten aus dem Quelltext + # 2. KI extrahiert Fakten aus dem Quelltext (Haiku, sonst lokaler Fallback) prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text) try: - raw = await _haiku_complete(prompt) + raw, used_model = await _haiku_complete(prompt) except Exception as e: - logger.error("Haiku-Anfrage fehlgeschlagen für %s: %s", name, e) + logger.error("KI-Anfrage fehlgeschlagen für %s: %s", name, e) await asyncio.sleep(3) return False @@ -435,7 +452,7 @@ async def _enrich_one(rasse, dry_run: bool = False) -> bool: if "temperament" in updates: updates["temperament"] = translate_temperament(updates["temperament"]) updates["ki_enriched"] = 1 - updates["ki_model"] = _HAIKU_MODEL + updates["ki_model"] = used_model updates["ki_source"] = f"wikipedia_{wiki_lang}" cols = ", ".join(f"{k}=?" for k in updates) diff --git a/backend/scraper/breed_evaluator.py b/backend/scraper/breed_evaluator.py index c78bbfd..30c352f 100644 --- a/backend/scraper/breed_evaluator.py +++ b/backend/scraper/breed_evaluator.py @@ -43,19 +43,23 @@ Aktivität zur Erfahrung)? ''' -async def evaluate_enrichment(sample_size: int = 20) -> dict: +async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) -> dict: """ - Bewertet `sample_size` zufällig gewählte angereicherte Rassen via Claude. + Bewertet `sample_size` zufällig gewählte angereicherte Rassen als LLM-as-Judge. + + Läuft über die zentrale KI-Abstraktion (ki.complete). Admins/Moderatoren werden + dort Cloud-priorisiert (Claude); ist die Cloud nicht erreichbar, fällt die + Bewertung sauber auf das lokale Modell zurück, statt hart abzubrechen. Returns dict mit aggregierten Scores und Einzelergebnissen. """ import sys, os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from database import db + import ki - ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") - if not ANTHROPIC_KEY: - raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt — Evaluierung benötigt Cloud.") + if ki.KI_MODE == "off": + raise RuntimeError("KI ist deaktiviert (KI_MODE=off) — Evaluierung nicht möglich.") with db() as conn: rassen = conn.execute( @@ -65,8 +69,7 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict: wohnung_geeignet, temperament, ki_model FROM wiki_rassen WHERE ki_enriched = 1 - AND ki_model IS NOT NULL - AND ki_model NOT LIKE 'claude%' + AND (ki_model IS NULL OR ki_model NOT LIKE 'claude%') ORDER BY RANDOM() LIMIT ?""", (sample_size,), @@ -75,10 +78,10 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict: if not rassen: return {"error": "Keine angereicherten Rassen gefunden."} - import anthropic - client = anthropic.Anthropic(api_key=ANTHROPIC_KEY) + _EVAL_SYSTEM = "Du bist ein präziser Qualitätsprüfer. Antworte ausschließlich als JSON." results = [] + sources = set() totals = {"vollstaendigkeit": 0, "korrektheit": 0, "sprachqualitaet": 0, "konsistenz": 0, "gesamt": 0} @@ -102,22 +105,17 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict: data=json.dumps(data, ensure_ascii=False, indent=2), ) try: - def _call(): - return client.messages.create( - model="claude-haiku-4-5-20251001", - max_tokens=256, - system=[{ - "type": "text", - "text": "Du bist ein präziser Qualitätsprüfer. Antworte ausschließlich als JSON.", - "cache_control": {"type": "ephemeral"}, - }], - messages=[{"role": "user", "content": prompt}], - ) - loop = asyncio.get_event_loop() - resp = await loop.run_in_executor(None, _call) - raw = resp.content[0].text.strip() + raw, source = await ki.complete( + prompt, + system=_EVAL_SYSTEM, + max_tokens=256, + json_mode=True, + user_id=user_id, + return_source=True, + ) + sources.add(source) - # JSON extrahieren + # JSON extrahieren (lokale Modelle wrappen gern in ```json … ```) import re match = re.search(r"\{[\s\S]+\}", raw) scores = json.loads(match.group(0)) if match else {} @@ -136,9 +134,12 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict: count = len([r for r in results if "error" not in r]) averages = {k: round(v / count, 2) for k, v in totals.items()} if count else {} + judge_source = "/".join(sorted(sources)) if sources else "unbekannt" + return { "sample_size": len(rassen), "evaluated": count, "averages": averages, + "judge_source": judge_source, # "cloud" (Claude) oder "local" (LM Studio) "results": results, } diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 8d537f5..a96b2ab 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -3088,12 +3088,23 @@ html.modal-open { } .rdr-play svg { width: 14px; height: 14px; } .rdr-play:active { background: var(--c-border); } -.rdr-slider { flex: 1; min-width: 0; height: 4px; accent-color: var(--c-primary); cursor: pointer; } +.rdr-track-wrap { position: relative; flex: 1; min-width: 0; display: flex; align-items: center; } +.rdr-slider { width: 100%; min-width: 0; height: 4px; accent-color: var(--c-primary); cursor: pointer; } +/* "Jetzt"-Markierung in der Mitte der Zeitleiste (Fangpunkt) */ +.rdr-now-tick { + position: absolute; left: 50%; top: 50%; + transform: translate(-50%, -50%); + width: 2px; height: 13px; + background: var(--c-primary); opacity: 0.45; + border-radius: 1px; pointer-events: none; z-index: 1; +} .rdr-time { flex-shrink: 0; font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; - min-width: 74px; text-align: right; color: var(--c-text-secondary); + width: 112px; /* FESTE Breite → Regler bleibt immer gleich lang */ + white-space: nowrap; overflow: hidden; + text-align: right; color: var(--c-text-secondary); } .rdr-time.is-forecast { color: var(--c-primary); } /* Nowcast/Vorhersage-Frames hervorgehoben */ diff --git a/backend/static/index.html b/backend/static/index.html index 385e4ae..830ff54 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -620,12 +620,12 @@ - - - - - - + + + + + + @@ -635,7 +635,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 9b7942e..2c2232a 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 = '1278'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1292'; // ← 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/admin.js b/backend/static/js/pages/admin.js index f5ad0ee..ae8d0db 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -1419,7 +1419,10 @@ window.Page_admin = (() => { ${rows} `; - res.textContent = `✓ Bewertung abgeschlossen`; + const judge = d.judge_source === 'cloud' ? 'Claude (Cloud)' + : d.judge_source === 'local' ? 'lokales Modell ⚠︎' + : (d.judge_source || '–'); + res.textContent = `✓ Bewertung abgeschlossen — Prüfer: ${judge}`; } catch (err) { res.textContent = '✗ Fehler: ' + (err.message || err); } finally { diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 130dd4f..fad2572 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -451,6 +451,9 @@ window.Page_map = (() => { let _radarNowIdx = 0; // Index des "jetzt"-Frames (letzte Vergangenheit) let _radarPlaying = false; let _radarPlayTimer = null; + let _radarLayerKind = null; // 'rv' (RainViewer-PNG) | 'dwd' (pmtiles) — für sauberen Layer-Wechsel + let _rdrPendingIdx = null; // Regler-Entprellung: zuletzt gewünschter Frame + let _rdrRaf = null; // requestAnimationFrame-Handle für die Koaleszenz async function _toggleRadar() { if (!App.hasPro(_appState?.user)) { @@ -461,7 +464,9 @@ window.Page_map = (() => { if (_radarActive) { _radarActive = false; _radarPause(); - if (_radarLayer) { _wxRemoveRaster(_radarLayer); _radarLayer = null; } + if (_radarLayer) { _wxRemoveRaster(_radarLayer); _radarLayer = null; _radarLayerKind = null; } + if (_rdrRaf != null) { cancelAnimationFrame(_rdrRaf); _rdrRaf = null; } + _rdrPendingIdx = null; clearInterval(_radarTimer); document.getElementById('map-radar-timeline')?.remove(); btn?.classList.remove('active'); @@ -602,67 +607,107 @@ window.Page_map = (() => { async function _loadRadar() { if (!_radarActive || !_map) return; try { - const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' }); + // Cache-Buster: sonst liefert der Service-Worker u. U. einen alten RainViewer-Stand + // (Frames hingen ~50 min nach → DWD-Frische-Check fiel durch, Gerätetest 2026-06-09). + const resp = await fetch(`https://api.rainviewer.com/public/weather-maps.json?_t=${Date.now()}`, { cache: 'no-store' }); const data = await resp.json(); const past = data.radar?.past || [], nowcast = data.radar?.nowcast || []; if (!past.length && !nowcast.length) return; _radarHost = data.host || _radarHost; const rvUrl = f => `${_radarHost}${f.path}/256/{z}/{x}/{y}/4/1_1.png`; - // Default: RainViewer komplett (~2h Vergangenheit + ~30 min Nowcast) - let frames = [...past, ...nowcast].map(f => ({ url: rvUrl(f), time: f.time })); - let nowIdx = Math.max(0, past.length - 1); // "jetzt" = letzter Vergangenheits-Frame + // Symmetrische ±2h-Zeitleiste: letzte 2 h (RainViewer) | jetzt | nächste 2 h (DWD/Nowcast) + const WINDOW = 2 * 60 * 60; // 2 h je Seite + const nowSec = Math.floor(Date.now() / 1000); // Echtzeit-Referenz (Geräteuhr ist zuverlässig) - // DWD-Vorhersage (0–120 min, 5-Min-Schritte): ersetzt den RainViewer-Nowcast, - // Vergangenheit bleibt RainViewer (docs/DWD_RAIN_FORECAST_PLAN.md). + // Vergangenheit: RainViewer der letzten 2 h + let pastFrames = past + .filter(f => f.time >= nowSec - WINDOW && f.time <= nowSec) + .map(f => ({ url: rvUrl(f), time: f.time })); + + // "Jetzt" + Zukunft — Default: RainViewer-Nowcast (~30 min) + let nowFrame = null; + let futureFrames = nowcast + .filter(f => f.time > nowSec && f.time <= nowSec + WINDOW) + .map(f => ({ url: rvUrl(f), time: f.time })); + + // DWD-Vorhersage (0–120 min, 5-Min-Schritte) bevorzugt (docs/DWD_RAIN_FORECAST_PLAN.md) if (_dwdEnabled() && _engineGL && _mapInDwdCoverage()) { try { const r = await fetch('/radar/manifest.json', { cache: 'no-store' }); if (r.ok) { const man = await r.json(); const runT = Math.floor(Date.parse(man.run_time_utc) / 1000); - // Nur wenn der Lauf frisch ist (< 30 min) — sonst RainViewer-Fallback - if (man.frames?.length && (Date.now() / 1000 - runT) < 1800) { - const pastRv = past.filter(f => f.time <= runT).map(f => ({ url: rvUrl(f), time: f.time })); + // Frische des DWD-Laufs gegen die ECHTZEIT prüfen (< 30 min) — NICHT gegen den + // jüngsten RainViewer-Frame, der deutlich nachhängen kann (sonst fällt DWD raus). + if (man.frames?.length && Math.abs(nowSec - runT) < 1800) { const dwd = man.frames.map(fr => ({ url: `pmtiles://${location.origin}/radar/${man.path}/${fr.file}/{z}/{x}/{y}`, time: runT + fr.lead_min * 60, + lead: fr.lead_min, dwd: true, })); - frames = [...pastRv, ...dwd]; - nowIdx = pastRv.length; // DWD lead 0 = "jetzt" + nowFrame = dwd.find(f => f.lead === 0) || null; // lead 0 = "jetzt" + futureFrames = dwd.filter(f => f.lead > 0 && f.time <= runT + WINDOW); + pastFrames = pastFrames.filter(f => f.time < runT); // Überlappung mit DWD-"jetzt" vermeiden } } } catch (e) { /* offline/kein Manifest → RainViewer-Fallback */ } } - _radarFrames = frames; - _radarNowIdx = nowIdx; + // Kein DWD-"jetzt"? → jüngsten Vergangenheits-Frame (sonst ältesten Zukunfts-Frame) als "jetzt" + if (!nowFrame) { + if (pastFrames.length) nowFrame = pastFrames.pop(); + else if (futureFrames.length) nowFrame = futureFrames.shift(); + } + if (!nowFrame) return; + + _radarFrames = [...pastFrames, nowFrame, ...futureFrames]; + _radarNowIdx = pastFrames.length; // "jetzt" liegt direkt nach der Vergangenheit if (_radarIdx == null || _radarIdx >= _radarFrames.length) _radarIdx = _radarNowIdx; _showRadarFrame(_radarIdx); _buildRadarTimeline(); } catch { /* still */ } } - function _radarUrl(idx) { - return _radarFrames[idx].url; - } - // Frame anzeigen — wenn möglich smooth via setTiles (kein Flackern), sonst Layer neu. function _showRadarFrame(idx) { if (!_radarActive || !_radarFrames[idx]) return; _radarIdx = idx; - const url = _radarUrl(idx); + const f = _radarFrames[idx]; + const url = f.url; + const kind = f.dwd ? 'dwd' : 'rv'; const src = _engineGL && _radarLayer && _map.getSource && _map.getSource('wx-radar'); - if (src && src.setTiles) { + // setTiles nur innerhalb DESSELBEN Quell-Typs (png↔png bzw. pmtiles↔pmtiles). + // Beim Wechsel RainViewer↔DWD den Layer komplett neu aufbauen — sonst bleiben die + // alten Kacheln stehen (DWD "neutralisiert" die RainViewer-Wolken nicht). + if (src && src.setTiles && kind === _radarLayerKind) { src.setTiles([url]); } else { if (_radarLayer) _wxRemoveRaster(_radarLayer); _radarLayer = _wxAddRaster('radar', url, 0.7, 7); + _radarLayerKind = kind; } _updateRadarTimelineUI(); } + // Slider-Position (0–1000) ↔ Frame-Index. "jetzt" liegt fix bei 500 (Mitte): + // Vergangenheit nutzt die linke, Vorhersage die rechte Hälfte — unabhängig von der + // Frame-Anzahl je Seite. So sitzt "jetzt" optisch mittig (Fangpunkt). + const RDR_MID = 500, RDR_SNAP = 28; + function _radarPosToIdx(pos) { + const now = _radarNowIdx, last = _radarFrames.length - 1; + if (pos <= RDR_MID) return now > 0 ? Math.round((pos / RDR_MID) * now) : 0; + const fut = last - now; + return fut > 0 ? now + Math.round(((pos - RDR_MID) / RDR_MID) * fut) : now; + } + function _radarIdxToPos(idx) { + const now = _radarNowIdx, last = _radarFrames.length - 1; + if (idx <= now) return now > 0 ? Math.round((idx / now) * RDR_MID) : RDR_MID; + const fut = last - now; + return fut > 0 ? RDR_MID + Math.round(((idx - now) / fut) * RDR_MID) : RDR_MID; + } + function _buildRadarTimeline() { if (!_radarFrames.length) return; let el = document.getElementById('map-radar-timeline'); @@ -674,17 +719,27 @@ window.Page_map = (() => { - +
+ + +
`; document.getElementById('central-map')?.appendChild(el); el.querySelector('#rdr-play').addEventListener('click', _toggleRadarPlay); el.querySelector('#rdr-slider').addEventListener('input', e => { - const idx = parseInt(e.target.value, 10); // ZUERST lesen: _radarPause() setzt slider.value zurück + let pos = parseInt(e.target.value, 10); // ZUERST lesen: _radarPause() setzt slider.value zurück + if (Math.abs(pos - RDR_MID) <= RDR_SNAP) { pos = RDR_MID; e.target.value = RDR_MID; } // Fangpunkt "jetzt" _radarPause(); - _showRadarFrame(idx); + // Entprellen: pro Animationsframe nur EIN setTiles, egal wie schnell gezogen wird + // (sonst bricht jeder neue Frame die laufenden Kachel-Requests ab → AbortError-Spam). + _rdrPendingIdx = _radarPosToIdx(pos); + if (_rdrRaf == null) { + _rdrRaf = requestAnimationFrame(() => { + _rdrRaf = null; + if (_rdrPendingIdx != null) { _showRadarFrame(_rdrPendingIdx); _rdrPendingIdx = null; } + }); + } }); - } else { - el.querySelector('#rdr-slider').max = _radarFrames.length - 1; } // Breite an die Status-Pill angleichen → gleiche linke + rechte Kante. const pill = document.querySelector('.map-statusbar'); @@ -696,7 +751,7 @@ window.Page_map = (() => { const slider = document.getElementById('rdr-slider'); const timeEl = document.getElementById('rdr-time'); const playBtn = document.getElementById('rdr-play'); - if (slider) slider.value = _radarIdx; + if (slider) slider.value = _radarIdxToPos(_radarIdx); if (playBtn) playBtn.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${_radarPlaying ? 'pause' : 'play'}`); const f = _radarFrames[_radarIdx]; if (timeEl && f) { @@ -704,8 +759,8 @@ window.Page_map = (() => { const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; const diffMin = Math.round((f.time - _radarFrames[_radarNowIdx].time) / 60); const rel = diffMin === 0 ? 'jetzt' : (diffMin > 0 ? `+${diffMin} Min` : `${diffMin} Min`); - timeEl.textContent = `${hhmm} · ${rel}${f.dwd && diffMin > 0 ? ' · DWD' : ''}`; - timeEl.classList.toggle('is-forecast', diffMin > 0); + timeEl.textContent = `${hhmm} · ${rel}`; // feste Breite (CSS) → Regler springt nicht + timeEl.classList.toggle('is-forecast', diffMin > 0); // Vorhersage-Frames farblich (statt "· DWD"-Text) } } @@ -1008,6 +1063,14 @@ window.Page_map = (() => { center, zoom, attributionControl: false, maxZoom: 19, dragRotate: false, pitchWithRotate: false, }); + // setTiles bricht beim schnellen Regler-Ziehen laufende Kachel-Requests ab → harmloser + // AbortError. Eigener error-Handler verschluckt ihn, lässt echte Fehler aber durch. + _map.on('error', (e) => { + const err = e && e.error; + const msg = (err && ((err.name || '') + ' ' + (err.message || ''))) || String(e || ''); + if (/abort/i.test(msg)) return; + console.warn('MapLibre:', err || e); + }); // Zwei-Finger-Rotation aus → Pinch ist reines Zoom (weniger moveend, klarere Geste). _map.touchZoomRotate.disableRotation(); _map.touchPitch.disable(); diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index ac1d151..d57863b 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -1996,6 +1996,15 @@ window.Page_routes = (() => { // alles grün, 99 % ab Start (Praxistest René 2026-06-07, Gassirunde Siegenhofen). // Global nur beim ersten Fix oder wenn verloren (Fenster-Treffer > 300 m entfernt). let _navIdxInit = false; + // Runde erkennen: Start ≈ Ende (< 60 m). An einem solchen Start/Ende-Knoten ist der + // ENDPUNKT oft ein paar Meter näher als der Startpunkt — die globale Erst-Suche sprang + // dann sofort ans Track-ENDE → 100 % / 0 km ab Sekunde 1, kein Bellen, alles grün, und + // der gelaufene-Weg-Eintrag wurde fälschlich als komplett gespeichert. Der alte 25-m- + // Gleichstand reichte nicht, wenn der Start >28 m weg lag (Siegenhofen René 2026-06-07, + // Deining Angie 2026-06-09). + const _navIsLoop = track.length > 2 && + _haversineKm(track[0].lat, track[0].lon, + track[track.length - 1].lat, track[track.length - 1].lon) < 0.06; const _closestIdx = (lat, lon) => { const search = (from, to) => { let best = from, bestD = Infinity; @@ -2007,8 +2016,16 @@ window.Page_routes = (() => { }; if (!_navIdxInit) { _navIdxInit = true; - // Erster Fix: global, aber bei Quasi-Gleichstand (< 25 m) den START bevorzugen (Loop!) const g = search(0, track.length - 1); + if (_navIsLoop) { + // Runde: steht man irgendwo in Startnähe (< 150 m), bei 0 % beginnen statt ans + // nahe Track-Ende zu springen. Erst wer weit vom Start steht, ist mitten in die + // Runde eingestiegen → globaler Treffer. Startfenster = erste 15 % (mind. 30 Pkt.). + const win = Math.min(track.length - 1, Math.max(30, Math.floor(track.length * 0.15))); + const s = search(0, win); + return s.bestD < 0.15 ? s.best : g.best; + } + // Punkt-zu-Punkt: bei Quasi-Gleichstand (< 25 m) den START bevorzugen. const s = search(0, Math.min(track.length - 1, 30)); return (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best; } diff --git a/backend/static/landing.html b/backend/static/landing.html index 272122c..9cb3d3f 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 119cc71..c7e474e 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 = '1278'; +const VER = '1292'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/docker-compose.yml b/docker-compose.yml index d984dcd..240f881 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: - DB_PATH=/data/banyaro.db - MEDIA_DIR=/data/media - UMAMI_URL=https://umami.motocamp.de + - KI_MODE=cloud # VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_CONTACT # → kommen aus .env (nicht in Git) healthcheck: diff --git a/tests/js/README.md b/tests/js/README.md index 1b5675e..f0428e7 100644 --- a/tests/js/README.md +++ b/tests/js/README.md @@ -13,5 +13,13 @@ for f in tests/js/test-map-offline*.js; do node "$f" backend/static/js/map-offli - r6: Standort-Grundversorgung (ensureHomeArea: lädt/skippt/Cap, überlebt clear) - r7: selektives Löschen (Korridor-Keep via keepTracks, manuelle Gebiete weg, Komplett-Wipe-Fallback) +Eigenständig (kein Stub-Argument nötig): + +``` +node tests/js/test-nav-loop-closestidx.js +``` + +- nav-loop-closestidx: Navi-Erst-Fix bei Runden springt nicht ans Track-Ende (spiegelt `_closestIdx` aus `js/pages/routes.js`) — Bugfix Angie/Deining 09.06.2026 + ⚠️ Node 21+: eingebautes `navigator`-Global — Stubs via `Object.defineProperty(globalThis, 'navigator', …)`, ein einfaches `global.navigator =` wird still verschluckt. diff --git a/tests/js/test-nav-loop-closestidx.js b/tests/js/test-nav-loop-closestidx.js new file mode 100644 index 0000000..df45372 --- /dev/null +++ b/tests/js/test-nav-loop-closestidx.js @@ -0,0 +1,98 @@ +// Navi-Erst-Fix bei RUNDEN: der Startindex darf nicht ans Track-Ende springen. +// +// Spiegelt die _closestIdx-Erst-Fix-Logik aus js/pages/routes.js (_startNav). An einem +// Start/Ende-Knoten einer Runde ist der ENDPUNKT oft ein paar Meter näher als der +// Startpunkt; die alte globale Suche sprang dann sofort ans Track-Ende → 100 % / 0 km ab +// Sekunde 1 (Angie, Deining-Runde 09.06.2026). Bei Änderung BEIDE Stellen anpassen. +// +// Hinweis: bewusst eine Nachbildung — die echte Funktion ist eine Closure in _startNav +// und nicht exportierbar, ohne routes.js umzubauen. + +const _haversineKm = (lat1, lon1, lat2, lon2) => { + const R = 6371, dLat = (lat2 - lat1) * Math.PI / 180, dLon = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +}; + +// Erst-Fix-Index für gegebenen track + Userposition (1:1 aus routes.js). +function firstFixIdx(track, lat, lon) { + const search = (from, to) => { + let best = from, bestD = Infinity; + for (let i = from; i <= to; i++) { + const d = _haversineKm(lat, lon, track[i].lat, track[i].lon); + if (d < bestD) { bestD = d; best = i; } + } + return { best, bestD }; + }; + const isLoop = track.length > 2 && + _haversineKm(track[0].lat, track[0].lon, + track[track.length - 1].lat, track[track.length - 1].lon) < 0.06; + const g = search(0, track.length - 1); + if (isLoop) { + const win = Math.min(track.length - 1, Math.max(30, Math.floor(track.length * 0.15))); + const s = search(0, win); + return { idx: s.bestD < 0.15 ? s.best : g.best, isLoop }; + } + const s = search(0, Math.min(track.length - 1, 30)); + return { idx: (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best, isLoop }; +} + +// Die ALTE Logik (vor dem Fix) — nur zum Beweis, dass der Fix wirklich etwas ändert. +function firstFixIdxOld(track, lat, lon) { + const search = (from, to) => { + let best = from, bestD = Infinity; + for (let i = from; i <= to; i++) { + const d = _haversineKm(lat, lon, track[i].lat, track[i].lon); + if (d < bestD) { bestD = d; best = i; } + } + return { best, bestD }; + }; + const g = search(0, track.length - 1); + const s = search(0, Math.min(track.length - 1, 30)); + return (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best; +} + +// --- Synthetische Deining-artige Runde ------------------------------------- +const C = { lat: 48.07, lon: 11.50 }; +const mLat = m => m / 111320; +const mLon = (m, lat) => m / (111320 * Math.cos(lat * Math.PI / 180)); +// Punkt auf einem Kreis: Winkel von Nord, im Uhrzeigersinn. +const onCircle = (deg, r) => { + const rad = deg * Math.PI / 180; + return { lat: C.lat + mLat(r * Math.cos(rad)), lon: C.lon + mLon(r * Math.sin(rad), C.lat) }; +}; + +const N = 40, R = 80; // 40 Punkte auf 80-m-Kreis, lange Runde von 0°→329° +const track = []; +for (let i = 0; i < N; i++) track.push(onCircle(i / (N - 1) * 329, R)); +// User steht 3 m außerhalb des ENDpunkts (329°) → näher am Ende als am Start. +const user = onCircle(329, R + 3); + +const startEndM = _haversineKm(track[0].lat, track[0].lon, + track[N - 1].lat, track[N - 1].lon) * 1000; +const dStart = _haversineKm(user.lat, user.lon, track[0].lat, track[0].lon) * 1000; +const dEnd = _haversineKm(user.lat, user.lon, track[N - 1].lat, track[N - 1].lon) * 1000; +console.log(`Runde: Start↔Ende ${startEndM.toFixed(0)} m | User→Start ${dStart.toFixed(0)} m, User→Ende ${dEnd.toFixed(0)} m`); + +// 1. Loop wird erkannt (Start ≈ Ende < 60 m) +const res = firstFixIdx(track, user.lat, user.lon); +if (!res.isLoop) throw new Error('Runde nicht als Loop erkannt'); + +// 2. Erst-Fix landet im STARTbereich, NICHT am Track-Ende +console.log('Erst-Fix-Index:', res.idx, '(von', N - 1 + ')'); +if (res.idx > Math.floor(N * 0.15)) throw new Error(`Erst-Fix sprang weg vom Start (idx ${res.idx})`); + +// 3. Beweis: die alte Logik wäre hier ans Ende gesprungen (100 %) +const old = firstFixIdxOld(track, user.lat, user.lon); +console.log('Alte Logik-Index:', old); +if (old !== N - 1) throw new Error('Erwartet: alte Logik springt ans Ende — Testfall trifft den Bug nicht mehr'); + +// 4. Punkt-zu-Punkt-Route (kein Loop): User am Start → 0 %, am Ende → bleibt sinnvoll +const ptp = []; +for (let i = 0; i < N; i++) ptp.push({ lat: C.lat + mLat(i * 25), lon: C.lon }); // 25-m-Schritte nach Norden +const ptpRes = firstFixIdx(ptp, ptp[0].lat, ptp[0].lon); +if (ptpRes.isLoop) throw new Error('Gerade Route fälschlich als Loop erkannt'); +if (ptpRes.idx !== 0) throw new Error(`Punkt-zu-Punkt am Start sollte idx 0 sein, war ${ptpRes.idx}`); + +console.log('\nALLE NAV-LOOP-TESTS BESTANDEN');