Compare commits

..

No commits in common. "72ee339860e76187c4e665dbf0f0529c6e29d932" and "f5b5bb2289be023408edb0bf458cfdc28874883d" have entirely different histories.

77 changed files with 1247 additions and 5861 deletions

8
.gitignore vendored
View file

@ -13,11 +13,3 @@ __pycache__/
.claude/worktrees/
Ban Yaro - Google Play package/
/unsplash/
# Selbst-gehostete Vektor-Tiles (groß, gehören nicht ins Repo)
tiles/build/
*.pmtiles
*.osm.pbf
*.mbtiles
tiles/build.log
tiles/.DS_Store

View file

@ -24,11 +24,10 @@ TAR_EXCLUDE := --exclude='.git' \
--exclude='./backend/__pycache__' \
--exclude='./.env' \
--exclude='./*.db' \
--exclude='./tiles' \
--exclude='./.DS_Store'
.PHONY: help deploy deploy-clean staging release sync push restart build stop status \
logs logs-f shell db dev clean-cache check-ssh reports bump test tiles tiles-deploy
logs logs-f shell db dev clean-cache check-ssh reports bump test
# ----------------------------------------------------------
# SSH-Prüfung — Abhängigkeit aller DS-Befehle
@ -140,56 +139,6 @@ staging-db: check-ssh
sudo chmod 666 $(DS_PATH_STAGING)/data/banyaro.db && \
echo '✓ DB kopiert'"
# ----------------------------------------------------------
# TILES — DACH-Vektortiles (planetiler → PMTiles), lokal bauen + ausliefern
# Voraussetzung: Docker Desktop läuft, osmium installiert (brew install osmium-tool).
# make tiles DACH neu generieren (download → merge → planetiler)
# make tiles-deploy dach.pmtiles auf Staging ausliefern (atomar)
# make tiles-deploy ENV=prod dach.pmtiles auf Produktion ausliefern (atomar)
# Monatlich neu generieren hält die Karte aktuell. Datei liegt im data-Volume,
# NICHT im Image — wird per Range-Route (/tiles) ausgeliefert.
# ----------------------------------------------------------
TILES_DIR := tiles/build
# DACH + alle angrenzenden Länder (15). Reihenfolge egal — osmium merge -H + time-filter
# dedupliziert Grenz-Nodes. Output bleibt dach.pmtiles (Frontend referenziert den Namen).
TILES_REGIONS := germany austria switzerland france italy czech-republic poland \
slovakia hungary slovenia netherlands belgium luxembourg denmark liechtenstein
PLANETILER_IMAGE := ghcr.io/onthegomap/planetiler:latest
TILES_TARGET := $(if $(filter prod,$(ENV)),$(DS_PATH),$(DS_PATH_STAGING))
tiles:
@mkdir -p $(TILES_DIR)
@echo "→ Geofabrik-Extrakte laden ($(TILES_REGIONS))..."
@for r in $(TILES_REGIONS); do \
echo " $$r"; \
curl -fsSL -o $(TILES_DIR)/$$r.osm.pbf https://download.geofabrik.de/europe/$$r-latest.osm.pbf; done
@echo "→ merge (History) + time-filter dedup → dach.osm.pbf..."
@# Geofabrik-Extrakte können versetzte Stände haben (z.B. germany älter als at/ch)
@# Grenz-Nodes mit abweichender Version. Als History mergen + auf 'jetzt' snapshotten
@# liefert genau eine Version pro ID (planetiler braucht eindeutige, sortierte IDs).
@osmium merge -H $(foreach r,$(TILES_REGIONS),$(TILES_DIR)/$(r).osm.pbf) -o $(TILES_DIR)/dach-hist.osm.pbf --overwrite
@osmium time-filter $(TILES_DIR)/dach-hist.osm.pbf -o $(TILES_DIR)/dach.osm.pbf --overwrite
@# History + Einzel-PBFs jetzt freigeben (spart ~Quellsumme an Spitzen-Plattenplatz vor planetiler).
@rm -f $(TILES_DIR)/dach-hist.osm.pbf $(foreach r,$(TILES_REGIONS),$(TILES_DIR)/$(r).osm.pbf)
@echo "→ planetiler → dach.pmtiles (disk-backed mmap)..."
@docker run --rm -v "$(CURDIR)/$(TILES_DIR):/data" $(PLANETILER_IMAGE) \
--osm-path=/data/dach.osm.pbf --download --output=/data/dach.pmtiles --force \
--storage=mmap --nodemap-storage=mmap
@echo ""
@echo " ✓ Tiles gebaut:"; ls -lh $(TILES_DIR)/dach.pmtiles
tiles-deploy: check-ssh
@if [ ! -f $(TILES_DIR)/dach.pmtiles ]; then echo "$(TILES_DIR)/dach.pmtiles fehlt — erst 'make tiles'"; exit 1; fi
@echo "→ Ausliefern nach $(TILES_TARGET)/data/tiles/ (atomarer Swap)..."
@ssh $(DS_HOST) "mkdir -p $(TILES_TARGET)/data/tiles"
@scp -O $(TILES_DIR)/dach.pmtiles $(DS_HOST):$(TILES_TARGET)/data/tiles/dach.pmtiles.tmp
@ssh $(DS_HOST) "mv -f $(TILES_TARGET)/data/tiles/dach.pmtiles.tmp $(TILES_TARGET)/data/tiles/dach.pmtiles"
@echo " ✓ dach.pmtiles ausgeliefert ($(if $(filter prod,$(ENV)),PRODUKTION,Staging))"
@# Cache-Bust: TILES_VER in map-gl-style.js hochzählen (sonst liefert der Browser bis 24h alte Tiles).
@NEWVER=$$(date +%Y%m%d%H%M); \
sed -i '' "s/var TILES_VER = '[0-9]*';/var TILES_VER = '$$NEWVER';/" backend/static/js/map-gl-style.js; \
echo " ↻ TILES_VER → $$NEWVER — JETZT Frontend ausliefern: make bump && make $(if $(filter prod,$(ENV)),deploy,staging)"
# ----------------------------------------------------------
# RELEASE — develop → main → Production (VERSION= pflichtangabe)
# Beispiel: make release VERSION=1.1.0

View file

@ -1 +1 @@
1219
1161

View file

@ -111,7 +111,6 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
response.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' https://umami.motocamp.de; " # ohne unsafe-inline/eval — alle Inline-Scripts extrahiert
"worker-src 'self' blob:; " # 'self' = Service Worker (sw.js); blob: = MapLibre-GL-Worker
"style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen für jetzt)
"img-src 'self' data: blob: https:; "
"connect-src 'self' https:; "
@ -372,100 +371,6 @@ app.mount("/js", StaticFiles(directory=f"{STATIC_DIR}/js"), name="js")
app.mount("/icons", StaticFiles(directory=f"{STATIC_DIR}/icons"), name="icons")
app.mount("/img", StaticFiles(directory=f"{STATIC_DIR}/img"), name="img")
# Selbst-gehostete Vektor-Tiles (.pmtiles) — liegen im data-Volume, NICHT im Image.
# WICHTIG: Starlettes StaticFiles/FileResponse liefert hinter unserer BaseHTTPMiddleware
# KEINE Range-Requests (206) — app-weit kommt nur 200 ohne Accept-Ranges zurück.
# MapLibre/pmtiles BRAUCHT aber Byte-Ranges (liest einzelne Tiles aus dem Single-File).
# Daher eine eigene Route, die 206 als normales Response (Byte-Slice) zurückgibt — das
# überlebt die Middleware. Für Produktion/Skalierung gehört das hinter nginx/NPM direkt
# (Range nativ, keine App-CPU) — siehe docs/TILE_SERVER_HANDOVER.md, Entscheidung #2.
_TILES_DIR = os.getenv("TILES_DIR", "/data/tiles")
# Glyphs (Font-PBFs) für MapLibre-Labels — kleine Static-Files (kein Range nötig),
# liegen im data-Volume unter tiles/fonts/{fontstack}/{range}.pbf.
_FONTS_DIR = os.getenv("FONTS_DIR", os.path.join(_TILES_DIR, "fonts"))
if os.path.isdir(_FONTS_DIR):
app.mount("/fonts", StaticFiles(directory=_FONTS_DIR), name="fonts")
@app.api_route("/tiles/{filename}", methods=["GET", "HEAD"])
async def serve_tile(filename: str, request: Request):
# Kein Path-Traversal
if "/" in filename or "\\" in filename or ".." in filename:
return Response(status_code=404)
path = os.path.join(_TILES_DIR, filename)
if not os.path.isfile(path):
return Response(status_code=404)
file_size = os.path.getsize(path)
_mtime = int(os.path.getmtime(path))
_etag = f'"{file_size:x}-{_mtime:x}"'
# Versionierte URL (?v=…) ist inhaltsstabil → lange + immutable cachen. OHNE Version nur kurz cachen,
# damit ein Tile-Swap (gleiche URL, neuer Inhalt) sich innerhalb ~1 Min von selbst heilt — sonst
# liefert der Browser bis zu 24h die alten PMTiles-Bytes (alte Abdeckung).
_versioned = "v" in request.query_params
_cache = "public, max-age=31536000, immutable" if _versioned else "public, max-age=60"
base_headers = {"Accept-Ranges": "bytes", "Cache-Control": _cache, "ETag": _etag}
if request.method == "HEAD":
return Response(
status_code=200, media_type="application/octet-stream",
headers={**base_headers, "Content-Length": str(file_size)},
)
range_header = request.headers.get("range")
if range_header and range_header.startswith("bytes="):
rng = range_header[6:].split(",")[0] # nur erster Range (pmtiles nutzt single-range)
start_s, _, end_s = rng.partition("-")
try:
if start_s == "": # Suffix-Range "bytes=-N"
length = int(end_s)
start = max(0, file_size - length)
end = file_size - 1
else:
start = int(start_s)
end = int(end_s) if end_s else file_size - 1
except ValueError:
return Response(status_code=416, headers={**base_headers, "Content-Range": f"bytes */{file_size}"})
end = min(end, file_size - 1)
if start > end or start >= file_size:
return Response(status_code=416, headers={**base_headers, "Content-Range": f"bytes */{file_size}"})
with open(path, "rb") as f:
f.seek(start)
data = f.read(end - start + 1)
return Response(
data, status_code=206, media_type="application/octet-stream",
headers={**base_headers, "Content-Range": f"bytes {start}-{end}/{file_size}"},
)
# Kein Range → ganze Datei streamen (pmtiles macht das normalerweise nicht).
return FileResponse(path, media_type="application/octet-stream", headers=base_headers)
@app.get("/maplibre-test")
async def maplibre_test():
# Spike-Testseite: MapLibre rendert /tiles/*.pmtiles (Geometrie-Style, kein Glyph).
return FileResponse(os.path.join(STATIC_DIR, "maplibre-test.html"), media_type="text/html")
@app.get("/leaflet-vector-test")
async def leaflet_vector_test():
# Isolationstest: protomaps-leaflet + map-vector.js + DACH-PMTiles, ohne App-Shell/Flag.
return FileResponse(os.path.join(STATIC_DIR, "leaflet-vector-test.html"), media_type="text/html")
@app.get("/ui-vector-test")
async def ui_vector_test():
# Testet den echten ui.js-Vektor-Pfad (UI.map.create) ohne Auth/App-Shell.
return FileResponse(os.path.join(STATIC_DIR, "ui-vector-test.html"), media_type="text/html")
@app.get("/maplibre-perf-test")
async def maplibre_perf_test():
# Wegwerf-Perf-Test: MapLibre GPU + 600 Cluster-Marker auf DACH-Basemap (Handy-Test).
return FileResponse(os.path.join(STATIC_DIR, "maplibre-perf-test.html"), media_type="text/html")
@app.get("/maplibre-markers-test")
async def maplibre_markers_test():
# Headless-Proof für map-gl-markers.js (Cluster/Icons/Danger/Toggle/Popup, ohne Auth).
return FileResponse(os.path.join(STATIC_DIR, "maplibre-markers-test.html"), media_type="text/html")
# User-generierte Medien (Fotos aus Tagebuch, Giftköder-Alarm, etc.)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True)

View file

@ -166,22 +166,8 @@ async def list_threads(
# ------------------------------------------------------------------
# POST /api/forum/threads
# ------------------------------------------------------------------
def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | None = None,
is_thread: bool = False, now_client: str | None = None):
"""Cooldown, Stunden-Limit und Duplikat-Check für Forum-Posts.
WICHTIG: created_at wird als Client-Lokalzeit gespeichert (safe_client_time).
Alle Zeit-Checks müssen daher gegen die gleiche Zeitbasis rechnen sonst
sorgt der UTC/Lokalzeit-Versatz (z.B. CEST = UTC+2) dafür, dass der Cooldown
dauerhaft greift (diff wird negativ immer < 30). Referenz ist die
Client-Zeit dieses Requests (now_client), Fallback UTC.
"""
from datetime import datetime as _dt, timedelta as _td
try:
now_dt = _dt.fromisoformat(now_client) if now_client else _dt.utcnow()
except (ValueError, TypeError):
now_dt = _dt.utcnow()
def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | None = None, is_thread: bool = False):
"""Cooldown, Stunden-Limit und Duplikat-Check für Forum-Posts."""
# 30-Sekunden-Cooldown zwischen beliebigen Posts
last = conn.execute(
"""SELECT MAX(created_at) AS last FROM (
@ -193,25 +179,25 @@ def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | Non
).fetchone()["last"]
if last:
try:
diff = (now_dt - _dt.fromisoformat(last)).total_seconds()
if 0 <= diff < 30:
from datetime import datetime as _dt
diff = (_dt.utcnow() - _dt.fromisoformat(last)).total_seconds()
if diff < 30:
raise HTTPException(429, "Bitte warte einen Moment bevor du erneut postest.")
except (ValueError, TypeError):
pass
# Stunden-Limit (gleiche Zeitbasis wie created_at)
hour_ago = (now_dt - _td(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
# Stunden-Limit
if is_thread:
count = conn.execute(
"SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > ?",
(user_id, hour_ago),
"SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > datetime('now','-1 hour')",
(user_id,),
).fetchone()[0]
if count >= 5:
raise HTTPException(429, "Du hast in dieser Stunde bereits 5 Threads erstellt. Bitte warte etwas.")
else:
count = conn.execute(
"SELECT COUNT(*) FROM forum_posts WHERE user_id=? AND created_at > ?",
(user_id, hour_ago),
"SELECT COUNT(*) FROM forum_posts WHERE user_id=? AND created_at > datetime('now','-1 hour')",
(user_id,),
).fetchone()[0]
if count >= 20:
raise HTTPException(429, "Du hast in dieser Stunde bereits 20 Antworten geschrieben. Bitte warte etwas.")
@ -237,8 +223,8 @@ async def create_thread(data: ThreadCreate, user=Depends(get_current_user)):
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, "Ungültige Kategorie.")
with db() as conn:
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=True)
ct = safe_client_time(data.client_time)
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=True, now_client=ct)
cur = conn.execute(
"""INSERT INTO forum_threads (user_id, kategorie, titel, text, thread_lat, thread_lon, thread_ort, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
@ -384,9 +370,9 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
if thread['is_deleted']:
raise HTTPException(404, "Thread nicht gefunden.")
ct = safe_client_time(data.client_time)
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False, now_client=ct)
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False)
ct = safe_client_time(data.client_time)
cur = conn.execute(
"INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)",
(thread_id, user['id'], data.text.strip(), ct)

View file

@ -149,74 +149,25 @@ async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)):
# ----------------------------------------------------------
# DELETE /profile/account — Konto unwiderruflich löschen
# ----------------------------------------------------------
# Spalten, die eine HANDLUNG referenzieren (Moderator/Admin/Ersteller),
# nicht Eigentum des Users. Beim Löschen auf NULL setzen statt die fremde
# Zeile (z. B. einen Partner-Code oder eine moderierte Einreichung) mitzureißen.
_ACTOR_COLUMNS = {
("wiki_foto_submissions", "reviewed_by"),
("osm_poi_edits", "mod_id"),
("partner_codes", "created_by"),
("outreach_log", "sent_by"),
("upgrade_requests", "fulfilled_by"),
}
@router.delete('/account')
async def delete_account(user=Depends(get_current_user)):
"""Löscht das Konto und ALLE zugehörigen Daten unwiderruflich (DSGVO + App-Store-Gl. 4).
FK-sicher und schema-robust: ermittelt per Introspektion alle Tabellen, die
auf users(id) verweisen. CASCADE-Tabellen werden beim users-DELETE automatisch
geleert; NO-ACTION/RESTRICT-Eigentumstabellen löschen wir explizit; Aktions-
Spalten (Moderator/Admin) setzen wir auf NULL. `defer_foreign_keys` macht die
Reihenfolge irrelevant geprüft wird erst beim Commit.
"""
"""Löscht das Konto und alle zugehörigen Daten unwiderruflich."""
uid = user['id']
with db() as conn:
# FK-Prüfung bis zum Commit aufschieben → Löschreihenfolge egal.
conn.execute("PRAGMA defer_foreign_keys=ON")
tables = [r['name'] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
).fetchall()]
# Tabellen merken, deren User-Bezug bereits über eine FK behandelt wurde
# (gelöscht oder genullt), damit der Spalten-Scan sie nicht doppelt anfasst.
handled_fk_cols: set[tuple] = set()
# --- 1) Formale FKs auf users(id) ---
for tbl in tables:
try:
fks = conn.execute(f"PRAGMA foreign_key_list({tbl})").fetchall()
except Exception:
continue
for fk in fks:
if fk['table'] != 'users':
continue
col = fk['from']
handled_fk_cols.add((tbl, col))
on_delete = (fk['on_delete'] or '').upper()
if on_delete == 'CASCADE':
continue # wird durch den finalen users-DELETE mitgelöscht
if on_delete == 'SET NULL' or (tbl, col) in _ACTOR_COLUMNS:
conn.execute(f"UPDATE {tbl} SET {col}=NULL WHERE {col}=?", (uid,))
else:
# NO ACTION / RESTRICT auf einer Eigentums-Spalte → Zeilen löschen.
conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,))
# --- 2) Eigentums-Spalten OHNE formale FK (z. B. events.user_id) ---
# Manche Tabellen tragen user_id/owner_id ohne REFERENCES-Klausel. Die fängt
# die FK-Introspektion nicht — für ein echtes „alle Daten löschen" hier nach.
for tbl in tables:
try:
cols = {r['name'] for r in conn.execute(f"PRAGMA table_info({tbl})").fetchall()}
except Exception:
continue
for col in ('user_id', 'owner_id'):
if col in cols and (tbl, col) not in handled_fk_cols and (tbl, col) not in _ACTOR_COLUMNS:
conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,))
# Räumt alle verbliebenen ON-DELETE-CASCADE-Tabellen automatisch ab.
# Alle Hunde-IDs des Users
dog_ids = [r['id'] for r in conn.execute(
"SELECT id FROM dogs WHERE user_id=?", (uid,)).fetchall()]
for did in dog_ids:
conn.execute("DELETE FROM diary WHERE dog_id=?", (did,))
conn.execute("DELETE FROM health WHERE dog_id=?", (did,))
conn.execute("DELETE FROM training_sessions WHERE dog_id=?", (did,))
conn.execute("DELETE FROM training_streaks WHERE dog_id=?", (did,))
conn.execute("DELETE FROM expenses WHERE dog_id=?", (did,))
conn.execute("DELETE FROM dogs WHERE user_id=?", (uid,))
conn.execute("DELETE FROM upgrade_requests WHERE user_id=?", (uid,))
conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,))
conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,))
conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
return {"status": "deleted"}

View file

