KI-Vision-Model, Breed-Scraper, Karte/Routen + Release v1292

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).
This commit is contained in:
rene 2026-06-14 20:23:21 +02:00
parent 51aad6cf1b
commit f7370028da
17 changed files with 322 additions and 100 deletions

View file

@ -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 | | 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 | | Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst |
| Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE | | 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 | | 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) | | 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 | | Play Store (Android) | 🔴 ON HOLD | 12 Closed-Tester / 14 Tage fehlen |
@ -41,7 +41,9 @@ Legende: 🟢 läuft/erledigt · 🟡 angefangen · ⬜ offen · 💡 Idee ·
## ✅ Erledigt ## ✅ Erledigt
- [x] 1000 Flyer A5 (zweiseitig) gedruckt — 03.06.2026 - [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] 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] Influencer-Outreach Runde 1 (5) + Runde 2 (13) — Mai 2026
- [x] SEO-Grundlagen (llms.txt, Landing About-Section) - [x] SEO-Grundlagen (llms.txt, Landing About-Section)

View file

@ -1 +1 @@
1278 1292

View file

@ -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_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") LOCAL_MODEL = os.getenv("KI_LOCAL_MODEL", "gemma-4-31b-it")
CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-sonnet-4-6") 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", "") ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "")
CLOUD_WEEKLY_LIMIT = int(os.getenv("KI_CLOUD_WEEKLY_LIMIT", "20")) CLOUD_WEEKLY_LIMIT = int(os.getenv("KI_CLOUD_WEEKLY_LIMIT", "20"))

View file

@ -298,7 +298,7 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array."""
def _sync_call(): def _sync_call():
client = anthropic.Anthropic(api_key=api_key) client = anthropic.Anthropic(api_key=api_key)
return client.messages.create( return client.messages.create(
model="claude-opus-4-7", model=ki_module.VISION_MODEL,
max_tokens=500, max_tokens=500,
messages=[{ messages=[{
"role": "user", "role": "user",

View file

@ -360,13 +360,21 @@ async def _fetch_wikimedia_photo(name: str) -> str | None:
return None return None
async def _haiku_complete(prompt: str) -> str: async def _haiku_complete(prompt: str) -> tuple[str, str]:
"""Claude Haiku direkt aufrufen (immer Cloud, für maximale Genauigkeit).""" """
import anthropic 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", "") key = os.getenv("ANTHROPIC_API_KEY", "")
if not key:
raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt") # 1. Bevorzugt: Claude Haiku direkt (günstigstes Cloud-Modell)
if key:
try:
import anthropic
def _call(): def _call():
client = anthropic.Anthropic(api_key=key) client = anthropic.Anthropic(api_key=key)
@ -383,7 +391,16 @@ async def _haiku_complete(prompt: str) -> str:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
resp = await loop.run_in_executor(None, _call) resp = await loop.run_in_executor(None, _call)
return resp.content[0].text.strip() 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: 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)) logger.info("[DRY-RUN] Gefunden: %s (WP-%s, %d Zeichen)", name, wiki_lang.upper(), len(wiki_text))
return True 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) prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text)
try: try:
raw = await _haiku_complete(prompt) raw, used_model = await _haiku_complete(prompt)
except Exception as e: 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) await asyncio.sleep(3)
return False return False
@ -435,7 +452,7 @@ async def _enrich_one(rasse, dry_run: bool = False) -> bool:
if "temperament" in updates: if "temperament" in updates:
updates["temperament"] = translate_temperament(updates["temperament"]) updates["temperament"] = translate_temperament(updates["temperament"])
updates["ki_enriched"] = 1 updates["ki_enriched"] = 1
updates["ki_model"] = _HAIKU_MODEL updates["ki_model"] = used_model
updates["ki_source"] = f"wikipedia_{wiki_lang}" updates["ki_source"] = f"wikipedia_{wiki_lang}"
cols = ", ".join(f"{k}=?" for k in updates) cols = ", ".join(f"{k}=?" for k in updates)

View file

