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:
parent
51aad6cf1b
commit
f7370028da
17 changed files with 322 additions and 100 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1278
|
||||
1292
|
||||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- 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 -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1278">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1278">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1278">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1278">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1278">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=1292">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=1292">
|
||||
<link rel="stylesheet" href="/css/components.css?v=1292">
|
||||
<link rel="stylesheet" href="/css/utilities.css?v=1292">
|
||||
<link rel="stylesheet" href="/css/lists.css?v=1292">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -620,12 +620,12 @@
|
|||
<div id="modal-container"></div>
|
||||
|
||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||
<script src="/js/api.js?v=1278"></script>
|
||||
<script src="/js/ui.js?v=1278"></script>
|
||||
<script src="/js/app.js?v=1278"></script>
|
||||
<script src="/js/worlds.js?v=1278"></script>
|
||||
<script src="/js/offline-indicator.js?v=1278"></script>
|
||||
<script src="/js/contact-form.js?v=1278"></script>
|
||||
<script src="/js/api.js?v=1292"></script>
|
||||
<script src="/js/ui.js?v=1292"></script>
|
||||
<script src="/js/app.js?v=1292"></script>
|
||||
<script src="/js/worlds.js?v=1292"></script>
|
||||
<script src="/js/offline-indicator.js?v=1292"></script>
|
||||
<script src="/js/contact-form.js?v=1292"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -635,7 +635,7 @@
|
|||
|
||||
|
||||
<!-- 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1419,7 +1419,10 @@ window.Page_admin = (() => {
|
|||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</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) {
|
||||
res.textContent = '✗ Fehler: ' + (err.message || err);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -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 = (() => {
|
|||
<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>
|
||||
</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>`;
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
98
tests/js/test-nav-loop-closestidx.js
Normal file
98
tests/js/test-nav-loop-closestidx.js
Normal 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');
|
||||
Loading…
Add table
Add a link
Reference in a new issue