@ -2514,6 +2514,115 @@ html.modal-open {
text-align: center;
}
/* ============================================================
ORTE (places.js)
============================================================ */
.places-layout {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.places-toolbar {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--c-surface);
border-bottom: 1px solid var(--c-border-light);
flex-shrink: 0;
overflow-x: auto;
scrollbar-width: none;
}
.places-toolbar::-webkit-scrollbar { display: none; }
.places-filter {
display: flex;
gap: var(--space-2);
flex: 1;
overflow-x: auto;
scrollbar-width: none;
}
.places-filter::-webkit-scrollbar { display: none; }
.places-filter-btn {
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
border: 1.5px solid var(--c-border);
background: var(--c-surface);
color: var(--c-text-secondary);
font-size: var(--text-sm);
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
flex-shrink: 0;
}
.places-filter-btn.active {
background: var(--c-primary);
border-color: var(--c-primary);
color: #fff;
}
.places-map {
height: 42%;
flex-shrink: 0;
min-height: 180px;
}
.places-list {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--c-primary) var(--c-surface);
}
.places-list-inner {
padding: var(--space-3) var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.places-card {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3);
background: var(--c-surface);
border: 1.5px solid var(--c-border-light);
border-left: 4px solid var(--typ-color, var(--c-primary));
border-radius: var(--radius-lg);
cursor: pointer;
transition: box-shadow 0.15s;
}
.places-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.places-card-icon { font-size: 1.6rem; flex-shrink: 0; }
.places-card-body { flex: 1; min-width: 0; }
.places-card-name { font-weight: var(--weight-semibold); color: var(--c-text); }
.places-card-meta { font-size: var(--text-sm); color: var(--c-text-secondary); margin-top: 2px; }
.places-card-flags { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-top: var(--space-1); }
.places-card-arrow { color: var(--c-text-muted); font-size: 1.2rem; }
.places-flag {
font-size: var(--text-xs);
padding: 2px 7px;
border-radius: var(--radius-full);
background: var(--c-surface-2);
color: var(--c-text-secondary);
}
.places-flag--detail {
font-size: var(--text-sm);
padding: var(--space-1) var(--space-3);
}
.places-locate-btn {
width: 40px;
height: 40px;
border-radius: 50%;
background: #C4843A;
color: #fff;
border: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
margin: 10px;
}
.places-locate-btn:hover { background: #9E6520; }
/* ============================================================
ROUTEN Komoot-Stil (routes.js)
============================================================ */
@ -3023,16 +3132,6 @@ html.modal-open {
}
.map-full { width: 100%; height: 100%; }
/* Karten-Overlays click-through: die Container der Buttons/Infos liegen über der
Karte und fingen Touch in ihrer GANZEN Bounding-Box ab tote Zonen, in denen
sich die Karte nicht greifen/pannen ließ. Nur die echten Buttons fangen Touch. */
.map-statusbar,
.map-crosshair,
.map-speed-dial,
.map-search-wrap:not(.active),
.map-rec-panel:not(.active) { pointer-events: none; }
.map-sd-trigger { pointer-events: auto; }
/* Legende: horizontaler Scroll-Strip oben */
.map-legend {
position: absolute;
@ -3052,51 +3151,6 @@ html.modal-open {
}
.map-legend::-webkit-scrollbar { display: none; }
/* Regenradar-Zeitleiste (RainViewer: ~2h Vergangenheit + ~30min Nowcast, Play/Pause + Slider) */
/* Optik + Maße wie die Status-Pill darunter: gleiche linke Kante (var(--space-3)); die Breite wird
per JS an die Pill angeglichen (gleiche rechte Kante). Höhe wie die Pill. */
.map-radar-timeline {
position: absolute;
left: var(--space-3);
width: min(320px, calc(100% - 100px)); /* Fallback; JS setzt = Pill-Breite */
bottom: calc(var(--space-3) + 34px); /* unmittelbar über der Status-Pill */
z-index: 900;
display: flex;
align-items:center;
gap: 7px;
padding: 3px 12px 3px 4px;
border-radius: var(--radius-full);
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border: 1px solid var(--c-border-light);
color: var(--c-text);
pointer-events: auto;
box-sizing: border-box;
}
:root[data-theme="dark"] .map-radar-timeline {
background: rgba(24, 20, 16, 0.92);
border-color: rgba(255, 255, 255, 0.1);
}
.rdr-play {
flex-shrink: 0;
width: 24px; height: 24px;
border: none; border-radius: 50%;
background: var(--c-surface-2);
color: var(--c-text); cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.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-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);
}
.rdr-time.is-forecast { color: var(--c-primary); } /* Nowcast/Vorhersage-Frames hervorgehoben */
.map-legend-btn {
flex-shrink: 0;
display: inline-flex;
@ -6799,6 +6853,15 @@ html.modal-open {
margin-bottom: 2px;
}
/* OSM-Attribution unter der Karte */
.lost-map-attribution {
font-size: 10px;
color: var(--c-text-secondary);
text-align: right;
padding: 2px var(--space-2) 0;
margin-bottom: var(--space-4);
}
/* Info-Zeile über der Liste ("X vermisste Hunde …") */
.lost-info-text {
font-size: var(--text-sm);

View file

@ -63,14 +63,3 @@
font-weight: 600;
margin-bottom: var(--space-1);
}
/* ------------------------------------------------------------------
Hover-Utilities ersetzen CSP-blockierte onmouseenter/leave/over.
:hover braucht !important, da Inline-Base-Styles höher spezifisch sind.
------------------------------------------------------------------ */
.by-hover-lift { transition: transform .15s, box-shadow .15s; }
.by-hover-lift:hover { transform: translateY(-2px) !important; box-shadow: var(--shadow-md) !important; }
.by-hover-surface2 { transition: background .15s; }
.by-hover-surface2:hover{ background: var(--c-surface-2) !important; }
.by-hover-surface3 { transition: background .15s; }
.by-hover-surface3:hover{ background: var(--c-surface-3) !important; }

View file

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

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung.
============================================================ */
const APP_VER = '1219'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VER = '1161'; // ← 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;
@ -137,8 +137,7 @@ const App = (() => {
let lastForce = 0;
try { lastForce = parseInt(localStorage.getItem('by_last_force_update') || '0', 10); } catch {}
const cooldownActive = (Date.now() - lastForce) < 10 * 60 * 1000;
// Während einer laufenden Aufzeichnung NIE force-updaten (Datenverlust).
if (!modalOpen && !cooldownActive && !window._byRecording) {
if (!modalOpen && !cooldownActive) {
window._byUpdatePending = false;
sessionStorage.setItem('by_updated_to', window._byNewVersion || '');
sessionStorage.setItem('by_update_target', pageId);
@ -295,27 +294,16 @@ const App = (() => {
});
page.module = {}; // verhindert erneutes Laden
}
} catch (err) {
// Echten Fehler NICHT verschlucken — sonst rätselt man bei jedem Seiten-Crash
console.error(`[page-load] ${pageId} init fehlgeschlagen:`, err);
} catch {
const _offline = !navigator.onLine;
container.innerHTML = UI.emptyState({
icon: _offline ? '📡' : '⚠️',
icon: _offline ? '📡' : '🚧',
title: pages[pageId].title,
text: _offline
? 'Diese Seite ist offline nicht verfügbar. Bitte öffne sie einmal mit Internetverbindung, damit sie gecacht wird.'
: 'Die Seite konnte nicht geladen werden. Das passiert manchmal nach einem Update.',
action: _offline ? '' :
`<button class="btn btn-primary" id="page-retry-btn">Erneut versuchen</button>`,
: 'Diese Seite ist noch in Entwicklung.',
});
document.getElementById('page-retry-btn')?.addEventListener('click', () => {
page._loading = false;
navigate(pageId, false, params);
});
// WICHTIG: page.module NICHT auf {} setzen. Bei einem echten Fehler (Netz-Blip,
// SW-Update mitten in der Navigation, Race) würde {} die Seite für die ganze
// Session tot stellen — der Guard `if (page.module)` käme nie mehr zum Laden.
// So wird beim nächsten Aufruf neu versucht und ein transienter Fehler heilt sich.
page.module = {};
} finally {
page._loading = false;
}
@ -446,62 +434,6 @@ const App = (() => {
// NAVIGATION EVENTS
// ----------------------------------------------------------
function _bindNavigation() {
// Globaler Bild-Fallback — ersetzt CSP-blockierte onerror-Attribute.
// 'error' bubbelt nicht → Capture-Phase. Greift nur bei [data-fb]/[data-fb-src].
document.addEventListener('error', e => {
const el = e.target;
if (!el || el.tagName !== 'IMG') return;
const fb = el.dataset.fb, altSrc = el.dataset.fbSrc;
if (fb === undefined && altSrc === undefined) return;
// Schritt 1: Alternative Quelle versuchen (z.B. _preview → Original / Platzhalter)
if (altSrc && !el.dataset.fbTried) {
el.dataset.fbTried = '1';
el.src = altSrc;
return;
}
// Schritt 2: terminaler Fallback
switch (fb) {
case 'hide-parent':
if (el.parentElement) el.parentElement.style.display = 'none';
break;
case 'dim-grandparent':
if (el.parentElement?.parentElement) el.parentElement.parentElement.style.opacity = '.4';
break;
case 'sibling':
el.style.display = 'none';
if (el.nextElementSibling) el.nextElementSibling.style.display = 'flex';
break;
case 'show-el': {
el.style.display = 'none';
const t = el.dataset.fbEl && document.getElementById(el.dataset.fbEl);
if (t) t.style.display = 'flex';
break;
}
case 'emoji':
if (el.parentElement) el.parentElement.innerHTML =
`<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:${el.dataset.fbSize || '2rem'}">${el.dataset.fbEmoji || '🐾'}</div>`;
break;
case 'initials': {
const sz = parseInt(el.dataset.fbSize, 10) || 40;
el.outerHTML =
`<div style="width:${sz}px;height:${sz}px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;font-size:${Math.round(sz * 0.45)}px;font-weight:700;color:var(--c-primary)">${el.dataset.fbInitials || ''}</div>`;
break;
}
default: // 'hide'
el.style.display = 'none';
el.classList.add('img-broken');
}
}, true);
// Video-Vorschau bei Hover (ersetzt CSP-blockierte onmouseenter/leave).
// <video> hat keine Kinder → e.target ist das Video selbst (matches() O(1)).
document.addEventListener('mouseover', e => {
if (e.target.matches?.('[data-hover-play]')) e.target.play?.().catch(() => {});
});
document.addEventListener('mouseout', e => {
if (e.target.matches?.('[data-hover-play]')) e.target.pause?.();
});
// Bottom Nav + Sidebar Klicks
document.addEventListener('click', e => {
const item = e.target.closest('[data-page]');
@ -511,20 +443,6 @@ const App = (() => {
return;
}
// Foto-Lightbox (Inline-onclick ist CSP-blockiert)
const lb = e.target.closest('[data-lightbox-url]');
if (lb) {
window.UI?.lightbox?.show?.([{ url: lb.dataset.lightboxUrl }], 0);
return;
}
// Externer Link in neuem Tab
const ol = e.target.closest('[data-open-url]');
if (ol) {
window.open(ol.dataset.openUrl, '_blank');
return;
}
// Header-User-Button → Settings
if (e.target.closest('#header-user-btn')) {
navigate('settings');
@ -1127,15 +1045,11 @@ const App = (() => {
sessionStorage.setItem('by_stay_in_app', '1');
}
// Referral-Code aus URL ?ref=CODE speichern (Backup zu boot.js; localStorage
// überlebt App-Schließen, sodass die Zuordnung auch bei späterer Registrierung klappt)
// Referral-Code aus URL ?ref=CODE speichern
const urlParams = new URLSearchParams(window.location.search);
const refCode = urlParams.get('ref');
if (refCode) {
try {
localStorage.setItem('by_ref_code', refCode.toUpperCase());
localStorage.setItem('by_ref_code_ts', String(Date.now()));
} catch {}
sessionStorage.setItem('by_ref_code', refCode.toUpperCase());
// URL bereinigen ohne Reload
history.replaceState({}, '', window.location.pathname + window.location.hash);
}
@ -1250,7 +1164,7 @@ const App = (() => {
icon: UI.icon(icon),
title: 'Anmelden erforderlich',
text,
action: `<button class="btn btn-primary" data-page="settings">Anmelden</button>`,
action: `<button class="btn btn-primary" onclick="App.navigate('settings')">Anmelden</button>`,
});
}

View file

@ -4,29 +4,6 @@
Extrahiert aus index.html für CSP-Härtung (kein unsafe-inline)
============================================================ */
// ----------------------------------------------------------
// Referral-Code aus ?ref= SOFORT in localStorage sichern — so früh wie möglich,
// bevor ein SW-Update-Reload die URL durch /?_t=... ersetzt und den Code verliert.
// localStorage (statt sessionStorage) überlebt auch App-Schließen/PWA-Neustart,
// sodass die Zuordnung auch klappt, wenn sich die Person erst später registriert.
// ----------------------------------------------------------
(function() {
try {
var rc = new URLSearchParams(location.search).get('ref');
if (rc) {
localStorage.setItem('by_ref_code', rc.toUpperCase());
localStorage.setItem('by_ref_code_ts', String(Date.now()));
}
// Vektor-Basemap-Feature-Flag aus ?vectormap=1/0 SOFORT sichern (bevor Boot
// die URL-Query strippt). Wird in ui.js Map.create ausgewertet.
var vm = new URLSearchParams(location.search).get('vectormap');
if (vm !== null) localStorage.setItem('by_vector_map', vm === '0' ? '0' : '1');
// MapLibre-GL-Karte (zentrale Karte) aus ?mapgl=1/0 — wird in pages/map.js _useGL() ausgewertet.
var mg = new URLSearchParams(location.search).get('mapgl');
if (mg !== null) localStorage.setItem('by_map_gl', mg === '0' ? '0' : '1');
} catch (e) {}
})();
// ----------------------------------------------------------
// Offline-Banner
// ----------------------------------------------------------
@ -69,38 +46,6 @@
_updateBanner();
})();
// ----------------------------------------------------------
// Aufzeichnungs-Speicher (Sicherheitsnetz gegen Datenverlust bei Reload/Crash)
// ----------------------------------------------------------
window.RecStore = {
save: function(s) { try { localStorage.setItem('by_active_recording', JSON.stringify(Object.assign({ ts: Date.now() }, s))); } catch (e) {} },
load: function() { try { return JSON.parse(localStorage.getItem('by_active_recording') || 'null'); } catch (e) { return null; } },
clear: function() { try { localStorage.removeItem('by_active_recording'); } catch (e) {} },
};
// ----------------------------------------------------------
// SW-Reload — wird während einer laufenden Routen-Aufzeichnung AUFGESCHOBEN,
// damit der nur im RAM gehaltene Track nicht verloren geht. Sobald die
// Aufzeichnung beendet ist, holt window._byReloadIfPending() den Reload nach.
// ----------------------------------------------------------
window._byReloadIfPending = function() {
if (window._byReloadPending) {
window._byReloadPending = false;
window.location.replace('/?_t=' + Date.now());
}
};
function _bySwReload() {
if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload'); // einmalig konsumieren
return;
}
if (window._byRecording) { // Aufzeichnung läuft → Reload aufschieben
window._byReloadPending = true;
return;
}
window.location.replace('/?_t=' + Date.now());
}
// ----------------------------------------------------------
// Service Worker Registration + Update-Flow
// ----------------------------------------------------------
@ -111,7 +56,13 @@ if ('serviceWorker' in navigator) {
function _watchSW(sw) {
if (!sw) return;
sw.addEventListener('statechange', function() {
if (sw.state === 'activated') _bySwReload();
if (sw.state === 'activated') {
if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload'); // einmalig konsumieren
return;
}
window.location.replace('/?_t=' + Date.now());
}
});
}
reg.addEventListener('updatefound', function() { _watchSW(reg.installing); });
@ -132,7 +83,11 @@ if ('serviceWorker' in navigator) {
// NICHT registrieren wenn diese Seite selbst durch SW-Reload entstand
if (!window._BY_SW_RELOAD) {
navigator.serviceWorker.addEventListener('controllerchange', function() {
_bySwReload();
if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload');
return;
}
window.location.replace('/?_t=' + Date.now());
});
}

View file

@ -1,24 +0,0 @@
// Isolationstest: rendert die DACH-PMTiles direkt via protomaps-leaflet + map-vector.js,
// OHNE App-Shell, ohne Feature-Flag, ohne SW-Komplikationen. Beweist, ob die
// Vektor-Basemap-Kette an sich funktioniert.
(function () {
'use strict';
var st = document.getElementById('status');
function set(t) { if (st) st.textContent = t; }
try {
if (!window.L) return set('❌ Leaflet nicht geladen');
if (!window.protomapsL) return set('❌ protomaps-leaflet nicht geladen');
if (!window.MapVector) return set('❌ MapVector nicht geladen');
var map = L.map('map', { attributionControl: false }).setView([48.137, 11.576], 12); // München
L.control.attribution({ prefix: false }).addTo(map)
.addAttribution('© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors');
var layer = MapVector.basemapLayer({ dark: false });
layer.addTo(map);
set('✅ Vektor-Layer hinzugefügt — Tiles: ' + MapVector.tilesUrl());
} catch (e) {
set('❌ Fehler: ' + (e && e.message ? e.message : e));
console.error('Isolationstest-Fehler:', e);
}
})();

View file

@ -1,224 +0,0 @@
// GL-Marker-Subsystem für die zentrale Karte (MapLibre). Eigenständig + headless
// testbar, BEVOR es in map.js verdrahtet wird. Pro Kategorie eine GeoJSON-Source mit
// cluster:true → Cluster-Kreise (circle, GPU) + Einzel-POIs (symbol mit Phosphor-Icon).
// Faithful zum Leaflet-Look: Kategorie-Farbe + weißes Phosphor-Icon auf Kreis.
// Cluster-ZAHLEN brauchen Glyphs → später (Größe kodiert Dichte). Danger-Radien als Polygon.
(function () {
'use strict';
var _map = null;
var _types = {}; // { key: { color, iconName, danger } }
var _dangerRadiusM = 100;
var _popupHTML = null; // (props, key) -> htmlString
var _popupWire = null; // (props, key, closeFn) -> void
var _onClick = null; // (props, key) -> true = Klick behandelt, Popup unterdrücken
var _activePopup = null;
var _dangerKeys = [];
var _clickBound = {}; // Click/Hover-Handler pro Kategorie nur EINMAL binden
function _empty() { return { type: 'FeatureCollection', features: [] }; }
// POIs ({lat,lon,...}) → GeoJSON-Features ([lng,lat] + flache Properties).
function _toFeatures(pois) {
return {
type: 'FeatureCollection',
features: (pois || []).filter(function (p) { return p && p.lat != null && p.lon != null; })
.map(function (p) {
var props = {};
Object.keys(p).forEach(function (k) {
var v = p[k];
if (v != null && typeof v !== 'object') props[k] = v;
});
return { type: 'Feature', properties: props, geometry: { type: 'Point', coordinates: [p.lon, p.lat] } };
}),
};
}
// Kreis-Polygon (Meter-genau) für Danger-Radius.
function _circlePolygon(lon, lat, radiusM, steps) {
steps = steps || 36;
var coords = [], r = radiusM / 6378137, latR = lat * Math.PI / 180, lonR = lon * Math.PI / 180;
for (var i = 0; i <= steps; i++) {
var brng = i / steps * 2 * Math.PI;
var lat2 = Math.asin(Math.sin(latR) * Math.cos(r) + Math.cos(latR) * Math.sin(r) * Math.cos(brng));
var lon2 = lonR + Math.atan2(Math.sin(brng) * Math.sin(r) * Math.cos(latR), Math.cos(r) - Math.sin(latR) * Math.sin(lat2));
coords.push([lon2 * 180 / Math.PI, lat2 * 180 / Math.PI]);
}
return { type: 'Polygon', coordinates: [coords] };
}
// Phosphor-Icon → ImageData. iconOnly=false: weißes Icon auf farbigem Kreis (Marker).
// iconOnly=true: nur weißes Icon, transparent + größer (für Cluster-Mitte).
function _iconImage(spriteDoc, iconName, color, iconOnly) {
return new Promise(function (resolve) {
var s = 64, c = document.createElement('canvas'); c.width = c.height = s;
var x = c.getContext('2d');
function base() {
x.clearRect(0, 0, s, s);
if (iconOnly) return;
x.beginPath(); x.arc(s / 2, s / 2, s / 2 - 5, 0, Math.PI * 2);
x.fillStyle = color; x.fill();
x.lineWidth = 4; x.strokeStyle = 'rgba(52,68,36,0.55)'; x.stroke();
}
var ic = s * (iconOnly ? 0.66 : 0.52);
var sym = spriteDoc && iconName && spriteDoc.getElementById(iconName);
if (!sym) { base(); resolve(x.getImageData(0, 0, s, s)); return; }
var vb = sym.getAttribute('viewBox') || '0 0 256 256';
var svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="' + vb + '" fill="white">' + sym.innerHTML + '</svg>';
var im = new Image();
im.onload = function () { base(); x.drawImage(im, (s - ic) / 2, (s - ic) / 2, ic, ic); resolve(x.getImageData(0, 0, s, s)); };
im.onerror = function () { base(); resolve(x.getImageData(0, 0, s, s)); };
im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
});
}
function _buildIcons() {
var doc = null;
return fetch('/icons/phosphor.svg').then(function (r) { return r.text(); })
.then(function (txt) { doc = new DOMParser().parseFromString(txt, 'image/svg+xml'); })
.catch(function () { doc = null; })
.then(function () {
var keys = Object.keys(_types);
return keys.reduce(function (chain, key) {
return chain.then(function () {
var p = Promise.resolve();
// Marker-Icon (Kreis + weißes Icon)
if (!_map.hasImage('poi-' + key)) {
p = p.then(function () { return _iconImage(doc, _types[key].iconName, _types[key].color, false); })
.then(function (img) { if (!_map.hasImage('poi-' + key)) _map.addImage('poi-' + key, img, { pixelRatio: 2 }); });
}
// Cluster-Icon (nur weißes Icon, für die Cluster-Mitte)
if (!_map.hasImage('cli-' + key)) {
p = p.then(function () { return _iconImage(doc, _types[key].iconName, _types[key].color, true); })
.then(function (img) { if (!_map.hasImage('cli-' + key)) _map.addImage('cli-' + key, img, { pixelRatio: 2 }); });
}
return p;
});
}, Promise.resolve());
});
}
function _addCategoryLayers() {
Object.keys(_types).forEach(function (key) {
var src = 'poi-' + key, color = _types[key].color;
if (!_map.getSource(src)) {
_map.addSource(src, { type: 'geojson', data: _empty(), cluster: true, clusterRadius: 50, clusterMaxZoom: 16 });
}
if (!_map.getLayer('cl-' + key)) {
_map.addLayer({ id: 'cl-' + key, type: 'circle', source: src, filter: ['has', 'point_count'],
paint: {
'circle-color': color, 'circle-opacity': 0.92,
'circle-stroke-color': 'rgba(52,68,36,0.65)', 'circle-stroke-width': 2,
'circle-radius': ['step', ['get', 'point_count'], 14, 10, 18, 50, 24],
} });
}
if (!_map.getLayer('clsym-' + key)) {
// Anzahl als weiße Zahl mittig auf dem Cluster (braucht Glyphs aus dem Style).
_map.addLayer({ id: 'clsym-' + key, type: 'symbol', source: src, filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-font': ['Open Sans Semibold'],
'text-size': ['step', ['get', 'point_count'], 12, 100, 14, 1000, 16],
'text-allow-overlap': true, 'text-ignore-placement': true,
},
paint: { 'text-color': '#ffffff', 'text-halo-color': 'rgba(52,68,36,0.55)', 'text-halo-width': 1 } });
}
if (!_map.getLayer('pt-' + key)) {
_map.addLayer({ id: 'pt-' + key, type: 'symbol', source: src, filter: ['!', ['has', 'point_count']],
layout: { 'icon-image': 'poi-' + key, 'icon-allow-overlap': true, 'icon-ignore-placement': true, 'icon-size': 0.9 } });
}
// Click/Hover NUR EINMAL binden (Handler überleben setStyle/Theme-Wechsel,
// sind an die Layer-ID gebunden → sonst doppelte Popups nach Theme-Switch).
if (!_clickBound[key]) {
_clickBound[key] = true;
_map.on('click', 'pt-' + key, function (e) { _onPoiClick(e, key); });
_map.on('click', 'cl-' + key, function (e) {
var f = e.features[0];
var s = _map.getSource('poi-' + key);
if (s && s.getClusterExpansionZoom) {
s.getClusterExpansionZoom(f.properties.cluster_id, function (err, z) {
if (!err) _map.easeTo({ center: f.geometry.coordinates, zoom: z });
});
}
});
_map.on('mouseenter', 'pt-' + key, function () { _map.getCanvas().style.cursor = 'pointer'; });
_map.on('mouseleave', 'pt-' + key, function () { _map.getCanvas().style.cursor = ''; });
}
});
// Danger-Radius-Layer (poison/giftkoeder), unter den Markern.
if (_dangerKeys.length && !_map.getSource('danger')) {
_map.addSource('danger', { type: 'geojson', data: _empty() });
var firstSymbol = 'cl-' + Object.keys(_types)[0];
_map.addLayer({ id: 'danger-fill', type: 'fill', source: 'danger',
paint: { 'fill-color': '#DC2626', 'fill-opacity': 0.12 } },
_map.getLayer(firstSymbol) ? firstSymbol : undefined);
_map.addLayer({ id: 'danger-line', type: 'line', source: 'danger',
paint: { 'line-color': '#DC2626', 'line-width': 2, 'line-opacity': 0.7 } },
_map.getLayer(firstSymbol) ? firstSymbol : undefined);
}
}
function _onPoiClick(e, key) {
if (!e.features || !e.features.length) return;
var f = e.features[0];
var props = f.properties || {};
if (_onClick && _onClick(props, key) === true) return; // Klick anderweitig behandelt
if (_activePopup) { _activePopup.remove(); _activePopup = null; }
var html = _popupHTML ? _popupHTML(props, key) : ('<b>' + (props.name || key) + '</b>');
if (!html) return;
_activePopup = new maplibregl.Popup({ maxWidth: '260px' })
.setLngLat(f.geometry.coordinates).setHTML(html).addTo(_map);
if (_popupWire) {
var pop = _activePopup;
setTimeout(function () { _popupWire(props, key, function () { pop.remove(); }); }, 50);
}
}
// Danger-Source aus allen aktuell gesetzten Danger-POIs neu aufbauen.
var _dangerPois = {}; // { key: [pois] }
function _refreshDanger() {
if (!_map.getSource('danger')) return;
var feats = [];
_dangerKeys.forEach(function (k) {
(_dangerPois[k] || []).forEach(function (p) {
if (p.lat != null && p.lon != null) feats.push({ type: 'Feature', properties: {}, geometry: _circlePolygon(p.lon, p.lat, _dangerRadiusM) });
});
});
_map.getSource('danger').setData({ type: 'FeatureCollection', features: feats });
}
// ---- Öffentliche API ----
var API = {
// init(map, { types, dangerKeys, dangerRadiusM, popupHTML, popupWire }) → Promise (Icons geladen)
init: function (map, opts) {
_map = map; opts = opts || {};
_types = opts.types || {};
_dangerKeys = opts.dangerKeys || [];
_dangerRadiusM = opts.dangerRadiusM || 100;
_popupHTML = opts.popupHTML || null;
_popupWire = opts.popupWire || null;
_onClick = opts.onClick || null;
_addCategoryLayers();
return _buildIcons();
},
// POIs einer Kategorie setzen (ersetzt alle).
setLayer: function (key, pois) {
var src = _map && _map.getSource('poi-' + key);
if (!src) return;
src.setData(_toFeatures(pois));
if (_dangerKeys.indexOf(key) !== -1) { _dangerPois[key] = pois || []; _refreshDanger(); }
},
clear: function (key) { API.setLayer(key, []); },
setVisible: function (key, on) {
if (!_map) return;
var vis = on ? 'visible' : 'none';
['cl-' + key, 'clsym-' + key, 'pt-' + key].forEach(function (id) {
if (_map.getLayer(id)) _map.setLayoutProperty(id, 'visibility', vis);
});
},
ready: function () { return !!(_map && _map.getSource('poi-' + Object.keys(_types)[0])); },
};
window.MapGLMarkers = API;
})();

View file

@ -1,290 +0,0 @@
// Leaflet-kompatible MapLibre-Facade für die SEITENKARTEN (Giftköder, Verlorene,
// Events, Gassi, Routen). Liefert Wrapper, die die von den Seiten genutzte Leaflet-
// API nachbilden (setView/fitBounds/invalidateSize/addTo/bindPopup/openPopup/on/remove),
// sodass die Seiten fast unverändert auf demselben GL-Style (MapGLStyle) laufen.
// Koordinaten nach außen [lat,lon] (Leaflet-Konvention), intern MapLibre [lng,lat].
(function () {
'use strict';
// [lat,lon]-Array ODER {lat,lng}-Objekt → [lng,lat] für MapLibre.
function _ll(latlon) {
if (latlon && latlon.lat != null) return [latlon.lng, latlon.lat];
return [latlon[1], latlon[0]];
}
// ---- Map-Wrapper ----
function _wrapMap(map) {
return {
_gl: map,
_isGL: true,
setView: function (latlon, zoom) { map.jumpTo({ center: _ll(latlon), zoom: zoom }); return this; },
flyTo: function (latlon, zoom, opts) {
map.flyTo({ center: _ll(latlon), zoom: zoom, duration: opts && opts.duration ? opts.duration * 1000 : 1000 });
return this;
},
panTo: function (latlon) { map.panTo(_ll(latlon)); return this; },
fitBounds: function (b, opts) {
var bb = _toBounds(b);
// Nur fitten wenn Bounds gültig UND der Container eine Größe hat (im Modal
// ist er beim Erstellen 0×0 → fitBounds würde NaN werfen; der Re-Fit nach
// Modal-Animation greift dann).
var _c = map.getContainer();
if (bb && !isNaN(bb.getWest()) && _c.clientWidth > 0 && _c.clientHeight > 0) {
var pad = 30;
if (opts && opts.padding) pad = Array.isArray(opts.padding) ? opts.padding[0] : opts.padding;
try { map.fitBounds(bb, { padding: pad, maxZoom: opts && opts.maxZoom, duration: 0 }); } catch (e) {}
}
return this;
},
invalidateSize: function () { map.resize(); return this; },
removeLayer: function (layer) { if (layer && layer.remove) layer.remove(); return this; },
addLayer: function (layer) { if (layer && layer.addTo) layer.addTo(this); return this; },
hasLayer: function () { return true; },
remove: function () { try { map.remove(); } catch (e) {} },
on: function (ev, fn) {
if (ev === 'click') {
map.on('click', function (e) { if (e.lngLat && !e.latlng) e.latlng = { lat: e.lngLat.lat, lng: e.lngLat.lng }; fn(e); });
} else { map.on(ev, fn); }
return this;
},
off: function (ev, fn) { map.off(ev, fn); return this; },
getZoom: function () { return map.getZoom(); },
getCenter: function () { var c = map.getCenter(); return { lat: c.lat, lng: c.lng }; },
// Leaflet-Handler-Stub (z.B. _suggestMap.scrollWheelZoom.disable()).
scrollWheelZoom: { disable: function () { try { map.scrollZoom.disable(); } catch (e) {} }, enable: function () { try { map.scrollZoom.enable(); } catch (e) {} } },
// Distanz in Metern (Haversine) — Ersatz für Leaflets map.distance.
distance: function (a, b) {
var la = a.lat != null ? a.lat : a[0], lo = a.lng != null ? a.lng : a[1];
var lb = b.lat != null ? b.lat : b[0], ob = b.lng != null ? b.lng : b[1];
var R = 6371000, p1 = la * Math.PI / 180, p2 = lb * Math.PI / 180;
var dp = (lb - la) * Math.PI / 180, dl = (ob - lo) * Math.PI / 180;
var x = Math.sin(dp / 2) * Math.sin(dp / 2) + Math.cos(p1) * Math.cos(p2) * Math.sin(dl / 2) * Math.sin(dl / 2);
return 2 * R * Math.asin(Math.sqrt(x));
},
};
}
// Bounds aus: Array von [lat,lon] | featureGroup-Wrapper (_coords) | Leaflet-Bounds.
function _toBounds(b) {
if (!b) return null;
var coords = null;
if (Array.isArray(b)) coords = b;
else if (b._coords) coords = b._coords;
else if (typeof b.getSouthWest === 'function') {
var sw = b.getSouthWest(), ne = b.getNorthEast();
return new maplibregl.LngLatBounds([sw.lng, sw.lat], [ne.lng, ne.lat]);
}
if (!coords || !coords.length) return null;
var bb = new maplibregl.LngLatBounds();
coords.forEach(function (c) { bb.extend(_ll(c)); });
return bb;
}
// ---- Marker-Wrapper (HTML-Marker; svgMarker + circleMarker) ----
function _wrapMarker(lat, lon, el, anchor) {
var m = new maplibregl.Marker({ element: el, anchor: anchor || 'center' }).setLngLat([lon, lat]);
var wrap = {
_gl: m,
_el: el,
addTo: function (mapWrap) { m.addTo(mapWrap && mapWrap._gl ? mapWrap._gl : mapWrap); return this; },
bindPopup: function (html, opts) {
m.setPopup(new maplibregl.Popup({ maxWidth: (opts && opts.maxWidth ? opts.maxWidth + 'px' : '260px'), closeButton: true, offset: 18 }).setHTML(html));
return this;
},
openPopup: function () { var p = m.getPopup(); if (p && !p.isOpen()) m.togglePopup(); return this; },
closePopup: function () { var p = m.getPopup(); if (p && p.isOpen()) m.togglePopup(); return this; },
bindTooltip: function (t) { try { el.title = typeof t === 'string' ? t.replace(/<[^>]*>/g, '') : ''; } catch (e) {} return this; },
on: function (ev, fn) {
if (ev === 'click') el.addEventListener('click', function (e) { e.stopPropagation(); fn(e); });
return this;
},
setLatLng: function (latlon) { m.setLngLat(_ll(latlon)); return this; },
getLatLng: function () { var c = m.getLngLat(); return { lat: c.lat, lng: c.lng }; },
setOpacity: function (o) { el.style.opacity = o; return this; },
remove: function () { try { m.remove(); } catch (e) {} return this; },
};
return wrap;
}
// ---- Polyline-Wrapper (GL geojson line-source/-layer) ----
var _seq = 0;
function _toLngLat(p) { return (p && p.lat != null) ? [p.lng, p.lat] : [p[1], p[0]]; } // L.latLng | [lat,lon]
function _wrapPolyline(latlngs, opts) {
opts = opts || {};
return {
_latlngs: latlngs || [],
_id: 'poly-' + (++_seq),
_map: null,
_opts: opts,
_handlers: {}, // ev → [fn]
_tooltip: null,
_tipPopup: null,
_geo: function () { return { type: 'Feature', geometry: { type: 'LineString', coordinates: this._latlngs.map(_toLngLat) } }; },
_hitId: function () { return this._id + '-hit'; },
_ensure: function () {
var self = this, m = self._map;
var add = function () {
if (!m.getSource(self._id)) m.addSource(self._id, { type: 'geojson', data: self._geo() });
if (!m.getLayer(self._id)) m.addLayer({ id: self._id, type: 'line', source: self._id,
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': self._opts.color || '#C4843A', 'line-width': self._opts.weight || 4, 'line-opacity': self._opts.opacity != null ? self._opts.opacity : 0.9 } });
// Breite, fast unsichtbare Hit-Linie → auf dem Handy gut antippbar.
if (self._opts.interactive !== false && !m.getLayer(self._hitId())) {
m.addLayer({ id: self._hitId(), type: 'line', source: self._id,
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#000', 'line-opacity': 0.01, 'line-width': 18 } });
}
self._wireAll();
};
if (m.isStyleLoaded && m.isStyleLoaded()) add(); else m.once('load', add);
},
_wireOne: function (ev, fn) {
var self = this, m = self._map, hit = self._hitId();
if (!m.getLayer(hit)) return;
if (ev === 'click') {
m.on('click', hit, function (e) { if (e.originalEvent) e.originalEvent.stopPropagation(); fn(e); });
} else if (ev === 'mouseover') {
m.on('mouseenter', hit, function (e) { m.getCanvas().style.cursor = 'pointer'; fn(e); });
} else if (ev === 'mouseout') {
m.on('mouseleave', hit, function (e) { m.getCanvas().style.cursor = ''; fn(e); });
}
},
_wireAll: function () {
var self = this;
Object.keys(self._handlers).forEach(function (ev) {
self._handlers[ev].forEach(function (fn) { self._wireOne(ev, fn); });
self._handlers[ev]._wired = true;
});
if (self._tooltip && !self._tipWired) self._wireTooltip();
},
_wireTooltip: function () {
var self = this, m = self._map, hit = self._hitId();
if (!m.getLayer(hit)) return;
self._tipWired = true;
m.on('mousemove', hit, function (e) {
if (!self._tipPopup) self._tipPopup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 10, className: 'rk-map-tip' });
self._tipPopup.setLngLat(e.lngLat).setHTML(self._tooltip).addTo(m);
});
m.on('mouseleave', hit, function () { if (self._tipPopup) { self._tipPopup.remove(); } });
},
addTo: function (mapWrap) { this._map = mapWrap && mapWrap._gl ? mapWrap._gl : mapWrap; this._ensure(); return this; },
on: function (ev, fn) {
(this._handlers[ev] = this._handlers[ev] || []).push(fn);
if (this._map && this._map.getLayer(this._hitId())) this._wireOne(ev, fn);
return this;
},
bindTooltip: function (t) {
this._tooltip = typeof t === 'string' ? t : '';
if (this._map && this._map.getLayer(this._hitId())) this._wireTooltip();
return this;
},
setStyle: function (s) {
var m = this._map; if (!m || !m.getLayer(this._id)) return this;
if (s.color != null) m.setPaintProperty(this._id, 'line-color', s.color);
if (s.weight != null) m.setPaintProperty(this._id, 'line-width', s.weight);
if (s.opacity != null) m.setPaintProperty(this._id, 'line-opacity', s.opacity);
return this;
},
setLatLngs: function (lls) {
this._latlngs = lls || [];
if (this._map && this._map.getSource(this._id)) this._map.getSource(this._id).setData(this._geo());
return this;
},
// Leaflet-kompatibel: Array von {lat,lng} (für fitBounds-Sammlung).
getLatLngs: function () { return this._latlngs.map(function (p) { return (p && p.lat != null) ? { lat: p.lat, lng: p.lng } : { lat: p[0], lng: p[1] }; }); },
getBounds: function () { return { _coords: this._latlngs.map(function (p) { return (p && p.lat != null) ? [p.lat, p.lng] : p; }) }; },
remove: function () {
var m = this._map; if (!m) return this;
if (this._tipPopup) { try { this._tipPopup.remove(); } catch (e) {} }
if (m.getLayer(this._hitId())) m.removeLayer(this._hitId());
if (m.getLayer(this._id)) m.removeLayer(this._id);
if (m.getSource(this._id)) m.removeSource(this._id);
return this;
},
};
}
// ---- Gruppe (Cluster-Ersatz: fügt Marker direkt hinzu; GL clustert Seitenkarten nicht) ----
function _wrapGroup() {
return {
_markers: [], _map: null,
addLayer: function (m) { this._markers.push(m); if (this._map) m.addTo(this._map); return this; },
addLayers: function (ms) { (ms || []).forEach(this.addLayer, this); return this; },
removeLayers: function (ms) { (ms || []).forEach(function (m) { m.remove(); }); this._markers = this._markers.filter(function (m) { return (ms || []).indexOf(m) === -1; }); return this; },
addTo: function (mapWrap) { this._map = mapWrap; this._markers.forEach(function (m) { m.addTo(mapWrap); }); return this; },
clearLayers: function () { this._markers.forEach(function (m) { m.remove(); }); this._markers = []; return this; },
remove: function () { this.clearLayers(); this._map = null; return this; },
};
}
// Element aus HTML-String (für svgMarker mit custom HTML).
function _elFromHtml(html, size, anchorY) {
var wrap = document.createElement('div');
wrap.innerHTML = html;
var el = wrap.firstElementChild || wrap;
el.style.cursor = 'pointer';
return el;
}
window.MapGLMini = {
createMap: function (container, opts) {
opts = opts || {};
var el = typeof container === 'string' ? document.getElementById(container) : container;
var center = opts.center || [51.1657, 10.4515];
var map = new maplibregl.Map({
container: el,
style: MapGLStyle.build({ dark: !!opts.dark }),
center: _ll(center), zoom: opts.zoom != null ? opts.zoom : 6,
attributionControl: false, dragRotate: false, pitchWithRotate: false, maxZoom: 19,
});
map.touchZoomRotate.disableRotation();
map.touchPitch.disable();
try { el.style.touchAction = 'none'; } catch (e) {}
if (opts.zoomControl !== false) map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left');
map.addControl(new maplibregl.AttributionControl({
compact: true, customAttribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}));
MapGLStyle.collapseAttribution(map); // nur ⓘ, nicht ausgeschrieben
// Container kann beim Erstellen (Modal/Animation) noch 0×0 sein → mehrfach resizen.
var _rz = function () { try { map.resize(); } catch (e) {} };
requestAnimationFrame(_rz);
setTimeout(_rz, 120); setTimeout(_rz, 400);
return _wrapMap(map);
},
// svgMarker: custom HTML-Icon. opts: { size, anchorY }
svgMarker: function (lat, lon, html, opts) {
opts = opts || {};
var el = _elFromHtml(html);
// anchorY: Pixel von oben zum Ankerpunkt (Leaflet iconAnchor). 'bottom' wenn anchorY≈size.
var anchor = 'center';
if (opts.anchorY != null && opts.size) {
anchor = opts.anchorY >= opts.size * 0.8 ? 'bottom' : 'center';
}
return _wrapMarker(lat, lon, el, anchor);
},
circleMarker: function (lat, lon, opts) {
opts = opts || {};
var r = opts.radius || 8;
var el = document.createElement('div');
el.style.cssText = 'width:' + (r * 2) + 'px;height:' + (r * 2) + 'px;border-radius:50%;background:' +
(opts.fillColor || opts.color || '#3B82F6') + ';border:' + (opts.weight || 2) + 'px solid ' +
(opts.color || '#fff') + ';opacity:' + (opts.fillOpacity != null ? opts.fillOpacity : 1) +
';box-shadow:0 1px 4px rgba(0,0,0,.35);cursor:pointer';
return _wrapMarker(lat, lon, el, 'center');
},
polyline: function (latlngs, opts) { return _wrapPolyline(latlngs, opts); },
clusterGroup: function () { return _wrapGroup(); },
// featureGroup: nur als Bounds-Container (markers = Array von Wrappern mit _gl.getLngLat()).
featureGroup: function (markers) {
var coords = (markers || []).map(function (m) {
var ll = m && m._gl && m._gl.getLngLat ? m._gl.getLngLat() : null;
return ll ? [ll.lat, ll.lng] : null;
}).filter(Boolean);
return { _coords: coords, getBounds: function () { return { _coords: coords }; }, addTo: function () { return this; } };
},
};
})();

View file

@ -1,176 +0,0 @@
// MapLibre-GL-Style für die zentrale Karte — gerendert aus unseren DACH-PMTiles
// (OpenMapTiles-Schema). GPU + Worker → performant auf dem Handy (Ziel der Migration).
// GEOMETRIE-ONLY (keine Symbol/Text-Layer) → KEINE Glyphs/Fonts nötig für den ersten
// Perf-Test. Labels (mit Glyph-Hosting) kommen in M3, wenn die Performance steht.
(function () {
'use strict';
var TILES_FILE = 'dach.pmtiles';
// Cache-Bust: gleiche Datei-URL, aber Inhalt ändert sich bei jedem Tile-Deploy (atomarer Swap).
// Ohne Versions-Param liefert der Browser bis zu 24h die ALTEN PMTiles-Bytes (inkl. altem Directory)
// → man sähe die alte Abdeckung. Bei jedem `make tiles-deploy` HOCHZÄHLEN (Makefile sed't das).
var TILES_VER = '20260605';
function tilesUrl() { return window.location.origin + '/tiles/' + TILES_FILE + '?v=' + TILES_VER; }
// Offline-Tiles-Modus (byt://-Quelle). Opt-in via localStorage by_offline_tiles='1' bzw. ?tilesoffline=1.
// Default AUS, bis auf Gerät verifiziert — dann hier auf Staging-Default umstellen (analog by_map_gl).
function _offlineEnabled() {
try { return localStorage.getItem('by_offline_tiles') === '1'; } catch (e) { return false; }
}
var THEMES = {
light: {
bg: '#f2efe8', land: '#cbe3a8', park: '#aedd88', water: '#7fbbe8',
forest: '#74b356', grass: '#cdeaa6', wetland: '#9ed2bc', farmland: '#e7eecb', sand: '#efe6c8', parkLine: '#4e9a3a',
road: '#ffffff', roadMotorway: '#e89aa0', roadTrunk: '#efb188', roadPrimary: '#f4cf92', roadSecondary: '#efe79c',
roadCasing: '#cdbfa9', building: '#e6d8bf',
buildingLine: '#cdbb9c', boundary: '#a06ec0', path: '#b08160', rail: '#9a9aa2',
label: '#2a2823', roadLabel: '#574f43', waterLabel: '#2f6aa0', poiLabel: '#4a4236', labelHalo: 'rgba(255,255,255,0.95)',
},
dark: {
bg: '#1a1d21', land: '#252e1d', park: '#2c3c1f', water: '#163242',
forest: '#33501f', grass: '#26361a', wetland: '#264039', farmland: '#222b18', sand: '#332f1f', parkLine: '#3f6e2a',
road: '#444a52', roadMotorway: '#70454b', roadTrunk: '#6e533c', roadPrimary: '#6b5e3a', roadSecondary: '#565232',
roadCasing: '#23282d', building: '#2a2f35',
buildingLine: '#373d44', boundary: '#8a63a0', path: '#6b5d52', rail: '#5e5e68',
label: '#e2e5e9', roadLabel: '#a6acb3', waterLabel: '#7db0dd', poiLabel: '#c3b9a8', labelHalo: 'rgba(0,0,0,0.85)',
},
};
var FONT = ['Open Sans Regular'];
var FONT_BOLD = ['Open Sans Semibold'];
// Liefert ein MapLibre-Style-JSON (Version 8) ohne glyphs/sprite.
function build(opts) {
opts = opts || {};
var t = THEMES[opts.dark ? 'dark' : 'light'];
// offline → Tiles übers byt://-Protokoll (IndexedDB-first, remote-Fallback) statt direkt aus der
// Remote-PMTiles. Nötig für Offline-Betrieb. Default aus Flag (by_offline_tiles), explizit übersteuerbar.
var useOffline = opts.offline != null ? opts.offline : _offlineEnabled();
var src = useOffline
? { type: 'vector', tiles: ['byt://t/{z}/{x}/{y}'], minzoom: 0, maxzoom: 14 }
: { type: 'vector', url: 'pmtiles://' + tilesUrl() };
return {
version: 8,
glyphs: window.location.origin + '/fonts/{fontstack}/{range}.pbf',
sources: {
by: src,
},
layers: [
{ id: 'bg', type: 'background', paint: { 'background-color': t.bg } },
// Landbedeckung nach Klasse: Wald dunkler, Wiese heller, Moor/Feuchtgebiet eigen.
{ id: 'landcover', type: 'fill', source: 'by', 'source-layer': 'landcover',
paint: {
'fill-color': ['match', ['get', 'class'],
'wood', t.forest, 'grass', t.grass, 'wetland', t.wetland,
'farmland', t.farmland, 'sand', t.sand, t.land],
'fill-opacity': 0.85,
} },
// Schutzgebiete/Naturparks: dezente Füllung + grüne gestrichelte Umrandung
// (NICHT aufhellend über den Wald legen → sonst wirkt z.B. der Ebersberger Forst heller).
{ id: 'park-fill', type: 'fill', source: 'by', 'source-layer': 'park',
paint: { 'fill-color': t.park, 'fill-opacity': 0.18 } },
{ id: 'park-outline', type: 'line', source: 'by', 'source-layer': 'park',
paint: { 'line-color': t.parkLine, 'line-width': 1.2, 'line-dasharray': [4, 2], 'line-opacity': 0.55 } },
{ id: 'water', type: 'fill', source: 'by', 'source-layer': 'water',
paint: { 'fill-color': t.water } },
{ id: 'waterway', type: 'line', source: 'by', 'source-layer': 'waterway',
paint: { 'line-color': t.water, 'line-width': 1 } },
// Pfade/Wege/Tracks: dünn + gestrichelt (NICHT wie Straßen).
{ id: 'paths', type: 'line', source: 'by', 'source-layer': 'transportation', minzoom: 13,
filter: ['in', ['get', 'class'], ['literal', ['path', 'track']]],
paint: { 'line-color': t.path, 'line-dasharray': [1.8, 1.8],
'line-width': ['interpolate', ['linear'], ['zoom'], 13, 0.6, 16, 1.2, 19, 2] } },
// Straßen-Casing (nur echte Straßen, Breite nach Klasse).
{ id: 'road-casing', type: 'line', source: 'by', 'source-layer': 'transportation', minzoom: 11,
filter: ['!', ['in', ['get', 'class'], ['literal', ['path', 'track', 'ferry', 'rail', 'transit', 'aerialway']]]],
paint: { 'line-color': t.roadCasing,
'line-width': ['interpolate', ['linear'], ['zoom'],
11, ['match', ['get', 'class'], ['motorway', 'trunk'], 3, ['primary', 'secondary'], 2.2, 1.6],
16, ['match', ['get', 'class'], ['motorway', 'trunk'], 8, ['primary', 'secondary'], 6, 4.5]] } },
// Straßen-Füllung.
{ id: 'roads', type: 'line', source: 'by', 'source-layer': 'transportation',
filter: ['!', ['in', ['get', 'class'], ['literal', ['path', 'track', 'ferry', 'rail', 'transit', 'aerialway']]]],
paint: { 'line-color': ['match', ['get', 'class'],
'motorway', t.roadMotorway, 'trunk', t.roadTrunk, 'primary', t.roadPrimary, 'secondary', t.roadSecondary, t.road],
'line-width': ['interpolate', ['linear'], ['zoom'],
6, ['match', ['get', 'class'], ['motorway', 'trunk'], 1.4, 0.4],
12, ['match', ['get', 'class'], ['motorway', 'trunk', 'primary'], 2.4, 1.1],
16, ['match', ['get', 'class'], ['motorway', 'trunk'], 6, ['primary', 'secondary'], 4.5, 3]] } },
// Bahntrassen: Basis-Linie + Schwellen (dicke gestrichelte Überlagerung).
{ id: 'railway', type: 'line', source: 'by', 'source-layer': 'transportation', minzoom: 11,
filter: ['in', ['get', 'class'], ['literal', ['rail', 'transit']]],
paint: { 'line-color': t.rail, 'line-width': ['interpolate', ['linear'], ['zoom'], 11, 0.8, 16, 2] } },
{ id: 'railway-ties', type: 'line', source: 'by', 'source-layer': 'transportation', minzoom: 13,
filter: ['in', ['get', 'class'], ['literal', ['rail', 'transit']]],
paint: { 'line-color': t.rail, 'line-dasharray': [0.35, 3],
'line-width': ['interpolate', ['linear'], ['zoom'], 13, 3, 16, 6] } },
{ id: 'buildings', type: 'fill', source: 'by', 'source-layer': 'building',
minzoom: 13,
paint: { 'fill-color': t.building, 'fill-outline-color': t.buildingLine } },
{ id: 'boundary', type: 'line', source: 'by', 'source-layer': 'boundary',
paint: { 'line-color': t.boundary, 'line-dasharray': [2, 2], 'line-width': 1 } },
// ---- Labels (brauchen glyphs). Reihenfolge = Kollisions-Priorität (zuerst = wichtiger). ----
// Ortsnamen (Städte/Dörfer) zuerst — höchste Priorität, auch bei kleinem Zoom.
{ id: 'place-labels', type: 'symbol', source: 'by', 'source-layer': 'place',
filter: ['in', ['get', 'class'], ['literal', ['city', 'town', 'village', 'suburb', 'hamlet', 'neighbourhood']]],
layout: {
'text-field': ['coalesce', ['get', 'name:de'], ['get', 'name']],
'text-font': FONT_BOLD, 'text-max-width': 8, 'text-anchor': 'center',
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 10, 8, 12, 12, 14, 16, 17],
},
paint: { 'text-color': t.label, 'text-halo-color': t.labelHalo, 'text-halo-width': 1.6 } },
{ id: 'water-labels', type: 'symbol', source: 'by', 'source-layer': 'water_name',
layout: { 'text-field': ['coalesce', ['get', 'name:de'], ['get', 'name']], 'text-font': FONT_BOLD, 'text-size': 12, 'text-max-width': 6 },
paint: { 'text-color': t.waterLabel, 'text-halo-color': t.labelHalo, 'text-halo-width': 1.2 } },
{ id: 'street-labels', type: 'symbol', source: 'by', 'source-layer': 'transportation_name', minzoom: 14,
layout: { 'text-field': ['coalesce', ['get', 'name:de'], ['get', 'name']], 'text-font': FONT_BOLD, 'symbol-placement': 'line', 'text-size': 11 },
paint: { 'text-color': t.roadLabel, 'text-halo-color': t.labelHalo, 'text-halo-width': 1.2 } },
// Straßennummern (A9, B304, ST2078) entlang großer Straßen.
{ id: 'road-refs', type: 'symbol', source: 'by', 'source-layer': 'transportation_name', minzoom: 8,
filter: ['all', ['has', 'ref'], ['in', ['get', 'class'], ['literal', ['motorway', 'trunk', 'primary', 'secondary', 'tertiary']]]],
layout: {
'text-field': ['get', 'ref'], 'text-font': FONT_BOLD, 'text-size': 11,
'symbol-placement': 'line', 'symbol-spacing': 350, 'text-max-angle': 20,
'text-rotation-alignment': 'viewport', 'text-pitch-alignment': 'viewport',
},
paint: { 'text-color': t.label, 'text-halo-color': t.labelHalo, 'text-halo-width': 2.5 } },
// POI-Namen (Kinderspielplatz, Schule, …) ab Z15 — Kollisionserkennung verhindert Überladung.
{ id: 'poi-labels', type: 'symbol', source: 'by', 'source-layer': 'poi', minzoom: 15,
layout: {
'text-field': ['coalesce', ['get', 'name:de'], ['get', 'name']],
'text-font': FONT_BOLD, 'text-size': 11, 'text-max-width': 8,
'text-anchor': 'top', 'text-offset': [0, 0.4], 'symbol-sort-key': ['get', 'rank'],
},
paint: { 'text-color': t.poiLabel, 'text-halo-color': t.labelHalo, 'text-halo-width': 1.2 } },
// Hausnummern ab Z17 (niedrigste Priorität).
{ id: 'housenumbers', type: 'symbol', source: 'by', 'source-layer': 'housenumber', minzoom: 17,
layout: { 'text-field': ['get', 'housenumber'], 'text-font': FONT_BOLD, 'text-size': 9.5 },
paint: { 'text-color': t.roadLabel, 'text-halo-color': t.labelHalo, 'text-halo-width': 1 } },
],
};
}
// Compact-Attribution standardmäßig EINGEKLAPPT lassen (nur das ⓘ; der volle Text
// "© OpenStreetMap contributors" erscheint erst auf Klick). MapLibre rendert sie sonst
// offen (Klasse maplibregl-compact-show + open). Rechtlich reicht das ⓘ (ODbL).
function collapseAttribution(map) {
var fn = function () {
try {
var a = map.getContainer().querySelector('.maplibregl-ctrl-attrib');
if (a) { a.classList.remove('maplibregl-compact-show'); a.removeAttribute('open'); }
} catch (e) {}
};
fn();
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(fn);
setTimeout(fn, 60);
}
window.MapGLStyle = { build: build, tilesUrl: tilesUrl, tilesFile: TILES_FILE, collapseAttribution: collapseAttribution, offlineEnabled: _offlineEnabled };
})();

View file

@ -1,141 +0,0 @@
/* ============================================================
BAN YARO Offline-Vektorkacheln
Lädt einen Bereich aus der Remote-PMTiles (dach.pmtiles) als einzelne MVT-Tiles
in IndexedDB und bedient MapLibre offline daraus über das `byt://`-Protokoll.
Plan/Architektur: docs/OFFLINE_MAPS_PLAN.md
============================================================ */
window.MapOffline = (function () {
'use strict';
var DB_NAME = 'by-offline-tiles', STORE = 'tiles', META = 'meta', DB_VER = 1;
var MAXZOOM = 14; // unsere pmtiles enden bei z14 (Overzoom darüber)
var _db = null, _pm = null;
// Hinweis: pmtiles.getZxy() liefert die Tiles BEREITS dekomprimiert (rohe MVT-Protobufs) →
// wir speichern/servieren sie direkt, kein gunzip. Dadurch ist die IndexedDB-Größe ~2,5× die
// komprimierte pmtiles-Extract-Größe (5 km ≈ ~16 MB statt 6,4 MB) — fürs Handy unkritisch.
// ---- IndexedDB ----
function _open() {
if (_db) return Promise.resolve(_db);
return new Promise(function (res, rej) {
var r = indexedDB.open(DB_NAME, DB_VER);
r.onupgradeneeded = function () {
var d = r.result;
if (!d.objectStoreNames.contains(STORE)) d.createObjectStore(STORE);
if (!d.objectStoreNames.contains(META)) d.createObjectStore(META);
};
r.onsuccess = function () { _db = r.result; res(_db); };
r.onerror = function () { rej(r.error); };
});
}
function _req(store, mode, make) {
return _open().then(function (d) { return new Promise(function (res, rej) {
var tx = d.transaction(store, mode), rq = make(tx.objectStore(store));
tx.oncomplete = function () { res(rq ? rq.result : undefined); };
tx.onerror = function () { rej(tx.error); };
}); });
}
var _get = function (k) { return _req(STORE, 'readonly', function (os) { return os.get(k); }); };
var _put = function (k, v) { return _req(STORE, 'readwrite', function (os) { os.put(v, k); }); };
var _count = function () { return _req(STORE, 'readonly', function (os) { return os.count(); }); };
// ---- Remote-PMTiles ----
function _pmInst() { if (!_pm) _pm = new pmtiles.PMTiles(MapGLStyle.tilesUrl()); return _pm; }
// MVT-Bytes (Uint8Array) für z/x/y — IndexedDB zuerst, sonst remote (online), sonst null.
function tile(z, x, y) {
return _get(z + '/' + x + '/' + y).then(function (hit) {
if (hit) return hit instanceof Uint8Array ? hit : new Uint8Array(hit);
return _pmInst().getZxy(z, x, y).then(function (r) {
return (r && r.data) ? new Uint8Array(r.data) : null; // getZxy ist bereits dekomprimiert
}).catch(function () { return null; }); // offline + nicht gespeichert → leeres Tile
});
}
// MapLibre-Protokoll `byt://t/{z}/{x}/{y}` registrieren (idempotent).
function registerProtocol() {
if (registerProtocol._done || typeof maplibregl === 'undefined') return;
registerProtocol._done = true;
maplibregl.addProtocol('byt', function (params) {
var m = /byt:\/\/t\/(\d+)\/(\d+)\/(\d+)/.exec(params.url);
if (!m) return Promise.resolve({ data: new ArrayBuffer(0) });
return tile(+m[1], +m[2], +m[3]).then(function (u) {
if (!u) return { data: new ArrayBuffer(0) };
return { data: u.buffer.slice(u.byteOffset, u.byteOffset + u.byteLength) };
});
});
}
// ---- Slippy-Tile-Mathe ----
function _x(lon, z) { return Math.floor((lon + 180) / 360 * Math.pow(2, z)); }
function _y(lat, z) {
var r = lat * Math.PI / 180;
return Math.floor((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2 * Math.pow(2, z));
}
function _tileList(lat, lon, radiusKm) {
var dLat = radiusKm / 111, dLon = radiusKm / (111 * Math.cos(lat * Math.PI / 180));
var w = lon - dLon, e = lon + dLon, s = lat - dLat, n = lat + dLat, list = [];
for (var z = 0; z <= MAXZOOM; z++) {
var x0 = _x(w, z), x1 = _x(e, z), y0 = _y(n, z), y1 = _y(s, z);
for (var x = x0; x <= x1; x++) for (var y = y0; y <= y1; y++) list.push([z, x, y]);
}
return list;
}
// Glyphs (Open Sans Regular/Semibold, Latin + Latin-Extended) holen, damit der Service-Worker sie cacht.
// KRITISCH: ohne Glyphs lässt MapLibre offline die GANZE Kachel fallen (nicht nur die Labels) → leer.
// 0-255 + 256-511 deckt DE/FR/PL/CZ/IT-Sonderzeichen ab. (Persistenz über App-Updates = Follow-up.)
var FONTS = ['Open Sans Regular', 'Open Sans Semibold'], RANGES = ['0-255', '256-511'];
function _cacheGlyphs() {
var bytes = 0, jobs = [];
FONTS.forEach(function (f) { RANGES.forEach(function (rg) {
jobs.push(fetch('/fonts/' + encodeURIComponent(f) + '/' + rg + '.pbf')
.then(function (r) { return r.ok ? r.arrayBuffer() : null; })
.then(function (b) { if (b) bytes += b.byteLength; })
.catch(function () {}));
}); });
return Promise.all(jobs).then(function () { return bytes; });
}
// Bereich um lat/lon (radiusKm, Default 5) herunterladen + in IndexedDB ablegen.
// onProgress({done,total,bytes}). Liefert {tiles,bytes}.
function downloadAround(lat, lon, radiusKm, onProgress) {
radiusKm = radiusKm || 5;
var list = _tileList(lat, lon, radiusKm), total = list.length, done = 0, bytes = 0, stored = 0, i = 0, CONC = 6;
function next() {
if (i >= total) return Promise.resolve();
var t = list[i++], key = t[0] + '/' + t[1] + '/' + t[2];
return _pmInst().getZxy(t[0], t[1], t[2]).then(function (r) {
if (r && r.data) { var u = new Uint8Array(r.data); bytes += u.byteLength; stored++; return _put(key, u); }
}).catch(function () {}).then(function () {
done++;
if (onProgress && (done % 8 === 0 || done === total)) onProgress({ done: done, total: total, bytes: bytes });
return next();
});
}
var w = []; for (var k = 0; k < CONC; k++) w.push(next());
return Promise.all(w)
.then(function () { return _cacheGlyphs(); }) // Glyphs mitcachen (sonst offline kein Render)
.then(function (gb) { bytes += gb; return _req(META, 'readwrite', function (os) {
os.put({ lat: lat, lon: lon, radiusKm: radiusKm, tiles: stored, bytes: bytes, savedAt: Date.now() }, 'region');
}); })
.then(function () { return { tiles: stored, bytes: bytes }; });
}
function stats() {
return _count().then(function (count) {
return _req(META, 'readonly', function (os) { return os.get('region'); })
.then(function (meta) { return { count: count, meta: meta || null }; });
});
}
function hasRegion() { return stats().then(function (s) { return s.count > 0; }).catch(function () { return false; }); }
function clear() {
return _req(STORE, 'readwrite', function (os) { os.clear(); })
.then(function () { return _req(META, 'readwrite', function (os) { os.clear(); }); });
}
return {
registerProtocol: registerProtocol, downloadAround: downloadAround, tile: tile,
stats: stats, hasRegion: hasRegion, clear: clear, MAXZOOM: MAXZOOM,
};
})();

View file

@ -1,92 +0,0 @@
// Vektor-Basemap für Leaflet via protomaps-leaflet, gerendert aus unseren eigenen
// PMTiles (OpenMapTiles-Schema von planetiler, ausgeliefert unter /tiles/).
// Ersetzt den OSM-Raster-Layer — Leaflet + markercluster + alle Marker bleiben unberührt.
// Labels werden von protomaps-leaflet per Canvas-Text gezeichnet → KEINE Glyphs nötig.
(function () {
'use strict';
// Single-File-Tile-Archiv (DACH). Liegt im data-Volume, per Range ausgeliefert.
var TILES_FILE = 'dach.pmtiles';
function tilesUrl() {
return window.location.origin + '/tiles/' + TILES_FILE;
}
// Straßenbreite zoomabhängig (dünn weit draußen, breit im Detail).
function roadWidth(z) {
if (z >= 16) return 4;
if (z >= 14) return 2.5;
if (z >= 12) return 1.5;
if (z >= 9) return 0.8;
return 0.4;
}
// Farbpaletten Light/Dark.
var THEMES = {
light: {
bg: '#f4f1ec', water: '#a0c8f0', land: '#dce8c8', park: '#c8e6b0',
road: '#ffffff', roadCasing: '#d9cfc2', building: '#e6ddcf',
buildingLine: '#d4cabb', boundary: '#b08ac0',
label: '#33312e', labelHalo: 'rgba(255,255,255,.85)',
},
dark: {
bg: '#1a1d21', water: '#16242e', land: '#222820', park: '#27331f',
road: '#3a4046', roadCasing: '#23282d', building: '#262b30',
buildingLine: '#31373d', boundary: '#7d5a8c',
label: '#cfd2d6', labelHalo: 'rgba(0,0,0,.8)',
},
};
function buildRules(t) {
var P = protomapsL;
var paint = [
{ dataLayer: 'landcover',
symbolizer: new P.PolygonSymbolizer({ fill: t.land, opacity: 0.55 }) },
{ dataLayer: 'park',
symbolizer: new P.PolygonSymbolizer({ fill: t.park, opacity: 0.5 }) },
{ dataLayer: 'water',
symbolizer: new P.PolygonSymbolizer({ fill: t.water }) },
{ dataLayer: 'waterway',
symbolizer: new P.LineSymbolizer({ color: t.water, width: 0.8 }) },
// Straßen-Casing zuerst (liegt unter der Füllung), dann die Straße.
{ dataLayer: 'transportation', minzoom: 11,
symbolizer: new P.LineSymbolizer({ color: t.roadCasing,
width: function (z) { return roadWidth(z) + 1.5; } }) },
{ dataLayer: 'transportation',
symbolizer: new P.LineSymbolizer({ color: t.road, width: roadWidth }) },
{ dataLayer: 'building', minzoom: 13,
symbolizer: new P.PolygonSymbolizer({ fill: t.building, stroke: t.buildingLine, width: 0.5 }) },
{ dataLayer: 'boundary',
symbolizer: new P.LineSymbolizer({ color: t.boundary, width: 1, dash: [2, 2] }) },
];
var label = [
{ dataLayer: 'place', minzoom: 4,
symbolizer: new P.CenteredTextSymbolizer({
labelProps: ['name:de', 'name'],
fill: t.label, stroke: t.labelHalo, width: 2.5,
font: function (z) { return (z >= 10 ? '600 13px' : '600 11px') + ' system-ui, sans-serif'; },
}) },
];
return { paint: paint, label: label };
}
// Erzeugt den Leaflet-Layer. dark=true → dunkles Theme.
function basemapLayer(opts) {
opts = opts || {};
var t = THEMES[opts.dark ? 'dark' : 'light'];
var rules = buildRules(t);
return protomapsL.leafletLayer({
url: tilesUrl(),
paintRules: rules.paint,
labelRules: rules.label,
backgroundColor: t.bg,
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
});
}
window.MapVector = {
basemapLayer: basemapLayer,
tilesUrl: tilesUrl,
tilesFile: TILES_FILE,
};
})();

View file

@ -1,64 +0,0 @@
// Headless-Proof für map-gl-markers.js: 4 Kategorien, ~490 Fake-Marker, Cluster,
// Phosphor-Icons, Danger-Radien, Sichtbarkeits-Toggle, Popups — alles ohne App/Auth.
(function () {
'use strict';
var st = document.getElementById('status');
function set(t) { if (st) st.textContent = t; }
var TYPES = {
restaurant: { color: '#F97316', iconName: 'fork-knife' },
freilauf: { color: '#22C55E', iconName: 'dog' },
tierarzt: { color: '#EF4444', iconName: 'first-aid' },
poison: { color: '#DC2626', iconName: 'skull', danger: true },
};
var COUNTS = { restaurant: 250, freilauf: 150, tierarzt: 80, poison: 8 };
function genPois(n, seedStart) {
var feats = [], seed = seedStart;
function rnd() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }
for (var i = 0; i < n; i++) {
feats.push({ lat: 48.03 + rnd() * 0.24, lon: 11.40 + rnd() * 0.36, name: 'POI ' + i, source: 'osm' });
}
return feats;
}
try {
var proto = new pmtiles.Protocol();
maplibregl.addProtocol('pmtiles', proto.tile);
var map = new maplibregl.Map({
container: 'map', style: MapGLStyle.build({ dark: false }),
center: [11.576, 48.137], zoom: 12, dragRotate: false,
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-left');
map.addControl(new maplibregl.ScaleControl());
map.on('error', function (e) { set('⚠️ ' + (e && e.error ? e.error.message : 'Fehler')); if (e && e.error) console.error(e.error); });
map.on('load', function () {
MapGLMarkers.init(map, {
types: TYPES, dangerKeys: ['poison'], dangerRadiusM: 100,
popupHTML: function (p, key) { return '<b>' + (p.name || key) + '</b><br><small>' + key + '</small><br><button id="mp-x">OK</button>'; },
popupWire: function (p, key, close) { document.getElementById('mp-x') && document.getElementById('mp-x').addEventListener('click', close); },
}).then(function () {
var seeds = { restaurant: 11, freilauf: 77, tierarzt: 123, poison: 200 };
Object.keys(TYPES).forEach(function (k) { MapGLMarkers.setLayer(k, genPois(COUNTS[k], seeds[k])); });
// Toggle-Buttons
var box = document.getElementById('toggles');
Object.keys(TYPES).forEach(function (k) {
var b = document.createElement('button'); b.textContent = k; b.dataset.on = '1';
b.style.borderColor = TYPES[k].color;
b.addEventListener('click', function () {
var on = b.dataset.on === '1' ? false : true;
b.dataset.on = on ? '1' : '0'; b.classList.toggle('off', !on);
MapGLMarkers.setVisible(k, on);
});
box.appendChild(b);
});
var total = COUNTS.restaurant + COUNTS.freilauf + COUNTS.tierarzt + COUNTS.poison;
set('✅ ' + total + ' Marker (4 Kat.) — Cluster + Phosphor-Icons + Danger-Radius + Toggle + Popup');
}).catch(function (e) { set('❌ init: ' + (e && e.message ? e.message : e)); console.error(e); });
});
} catch (e) {
set('❌ ' + (e && e.message ? e.message : e));
}
})();

View file

@ -1,70 +0,0 @@
// Wegwerf-Perf-Test: beweist MapLibre-GPU-Rendering auf dem Handy mit realistischer
// Marker-Last (600 Punkte, GeoJSON-Clustering) auf unserer DACH-Basemap — die Kombi,
// die mit protomaps-leaflet (Main-Thread) den UI-Thread blockierte.
// Cluster-Zahlen weggelassen (Text bräuchte Glyphs → kommt erst in M3).
(function () {
'use strict';
var st = document.getElementById('status');
function set(t) { if (st) st.textContent = t; }
try {
var proto = new pmtiles.Protocol();
maplibregl.addProtocol('pmtiles', proto.tile);
var isDark = document.documentElement.dataset.theme === 'dark';
var map = new maplibregl.Map({
container: 'map',
style: MapGLStyle.build({ dark: isDark }),
center: [11.576, 48.137], zoom: 12, hash: true,
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
map.addControl(new maplibregl.ScaleControl());
// Kategorie-Icon (farbiger Kreis) per Canvas → addImage (Icons brauchen KEINE Glyphs).
function makeIcon(color) {
var s = 34, c = document.createElement('canvas'); c.width = c.height = s;
var x = c.getContext('2d');
x.beginPath(); x.arc(s / 2, s / 2, s / 2 - 3, 0, Math.PI * 2);
x.fillStyle = color; x.fill();
x.lineWidth = 2; x.strokeStyle = 'rgba(52,68,36,0.6)'; x.stroke();
return x.getImageData(0, 0, s, s);
}
// 600 deterministische Pseudo-POIs um München (3 Kategorien).
function genPois(n) {
var feats = [], seed = 42;
function rnd() { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; }
for (var i = 0; i < n; i++) {
feats.push({ type: 'Feature', properties: { cat: i % 3 },
geometry: { type: 'Point', coordinates: [11.40 + rnd() * 0.36, 48.03 + rnd() * 0.24] } });
}
return { type: 'FeatureCollection', features: feats };
}
map.on('load', function () {
map.addImage('cat0', makeIcon('#e8590c'));
map.addImage('cat1', makeIcon('#2f9e44'));
map.addImage('cat2', makeIcon('#1971c2'));
map.addSource('pois', { type: 'geojson', data: genPois(600),
cluster: true, clusterRadius: 50, clusterMaxZoom: 16 });
map.addLayer({ id: 'clusters', type: 'circle', source: 'pois', filter: ['has', 'point_count'],
paint: {
'circle-color': '#5b4a2f', 'circle-stroke-color': 'rgba(52,68,36,0.65)', 'circle-stroke-width': 2,
'circle-radius': ['step', ['get', 'point_count'], 14, 10, 18, 50, 24],
} });
map.addLayer({ id: 'poi', type: 'symbol', source: 'pois', filter: ['!', ['has', 'point_count']],
layout: {
'icon-image': ['match', ['get', 'cat'], 0, 'cat0', 1, 'cat1', 2, 'cat2', 'cat0'],
'icon-allow-overlap': true, 'icon-size': 0.7,
} });
set('✅ MapLibre + 600 Marker — jetzt zoomen/schieben, fühlt sich das flüssig an?');
});
map.on('error', function (e) {
set('⚠️ ' + (e && e.error ? e.error.message : 'Fehler'));
if (e && e.error) console.error(e.error);
});
} catch (e) {
set('❌ ' + (e && e.message ? e.message : e));
}
})();

View file

@ -1,86 +0,0 @@
// Tile-Server-Spike: MapLibre rendert unsere eigene bayern.pmtiles.
// Minimaler Geometrie-Style (OpenMapTiles-Schema von planetiler), KEINE Labels →
// keine Glyphs/Fonts nötig. Touren-Overlay als GeoJSON-Line obendrauf (basemap-unabhängig).
(function () {
'use strict';
var statusEl = document.getElementById('status');
function setStatus(t) { if (statusEl) statusEl.textContent = t; }
// pmtiles-Protokoll registrieren (liest Tiles per HTTP-Range aus dem Single-File).
var protocol = new pmtiles.Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);
var TILES = 'pmtiles://' + window.location.origin + '/tiles/bayern.pmtiles';
var style = {
version: 8,
// Kein 'glyphs'/'sprite' — minimaler Style ohne Text-/Icon-Layer.
sources: {
by: { type: 'vector', url: TILES }
},
layers: [
{ id: 'bg', type: 'background', paint: { 'background-color': '#f4f1ec' } },
{ id: 'landcover', type: 'fill', source: 'by', 'source-layer': 'landcover',
paint: { 'fill-color': '#d6e6c3', 'fill-opacity': 0.6 } },
{ id: 'park', type: 'fill', source: 'by', 'source-layer': 'park',
paint: { 'fill-color': '#c8e6b0', 'fill-opacity': 0.5 } },
{ id: 'water', type: 'fill', source: 'by', 'source-layer': 'water',
paint: { 'fill-color': '#a0c8f0' } },
{ id: 'waterway', type: 'line', source: 'by', 'source-layer': 'waterway',
paint: { 'line-color': '#a0c8f0', 'line-width': 1 } },
{ id: 'roads', type: 'line', source: 'by', 'source-layer': 'transportation',
paint: {
'line-color': '#ffffff',
'line-width': ['interpolate', ['linear'], ['zoom'], 6, 0.5, 12, 1.5, 16, 4]
} },
{ id: 'road-casing', type: 'line', source: 'by', 'source-layer': 'transportation',
minzoom: 11,
paint: { 'line-color': '#d9cfc2', 'line-gap-width': 1,
'line-width': ['interpolate', ['linear'], ['zoom'], 11, 0.5, 16, 2] } },
{ id: 'buildings', type: 'fill', source: 'by', 'source-layer': 'building',
minzoom: 13,
paint: { 'fill-color': '#e3dccf', 'fill-outline-color': '#d0c8ba' } },
{ id: 'boundary', type: 'line', source: 'by', 'source-layer': 'boundary',
paint: { 'line-color': '#b08ac0', 'line-dasharray': [2, 2], 'line-width': 1 } }
]
};
var map = new maplibregl.Map({
container: 'map',
style: style,
center: [11.576, 48.137], // München
zoom: 11,
hash: true
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
map.addControl(new maplibregl.ScaleControl());
map.on('load', function () {
setStatus('✅ Tiles geladen — Range-Requests laufen');
// Touren-Overlay: GeoJSON-Linie (Demo, München-Innenstadt) — Basemap-unabhängig.
map.addSource('tour', {
type: 'geojson',
data: {
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[11.5755, 48.1374], [11.5780, 48.1390], [11.5820, 48.1402],
[11.5860, 48.1395], [11.5895, 48.1378], [11.5910, 48.1350]
]
}
}
});
map.addLayer({
id: 'tour-line', type: 'line', source: 'tour',
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#e8590c', 'line-width': 5, 'line-opacity': 0.9 }
});
});
map.on('error', function (e) {
setStatus('⚠️ Fehler: ' + (e && e.error ? e.error.message : 'unbekannt'));
if (e && e.error) console.error('MapLibre error:', e.error);
});
})();

View file

@ -20,34 +20,6 @@ window.OfflineIndicator = (() => {
return found ? await caches.open(found) : null;
}
// GL-Offline-Tiles-Modus (byt://-Vektorkacheln in IndexedDB) statt OSM-Raster.
function _offlineTilesMode() {
try { return localStorage.getItem('by_offline_tiles') === '1'; } catch (e) { return false; }
}
// Ist eine Offline-Region (Vektorkacheln) in IndexedDB gespeichert? (ohne MapOffline zu laden)
// WICHTIG: dasselbe Schema/Version wie map-offline.js anlegen — sonst legt ein versionsloses open()
// die DB leer an und MapOffline kann seine Stores nicht mehr erstellen.
function _offlineRegionStored() {
return new Promise(res => {
try {
const r = indexedDB.open('by-offline-tiles', 1);
r.onupgradeneeded = () => {
const d = r.result;
if (!d.objectStoreNames.contains('tiles')) d.createObjectStore('tiles');
if (!d.objectStoreNames.contains('meta')) d.createObjectStore('meta');
};
r.onsuccess = () => {
const db = r.result;
if (!db.objectStoreNames.contains('tiles')) { db.close(); return res(false); }
const cnt = db.transaction('tiles', 'readonly').objectStore('tiles').count();
cnt.onsuccess = () => { res(cnt.result > 0); db.close(); };
cnt.onerror = () => { res(false); db.close(); };
};
r.onerror = () => res(false);
} catch (e) { res(false); }
});
}
const CHECKS = [
{ step: 1, title: 'App-Grundgerüst',
detail: 'CSS, Layout und Hauptmodule — die Basis',
@ -97,10 +69,8 @@ window.OfflineIndicator = (() => {
} },
{ step: 5, title: 'Karten-Kacheln',
detail: 'Karten für deine Gegend offline verfügbar',
detail: `Mindestens ${TILE_MIN} OSM-Tiles im Umkreis`,
probe: async () => {
// GL-Modus: gespeicherte Vektor-Region in IndexedDB (das alte OSM-Raster nutzt die GL-Karte nicht).
if (_offlineTilesMode()) return _offlineRegionStored();
const c = await caches.open(CACHE_TILES).catch(() => null);
if (!c) return false;
return (await c.keys()).length >= TILE_MIN;
@ -199,30 +169,12 @@ window.OfflineIndicator = (() => {
tasks.push(fetch('/api/routes').catch(() => {}));
tasks.push(fetch('/api/notes').catch(() => {}));
} else if (m.step === 5) {
if (_offlineTilesMode()) await _downloadOfflineRegion();
else await _prefetchTiles();
await _prefetchTiles();
}
}
await Promise.all(tasks);
}
// GL-Offline: Vektor-Region (~5 km) um den aktuellen Standort in IndexedDB laden.
async function _downloadOfflineRegion() {
let pos = null;
try { pos = await API.getLocation(); } catch (e) {}
if (!pos) {
try {
const raw = localStorage.getItem(LS_LAST_POS);
if (raw) { const p = JSON.parse(raw); pos = { lat: p.lat, lon: p.lon }; }
} catch (e) {}
}
if (!pos) { UI.toast.warning('Standort nötig, um die Gegend offline zu speichern.'); return; }
try {
await UI.loadMapLibreUI();
if (window.MapOffline) await MapOffline.downloadAround(pos.lat, pos.lon, 5);
} catch (e) { console.warn('Offline-Region-Download fehlgeschlagen:', e); }
}
// ----------------------------------------------------------
// Tile-URL-Berechnung (OSM, Subdomain 'a')
// ----------------------------------------------------------

View file

@ -2628,7 +2628,9 @@ window.Page_admin = (() => {
</thead>
<tbody>
${log.map((l, i) => `
<tr data-log-idx="${i}" class="by-hover-surface2" style="border-bottom:1px solid var(--c-border);cursor:pointer">
<tr data-log-idx="${i}" style="border-bottom:1px solid var(--c-border);cursor:pointer"
onmouseover="this.style.background='var(--c-surface-2)'"
onmouseout="this.style.background=''">
<td class="p-2">${accountBadge(l.from_account)}</td>
<td class="p-2">${UI.escape(l.recipient)}</td>
<td style="padding:var(--space-2);color:var(--c-text-secondary)">${UI.escape(l.subject)}</td>
@ -2659,7 +2661,7 @@ window.Page_admin = (() => {
background:var(--c-surface-2);border-radius:var(--radius-md);
padding:var(--space-3);max-height:60vh;overflow-y:auto;
color:var(--c-text)">${UI.escape(l.body || '(kein Text gespeichert)')}</pre>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
});
});
@ -2758,7 +2760,7 @@ window.Page_admin = (() => {
</div>
</form>`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">Speichern</button>`,
});
@ -2991,7 +2993,7 @@ window.Page_admin = (() => {
</div>
</div>`,
footer: `
<button class="btn btn-secondary" data-modal-close>Schließen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="adm-bew-save-note">Notiz speichern</button>`,
});
document.getElementById('adm-bew-save-note')?.addEventListener('click', async () => {

View file

@ -357,7 +357,7 @@ window.Page_adoption = (() => {
const foto = a.foto_url
? `<img src="${UI.escape(a.foto_url)}" alt="${UI.escape(a.name)}"
style="width:100%;height:100%;object-fit:cover"
data-fb="emoji" data-fb-emoji="🐶" data-fb-size="2rem">`
onerror="this.parentElement.innerHTML='<div style=&quot;display:flex;align-items:center;justify-content:center;height:100%;font-size:2rem&quot;>🐶</div>'">`
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐶</div>';
const distTxt = a.distanz_km != null ? `${a.distanz_km} km` : '';
@ -366,10 +366,13 @@ window.Page_adoption = (() => {
const tierheim = a.tierheim || '';
return `
<div data-adp-url="${UI.escape(a.adoptions_url)}" class="by-hover-lift"
<div data-adp-url="${UI.escape(a.adoptions_url)}"
style="border-radius:var(--radius-md);overflow:hidden;
background:var(--c-surface-2);cursor:pointer;
box-shadow:0 1px 4px rgba(0,0,0,0.08)">
box-shadow:0 1px 4px rgba(0,0,0,0.08);
transition:transform .15s,box-shadow .15s"
onmouseenter="this.style.transform='translateY(-2px)';this.style.boxShadow='0 4px 12px rgba(0,0,0,0.12)'"
onmouseleave="this.style.transform='';this.style.boxShadow='0 1px 4px rgba(0,0,0,0.08)'">
<div style="height:120px;overflow:hidden;background:var(--c-surface-3)">
${foto}
</div>
@ -456,11 +459,14 @@ window.Page_adoption = (() => {
function _shelterRow(s) {
return `
<a href="${UI.escape(s.url)}" target="_blank" rel="noopener noreferrer" class="by-hover-surface3"
<a href="${UI.escape(s.url)}" target="_blank" rel="noopener noreferrer"
style="display:flex;align-items:center;gap:var(--space-3);
padding:var(--space-3);border-radius:var(--radius-md);
background:var(--c-surface-2);text-decoration:none;color:inherit;
border:1px solid var(--c-border)">
border:1px solid var(--c-border);
transition:background .15s"
onmouseenter="this.style.background='var(--c-surface-3)'"
onmouseleave="this.style.background='var(--c-surface-2)'">
<div style="width:40px;height:40px;border-radius:50%;
background:var(--c-primary-light,#ede9fe);flex-shrink:0;
display:flex;align-items:center;justify-content:center;
@ -606,7 +612,7 @@ window.Page_adoption = (() => {
const foto = l.foto_url
? `<img src="${UI.escape(l.foto_url)}" alt="${UI.escape(l.name)}"
style="width:100%;height:100%;object-fit:cover"
data-fb="emoji" data-fb-emoji="🐾" data-fb-size="2.5rem">`
onerror="this.parentElement.innerHTML='<div style=&quot;display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem&quot;>🐾</div>'">`
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>';
const isActive = !l.status || l.status === 'active';
@ -774,7 +780,7 @@ window.Page_adoption = (() => {
<button type="submit" form="adp-interest-form" class="btn btn-primary flex-1" id="adp-interest-submit">
${UI.icon('heart')} Interesse bekunden
</button>
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
</div>
`;
@ -873,7 +879,7 @@ window.Page_adoption = (() => {
<button type="submit" form="adp-create-form" class="btn btn-primary w-full" id="adp-create-submit">
${UI.icon('plus')} Inserat erstellen
</button>
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
</div>
`;

View file

@ -162,7 +162,7 @@ window.Page_breeder_editor = (() => {
<div style="position:relative;aspect-ratio:1;border-radius:var(--radius-md);overflow:hidden;background:var(--c-surface-2)">
${isVid
? `<video src="${UI.escape(ph.url)}" style="width:100%;height:100%;object-fit:cover" muted playsinline loop
data-hover-play></video>
onmouseenter="this.play()" onmouseleave="this.pause()"></video>
<div style="position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.55);border-radius:4px;padding:1px 5px;font-size:10px;color:#fff"> Video</div>`
: `<img src="${UI.escape(ph.thumbnail_url || ph.url)}" style="width:100%;height:100%;object-fit:cover">`}
${ph.is_primary ? `<div style="position:absolute;top:4px;left:4px;background:rgba(196,132,58,.9);border-radius:3px;padding:1px 5px;font-size:9px;color:#fff;font-weight:700">LOGO</div>` : ''}

View file

@ -91,7 +91,7 @@ window.Page_breeder = (() => {
? `<img src="${UI.escape(p.logo_url)}" alt="Zwinger-Logo"
style="width:72px;height:72px;border-radius:50%;object-fit:cover;
border:3px solid rgba(255,255,255,.5);flex-shrink:0;box-shadow:0 2px 12px rgba(0,0,0,.25)"
data-fb="hide">`
onerror="this.style.display='none'">`
: `<div style="background:rgba(255,255,255,.15);border-radius:50%;width:64px;height:64px;
display:flex;align-items:center;justify-content:center;flex-shrink:0">
<svg style="width:32px;height:32px" viewBox="0 0 256 256"><use href="/icons/phosphor.svg#paw-print"></use></svg>
@ -214,7 +214,7 @@ window.Page_breeder = (() => {
<img src="${UI.escape(ph.thumb)}" alt="${UI.escape(ph.caption)}"
loading="${i < 6 ? 'eager' : 'lazy'}"
style="width:100%;height:100%;object-fit:cover;display:block"
data-fb="hide-parent">
onerror="this.parentElement.style.display='none'">
${ph.primary ? `<span style="position:absolute;top:4px;left:4px;background:var(--c-primary);
color:white;font-size:9px;font-weight:700;border-radius:999px;padding:1px 6px">Logo</span>` : ''}
${ph.caption ? `<div style="position:absolute;bottom:0;left:0;right:0;
@ -386,7 +386,7 @@ window.Page_breeder = (() => {
border:1px solid var(--c-border);aspect-ratio:1">
<img src="${UI.escape(ph.thumbnail_url||ph.url||'')}" alt="${UI.escape(ph.caption||'')}"
loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block"
data-fb="hide-parent">
onerror="this.parentElement.style.display='none'">
</a>`).join('')}
</div>
</div>`;

View file

@ -18,23 +18,6 @@ window.Page_chat = (() => {
_container = container;
_myId = appState?.user?.id || null;
// Delegierter Click-Handler — Inline-onclick wird von der CSP blockiert.
if (!_container._chatClickBound) {
_container.addEventListener('click', e => {
const t = e.target.closest('[data-chat-action]');
if (!t) return;
switch (t.dataset.chatAction) {
case 'open': _openThread(parseInt(t.dataset.chatId, 10)); break;
case 'list': _showList(); break;
case 'photo': document.getElementById('chat-photo-input')?.click(); break;
case 'send': _send(); break;
case 'delete': _deleteMsg(parseInt(t.dataset.chatId, 10)); break;
case 'img': window.open(t.dataset.chatUrl, '_blank'); break;
}
});
_container._chatClickBound = true;
}
// Heartbeat: alle 30s online-Status senden
API.chat.heartbeat().catch(() => {});
_heartbeatTimer = setInterval(() => {
@ -149,7 +132,7 @@ window.Page_chat = (() => {
? `<span class="online-dot" title="Online"></span>`
: '';
return `
<div class="chat-conv-item" data-chat-action="open" data-chat-id="${c.id}">
<div class="chat-conv-item" onclick="Page_chat._openThread(${c.id})">
<div style="position:relative;flex-shrink:0">
<div class="chat-conv-avatar">${initials}</div>
${onlineDot ? `<span class="online-dot chat-avatar-dot"></span>` : ''}
@ -183,14 +166,14 @@ window.Page_chat = (() => {
// Aktive Markierung in der Liste
document.querySelectorAll('.chat-conv-item').forEach(el =>
el.classList.toggle('active', el.dataset.chatId === String(convId))
el.classList.toggle('active', el.getAttribute('onclick')?.includes(String(convId)))
);
const threadHTML = `
<div class="chat-thread" id="chat-thread">
<div class="chat-thread-header">
${_isDesktop() ? '' : `
<button class="btn btn-ghost btn-sm" data-chat-action="list" style="padding:var(--space-1)">
<button class="btn btn-ghost btn-sm" onclick="Page_chat._showList()" style="padding:var(--space-1)">
<svg class="ph-icon"><use href="/icons/phosphor.svg#arrow-left"></use></svg>
</button>`}
<div style="position:relative;flex-shrink:0">
@ -205,13 +188,14 @@ window.Page_chat = (() => {
</div>
</div>
<div class="chat-input-bar">
<input type="file" id="chat-photo-input" accept="image/*" class="hidden">
<button class="chat-photo-btn" data-chat-action="photo" title="Foto senden">
<input type="file" id="chat-photo-input" accept="image/*" class="hidden"
onchange="Page_chat._onPhotoSelected(this)">
<button class="chat-photo-btn" onclick="document.getElementById('chat-photo-input').click()" title="Foto senden">
<svg class="ph-icon"><use href="/icons/phosphor.svg#camera"></use></svg>
</button>
<textarea id="chat-input" class="chat-input" rows="1"
placeholder="Nachricht…" maxlength="2000"></textarea>
<button class="chat-send-btn" id="chat-send-btn" data-chat-action="send">
<button class="chat-send-btn" id="chat-send-btn" onclick="Page_chat._send()">
<svg class="ph-icon"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg>
</button>
</div>
@ -235,11 +219,9 @@ window.Page_chat = (() => {
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
_send();
Page_chat._send();
}
});
document.getElementById('chat-photo-input')
?.addEventListener('change', e => _onPhotoSelected(e.target));
await _loadMessages(true);
await API.chat.markRead(_convId).catch(() => {});
@ -331,7 +313,7 @@ window.Page_chat = (() => {
const timeStr = _fmtTime(m.created_at);
const deleteBtn = isMine && !m.is_deleted
? `<button class="btn btn-ghost" style="padding:2px;opacity:0.4;font-size:var(--text-xs)"
data-chat-action="delete" data-chat-id="${m.id}" title="Löschen">
onclick="Page_chat._deleteMsg(${m.id})" title="Löschen">
<svg class="ph-icon" style="width:12px;height:12px"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>`
: '';
@ -346,7 +328,7 @@ window.Page_chat = (() => {
// Medieninhalt
let bubbleContent = '';
if (m.media_url) {
bubbleContent += `<img src="${UI.escape(m.media_url)}" class="chat-bubble-img" alt="Foto" data-chat-action="img" data-chat-url="${UI.escape(m.media_url)}">`;
bubbleContent += `<img src="${UI.escape(m.media_url)}" class="chat-bubble-img" alt="Foto" onclick="window.open('${UI.escape(m.media_url)}','_blank')">`;
}
if (m.text) {
bubbleContent += (m.media_url ? `<div style="margin-top:var(--space-1)">` : '') +

View file

@ -310,10 +310,8 @@ window.Page_diary = (() => {
aria-label="Schließen">×</button>
`;
// #diary-list liegt in #diary-view-content (nicht direkt in _container) → vor der
// Liste in IHREM echten Elternknoten einfügen, sonst wirft insertBefore (NotFoundError).
const list = _container.querySelector('#diary-list');
if (list && list.parentNode) list.parentNode.insertBefore(card, list);
if (list) _container.insertBefore(card, list);
card.querySelector('#diary-praise-close')?.addEventListener('click', () => {
card.style.opacity = '0';
@ -365,13 +363,6 @@ window.Page_diary = (() => {
let _currentView = 'list'; // 'list' | 'media' | 'calendar' | 'map'
let _totalStats = null; // {entries, photos, days} — Gesamtstatistik aus API
let _diaryMaps = []; // aktive Karten-Instanzen → beim View-Wechsel freigeben (GL-Kontext-Leak)
// Karten beim View-Wechsel/Verlassen sauber freigeben (sonst leakt der WebGL-Kontext).
function _clearDiaryMaps() {
_diaryMaps.forEach(m => { try { m && m.remove && m.remove(); } catch (e) {} });
_diaryMaps = [];
}
async function _loadStats() {
const dog = _appState.activeDog;
@ -440,7 +431,6 @@ window.Page_diary = (() => {
const content = _container.querySelector('#diary-view-content');
const loadMore = _container.querySelector('#diary-load-more');
if (!content) return;
_clearDiaryMaps(); // evtl. offene Karte (z.B. Map-Ansicht) freigeben
// "Weitere laden" nur in der Listenansicht sinnvoll
if (loadMore) loadMore.style.display = 'none';
if (_currentView === 'list') {
@ -480,6 +470,16 @@ window.Page_diary = (() => {
return;
}
// Leaflet laden
if (!window.L) {
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = `/js/leaflet.js?v=${APP_VER}`;
s.onload = res; s.onerror = rej;
document.head.appendChild(s);
});
}
const mapEl = content.querySelector('#diary-map-view');
if (!mapEl) return;
@ -488,23 +488,8 @@ window.Page_diary = (() => {
const lons = locations.map(l => l.gps_lon);
const bounds = [[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]];
// GL-Karte (gleicher Style wie die zentrale Karte), Fallback Leaflet über die Facade.
const map = await UI.map.create(mapEl, { zoomControl: true, attributionControl: false });
_diaryMaps.push(map);
// Popup-Klick → Eintrag öffnen (Delegation auf dem Karten-Container; engine-neutral,
// ersetzt das Leaflet-'popupopen'-Wiring, das die GL-Facade nicht kennt).
mapEl.addEventListener('click', async (e) => {
const pop = e.target.closest('.diary-map-popup');
if (!pop) return;
const id = parseInt(pop.dataset.id);
if (!_entries.find(en => en.id === id)) {
try { const fresh = await API.diary.get(_appState.activeDog.id, id); _entries.unshift(fresh); }
catch { return; }
}
if (map.closePopup) map.closePopup();
_openDetail(id);
});
const map = L.map(mapEl, { zoomControl: true, attributionControl: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
// Marker für jeden Eintrag
locations.forEach(loc => {
@ -512,36 +497,59 @@ window.Page_diary = (() => {
const dateStr = loc.datum ? new Date(loc.datum+'T12:00').toLocaleDateString('de-DE', {day:'numeric',month:'short',year:'numeric'}) : '';
const title = UI.escape(loc.titel || loc.location_name || dateStr);
const iconHtml = hasPhoto
const icon = L.divIcon({
html: hasPhoto
? `<div style="width:44px;height:44px;border-radius:50%;overflow:hidden;border:3px solid var(--c-primary,#C4843A);box-shadow:0 2px 8px rgba(0,0,0,.3);background:#fff">
<img src="${UI.escape(loc.cover_preview_url || loc.cover_url)}" style="width:100%;height:100%;object-fit:cover" data-fb-src="${UI.escape(loc.cover_url)}">
<img src="${UI.escape(loc.cover_preview_url || loc.cover_url)}" style="width:100%;height:100%;object-fit:cover" onerror="this.src='${UI.escape(loc.cover_url)}'">
</div>`
: `<div style="width:32px;height:32px;border-radius:50%;background:var(--c-primary,#C4843A);border:3px solid #fff;box-shadow:0 2px 8px rgba(0,0,0,.3);display:flex;align-items:center;justify-content:center">
<svg style="width:16px;height:16px;fill:#fff" viewBox="0 0 256 256"><path d="M128,16a96,96,0,1,0,96,96A96.11,96.11,0,0,0,128,16Zm0,176a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,192Zm0-104a24,24,0,1,0,24,24A24,24,0,0,0,128,88Z"/></svg>
</div>`;
const _mSize = hasPhoto ? 44 : 32;
</div>`,
iconSize: hasPhoto ? [44, 44] : [32, 32],
iconAnchor: hasPhoto ? [22, 22] : [16, 16],
className: '',
});
UI.map.svgMarker(loc.gps_lat, loc.gps_lon, iconHtml, { size: _mSize, anchorY: _mSize / 2 })
.bindPopup(`
const marker = L.marker([loc.gps_lat, loc.gps_lon], { icon });
marker.bindPopup(`
<div style="min-width:160px;cursor:pointer" class="diary-map-popup" data-id="${loc.id}">
${hasPhoto ? `<img src="${UI.escape(loc.cover_preview_url || loc.cover_url)}" style="width:100%;height:100px;object-fit:cover;border-radius:6px;display:block;margin-bottom:8px" data-fb-src="${UI.escape(loc.cover_url)}">` : ''}
${hasPhoto ? `<img src="${UI.escape(loc.cover_preview_url || loc.cover_url)}" style="width:100%;height:100px;object-fit:cover;border-radius:6px;display:block;margin-bottom:8px" onerror="this.src='${UI.escape(loc.cover_url)}'">` : ''}
<div style="font-weight:600;font-size:13px;margin-bottom:2px">${title}</div>
<div style="font-size:11px;color:#888">${dateStr}</div>
${loc.media_count > 1 ? `<div style="font-size:11px;color:#888;margin-top:2px">📷 ${loc.media_count} Medien</div>` : ''}
<div style="margin-top:6px;text-align:center;font-size:12px;color:var(--c-primary,#C4843A);font-weight:600"> Öffnen</div>
</div>`, { maxWidth: 200 })
.addTo(map);
</div>`, { maxWidth: 200 });
marker.on('popupopen', () => {
setTimeout(() => {
document.querySelectorAll('.diary-map-popup').forEach(el => {
el.addEventListener('click', async () => {
map.closePopup();
const id = parseInt(el.dataset.id);
// Eintrag aus _entries holen oder per API nachladen
if (!_entries.find(e => e.id === id)) {
try {
const fresh = await API.diary.get(_appState.activeDog.id, id);
_entries.unshift(fresh);
} catch { return; }
}
_openDetail(id);
});
});
}, 50);
});
// Karte auf alle Punkte zoomen — mehrfach (Container/Style können beim Erstellen
// noch nicht final sein → erneut fitten nach Layout/Tile-Load).
const _fit = () => {
map.invalidateSize();
if (locations.length === 1) map.setView([locations[0].gps_lat, locations[0].gps_lon], 14);
else map.fitBounds(bounds, { padding: [40, 40] });
};
_fit();
setTimeout(_fit, 200); setTimeout(_fit, 500);
marker.addTo(map);
});
// Karte auf alle Punkte zoomen
if (locations.length === 1) {
map.setView([locations[0].gps_lat, locations[0].gps_lon], 14);
} else {
map.fitBounds(bounds, { padding: [40, 40] });
}
setTimeout(() => map.invalidateSize(), 100);
}
function _renderMediaGrid(content) {
@ -561,7 +569,7 @@ window.Page_diary = (() => {
<img src="${UI.escape(m.preview_url || m.url)}"
${m.preview_url ? `srcset="${UI.escape(m.preview_url)} 800w, ${UI.escape(m.url)} 2000w" sizes="(max-width:400px) 200px, 400px"` : ''}
alt="" loading="lazy"
data-fb-src="${UI.escape(m.url)}">
onerror="this.src='${UI.escape(m.url)}'">
</div>`).join('')
}</div>`;
content.querySelectorAll('.diary-mosaic-item').forEach(el => {
@ -611,7 +619,7 @@ window.Page_diary = (() => {
const key = `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
const entry = byDate[key];
cells.push(`<div class="diary-cal-cell${entry?' has-entry':''}${key===today?' today':''}" data-entry-id="${entry?.id||''}">
${entry?.cover_url ? `<img src="${UI.escape(entry.cover_preview_url || entry.cover_url)}" alt="" loading="lazy" data-fb-src="${UI.escape(entry.cover_url)}">` : ''}
${entry?.cover_url ? `<img src="${UI.escape(entry.cover_preview_url || entry.cover_url)}" alt="" loading="lazy" onerror="this.src='${UI.escape(entry.cover_url)}'">` : ''}
<span class="diary-cal-day">${d}</span>
</div>`);
}
@ -804,7 +812,7 @@ window.Page_diary = (() => {
<img src="${e.cover_preview_url || e.cover_url || coverMedia.preview_url || coverMedia.url}"
${(e.cover_preview_url && e.cover_url) ? `srcset="${UI.escape(e.cover_preview_url)} 800w, ${UI.escape(e.cover_url)} 2000w" sizes="(max-width:600px) 300px, 600px"` : ''}
alt="Foto" loading="lazy"
${e.cover_url ? `data-fb-src="${UI.escape(e.cover_url)}"` : ''}>
${e.cover_url ? `onerror="this.src='${UI.escape(e.cover_url)}'"` : ''}>
${mediaCount > 1 ? `<span class="diary-card-media-count">${mediaCount}</span>` : ''}
</div>`;
}
@ -1105,7 +1113,8 @@ window.Page_diary = (() => {
<span class="diary-detail-date-center">${datumLang}</span>
<div style="display:flex;align-items:center;gap:4px">
${!_appState?.activeDog?.is_guest
? `<button id="diary-dv-note" class="btn btn-ghost btn-xs" title="Notiz">
? `<button id="diary-dv-note" class="btn btn-ghost btn-xs" title="Notiz"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
</button>
<button id="diary-dv-edit" class="diary-detail-edit">
@ -1146,18 +1155,26 @@ window.Page_diary = (() => {
setTimeout(async () => {
const mapEl = view.querySelector('#diary-dv-map');
if (!mapEl) return;
const map = await UI.map.create(mapEl, {
center: [entry.gps_lat, entry.gps_lon], zoom: 15,
zoomControl: true, attributionControl: false,
if (!window.L) {
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = `/js/leaflet.js?v=${APP_VER}`;
s.onload = res; s.onerror = rej;
document.head.appendChild(s);
});
_diaryMaps.push(map);
const iconHtml = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="32" height="32">
}
const map = L.map(mapEl, { zoomControl: true, attributionControl: false });
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
const svgIcon = L.divIcon({
html: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="32" height="32">
<circle cx="128" cy="128" r="96" fill="var(--c-primary,#C4843A)" opacity=".25"/>
<circle cx="128" cy="128" r="48" fill="var(--c-primary,#C4843A)"/>
</svg>`;
UI.map.svgMarker(entry.gps_lat, entry.gps_lon, iconHtml, { size: 32, anchorY: 16 }).addTo(map);
const _fit = () => { map.invalidateSize(); map.setView([entry.gps_lat, entry.gps_lon], 15); };
_fit(); setTimeout(_fit, 200); setTimeout(_fit, 500);
</svg>`,
iconSize: [32, 32], iconAnchor: [16, 16], className: '',
});
L.marker([entry.gps_lat, entry.gps_lon], { icon: svgIcon }).addTo(map);
map.setView([entry.gps_lat, entry.gps_lon], 15);
map.invalidateSize();
}, 150);
}
@ -1705,7 +1722,7 @@ window.Page_diary = (() => {
<div id="import-result" style="display:none;margin-top:var(--space-4)"></div>`,
footer: `
<button class="btn btn-secondary" data-modal-close>Schließen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="import-start-btn">Importieren</button>`,
});
@ -1795,8 +1812,6 @@ window.Page_diary = (() => {
.trim();
}
function destroy() { _clearDiaryMaps(); }
return { init, refresh, openNew, onDogChange, openDetail: _openDetail, destroy };
return { init, refresh, openNew, onDogChange, openDetail: _openDetail };
})();

View file

@ -616,7 +616,7 @@ window.Page_dog_profile = (() => {
footer: `
<div class="w3-btn-stack">
<button class="btn btn-primary" id="chip-edit-save-btn" class="w-full">Speichern</button>
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
</div>`,
});
document.getElementById('chip-edit-save-btn').addEventListener('click', async () => {
@ -675,7 +675,7 @@ window.Page_dog_profile = (() => {
${hasPhoto ? `<button class="btn btn-primary" id="pe-save-btn" class="w-full">Speichern</button>` : ''}
<div class="flex-gap-2">
${hasPhoto ? `<button class="btn btn-danger" id="pe-delete-btn">${UI.icon('trash')} Löschen</button>` : ''}
<button class="btn btn-secondary flex-1" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
</div>
</div>
`;
@ -957,7 +957,7 @@ window.Page_dog_profile = (() => {
</div>
<div id="share-list-wrap" class="mt-4"></div>`,
footer: `
<button class="btn btn-secondary" data-modal-close>Schließen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="share-create-btn">Link erstellen</button>`,
});
@ -1489,7 +1489,7 @@ window.Page_dog_profile = (() => {
</p>
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${UI.escape(data.hinweis)}</p>` : ''}
</div>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
return;
}
@ -1608,7 +1608,7 @@ window.Page_dog_profile = (() => {
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;justify-content:flex-end">
<button class="btn btn-secondary" data-modal-close>Schließen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<a class="btn btn-secondary" href="/ausweis/${dog.id}" target="_blank" rel="noopener">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#identification-card"></use></svg>
Ausweis öffnen
@ -1832,7 +1832,7 @@ window.Page_dog_profile = (() => {
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-meta-save">Speichern</button>
</div>`,
});
@ -1896,7 +1896,7 @@ window.Page_dog_profile = (() => {
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-vacc-save">Speichern</button>
</div>`,
});
@ -1960,7 +1960,7 @@ window.Page_dog_profile = (() => {
</div>`,
footer: `
<div style="display:flex;gap:var(--space-2);justify-content:flex-end">
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="pp-med-save">Speichern</button>
</div>`,
});
@ -2017,7 +2017,7 @@ window.Page_dog_profile = (() => {
UI.modal.open({
title: 'Hundepass-Link teilen',
body: shareWrap.innerHTML,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
document.getElementById('pp-sharelink-copy')?.addEventListener('click', async () => {
await navigator.clipboard.writeText(url).catch(() => {});
@ -2209,7 +2209,7 @@ window.Page_dog_profile = (() => {
? 'background:#7a4f1a;color:#f5e4c0;border-color:#7a4f1a;'
: 'background:#f5f0e8;color:#444;border-color:#e0d4b8;';
const label = y === 'alle' ? 'Alle' : y;
return `<button data-buch-action="year" data-buch-year="${y}" style="
return `<button onclick="window._buchSetJahr('${y}')" style="
border:1px solid;border-radius:8px;padding:8px 16px;
font-size:0.9rem;cursor:pointer;font-family:inherit;
${active}
@ -2239,14 +2239,14 @@ window.Page_dog_profile = (() => {
<div style="margin-bottom:20px;display:flex;flex-direction:column;gap:10px">
<label style="display:flex;align-items:center;gap:12px;cursor:pointer">
<button data-buch-action="fotos" style="
<button onclick="window._buchToggleFotos()" style="
width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;
${togStyle(nurFotos)}
">${nurFotos ? '✓' : ''}</button>
<span style="font-size:0.95rem">Nur Einträge mit Fotos</span>
</label>
<label style="display:flex;align-items:center;gap:12px;cursor:pointer">
<button data-buch-action="meilen" style="
<button onclick="window._buchToggleMeilensteine()" style="
width:44px;height:24px;border-radius:12px;border:none;cursor:pointer;
${togStyle(nurMeilensteine)}
">${nurMeilensteine ? '✓' : ''}</button>
@ -2255,11 +2255,11 @@ window.Page_dog_profile = (() => {
</div>
<div style="display:flex;gap:10px">
<button data-buch-action="open" style="
<button onclick="window._buchOpen()" style="
flex:1;background:#7a4f1a;color:#f5e4c0;border:none;border-radius:10px;
padding:14px;font-size:1rem;font-weight:700;cursor:pointer;font-family:inherit;
">📖 Buch öffnen</button>
<button data-buch-action="close" style="
<button onclick="window._buchClose()" style="
background:#f0f0f0;color:#555;border:none;border-radius:10px;
padding:14px 18px;font-size:1rem;cursor:pointer;font-family:inherit;
"></button>
@ -2268,14 +2268,18 @@ window.Page_dog_profile = (() => {
`;
};
const setJahr = (y) => { selectedJahr = y; renderModal(); };
const toggleFotos = () => { nurFotos = !nurFotos; renderModal(); };
const toggleMeilen = () => { nurMeilensteine = !nurMeilensteine; renderModal(); };
const closeModal = () => {
window._buchSetJahr = (y) => { selectedJahr = y; renderModal(); };
window._buchToggleFotos = () => { nurFotos = !nurFotos; renderModal(); };
window._buchToggleMeilensteine = () => { nurMeilensteine = !nurMeilensteine; renderModal(); };
window._buchClose = () => {
modalEl.remove();
document.removeEventListener('keydown', onKey);
delete window._buchSetJahr;
delete window._buchToggleFotos;
delete window._buchToggleMeilensteine;
delete window._buchOpen;
delete window._buchClose;
};
const openBuch = () => {
window._buchOpen = () => {
const params = new URLSearchParams();
if (selectedJahr !== 'alle') params.set('jahr', selectedJahr);
if (nurFotos) params.set('nur_fotos', 'true');
@ -2286,24 +2290,10 @@ window.Page_dog_profile = (() => {
renderModal();
document.body.appendChild(modalEl);
// Delegierter Click-Handler (Inline-onclick wird von der CSP blockiert);
// überlebt das Re-Rendern via renderModal().
modalEl.addEventListener('click', e => {
if (e.target === modalEl) { closeModal(); return; }
const btn = e.target.closest('[data-buch-action]');
if (!btn) return;
switch (btn.dataset.buchAction) {
case 'year': setJahr(btn.dataset.buchYear); break;
case 'fotos': toggleFotos(); break;
case 'meilen': toggleMeilen(); break;
case 'open': openBuch(); break;
case 'close': closeModal(); break;
}
});
modalEl.addEventListener('click', e => { if (e.target === modalEl) window._buchClose(); });
const onKey = e => {
if (e.key === 'Escape') { closeModal(); }
if (e.key === 'Escape') { window._buchClose(); document.removeEventListener('keydown', onKey); }
};
document.addEventListener('keydown', onKey);
}
@ -2320,7 +2310,7 @@ window.Page_dog_profile = (() => {
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
size: 'large',
});
@ -2462,7 +2452,7 @@ window.Page_dog_profile = (() => {
const el = document.getElementById('dp-same-breed-chip');
if (!el) return;
try {
const data = await API.get('/friends/same-breed');
const data = await API.get('friends/same-breed');
if (!data || data.count === 0) return;
const hauptRasse = data.rassen[0]?.rasse || '';
const label = data.count === 1

View file

@ -53,7 +53,7 @@ window.Page_ernaehrung = (() => {
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bowl-food"></use></svg>',
title: 'Noch kein Hund angelegt',
text: 'Erstelle zuerst ein Hundeprofil.',
action: `<button class="btn btn-primary" data-page="dog-profile">Profil erstellen</button>`,
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
});
return;
}
@ -113,34 +113,12 @@ window.Page_ernaehrung = (() => {
// ------------------------------------------------------------------
// TAB 1: KALORIEN-RECHNER
// ------------------------------------------------------------------
// Alter in Jahren (1 Nachkommastelle) aus Geburtstag, '' wenn unbekannt/ungültig.
function _alterJahre(geburtstag) {
if (!geburtstag) return '';
const birth = new Date(geburtstag + 'T00:00:00');
if (isNaN(birth.getTime())) return '';
const years = (Date.now() - birth.getTime()) / (365.25 * 24 * 3600 * 1000);
return (years > 0 && years < 30) ? Math.round(years * 10) / 10 : '';
}
// Lebensphase aus Alter (Jahre): Welpen/Junghunde brauchen mehr (Wachstum),
// Senioren weniger. `growth` (absoluter RER-Faktor) überschreibt den
// Aktivitäts-Faktor; `mult` skaliert den Erwachsenen-Faktor.
function _lifeStage(alter) {
if (!alter || alter <= 0) return { label: '', mult: 1, growth: null };
if (alter < 0.34) return { label: '🍼 Welpe (< 4 Mon.) — hoher Wachstumsbedarf', mult: 1, growth: 3.0 };
if (alter < 1) return { label: '🐶 Junghund (412 Mon.) — erhöhter Bedarf', mult: 1, growth: 2.0 };
if (alter >= 11) return { label: '🐕 Hochbetagt (11+ J.) — reduzierter Bedarf', mult: 0.85, growth: null };
if (alter >= 7) return { label: '🐕 Senior (7+ J.) — leicht reduzierter Bedarf', mult: 0.90, growth: null };
return { label: '', mult: 1, growth: null };
}
function _renderRechner(el) {
const dog = _appState.activeDog;
// Auto-Werte aus Hundeprofil. Feldnamen: gewicht_kg (nicht gewicht); Alter gibt
// es nicht als Feld → aus geburtstag berechnen.
const gewichtDefault = dog?.gewicht_kg ?? '';
const alterDefault = _alterJahre(dog?.geburtstag);
// Auto-Werte aus Hundeprofil
const gewichtDefault = dog?.gewicht || '';
const alterDefault = dog?.alter || '';
el.innerHTML = `
<div style="padding:var(--space-4) 0">
@ -260,28 +238,10 @@ window.Page_ernaehrung = (() => {
});
el.querySelector('#ern-rechner-btn').addEventListener('click', () => _berechne(el));
// Bereits gespeichertes Futter-Profil beim Öffnen direkt anzeigen — sonst war
// es „nicht auffindbar" (Formular lag versteckt hinter der Berechnung).
const hasProfil = !!(_profil && (_profil.futter_typ || _profil.marke || _profil.notizen || _profil.kcal_tag));
if (hasProfil) {
if (_profil.kcal_tag) {
// Gespeicherten Tagesbedarf 1:1 wieder anzeigen (kein Neu-Rechnen → keine
// abweichende Zahl, da Aktivität/Kastration nicht persistiert werden).
_showResult(el, _profil.kcal_tag);
} else {
const ps = el.querySelector('#ern-profil-speichern');
if (ps) {
ps.style.display = '';
el.querySelector('#ern-prof-save-btn').onclick = () => _speichereProfil(el, null);
}
}
}
}
function _berechne(el) {
const gewicht = parseFloat(el.querySelector('#ern-gewicht').value);
const alter = parseFloat(el.querySelector('#ern-alter').value) || 0;
const aktivitaet = el.querySelector('[data-akt].active')?.dataset.akt || 'normal';
const kastriert = el.querySelector('[data-kas].active')?.dataset.kas === 'ja';
@ -297,25 +257,14 @@ window.Page_ernaehrung = (() => {
aktiv: { intakt: 1.8, kastriert: 1.6 },
sport: { intakt: 2.1, kastriert: 1.9 },
};
// Lebensphase einrechnen: Welpe/Junghund = Wachstumsfaktor (überschreibt
// Aktivität), Senior = reduzierter Faktor.
const baseFactor = faktoren[aktivitaet][kastriert ? 'kastriert' : 'intakt'];
const stage = _lifeStage(alter);
const factor = stage.growth != null ? stage.growth : baseFactor * stage.mult;
const kcal = Math.round(rer * factor);
_showResult(el, kcal);
}
const kcal = Math.round(rer * faktoren[aktivitaet][kastriert ? 'kastriert' : 'intakt']);
// Tagesbedarf-Ergebnis + Profil-Formular rendern (genutzt von Berechnung UND
// beim Öffnen mit gespeichertem kcal_tag).
function _showResult(el, kcal) {
// Umrechnung in Futtermengen
const trocken = Math.round(kcal / 3.5); // ~350 kcal/100g
const nass = Math.round(kcal / 0.85); // ~85 kcal/100g
const barf = Math.round(kcal / 1.5); // ~150 kcal/100g
const kcalFormatted = kcal.toLocaleString('de-DE');
const stageLabel = _lifeStage(parseFloat(el.querySelector('#ern-alter')?.value) || 0).label;
const resultEl = el.querySelector('#ern-rechner-result');
resultEl.style.display = '';
@ -325,7 +274,6 @@ window.Page_ernaehrung = (() => {
border-radius:var(--radius-lg);margin-bottom:var(--space-4)">
<div style="font-size:var(--text-2xl);font-weight:700">ca. ${kcalFormatted} kcal</div>
<div style="font-size:var(--text-sm);opacity:0.85">pro Tag</div>
${stageLabel ? `<div style="font-size:var(--text-xs);opacity:0.9;margin-top:6px">${stageLabel}</div>` : ''}
</div>
<div style="display:grid;gap:var(--space-3)">
@ -780,7 +728,7 @@ window.Page_ernaehrung = (() => {
</form>
`;
const footer = `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="vert-futter-save-btn" form="${id}">Speichern</button>
`;
UI.modal.open({ title: 'Futter erfassen', body, footer });
@ -892,7 +840,7 @@ window.Page_ernaehrung = (() => {
</form>
`;
const footer = `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="vert-reaktion-save-btn" form="${id}">Speichern</button>
`;
UI.modal.open({ title: 'Reaktion erfassen', body, footer });

View file

@ -221,17 +221,17 @@ window.Page_events = (() => {
</div>
${ev.rsvp_count ? `<span class="event-attendees" data-ev-attendees="${ev.id}">${_icon('users')} ${ev.rsvp_count} nehmen teil</span>` : ''}
${ev.link ? `<div class="events-card-actions">
<a class="btn btn-ghost btn-xs ev-ext-link" href="${UI.escape(ev.link)}" target="_blank" rel="noopener">
<a class="btn btn-ghost btn-xs ev-ext-link" href="${UI.escape(ev.link)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">
${_icon('arrow-square-out')} Details
</a>
</div>` : ''}
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:var(--space-1)">
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">${_icon('pencil-simple')}</button>` : ''}
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten" onclick="event.stopPropagation()">${_icon('pencil-simple')}</button>` : ''}
${_state.user ? `<button class="btn-icon ev-note-btn" data-ev-note-id="${ev.id}"
data-ev-note-label="${UI.escape(ev.titel + ' ' + ev.datum)}"
data-ev-note-ort="${UI.escape(ev.ort_name || '')}"
title="Notiz" class="text-muted">
title="Notiz" class="text-muted" onclick="event.stopPropagation()">
${_icon('note-pencil')}</button>` : ''}
</div>
</div>
@ -258,7 +258,7 @@ window.Page_events = (() => {
if (_clusterGroup) {
_map.removeLayer(_clusterGroup);
}
_clusterGroup = UI.map.clusterGroup();
_clusterGroup = L.markerClusterGroup();
_markers = [];
const bounds = [];
@ -276,7 +276,7 @@ window.Page_events = (() => {
<span style="color:var(--c-text-muted);font-size:12px">${datum}</span><br>
${ev.ort_name ? `<span style="font-size:12px">📍 ${UI.escape(ev.ort_name)}</span><br>` : ''}
${ev.beschreibung ? `<span style="font-size:12px">${UI.escape(ev.beschreibung.slice(0, 80))}${ev.beschreibung.length > 80 ? '…' : ''}</span><br>` : ''}
<a href="#" data-ev-detail="${ev.id}"
<a href="#" onclick="event.preventDefault();Page_events._openDetail(${ev.id})"
style="font-size:12px;color:var(--c-primary,#2563eb)">Details</a>
</div>
`;
@ -512,7 +512,7 @@ window.Page_events = (() => {
</button>
<div class="flex-gap-2">
${isEdit ? `<button type="button" class="btn btn-danger" id="ev-form-delete">Löschen</button>` : ''}
<button class="btn btn-secondary flex-1" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
</div>
</div>
`;
@ -570,14 +570,6 @@ window.Page_events = (() => {
// Click-Handler
// ----------------------------------------------------------
function _onClick(e) {
// Detail-Link (Karten-Popup) — Inline-onclick ist CSP-blockiert
const detailLink = e.target.closest('[data-ev-detail]');
if (detailLink) {
e.preventDefault();
_showDetail(parseInt(detailLink.dataset.evDetail, 10));
return;
}
// Quelle-Filter
const sourceBtn = e.target.closest('[data-ev-quelle]');
if (sourceBtn) {
@ -669,8 +661,6 @@ window.Page_events = (() => {
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
function _destroy() { try { _map && _map.remove(); } catch (e) {} _map = null; _clusterGroup = null; _markers = []; }
return { init, refresh, openNew, _openDetail: _showDetail, destroy: _destroy };
return { init, refresh, openNew, _openDetail: _showDetail };
})();

View file

@ -485,7 +485,7 @@ window.Page_expenses = (() => {
</form>`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" data-modal-close>Abbrechen</button>
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="exp-recurring-form" class="btn btn-primary flex-1">Speichern</button>`;
UI.modal.open({ title: r ? 'Dauerauftrag bearbeiten' : 'Neuer Dauerauftrag', body, footer });
@ -755,10 +755,10 @@ window.Page_expenses = (() => {
style="color:var(--c-danger);margin-right:auto">
${UI.icon('trash')}
</button>
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button type="button" class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="${formId}" class="btn btn-primary">Speichern</button>
` : `
<button type="button" class="btn btn-secondary flex-1" data-modal-close>Abbrechen</button>
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="${formId}" class="btn btn-primary flex-1">Speichern</button>
`;

View file

@ -13,6 +13,8 @@ window.Page_forum = (() => {
let _offset = 0;
let _searchTimer = null;
let _searching = false;
let _mapLoaded = false;
let _leafletLoaded = false;
let _map = null;
let _clusterGroup = null;
let _activeSection = 'list'; // 'list' | 'map'
@ -293,7 +295,7 @@ function _fmtDate(iso) {
</div>`;
UI.modal.open({ title: '🏆 Hund des Monats', body,
footer: `<button class="btn btn-secondary flex-1" data-modal-close>Schließen</button>` });
footer: `<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Schließen</button>` });
document.getElementById('hdm-login-link')?.addEventListener('click', e => {
e.preventDefault(); UI.modal.close(); App.navigate('settings');
@ -446,7 +448,7 @@ function _fmtDate(iso) {
: `<img class="forum-card-thumb" src="${UI.escape(t.foto_preview_url || t.foto_preview)}"
${(t.foto_preview_url && t.foto_preview) ? `srcset="${UI.escape(t.foto_preview_url)} 800w" sizes="120px"` : ''}
alt="" loading="lazy"
data-fb-src="${UI.escape(t.foto_preview)}">`
onerror="this.src='${UI.escape(t.foto_preview)}'">`
: '';
return `
@ -1020,7 +1022,7 @@ function _fmtDate(iso) {
</p>
</div>`,
footer: `<button class="btn btn-primary flex-1" data-modal-close>Verstanden</button>`,
footer: `<button class="btn btn-primary flex-1" onclick="UI.modal.close()">Verstanden</button>`,
});
}
@ -1235,11 +1237,15 @@ function _fmtDate(iso) {
}
});
await _loadLeaflet();
const mapEl = document.getElementById('forum-map');
if (!mapEl) return;
// GL über die Facade (gleicher Style wie die zentrale Karte), Fallback Leaflet.
_map = await UI.map.create(mapEl, { center: [51.0, 10.0], zoom: 6, zoomControl: true, attributionControl: false });
_map = L.map(mapEl, { zoomControl: true }).setView([51.0, 10.0], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap',
maxZoom: 18,
}).addTo(_map);
_loadMembersOnMap();
}
@ -1247,20 +1253,37 @@ function _fmtDate(iso) {
async function _loadMembersOnMap() {
if (!_map) return;
try {
// MarkerCluster laden falls nicht vorhanden
if (!window.L.markerClusterGroup) {
await Promise.all([
new Promise((res, rej) => {
if (document.querySelector('link[href*="MarkerCluster"]')) { res(); return; }
const l1 = document.createElement('link'); l1.rel='stylesheet'; l1.href='/css/MarkerCluster.css'; l1.onload=res; l1.onerror=rej; document.head.appendChild(l1);
}),
new Promise((res, rej) => {
const s = document.createElement('script'); s.src='/js/leaflet.markercluster.js'; s.onload=res; s.onerror=rej; document.head.appendChild(s);
}),
]);
}
const members = await API.forum.membersMap();
// Alte Gruppe sauber entfernen
if (_clusterGroup) { try { _map.removeLayer(_clusterGroup); } catch (e) {} _clusterGroup = null; }
// Alte Cluster-Gruppe sauber entfernen
if (_clusterGroup) { _map.removeLayer(_clusterGroup); _clusterGroup = null; }
_clusterGroup = UI.map.clusterGroup({ maxClusterRadius: 60 });
_clusterGroup = L.markerClusterGroup({ maxClusterRadius: 60 });
members.forEach(m => {
const html = `<div style="width:32px;height:32px;border-radius:50%;
const icon = L.divIcon({
className: '',
html: `<div style="width:32px;height:32px;border-radius:50%;
background:var(--c-primary);color:#fff;font-size:13px;font-weight:700;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);
border:2px solid rgba(255,255,255,0.8)">${UI.escape((m.vorname||'?')[0].toUpperCase())}</div>`;
border:2px solid rgba(255,255,255,0.8)">${UI.escape((m.vorname||'?')[0].toUpperCase())}</div>`,
iconSize: [32, 32], iconAnchor: [16, 16],
});
_clusterGroup.addLayer(
UI.map.svgMarker(m.lat, m.lon, html, { size: 32, anchorY: 16 })
L.marker([m.lat, m.lon], { icon })
.bindPopup(`<strong>${UI.escape(m.vorname || '?')}</strong>`)
);
});
@ -1270,6 +1293,30 @@ function _fmtDate(iso) {
}
}
async function _loadLeaflet() {
if (_leafletLoaded || window.L) { _leafletLoaded = true; return; }
// CSS
if (!document.querySelector('link[href*="leaflet.css"]')) {
const lCss = document.createElement('link');
lCss.rel = 'stylesheet';
lCss.href = '/css/leaflet.css';
document.head.appendChild(lCss);
}
// JS
await new Promise((resolve, reject) => {
if (window.L) { resolve(); return; }
const s = document.createElement('script');
s.src = '/js/leaflet.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
_leafletLoaded = true;
}
// ----------------------------------------------------------
// Moderations-Panel
// ----------------------------------------------------------
@ -1329,7 +1376,7 @@ function _fmtDate(iso) {
</div>
</form>`,
footer: `
<button class="btn btn-ghost flex-1" data-modal-close>Abbrechen</button>
<button class="btn btn-ghost flex-1" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="${id}" class="btn btn-primary flex-1">${UI.icon('floppy-disk')} Speichern</button>`,
});
document.getElementById(id)?.addEventListener('submit', async e => {
@ -1376,7 +1423,7 @@ function _fmtDate(iso) {
</div>
</form>`,
footer: `
<button class="btn btn-ghost flex-1" data-modal-close>Abbrechen</button>
<button class="btn btn-ghost flex-1" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="${id}" class="btn btn-primary flex-1">${UI.icon('floppy-disk')} Speichern</button>`,
});
@ -1422,13 +1469,6 @@ function _fmtDate(iso) {
document.body.appendChild(lb);
}
// Karte beim Verlassen freigeben (WebGL-Kontext-Leak vermeiden).
function destroy() {
try { _clusterGroup && _clusterGroup.remove && _clusterGroup.remove(); } catch (e) {}
try { _map && _map.remove && _map.remove(); } catch (e) {}
_map = null; _clusterGroup = null;
}
return { init, refresh, onDogChange, openNew, openThread: _openThread, destroy };
return { init, refresh, onDogChange, openNew, openThread: _openThread };
})();

View file

@ -27,7 +27,7 @@ window.Page_friends = (() => {
icon: UI.icon('users'),
title: 'Anmelden erforderlich',
text: 'Melde dich an, um Freunde zu finden und Anfragen zu verwalten.',
action: `<button class="btn btn-primary" data-page="settings">Anmelden</button>`,
action: `<button class="btn btn-primary" onclick="App.navigate('settings')">Anmelden</button>`,
});
return;
}
@ -148,21 +148,6 @@ window.Page_friends = (() => {
_searchTimer = setTimeout(() => _doSearch(q), 380);
});
// Delegierter Click-Handler — robust auch unter strikter CSP / für
// dynamisch nachgerenderte Buttons (Anfragen, Freundesliste).
// Ersetzt Inline-onclick, das auf manchen iOS-PWA-Sessions nicht feuerte.
_container.addEventListener('click', e => {
const btn = e.target.closest('[data-fr-action]');
if (!btn) return;
const id = parseInt(btn.dataset.frId, 10);
switch (btn.dataset.frAction) {
case 'accept': _accept(id); break;
case 'decline': _decline(id); break;
case 'cancel': _cancel(id); break;
case 'chat': _openChat(id); break;
}
});
// Prefill aus URL-Parameter → sofort suchen
if (prefill && prefill.length >= 2) {
_doSearch(prefill);
@ -298,11 +283,11 @@ window.Page_friends = (() => {
const avatar = item.dog_foto
? `<img src="${UI.escape(item.dog_foto)}" alt="${UI.escape(item.dog_name || '')}"
loading="lazy" decoding="async" data-fb="hide"
loading="lazy" decoding="async" onerror="this.style.display='none'"
class="fr-activity-avatar">`
: item.avatar_url
? `<img src="${UI.escape(item.avatar_url)}" alt="${UI.escape(item.user_name)}"
loading="lazy" decoding="async" data-fb="hide"
loading="lazy" decoding="async" onerror="this.style.display='none'"
class="fr-activity-avatar">`
: `<div class="fr-activity-avatar fr-activity-avatar--initial">
${UI.escape((item.user_name || '?')[0].toUpperCase())}
@ -374,14 +359,12 @@ window.Page_friends = (() => {
</div>
<div style="display:flex;gap:var(--space-2);flex-shrink:0">
<button class="btn btn-primary btn-sm"
data-fr-action="accept" data-fr-id="${r.id}" title="Annehmen">
onclick="Page_friends._accept(${r.id})" title="Annehmen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#check"></use></svg>
Annehmen
</button>
<button class="btn btn-ghost btn-sm"
data-fr-action="decline" data-fr-id="${r.id}" title="Ablehnen">
onclick="Page_friends._decline(${r.id})" title="Ablehnen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
Ablehnen
</button>
</div>
</div>
@ -417,7 +400,7 @@ window.Page_friends = (() => {
<div class="text-xs-muted">Anfrage ausstehend</div>
</div>
<button class="btn btn-ghost btn-sm"
data-fr-action="cancel" data-fr-id="${r.id}" title="Zurückziehen">
onclick="Page_friends._cancel(${r.id})" title="Zurückziehen">
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
</button>
</div>
@ -524,11 +507,12 @@ window.Page_friends = (() => {
<button class="btn btn-ghost btn-sm fr-note-btn"
data-fr-note-id="${f.friend_id}"
data-fr-note-name="${UI.escape(f.friend_name)}"
title="Notiz">
title="Notiz"
onclick="event.stopPropagation()">
<svg class="ph-icon"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
</button>
<button class="btn btn-ghost btn-sm"
data-fr-action="chat" data-fr-id="${f.friend_id}"
onclick="Page_friends._openChat(${f.friend_id})"
title="Nachricht schreiben">
<svg class="ph-icon"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg>
</button>
@ -556,7 +540,7 @@ window.Page_friends = (() => {
${withPhotos.slice(0, 4).map(d => `
<div class="text-center">
<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}"
loading="lazy" decoding="async" data-fb="hide"
loading="lazy" decoding="async" onerror="this.style.display='none'"
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-surface)">
<div style="font-size:10px;color:var(--c-text-muted);margin-top:2px;
@ -580,7 +564,7 @@ window.Page_friends = (() => {
<div class="text-center">
${d.foto_url
? `<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}"
loading="lazy" decoding="async" data-fb="hide"
loading="lazy" decoding="async" onerror="this.style.display='none'"
style="width:72px;height:72px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);margin-bottom:var(--space-2)">`
: `<div style="width:72px;height:72px;border-radius:50%;
@ -803,13 +787,13 @@ window.Page_friends = (() => {
function _userAvatar(name, firstDog, avatarUrl) {
if (avatarUrl) {
return `<img src="${UI.escape(avatarUrl)}" alt="${UI.escape(name)}"
loading="lazy" decoding="async" data-fb="hide"
loading="lazy" decoding="async" onerror="this.style.display='none'"
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);flex-shrink:0">`;
}
if (firstDog?.foto_url) {
return `<img src="${UI.escape(firstDog.foto_url)}" alt="${UI.escape(firstDog.name)}"
loading="lazy" decoding="async" data-fb="hide"
loading="lazy" decoding="async" onerror="this.style.display='none'"
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
border:2px solid var(--c-primary);flex-shrink:0">`;
}

View file

@ -83,7 +83,7 @@ window.Page_health = (() => {
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#syringe"></use></svg>',
title: 'Noch kein Hund angelegt',
text: 'Erstelle zuerst ein Hundeprofil.',
action: `<button class="btn btn-primary" data-page="dog-profile">Profil erstellen</button>`,
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
});
return;
}
@ -403,7 +403,8 @@ window.Page_health = (() => {
${e.notiz ? `<div class="list-item-text">${UI.escape(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${UI.escape(e.bezeichnung)}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
data-label="${UI.escape(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>
`;
@ -510,7 +511,8 @@ window.Page_health = (() => {
${e.notiz ? `<div class="list-item-text">${UI.escape(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${UI.escape(e.bezeichnung)}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
data-label="${UI.escape(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>
`;
@ -561,7 +563,8 @@ window.Page_health = (() => {
${e.notiz ? `<div class="list-item-text" style="padding-top:var(--space-1)">${UI.escape(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-1);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="Gewicht ${UI.escape(e.datum)}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
data-label="Gewicht ${UI.escape(e.datum)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>
`).join('');
@ -798,7 +801,8 @@ window.Page_health = (() => {
${e.notiz ? `<div class="list-item-text">${UI.escape(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="Läufigkeit ${UI.escape(e.datum)}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
data-label="Läufigkeit ${UI.escape(e.datum)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>`;
}).join('');
@ -835,7 +839,8 @@ window.Page_health = (() => {
${e.notiz ? `<div class="list-item-text">${UI.escape(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${UI.escape(e.bezeichnung)}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
data-label="${UI.escape(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>
`).join('')}
@ -875,7 +880,8 @@ window.Page_health = (() => {
${e.notiz ? `<div class="list-item-text">${UI.escape(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${UI.escape(e.bezeichnung)}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
data-label="${UI.escape(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
</div>
</div>
`).join('');
@ -917,16 +923,19 @@ window.Page_health = (() => {
${e.notiz ? `<div class="list-item-text">${UI.escape(e.notiz)}</div>` : ''}
<button class="btn btn-ghost btn-xs" style="margin-top:var(--space-2);font-size:var(--text-xs);color:var(--c-text-muted);padding:2px 6px"
data-action="open-note" data-entry-id="${e.id}"
data-label="${UI.escape(e.bezeichnung)}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
data-label="${UI.escape(e.bezeichnung)}"
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
${count
? `<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);align-items:center;flex-wrap:wrap">
${mediaList.slice(0, 3).map(m => m.media_type === 'pdf'
? `<a href="${UI.escape(m.url)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" style="display:inline-flex">
class="btn btn-secondary btn-sm" style="display:inline-flex"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF
</a>`
: `<a href="${UI.escape(m.url)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm" style="display:inline-flex">
class="btn btn-secondary btn-sm" style="display:inline-flex"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild
</a>`
).join('')}
@ -1764,24 +1773,28 @@ window.Page_health = (() => {
${ratingHtml}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
${p.telefon ? `
<a href="tel:${UI.escape(p.telefon)}" class="btn btn-secondary btn-sm">
<a href="tel:${UI.escape(p.telefon)}" class="btn btn-secondary btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg> Anrufen
</a>` : ''}
${p.notfall_telefon ? `
<a href="tel:${UI.escape(p.notfall_telefon)}" class="btn btn-danger btn-sm">
<a href="tel:${UI.escape(p.notfall_telefon)}" class="btn btn-danger btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall
</a>` : ''}
<button class="btn btn-sm btn-secondary"
data-action="bewerten" data-praxis-id="${p.id}"
title="Bewertung abgeben"
style="flex-shrink:0">
style="flex-shrink:0"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
Bewerten
</button>
<button class="btn btn-sm ${isFav ? 'btn-primary' : 'btn-secondary'}"
data-action="toggle-fav" data-praxis-id="${p.id}"
title="${isFav ? 'Favorit entfernen' : 'Als mein Tierarzt merken'}"
style="flex-shrink:0">
style="flex-shrink:0"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true">
<use href="/icons/phosphor.svg#${isFav ? 'heart-fill' : 'heart'}"></use>
</svg>
@ -1790,7 +1803,8 @@ window.Page_health = (() => {
<button class="btn btn-sm btn-secondary"
data-action="edit-praxis" data-praxis-id="${p.id}"
title="Praxis bearbeiten"
style="flex-shrink:0">
style="flex-shrink:0"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
</button>
</div>
@ -1867,7 +1881,7 @@ window.Page_health = (() => {
<use href="/icons/phosphor.svg#spinner-gap"></use>
</svg>
</div>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="detail-bewerten-btn">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
Jetzt bewerten
@ -1984,7 +1998,7 @@ window.Page_health = (() => {
title: `${UI.escape(praxis.name)} bewerten`,
body,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="bew-submit-btn" form="bew-form">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#star"></use></svg>
${existing ? 'Bewertung aktualisieren' : 'Bewertung abgeben'}
@ -2359,7 +2373,7 @@ window.Page_health = (() => {
value="${UI.escape(currentNr)}" placeholder="z.B. 276009200123456" maxlength="20">
</div>`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="transponder-save-btn">Speichern</button>`,
});
document.getElementById('transponder-save-btn').addEventListener('click', async () => {
@ -2427,11 +2441,11 @@ window.Page_health = (() => {
const b = berichte[idx];
const nav = berichte.length > 1 ? `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<button data-ki-nav="prev" style="padding:6px 16px;border-radius:999px;
<button onclick="window._kiPrev()" style="padding:6px 16px;border-radius:999px;
border:1.5px solid var(--c-border);background:var(--c-surface);cursor:pointer;
font-size:var(--text-sm);${idx >= berichte.length-1 ? 'opacity:.3;pointer-events:none' : ''}"> Älter</button>
<span class="text-xs-muted">${idx+1} / ${berichte.length}</span>
<button data-ki-nav="next" style="padding:6px 16px;border-radius:999px;
<button onclick="window._kiNext()" style="padding:6px 16px;border-radius:999px;
border:1.5px solid var(--c-border);background:var(--c-surface);cursor:pointer;
font-size:var(--text-sm);${idx <= 0 ? 'opacity:.3;pointer-events:none' : ''}">Neuer </button>
</div>` : '';
@ -2441,13 +2455,10 @@ window.Page_health = (() => {
<div style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin-bottom:8px">${fmtDate(b)}</div>
<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${UI.escape(b.bericht)}</div>`,
});
// Inline-onclick wird von der CSP blockiert → per addEventListener verdrahten.
document.querySelector('[data-ki-nav="prev"]')
?.addEventListener('click', () => { if (idx < berichte.length - 1) { idx++; showBericht(); } });
document.querySelector('[data-ki-nav="next"]')
?.addEventListener('click', () => { if (idx > 0) { idx--; showBericht(); } });
}
window._kiPrev = () => { if (idx < berichte.length - 1) { idx++; showBericht(); } };
window._kiNext = () => { if (idx > 0) { idx--; showBericht(); } };
showBericht();
});
} catch (_) {
@ -2609,13 +2620,15 @@ window.Page_health = (() => {
${adresse ? `<div class="list-item-meta-row">${UI.escape(adresse)}</div>` : ''}
${vet.telefon ? `
<div class="mt-2">
<a href="tel:${UI.escape(vet.telefon)}" class="btn btn-secondary btn-sm">
<a href="tel:${UI.escape(vet.telefon)}" class="btn btn-secondary btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#phone"></use></svg> ${UI.escape(vet.telefon)}
</a>
</div>` : ''}
${vet.notfall_telefon ? `
<div style="margin-top:var(--space-1)">
<a href="tel:${UI.escape(vet.notfall_telefon)}" class="btn btn-danger btn-sm">
<a href="tel:${UI.escape(vet.notfall_telefon)}" class="btn btn-danger btn-sm"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall: ${UI.escape(vet.notfall_telefon)}
</a>
</div>` : ''}
@ -2705,13 +2718,14 @@ window.Page_health = (() => {
${doc.beschreibung ? `<div class="list-item-text">${UI.escape(doc.beschreibung)}</div>` : ''}
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-2);flex-wrap:wrap">
<a href="${UI.escape(doc.file_path)}" target="_blank" rel="noopener"
class="btn btn-secondary btn-sm">
class="btn btn-secondary btn-sm" onclick="event.stopPropagation()">
${isImg
? '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild öffnen'
: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg> PDF öffnen'}
</a>
<button class="btn btn-ghost btn-xs text-danger"
data-action="delete-hdoc" data-doc-id="${doc.id}">
data-action="delete-hdoc" data-doc-id="${doc.id}"
onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>
@ -2993,7 +3007,7 @@ function _showPoiKorrekturModal(osmId, poiName, currentOh) {
Bei ernsthaften oder sich verschlechternden Symptomen sofort zum Tierarzt.
</div>`,
footer: `
<button class="btn btn-secondary" data-modal-close>Schließen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="ki-tierarzt-submit-btn">Frage stellen</button>`,
});
@ -3219,7 +3233,7 @@ function _showPoiKorrekturModal(osmId, poiName, currentOh) {
</div>
</form>`;
const footer = `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="ins-save-btn" form="${id}">Speichern</button>`;
UI.modal.open({ title: existing ? 'Versicherung bearbeiten' : 'Versicherung eintragen', body, footer });
setTimeout(() => {
@ -3371,7 +3385,7 @@ function _showPoiKorrekturModal(osmId, poiName, currentOh) {
</div>
</form>`;
const footer = `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="beh-save-btn" form="${id}">Speichern</button>`;
UI.modal.open({ title: 'Verhalten erfassen', body, footer });
setTimeout(() => {

View file

@ -39,7 +39,7 @@ window.Page_laeufi = (() => {
? `<img src="${UI.escape(logoUrl)}" alt="Logo"
style="width:48px;height:48px;border-radius:50%;object-fit:cover;
border:2px solid rgba(196,132,58,.5);flex-shrink:0"
data-fb="hide">`
onerror="this.style.display='none'">`
: `<div style="width:48px;height:48px;border-radius:50%;background:rgba(196,132,58,.15);
border:2px solid rgba(196,132,58,.4);display:flex;align-items:center;
justify-content:center;flex-shrink:0">
@ -394,7 +394,7 @@ window.Page_laeufi = (() => {
</div>
</form>`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="laeufi-form" type="submit">${isEdit ? 'Speichern' : 'Eintragen'}</button>`,
});
document.getElementById('laeufi-form').addEventListener('submit', async e => {
@ -472,7 +472,7 @@ window.Page_laeufi = (() => {
</div>
</form>`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="deck-form" type="submit">${isEdit ? 'Speichern' : 'Eintragen'}</button>`,
});
document.getElementById('deck-form').addEventListener('submit', async e => {
@ -505,7 +505,7 @@ window.Page_laeufi = (() => {
title: `Progesterontests — ${_fmtDate(laeufi.beginn)}`,
body: `<div id="prog-modal-content"><p class="text-muted">Lädt…</p></div>`,
footer: `
<button class="btn btn-secondary" data-modal-close>Schließen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
<button class="btn btn-primary" id="prog-add-btn">${UI.icon('plus')} Test eintragen</button>`,
});
await _loadProgContent(laeufi.id);
@ -602,7 +602,7 @@ window.Page_laeufi = (() => {
</div>
</form>`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="prog-form" type="submit">Eintragen</button>`,
});
document.getElementById('prog-form').addEventListener('submit', async e => {

View file

@ -100,7 +100,7 @@ window.Page_litters = (() => {
? `<img src="${UI.escape(logoUrl)}" alt="Logo"
style="width:48px;height:48px;border-radius:50%;object-fit:cover;
border:2px solid rgba(196,132,58,.5);flex-shrink:0"
data-fb="hide">`
onerror="this.style.display='none'">`
: `<div style="width:48px;height:48px;border-radius:50%;background:rgba(196,132,58,.15);
border:2px solid rgba(196,132,58,.4);display:flex;align-items:center;
justify-content:center;flex-shrink:0">
@ -867,7 +867,7 @@ window.Page_litters = (() => {
</div>
</form>`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="wl-form" type="submit">${isEdit ? 'Speichern' : 'Eintragen'}</button>`,
});
@ -1358,7 +1358,7 @@ window.Page_litters = (() => {
<img src="${UI.escape(thumb)}" alt="${UI.escape(ph.caption || '')}"
loading="lazy"
style="width:100%;height:100%;object-fit:cover;display:block"
data-fb-src="/static/img/placeholder.webp">
onerror="this.src='/static/img/placeholder.webp'">
</a>
<button class="photos-vis-btn"
data-photo-id="${ph.id}"

View file

@ -107,9 +107,13 @@ window.Page_lost = (() => {
<div id="lost-map"
style="height:280px;border-radius:var(--radius-md);overflow:hidden;
margin-bottom:var(--space-4);
background:var(--c-surface-2)">
</div>
<div style="font-size:10px;color:var(--c-text-secondary);
text-align:right;margin-bottom:var(--space-4);
padding:2px var(--space-2) 0">
© OpenStreetMap-Mitwirkende
</div>
<p id="lost-info"
style="font-size:var(--text-sm);color:var(--c-text-secondary);
@ -174,9 +178,9 @@ window.Page_lost = (() => {
}
function _showUserOnMap() {
if (!_map || !_userPos) return;
if (!_map || !window.L || !_userPos) return;
if (_userMarker) _map.removeLayer(_userMarker);
_userMarker = UI.map.circleMarker(_userPos.lat, _userPos.lon, {
_userMarker = L.circleMarker([_userPos.lat, _userPos.lon], {
radius : 9,
fillColor : '#3498db',
color : '#fff',
@ -262,7 +266,7 @@ window.Page_lost = (() => {
// KARTEN-MARKER
// ----------------------------------------------------------
function _renderMarkers() {
if (!_map) return;
if (!_map || !window.L) return;
_markers.forEach(m => _map.removeLayer(m));
_markers = [];
@ -406,6 +410,7 @@ window.Page_lost = (() => {
<span style="font-size:10px;color:var(--c-warning,#d97706);font-weight:600"> Sync ausstehend</span>
<button class="btn btn-ghost btn-xs lost-discard-btn"
data-pending-id="${r.id}"
onclick="event.stopPropagation()"
style="color:var(--c-danger,#dc2626)">
🗑 Verwerfen
</button>
@ -414,7 +419,7 @@ window.Page_lost = (() => {
<button class="btn btn-ghost btn-xs lost-note-btn"
data-lost-note-id="${r.id}"
data-lost-note-name="${UI.escape(r.name)}"
title="Notiz">
title="Notiz" onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
</button>
</div>` : '')}
@ -803,8 +808,6 @@ function _emptyState(icon, title, text, cta = '') {
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
function _destroy() { try { _map && _map.remove(); } catch (e) {} _map = null; _markers = []; _userMarker = null; }
return { init, refresh, openNew, destroy: _destroy };
return { init, refresh, openNew };
})();

File diff suppressed because it is too large Load diff

View file

@ -114,9 +114,9 @@ window.Page_moderation = (() => {
}
function _statCard(icon, label, value, color, tab) {
const clickable = tab ? `data-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer"` : `style="padding:var(--space-4);text-align:center"`;
const clickable = tab ? `data-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer;transition:box-shadow .15s,transform .15s" onmouseenter="this.style.boxShadow='var(--shadow-md)';this.style.transform='translateY(-2px)'" onmouseleave="this.style.boxShadow='';this.style.transform=''"` : `style="padding:var(--space-4);text-align:center"`;
return `
<div class="card mod-stat-card${tab ? ' by-hover-lift' : ''}" ${clickable}>
<div class="card mod-stat-card" ${clickable}>
<svg class="ph-icon" style="width:24px;height:24px;color:${color};
margin-bottom:var(--space-2)" aria-hidden="true">
<use href="/icons/phosphor.svg#${icon}"></use>

View file

@ -155,7 +155,7 @@ window.Page_partner_profil = (() => {
background:var(--c-surface-2)">
${isVid
? `<video src="${UI.escape(url)}" style="width:100%;height:100%;object-fit:cover" muted playsinline loop
data-hover-play></video>
onmouseenter="this.play()" onmouseleave="this.pause()"></video>
<div style="position:absolute;bottom:4px;left:4px;background:rgba(0,0,0,.55);
border-radius:4px;padding:1px 5px;font-size:10px;color:#fff"> Video</div>`
: `<img src="${UI.escape(url)}" style="width:100%;height:100%;object-fit:cover">`}

View file

@ -392,7 +392,7 @@ window.Page_personality = (() => {
<div style="display:flex;gap:8px;flex-wrap:wrap">
${typ.aktivitaeten.map(a => `
<button class="btn btn-secondary" style="font-size:var(--text-xs);padding:6px 14px;border-radius:999px"
data-page="${a.page}">${a.label} </button>`).join('')}
onclick="App.navigate('${a.page}')">${a.label} </button>`).join('')}
</div>
</div>
</div>

View file

@ -0,0 +1,469 @@
/* ============================================================
BAN YARO Orte (Hundefreundliche Orte)
Karte + Liste, Eigene Orte anlegen/bearbeiten
============================================================ */
window.Page_places = (() => {
let _container = null;
let _appState = null;
let _map = null;
let _markers = [];
let _data = [];
let _activeTyp = null; // null = alle
let _search = '';
let _userPos = null;
// ----------------------------------------------------------
// Typen-Konfiguration
// ----------------------------------------------------------
const TYPEN = {
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant & Café', color: '#F97316' },
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauffläche', color: '#22C55E' },
shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6' },
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel-Station', color: '#84A98C' },
tierarzt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>', label: 'Tierarzt', color: '#EF4444' },
hundeschule: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#graduation-cap"></use></svg>', label: 'Hundeschule', color: '#8B5CF6' },
};
// _esc ersetzt durch UI.escape()
// ----------------------------------------------------------
// INIT
// ----------------------------------------------------------
async function init(container, appState) {
_container = container;
_appState = appState;
_render();
_loadData();
try { _userPos = await API.getLocation(); } catch {}
}
function refresh() { _loadData(); }
function onDogChange() {}
// ----------------------------------------------------------
// RENDER — Grundstruktur
// ----------------------------------------------------------
function _render() {
_container.innerHTML = `
<div class="places-layout">
<!-- Toolbar -->
<div class="places-toolbar">
<div class="places-filter" id="places-filter">
<button class="places-filter-btn active" data-typ=""><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-trifold"></use></svg> Alle</button>
${Object.entries(TYPEN).map(([k, t]) =>
`<button class="places-filter-btn" data-typ="${k}">${t.icon} ${t.label}</button>`
).join('')}
</div>
<button class="btn btn-primary btn-sm" id="places-add-btn" style="white-space:nowrap">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg> Ort hinzufügen
</button>
</div>
<!-- Suche -->
<div class="diary-search-wrap" style="margin:var(--space-2) var(--space-3) 0" id="places-search-wrap">
<svg class="ph-icon diary-search-icon" aria-hidden="true"><use href="/icons/phosphor.svg#magnifying-glass"></use></svg>
<input type="search" class="diary-search-input" id="places-search"
placeholder="Orte durchsuchen…" autocomplete="off">
</div>
<!-- Karte -->
<div id="places-map" class="places-map"></div>
<!-- Liste -->
<div id="places-list" class="places-list">
<div class="places-list-inner">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">
Lädt
</p>
</div>
</div>
</div>
`;
// Events
document.getElementById('places-filter').addEventListener('click', e => {
const btn = e.target.closest('.places-filter-btn');
if (!btn) return;
document.querySelectorAll('.places-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_activeTyp = btn.dataset.typ || null;
_applyFilter();
});
document.getElementById('places-add-btn').addEventListener('click', () => {
if (!_appState.user) {
UI.toast.warning('Bitte zuerst anmelden.');
App.navigate('settings');
return;
}
_showForm(null);
});
// Suche mit Debounce
let _searchTimer = null;
document.getElementById('places-search')?.addEventListener('input', e => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => {
_search = e.target.value.trim().toLowerCase();
_applyFilter();
}, 300);
});
UI.loadLeaflet().then(_initMap);
}
// ----------------------------------------------------------
// Karte initialisieren
// ----------------------------------------------------------
function _initMap() {
const el = document.getElementById('places-map');
if (!el || !window.L || _map) return;
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515];
const zoom = _userPos ? 13 : 6;
_map = L.map('places-map', { zoomControl: true, attributionControl: false })
.setView(center, zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 })
.addTo(_map);
// GPS-Locate-Button
L.Control.Locate = L.Control.extend({
onAdd() {
const btn = L.DomUtil.create('button', 'places-locate-btn');
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true"><path d="M128,16a96,96,0,1,0,96,96A96.11,96.11,0,0,0,128,16Zm0,176a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,192Zm0-120a40,40,0,1,0,40,40A40,40,0,0,0,128,72Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,136Z"/></svg>';
btn.title = 'Meinen Standort';
btn.onclick = async () => {
try {
const pos = await API.getLocation({ enableHighAccuracy: true });
_userPos = pos;
_map.setView([pos.lat, pos.lon], 14);
} catch { UI.toast.error('Standort konnte nicht ermittelt werden.'); }
};
return btn;
},
onRemove() {},
});
new L.Control.Locate({ position: 'bottomright' }).addTo(_map);
_renderMarkers();
}
// ----------------------------------------------------------
// Daten laden
// ----------------------------------------------------------
async function _loadData() {
try {
_data = await API.places.list();
_renderList();
_renderMarkers();
} catch (err) {
UI.toast.error(err.message || 'Fehler beim Laden der Orte.');
}
}
// ----------------------------------------------------------
// Filter anwenden
// ----------------------------------------------------------
function _filtered() {
let list = _activeTyp ? _data.filter(p => p.typ === _activeTyp) : _data;
if (_search) {
const q = _search;
list = list.filter(p =>
(p.name || '').toLowerCase().includes(q) ||
(p.adresse|| '').toLowerCase().includes(q) ||
(p.typ || '').toLowerCase().includes(q)
);
}
return list;
}
function _applyFilter() {
_renderList();
_renderMarkers();
}
// ----------------------------------------------------------
// Marker rendern
// ----------------------------------------------------------
function _renderMarkers() {
if (!_map || !window.L) return;
_markers.forEach(m => m.remove());
_markers = [];
_filtered().forEach(place => {
const t = TYPEN[place.typ] || { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>', color: '#6B7280' };
const marker = UI.leafletMarker({ lat: place.lat, lon: place.lon, color: t.color, icon: t.icon, size: 34 })
.addTo(_map)
.on('click', () => _openDetail(place));
_markers.push(marker);
});
}
// ----------------------------------------------------------
// Liste rendern
// ----------------------------------------------------------
function _renderList() {
const list = document.getElementById('places-list');
if (!list) return;
const items = _filtered();
if (!items.length) {
const msg = _search
? `Keine Orte gefunden für „${UI.escape(_search)}".`
: (_activeTyp ? 'Keine Orte in dieser Kategorie.' : 'Noch keine Orte eingetragen.');
list.innerHTML = `
<div class="places-list-inner">
<p style="color:var(--c-text-secondary);text-align:center;padding:var(--space-6)">${msg}</p>
</div>`;
return;
}
list.innerHTML = `
<div class="places-list-inner">
${items.map(p => _cardHTML(p)).join('')}
</div>`;
list.querySelectorAll('.places-card').forEach(card => {
const id = parseInt(card.dataset.id);
const place = _data.find(p => p.id === id);
if (place) card.addEventListener('click', () => _openDetail(place));
});
}
function _cardHTML(p) {
const t = TYPEN[p.typ] || { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>', label: p.typ, color: '#6B7280' };
const flags = [
p.hund_rein === true ? `${UI.icon('dog')} Hund rein` : null,
p.leine_pflicht === true ? `${UI.icon('tag')} Leinenpflicht` : null,
p.wasser_fuer_hunde === true ? `${UI.icon('drop')} Wasser` : null,
].filter(Boolean);
return `
<div class="places-card" data-id="${p.id}" style="--typ-color:${t.color}">
<div class="places-card-icon">${t.icon}</div>
<div class="places-card-body">
<div class="places-card-name">${UI.escape(p.name)}</div>
<div class="places-card-meta">
<span class="places-card-typ" style="color:${t.color}">${t.label}</span>
${p.adresse ? `· <span>${UI.escape(p.adresse)}</span>` : ''}
</div>
${flags.length ? `<div class="places-card-flags">${flags.map(f => `<span class="places-flag">${f}</span>`).join('')}</div>` : ''}
</div>
<div class="places-card-arrow">${UI.icon('arrow-right')}</div>
</div>`;
}
// ----------------------------------------------------------
// Detail-Modal
// ----------------------------------------------------------
function _openDetail(place) {
const t = TYPEN[place.typ] || { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg>', label: place.typ, color: '#6B7280' };
const isOwn = _appState.user?.id === place.user_id;
const flags = [
place.hund_rein === true ? `${UI.icon('dog')} Hund erlaubt` : (place.hund_rein === false ? `${UI.icon('x')} Kein Hund` : null),
place.leine_pflicht === true ? `${UI.icon('tag')} Leinenpflicht` : (place.leine_pflicht === false ? `${UI.icon('check')} Leine optional` : null),
place.wasser_fuer_hunde === true ? `${UI.icon('drop')} Wasser vorhanden`: null,
].filter(Boolean);
const body = `
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-4)">
<div style="font-size:2.5rem">${t.icon}</div>
<div>
<div style="font-size:1.1rem;font-weight:600">${UI.escape(place.name)}</div>
<div style="color:${t.color};font-size:0.9rem">${t.label}</div>
</div>
</div>
${place.adresse ? `<p style="color:var(--c-text-secondary);margin-bottom:var(--space-2)">${UI.icon('map-pin')} ${UI.escape(place.adresse)}</p>` : ''}
${place.telefon ? `<p class="mb-2"><a href="tel:${UI.escape(place.telefon)}" class="text-primary">${UI.icon('phone')} ${UI.escape(place.telefon)}</a></p>` : ''}
${place.website ? `<p class="mb-2"><a href="${UI.escape(place.website)}" target="_blank" class="text-primary">${UI.icon('arrow-square-out')} ${UI.escape(place.website)}</a></p>` : ''}
${flags.length ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-top:var(--space-3)">${flags.map(f => `<span class="places-flag places-flag--detail">${f}</span>`).join('')}</div>` : ''}
<div id="place-rating-${place.id}"></div>
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
Eingetragen von ${UI.escape(place.user_name || 'Unbekannt')}
</p>
`;
const footer = isOwn ? `
<button type="button" class="btn btn-secondary w-full" id="place-detail-edit">Bearbeiten</button>
<button type="button" class="btn btn-ghost" style="width:100%;margin-top:var(--space-2)" id="place-detail-close">Schließen</button>
` : `
<button type="button" class="btn btn-primary flex-1" id="place-detail-close">Schließen</button>
`;
UI.modal.open({ title: `${t.icon} ${UI.escape(place.name)}`, body, footer });
UI.ratingStars({
containerId: `place-rating-${place.id}`,
targetType: 'place',
targetId: place.id,
isLoggedIn: !!_appState.user,
});
document.getElementById('place-detail-close')?.addEventListener('click', UI.modal.close);
document.getElementById('place-detail-edit')?.addEventListener('click', () => {
UI.modal.close();
_showForm(place);
});
// Auf Karte zentrieren
if (_map) _map.setView([place.lat, place.lon], 15);
}
// ----------------------------------------------------------
// Formular — Ort anlegen / bearbeiten
// ----------------------------------------------------------
function _showForm(place) {
const isEdit = !!place;
const typOpts = Object.entries(TYPEN)
.map(([k, t]) => `<option value="${k}" ${place?.typ === k ? 'selected' : ''}>${t.label}</option>`)
.join('');
const body = `
<form id="place-form" autocomplete="off">
<div class="form-group">
<label class="form-label">Name *</label>
<input class="form-control" type="text" name="name"
value="${UI.escape(place?.name || '')}" placeholder="z. B. Café Hund & Herrchen" required>
</div>
<div class="form-group">
<label class="form-label">Kategorie *</label>
<select class="form-control" name="typ">${typOpts}</select>
</div>
<div class="form-group">
<label class="form-label">GPS-Position *</label>
<div id="pf-location-picker"></div>
</div>
<div class="form-group">
<label class="form-label">Adresse <span class="text-secondary">(optional)</span></label>
<input class="form-control" type="text" name="adresse"
value="${UI.escape(place?.adresse || '')}" placeholder="Musterstraße 1, 12345 Musterstadt">
</div>
<div class="form-group">
<label class="form-label">Website <span class="text-secondary">(optional)</span></label>
<input class="form-control" type="url" name="website"
value="${UI.escape(place?.website || '')}" placeholder="https://…">
</div>
<div class="form-group">
<label class="form-label">Telefon <span class="text-secondary">(optional)</span></label>
<input class="form-control" type="tel" name="telefon"
value="${UI.escape(place?.telefon || '')}" placeholder="+49 89 123456">
</div>
<div class="form-group flex-col-gap-2">
<label class="form-label">Hundefreundlichkeit</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="hund_rein" ${place?.hund_rein ? 'checked' : ''}>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg> Hund darf rein
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="leine_pflicht" ${place?.leine_pflicht ? 'checked' : ''}>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#tag"></use></svg> Leinenpflicht beachten
</label>
<label style="display:flex;align-items:center;gap:var(--space-2);cursor:pointer">
<input type="checkbox" name="wasser_fuer_hunde" ${place?.wasser_fuer_hunde ? 'checked' : ''}>
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#drop"></use></svg> Wasser für Hunde vorhanden
</label>
</div>
</form>
`;
const footer = `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button type="submit" form="place-form" class="btn btn-primary w-full">
${isEdit ? 'Speichern' : 'Ort hinzufügen'}
</button>
<div class="flex-gap-2">
${isEdit ? `<button type="button" class="btn btn-danger" id="place-form-delete">Löschen</button>` : ''}
<button type="button" class="btn btn-secondary flex-1" id="place-form-cancel">Abbrechen</button>
</div>
</div>
`;
UI.modal.open({ title: isEdit ? `${UI.escape(place.name)} bearbeiten` : '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin"></use></svg> Neuer Ort', body, footer });
document.getElementById('place-form-cancel')?.addEventListener('click', UI.modal.close);
document.getElementById('place-form-delete')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Ort löschen?', message: `${place.name}" wird dauerhaft entfernt.`, confirmText: 'Löschen', danger: true,
});
if (!ok) return;
try {
await API.places.delete(place.id);
_data = _data.filter(p => p.id !== place.id);
UI.modal.close();
_renderList();
_renderMarkers();
UI.toast.success('Ort gelöscht.');
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
});
// Location-Picker initialisieren
const _picker = UI.locationPicker({ containerId: 'pf-location-picker' });
if (place?.lat && place?.lon) {
_picker.setValue(place.lat, place.lon, null);
}
document.getElementById('place-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="place-form"][type="submit"]') || e.target.querySelector('[type="submit"]');
const fd = UI.formData(e.target);
const loc = _picker.getValue();
if (!loc.lat || !loc.lon) {
UI.toast.warning('Bitte GPS-Position ermitteln.');
return;
}
await UI.asyncButton(btn, async () => {
const payload = {
name: fd.name?.trim(),
typ: fd.typ,
lat: loc.lat,
lon: loc.lon,
adresse: fd.adresse || null,
website: fd.website || null,
telefon: fd.telefon || null,
hund_rein: 'hund_rein' in fd,
leine_pflicht: 'leine_pflicht' in fd,
wasser_fuer_hunde: 'wasser_fuer_hunde' in fd,
};
if (isEdit) {
const updated = await API.places.update(place.id, payload);
const idx = _data.findIndex(p => p.id === place.id);
if (idx !== -1) _data[idx] = updated;
UI.toast.success('Gespeichert.');
} else {
const created = await API.places.create(payload);
_data.unshift(created);
UI.toast.success('Ort hinzugefügt!');
}
UI.modal.close();
_renderList();
_renderMarkers();
});
});
}
return { init, refresh, onDogChange };
})();

View file

@ -26,7 +26,7 @@ function _fmtDate(iso) {
if (foto_url) {
return `<img src="${UI.escape(foto_url)}" alt="${initials}"
style="width:${size}px;height:${size}px;border-radius:50%;object-fit:cover;display:block;"
data-fb="initials" data-fb-initials="${initials}" data-fb-size="${size}">`;
onerror="this.outerHTML='<div style=\'width:${size}px;height:${size}px;border-radius:50%;background:var(--c-primary-subtle);display:flex;align-items:center;justify-content:center;font-size:${Math.round(size*0.45)}px;font-weight:700;color:var(--c-primary);\'>${initials}</div>'">`;
}
return `<div style="width:${size}px;height:${size}px;border-radius:50%;
background:var(--c-primary-subtle);display:flex;align-items:center;
@ -333,7 +333,7 @@ function _fmtDate(iso) {
icon: UI.icon('paw-print'),
title: 'Noch kein Hund',
text: 'Lege zuerst einen Hund in deinem Profil an.',
action: `<button class="btn btn-primary" data-page="dog-profile">Hund anlegen</button>`,
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Hund anlegen</button>`,
});
return;
}

View file

@ -61,9 +61,13 @@ window.Page_poison = (() => {
<div id="poison-map"
style="height:280px;border-radius:var(--radius-md);overflow:hidden;
margin-bottom:var(--space-4);
background:var(--c-surface-2)">
</div>
<div style="font-size:10px;color:var(--c-text-secondary);
text-align:right;margin-bottom:var(--space-4);
padding:2px var(--space-2) 0">
© OpenStreetMap-Mitwirkende
</div>
<div style="display:flex;gap:var(--space-2);flex-wrap:wrap;margin-bottom:var(--space-3)">
<a href="tel:110" class="btn btn-secondary" style="flex:1;text-align:center;text-decoration:none">
@ -139,9 +143,9 @@ window.Page_poison = (() => {
}
function _showUserOnMap() {
if (!_map || !_userPos) return;
if (!_map || !window.L || !_userPos) return;
if (_userMarker) _map.removeLayer(_userMarker);
_userMarker = UI.map.circleMarker(_userPos.lat, _userPos.lon, {
_userMarker = L.circleMarker([_userPos.lat, _userPos.lon], {
radius : 9,
fillColor : '#3498db',
color : '#fff',
@ -197,7 +201,7 @@ window.Page_poison = (() => {
// KARTEN-MARKER
// ----------------------------------------------------------
function _renderMarkers() {
if (!_map) return;
if (!_map || !window.L) return;
_markers.forEach(m => _map.removeLayer(m));
_markers = [];
@ -298,7 +302,7 @@ window.Page_poison = (() => {
${_appState.user ? `<div style="margin-top:var(--space-2);text-align:right">
<button class="btn btn-ghost btn-xs poison-note-btn"
data-poison-note-id="${r.id}"
title="Notiz">
title="Notiz" onclick="event.stopPropagation()">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
</button>
</div>` : ''}
@ -650,9 +654,6 @@ window.Page_poison = (() => {
// ----------------------------------------------------------
// PUBLIC
// ----------------------------------------------------------
// GL-Karte beim Seitenwechsel freigeben (sonst leakt der WebGL-Kontext → iOS-Limit).
function _destroy() { try { _map && _map.remove(); } catch (e) {} _map = null; _markers = []; _userMarker = null; }
return { init, refresh, openNew, openDetail: _openDetail, destroy: _destroy };
return { init, refresh, openNew, openDetail: _openDetail };
})();

View file

@ -89,7 +89,6 @@ window.Page_routes = (() => {
let _viewMode = 'list';
let _searchMap = null; // L.map Instanz der Suchkarte
let _searchLines = new Map(); // routeId → { line, route }
let _detailMap = null; // GL-Karte im Detail-Modal (Kontext beim Schließen freigeben!)
// Mini-Karten auf den Route-Cards
let _miniMaps = new Map(); // routeId → L.map
@ -128,7 +127,6 @@ window.Page_routes = (() => {
_flushPendingNavWalk(); // nicht gespeicherten Navigations-Walk nachtragen
try { _userPos = await API.getLocation(); } catch {}
await _loadData();
_offerResume(); // unterbrochene Aufzeichnung anbieten
// Vorschlag sofort rendern (Leaflet war noch nicht bereit bei _render)
if (params._suggestResult) {
@ -151,7 +149,7 @@ window.Page_routes = (() => {
btnRow.innerHTML = `
<button id="rk-filter-btn" style="${_btnStyle()}position:relative">
${UI.icon('gear')} Filter
<span class="rk-filter-badge hidden" id="rk-filter-badge"></span>
<span class="rk-filter-badge" id="rk-filter-badge" class="hidden"></span>
</button>
<label id="rk-imp-wrap" title="GPX / KML / TCX importieren" style="${_btnStyle()}">
${UI.icon('download-simple')} Import
@ -171,14 +169,6 @@ window.Page_routes = (() => {
}
function onDogChange() {}
// Beim Verlassen der Seite alle Listen-/Detail-Karten freigeben (WebGL-Kontext-Leak).
// Aktive Navigations-/Aufzeichnungs-Overlays (_navMap/_recMap) bleiben unangetastet.
function destroy() {
[_detailMap, _suggestMap, _searchMap].forEach(m => { try { m && m.remove && m.remove(); } catch (e) {} });
_detailMap = _suggestMap = _searchMap = null;
try { _miniMaps.forEach(m => m.remove && m.remove()); _miniMaps.clear(); } catch (e) {}
}
// ----------------------------------------------------------
// Render
// ----------------------------------------------------------
@ -223,7 +213,7 @@ window.Page_routes = (() => {
<div style="display:flex;gap:8px">
<button id="rk-filter-btn" style="${_btnStyle()}position:relative">
${UI.icon('gear')} Filter
<span class="rk-filter-badge hidden" id="rk-filter-badge"></span>
<span class="rk-filter-badge" id="rk-filter-badge" class="hidden"></span>
</button>
<label id="rk-imp-wrap" title="GPX / KML / TCX importieren" style="${_btnStyle()}">
${UI.icon('download-simple')} Import
@ -231,7 +221,7 @@ window.Page_routes = (() => {
</label>
<button class="rk-rec-btn" id="rk-rec-btn" style="${_btnStyle(true)}">${UI.icon('path')} Aufzeichnen</button>
</div>
<div class="rk-filter-panel hidden" id="rk-filter-panel">
<div class="rk-filter-panel" id="rk-filter-panel" class="hidden">
<div class="rk-filters" id="rk-filters">
<div class="rk-filter-group">
<div class="rk-filter-label">Schwierigkeit</div>
@ -622,11 +612,12 @@ window.Page_routes = (() => {
zoomControl: false, attributionControl: false,
});
_suggestMap.scrollWheelZoom.disable();
const poly = UI.map.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.9 }).addTo(_suggestMap);
UI.map.circleMarker(lls[0], { radius:7, color:'#22C55E', fillColor:'#22C55E', fillOpacity:1, weight:2 }).addTo(_suggestMap);
UI.map.circleMarker(lls.at(-1), { radius:7, color:'#EF4444', fillColor:'#EF4444', fillOpacity:1, weight:2 }).addTo(_suggestMap);
const poly = L.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.9 }).addTo(_suggestMap);
L.circleMarker(lls[0], { radius:7, color:'#22C55E', fillColor:'#22C55E', fillOpacity:1, weight:2 }).addTo(_suggestMap);
L.circleMarker(lls.at(-1), { radius:7, color:'#EF4444', fillColor:'#EF4444', fillOpacity:1, weight:2 }).addTo(_suggestMap);
_addRouteArrows(_suggestMap, track, '#3b82f6');
_fitRouteMap(_suggestMap, mapEl, () => poly.getBounds());
_suggestMap.fitBounds(poly.getBounds(), { padding: [16, 16] });
setTimeout(() => _suggestMap?.invalidateSize(), 120);
};
_initMap();
@ -668,26 +659,6 @@ window.Page_routes = (() => {
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
}
// Unterbrochene Aufzeichnung (Reload/Crash/Update) zum Fortsetzen anbieten.
let _resumeOffered = false;
async function _offerResume() {
if (_recActive || _resumeOffered || _recOvl) return;
const saved = window.RecStore?.load();
if (!saved || saved.source !== 'routes' || !Array.isArray(saved.track) || saved.track.length < 2) return;
if (Date.now() - (saved.ts || 0) > 6 * 3600 * 1000) { window.RecStore?.clear(); return; }
_resumeOffered = true;
const km = (saved.distKm || 0).toFixed(2);
const ok = await UI.modal.confirm({
title: 'Aufzeichnung fortsetzen?',
message: `Eine unterbrochene Aufzeichnung wurde gefunden (${km} km, ${saved.track.length} Punkte). Möchtest du sie fortsetzen?`,
confirmText: 'Fortsetzen',
cancelText: 'Später',
});
if (!ok) return; // Track bleibt erhalten (erneut anbieten / Staleness räumt auf)
await _openRecOvl();
await _startRecInOvl(saved);
}
async function _openRecOvl() {
if (!_appState.user) { UI.toast.warning('Bitte anmelden.'); return; }
if (_recOvl) return;
@ -759,7 +730,7 @@ window.Page_routes = (() => {
center: [pos.lat, pos.lon], zoom: 15,
zoomControl: false, attributionControl: false,
});
_recLocMarker = UI.map.circleMarker([pos.lat, pos.lon], {
_recLocMarker = L.circleMarker([pos.lat, pos.lon], {
radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1
}).addTo(_recMap);
} catch {
@ -781,30 +752,10 @@ window.Page_routes = (() => {
} catch {}
}
// Aufzeichnung gedrosselt sichern (Sicherheitsnetz gegen Datenverlust).
let _recPersistAt = 0;
function _persistRec(force) {
const now = Date.now();
if (!force && now - _recPersistAt < 8000) return;
_recPersistAt = now;
window.RecStore?.save({ source: 'routes', track: _recTrack, distKm: _recDistKm, startTime: _recStartTime });
}
function _recDone() {
window.RecStore?.clear();
window._byRecording = false;
window._byReloadIfPending?.();
}
async function _startRecInOvl(resume) {
async function _startRecInOvl() {
if (!navigator.geolocation) { UI.toast.error('GPS nicht verfügbar.'); return; }
window._byRecording = true; // Guard: Update-Reload wird aufgeschoben
_recActive = true;
if (resume && Array.isArray(resume.track) && resume.track.length) {
_recTrack = resume.track.slice(); _recDistKm = resume.distKm || 0;
_recStartTime = resume.startTime || Date.now();
} else {
_recTrack = []; _recDistKm = 0; _recStartTime = Date.now();
}
// iOS-Hinweis: Display muss wach bleiben
if (/iPad|iPhone|iPod/.test(navigator.userAgent)) {
@ -864,17 +815,9 @@ window.Page_routes = (() => {
btn.addEventListener('pointercancel', cancelHold);
document.getElementById('rk-rec-stats-bar').style.display = '';
if (_recMap) {
// Bei Fortsetzung den bestehenden Track sofort einzeichnen
const seed = (resume && _recTrack.length) ? _recTrack.map(p => [p.lat, p.lon]) : [];
_recPolyline = UI.map.polyline(seed, { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
if (seed.length) {
const last = seed[seed.length - 1];
_recLocMarker?.setLatLng(last);
_recMap.setView(last, 16);
if (_recMap && window.L) {
_recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
}
}
if (resume) { _updateRecStats(); _persistRec(true); }
await _recAcquireWakeLock();
document.addEventListener('visibilitychange', _recOnVisibility);
@ -889,7 +832,6 @@ window.Page_routes = (() => {
_recDistKm += d;
}
_recTrack.push({ lat, lon, ...(alt !== null ? { alt: Math.round(alt) } : {}) });
_persistRec();
_recPolyline?.addLatLng([lat, lon]);
_recLocMarker?.setLatLng([lat, lon]);
if (_recTrack.length === 1) _recMap?.setView([lat, lon], 16);
@ -998,14 +940,12 @@ window.Page_routes = (() => {
_recOvl?.removeEventListener('touchstart', _onRecOvlTouch);
_recOvl?.removeEventListener('pointerdown', _onRecOvlTouch);
if (!save) { _closeRecOvlClean(); _recDone(); return; }
if (!save) { _closeRecOvlClean(); return; }
const track = [..._recTrack], distKm = _recDistKm;
const dauMin = Math.round((Date.now() - _recStartTime) / 60000);
_persistRec(true); // finalen Stand sichern, bevor _recTrack zurückgesetzt wird
_closeRecOvlClean();
if (track.length < 2) { UI.toast.warning('Zu wenige GPS-Punkte zum Speichern.'); _recDone(); return; }
// Guard bleibt aktiv bis im Save-Modal gespeichert/verworfen wird.
if (track.length < 2) { UI.toast.warning('Zu wenige GPS-Punkte zum Speichern.'); return; }
_showRecSaveModal(track, distKm, dauMin);
}
@ -1108,7 +1048,7 @@ window.Page_routes = (() => {
document.getElementById('rk-rms-paw-val').value = btn.dataset.val;
});
document.getElementById('rk-rms-discard')?.addEventListener('click', () => { UI.modal.close(); _recDone(); });
document.getElementById('rk-rms-discard')?.addEventListener('click', () => UI.modal.close());
document.getElementById('rk-rms-form')?.addEventListener('submit', async e => {
e.preventDefault();
@ -1132,14 +1072,12 @@ window.Page_routes = (() => {
if (!navigator.onLine) {
_addPending(payload);
UI.modal.close();
_recDone();
UI.toast.success(`Route offline gespeichert — wird synchronisiert sobald Verbindung besteht.`);
_loadData();
return;
}
const saved = await API.routes.create(payload);
UI.modal.close();
_recDone();
UI.toast.success(`Route „${saved.name}" gespeichert!`);
_loadData();
});
@ -1253,7 +1191,7 @@ window.Page_routes = (() => {
}
function _renderRoutesOnMap() {
if (!_searchMap) return;
if (!_searchMap || !window.L) return;
// Alte Linien entfernen
_searchLines.forEach(({ line }) => line.remove());
@ -1265,15 +1203,15 @@ window.Page_routes = (() => {
const pts = (route.preview_track || []).map(p => [p.lat, p.lon]);
if (pts.length < 2) return;
const line = UI.map.polyline(pts, {
const line = L.polyline(pts, {
color: '#C4843A', weight: 4, opacity: 0.75,
}).addTo(_searchMap);
// Start-/End-Marker
const startM = UI.map.circleMarker(pts[0], {
const startM = L.circleMarker(pts[0], {
radius: 6, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5
}).addTo(_searchMap);
const endM = UI.map.circleMarker(pts[pts.length - 1], {
const endM = L.circleMarker(pts[pts.length - 1], {
radius: 6, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5
}).addTo(_searchMap);
@ -1302,7 +1240,7 @@ window.Page_routes = (() => {
if (_data.length && _searchLines.size && !_userPos) {
const allPts = [..._searchLines.values()].flatMap(({ line }) => line.getLatLngs());
if (allPts.length) {
try { _searchMap.fitBounds(allPts, { padding: [20, 20], maxZoom: 14 }); }
try { _searchMap.fitBounds(L.latLngBounds(allPts), { padding: [20, 20], maxZoom: 14 }); }
catch {}
}
}
@ -1571,24 +1509,33 @@ window.Page_routes = (() => {
document.querySelectorAll('.rk-mini-map').forEach(el => obs.observe(el));
};
init();
if (window.L) { init(); return; }
// Leaflet noch am Laden — kurz pollen
let tries = 0;
const poll = setInterval(() => {
if (window.L || ++tries > 30) { clearInterval(poll); if (window.L) init(); }
}, 100);
}
// Mini-Vorschau: zuerst sofort die SVG-Routenform (kein Warten), dann — sobald
// gerendert — auf ein echtes Karten-PNG (Basemap + Route) upgraden. Das PNG kommt
// aus EINEM geteilten Offscreen-GL-Kontext (UI.map.snapshot, mit Cache), damit viele
// Listeneinträge nicht das WebGL-Kontextlimit sprengen. Ist GL aus → SVG bleibt.
function _buildMiniMap(el) {
const track = JSON.parse(el.dataset.track || '[]');
el.innerHTML = _svgPreview(track);
if (track.length < 2 || !UI.map.snapshot) return;
UI.map.snapshot(track, { key: 'r' + (el.dataset.id || '') }).then(url => {
if (!url || !el.isConnected) return; // GL aus/Fehler → SVG-Platzhalter bleibt
el.style.backgroundImage = `url("${url}")`;
el.style.backgroundSize = 'cover';
el.style.backgroundPosition = 'center';
el.innerHTML = ''; // SVG-Platzhalter entfernen (PNG enthält die Route)
}).catch(() => {});
const routeId = parseInt(el.dataset.id);
if (track.length < 2) {
el.innerHTML = '<div class="rk-preview-empty">🗺️</div>';
return;
}
const lls = track.map(p => [p.lat, p.lon]);
const m = L.map(el, {
zoomControl: false, attributionControl: false,
dragging: false, touchZoom: false, scrollWheelZoom: false,
doubleClickZoom: false, keyboard: false, boxZoom: false,
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 17 }).addTo(m);
const poly = L.polyline(lls, { color: '#C4843A', weight: 3, opacity: 0.9 }).addTo(m);
L.circleMarker(lls[0], { radius: 5, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5 }).addTo(m);
L.circleMarker(lls.at(-1), { radius: 5, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5 }).addTo(m);
m.fitBounds(poly.getBounds(), { padding: [8, 8] });
_miniMaps.set(routeId, m);
}
// ----------------------------------------------------------
@ -1721,25 +1668,14 @@ window.Page_routes = (() => {
stroke-linejoin="round"
stroke-linecap="round"/>
</svg>
<!-- 2-Sek-Halten-Knopf: NUR hier entsperrt sich der Bildschirm (Fingerabdruck).
Tippen irgendwo sonst auf dem Dim-Overlay tut bewusst nichts. -->
<button id="rk-nav-unlock-btn"
style="background:none;border:none;cursor:pointer;outline:none;
display:flex;flex-direction:column;align-items:center;gap:0;
padding:0 16px 16px;-webkit-tap-highlight-color:transparent;
touch-action:none;user-select:none">
<!-- 2-Sek-Halten-Ring -->
<svg width="56" height="56" viewBox="0 0 56 56">
<circle cx="28" cy="28" r="24" fill="none" stroke="rgba(255,255,255,.12)" stroke-width="2.5"/>
<circle id="rk-nav-dim-prog" cx="28" cy="28" r="24" fill="none" stroke="rgba(255,255,255,.7)" stroke-width="2.5"
stroke-dasharray="150.8" stroke-dashoffset="150.8" stroke-linecap="round"
transform="rotate(-90 28 28)" style="transition:none"/>
</svg>
<!-- Fingerabdruck, inline path (kein <use> wegen iOS-Bug) -->
<svg viewBox="0 0 256 256" width="28" height="28" fill="white" style="margin-top:12px;opacity:0.5">
<path d="M126.42,24C70.73,24.85,25.21,70.09,24,125.81a103.53,103.53,0,0,0,13.52,53.54,4,4,0,0,0,7.1-.3,119.35,119.35,0,0,0,11.37-51A71.77,71.77,0,0,1,83,71.83a8,8,0,1,1,9.86,12.61A55.82,55.82,0,0,0,72,128.07a135.28,135.28,0,0,1-18.45,68.35,4,4,0,0,0,.61,4.85c2,2,4.09,4,6.25,5.82a4,4,0,0,0,6-1A151.18,151.18,0,0,0,85,158.49a8,8,0,1,1,15.68,3.19,167.33,167.33,0,0,1-21.07,53.64,4,4,0,0,0,1.6,5.63c2.47,1.25,5,2.41,7.57,3.47a4,4,0,0,0,5-1.61A183,183,0,0,0,120,128.28a8.16,8.16,0,0,1,7.44-8.21,8,8,0,0,1,8.56,8,198.94,198.94,0,0,1-25.21,97.16,4,4,0,0,0,2.95,5.92q4.55.63,9.21.86a4,4,0,0,0,3.67-2.1A214.88,214.88,0,0,0,152,128.8c.05-13.25-10.3-24.49-23.54-24.74A24,24,0,0,0,104,128a8.1,8.1,0,0,1-7.29,8,8,8,0,0,1-8.71-8,40,40,0,0,1,40.42-40c22,.23,39.68,19.17,39.57,41.16a231.37,231.37,0,0,1-20.52,94.57,4,4,0,0,0,4.62,5.51,103.49,103.49,0,0,0,10.26-3,4,4,0,0,0,2.35-2.22,243.76,243.76,0,0,0,11.48-34,8,8,0,1,1,15.5,4q-1.12,4.37-2.4,8.7a4,4,0,0,0,6.46,4.17A104,104,0,0,0,126.42,24ZM198,161.08a8,8,0,0,1-7.92,7,8.39,8.39,0,0,1-1-.06,8,8,0,0,1-6.95-8.93,252.57,252.57,0,0,0,1.92-31,56.08,56.08,0,0,0-56-56,56.78,56.78,0,0,0-7,.43,8,8,0,0,1-2-15.89,72.1,72.1,0,0,1,81,71.49A266.93,266.93,0,0,1,198,161.08Z"/>
</svg>
<div style="font-size:11px;opacity:.3;margin-top:6px">2 Sek. halten</div>
</button>
<div style="font-size:11px;opacity:.3;margin-top:8px">2 Sek. halten</div>
</div>
`;
document.body.appendChild(ovl);
@ -1787,8 +1723,8 @@ window.Page_routes = (() => {
_navMap.invalidateSize();
// Route-Polylines: erledigt (grün) + ausstehend (orange)
const doneLine = UI.map.polyline([], { color: '#22c55e', weight: 5, opacity: 0.85 }).addTo(_navMap);
const remainLine = UI.map.polyline(track.map(p => [p.lat, p.lon]), { color: '#f97316', weight: 5, opacity: 0.9 }).addTo(_navMap);
const doneLine = L.polyline([], { color: '#22c55e', weight: 5, opacity: 0.85 }).addTo(_navMap);
const remainLine = L.polyline(track.map(p => [p.lat, p.lon]), { color: '#f97316', weight: 5, opacity: 0.9 }).addTo(_navMap);
_navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] });
_addRouteArrows(_navMap, track, '#3b82f6');
@ -1801,7 +1737,7 @@ window.Page_routes = (() => {
}, 250);
// Start/End-Marker (als Variable damit Reverse sie neu setzen kann)
const mkPin = (p, color) => UI.map.circleMarker([p.lat, p.lon], {
const mkPin = (p, color) => L.circleMarker([p.lat, p.lon], {
radius: 8, color: '#fff', weight: 2, fillColor: color, fillOpacity: 1
}).addTo(_navMap);
let startPin = mkPin(track[0], '#22c55e');
@ -1816,14 +1752,19 @@ window.Page_routes = (() => {
pois.forEach(poi => {
const svgIcon = poi._svgIcon || 'map-pin';
const color = poi._color || '#6b7280';
const html = `<div style="background:${color};color:#fff;width:32px;height:32px;border-radius:50%;
const icon = L.divIcon({
className: '',
html: `<div style="background:${color};color:#fff;width:32px;height:32px;border-radius:50%;
display:flex;align-items:center;justify-content:center;
box-shadow:0 2px 5px rgba(0,0,0,0.35);border:2px solid rgba(255,255,255,.7)">
<svg style="width:16px;height:16px;fill:currentColor" viewBox="0 0 256 256" aria-hidden="true">
<use href="/icons/phosphor.svg#${svgIcon}"></use>
</svg></div>`;
UI.map.svgMarker(poi.lat, poi.lon, html, { size: 32 })
.bindTooltip(poi.name || poi._label)
</svg></div>`,
iconSize: [32, 32],
iconAnchor: [16, 16],
});
L.marker([poi.lat, poi.lon], { icon })
.bindTooltip(poi.name || poi._label, { direction: 'top', offset: [0, -16] })
.bindPopup(`<strong>${UI.escape(poi.name||poi._label)}</strong>
${poi.phone ? `<br>📞 <a href="tel:${UI.escape(poi.phone)}">${UI.escape(poi.phone)}</a>` : ''}
${poi.opening_hours ? `<br>🕐 ${UI.escape(poi.opening_hours)}` : ''}`)
@ -1918,7 +1859,7 @@ window.Page_routes = (() => {
_navWatchId = navigator.geolocation.watchPosition(pos => {
const { latitude: lat, longitude: lon } = pos.coords;
if (!locMarker) {
locMarker = UI.map.circleMarker([lat, lon], {
locMarker = L.circleMarker([lat, lon], {
radius: 10, color: '#fff', weight: 3, fillColor: '#3b82f6', fillOpacity: 1,
className: 'rk-nav-loc-pulse'
}).addTo(_navMap);
@ -1955,32 +1896,23 @@ window.Page_routes = (() => {
_navResetInactTimer();
const dim = document.getElementById('rk-nav-dim');
// Entsperren reagiert NUR auf den Fingerabdruck-Knopf (2 Sek. halten) — nicht mehr
// auf das ganze Dim-Overlay. Tippen daneben lässt den Bildschirm bewusst gedimmt.
const navUnlock = document.getElementById('rk-nav-unlock-btn');
let _lpTimer = null;
const cancelLp = () => {
clearTimeout(_lpTimer);
const prog = document.getElementById('rk-nav-dim-prog');
if (prog) { prog.style.transition = 'none'; prog.style.strokeDashoffset = '150.8'; }
};
navUnlock.addEventListener('pointerdown', e => {
dim.addEventListener('pointerdown', e => {
e.stopPropagation();
try { navUnlock.setPointerCapture(e.pointerId); } catch (err) {}
const prog = document.getElementById('rk-nav-dim-prog');
if (prog) { prog.style.transition = 'stroke-dashoffset 2s linear'; prog.style.strokeDashoffset = '0'; }
_lpTimer = setTimeout(() => {
dim.style.display = 'none'; _navDimmed = false; _navResetInactTimer();
}, 2000);
});
navUnlock.addEventListener('pointerup', cancelLp);
navUnlock.addEventListener('pointercancel', cancelLp);
// Verlässt der Finger den Knopf während des Haltens → abbrechen (sonst entsperrt
// ein wegrutschender Finger weiter). pointerleave reicht dank setPointerCapture.
navUnlock.addEventListener('pointerleave', cancelLp);
// Sicherheitsnetz: ein Tipp aufs Dim-Overlay (nicht auf den Knopf) tut nichts,
// aber wir schlucken ihn, damit darunterliegende Buttons nicht reagieren.
dim.addEventListener('pointerdown', e => { if (e.target === dim) e.stopPropagation(); });
dim.addEventListener('pointerup', cancelLp);
dim.addEventListener('pointercancel', cancelLp);
dim.addEventListener('pointerleave', cancelLp);
// Aktions-Buttons
document.getElementById('rk-nav-back').addEventListener('click', _closeNav);
@ -2014,7 +1946,7 @@ window.Page_routes = (() => {
<textarea id="rk-nav-fb-text" class="form-control" rows="4"
placeholder="z.B. Der Weg nach links ist gesperrt…" maxlength="500"></textarea>`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" data-modal-close>Abbrechen</button>
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
<button type="button" class="btn btn-primary flex-1" id="rk-nav-fb-send">${UI.icon('paper-plane-tilt')} Senden</button>`;
UI.modal.open({ title: `${UI.icon('chat-circle-dots')} Feedback senden`, body, footer });
document.getElementById('rk-nav-fb-send')?.addEventListener('click', async () => {
@ -2055,7 +1987,7 @@ window.Page_routes = (() => {
target="_blank" style="flex-shrink:0;margin-left:8px;color:var(--c-primary);font-size:12px">Navi</a>
</div>`).join('')}
</div>`).join('');
UI.modal.open({ title: `${UI.icon('map-pin')} POIs entlang der Route`, body, footer: `<button class="btn btn-secondary flex-1" data-modal-close>Schließen</button>` });
UI.modal.open({ title: `${UI.icon('map-pin')} POIs entlang der Route`, body, footer: `<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Schließen</button>` });
});
}
@ -2253,11 +2185,11 @@ window.Page_routes = (() => {
});
// Marker & Polylines
let greyBefore = UI.map.polyline([], { color: '#9ca3af', weight: 3, opacity: 0.5 }).addTo(trimMap);
let activeLine = UI.map.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(trimMap);
let greyAfter = UI.map.polyline([], { color: '#9ca3af', weight: 3, opacity: 0.5 }).addTo(trimMap);
let greyBefore = L.polyline([], { color: '#9ca3af', weight: 3, opacity: 0.5 }).addTo(trimMap);
let activeLine = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(trimMap);
let greyAfter = L.polyline([], { color: '#9ca3af', weight: 3, opacity: 0.5 }).addTo(trimMap);
const mkMarker = (lat, lon, color) => UI.map.circleMarker([lat, lon], {
const mkMarker = (lat, lon, color) => L.circleMarker([lat, lon], {
radius: 9, color: '#fff', weight: 2.5, fillColor: color, fillOpacity: 1
}).addTo(trimMap);
@ -2285,13 +2217,13 @@ window.Page_routes = (() => {
&nbsp;·&nbsp; <span class="text-muted">Original: ${origKm.toFixed(2)} km · ${origMin} min (bleibt angerechnet)</span>`;
};
update();
trimMap.fitBounds(UI.map.polyline(fullTrack.map(p => [p.lat, p.lon])).getBounds(), { padding: [20, 20] });
trimMap.fitBounds(L.polyline(fullTrack.map(p => [p.lat, p.lon])).getBounds(), { padding: [20, 20] });
// Nächsten Track-Punkt zu einem Klick finden
const nearestIdx = (latlng) => {
let best = 0, bestD = Infinity;
fullTrack.forEach((p, i) => {
const d = trimMap.distance(latlng, { lat: p.lat, lng: p.lon });
const d = trimMap.distance(latlng, L.latLng(p.lat, p.lon));
if (d < bestD) { bestD = d; best = i; }
});
return best;
@ -2367,7 +2299,7 @@ window.Page_routes = (() => {
const photoGallery = photos.length ? `
<div class="rk-photo-gallery">
${photos.map(u => `<img src="${UI.escape(u)}" class="rk-photo-thumb" data-open-url="${UI.escape(u)}">`).join('')}
${photos.map(u => `<img src="${UI.escape(u)}" class="rk-photo-thumb" onclick="window.open('${UI.escape(u)}','_blank')">`).join('')}
${isOwn ? `<label class="rk-photo-add" title="Foto hinzufügen">
<span>+</span>
<input type="file" id="rk-photo-input" accept="image/*" multiple class="hidden">
@ -2443,12 +2375,7 @@ window.Page_routes = (() => {
</div>
`;
// onClose: GL-Kontext der Detailkarte freigeben — sonst leakt jede geöffnete Route
// einen WebGL-Kontext. Nach ~8 wirft MapLibre, und UI.map.create fällt auf
// Leaflet+OSM-Raster zurück (genau das Symptom: Detailkarte plötzlich OSM-Raster
// statt GL, und der Zoom passt nicht mehr).
UI.modal.open({ title: `🥾 ${UI.escape(route.name)}`, body, footer,
onClose: () => { if (_detailMap) { try { _detailMap.remove(); } catch (e) {} _detailMap = null; } } });
UI.modal.open({ title: `🥾 ${UI.escape(route.name)}`, body, footer });
UI.ratingStars({
containerId: `rk-rating-${route.id}`,
@ -2566,8 +2493,8 @@ window.Page_routes = (() => {
UI.noteModal('route', route.id, label, null);
});
// Mini-Map (modulweite _detailMap → wird beim Schließen im onClose freigegeben)
if (_detailMap) { try { _detailMap.remove(); } catch (e) {} _detailMap = null; }
// Mini-Map
let _detailMap = null;
setTimeout(async () => {
const el = document.getElementById('rk-detail-map');
if (!el || !track.length) return;
@ -2674,61 +2601,33 @@ window.Page_routes = (() => {
for (let i = 1; i < track.length - 1; i++) {
if (cum[i] >= next) {
const deg = brng(track[i-1], track[i]);
// Rotation INNERHALB des SVG (am Pfad), NICHT als CSS-transform am SVG-Element:
// maplibregl.Marker setzt transform:translate() aufs Element → würde rotate() killen
// (Pfeile zeigten alle nach Norden).
const html = `<svg width="20" height="20" viewBox="0 0 20 20" style="display:block;pointer-events:none">
<path d="M10,3 L15,15 L10,12 L5,15 Z" transform="rotate(${deg.toFixed(0)} 10 10)"
const icon = L.divIcon({
className: '',
html: `<svg width="20" height="20" viewBox="0 0 20 20"
style="transform:rotate(${deg.toFixed(0)}deg);transform-origin:10px 10px;display:block">
<path d="M10,3 L15,15 L10,12 L5,15 Z"
fill="${color}" fill-opacity="0.85"
stroke="rgba(0,0,0,0.25)" stroke-width="1" stroke-linejoin="round"/>
</svg>`;
UI.map.svgMarker(track[i].lat, track[i].lon, html, { size: 20 }).addTo(map);
</svg>`,
iconSize: [20, 20], iconAnchor: [10, 10],
});
L.marker([track[i].lat, track[i].lon], { icon, interactive: false, zIndexOffset: -100 }).addTo(map);
next += spacing;
}
}
}
// Karte robust auf die ganze Route fitten.
// WICHTIG (iOS): MapLibre verwirft ein fitBounds, das VOR dem ersten Render läuft —
// die Karte bleibt dann beim Start-Zoom (zoom 14, center=Start) hängen, statt auf die
// Route zu zoomen. (In Headless-Chromium passiert das nicht, daher fiel es dort nicht
// auf.) Deshalb fitten wir auf das 'load'/'idle'-Event der Karte — DANN ist sie wirklich
// gerendert und der Fit bleibt. Feste Timeouts + ResizeObserver als Sicherheitsnetz.
function _fitRouteMap(m, el, getBounds, opts) {
opts = opts || { padding: [16, 16], maxZoom: 16 };
let active = true;
const sized = () => !el || (el.clientWidth > 0 && el.clientHeight > 0);
const fit = () => { if (!active) return; try { m.invalidateSize(); m.fitBounds(getBounds(), opts); } catch (e) {} };
const onReady = () => {
if (!active) return;
fit();
// Erstes Ready-Event mit korrekt vermessenem Container = der gute Fit → danach Schluss,
// damit der Nutzer frei zoomen/pannen kann.
if (sized()) { active = false; try { m.off && m.off('idle', onReady); m.off && m.off('load', onReady); } catch (e) {} }
};
fit();
[120, 350, 700, 1200, 2000].forEach(t => setTimeout(fit, t));
try { m.on('load', onReady); } catch (e) {}
try { m.on('idle', onReady); } catch (e) {}
if (window.ResizeObserver && el) {
const ro = new ResizeObserver(() => fit());
ro.observe(el);
setTimeout(() => { try { ro.disconnect(); } catch (e) {} }, 4000);
}
setTimeout(() => { active = false; }, 4000);
}
async function _buildDetailMap(el, track) {
const lls = track.map(p => [p.lat, p.lon]);
const m = await UI.map.create(el, {
center: lls[0], zoom: 14,
zoomControl: false, attributionControl: false,
});
const poly = UI.map.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.85 }).addTo(m);
const poly = L.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.85 }).addTo(m);
_addRouteArrows(m, track, '#3b82f6');
UI.map.circleMarker(lls[0], { radius:7, color:'#22C55E', fillColor:'#22C55E', fillOpacity:1 }).addTo(m);
UI.map.circleMarker(lls.at(-1), { radius:7, color:'#EF4444', fillColor:'#EF4444', fillOpacity:1 }).addTo(m);
_fitRouteMap(m, el, () => poly.getBounds());
L.circleMarker(lls[0], { radius:7, color:'#22C55E', fillColor:'#22C55E', fillOpacity:1 }).addTo(m);
L.circleMarker(lls.at(-1), { radius:7, color:'#EF4444', fillColor:'#EF4444', fillOpacity:1 }).addTo(m);
m.fitBounds(poly.getBounds(), { padding:[10,10] });
return m;
}
@ -3202,9 +3101,11 @@ window.Page_routes = (() => {
const friendRows = friends.map(f => {
const initial = (f.name || '?')[0].toUpperCase();
return `<div class="rk-friend-row by-hover-surface2" data-id="${f.id}" data-name="${UI.escape(f.name || 'Anonym')}"
return `<div class="rk-friend-row" data-id="${f.id}" data-name="${UI.escape(f.name || 'Anonym')}"
style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3);
cursor:pointer;border-radius:var(--radius-md)">
cursor:pointer;border-radius:var(--radius-md);transition:background .15s"
onmouseover="this.style.background='var(--c-surface-2)'"
onmouseout="this.style.background=''">
<div style="width:36px;height:36px;border-radius:50%;background:var(--c-primary);
color:#fff;display:flex;align-items:center;justify-content:center;
font-weight:600;flex-shrink:0">${UI.escape(initial)}</div>
@ -3247,6 +3148,6 @@ window.Page_routes = (() => {
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
// ----------------------------------------------------------
return { init, refresh, onDogChange, destroy };
return { init, refresh, onDogChange };
})();

View file

@ -321,7 +321,7 @@ window.Page_settings = (() => {
style="margin-top:2px;flex-shrink:0;accent-color:${color}">
<span>
Ich habe die <span style="color:var(--c-primary);cursor:pointer"
data-page="agb">AGB</span> gelesen und stimme ihnen zu.
onclick="App.navigate('agb')">AGB</span> gelesen und stimme ihnen zu.
</span>
</label>
</div>
@ -2396,7 +2396,7 @@ window.Page_settings = (() => {
</div>
</form>`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit">Link senden</button>`,
});
document.getElementById(id)?.addEventListener('submit', async e => {
@ -2485,27 +2485,13 @@ window.Page_settings = (() => {
});
}
// Referral-Code aus localStorage lesen (30-Tage-Ablauf) bzw. löschen.
const _storedRefCode = () => {
try {
const code = localStorage.getItem('by_ref_code') || '';
if (!code) return '';
const ts = parseInt(localStorage.getItem('by_ref_code_ts') || '0', 10);
if (ts && Date.now() - ts > 30 * 24 * 3600 * 1000) { _clearRefCode(); return ''; }
return code;
} catch { return ''; }
};
const _clearRefCode = () => {
try { localStorage.removeItem('by_ref_code'); localStorage.removeItem('by_ref_code_ts'); } catch {}
};
// Partner-Code live validieren
const partnerInput = document.getElementById('reg-partner-code');
const partnerHint = document.getElementById('reg-partner-hint');
let _partnerValid = false;
if (partnerInput) {
// Vorausfüllen falls via Referral-Link gesetzt (localStorage, überlebt App-Schließen)
const stored = _storedRefCode();
// Vorausfüllen falls via sessionStorage gesetzt
const stored = sessionStorage.getItem('by_ref_code') || '';
if (stored) partnerInput.value = stored;
let _debounce = null;
@ -2553,10 +2539,10 @@ window.Page_settings = (() => {
await UI.asyncButton(btn, async () => {
const partnerCode = (fd.partner_code || '').trim().toUpperCase() || undefined;
const refCode = _storedRefCode();
const refCode = sessionStorage.getItem('by_ref_code') || '';
const finalCode = partnerCode || refCode || undefined;
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
if (refCode) _clearRefCode();
if (refCode) sessionStorage.removeItem('by_ref_code');
if (result.pending_verification) {
_renderVerifyPending(fd.email);

View file

@ -139,7 +139,8 @@ window.Page_sitting = (() => {
${_state.user ? `<button class="btn-icon sit-note-btn"
data-sit-note-id="${s.id}"
data-sit-note-label="${UI.escHtml(s.sitter_name + ' ' + (s.datum || ''))}"
title="Notiz" style="color:var(--c-text-muted);margin-top:var(--space-1)">
title="Notiz" style="color:var(--c-text-muted);margin-top:var(--space-1)"
onclick="event.stopPropagation()">
${UI.icon('note-pencil')}</button>` : ''}
</div>
</div>
@ -323,7 +324,7 @@ window.Page_sitting = (() => {
</form>
`;
const footer = `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" type="submit" form="${id}" id="sit-anfrage-submit">${UI.icon('paper-plane-tilt')} Anfrage senden</button>
`;
UI.modal.open({ title: 'Anfrage senden', body, footer });
@ -409,7 +410,7 @@ window.Page_sitting = (() => {
<button class="btn btn-primary" type="submit" form="${id}" id="sit-profil-submit" class="w-full">
${s ? `${UI.icon('floppy-disk')} Speichern` : `${UI.icon('plus')} Profil erstellen`}
</button>
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
</div>
`;
UI.modal.open({ title: s ? 'Sitter-Profil bearbeiten' : 'Sitter-Profil erstellen', body, footer });

View file

@ -658,7 +658,7 @@ window.Page_social = (() => {
box-shadow:var(--shadow-xs)">
${mediaUrl ? `<img src="${mediaUrl}"
style="width:60px;height:60px;border-radius:var(--radius-md);object-fit:cover;flex-shrink:0"
data-fb="hide">` : '<span style="font-size:2.2em">🐶</span>'}
onerror="this.style.display='none'">` : '<span style="font-size:2.2em">🐶</span>'}
<div>
<div style="font-size:11px;color:var(--c-primary);font-weight:600;margin-bottom:2px">
Rasse des Tages</div>
@ -882,7 +882,7 @@ window.Page_social = (() => {
<div class="sm-label">📎 Dein Medien-Upload</div>
<img src="${mediaUrl}" style="max-width:100%;max-height:200px;
border-radius:var(--radius-md);object-fit:cover;margin-top:8px"
data-fb="hide">
onerror="this.style.display='none'">
</div>` : ''}
${_resultBlock('📝 Caption', data.caption, true)}

View file

@ -351,7 +351,8 @@ window.Page_walks = (() => {
data-wk-note-id="${w.id}"
data-wk-note-label="${UI.escape(w.titel + ' ' + w.datum)}"
data-wk-note-ort="${UI.escape(w.ort_name || '')}"
title="Notiz" style="color:var(--c-text-muted);font-size:var(--text-xs)">
title="Notiz" style="color:var(--c-text-muted);font-size:var(--text-xs)"
onclick="event.stopPropagation()">
${UI.icon('note-pencil')}</button>` : ''}
</div>
</div>`;
@ -369,7 +370,7 @@ window.Page_walks = (() => {
}
function _renderMarkers() {
if (!_map) return;
if (!_map || !window.L) return;
_markers.forEach(m => m.remove());
_markers = [];
_data.forEach(w => {
@ -386,8 +387,8 @@ window.Page_walks = (() => {
if (_markers.length === 1) {
_map.setView(_markers[0].getLatLng(), 13);
} else if (_markers.length > 1) {
const group = UI.map.featureGroup(_markers);
_map.fitBounds(group, { padding: 50 });
const group = L.featureGroup(_markers);
_map.fitBounds(group.getBounds().pad(0.2));
}
}
@ -553,7 +554,7 @@ window.Page_walks = (() => {
: (walk.photos || []).map(p => `
<div style="position:relative;aspect-ratio:1">
<img src="${UI.escape(p.url)}" style="width:100%;height:100%;object-fit:cover;border-radius:var(--radius-sm);cursor:pointer"
data-lightbox-url="${UI.escape(p.url)}">
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(p.url)}' }], 0)">
${p.user_id === _appState.user?.id || isOwn ? `
<button type="button" class="wd-photo-del" data-photo-id="${p.id}"
style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:50%;width:20px;height:20px;cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;padding:0"></button>
@ -636,7 +637,7 @@ window.Page_walks = (() => {
div.style.cssText = 'position:relative;aspect-ratio:1';
div.innerHTML = `
<img src="${UI.escape(photo.url)}" style="width:100%;height:100%;object-fit:cover;border-radius:var(--radius-sm);cursor:pointer"
data-lightbox-url="${UI.escape(photo.url)}">
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(photo.url)}' }], 0)">
<button type="button" class="wd-photo-del" data-photo-id="${photo.id}"
style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:50%;width:20px;height:20px;cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;padding:0"></button>`;
grid.appendChild(div);
@ -1106,8 +1107,8 @@ window.Page_walks = (() => {
return `
<div class="challenge-sub-card">
<img src="${UI.escape(s.foto_url)}" alt="Challenge-Foto" loading="lazy"
data-fb-src="/icons/icon-192.png"
data-lightbox-url="${UI.escape(s.foto_url)}">
onerror="this.src='/icons/icon-192.png'"
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(s.foto_url)}' }], 0)">
<div class="challenge-sub-info">
<div class="challenge-sub-user">${UI.icon('user')} ${UI.escape(s.user_name || 'Anonym')}
${s.dog_name ? ` · 🐕 ${UI.escape(s.dog_name)}` : ''}</div>
@ -1130,7 +1131,7 @@ window.Page_walks = (() => {
winners.map(w => {
if (!w.winner) return `<div class="challenge-winner-chip"><span>${UI.escape(w.challenge.thema)}</span><small>Kein Gewinner</small></div>`;
return `<div class="challenge-winner-chip">
<img src="${UI.escape(w.winner.foto_url)}" alt="Gewinner" data-fb-src="/icons/icon-192.png">
<img src="${UI.escape(w.winner.foto_url)}" alt="Gewinner" onerror="this.src='/icons/icon-192.png'">
<div>
<div style="font-weight:600;font-size:var(--text-xs)">${UI.escape(w.challenge.thema)}</div>
<div class="text-xs-secondary">${UI.escape(w.winner.user_name)} · ${w.winner.votes} </div>
@ -1168,7 +1169,7 @@ window.Page_walks = (() => {
</form>
`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="challenge-submit-ok">Einreichen</button>
`,
});
@ -1365,7 +1366,7 @@ window.Page_walks = (() => {
</form>
`,
footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="gz-save-btn">Speichern</button>
`,
});
@ -1396,8 +1397,6 @@ window.Page_walks = (() => {
}
function _destroy() { try { _map && _map.remove(); } catch (e) {} _map = null; _markers = []; }
return { init, refresh, onDogChange, openNew, openDetail: _openDetail, destroy: _destroy };
return { init, refresh, onDogChange, openNew, openDetail: _openDetail };
})();

View file

@ -30,7 +30,7 @@ window.Page_widget = (() => {
icon: UI.icon('dog'),
title: 'Kein Hund angelegt',
text: 'Erstelle zuerst ein Hundeprofil.',
action: `<button class="btn btn-primary" data-page="dog-profile">Profil erstellen</button>`,
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
});
return;
}

View file

@ -80,19 +80,6 @@ window.Page_wiki = (() => {
async function init(container, appState) {
_container = container;
_appState = appState;
// Delegierter Click-Handler — Inline-onclick wird von der CSP blockiert.
if (!_container._wikiClickBound) {
_container.addEventListener('click', e => {
const btn = e.target.closest('[data-wiki-action]');
if (!btn) return;
const id = parseInt(btn.dataset.wikiId, 10);
if (btn.dataset.wikiAction === 'approve') _approveSubmission(id);
else if (btn.dataset.wikiAction === 'reject') _rejectSubmission(id);
});
_container._wikiClickBound = true;
}
await _render();
}
@ -193,11 +180,11 @@ window.Page_wiki = (() => {
</div>
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-3)">
<button class="btn btn-primary btn-sm flex-1"
data-wiki-action="approve" data-wiki-id="${s.id}">
onclick="Page_wiki._approveSubmission(${s.id})">
${UI.icon('check')} Freischalten
</button>
<button class="btn btn-ghost btn-sm flex-1"
data-wiki-action="reject" data-wiki-id="${s.id}">
onclick="Page_wiki._rejectSubmission(${s.id})">
${UI.icon('x')} Ablehnen
</button>
</div>
@ -403,7 +390,7 @@ window.Page_wiki = (() => {
: fotoUrl;
const photoHtml = fotoUrl
? `<img class="wiki-breed-photo" src="${UI.escape(srcUrl)}" loading="lazy" alt="${UI.escape(r.name)}"
data-fb="sibling" data-fb-src="${UI.escape(fotoUrl)}">`
onerror="if(this.src.includes('_preview')){this.src='${UI.escape(fotoUrl)}'}else{this.style.display='none';this.nextElementSibling.style.display='flex'}">`
: '';
const fallbackHtml = `<div class="wiki-breed-photo-fallback" style="${fotoUrl ? 'display:none' : ''}">${_DOG_SILHOUETTE}</div>`;
@ -757,7 +744,7 @@ window.Page_wiki = (() => {
? `<div class="wiki-gallery-wrap">
<img class="wiki-detail-photo wiki-gallery-main" id="wiki-main-photo"
src="${UI.escape(allFotos[0].foto_url)}" alt="${UI.escape(rasse.name)}"
data-fb="show-el" data-fb-el="wiki-photo-fallback">
onerror="this.style.display='none';document.getElementById('wiki-photo-fallback').style.display='flex'">
<div id="wiki-photo-fallback" class="wiki-detail-photo-placeholder hidden">${_dogSvgLg}<span>Kein Foto verfügbar</span></div>
${allFotos.length > 1 ? `
<div class="wiki-gallery-strip" id="wiki-gallery-strip">
@ -766,7 +753,7 @@ window.Page_wiki = (() => {
aria-label="Foto ${i + 1}">
<img src="${UI.escape(f.foto_url.startsWith('/media/') ? f.foto_url.replace(/\.(jpe?g|png|gif|webp)$/i,'_preview.webp') : f.foto_url)}"
alt="" loading="lazy"
data-fb-src="${UI.escape(f.foto_url)}">
onerror="if(this.src.includes('_preview')){this.src='${UI.escape(f.foto_url)}'}else{this.style.display='none'}">
${f.user_name ? `<span class="wiki-gallery-thumb-label">von ${UI.escape(f.user_name)}</span>` : ''}
</button>`).join('')}
</div>` : ''}
@ -1238,7 +1225,7 @@ window.Page_wiki = (() => {
const cardsHtml = data.results.map(r => {
const photoHtml = r.foto_url
? `<img class="wiki-quiz-result-photo" src="${UI.escape(r.foto_url)}" loading="lazy" alt="${UI.escape(r.name)}" data-fb="hide">`
? `<img class="wiki-quiz-result-photo" src="${UI.escape(r.foto_url)}" loading="lazy" alt="${UI.escape(r.name)}" onerror="this.style.display='none'">`
: `<div class="wiki-quiz-result-photo-fallback">${UI.icon('dog')}</div>`;
return `
<div class="wiki-quiz-result-card">
@ -1413,7 +1400,7 @@ window.Page_wiki = (() => {
</p>
${data.hinweis ? `<p style="font-size:var(--text-sm);color:var(--c-text-muted);margin-top:var(--space-3)">${UI.escape(data.hinweis)}</p>` : ''}
</div>`,
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
});
return;
}

View file

@ -118,9 +118,8 @@ window.Page_zucht_profil = (() => {
<div style="text-align:center;padding:var(--space-10) var(--space-4)">
<div style="font-size:3rem;margin-bottom:var(--space-3)">${UI.icon('warning')}</div>
<p class="text-danger">${UI.escape(err.message || 'Fehler beim Laden.')}</p>
<button class="btn btn-secondary" id="zp-back-btn">Zurück</button>
<button class="btn btn-secondary" onclick="history.back()">Zurück</button>
</div>`;
_container.querySelector('#zp-back-btn')?.addEventListener('click', () => history.back());
}
}

View file

@ -102,7 +102,7 @@ window.Page_zuchthunde = (() => {
? `<img src="${UI.escape(logoUrl)}" alt="Logo"
style="width:48px;height:48px;border-radius:50%;object-fit:cover;
border:2px solid rgba(196,132,58,.5);flex-shrink:0"
data-fb="hide">`
onerror="this.style.display='none'">`
: `<div style="width:48px;height:48px;border-radius:50%;background:rgba(196,132,58,.15);
border:2px solid rgba(196,132,58,.4);display:flex;align-items:center;
justify-content:center;flex-shrink:0">
@ -1751,7 +1751,7 @@ window.Page_zuchthunde = (() => {
<a href="${UI.escape(ph.url || '')}" target="_blank" rel="noopener noreferrer">
<img src="${UI.escape(thumb)}" alt="${UI.escape(ph.caption || '')}"
loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block"
data-fb="dim-grandparent">
onerror="this.parentElement.parentElement.style.opacity='.4'">
</a>
${isPrimary ? `<span style="position:absolute;top:3px;left:3px;background:var(--c-primary);color:white;
font-size:9px;font-weight:700;border-radius:999px;padding:1px 5px">Logo</span>` : ''}

View file

@ -1,11 +0,0 @@
// Presse-Seite — ausgelagert, da Inline-Script/onclick von der CSP blockiert wird.
document.addEventListener('DOMContentLoaded', () => {
const btn = document.querySelector('.copy-btn');
btn?.addEventListener('click', () => {
const text = document.getElementById('boilerplate-text').innerText.replace('Kopieren', '').trim();
navigator.clipboard.writeText(text).then(() => {
btn.textContent = 'Kopiert ✓';
setTimeout(() => btn.textContent = 'Kopieren', 2000);
});
});
});

View file

@ -1,23 +0,0 @@
// Testet den ECHTEN ui.js-Vektor-Pfad: lädt die reale ui.js und baut die Karte
// via UI.map.create() — exakt wie die App. Beweist, ob UI.map.vectorEnabled()/
// vectorLayer() im realen ui.js-Kontext funktionieren (unabhängig von Auth/SW).
(function () {
'use strict';
var s = document.getElementById('status');
function set(t) { if (s) s.textContent = t; }
(async function () {
try {
if (typeof UI === 'undefined' || !UI.map) return set('❌ UI.map nicht definiert (ui.js nicht geladen?)');
var enabled = UI.map.vectorEnabled ? UI.map.vectorEnabled() : 'METHODE FEHLT';
set('vectorEnabled=' + enabled + ' — erstelle Karte…');
var m = await UI.map.create('map', { center: [48.137, 11.576], zoom: 12 });
var layers = [];
m.eachLayer(function (l) { layers.push(l.constructor && l.constructor.name); });
set('✅ Karte erstellt | vectorEnabled=' + enabled + ' | Layer: ' + layers.join(',') +
' | protomapsL=' + !!window.protomapsL + ' MapVector=' + !!window.MapVector);
} catch (e) {
set('❌ Fehler: ' + (e && e.message ? e.message : e));
console.error(e);
}
})();
})();

View file

@ -439,6 +439,7 @@ const UI = (() => {
OSM_MAX_ZOOM: 19,
async create(containerId, options = {}) {
await loadLeaflet();
const {
center = [51.1657, 10.4515],
zoom = 6,
@ -446,48 +447,12 @@ const UI = (() => {
attributionControl = false,
darkFilter = false,
} = options;
// MapLibre-GL-Seitenkarte (gleicher Style wie die Hauptkarte) — hinter by_map_gl-Flag.
if (_uiUseGL()) {
try {
await loadMapLibreUI();
_uiGL = true;
const isDark = document.documentElement.dataset.theme === 'dark';
return MapGLMini.createMap(containerId, { center, zoom, zoomControl, dark: isDark });
} catch (e) {
console.warn('GL-Seitenkarte nicht verfügbar — Fallback Leaflet:', e);
}
}
_uiGL = false;
await loadLeaflet();
const m = L.map(containerId, { zoomControl, attributionControl }).setView(center, zoom);
// Vektor-Basemap aus eigenen PMTiles (hinter Feature-Flag). Bei Fehler
// (Tiles/Lib nicht da) sauberer Fallback auf den OSM-Raster — Marker etc.
// bleiben in beiden Fällen identisch (reiner Basemap-Tausch).
let usedVector = false;
if (_vectorMapEnabled()) {
try {
await loadProtomaps();
const isDark = document.documentElement.dataset.theme === 'dark';
MapVector.basemapLayer({ dark: isDark }).addTo(m);
if (!attributionControl) {
L.control.attribution({ prefix: false }).addTo(m)
.addAttribution('© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors');
}
usedVector = true;
} catch (e) {
console.warn('Vektor-Basemap nicht verfügbar — Fallback auf Raster:', e);
}
}
if (!usedVector) {
const tiles = L.tileLayer(this.OSM_URL, { maxZoom: this.OSM_MAX_ZOOM }).addTo(m);
if (darkFilter) {
const isDark = document.documentElement.dataset.theme === 'dark';
if (isDark) tiles.getContainer().style.filter = 'brightness(0.7) invert(1) contrast(0.9) hue-rotate(200deg)';
}
}
// Safety-Net: Container-Größe nach Layout neu vermessen. Verhindert
// grau bleibende Bereiche wenn die Karte vor dem finalen Layout erstellt
// wird (z.B. in frisch eingefügten Overlays mit flex:1).
@ -497,7 +462,6 @@ const UI = (() => {
// SVG-Marker mit eigenem HTML (z.B. mit Pulse-Animation, Rotation, etc.)
svgMarker(lat, lon, html, { size = 32, anchorY = null, className = '' } = {}) {
if (_uiGL && window.MapGLMini) return MapGLMini.svgMarker(lat, lon, html, { size, anchorY });
const icon = L.divIcon({
className,
html,
@ -506,51 +470,6 @@ const UI = (() => {
});
return L.marker([lat, lon], { icon });
},
// Engine-neutral: Kreis-Marker. Akzeptiert (lat, lon, opts) ODER ([lat,lon], opts) (Leaflet-Stil).
circleMarker(lat, lon, opts = {}) {
if (Array.isArray(lat)) { opts = lon || {}; lon = lat[1]; lat = lat[0]; }
else if (lat && lat.lat != null) { opts = lon || {}; lon = lat.lng; lat = lat.lat; }
if (_uiGL && window.MapGLMini) return MapGLMini.circleMarker(lat, lon, opts);
return L.circleMarker([lat, lon], opts);
},
// Engine-neutral: FeatureGroup (nur als Bounds-Container für fitBounds genutzt).
featureGroup(markers = []) {
if (_uiGL && window.MapGLMini) return MapGLMini.featureGroup(markers);
return L.featureGroup(markers);
},
// Engine-neutral: Polylinie (Route/Track).
polyline(latlngs, opts = {}) {
if (_uiGL && window.MapGLMini) return MapGLMini.polyline(latlngs, opts);
return L.polyline(latlngs, opts);
},
// Engine-neutral: Cluster-/Marker-Gruppe (GL: ohne Clustering, einfache Gruppe).
clusterGroup(opts = {}) {
if (_uiGL && window.MapGLMini) return MapGLMini.clusterGroup();
return L.markerClusterGroup(opts);
},
// Feature-Flag-Status der Vektor-Basemap (für Karten, die ihren Basemap-Layer
// selbst verwalten, z.B. pages/map.js).
vectorEnabled() { return _vectorMapEnabled(); },
// Lädt protomaps-leaflet + Regeln und liefert den fertigen Vektor-Basemap-Layer
// (Promise). dark=true → dunkles Theme.
async vectorLayer(opts = {}) {
await loadProtomaps();
return MapVector.basemapLayer(opts);
},
// Rendert für einen Track (Array {lat,lon}) ein PNG-Vorschaubild MIT Basemap
// (gleicher GL-Style wie die echte Karte) und liefert eine data-URL.
// EIN einziger Offscreen-GL-Kontext, serielle Verarbeitung, Cache pro key —
// so bekommt jede Routenkarte ihren geografischen Kontext, ohne das WebGL-
// Kontextlimit zu sprengen (Problem bei N Live-Mini-Karten auf iOS).
// Liefert null wenn GL aus ist (Aufrufer nutzt dann seinen SVG-Fallback).
snapshot(track, opts = {}) { return _glSnapshot(track, opts); },
};
// ----------------------------------------------------------
@ -894,187 +813,6 @@ const UI = (() => {
});
}
// ----------------------------------------------------------
// MapLibre-GL für Seitenkarten (UI.map) — lazy laden + Facade
// ----------------------------------------------------------
let _uiGL = false; // ist die aktuell erstellte UI-Karte GL?
let _maplibreUIPromise = null;
// Gleiche Logik wie pages/map.js _useGL: Staging-Default AN, Prod AUS, by_map_gl überschreibt.
function _uiUseGL() {
try {
const flag = localStorage.getItem('by_map_gl');
if (flag === '1') return true;
if (flag === '0') return false;
return /(^|\.)staging\.banyaro\.app$/.test(location.hostname);
} catch (e) { return false; }
}
function loadMapLibreUI() {
if (_maplibreUIPromise) return _maplibreUIPromise;
const v = '?v=' + (window.APP_VER || '');
if (!document.querySelector('link[href*="maplibre-gl.css"]')) {
const l = document.createElement('link');
l.rel = 'stylesheet'; l.href = '/js/vendor/maplibre-gl.css';
document.head.appendChild(l);
}
const seq = (srcs) => srcs.reduce((p, src) => p.then(() => new Promise((res, rej) => {
if ((src.includes('maplibre-gl.js') && window.maplibregl) ||
(src.includes('pmtiles.js') && window.pmtiles) ||
(src.includes('map-gl-style') && window.MapGLStyle) ||
(src.includes('map-offline') && window.MapOffline) ||
(src.includes('map-gl-mini') && window.MapGLMini)) return res();
const s = document.createElement('script');
s.src = src + v; s.onload = res; s.onerror = rej;
document.head.appendChild(s);
})), Promise.resolve());
_maplibreUIPromise = seq(['/js/vendor/maplibre-gl.js', '/js/vendor/pmtiles.js', '/js/map-gl-style.js', '/js/map-offline.js', '/js/map-gl-mini.js']).then(() => {
if (!(window.maplibregl && window.pmtiles && window.MapGLStyle && window.MapGLMini)) throw new Error('MapLibre (UI) nicht geladen');
try { const proto = new pmtiles.Protocol(); maplibregl.addProtocol('pmtiles', proto.tile); } catch (e) { /* evtl. schon registriert */ }
try { window.MapOffline && MapOffline.registerProtocol(); } catch (e) { /* byt://-Protokoll für Offline-Tiles */ }
});
return _maplibreUIPromise;
}
// ----------------------------------------------------------
// TRACK-VORSCHAU-SNAPSHOT — ein Offscreen-GL-Kontext rendert PNGs (Basemap+Route)
// ----------------------------------------------------------
let _snapMap = null, _snapReady = null, _snapChain = Promise.resolve(), _snapReleaseTimer = null;
const _snapCache = new Map(); // key → data-URL
const _EMPTY_FC = { type: 'FeatureCollection', features: [] };
function _ensureSnapMap() {
if (_snapReady) return _snapReady;
_snapReady = loadMapLibreUI().then(() => new Promise((resolve, reject) => {
const el = document.createElement('div');
// Aspekt wie .rk-card-preview (360×140); MapLibre rendert in devicePixelRatio → scharf.
el.style.cssText = 'position:fixed;left:-10000px;top:0;width:360px;height:140px;pointer-events:none;visibility:hidden;';
document.body.appendChild(el);
const isDark = document.documentElement.dataset.theme === 'dark';
const m = new maplibregl.Map({
container: el, style: MapGLStyle.build({ dark: isDark }),
center: [10.4515, 51.1657], zoom: 6,
interactive: false, attributionControl: false,
preserveDrawingBuffer: true, fadeDuration: 0,
});
m.on('error', () => {}); // einzelne Tile-Fehler nicht eskalieren
m.once('load', () => {
m.addSource('snap-line', { type: 'geojson', data: _EMPTY_FC });
m.addSource('snap-pts', { type: 'geojson', data: _EMPTY_FC });
m.addLayer({ id: 'snap-line', type: 'line', source: 'snap-line',
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#C4843A', 'line-width': 4, 'line-opacity': 0.95 } });
m.addLayer({ id: 'snap-pts', type: 'circle', source: 'snap-pts',
paint: { 'circle-radius': 6, 'circle-color': ['get', 'color'],
'circle-stroke-color': '#fff', 'circle-stroke-width': 2 } });
_snapMap = m;
resolve(m);
});
setTimeout(() => { if (!_snapMap) reject(new Error('snap-map load timeout')); }, 8000);
}));
return _snapReady;
}
function _renderSnap(track, key) {
return _ensureSnapMap().then(m => new Promise(resolve => {
const line = track.map(p => [p.lon, p.lat]);
m.getSource('snap-line').setData({ type: 'Feature', properties: {},
geometry: { type: 'LineString', coordinates: line } });
const a = track[0], b = track[track.length - 1];
m.getSource('snap-pts').setData({ type: 'FeatureCollection', features: [
{ type: 'Feature', properties: { color: '#22C55E' }, geometry: { type: 'Point', coordinates: [a.lon, a.lat] } },
{ type: 'Feature', properties: { color: '#EF4444' }, geometry: { type: 'Point', coordinates: [b.lon, b.lat] } },
] });
const bounds = line.reduce((bb, c) => bb.extend(c), new maplibregl.LngLatBounds(line[0], line[0]));
try { m.fitBounds(bounds, { padding: 22, duration: 0, maxZoom: 16 }); } catch (e) {}
let done = false;
const finish = () => {
if (done) return; done = true;
m.off('idle', finish);
requestAnimationFrame(() => {
let url = null;
try { url = m.getCanvas().toDataURL('image/png'); } catch (e) {}
if (url) _snapCache.set(key, url);
resolve(url);
});
};
m.on('idle', finish);
setTimeout(finish, 4000); // Fallback falls Tiles hängen
}));
}
// Offscreen-GL-Kontext nach Leerlauf freigeben — nicht dauerhaft halten, sonst belegt
// er einen der knappen iOS-WebGL-Kontexte und beschleunigt das Limit (Detailkarten
// fielen dann auf Leaflet+OSM-Raster zurück). Der PNG-Cache bleibt → kein Neu-Rendern.
function _releaseSnapMap() {
_snapReleaseTimer = null;
if (_snapMap) { try { _snapMap.remove(); } catch (e) {} _snapMap = null; }
_snapReady = null;
}
function _glSnapshot(track, opts = {}) {
if (!_uiUseGL()) return Promise.resolve(null); // GL aus → SVG-Fallback beim Aufrufer
if (!track || track.length < 2) return Promise.resolve(null);
const key = opts.key || ('t' + track.length + ',' + track[0].lat + ',' + track[0].lon + ',' +
track[track.length - 1].lat + ',' + track[track.length - 1].lon);
if (_snapCache.has(key)) return Promise.resolve(_snapCache.get(key));
if (_snapReleaseTimer) { clearTimeout(_snapReleaseTimer); _snapReleaseTimer = null; }
// Serielle Verarbeitung am gemeinsamen Offscreen-Kontext.
const run = _snapChain.then(() => _renderSnap(track, key)).catch(() => null);
_snapChain = run.catch(() => {});
run.then(() => {
if (_snapReleaseTimer) clearTimeout(_snapReleaseTimer);
_snapReleaseTimer = setTimeout(_releaseSnapMap, 15000);
});
return run;
}
// ----------------------------------------------------------
// VEKTOR-BASEMAP (protomaps-leaflet + eigene PMTiles) — lazy laden [DEAKTIVIERT]
// ----------------------------------------------------------
let _protomapsPromise = null;
function loadProtomaps() {
if (_protomapsPromise) return _protomapsPromise;
const v = '?v=' + (window.APP_VER || '');
const loadSeq = (srcs) => srcs.reduce((p, src) => p.then(() => new Promise((res, rej) => {
if ((src.includes('protomaps-leaflet') && window.protomapsL) ||
(src.includes('map-vector') && window.MapVector)) return res();
const s = document.createElement('script');
s.src = src + v;
s.onload = res; s.onerror = rej;
document.head.appendChild(s);
})), Promise.resolve());
// map-vector.js hängt von protomapsL ab → strikt sequenziell laden.
_protomapsPromise = loadSeq(['/js/vendor/protomaps-leaflet.js', '/js/map-vector.js'])
.then(() => {
if (window.protomapsL && window.MapVector) return;
throw new Error('protomaps-leaflet/MapVector nicht geladen');
});
return _protomapsPromise;
}
// Feature-Flag Vektor-Basemap: ?vectormap=1/0 setzt localStorage 'by_vector_map'.
// Default: auf Staging AN (Reifephase), auf Produktion AUS bis zur Freigabe.
// Explizit überschreibbar per Flag (1=an, 0=aus) — gilt auch in der installierten PWA.
// NOTAUS 2026-06-05: Vektor-Basemap deaktiviert — protomaps-leaflet rendert auf dem
// Main-Thread und hängt auf dem Handy zusammen mit der App-Map-Logik die UI auf.
// Erst Performance lösen (z.B. maxzoom begrenzen / Style verschlanken / ggf. MapLibre),
// dann hier wieder freischalten. Greift hart, auch wenn localStorage-Flag='1' gesetzt ist.
const _VECTOR_BASEMAP_KILLED = true;
function _vectorMapEnabled() {
if (_VECTOR_BASEMAP_KILLED) return false;
try {
const u = new URLSearchParams(location.search);
if (u.has('vectormap')) {
localStorage.setItem('by_vector_map', u.get('vectormap') === '0' ? '0' : '1');
}
const flag = localStorage.getItem('by_vector_map');
if (flag === '1') return true;
if (flag === '0') return false;
return /(^|\.)staging\.banyaro\.app$/.test(location.hostname);
} catch (e) { return false; }
}
// ----------------------------------------------------------
// LEAFLET MARKER FACTORY — erzeugt einen L.divIcon-Marker
// Verwendung:
@ -1089,10 +827,9 @@ const UI = (() => {
// ----------------------------------------------------------
function leafletMarker({ lat, lon, color = 'var(--c-primary)', icon = '', size = 32, label = '' } = {}) {
const inner = label || icon;
const html = `<div style="background:${color};color:#fff;font-size:${Math.round(size * 0.45)}px;font-weight:700;width:${size}px;height:${size}px;border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 5px rgba(0,0,0,0.3);border:2px solid rgba(255,255,255,0.8)">${inner}</div>`;
if (_uiGL && window.MapGLMini) return MapGLMini.svgMarker(lat, lon, html, { size, anchorY: size / 2 });
const divIcon = L.divIcon({
className: '', html,
className: '',
html: `<div style="background:${color};color:#fff;font-size:${Math.round(size * 0.45)}px;font-weight:700;width:${size}px;height:${size}px;border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 5px rgba(0,0,0,0.3);border:2px solid rgba(255,255,255,0.8)">${inner}</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
@ -1514,7 +1251,6 @@ const UI = (() => {
let _myKommentar = '';
let _hoverStar = 0;
let _widgetOpen = false;
let _ratings = []; // alle Bewertungen (mit Kommentar) für die Liste
function _starHTML(filled, half = false, idx = 0) {
const cls = filled ? 'rating-star rating-star--filled' : (half ? 'rating-star rating-star--half' : 'rating-star rating-star--empty');
@ -1553,24 +1289,6 @@ const UI = (() => {
`;
}
function _renderRatingsList() {
const items = _ratings.filter(r => r.kommentar && r.kommentar.trim());
if (!items.length) return '';
return `
<div style="margin-top:var(--space-3);display:flex;flex-direction:column;gap:var(--space-2)">
${items.map(r => `
<div style="background:var(--c-surface-2);border-radius:var(--radius-md);padding:var(--space-2) var(--space-3)">
<div style="display:flex;align-items:center;justify-content:space-between;gap:var(--space-2);margin-bottom:2px">
<span style="font-weight:var(--weight-semibold);font-size:var(--text-sm);color:var(--c-text)">${escape(r.user_name || 'Anonym')}</span>
<span style="color:#f59e0b;font-size:var(--text-sm);letter-spacing:1px;flex-shrink:0">${'★'.repeat(r.stars)}<span style="color:var(--c-border)">${'★'.repeat(Math.max(0, 5 - r.stars))}</span></span>
</div>
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);line-height:1.45;white-space:pre-wrap;word-break:break-word">${escape(r.kommentar)}</div>
</div>
`).join('')}
</div>
`;
}
function _render() {
const avgLabel = _anzahl > 0
? `${_avgStars.toFixed(1)} (${_anzahl} Bewertung${_anzahl !== 1 ? 'en' : ''})`
@ -1589,7 +1307,6 @@ const UI = (() => {
${rateHint}
</div>
${_widgetOpen ? _renderWidget() : ''}
${_renderRatingsList()}
`;
// Events
@ -1648,11 +1365,13 @@ const UI = (() => {
if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = '…'; }
const komm = document.getElementById(`rw-komm-${containerId}`)?.value?.trim() || null;
try {
await API.ratings.rate(targetType, targetId, _myStars, komm);
const res = await API.ratings.rate(targetType, targetId, _myStars, komm);
_avgStars = res.bewertung;
_anzahl = res.anz_bewertungen;
_myKommentar = komm || '';
_widgetOpen = false;
_hoverStar = 0;
await _load(); // frische Liste + Durchschnitt inkl. eigener Bewertung
_render();
toast.success('Bewertung gespeichert!');
} catch (err) {
toast.error(err?.message || 'Fehler beim Speichern.');
@ -1669,7 +1388,6 @@ const UI = (() => {
]);
_avgStars = overview.bewertung || 0;
_anzahl = overview.anz_bewertungen || 0;
_ratings = Array.isArray(overview.ratings) ? overview.ratings : [];
_myStars = mine.stars || null;
_myKommentar = mine.kommentar || '';
} catch (e) {
@ -1800,7 +1518,6 @@ const UI = (() => {
escape, escHtml, help, pageInfo,
saveToAlbum,
loadLeaflet,
loadMapLibreUI,
leafletMarker,
locationPicker,
map,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -713,7 +713,6 @@ window.Worlds = (() => {
function _openConfigModal() {
let cfg = JSON.parse(JSON.stringify(_getConfig())); // deep copy
let _drag = null; // { page, fromWorld, ghost }
let _removeHintShown = false; // „ausblenden ≠ löschen"-Toast nur einmal pro Session
const isAdmin = _state?.user?.rolle === 'admin';
const worldColors = { jetzt:'rgba(196,132,58,0.6)', hund:'rgba(196,132,58,0.8)', welt:'rgba(99,130,220,0.6)' };
@ -775,8 +774,7 @@ window.Worlds = (() => {
<!-- Hinweis + Reset -->
<div style="padding:10px 20px 6px;display:flex;align-items:center;justify-content:space-between;gap:12px">
<div style="font-size:var(--text-xs);color:rgba(255,255,255,0.4);flex:1">
Lang drücken &amp; ziehen zum Verschieben. blendet aus (löscht nicht)
ausgeblendete Funktionen bleiben über Weitere Funktionen" abrufbar.
Lang drücken &amp; ziehen zum Verschieben. zum Entfernen.
</div>
<button id="wc-reset" style="background:none;border:1px solid rgba(255,255,255,0.2);
color:rgba(255,255,255,0.5);border-radius:999px;padding:5px 12px;
@ -871,14 +869,7 @@ window.Worlds = (() => {
const page = btn.dataset.page, zone = btn.dataset.zone;
const meta = _chipMeta(page);
if (meta?.pinned) return; // gepinnte Chips können nicht entfernt werden
if (zone !== 'pool') {
cfg[zone] = cfg[zone].filter(p => p !== page);
// Klarstellen: ausblenden ≠ löschen (einmal pro Session)
if (!_removeHintShown) {
_removeHintShown = true;
UI.toast?.info('Ausgeblendet, nicht gelöscht — über „Weitere Funktionen" jederzeit wieder einblendbar.');
}
}
if (zone !== 'pool') cfg[zone] = cfg[zone].filter(p => p !== page);
_render();
});
});
@ -1408,9 +1399,8 @@ window.Worlds = (() => {
<div style="font-size:4rem;margin-bottom:12px">🐾</div>
<div class="world-info-title">Dein Hund wartet</div>
<div class="world-info-sub" style="margin-bottom:20px">Melde dich an um loszulegen</div>
<button class="btn btn-primary" id="world-login-btn">Anmelden</button>
<button class="btn btn-primary" onclick="Worlds.navigateTo('settings')">Anmelden</button>
</div>`;
el.querySelector('#world-login-btn')?.addEventListener('click', () => navigateTo('settings'));
return;
}

View file

@ -1,6 +0,0 @@
// Züchter-Landingpage — ausgelagert, da Inline-onclick von der CSP blockiert wird.
// Verhindert Redirect-Loop beim Öffnen der App aus der Landingpage.
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('[data-stay-in-app]').forEach(el =>
el.addEventListener('click', () => sessionStorage.setItem('by_stay_in_app', '1')));
});

View file

@ -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=1219"></script>
<script src="/js/landing-init.js?v=1161"></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, ohne App Store.">
<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

@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Ban Yaro — Leaflet-Vektor-Isolationstest</title>
<link rel="stylesheet" href="/css/leaflet.css">
<style>
html,body{margin:0;height:100%}
#map{position:absolute;inset:0}
#hud{position:absolute;top:10px;left:10px;z-index:1000;background:rgba(255,255,255,.92);
padding:8px 12px;border-radius:8px;font:13px system-ui,sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.2);max-width:260px}
</style>
</head>
<body>
<div id="map"></div>
<div id="hud"><b>Isolationstest</b><br>protomaps-leaflet + DACH-PMTiles<br><span id="status">init…</span></div>
<script src="/js/leaflet.js"></script>
<script src="/js/vendor/protomaps-leaflet.js"></script>
<script src="/js/map-vector.js"></script>
<script src="/js/leaflet-vector-test.js"></script>
</body>
</html>

View file

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Ban Yaro — GL-Marker-Subsystem-Test</title>
<link rel="stylesheet" href="/js/vendor/maplibre-gl.css">
<style>
html,body{margin:0;height:100%}
#map{position:absolute;inset:0}
#hud{position:absolute;top:10px;left:54px;z-index:5;background:rgba(255,255,255,.92);
padding:8px 12px;border-radius:8px;font:13px system-ui,sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.2);max-width:78vw}
#toggles{position:absolute;bottom:14px;left:10px;z-index:5;display:flex;gap:6px;flex-wrap:wrap}
#toggles button{font:12px system-ui;padding:6px 10px;border-radius:14px;border:1px solid #bbb;background:#fff}
#toggles button.off{opacity:.45}
</style>
</head>
<body>
<div id="map"></div>
<div id="hud"><b>GL-Marker-Test</b><br><span id="status">lädt…</span></div>
<div id="toggles"></div>
<script src="/js/vendor/maplibre-gl.js"></script>
<script src="/js/vendor/pmtiles.js"></script>
<script src="/js/map-gl-style.js"></script>
<script src="/js/map-gl-markers.js"></script>
<script src="/js/maplibre-markers-test.js"></script>
</body>
</html>

View file

@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Ban Yaro — MapLibre Perf-Test (600 Marker)</title>
<link rel="stylesheet" href="/js/vendor/maplibre-gl.css">
<style>
html,body{margin:0;height:100%}
#map{position:absolute;inset:0}
#hud{position:absolute;top:10px;left:10px;z-index:5;background:rgba(255,255,255,.92);
padding:8px 12px;border-radius:8px;font:13px system-ui,sans-serif;box-shadow:0 1px 6px rgba(0,0,0,.2);max-width:80vw}
</style>
</head>
<body>
<div id="map"></div>
<div id="hud"><b>MapLibre Perf-Test</b> — DACH-Basemap + 600 Cluster-Marker<br><span id="status">lädt…</span></div>
<script src="/js/vendor/maplibre-gl.js"></script>
<script src="/js/vendor/pmtiles.js"></script>
<script src="/js/map-gl-style.js"></script>
<script src="/js/maplibre-perf-test.js"></script>
</body>
</html>

View file

@ -1,37 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Ban Yaro — MapLibre Tile-Spike</title>
<link rel="stylesheet" href="/js/vendor/maplibre-gl.css">
<style>
html, body { margin: 0; height: 100%; }
#map { position: absolute; inset: 0; }
#hud {
position: absolute; top: 10px; left: 10px; z-index: 5;
background: rgba(255,255,255,.9); padding: 8px 12px; border-radius: 8px;
font: 13px/1.4 system-ui, sans-serif; box-shadow: 0 1px 6px rgba(0,0,0,.2);
max-width: 260px;
}
#hud b { color: #2e7d32; }
.attr {
position: absolute; bottom: 0; right: 0; z-index: 5;
background: rgba(255,255,255,.7); padding: 2px 6px;
font: 11px system-ui, sans-serif;
}
</style>
</head>
<body>
<div id="map"></div>
<div id="hud">
<b>Tile-Spike</b> — eigene PMTiles (Bayern)<br>
Quelle: <code>/tiles/bayern.pmtiles</code><br>
<span id="status">lädt…</span>
</div>
<div class="attr">© OpenStreetMap contributors</div>
<script src="/js/vendor/maplibre-gl.js"></script>
<script src="/js/vendor/pmtiles.js"></script>
<script src="/js/maplibre-test.js"></script>
</body>
</html>

View file

@ -221,7 +221,7 @@
<section>
<div class="section-label">Über Ban Yaro — Kurztext für Redaktionen</div>
<div class="boilerplate" id="boilerplate-text">
<button class="copy-btn">Kopieren</button>
<button class="copy-btn" onclick="copyBoilerplate()">Kopieren</button>
<p>Ban Yaro ist eine kostenlose Hunde-App für den deutschsprachigen Raum. Die App läuft als Progressive Web App direkt im Smartphone-Browser — ohne Installation über den App Store. Funktionen: Hunde-Tagebuch mit Fotos und Wetter, digitale Gesundheitsakte, interaktive Karte mit Hundewiesen und Giftköder-Alarm, Community-Forum und Trainingspläne. Gegründet 2024 von René Degelmann, Ebersberg bei München. Erreichbar unter banyaro.app.</p>
</div>
</section>
@ -386,7 +386,16 @@
</div>
<script src="/js/presse.js"></script>
<script>
function copyBoilerplate() {
const text = document.getElementById('boilerplate-text').innerText.replace('Kopieren', '').trim();
navigator.clipboard.writeText(text).then(() => {
const btn = document.querySelector('.copy-btn');
btn.textContent = 'Kopiert ✓';
setTimeout(() => btn.textContent = 'Kopieren', 2000);
});
}
</script>
</body>
</html>

View file

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

View file

@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Ban Yaro — UI.map Vektor-Pfad-Test</title>
<link rel="stylesheet" href="/css/leaflet.css">
<style>html,body{margin:0;height:100%}#map{position:absolute;inset:0}
#status{position:absolute;top:8px;left:8px;z-index:1000;background:#fff;padding:6px 10px;border-radius:6px;font:12px system-ui;max-width:88vw;box-shadow:0 1px 6px rgba(0,0,0,.2)}</style>
</head>
<body>
<div id="map"></div>
<div id="status">init…</div>
<script src="/js/leaflet.js"></script>
<script src="/js/ui.js"></script>
<script src="/js/ui-vector-test.js"></script>
</body>
</html>

View file

@ -204,7 +204,7 @@
<span class="badge">VDH-kompatibel</span>
<span class="badge">Kein App Store nötig</span>
</div>
<a href="/#register?rolle=breeder" class="cta-btn" data-stay-in-app>Jetzt als Züchter registrieren</a>
<a href="/#register?rolle=breeder" class="cta-btn" onclick="sessionStorage.setItem('by_stay_in_app','1')">Jetzt als Züchter registrieren</a>
<a href="#funktionen" class="cta-btn-secondary">Alle Features ansehen ↓</a>
</div>
</header>
@ -217,8 +217,8 @@
<a href="#vergleich">Vergleich</a>
<a href="#vorteil">Alleinstellung</a>
<a href="#start">Loslegen</a>
<a href="/" data-stay-in-app>Zur App</a>
<a href="/#register?rolle=breeder" class="nav-cta" data-stay-in-app>Registrieren</a>
<a href="/" onclick="sessionStorage.setItem('by_stay_in_app','1')">Zur App</a>
<a href="/#register?rolle=breeder" class="nav-cta" onclick="sessionStorage.setItem('by_stay_in_app','1')">Registrieren</a>
</div>
</nav>
@ -580,7 +580,7 @@
<p style="font-size:.9rem;opacity:.7;margin-bottom:1.75rem">
39 €/Jahr für die ersten 20 Gründer-Züchter · danach 49 €/Jahr · kein App Store · keine Kreditkarte zum Start
</p>
<a href="/#register?rolle=breeder" class="cta-btn" data-stay-in-app>Als Züchter registrieren</a>
<a href="/#register?rolle=breeder" class="cta-btn" onclick="sessionStorage.setItem('by_stay_in_app','1')">Als Züchter registrieren</a>
<p style="margin-top:1.5rem;font-size:0.85rem;opacity:0.55">Fragen? <a href="mailto:hallo@banyaro.app" style="color:rgba(255,255,255,.7)">hallo@banyaro.app</a></p>
</div>
</section>
@ -599,6 +599,5 @@
</div>
</footer>
<script src="/js/zuechter.js"></script>
</body>
</html>

View file

@ -95,7 +95,7 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
wcode = cur.get('weathercode', 0)
wind = cur.get('windspeed_10m')
is_day = cur.get('is_day', 1)
_daily_precip_max = (daily.get('precipitation_probability_max') or [None])[0] # Fallback
precip = (daily.get('precipitation_probability_max') or [None])[0]
uv = (daily.get('uv_index_max') or [None])[0]
desc, icon = _WMO.get(wcode, ('Unbekannt', 'cloud'))
@ -115,12 +115,6 @@ async def get_weather_for_location(lat: float, lon: float) -> dict:
h_times = hourly.get('time', [])
h_precip = hourly.get('precipitation_probability', [])
# Niederschlag fürs Pill: Höchstwert der NÄCHSTEN 3 STUNDEN (ab aktueller Stunde) statt Tages-Max
# — relevanter fürs "jetzt/gleich Gassi?". h_precip ist nach Stunde indiziert (0 = heute 00:00);
# der Slice ist am Tagesende automatisch kürzer (forecast_days=1).
_next3 = [p for p in h_precip[now_h:now_h + 3] if p is not None]
precip = max(_next3) if _next3 else _daily_precip_max
# Index-Liste nur für Stunden im Fenster now_h+1 … now_h+12
window = []
for t, p in zip(h_times, h_precip):

View file

@ -1,56 +0,0 @@
# DWD Regen-Vorhersage (Radar-Nowcast) — Scoping-Plan
**Status:** gescoppt + Datenformat verifiziert (2026-06-05). Umsetzung offen.
**Ziel:** Verlässliche, längere Regen-**Vorhersage** als animiertes Karten-Overlay (bis +2 h) statt RainViewers
unzuverlässigem 30-Min-Nowcast (der oft leer ist). Self-hosted wie die Basemap — passt zur Tile-Server-Philosophie.
## Quelle: DWD RV (Composite RV) — kostenlos, kein API-Key
- `https://opendata.dwd.de/weather/radar/composite/rv/DE1200_RV<YYMMDDHHMM>.tar.bz2`
- **Alle 5 Min** publiziert. Jedes Archiv = ein Vorhersage-Lauf mit **25 Frames** `_000``_120`
(**0 bis +120 Min**, 5-Min-Schritte), je ~2,5 MB unkomprimiert (~1 MB als .tar.bz2).
- **Format (verifiziert):** RADOLAN-Binär. 194-Byte-ASCII-Header bis `ETX (0x03)`, dann **1200×1100 uint16
little-endian** (= 2.640.000 Byte). Header-Felder: `PR E-02` (0,01 mm), `INT 5` (5-Min-Summe),
`GP1200x1100`, `VV<lead>` (Lead-Time). **Wert = `raw & 0x0FFF` × 0,01 mm/5min; `raw & 0x2000` = kein Daten.**
→ Decode trivial, **kein wradlib nötig** (PoC: 1,32 Mio Zellen geparst, Regen korrekt erkannt).
- **Gitter/Projektion:** DE1200 (1 km), polar-stereografisch, fest georeferenziert (Eckkoordinaten dokumentiert;
wradlib `get_radolan_grid` ODER GDAL mit dem bekannten RADOLAN-PROJ-String).
- **Abdeckung:** Deutschland + Randbereiche (reicht etwas nach AT/CH/Nachbarn, aber DE-zentriert).
Voll-AT/CH bräuchte ACG/MeteoSwiss → out of scope.
## Pipeline (Server-seitig, Cron alle 5 Min — analog zum OSM-POI-Job)
1. **Fetch** neueste `DE1200_RV<time>.tar.bz2` (Verzeichnis-Listing → letzte Datei).
2. **Entpacken** → 25 RADOLAN-Grids.
3. **Decode** je Grid → 2D-Niederschlags-Array (eigener ~15-Z.-Parser, PoC-bewiesen).
4. **Kolorieren** → RGBA (transparent bei 0/kein-Regen; Radar-Farbskala wie das aktuelle Overlay).
5. **Reprojektion** DE1200 → EPSG:3857 + **Kacheln z09** (Radar ist grob 1 km → höher zoomen bringt nichts).
Tooling: **GDAL** (gdalwarp + gdal2tiles) oder rasterio/rio-tiler.
6. **Output:** 25 Frame-Tilesets — je eine kleine **PMTiles** pro Lead-Time (`rv_000.pmtiles``rv_120.pmtiles`)
ODER XYZ-PNG. Nur neuesten Lauf behalten (atomarer Swap wie dach.pmtiles). Plus **`rv_manifest.json`**
(Lauf-Zeit, Lead-Times, Tile-URL-Muster).
7. **Ausliefern** von der DS (wie `/tiles`).
## Frontend (bestehende Radar-Timeline erweitern, map.js)
- Manifest laden → DWD-Vorhersage-Frames (0…+120 Min) **rechts von „jetzt"** in die Timeline einhängen
(die Forecast-Markierung `is-forecast` + Scrub/Play gibt es schon).
- **Vergangenheit:** weiter RainViewer (einfach) ODER DWD RADOLAN-RY (5-Min-Analyse) für all-DWD-Konsistenz.
- Quelle pro Frame: Vergangenheit = RainViewer-Tiles, Vorhersage = DWD-PMTiles (byt-/raster-Source).
## Aufwand & offene Entscheidungen
- **Decode:** trivial (verifiziert). **Projektion:** der einzige Knackpunkt — DE1200-Georeferenzierung korrekt
nach 3857 (wradlib nimmt's ab, oder bekannter PROJ-String + Eckkoordinaten). **PoC nötig:** 1 Frame →
Tiles → über MapLibre rendern und gegen RainViewer/echten Regen gegenchecken (Passgenauigkeit).
- **Tiling alle 5 Min × 25 Frames:** z09 für DE ist schnell (Sekunden~1 Min auf der DS). Last beachten
(läuft neben Immich & Co.); ggf. nur jeden 2. Lead-Time tilen (10-Min-Schritte) zum Sparen.
- **Speicher:** Radar-Tiles sind dünn/transparent → wenige MB pro Lauf.
- **Cron alle 5 Min:** neuer Container/Job (analog `docker-compose.osm.yml`); ⚠️ `--remove-orphans`-Falle.
- **Entscheidungen:** (a) Vergangenheit RainViewer vs. DWD-RY; (b) PMTiles-pro-Frame vs. XYZ; (c) Farbskala;
(d) Zoom-Range (z09) + Lead-Schrittweite (5 vs 10 Min); (e) Container-Stack (GDAL/Python) auf der DS.
- **Abhängigkeit:** Docker auf der DS.
## Nächste Schritte
1. **PoC**: 1 RV-Archiv → 1 Frame decode → kolorieren → reprojizieren → 1 PMTiles → headless über MapLibre
rendern. **Kernfrage: stimmt die Georeferenzierung?** (Wenn ja, ist der Rest Fleißarbeit.)
2. Pipeline-Skript (fetch→decode→tile→deploy) + Cron-Job + Manifest.
3. Frontend: Manifest in die Timeline einhängen (Vorhersage-Frames rechts von „jetzt").
Siehe `docs/TILE_SERVER_HANDOVER.md` (Tile-Infra), Memory `project_tile_server_maintenance`.

View file

@ -1,148 +0,0 @@
# Offline-Karten (GL/Vektor) — Feature-Plan
**Status:** KERN UMGESETZT + headless verifiziert (2026-06-05, v1213), **flag-gated `by_offline_tiles` (Default AUS)** bis Gerätetest.
**Stand:** 2026-06-05. Autor: René + Claude (Design).
## Umsetzungsstand (2026-06-05)
**✅ Fertig + headless bewiesen:**
- `map-offline.js` (`window.MapOffline`): Region-Download (`downloadAround(lat,lon,radiusKm)`) → Vektorkacheln
z014 via `pmtiles.getZxy` (liefert bereits dekomprimierte MVT) + Glyphs in **IndexedDB** (`by-offline-tiles`).
`byt://`-MapLibre-Protokoll (IndexedDB-first, remote-Fallback). ~15 MB / 5 km (dekomprimiert).
- `map-gl-style.js` `build({offline})`: `byt`-Source statt `pmtiles://`. Flag `by_offline_tiles` (Default AUS).
- ui.js/map.js laden map-offline + registrieren `byt`. `UI.loadMapLibreUI` exportiert.
- Welten-FAB Segment 5: prüft im GL-Modus gespeicherte Region (nicht mehr OSM-Raster); „Fehlende nachladen"
stößt `MapOffline.downloadAround(GPS, 5km)` an.
- **Beweis:** Download 97 Tiles (5 km München) → Netz AUS → **1903 Features gerendert**, nicht geladene
Gegend (Paris) leer; Glyphs nötig (sonst lässt MapLibre offline die ganze Kachel fallen).
**🔲 Offen (Follow-ups):**
- **Gerätetest (iOS-PWA offline/IndexedDB)** → dann Flag-Default auf Staging-AN (analog `by_map_gl`).
- Download-Button auf der **Karte** (`map-offline-btn`) im GL-Modus auf `downloadAround(Karten-Center)` umbiegen
(bisher OSM-Raster-Prefetch).
- **Adaptives Lernen** (rollendes Vorausladen beim Aufzeichnen + Funkloch-Gedächtnis).
- **Bereichsauswahl / Routen-Korridor** (inkl. „Route offline speichern" aus routes.js `_openDetail`).
- **Glyph-Persistenz** über App-Updates (aktuell SW-Cache, wird bei Update gepurged) → in IndexedDB ablegen + via `byt://f/` servieren.
- Alten OSM-Raster-Prefetch (`offline-indicator.js _prefetchTiles`) entfernen, wenn Flag dauerhaft AN.
## Ziel
GL-Vektorkarten offline-tauglich machen — Kernszenario **Gassi/Wandern im Funkloch**.
Selbst-zielend (cacht wo nötig, nicht überall), speichersparsam, ohne Nutzeraufwand.
## Problem (warum GL aktuell NICHT offline geht)
- PMTiles lädt per **HTTP-Range (206 Partial Content)**. Die **Cache-API kann 206 nicht speichern**
(`cache.put()` wirft) → Basemap-Kacheln landen nie im Offline-Cache.
- SW hat **keine Regel für `/tiles`** (nur für `tile.openstreetmap.org` = altes Raster).
- `offline-indicator.js` prefetcht weiterhin **OSM-Raster** (a.tile.openstreetmap.org), das die GL-Karte
gar nicht nutzt → doppelter Regress: Raster gecacht das niemand zeigt, GL-Karte offline trotzdem leer.
- Folge offline heute: App + Daten da, aber GL-Karte = Routenlinie/Marker auf **leerem Hintergrund**.
## Gemessene Speicher-Fakten (an echter dach.pmtiles, maxzoom=14 + Overzoom bis ~16)
**Referenz-Radius = 5 km** (René, 2026-06-05: „5 km genügen"). Messungen:
| Gebiet | Fläche | Tiles | Größe |
|---|---|---|---|
| **München (48,1/11,5), dicht — 5 km** | 10×10 km | 82 | **6,4 MB** |
| **Bayerischer Wald, ländlich — 5 km** | 10×10 km | 99 | **2,6 MB** |
| (Kontext) München 10 km | 20×20 km | 252 | 15 MB |
| (Kontext) Bayerischer Wald 10 km | 20×20 km | 285 | 4,4 MB |
| (Kontext) Bayerischer Wald ~25 km | 50×50 km | 1.595 | 20 MB |
→ Vektor ist ~10× sparsamer als Raster (Raster 5 km ≈ 40120 MB). Stadt-Tiles ~2,5× dicker als Land.
→ 5 km ist NICHT 1/4 von 10 km (6,4 vs 15 MB) — die unteren Zoomstufen (Übersicht) sind immer dabei,
unabhängig von der bbox-Größe.
**Budget ≈ 7 MB** (5 km dichte Stadt + Glyphs). Budget-getrieben deckt das in der Stadt ~5 km, auf dem
**Land ~810 km** Radius ab (mehr Reichweite genau dort wo die Funklöcher sind). Glyphs (~12 MB) +
Style (winzig) → ~8 MB pro Gegend. Sehr sparsam → viele Gegenden problemlos (10 ≈ 80 MB).
→ Messmethode (reproduzierbar): `docker run --rm -v /tmp/pmt:/out protomaps/go-pmtiles:latest extract
https://staging.banyaro.app/tiles/dach.pmtiles /out/x.pmtiles --bbox=W,S,E,N` → Dateigröße ablesen.
## Architektur
### Region-Extract (budget-getrieben, NICHT fester Radius)
- PMTiles-**Directory enthält pro Tile die Byte-Länge** → Server kann die Größe einer Region
**aufsummieren OHNE die Tiles zu laden**.
- Endpoint `GET /tiles/region?lat=&lon=&budget=7` (MB): wächst die bbox um die Position, bis die
summierte Tile-Länge ≈ Budget erreicht (Stadt → kleiner Radius, Land → großer Radius), extrahiert dann
genau diese Region als `region.pmtiles` (**ein 200er**, ~7 MB). `pmtiles extract` (go-pmtiles) oder
python-pmtiles im Container.
- Client lädt die Datei einmal → **IndexedDB** (Blob; 200er, anders als die 206-Ranges cachebar).
- MapLibre liest **offline** aus dem lokalen Blob via `pmtiles://` (pmtiles.js kann aus ArrayBuffer lesen);
**online** weiter remote `dach.pmtiles` (immer aktuell, ganz DACH+Anrainer). Source je nach Verbindung wählen.
- Glyphs (`/fonts/*.pbf`, Open Sans Regular+Semibold) mit cachen (200er, cachebar).
### Adaptive Strategie (der eigentliche Clou — lernt von selbst)
1. **Rollendes Vorausladen beim Aufzeichnen:** Solange GPS aktiv UND Empfang da, fortlaufend Tiles um die
**aktuelle Position** cachen. Deckt den echten Weg + die Anfahrt automatisch ab — auch beim ersten Mal,
bevor man ins Funkloch läuft.
2. **Funkloch-Gedächtnis:** Wo echte Requests **scheitern** (Timeout/Fehler während aktivem GPS — NICHT
`navigator.onLine`, das lügt bei Captive-Portal/Schwachempfang), den Bereich als „Offline nötig"
markieren → priorisiert behalten, beim nächsten Online-Durchgang großzügiger nachladen.
Caveat: im Funkloch selbst kann nicht geladen werden → greift ab dem 2. Besuch (Gassi = repetitiv → ok).
3. **Manuelles Vorab-Laden** („Offline-Inhalte laden"-Button) — zwei Modi:
- **Aktuelle Gegend** (Default, Gassi): budget-getrieben um die Position (~7 MB), ein Tipp.
- **Bereich auswählen** (mehrtägige Wanderung — Auto-5km reicht da nicht): Nutzer wählt ein größeres
Gebiet, das ganz heruntergeladen wird. Auswahl-Optionen:
- **Karten-Ausschnitt:** aktuellen Karten-Viewport (durch Zoomen/Verschieben gewählter bbox) als
Download-Gebiet nehmen — simpel, kein Zeichnen nötig.
- **Rechteck ziehen** auf der Karte (präziser).
- **Routen-Korridor:** entlang einer Route ± Puffer (ideal für Touren, die einer Strecke folgen —
viel sparsamer als eine große bbox).
**Verbindung zum Routen-Feature (WICHTIG, sonst hängt der Modus in der Luft):** Einstieg primär
AUS der Route — im Routen-Detail (`routes.js` `_openDetail`, Aktionsleiste neben GPX/Teilen/Navi)
und in der Navigations-Ansicht ein Button **„Route offline speichern"**. Nutzt den bereits
geladenen `route.gps_track` → Korridor = alle Tiles im Puffer (z.B. 12 km) um den Track (nicht die
ganze bbox). Zusätzlich im „Offline-Inhalte laden"-Dialog ein Modus **„Aus meinen Routen wählen"**
(Liste der gespeicherten Routen → Korridor laden). Größen-Vorschau wie unten, vor dem Download.
- **Größen-Vorschau VOR dem Download:** die Tile-Byte-Längen aus der PMTiles-Directory aufsummieren
(kein Tile-Download nötig) → „~45 MB" anzeigen, Nutzer bestätigt. Schützt vor versehentlichem Riesen-Download.
- Fortschritt + „X MB gespeichert" + Liste gespeicherter Gebiete (umbenennen/löschen/aktualisieren).
- Im Bereichs-Modus gilt das ~7-MB-Budget NICHT (Nutzer entscheidet bewusst), aber eine sinnvolle
Obergrenze + der globale Speicher-Cap greifen.
### Drumherum
- **Budget-Cap + LRU:** Gesamtspeicher gedeckelt; selten besuchte Funkloch-Caches fallen raus.
- **Privatsphäre:** „Wo verliere ich Netz" = Aufenthaltsorte → **komplett lokal (IndexedDB), nie hochgeladen.**
- **Aktualität:** Offline-Region beim nächsten Online-Sein neu ziehbar (Basemap-Updates / pmtiles-Refresh).
- **Aufräumen:** Den alten OSM-Raster-Prefetch in `offline-indicator.js` ablösen/abschalten (cacht ungenutztes Raster).
- **Pfoten-Offline-Indikator (Welten-FAB) anpassen:** `offline-indicator.js` füllt 5 Pfoten-Segmente;
**Segment 5 „Karten-Kacheln"** prüft aktuell `CACHE_TILES` auf ≥ N **OSM-Raster**-Tiles (TILE_PREFETCH
z14/z13). Die GL-Karte nutzt dieses Raster NICHT → grünes Segment = falsches „Karte offline bereit".
→ Segment 5 umdefinieren: prüfen ob für die aktuelle Gegend eine **`region.pmtiles` + Glyphs** lokal
vorliegen (statt Raster-Tile-Count). `offline-fill-btn` („Offline-Inhalte laden") soll dann den
Region-Download anstoßen statt den Raster-Prefetch. (Bei Variante 1 „Raster-Fallback" bliebe Segment 5
wie es ist — Entscheidung hängt an der Offline-Strategie oben.)
## Offene Entscheidungen / Defaults
- Budget-Default **~7 MB** (Referenz 5 km Stadt; René 2026-06-05). Stadt ~5 km / Land ~810 km.
Optional Stufe „Groß ~16 MB" (Stadt ~10 km / Land ~1822 km) für Wandertage.
- Zoom z014 (Overzoom liefert Straßenebene gratis).
- Detektionssignal = echte Fetch-Timeouts bei aktivem GPS (nicht `navigator.onLine`).
- Speicher = IndexedDB (Blobs); MapLibre-Source-Umschaltung online/offline.
## Abhängigkeiten
- GL-Tiles in **Produktion** (dach.pmtiles + fonts auf Prod-Volume) — Voraussetzung.
- pmtiles-Directory-Byte-Summierung (Server) + pmtiles.js Blob-Source (Client).
- WebGL-Kontext-Disziplin beachten (siehe Skill/Memory: jede GL-Karte beim Schließen `remove()`).
Siehe `docs/TILE_SERVER_HANDOVER.md` (Tile-Pipeline) + Memory `project_tile_server_maintenance`.
---
# Weitere Karten-To-Dos (nicht offline-spezifisch)
## Wetter-Chip: Niederschlag „nächste 3 Std" statt ganzer Tag — ✅ ERLEDIGT (2026-06-05, v1214)
Umgesetzt: `weather.py get_weather_for_location``precip = max(h_precip[now_h:now_h+3])` (Fallback Tages-Max);
Pill `map.js` zeigt `💧 X% (3h)`. Gilt auch fürs Welten-Banner (geteiltes Feld). Hinweis: forecast_days=1 →
Slice am Tagesende kürzer (späte Nacht weniger Vorausschau); für volle Mitternachts-Abdeckung forecast_days=2.
(Original-Notiz:)
**Ist:** Der Karten-Chip unten zeigt die Regenwahrscheinlichkeit als **Tages-Maximum**
(`backend/weather.py:98``precip = daily['precipitation_probability_max'][0]`; angezeigt in
`pages/map.js:~2598` als `💧 {w.precip_prob}%`). Über den ganzen Tag gemittelt/maximiert = wenig
aussagekräftig für „soll ich JETZT raus".
**Soll:** Den **höchsten Wert der nächsten 3 Stunden** (ab aktueller Stunde) zeigen.
- Die stündlichen Daten werden in `weather.py` bereits geladen (`&hourly=precipitation_probability`,
Array `h_precip` ab Zeile ~116) — kein neuer API-Call nötig.
- Ändern: `precip_prob = max(h_precip[now_idx : now_idx+3])` (aktuellen Stundenindex bestimmen wie bei
der bestehenden `next_rain_time`-Logik). `next_rain_time`/Warnungen können bleiben.
- Optional Chip-Text klarstellen, dass sich der Wert auf die nächsten 3 h bezieht (z.B. `💧 {x}% (3h)`).

View file

@ -1,189 +0,0 @@
# Übergabe: Selbst-gehosteter Tile-Server (Karte + Touren auf eigenen Vektortiles)
> **An den Kollegen, der das hier im `banyaro`-Repo weiterbaut.**
> Geschrieben aus dem `banyaro-ios`-Kontext heraus, nachdem der App-Store-Resubmit
> (Build 1.0(5)) raus war. Dies ist der **Vorbereitungs- + Staging-Test-Plan**. Bitte
> erst „Kontext & Entscheidung" lesen, dann den Staging-Spike ausführen, dann die
> offenen Punkte mit René klären, bevor irgendwas nach Produktion geht.
---
## 1. Worum geht's
Wir wollen **Karten selbst hosten** (analog Pocket Earth), statt vom öffentlichen
OSM-Raster-Server zu ziehen. Damit bauen wir **Karte UND Touren** auf eigenen
Vektortiles auf — Web (PWA) und nativ (iOS).
**Warum überhaupt:**
- **Lizenz/Policy:** Die PWA nutzt aktuell `https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png`
(`backend/static/js/ui.js``Map.OSM_URL`, `offline-indicator.js`). Die
[OSMF-Tile-Usage-Policy](https://operations.osmfoundation.org/policies/tiles/)
verbietet heavy/kommerzielle Nutzung — auf Dauer kein tragfähiges Fundament.
- **Offline:** iOS soll Regionen offline vorhalten (Pocket-Earth-Modell). Mit eigenen
Tiles ist das sauber machbar, mit dem öffentlichen Raster-Server nicht.
- **Kontrolle & Konsistenz:** Eine Tile-/Style-Quelle für Web + iOS, kein
Drittanbieter-Limit, Retina/Vektor-Styling, eigenes Karten-Design möglich.
- **Companion-Prinzip:** Substanz lebt auf banyaro.app — die Tile-Infrastruktur gehört
genau hierher (DS/Docker/NPM/Staging sind alle in diesem Repo). Die iOS-App ist nur
das native Fenster.
**Bewusst NICHT Teil des laufenden App-Store-Resubmits** — das ist eigener Scope nach
der Freigabe (andere Risikoklasse: Infra/Ops, Heim-Uplink-Verfügbarkeit; kein offener
Apple-Punkt). Siehe iOS-Memory `project_app_review_build3` / `project_build4_karte`.
---
## 2. Ist-Zustand (im Repo verifiziert)
- **Karte Web:** Leaflet + `leaflet.markercluster`, Raster-Basemap vom öffentlichen
OSM-Server. Helper in `backend/static/js/ui.js` (`Map.OSM_URL`, `L.tileLayer(...)`),
weitere Vorkommen in `ui.js:1006` und `offline-indicator.js:193`.
- **Karte iOS:** aktuell Apple **MapKit** (im `banyaro-ios`-Repo). Soll später auf
MapLibre Native + Offline-Regionen umgestellt werden — **separater iOS-Workstream**,
dieser Tile-Server ist die Voraussetzung dafür.
- **Deployment:** ein Docker-Service `banyaro` (FastAPI :8000 → DS :3010) hinter
**NPM (Nginx Proxy Manager)**. Static-Files via FastAPI-`StaticFiles`-Mounts
(`/css`, `/js`, `/icons`, `/img` in `backend/main.py:369372`). Volume `./data:/data`.
- **Staging:** eigener Container `banyaro-staging`, `docker-compose.staging.yml`,
Pfad `/volume1/docker/banyaro-staging`, URL **https://staging.banyaro.app**,
Deploy via `make staging` (pusht `develop`).
- **DS-Zugang:** Host `ds` (10.47.11.10, SSH-Port 4711), `sudo docker`.
- **Tile-Pipeline:** existiert noch **nicht** (keine pbf/mbtiles/pmtiles im Repo).
Es gibt konzeptionell validierte OSM-POI-Pipeline-Notizen (iOS-Memory
`project_build4_pois`) — separat von den Basemap-Tiles, aber gleiche pbf-Quelle.
---
## 3. Empfohlene Architektur: **planetiler → PMTiles → MapLibre**
Für einen **Heim-Server (DiskStation)** mit **Offline-Anspruch** ist das die klar beste
Wahl — leichter als ein klassischer Raster-Renderer und ohne laufenden Render-Prozess:
```
OSM-Extract (.osm.pbf, Geofabrik)
│ planetiler (OpenMapTiles-Schema, einmalig + bei Updates)
region.pmtiles ← EIN Single-File-Tile-Archiv (Vektor)
│ per HTTP Range-Requests ausgeliefert (nginx/FastAPI StaticFiles → 206)
MapLibre ── Web: MapLibre GL JS + pmtiles-Protokoll (ersetzt Leaflet-Raster)
└─ iOS: MapLibre Native, Offline = .pmtiles lokal auf dem Gerät
+ Style-JSON + Glyphs (Fonts) + Sprite (statisch gehostet)
+ Touren = GeoJSON-Linien-Layer obendrauf (Basemap-unabhängig)
```
**Warum PMTiles (und nicht ein Raster-Tile-Server):**
- **Kein Server-Prozess, kein PostGIS, kein renderd/Mapnik.** Eine Datei, ausgeliefert
per Range-Request — die DS muss zur Laufzeit **nichts rendern**. Ideal für schwache
Hardware + Heim-Uplink.
- **Offline trivial:** dieselbe `.pmtiles` lokal auf dem iPhone → MapLibre liest direkt.
Das ist das Pocket-Earth-Modell.
- **Vektor:** Retina-scharf, eigenes Styling, Labels drehbar, klein.
**Verworfen — `openstreetmap-tile-server` / renderd+Mapnik (Raster):** schwerer
PostGIS-Import, CPU-Rendering pro Request, große Storage, schlechte Offline-Story,
kein Vektor-Styling. Für unseren Fall strikt unterlegen.
**Stack-Komponenten:**
| Teil | Tool | Hinweis |
|------|------|---------|
| Tile-Generierung | [planetiler](https://github.com/onthegomap/planetiler) (Docker) | OpenMapTiles-Schema, Output direkt `.pmtiles` |
| Auslieferung | nginx/NPM **oder** FastAPI `StaticFiles` | beide können Range-Requests (206); für Spike reicht StaticFiles |
| Web-Client | `maplibre-gl` + `pmtiles` (JS) | ersetzt Leaflet schrittweise (Feature-Flag) |
| iOS-Client | MapLibre Native (eigener Workstream) | Offline-Region = lokale `.pmtiles` |
| Style | OpenMapTiles-kompatibel (z. B. Positron/OSM-Bright) + Glyphs + Sprite | selbst hosten für volle Unabhängigkeit |
---
## 4. Staging-Spike (konkret, zum Loslegen)
Ziel: eine kleine Region als `.pmtiles` erzeugen, auf **staging** ausliefern, mit
MapLibre rendern — und Größe/Zeit/Performance/Range-Requests messen. **Erst klein**
(Bayern), nicht gleich DACH.
### 4.1 Extract + Tiles erzeugen (lokal/Build-Maschine, NICHT auf der DS)
```bash
mkdir -p tiles/build && cd tiles/build
# planetiler lädt den Geofabrik-Extract selbst und schreibt direkt PMTiles:
docker run --rm -v "$PWD:/data" ghcr.io/onthegomap/planetiler:latest \
--download --area=bayern --output=/data/bayern.pmtiles
# Ergebnis: bayern.pmtiles (grobe Schätzung: paar hundert MB, wenige Minuten)
```
> DACH gibt es bei Geofabrik nicht als ein Extract → später `germany` + `austria` +
> `switzerland` per `osmium merge` zusammenführen, dann planetiler darauf. Für den
> Spike reicht `bayern`.
### 4.2 Auf Staging ausliefern (einfachster Weg: FastAPI StaticFiles vom data-Volume)
Die große Datei NICHT ins Image bauen — ins `./data`-Volume legen und mounten.
```python
# backend/main.py — nahe der bestehenden StaticFiles-Mounts (Z. 369ff)
import os
_TILES_DIR = os.getenv("TILES_DIR", "/data/tiles")
if os.path.isdir(_TILES_DIR):
app.mount("/tiles", StaticFiles(directory=_TILES_DIR), name="tiles")
```
- `bayern.pmtiles` nach `/volume1/docker/banyaro-staging/data/tiles/` kopieren
(scp zur DS), dann `make staging`.
- Starlette `FileResponse` beherrscht Range-Requests → MapLibre/pmtiles bekommt 206.
- **Verifizieren:** `curl -I -H "Range: bytes=0-1023" https://staging.banyaro.app/tiles/bayern.pmtiles`
muss **HTTP/1.1 206 Partial Content** + `Accept-Ranges: bytes` liefern. Prüfen, dass
NPM die Range-Header nicht verschluckt.
### 4.3 MapLibre-Testseite (Feature-Flag, Leaflet bleibt Fallback)
- `maplibre-gl` + `pmtiles` einbinden, Protokoll registrieren:
```js
const p = new pmtiles.Protocol();
maplibregl.addProtocol('pmtiles', p.tile);
// source: { type:'vector', url:'pmtiles://https://staging.banyaro.app/tiles/bayern.pmtiles' }
```
- Style-JSON (OpenMapTiles-Schema) + Glyphs + Sprite hosten (z. B. unter `/tiles/style/`).
Fertige freie Styles: OSM-Bright / Positron (maputnik-kompatibel). Glyphs z. B. aus
`openmaptiles/fonts`. Für den Spike darf der Style minimal sein.
- Touren-Polyline als GeoJSON-`line`-Layer auf die Map legen (ist basemap-unabhängig —
sobald MapLibre läuft, ist die Tour nur noch ein Layer).
### 4.4 Messen & festhalten
- Dateigröße + Generierungszeit (Bayern, dann hochrechnen auf DACH).
- Render-Performance über den Heim-Uplink (erste Zoomstufen, Labels).
- DS-Storage-Budget (`/volume1/...` frei?).
- Range-Requests durch NPM ok? CORS nötig (falls Tiles auf anderer Subdomain als App)?
---
## 5. Offene Entscheidungen (mit René klären)
1. **Region-Scope:** DACH am Stück vs. Deutschland-only vs. **herunterladbare
Regionen** (für iOS-Offline granular). DACH-PMTiles grob ~23 GB (verifizieren).
2. **Hosting-Pfad:** Pfad (`/tiles` an der App) vs. eigene Subdomain
(`tiles.banyaro.app`) hinter NPM. Subdomain = sauberer cachebar, aber CORS-Setup.
Für Skalierung ggf. nginx direkt statt FastAPI StaticFiles (kein App-CPU).
3. **Style:** welcher Basis-Style (Positron/OSM-Bright/eigenes Hunde-Theme), Glyphs/Sprite
selbst hosten.
4. **Update-Kadenz:** wie oft aus frischem OSM-Extract neu generieren (monatlich?).
planetiler-Rerun + Datei tauschen (atomar). Cron/Make-Target dafür.
5. **iOS-Workstream:** MapLibre Native + Offline-Region-Download ersetzt MapKit —
eigener Build-4-Task im `banyaro-ios`-Repo, **nach** dieser Infra.
6. **Attribution (Pflicht):** „© OpenStreetMap contributors" (ODbL) muss in Web **und**
iOS sichtbar sein. Bei eigenem Style ggf. zusätzliche Datenquellen-Hinweise.
---
## 6. Vorgeschlagene Reihenfolge
1. Staging-Spike (Abschnitt 4) mit **Bayern** — Proof of Concept, Zahlen sammeln.
2. Entscheidungen aus Abschnitt 5 mit René.
3. DACH-PMTiles generieren (osmium merge → planetiler), Update-Make-Target.
4. PWA: MapLibre hinter Feature-Flag produktiv, Leaflet-Pfad rausnehmen wenn stabil.
5. iOS (separat): MapLibre Native + Offline-Regionen, MapKit ablösen.
---
## 7. Querverweise
- iOS-Memory: `project_build4_karte` (Entscheidung OSM/MapLibre/Offline),
`project_build4_pois` (POI-Pipeline aus pbf — gleiche Datenquelle),
`project_osm_contribution` (OSM-Beitragskreislauf, gehört in die PWA),
`project_companion` (Companion-Prinzip), `project_app_review_build3` (warum nicht im Resubmit).
- NPM/IPv6/Reverse-Proxy-Stolpersteine auf der DS: Skill `synology-troubleshooting`.
- Deploy: `make staging` (→ staging.banyaro.app), `make deploy` (→ Produktion, deployt
den **Arbeitsbaum**). `make bump` NUR bei Frontend-Asset-Änderungen (SW-Cache).

View file

@ -1,61 +0,0 @@
"""
Account-Löschung (DSGVO + App-Store-Gl. 4): muss FK-sicher ALLE Daten entfernen,
auch wenn der User Zeilen in Tabellen ohne ON DELETE CASCADE hat (routes, places,
walks, events, forum_threads, ). Regressionstest gegen den alten, FK-unvollständigen
Delete, der am finalen `DELETE FROM users` scheiterte, sobald solche Zeilen existierten.
"""
import secrets
def _make_user(client):
from database import db
email = f"del-{secrets.token_hex(4)}@example.com"
pw, name = "TestPass123!", f"deltest{secrets.token_hex(3)}"
r = client.post("/api/auth/register", json={"email": email, "password": pw, "name": name})
assert r.status_code == 200, r.text
with db() as conn:
conn.execute("UPDATE users SET email_verified=1 WHERE email=?", (email,))
uid = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone()["id"]
token = client.post("/api/auth/login", json={"email": email, "password": pw}).json()["token"]
return uid, {"Authorization": f"Bearer {token}"}
def test_delete_account_with_noncascade_data(client):
from database import db
uid, headers = _make_user(client)
dog_id = client.post("/api/dogs", headers=headers,
json={"name": "Rex", "rasse": "Mix", "is_public": False}).json()["id"]
# Direkt Zeilen in den Tabellen anlegen, die users(id) OHNE Cascade referenzieren —
# genau die, die den alten Delete blockiert haben.
with db() as conn:
conn.execute("INSERT INTO routes (user_id, name, gps_track) VALUES (?,?,?)",
(uid, "Testrunde", "[]"))
conn.execute("INSERT INTO places (user_id, name, typ, lat, lon) VALUES (?,?,?,?,?)",
(uid, "Hundewiese", "freilauf", 52.5, 13.4))
conn.execute("INSERT INTO walks (user_id, titel, datum, uhrzeit, lat, lon) VALUES (?,?,?,?,?,?)",
(uid, "Gassi-Treff", "2026-07-01", "18:00", 52.5, 13.4))
conn.execute("INSERT INTO events (user_id, titel, datum) VALUES (?,?,?)",
(uid, "Hundewanderung", "2026-07-02"))
conn.execute("INSERT INTO forum_threads (user_id, titel) VALUES (?,?)",
(uid, "Hallo Forum"))
resp = client.delete("/api/profile/account", headers=headers)
assert resp.status_code == 200, f"Delete failed: {resp.status_code} {resp.text}"
assert resp.json()["status"] == "deleted"
with db() as conn:
assert conn.execute("SELECT 1 FROM users WHERE id=?", (uid,)).fetchone() is None
for tbl in ("routes", "places", "walks", "events", "forum_threads", "dogs"):
cnt = conn.execute(f"SELECT COUNT(*) c FROM {tbl} WHERE user_id=?", (uid,)).fetchone()["c"]
assert cnt == 0, f"{tbl} hat noch {cnt} Zeile(n) nach Account-Löschung"
def test_delete_account_minimal_user(client):
"""Auch ein User ganz ohne Zusatzdaten lässt sich löschen."""
from database import db
uid, headers = _make_user(client)
resp = client.delete("/api/profile/account", headers=headers)
assert resp.status_code == 200, resp.text
with db() as conn:
assert conn.execute("SELECT 1 FROM users WHERE id=?", (uid,)).fetchone() is None

View file

@ -1,60 +0,0 @@
#!/bin/bash
# Ein Fortschritts-Snapshot für den Tile-Build. Gibt EINE Zeile aus:
# STAGE=<1|2|3|4|DONE> PCT=<n> LABEL=<menschlich, mit Balken + ETA>
B=tiles/build
LOG=tiles/build.log
EXP_DL=$((24 * 1024 * 1024 * 1024)) # ~24 GB erwartete Quell-/Merge-Summe (15 Länder)
EXP_PLAN_SEC=$((90 * 60)) # ~90 Min planetiler-Schätzung (Mittel-Europa)
now=$(date +%s)
_bytes() { local s=0 f; for f in "$@"; do [ -f "$f" ] && s=$((s + $(stat -f%z "$f" 2>/dev/null || echo 0))); done; echo "$s"; }
_birth() { if [ -f "$1" ]; then stat -f%B "$1" 2>/dev/null || echo "$now"; else echo "$now"; fi; }
_mtime() { if [ -f "$1" ]; then stat -f%m "$1" 2>/dev/null || echo "$now"; else echo "$now"; fi; }
_bar() { local p=$1 n i out=""; n=$((p * 20 / 100)); [ $n -gt 20 ] && n=20; [ $n -lt 0 ] && n=0
for ((i=0;i<20;i++)); do [ $i -lt $n ] && out+="█" || out+="░"; done; printf "%s" "$out"; }
_eta() { local s=$1; [ "$s" -lt 0 ] 2>/dev/null && s=0
if [ "$s" -ge 3600 ]; then printf "~%dh %dm" $((s/3600)) $(((s%3600)/60))
elif [ "$s" -ge 60 ]; then printf "~%d Min" $((s/60)); else printf "~%ds" "$s"; fi; }
_gb() { awk "BEGIN{printf \"%.1f\", $1/1073741824}"; }
if grep -q "Tiles gebaut" "$LOG" 2>/dev/null; then
sz=$(_bytes "$B/dach.pmtiles"); echo "STAGE=DONE PCT=100 LABEL=Fertig — dach.pmtiles $(_gb $sz) GB"; exit 0
fi
if grep -q "→ planetiler" "$LOG" 2>/dev/null; then
# Echte planetiler-Zeile parsen (ANSI/\r entfernen). Phasen: osm_pass1(nodes)→osm_pass2(ways/rels)→
# write/archive(tiles). Eine ehrliche Gesamt-% gibt planetiler nicht her → Phase + Phasen-% zeigen,
# KEINE erfundene Zeit-ETA (war zuvor Quatsch wegen Log-Rauschen).
line=$(sed 's/\x1b\[[0-9;]*m//g; s/\r/\n/g' "$LOG" 2>/dev/null | grep -aE "^[0-9]+:[0-9]{2}:[0-9]{2} .*INF \[" | tail -1)
phase=$(printf '%s' "$line" | sed -nE 's/.*INF \[([a-z0-9_]+)[]:].*/\1/p')
if printf '%s' "$line" | grep -q "ways:"; then
pct=$(printf '%s' "$line" | sed -nE 's/.*ways: \[[^]]*[[:space:]]([0-9]{1,3})%[[:space:]].*/\1/p')
else
pct=$(printf '%s' "$line" | grep -oE "[0-9]{1,3}%" | tail -1 | tr -d '%')
fi
[ -z "$pct" ] && pct=0
echo "STAGE=4 PCT=$pct LABEL=planetiler · ${phase:-läuft} $(_bar $pct) ${pct}% (Phasen-Fortschritt, Gesamt-ETA unsicher)"; exit 0
fi
if [ -f "$B/dach.osm.pbf" ]; then
b=$(_bytes "$B/dach.osm.pbf"); start=$(_birth "$B/dach.osm.pbf"); el=$((now - start)); [ $el -lt 1 ] && el=1
pct=$((b * 100 / EXP_DL)); [ $pct -gt 99 ] && pct=99
rate=$((b / el)); [ $rate -lt 1 ] && rate=1; eta=$(((EXP_DL - b) / rate))
echo "STAGE=3 PCT=$pct LABEL=osmium time-filter (dedup) $(_bar $pct) ${pct}% · $(_gb $b) GB · ETA $(_eta $eta)"; exit 0
fi
if [ -f "$B/dach-hist.osm.pbf" ]; then
b=$(_bytes "$B/dach-hist.osm.pbf"); start=$(_birth "$B/dach-hist.osm.pbf"); el=$((now - start)); [ $el -lt 1 ] && el=1
pct=$((b * 100 / EXP_DL)); [ $pct -gt 99 ] && pct=99
rate=$((b / el)); [ $rate -lt 1 ] && rate=1; eta=$(((EXP_DL - b) / rate))
echo "STAGE=2 PCT=$pct LABEL=osmium merge (Grenz-Nodes) $(_bar $pct) ${pct}% · $(_gb $b) GB · ETA $(_eta $eta)"; exit 0
fi
# Stufe 1: Download
files=$(ls "$B"/*.osm.pbf 2>/dev/null | grep -v dach)
b=$(_bytes $files); start=$(_birth "$LOG"); el=$((now - start)); [ $el -lt 1 ] && el=1
pct=$((b * 100 / EXP_DL)); [ $pct -gt 99 ] && pct=99
rate=$((b / el)); [ $rate -lt 1 ] && rate=1; eta=$(((EXP_DL - b) / rate))
ndone=$(echo "$files" | grep -c .)
cur=$(grep -E "^ [a-z]" "$LOG" 2>/dev/null | tail -1 | tr -d ' ')
echo "STAGE=1 PCT=$pct LABEL=Download $(_bar $pct) ${pct}% · ${ndone}/15 Länder (${cur}) · $(_gb $b)/24 GB · ETA $(_eta $eta)"