@ -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. Returns dict mit aggregierten Scores und Einzelergebnissen.
""" """
import sys, os import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database import db from database import db
import ki
ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") if ki.KI_MODE == "off":
if not ANTHROPIC_KEY: raise RuntimeError("KI ist deaktiviert (KI_MODE=off) — Evaluierung nicht möglich.")
raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt — Evaluierung benötigt Cloud.")
with db() as conn: with db() as conn:
rassen = conn.execute( rassen = conn.execute(
@ -65,8 +69,7 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict:
wohnung_geeignet, temperament, ki_model wohnung_geeignet, temperament, ki_model
FROM wiki_rassen FROM wiki_rassen
WHERE ki_enriched = 1 WHERE ki_enriched = 1
AND ki_model IS NOT NULL AND (ki_model IS NULL OR ki_model NOT LIKE 'claude%')
AND ki_model NOT LIKE 'claude%'
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT ?""", LIMIT ?""",
(sample_size,), (sample_size,),
@ -75,10 +78,10 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict:
if not rassen: if not rassen:
return {"error": "Keine angereicherten Rassen gefunden."} return {"error": "Keine angereicherten Rassen gefunden."}
import anthropic _EVAL_SYSTEM = "Du bist ein präziser Qualitätsprüfer. Antworte ausschließlich als JSON."
client = anthropic.Anthropic(api_key=ANTHROPIC_KEY)
results = [] results = []
sources = set()
totals = {"vollstaendigkeit": 0, "korrektheit": 0, totals = {"vollstaendigkeit": 0, "korrektheit": 0,
"sprachqualitaet": 0, "konsistenz": 0, "gesamt": 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), data=json.dumps(data, ensure_ascii=False, indent=2),
) )
try: try:
def _call(): raw, source = await ki.complete(
return client.messages.create( prompt,
model="claude-haiku-4-5-20251001", system=_EVAL_SYSTEM,
max_tokens=256, max_tokens=256,
system=[{ json_mode=True,
"type": "text", user_id=user_id,
"text": "Du bist ein präziser Qualitätsprüfer. Antworte ausschließlich als JSON.", return_source=True,
"cache_control": {"type": "ephemeral"},
}],
messages=[{"role": "user", "content": prompt}],
) )
loop = asyncio.get_event_loop() sources.add(source)
resp = await loop.run_in_executor(None, _call)
raw = resp.content[0].text.strip()
# JSON extrahieren # JSON extrahieren (lokale Modelle wrappen gern in ```json … ```)
import re import re
match = re.search(r"\{[\s\S]+\}", raw) match = re.search(r"\{[\s\S]+\}", raw)
scores = json.loads(match.group(0)) if match else {} 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]) 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 {} 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 { return {
"sample_size": len(rassen), "sample_size": len(rassen),
"evaluated": count, "evaluated": count,
"averages": averages, "averages": averages,
"judge_source": judge_source, # "cloud" (Claude) oder "local" (LM Studio)
"results": results, "results": results,
} }

View file

@ -3088,12 +3088,23 @@ html.modal-open {
} }
.rdr-play svg { width: 14px; height: 14px; } .rdr-play svg { width: 14px; height: 14px; }
.rdr-play:active { background: var(--c-border); } .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 { .rdr-time {
flex-shrink: 0; flex-shrink: 0;
font-size: 11px; font-weight: 600; font-size: 11px; font-weight: 600;
font-variant-numeric: tabular-nums; 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 */ .rdr-time.is-forecast { color: var(--c-primary); } /* Nowcast/Vorhersage-Frames hervorgehoben */

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title> <title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen --> <!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1278"></script> <script src="/js/boot-early.js?v=1292"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1278"> <link rel="stylesheet" href="/css/design-system.css?v=1292">
<link rel="stylesheet" href="/css/layout.css?v=1278"> <link rel="stylesheet" href="/css/layout.css?v=1292">
<link rel="stylesheet" href="/css/components.css?v=1278"> <link rel="stylesheet" href="/css/components.css?v=1292">
<link rel="stylesheet" href="/css/utilities.css?v=1278"> <link rel="stylesheet" href="/css/utilities.css?v=1292">
<link rel="stylesheet" href="/css/lists.css?v=1278"> <link rel="stylesheet" href="/css/lists.css?v=1292">
</head> </head>
<body> <body>
@ -620,12 +620,12 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1278"></script> <script src="/js/api.js?v=1292"></script>
<script src="/js/ui.js?v=1278"></script> <script src="/js/ui.js?v=1292"></script>
<script src="/js/app.js?v=1278"></script> <script src="/js/app.js?v=1292"></script>
<script src="/js/worlds.js?v=1278"></script> <script src="/js/worlds.js?v=1292"></script>
<script src="/js/offline-indicator.js?v=1278"></script> <script src="/js/offline-indicator.js?v=1292"></script>
<script src="/js/contact-form.js?v=1278"></script> <script src="/js/contact-form.js?v=1292"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->
@ -635,7 +635,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) --> <!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1278"></script> <script src="/js/boot.js?v=1292"></script>
</body> </body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 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_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION; window.APP_VERSION = APP_VERSION;

View file

@ -1419,7 +1419,10 @@ window.Page_admin = (() => {
<tbody>${rows}</tbody> <tbody>${rows}</tbody>
</table> </table>
</div>`; </div>`;
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) { } catch (err) {
res.textContent = '✗ Fehler: ' + (err.message || err); res.textContent = '✗ Fehler: ' + (err.message || err);
} finally { } finally {

View file

@ -451,6 +451,9 @@ window.Page_map = (() => {
let _radarNowIdx = 0; // Index des "jetzt"-Frames (letzte Vergangenheit) let _radarNowIdx = 0; // Index des "jetzt"-Frames (letzte Vergangenheit)
let _radarPlaying = false; let _radarPlaying = false;
let _radarPlayTimer = null; 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() { async function _toggleRadar() {
if (!App.hasPro(_appState?.user)) { if (!App.hasPro(_appState?.user)) {
@ -461,7 +464,9 @@ window.Page_map = (() => {
if (_radarActive) { if (_radarActive) {
_radarActive = false; _radarActive = false;
_radarPause(); _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); clearInterval(_radarTimer);
document.getElementById('map-radar-timeline')?.remove(); document.getElementById('map-radar-timeline')?.remove();
btn?.classList.remove('active'); btn?.classList.remove('active');
@ -602,67 +607,107 @@ window.Page_map = (() => {
async function _loadRadar() { async function _loadRadar() {
if (!_radarActive || !_map) return; if (!_radarActive || !_map) return;
try { 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 data = await resp.json();
const past = data.radar?.past || [], nowcast = data.radar?.nowcast || []; const past = data.radar?.past || [], nowcast = data.radar?.nowcast || [];
if (!past.length && !nowcast.length) return; if (!past.length && !nowcast.length) return;
_radarHost = data.host || _radarHost; _radarHost = data.host || _radarHost;
const rvUrl = f => `${_radarHost}${f.path}/256/{z}/{x}/{y}/4/1_1.png`; const rvUrl = f => `${_radarHost}${f.path}/256/{z}/{x}/{y}/4/1_1.png`;
// Default: RainViewer komplett (~2h Vergangenheit + ~30 min Nowcast) // Symmetrische ±2h-Zeitleiste: letzte 2 h (RainViewer) | jetzt | nächste 2 h (DWD/Nowcast)
let frames = [...past, ...nowcast].map(f => ({ url: rvUrl(f), time: f.time })); const WINDOW = 2 * 60 * 60; // 2 h je Seite
let nowIdx = Math.max(0, past.length - 1); // "jetzt" = letzter Vergangenheits-Frame const nowSec = Math.floor(Date.now() / 1000); // Echtzeit-Referenz (Geräteuhr ist zuverlässig)
// DWD-Vorhersage (0120 min, 5-Min-Schritte): ersetzt den RainViewer-Nowcast, // Vergangenheit: RainViewer der letzten 2 h
// Vergangenheit bleibt RainViewer (docs/DWD_RAIN_FORECAST_PLAN.md). 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 (0120 min, 5-Min-Schritte) bevorzugt (docs/DWD_RAIN_FORECAST_PLAN.md)
if (_dwdEnabled() && _engineGL && _mapInDwdCoverage()) { if (_dwdEnabled() && _engineGL && _mapInDwdCoverage()) {
try { try {
const r = await fetch('/radar/manifest.json', { cache: 'no-store' }); const r = await fetch('/radar/manifest.json', { cache: 'no-store' });
if (r.ok) { if (r.ok) {
const man = await r.json(); const man = await r.json();
const runT = Math.floor(Date.parse(man.run_time_utc) / 1000); const runT = Math.floor(Date.parse(man.run_time_utc) / 1000);
// Nur wenn der Lauf frisch ist (< 30 min) — sonst RainViewer-Fallback // Frische des DWD-Laufs gegen die ECHTZEIT prüfen (< 30 min) — NICHT gegen den
if (man.frames?.length && (Date.now() / 1000 - runT) < 1800) { // jüngsten RainViewer-Frame, der deutlich nachhängen kann (sonst fällt DWD raus).
const pastRv = past.filter(f => f.time <= runT).map(f => ({ url: rvUrl(f), time: f.time })); if (man.frames?.length && Math.abs(nowSec - runT) < 1800) {
const dwd = man.frames.map(fr => ({ const dwd = man.frames.map(fr => ({
url: `pmtiles://${location.origin}/radar/${man.path}/${fr.file}/{z}/{x}/{y}`, url: `pmtiles://${location.origin}/radar/${man.path}/${fr.file}/{z}/{x}/{y}`,
time: runT + fr.lead_min * 60, time: runT + fr.lead_min * 60,
lead: fr.lead_min,
dwd: true, dwd: true,
})); }));
frames = [...pastRv, ...dwd]; nowFrame = dwd.find(f => f.lead === 0) || null; // lead 0 = "jetzt"
nowIdx = pastRv.length; // DWD 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 */ } } catch (e) { /* offline/kein Manifest → RainViewer-Fallback */ }
} }
_radarFrames = frames; // Kein DWD-"jetzt"? → jüngsten Vergangenheits-Frame (sonst ältesten Zukunfts-Frame) als "jetzt"
_radarNowIdx = nowIdx; 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; if (_radarIdx == null || _radarIdx >= _radarFrames.length) _radarIdx = _radarNowIdx;
_showRadarFrame(_radarIdx); _showRadarFrame(_radarIdx);
_buildRadarTimeline(); _buildRadarTimeline();
} catch { /* still */ } } catch { /* still */ }
} }
function _radarUrl(idx) {
return _radarFrames[idx].url;
}
// Frame anzeigen — wenn möglich smooth via setTiles (kein Flackern), sonst Layer neu. // Frame anzeigen — wenn möglich smooth via setTiles (kein Flackern), sonst Layer neu.
function _showRadarFrame(idx) { function _showRadarFrame(idx) {
if (!_radarActive || !_radarFrames[idx]) return; if (!_radarActive || !_radarFrames[idx]) return;
_radarIdx = idx; _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'); 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]); src.setTiles([url]);
} else { } else {
if (_radarLayer) _wxRemoveRaster(_radarLayer); if (_radarLayer) _wxRemoveRaster(_radarLayer);
_radarLayer = _wxAddRaster('radar', url, 0.7, 7); _radarLayer = _wxAddRaster('radar', url, 0.7, 7);
_radarLayerKind = kind;
} }
_updateRadarTimelineUI(); _updateRadarTimelineUI();
} }
// Slider-Position (01000) ↔ 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() { function _buildRadarTimeline() {
if (!_radarFrames.length) return; if (!_radarFrames.length) return;
let el = document.getElementById('map-radar-timeline'); let el = document.getElementById('map-radar-timeline');
@ -674,17 +719,27 @@ window.Page_map = (() => {
<button id="rdr-play" class="rdr-play" type="button" aria-label="Abspielen"> <button id="rdr-play" class="rdr-play" type="button" aria-label="Abspielen">
<svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px"><use href="/icons/phosphor.svg#play"></use></svg> <svg class="ph-icon" aria-hidden="true" style="width:18px;height:18px"><use href="/icons/phosphor.svg#play"></use></svg>
</button> </button>
<input id="rdr-slider" class="rdr-slider" type="range" min="0" max="${_radarFrames.length - 1}" value="${_radarIdx}" step="1" aria-label="Radar-Zeit"> <div class="rdr-track-wrap">
<span class="rdr-now-tick" aria-hidden="true"></span>
<input id="rdr-slider" class="rdr-slider" type="range" min="0" max="1000" value="${_radarIdxToPos(_radarIdx)}" step="1" aria-label="Radar-Zeit">
</div>
<span id="rdr-time" class="rdr-time"></span>`; <span id="rdr-time" class="rdr-time"></span>`;
document.getElementById('central-map')?.appendChild(el); document.getElementById('central-map')?.appendChild(el);
el.querySelector('#rdr-play').addEventListener('click', _toggleRadarPlay); el.querySelector('#rdr-play').addEventListener('click', _toggleRadarPlay);
el.querySelector('#rdr-slider').addEventListener('input', e => { 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(); _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. // Breite an die Status-Pill angleichen → gleiche linke + rechte Kante.
const pill = document.querySelector('.map-statusbar'); const pill = document.querySelector('.map-statusbar');
@ -696,7 +751,7 @@ window.Page_map = (() => {
const slider = document.getElementById('rdr-slider'); const slider = document.getElementById('rdr-slider');
const timeEl = document.getElementById('rdr-time'); const timeEl = document.getElementById('rdr-time');
const playBtn = document.getElementById('rdr-play'); 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'}`); if (playBtn) playBtn.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${_radarPlaying ? 'pause' : 'play'}`);
const f = _radarFrames[_radarIdx]; const f = _radarFrames[_radarIdx];
if (timeEl && f) { 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 hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
const diffMin = Math.round((f.time - _radarFrames[_radarNowIdx].time) / 60); const diffMin = Math.round((f.time - _radarFrames[_radarNowIdx].time) / 60);
const rel = diffMin === 0 ? 'jetzt' : (diffMin > 0 ? `+${diffMin} Min` : `${diffMin} Min`); const rel = diffMin === 0 ? 'jetzt' : (diffMin > 0 ? `+${diffMin} Min` : `${diffMin} Min`);
timeEl.textContent = `${hhmm} · ${rel}${f.dwd && diffMin > 0 ? ' · DWD' : ''}`; timeEl.textContent = `${hhmm} · ${rel}`; // feste Breite (CSS) → Regler springt nicht
timeEl.classList.toggle('is-forecast', diffMin > 0); timeEl.classList.toggle('is-forecast', diffMin > 0); // Vorhersage-Frames farblich (statt "· DWD"-Text)
} }
} }
@ -1008,6 +1063,14 @@ window.Page_map = (() => {
center, zoom, attributionControl: false, center, zoom, attributionControl: false,
maxZoom: 19, dragRotate: false, pitchWithRotate: 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). // Zwei-Finger-Rotation aus → Pinch ist reines Zoom (weniger moveend, klarere Geste).
_map.touchZoomRotate.disableRotation(); _map.touchZoomRotate.disableRotation();
_map.touchPitch.disable(); _map.touchPitch.disable();

View file

@ -1996,6 +1996,15 @@ window.Page_routes = (() => {
// alles grün, 99 % ab Start (Praxistest René 2026-06-07, Gassirunde Siegenhofen). // 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). // Global nur beim ersten Fix oder wenn verloren (Fenster-Treffer > 300 m entfernt).
let _navIdxInit = false; 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 _closestIdx = (lat, lon) => {
const search = (from, to) => { const search = (from, to) => {
let best = from, bestD = Infinity; let best = from, bestD = Infinity;
@ -2007,8 +2016,16 @@ window.Page_routes = (() => {
}; };
if (!_navIdxInit) { if (!_navIdxInit) {
_navIdxInit = true; _navIdxInit = true;
// Erster Fix: global, aber bei Quasi-Gleichstand (< 25 m) den START bevorzugen (Loop!)
const g = search(0, track.length - 1); 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)); const s = search(0, Math.min(track.length - 1, 30));
return (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best; return (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best;
} }

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1278"></script> <script src="/js/landing-init.js?v=1292"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title> <title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser oder als native iPhone-App (Ban Yaro Go)."> <meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, direkt im Browser oder als native iPhone-App (Ban Yaro Go).">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz"> <meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

View file

@ -4,7 +4,7 @@
============================================================ */ ============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab // ← 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_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten

View file

@ -14,6 +14,7 @@ services:
- DB_PATH=/data/banyaro.db - DB_PATH=/data/banyaro.db
- MEDIA_DIR=/data/media - MEDIA_DIR=/data/media
- UMAMI_URL=https://umami.motocamp.de - UMAMI_URL=https://umami.motocamp.de
- KI_MODE=cloud
# VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_CONTACT # VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_CONTACT
# → kommen aus .env (nicht in Git) # → kommen aus .env (nicht in Git)
healthcheck: healthcheck:

View file

@ -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) - r6: Standort-Grundversorgung (ensureHomeArea: lädt/skippt/Cap, überlebt clear)
- r7: selektives Löschen (Korridor-Keep via keepTracks, manuelle Gebiete weg, Komplett-Wipe-Fallback) - 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', …)`, ⚠️ Node 21+: eingebautes `navigator`-Global — Stubs via `Object.defineProperty(globalThis, 'navigator', …)`,
ein einfaches `global.navigator =` wird still verschluckt. ein einfaches `global.navigator =` wird still verschluckt.

View file

@ -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');