Compare commits
78 commits
f5b5bb2289
...
72ee339860
| Author | SHA1 | Date | |
|---|---|---|---|
| 72ee339860 | |||
| ac187dc740 | |||
| ea2cdd4f89 | |||
| 22b8ccb784 | |||
| bcbf9a9645 | |||
| aefdac87ad | |||
| fd6be50762 | |||
| 5337ddfa05 | |||
| 8f13f4d38d | |||
| 2a809a9a0b | |||
| 80b56c32ab | |||
| f38301a391 | |||
| 27e7590eed | |||
| d1e44ebfb9 | |||
| daa44946f1 | |||
| 827ea95191 | |||
| 43b1e8026f | |||
| 29076bcdff | |||
| d11794355c | |||
| c7201aa07b | |||
| da6451a1c7 | |||
| 720971d252 | |||
| d203ab17a8 | |||
| 285928f6f7 | |||
| 1defeec537 | |||
| a0d16ba800 | |||
| d96fa9e24e | |||
| 27a3f954a4 | |||
| fbaf7c5409 | |||
| 96119e02ef | |||
| 5844f1ef51 | |||
| 9c4b999331 | |||
| eaf7801e6b | |||
| 04b2d8aeb8 | |||
| cc1fdb00b1 | |||
| fc9cac410c | |||
| 3523a44a0b | |||
| 425f99effb | |||
| 9c959dd632 | |||
| 4d0cd0f460 | |||
| 980338d7f1 | |||
| d447de2b8d | |||
| 2ccf75e076 | |||
| 2d7eca16a7 | |||
| ef16ec92ba | |||
| 542106e77b | |||
| 11922c1d22 | |||
| 63c9be68c6 | |||
| a27695d9c6 | |||
| 5e354f7e8e | |||
| 7d761bb342 | |||
| 1d64dc5d70 | |||
| 5cb7c3091d | |||
| b0fece16c8 | |||
| 736c326635 | |||
| 647aa684db | |||
| b2262a8e86 | |||
| 9006c85434 | |||
| 2b5afcf0ae | |||
| a561759034 | |||
| e5a2953a80 | |||
| bdadde8b98 | |||
| d9ecdb15fb | |||
| cde019cacf | |||
| 545b57c723 | |||
| 1448782564 | |||
| 258ccf84ee | |||
| cca9a9c70f | |||
| 0e77c04eee | |||
| 3513aeadb0 | |||
| 7945087a6c | |||
| 78866206b4 | |||
| ddfb9474ef | |||
| 959fd81a9b | |||
| c07b1cc01b | |||
| 2ddd8ac350 | |||
| 152fde716c | |||
| 55b354e865 |
77 changed files with 5861 additions and 1247 deletions
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -13,3 +13,11 @@ __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
|
||||
|
|
|
|||
53
Makefile
53
Makefile
|
|
@ -24,10 +24,11 @@ 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
|
||||
logs logs-f shell db dev clean-cache check-ssh reports bump test tiles tiles-deploy
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# SSH-Prüfung — Abhängigkeit aller DS-Befehle
|
||||
|
|
@ -139,6 +140,56 @@ 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
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1161
|
||||
1219
|
||||
|
|
@ -111,6 +111,7 @@ 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:; "
|
||||
|
|
@ -371,6 +372,100 @@ 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)
|
||||
|
|
|
|||
|
|
@ -166,8 +166,22 @@ 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):
|
||||
"""Cooldown, Stunden-Limit und Duplikat-Check für Forum-Posts."""
|
||||
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()
|
||||
|
||||
# 30-Sekunden-Cooldown zwischen beliebigen Posts
|
||||
last = conn.execute(
|
||||
"""SELECT MAX(created_at) AS last FROM (
|
||||
|
|
@ -179,25 +193,25 @@ def _check_post_limits(user_id: int, conn, text: str, user_created_at: str | Non
|
|||
).fetchone()["last"]
|
||||
if last:
|
||||
try:
|
||||
from datetime import datetime as _dt
|
||||
diff = (_dt.utcnow() - _dt.fromisoformat(last)).total_seconds()
|
||||
if diff < 30:
|
||||
diff = (now_dt - _dt.fromisoformat(last)).total_seconds()
|
||||
if 0 <= diff < 30:
|
||||
raise HTTPException(429, "Bitte warte einen Moment bevor du erneut postest.")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Stunden-Limit
|
||||
# Stunden-Limit (gleiche Zeitbasis wie created_at)
|
||||
hour_ago = (now_dt - _td(hours=1)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
if is_thread:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > datetime('now','-1 hour')",
|
||||
(user_id,),
|
||||
"SELECT COUNT(*) FROM forum_threads WHERE user_id=? AND created_at > ?",
|
||||
(user_id, hour_ago),
|
||||
).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 > datetime('now','-1 hour')",
|
||||
(user_id,),
|
||||
"SELECT COUNT(*) FROM forum_posts WHERE user_id=? AND created_at > ?",
|
||||
(user_id, hour_ago),
|
||||
).fetchone()[0]
|
||||
if count >= 20:
|
||||
raise HTTPException(429, "Du hast in dieser Stunde bereits 20 Antworten geschrieben. Bitte warte etwas.")
|
||||
|
|
@ -223,8 +237,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 (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
|
|
@ -370,9 +384,9 @@ async def create_post(thread_id: int, data: PostCreate, user=Depends(get_current
|
|||
if thread['is_deleted']:
|
||||
raise HTTPException(404, "Thread nicht gefunden.")
|
||||
|
||||
_check_post_limits(user["id"], conn, data.text.strip(), user.get("created_at"), is_thread=False)
|
||||
|
||||
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)
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO forum_posts (thread_id, user_id, text, created_at) VALUES (?, ?, ?, ?)",
|
||||
(thread_id, user['id'], data.text.strip(), ct)
|
||||
|
|
|
|||
|
|
@ -149,25 +149,74 @@ 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."""
|
||||
"""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.
|
||||
"""
|
||||
uid = user['id']
|
||||
with db() as conn:
|
||||
# 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,))
|
||||
# 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.
|
||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
|
|
|||
|
|
@ -2514,115 +2514,6 @@ 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)
|
||||
============================================================ */
|
||||
|
|
@ -3132,6 +3023,16 @@ 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;
|
||||
|
|
@ -3151,6 +3052,51 @@ 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;
|
||||
|
|
@ -6853,15 +6799,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -63,3 +63,14 @@
|
|||
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; }
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@
|
|||
<title>Ban Yaro</title>
|
||||
|
||||
<!-- Theme + theme-color Statusleiste vor CSS setzen -->
|
||||
<script src="/js/boot-early.js?v=1161"></script>
|
||||
<script src="/js/boot-early.js?v=1219"></script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<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">
|
||||
<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">
|
||||
</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=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>
|
||||
<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>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
@ -631,7 +631,7 @@
|
|||
|
||||
|
||||
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
|
||||
<script src="/js/boot.js?v=1161"></script>
|
||||
<script src="/js/boot.js?v=1219"></script>
|
||||
|
||||
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '1161'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '1219'; // ← 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,7 +137,8 @@ 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;
|
||||
if (!modalOpen && !cooldownActive) {
|
||||
// Während einer laufenden Aufzeichnung NIE force-updaten (Datenverlust).
|
||||
if (!modalOpen && !cooldownActive && !window._byRecording) {
|
||||
window._byUpdatePending = false;
|
||||
sessionStorage.setItem('by_updated_to', window._byNewVersion || '');
|
||||
sessionStorage.setItem('by_update_target', pageId);
|
||||
|
|
@ -294,16 +295,27 @@ const App = (() => {
|
|||
});
|
||||
page.module = {}; // verhindert erneutes Laden
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// Echten Fehler NICHT verschlucken — sonst rätselt man bei jedem Seiten-Crash
|
||||
console.error(`[page-load] ${pageId} init fehlgeschlagen:`, err);
|
||||
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.'
|
||||
: 'Diese Seite ist noch in Entwicklung.',
|
||||
: '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>`,
|
||||
});
|
||||
page.module = {};
|
||||
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.
|
||||
} finally {
|
||||
page._loading = false;
|
||||
}
|
||||
|
|
@ -434,6 +446,62 @@ 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]');
|
||||
|
|
@ -443,6 +511,20 @@ 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');
|
||||
|
|
@ -1045,11 +1127,15 @@ const App = (() => {
|
|||
sessionStorage.setItem('by_stay_in_app', '1');
|
||||
}
|
||||
|
||||
// Referral-Code aus URL ?ref=CODE speichern
|
||||
// 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)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const refCode = urlParams.get('ref');
|
||||
if (refCode) {
|
||||
sessionStorage.setItem('by_ref_code', refCode.toUpperCase());
|
||||
try {
|
||||
localStorage.setItem('by_ref_code', refCode.toUpperCase());
|
||||
localStorage.setItem('by_ref_code_ts', String(Date.now()));
|
||||
} catch {}
|
||||
// URL bereinigen ohne Reload
|
||||
history.replaceState({}, '', window.location.pathname + window.location.hash);
|
||||
}
|
||||
|
|
@ -1164,7 +1250,7 @@ const App = (() => {
|
|||
icon: UI.icon(icon),
|
||||
title: 'Anmelden erforderlich',
|
||||
text,
|
||||
action: `<button class="btn btn-primary" onclick="App.navigate('settings')">Anmelden</button>`,
|
||||
action: `<button class="btn btn-primary" data-page="settings">Anmelden</button>`,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,29 @@
|
|||
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
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -46,6 +69,38 @@
|
|||
_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
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -56,13 +111,7 @@ if ('serviceWorker' in navigator) {
|
|||
function _watchSW(sw) {
|
||||
if (!sw) return;
|
||||
sw.addEventListener('statechange', function() {
|
||||
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());
|
||||
}
|
||||
if (sw.state === 'activated') _bySwReload();
|
||||
});
|
||||
}
|
||||
reg.addEventListener('updatefound', function() { _watchSW(reg.installing); });
|
||||
|
|
@ -83,11 +132,7 @@ if ('serviceWorker' in navigator) {
|
|||
// NICHT registrieren wenn diese Seite selbst durch SW-Reload entstand
|
||||
if (!window._BY_SW_RELOAD) {
|
||||
navigator.serviceWorker.addEventListener('controllerchange', function() {
|
||||
if (sessionStorage.getItem('by_skip_sw_reload')) {
|
||||
sessionStorage.removeItem('by_skip_sw_reload');
|
||||
return;
|
||||
}
|
||||
window.location.replace('/?_t=' + Date.now());
|
||||
_bySwReload();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
24
backend/static/js/leaflet-vector-test.js
Normal file
24
backend/static/js/leaflet-vector-test.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// 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);
|
||||
}
|
||||
})();
|
||||
224
backend/static/js/map-gl-markers.js
Normal file
224
backend/static/js/map-gl-markers.js
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// 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;
|
||||
})();
|
||||
290
backend/static/js/map-gl-mini.js
Normal file
290
backend/static/js/map-gl-mini.js
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
// 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; } };
|
||||
},
|
||||
};
|
||||
})();
|
||||
176
backend/static/js/map-gl-style.js
Normal file
176
backend/static/js/map-gl-style.js
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
// 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 };
|
||||
})();
|
||||
141
backend/static/js/map-offline.js
Normal file
141
backend/static/js/map-offline.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/* ============================================================
|
||||
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,
|
||||
};
|
||||
})();
|
||||
92
backend/static/js/map-vector.js
Normal file
92
backend/static/js/map-vector.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// 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,
|
||||
};
|
||||
})();
|
||||
64
backend/static/js/maplibre-markers-test.js
Normal file
64
backend/static/js/maplibre-markers-test.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// 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));
|
||||
}
|
||||
})();
|
||||
70
backend/static/js/maplibre-perf-test.js
Normal file
70
backend/static/js/maplibre-perf-test.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// 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));
|
||||
}
|
||||
})();
|
||||
86
backend/static/js/maplibre-test.js
Normal file
86
backend/static/js/maplibre-test.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// 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);
|
||||
});
|
||||
})();
|
||||
|
|
@ -20,6 +20,34 @@ 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',
|
||||
|
|
@ -69,8 +97,10 @@ window.OfflineIndicator = (() => {
|
|||
} },
|
||||
|
||||
{ step: 5, title: 'Karten-Kacheln',
|
||||
detail: `Mindestens ${TILE_MIN} OSM-Tiles im Umkreis`,
|
||||
detail: 'Karten für deine Gegend offline verfügbar',
|
||||
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;
|
||||
|
|
@ -169,12 +199,30 @@ window.OfflineIndicator = (() => {
|
|||
tasks.push(fetch('/api/routes').catch(() => {}));
|
||||
tasks.push(fetch('/api/notes').catch(() => {}));
|
||||
} else if (m.step === 5) {
|
||||
await _prefetchTiles();
|
||||
if (_offlineTilesMode()) await _downloadOfflineRegion();
|
||||
else 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')
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -2628,9 +2628,7 @@ window.Page_admin = (() => {
|
|||
</thead>
|
||||
<tbody>
|
||||
${log.map((l, i) => `
|
||||
<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=''">
|
||||
<tr data-log-idx="${i}" class="by-hover-surface2" style="border-bottom:1px solid var(--c-border);cursor:pointer">
|
||||
<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>
|
||||
|
|
@ -2661,7 +2659,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" onclick="UI.modal.close()">Schließen</button>`,
|
||||
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -2760,7 +2758,7 @@ window.Page_admin = (() => {
|
|||
</div>
|
||||
</form>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
<button class="btn btn-primary" form="${id}" type="submit">Speichern</button>`,
|
||||
});
|
||||
|
||||
|
|
@ -2993,7 +2991,7 @@ window.Page_admin = (() => {
|
|||
</div>
|
||||
</div>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
|
||||
<button class="btn btn-secondary" data-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 () => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
onerror="this.parentElement.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2rem">🐶</div>'">`
|
||||
data-fb="emoji" data-fb-emoji="🐶" data-fb-size="2rem">`
|
||||
: '<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,13 +366,10 @@ window.Page_adoption = (() => {
|
|||
const tierheim = a.tierheim || '';
|
||||
|
||||
return `
|
||||
<div data-adp-url="${UI.escape(a.adoptions_url)}"
|
||||
<div data-adp-url="${UI.escape(a.adoptions_url)}" class="by-hover-lift"
|
||||
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);
|
||||
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)'">
|
||||
box-shadow:0 1px 4px rgba(0,0,0,0.08)">
|
||||
<div style="height:120px;overflow:hidden;background:var(--c-surface-3)">
|
||||
${foto}
|
||||
</div>
|
||||
|
|
@ -459,14 +456,11 @@ window.Page_adoption = (() => {
|
|||
|
||||
function _shelterRow(s) {
|
||||
return `
|
||||
<a href="${UI.escape(s.url)}" target="_blank" rel="noopener noreferrer"
|
||||
<a href="${UI.escape(s.url)}" target="_blank" rel="noopener noreferrer" class="by-hover-surface3"
|
||||
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);
|
||||
transition:background .15s"
|
||||
onmouseenter="this.style.background='var(--c-surface-3)'"
|
||||
onmouseleave="this.style.background='var(--c-surface-2)'">
|
||||
border:1px solid var(--c-border)">
|
||||
<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;
|
||||
|
|
@ -612,7 +606,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"
|
||||
onerror="this.parentElement.innerHTML='<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>'">`
|
||||
data-fb="emoji" data-fb-emoji="🐾" data-fb-size="2.5rem">`
|
||||
: '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:2.5rem">🐾</div>';
|
||||
|
||||
const isActive = !l.status || l.status === 'active';
|
||||
|
|
@ -780,7 +774,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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -879,7 +873,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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
onmouseenter="this.play()" onmouseleave="this.pause()"></video>
|
||||
data-hover-play></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>` : ''}
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
onerror="this.style.display='none'">`
|
||||
data-fb="hide">`
|
||||
: `<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"
|
||||
onerror="this.parentElement.style.display='none'">
|
||||
data-fb="hide-parent">
|
||||
${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"
|
||||
onerror="this.parentElement.style.display='none'">
|
||||
data-fb="hide-parent">
|
||||
</a>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,23 @@ 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(() => {
|
||||
|
|
@ -132,7 +149,7 @@ window.Page_chat = (() => {
|
|||
? `<span class="online-dot" title="Online"></span>`
|
||||
: '';
|
||||
return `
|
||||
<div class="chat-conv-item" onclick="Page_chat._openThread(${c.id})">
|
||||
<div class="chat-conv-item" data-chat-action="open" data-chat-id="${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>` : ''}
|
||||
|
|
@ -166,14 +183,14 @@ window.Page_chat = (() => {
|
|||
|
||||
// Aktive Markierung in der Liste
|
||||
document.querySelectorAll('.chat-conv-item').forEach(el =>
|
||||
el.classList.toggle('active', el.getAttribute('onclick')?.includes(String(convId)))
|
||||
el.classList.toggle('active', el.dataset.chatId === String(convId))
|
||||
);
|
||||
|
||||
const threadHTML = `
|
||||
<div class="chat-thread" id="chat-thread">
|
||||
<div class="chat-thread-header">
|
||||
${_isDesktop() ? '' : `
|
||||
<button class="btn btn-ghost btn-sm" onclick="Page_chat._showList()" style="padding:var(--space-1)">
|
||||
<button class="btn btn-ghost btn-sm" data-chat-action="list" 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">
|
||||
|
|
@ -188,14 +205,13 @@ window.Page_chat = (() => {
|
|||
</div>
|
||||
</div>
|
||||
<div class="chat-input-bar">
|
||||
<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">
|
||||
<input type="file" id="chat-photo-input" accept="image/*" class="hidden">
|
||||
<button class="chat-photo-btn" data-chat-action="photo" 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" onclick="Page_chat._send()">
|
||||
<button class="chat-send-btn" id="chat-send-btn" data-chat-action="send">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -219,9 +235,11 @@ window.Page_chat = (() => {
|
|||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
Page_chat._send();
|
||||
_send();
|
||||
}
|
||||
});
|
||||
document.getElementById('chat-photo-input')
|
||||
?.addEventListener('change', e => _onPhotoSelected(e.target));
|
||||
|
||||
await _loadMessages(true);
|
||||
await API.chat.markRead(_convId).catch(() => {});
|
||||
|
|
@ -313,7 +331,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)"
|
||||
onclick="Page_chat._deleteMsg(${m.id})" title="Löschen">
|
||||
data-chat-action="delete" data-chat-id="${m.id}" title="Löschen">
|
||||
<svg class="ph-icon" style="width:12px;height:12px"><use href="/icons/phosphor.svg#trash"></use></svg>
|
||||
</button>`
|
||||
: '';
|
||||
|
|
@ -328,7 +346,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" onclick="window.open('${UI.escape(m.media_url)}','_blank')">`;
|
||||
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)}">`;
|
||||
}
|
||||
if (m.text) {
|
||||
bubbleContent += (m.media_url ? `<div style="margin-top:var(--space-1)">` : '') +
|
||||
|
|
|
|||
|
|
@ -310,8 +310,10 @@ 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) _container.insertBefore(card, list);
|
||||
if (list && list.parentNode) list.parentNode.insertBefore(card, list);
|
||||
|
||||
card.querySelector('#diary-praise-close')?.addEventListener('click', () => {
|
||||
card.style.opacity = '0';
|
||||
|
|
@ -363,6 +365,13 @@ 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;
|
||||
|
|
@ -431,6 +440,7 @@ 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') {
|
||||
|
|
@ -470,16 +480,6 @@ 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,8 +488,23 @@ 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)]];
|
||||
|
||||
const map = L.map(mapEl, { zoomControl: true, attributionControl: false });
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
|
||||
// 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);
|
||||
});
|
||||
|
||||
// Marker für jeden Eintrag
|
||||
locations.forEach(loc => {
|
||||
|
|
@ -497,59 +512,36 @@ 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 icon = L.divIcon({
|
||||
html: hasPhoto
|
||||
const iconHtml = 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" onerror="this.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" data-fb-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>`,
|
||||
iconSize: hasPhoto ? [44, 44] : [32, 32],
|
||||
iconAnchor: hasPhoto ? [22, 22] : [16, 16],
|
||||
className: '',
|
||||
});
|
||||
</div>`;
|
||||
const _mSize = hasPhoto ? 44 : 32;
|
||||
|
||||
const marker = L.marker([loc.gps_lat, loc.gps_lon], { icon });
|
||||
marker.bindPopup(`
|
||||
UI.map.svgMarker(loc.gps_lat, loc.gps_lon, iconHtml, { size: _mSize, anchorY: _mSize / 2 })
|
||||
.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" onerror="this.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" data-fb-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 });
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
marker.addTo(map);
|
||||
</div>`, { maxWidth: 200 })
|
||||
.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);
|
||||
// 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);
|
||||
}
|
||||
|
||||
function _renderMediaGrid(content) {
|
||||
|
|
@ -569,7 +561,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"
|
||||
onerror="this.src='${UI.escape(m.url)}'">
|
||||
data-fb-src="${UI.escape(m.url)}">
|
||||
</div>`).join('')
|
||||
}</div>`;
|
||||
content.querySelectorAll('.diary-mosaic-item').forEach(el => {
|
||||
|
|
@ -619,7 +611,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" onerror="this.src='${UI.escape(entry.cover_url)}'">` : ''}
|
||||
${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)}">` : ''}
|
||||
<span class="diary-cal-day">${d}</span>
|
||||
</div>`);
|
||||
}
|
||||
|
|
@ -812,7 +804,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 ? `onerror="this.src='${UI.escape(e.cover_url)}'"` : ''}>
|
||||
${e.cover_url ? `data-fb-src="${UI.escape(e.cover_url)}"` : ''}>
|
||||
${mediaCount > 1 ? `<span class="diary-card-media-count">${mediaCount}</span>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -1113,8 +1105,7 @@ 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"
|
||||
onclick="event.stopPropagation()">
|
||||
? `<button id="diary-dv-note" class="btn btn-ghost btn-xs" title="Notiz">
|
||||
<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">
|
||||
|
|
@ -1155,26 +1146,18 @@ window.Page_diary = (() => {
|
|||
setTimeout(async () => {
|
||||
const mapEl = view.querySelector('#diary-dv-map');
|
||||
if (!mapEl) return;
|
||||
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 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">
|
||||
const map = await UI.map.create(mapEl, {
|
||||
center: [entry.gps_lat, entry.gps_lon], zoom: 15,
|
||||
zoomControl: true, attributionControl: false,
|
||||
});
|
||||
_diaryMaps.push(map);
|
||||
const iconHtml = `<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>`,
|
||||
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();
|
||||
</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);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
|
|
@ -1722,7 +1705,7 @@ window.Page_diary = (() => {
|
|||
<div id="import-result" style="display:none;margin-top:var(--space-4)"></div>`,
|
||||
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
|
||||
<button class="btn btn-secondary" data-modal-close>Schließen</button>
|
||||
<button class="btn btn-primary" id="import-start-btn">Importieren</button>`,
|
||||
});
|
||||
|
||||
|
|
@ -1812,6 +1795,8 @@ window.Page_diary = (() => {
|
|||
.trim();
|
||||
}
|
||||
|
||||
return { init, refresh, openNew, onDogChange, openDetail: _openDetail };
|
||||
function destroy() { _clearDiaryMaps(); }
|
||||
|
||||
return { init, refresh, openNew, onDogChange, openDetail: _openDetail, destroy };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary flex-1" data-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" onclick="UI.modal.close()">Schließen</button>
|
||||
<button class="btn btn-secondary" data-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" onclick="UI.modal.close()">Schließen</button>`,
|
||||
footer: `<button class="btn btn-secondary" data-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" onclick="UI.modal.close()">Schließen</button>
|
||||
<button class="btn btn-secondary" data-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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-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" onclick="UI.modal.close()">Schließen</button>`,
|
||||
footer: `<button class="btn btn-secondary" data-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 onclick="window._buchSetJahr('${y}')" style="
|
||||
return `<button data-buch-action="year" data-buch-year="${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 onclick="window._buchToggleFotos()" style="
|
||||
<button data-buch-action="fotos" 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 onclick="window._buchToggleMeilensteine()" style="
|
||||
<button data-buch-action="meilen" 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 onclick="window._buchOpen()" style="
|
||||
<button data-buch-action="open" 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 onclick="window._buchClose()" style="
|
||||
<button data-buch-action="close" style="
|
||||
background:#f0f0f0;color:#555;border:none;border-radius:10px;
|
||||
padding:14px 18px;font-size:1rem;cursor:pointer;font-family:inherit;
|
||||
">✕</button>
|
||||
|
|
@ -2268,18 +2268,14 @@ window.Page_dog_profile = (() => {
|
|||
`;
|
||||
};
|
||||
|
||||
window._buchSetJahr = (y) => { selectedJahr = y; renderModal(); };
|
||||
window._buchToggleFotos = () => { nurFotos = !nurFotos; renderModal(); };
|
||||
window._buchToggleMeilensteine = () => { nurMeilensteine = !nurMeilensteine; renderModal(); };
|
||||
window._buchClose = () => {
|
||||
const setJahr = (y) => { selectedJahr = y; renderModal(); };
|
||||
const toggleFotos = () => { nurFotos = !nurFotos; renderModal(); };
|
||||
const toggleMeilen = () => { nurMeilensteine = !nurMeilensteine; renderModal(); };
|
||||
const closeModal = () => {
|
||||
modalEl.remove();
|
||||
delete window._buchSetJahr;
|
||||
delete window._buchToggleFotos;
|
||||
delete window._buchToggleMeilensteine;
|
||||
delete window._buchOpen;
|
||||
delete window._buchClose;
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
window._buchOpen = () => {
|
||||
const openBuch = () => {
|
||||
const params = new URLSearchParams();
|
||||
if (selectedJahr !== 'alle') params.set('jahr', selectedJahr);
|
||||
if (nurFotos) params.set('nur_fotos', 'true');
|
||||
|
|
@ -2290,10 +2286,24 @@ window.Page_dog_profile = (() => {
|
|||
|
||||
renderModal();
|
||||
document.body.appendChild(modalEl);
|
||||
modalEl.addEventListener('click', e => { if (e.target === modalEl) window._buchClose(); });
|
||||
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
|
||||
const onKey = e => {
|
||||
if (e.key === 'Escape') { window._buchClose(); document.removeEventListener('keydown', onKey); }
|
||||
if (e.key === 'Escape') { closeModal(); }
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
}
|
||||
|
|
@ -2310,7 +2320,7 @@ window.Page_dog_profile = (() => {
|
|||
<use href="/icons/phosphor.svg#spinner-gap"></use>
|
||||
</svg>
|
||||
</div>`,
|
||||
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>`,
|
||||
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
|
||||
size: 'large',
|
||||
});
|
||||
|
||||
|
|
@ -2452,7 +2462,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
|
||||
|
|
|
|||
|
|
@ -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" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
|
||||
action: `<button class="btn btn-primary" data-page="dog-profile">Profil erstellen</button>`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -113,12 +113,34 @@ 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 (4–12 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
|
||||
const gewichtDefault = dog?.gewicht || '';
|
||||
const alterDefault = dog?.alter || '';
|
||||
// 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);
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="padding:var(--space-4) 0">
|
||||
|
|
@ -238,10 +260,28 @@ 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';
|
||||
|
||||
|
|
@ -257,14 +297,25 @@ window.Page_ernaehrung = (() => {
|
|||
aktiv: { intakt: 1.8, kastriert: 1.6 },
|
||||
sport: { intakt: 2.1, kastriert: 1.9 },
|
||||
};
|
||||
const kcal = Math.round(rer * faktoren[aktivitaet][kastriert ? 'kastriert' : 'intakt']);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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 = '';
|
||||
|
|
@ -274,6 +325,7 @@ 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)">
|
||||
|
|
@ -728,7 +780,7 @@ window.Page_ernaehrung = (() => {
|
|||
</form>
|
||||
`;
|
||||
const footer = `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-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 });
|
||||
|
|
@ -840,7 +892,7 @@ window.Page_ernaehrung = (() => {
|
|||
</form>
|
||||
`;
|
||||
const footer = `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-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 });
|
||||
|
|
|
|||
|
|
@ -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" onclick="event.stopPropagation()">
|
||||
<a class="btn btn-ghost btn-xs ev-ext-link" href="${UI.escape(ev.link)}" target="_blank" rel="noopener">
|
||||
${_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" onclick="event.stopPropagation()">${_icon('pencil-simple')}</button>` : ''}
|
||||
${isOwn ? `<button class="btn-icon" data-ev-edit="${ev.id}" title="Bearbeiten">${_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" onclick="event.stopPropagation()">
|
||||
title="Notiz" class="text-muted">
|
||||
${_icon('note-pencil')}</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -258,7 +258,7 @@ window.Page_events = (() => {
|
|||
if (_clusterGroup) {
|
||||
_map.removeLayer(_clusterGroup);
|
||||
}
|
||||
_clusterGroup = L.markerClusterGroup();
|
||||
_clusterGroup = UI.map.clusterGroup();
|
||||
_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="#" onclick="event.preventDefault();Page_events._openDetail(${ev.id})"
|
||||
<a href="#" data-ev-detail="${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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary flex-1" data-modal-close>Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -570,6 +570,14 @@ 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) {
|
||||
|
|
@ -661,6 +669,8 @@ window.Page_events = (() => {
|
|||
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
|
||||
// ----------------------------------------------------------
|
||||
|
||||
return { init, refresh, openNew, _openDetail: _showDetail };
|
||||
function _destroy() { try { _map && _map.remove(); } catch (e) {} _map = null; _clusterGroup = null; _markers = []; }
|
||||
|
||||
return { init, refresh, openNew, _openDetail: _showDetail, destroy: _destroy };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -485,7 +485,7 @@ window.Page_expenses = (() => {
|
|||
</form>`;
|
||||
|
||||
const footer = `
|
||||
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button type="button" class="btn btn-secondary flex-1" data-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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button type="button" class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
<button type="submit" form="${formId}" class="btn btn-primary">Speichern</button>
|
||||
` : `
|
||||
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button type="button" class="btn btn-secondary flex-1" data-modal-close>Abbrechen</button>
|
||||
<button type="submit" form="${formId}" class="btn btn-primary flex-1">Speichern</button>
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ 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'
|
||||
|
|
@ -295,7 +293,7 @@ function _fmtDate(iso) {
|
|||
</div>`;
|
||||
|
||||
UI.modal.open({ title: '🏆 Hund des Monats', body,
|
||||
footer: `<button class="btn btn-secondary flex-1" onclick="UI.modal.close()">Schließen</button>` });
|
||||
footer: `<button class="btn btn-secondary flex-1" data-modal-close>Schließen</button>` });
|
||||
|
||||
document.getElementById('hdm-login-link')?.addEventListener('click', e => {
|
||||
e.preventDefault(); UI.modal.close(); App.navigate('settings');
|
||||
|
|
@ -448,7 +446,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"
|
||||
onerror="this.src='${UI.escape(t.foto_preview)}'">`
|
||||
data-fb-src="${UI.escape(t.foto_preview)}">`
|
||||
: '';
|
||||
|
||||
return `
|
||||
|
|
@ -1022,7 +1020,7 @@ function _fmtDate(iso) {
|
|||
</p>
|
||||
|
||||
</div>`,
|
||||
footer: `<button class="btn btn-primary flex-1" onclick="UI.modal.close()">Verstanden</button>`,
|
||||
footer: `<button class="btn btn-primary flex-1" data-modal-close>Verstanden</button>`,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1237,15 +1235,11 @@ function _fmtDate(iso) {
|
|||
}
|
||||
});
|
||||
|
||||
await _loadLeaflet();
|
||||
const mapEl = document.getElementById('forum-map');
|
||||
if (!mapEl) return;
|
||||
|
||||
_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);
|
||||
// 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 });
|
||||
|
||||
_loadMembersOnMap();
|
||||
}
|
||||
|
|
@ -1253,37 +1247,20 @@ 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 Cluster-Gruppe sauber entfernen
|
||||
if (_clusterGroup) { _map.removeLayer(_clusterGroup); _clusterGroup = null; }
|
||||
// Alte Gruppe sauber entfernen
|
||||
if (_clusterGroup) { try { _map.removeLayer(_clusterGroup); } catch (e) {} _clusterGroup = null; }
|
||||
|
||||
_clusterGroup = L.markerClusterGroup({ maxClusterRadius: 60 });
|
||||
_clusterGroup = UI.map.clusterGroup({ maxClusterRadius: 60 });
|
||||
members.forEach(m => {
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="width:32px;height:32px;border-radius:50%;
|
||||
const 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>`,
|
||||
iconSize: [32, 32], iconAnchor: [16, 16],
|
||||
});
|
||||
border:2px solid rgba(255,255,255,0.8)">${UI.escape((m.vorname||'?')[0].toUpperCase())}</div>`;
|
||||
_clusterGroup.addLayer(
|
||||
L.marker([m.lat, m.lon], { icon })
|
||||
UI.map.svgMarker(m.lat, m.lon, html, { size: 32, anchorY: 16 })
|
||||
.bindPopup(`<strong>${UI.escape(m.vorname || '?')}</strong>`)
|
||||
);
|
||||
});
|
||||
|
|
@ -1293,30 +1270,6 @@ 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
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -1376,7 +1329,7 @@ function _fmtDate(iso) {
|
|||
</div>
|
||||
</form>`,
|
||||
footer: `
|
||||
<button class="btn btn-ghost flex-1" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-ghost flex-1" data-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 => {
|
||||
|
|
@ -1423,7 +1376,7 @@ function _fmtDate(iso) {
|
|||
</div>
|
||||
</form>`,
|
||||
footer: `
|
||||
<button class="btn btn-ghost flex-1" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-ghost flex-1" data-modal-close>Abbrechen</button>
|
||||
<button type="submit" form="${id}" class="btn btn-primary flex-1">${UI.icon('floppy-disk')} Speichern</button>`,
|
||||
});
|
||||
|
||||
|
|
@ -1469,6 +1422,13 @@ function _fmtDate(iso) {
|
|||
document.body.appendChild(lb);
|
||||
}
|
||||
|
||||
return { init, refresh, onDogChange, openNew, openThread: _openThread };
|
||||
// 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 };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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" onclick="App.navigate('settings')">Anmelden</button>`,
|
||||
action: `<button class="btn btn-primary" data-page="settings">Anmelden</button>`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -148,6 +148,21 @@ 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);
|
||||
|
|
@ -283,11 +298,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" onerror="this.style.display='none'"
|
||||
loading="lazy" decoding="async" data-fb="hide"
|
||||
class="fr-activity-avatar">`
|
||||
: item.avatar_url
|
||||
? `<img src="${UI.escape(item.avatar_url)}" alt="${UI.escape(item.user_name)}"
|
||||
loading="lazy" decoding="async" onerror="this.style.display='none'"
|
||||
loading="lazy" decoding="async" data-fb="hide"
|
||||
class="fr-activity-avatar">`
|
||||
: `<div class="fr-activity-avatar fr-activity-avatar--initial">
|
||||
${UI.escape((item.user_name || '?')[0].toUpperCase())}
|
||||
|
|
@ -359,12 +374,14 @@ window.Page_friends = (() => {
|
|||
</div>
|
||||
<div style="display:flex;gap:var(--space-2);flex-shrink:0">
|
||||
<button class="btn btn-primary btn-sm"
|
||||
onclick="Page_friends._accept(${r.id})" title="Annehmen">
|
||||
data-fr-action="accept" data-fr-id="${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"
|
||||
onclick="Page_friends._decline(${r.id})" title="Ablehnen">
|
||||
data-fr-action="decline" data-fr-id="${r.id}" title="Ablehnen">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -400,7 +417,7 @@ window.Page_friends = (() => {
|
|||
<div class="text-xs-muted">Anfrage ausstehend</div>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm"
|
||||
onclick="Page_friends._cancel(${r.id})" title="Zurückziehen">
|
||||
data-fr-action="cancel" data-fr-id="${r.id}" title="Zurückziehen">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#x"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -507,12 +524,11 @@ 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"
|
||||
onclick="event.stopPropagation()">
|
||||
title="Notiz">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#note-pencil"></use></svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm"
|
||||
onclick="Page_friends._openChat(${f.friend_id})"
|
||||
data-fr-action="chat" data-fr-id="${f.friend_id}"
|
||||
title="Nachricht schreiben">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#chat-circle-dots"></use></svg>
|
||||
</button>
|
||||
|
|
@ -540,7 +556,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" onerror="this.style.display='none'"
|
||||
loading="lazy" decoding="async" data-fb="hide"
|
||||
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;
|
||||
|
|
@ -564,7 +580,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" onerror="this.style.display='none'"
|
||||
loading="lazy" decoding="async" data-fb="hide"
|
||||
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%;
|
||||
|
|
@ -787,13 +803,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" onerror="this.style.display='none'"
|
||||
loading="lazy" decoding="async" data-fb="hide"
|
||||
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" onerror="this.style.display='none'"
|
||||
loading="lazy" decoding="async" data-fb="hide"
|
||||
style="width:44px;height:44px;border-radius:50%;object-fit:cover;
|
||||
border:2px solid var(--c-primary);flex-shrink:0">`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
|
||||
action: `<button class="btn btn-primary" data-page="dog-profile">Profil erstellen</button>`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -403,8 +403,7 @@ 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)}"
|
||||
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||
data-label="${UI.escape(e.bezeichnung)}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -511,8 +510,7 @@ 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)}"
|
||||
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||
data-label="${UI.escape(e.bezeichnung)}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -563,8 +561,7 @@ 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)}"
|
||||
onclick="event.stopPropagation()"><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)}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
|
@ -801,8 +798,7 @@ 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)}"
|
||||
onclick="event.stopPropagation()"><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)}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
|
@ -839,8 +835,7 @@ 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)}"
|
||||
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||
data-label="${UI.escape(e.bezeichnung)}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
|
|
@ -880,8 +875,7 @@ 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)}"
|
||||
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||
data-label="${UI.escape(e.bezeichnung)}"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
|
@ -923,19 +917,16 @@ 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)}"
|
||||
onclick="event.stopPropagation()"><svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz</button>
|
||||
data-label="${UI.escape(e.bezeichnung)}"><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"
|
||||
onclick="event.stopPropagation()">
|
||||
class="btn btn-secondary btn-sm" style="display:inline-flex">
|
||||
<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"
|
||||
onclick="event.stopPropagation()">
|
||||
class="btn btn-secondary btn-sm" style="display:inline-flex">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#image"></use></svg> Bild
|
||||
</a>`
|
||||
).join('')}
|
||||
|
|
@ -1773,28 +1764,24 @@ 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"
|
||||
onclick="event.stopPropagation()">
|
||||
<a href="tel:${UI.escape(p.telefon)}" class="btn btn-secondary btn-sm">
|
||||
<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"
|
||||
onclick="event.stopPropagation()">
|
||||
<a href="tel:${UI.escape(p.notfall_telefon)}" class="btn btn-danger btn-sm">
|
||||
<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"
|
||||
onclick="event.stopPropagation()">
|
||||
style="flex-shrink:0">
|
||||
<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"
|
||||
onclick="event.stopPropagation()">
|
||||
style="flex-shrink:0">
|
||||
<svg class="ph-icon" aria-hidden="true">
|
||||
<use href="/icons/phosphor.svg#${isFav ? 'heart-fill' : 'heart'}"></use>
|
||||
</svg>
|
||||
|
|
@ -1803,8 +1790,7 @@ 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"
|
||||
onclick="event.stopPropagation()">
|
||||
style="flex-shrink:0">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil-simple"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -1881,7 +1867,7 @@ window.Page_health = (() => {
|
|||
<use href="/icons/phosphor.svg#spinner-gap"></use>
|
||||
</svg>
|
||||
</div>`,
|
||||
footer: `<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
|
||||
footer: `<button class="btn btn-secondary" data-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
|
||||
|
|
@ -1998,7 +1984,7 @@ window.Page_health = (() => {
|
|||
title: `${UI.escape(praxis.name)} bewerten`,
|
||||
body,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-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'}
|
||||
|
|
@ -2373,7 +2359,7 @@ window.Page_health = (() => {
|
|||
value="${UI.escape(currentNr)}" placeholder="z.B. 276009200123456" maxlength="20">
|
||||
</div>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
<button class="btn btn-primary" id="transponder-save-btn">Speichern</button>`,
|
||||
});
|
||||
document.getElementById('transponder-save-btn').addEventListener('click', async () => {
|
||||
|
|
@ -2441,11 +2427,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 onclick="window._kiPrev()" style="padding:6px 16px;border-radius:999px;
|
||||
<button data-ki-nav="prev" 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 onclick="window._kiNext()" style="padding:6px 16px;border-radius:999px;
|
||||
<button data-ki-nav="next" 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>` : '';
|
||||
|
|
@ -2455,10 +2441,13 @@ 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 (_) {
|
||||
|
|
@ -2620,15 +2609,13 @@ 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"
|
||||
onclick="event.stopPropagation()">
|
||||
<a href="tel:${UI.escape(vet.telefon)}" class="btn btn-secondary btn-sm">
|
||||
<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"
|
||||
onclick="event.stopPropagation()">
|
||||
<a href="tel:${UI.escape(vet.notfall_telefon)}" class="btn btn-danger btn-sm">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg> Notfall: ${UI.escape(vet.notfall_telefon)}
|
||||
</a>
|
||||
</div>` : ''}
|
||||
|
|
@ -2718,14 +2705,13 @@ 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" onclick="event.stopPropagation()">
|
||||
class="btn btn-secondary btn-sm">
|
||||
${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}"
|
||||
onclick="event.stopPropagation()">
|
||||
data-action="delete-hdoc" data-doc-id="${doc.id}">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -3007,7 +2993,7 @@ function _showPoiKorrekturModal(osmId, poiName, currentOh) {
|
|||
Bei ernsthaften oder sich verschlechternden Symptomen sofort zum Tierarzt.
|
||||
</div>`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Schließen</button>
|
||||
<button class="btn btn-secondary" data-modal-close>Schließen</button>
|
||||
<button class="btn btn-primary" id="ki-tierarzt-submit-btn">Frage stellen</button>`,
|
||||
});
|
||||
|
||||
|
|
@ -3233,7 +3219,7 @@ function _showPoiKorrekturModal(osmId, poiName, currentOh) {
|
|||
</div>
|
||||
</form>`;
|
||||
const footer = `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-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(() => {
|
||||
|
|
@ -3385,7 +3371,7 @@ function _showPoiKorrekturModal(osmId, poiName, currentOh) {
|
|||
</div>
|
||||
</form>`;
|
||||
const footer = `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-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(() => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
onerror="this.style.display='none'">`
|
||||
data-fb="hide">`
|
||||
: `<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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-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" onclick="UI.modal.close()">Schließen</button>
|
||||
<button class="btn btn-secondary" data-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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
<button class="btn btn-primary" form="prog-form" type="submit">Eintragen</button>`,
|
||||
});
|
||||
document.getElementById('prog-form').addEventListener('submit', async e => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
onerror="this.style.display='none'">`
|
||||
data-fb="hide">`
|
||||
: `<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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-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"
|
||||
onerror="this.src='/static/img/placeholder.webp'">
|
||||
data-fb-src="/static/img/placeholder.webp">
|
||||
</a>
|
||||
<button class="photos-vis-btn"
|
||||
data-photo-id="${ph.id}"
|
||||
|
|
|
|||
|
|
@ -107,13 +107,9 @@ 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);
|
||||
|
|
@ -178,9 +174,9 @@ window.Page_lost = (() => {
|
|||
}
|
||||
|
||||
function _showUserOnMap() {
|
||||
if (!_map || !window.L || !_userPos) return;
|
||||
if (!_map || !_userPos) return;
|
||||
if (_userMarker) _map.removeLayer(_userMarker);
|
||||
_userMarker = L.circleMarker([_userPos.lat, _userPos.lon], {
|
||||
_userMarker = UI.map.circleMarker(_userPos.lat, _userPos.lon, {
|
||||
radius : 9,
|
||||
fillColor : '#3498db',
|
||||
color : '#fff',
|
||||
|
|
@ -266,7 +262,7 @@ window.Page_lost = (() => {
|
|||
// KARTEN-MARKER
|
||||
// ----------------------------------------------------------
|
||||
function _renderMarkers() {
|
||||
if (!_map || !window.L) return;
|
||||
if (!_map) return;
|
||||
_markers.forEach(m => _map.removeLayer(m));
|
||||
_markers = [];
|
||||
|
||||
|
|
@ -410,7 +406,6 @@ 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>
|
||||
|
|
@ -419,7 +414,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" onclick="event.stopPropagation()">
|
||||
title="Notiz">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
|
||||
</button>
|
||||
</div>` : '')}
|
||||
|
|
@ -808,6 +803,8 @@ function _emptyState(icon, title, text, cta = '') {
|
|||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh, openNew };
|
||||
function _destroy() { try { _map && _map.remove(); } catch (e) {} _map = null; _markers = []; _userMarker = null; }
|
||||
|
||||
return { init, refresh, openNew, destroy: _destroy };
|
||||
|
||||
})();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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;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"`;
|
||||
const clickable = tab ? `data-tab="${tab}" style="padding:var(--space-4);text-align:center;cursor:pointer"` : `style="padding:var(--space-4);text-align:center"`;
|
||||
return `
|
||||
<div class="card mod-stat-card" ${clickable}>
|
||||
<div class="card mod-stat-card${tab ? ' by-hover-lift' : ''}" ${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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
onmouseenter="this.play()" onmouseleave="this.pause()"></video>
|
||||
data-hover-play></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">`}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
onclick="App.navigate('${a.page}')">${a.label} →</button>`).join('')}
|
||||
data-page="${a.page}">${a.label} →</button>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,469 +0,0 @@
|
|||
/* ============================================================
|
||||
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 };
|
||||
|
||||
})();
|
||||
|
|
@ -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;"
|
||||
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>'">`;
|
||||
data-fb="initials" data-fb-initials="${initials}" data-fb-size="${size}">`;
|
||||
}
|
||||
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" onclick="App.navigate('dog-profile')">Hund anlegen</button>`,
|
||||
action: `<button class="btn btn-primary" data-page="dog-profile">Hund anlegen</button>`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,13 +61,9 @@ 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">
|
||||
|
|
@ -143,9 +139,9 @@ window.Page_poison = (() => {
|
|||
}
|
||||
|
||||
function _showUserOnMap() {
|
||||
if (!_map || !window.L || !_userPos) return;
|
||||
if (!_map || !_userPos) return;
|
||||
if (_userMarker) _map.removeLayer(_userMarker);
|
||||
_userMarker = L.circleMarker([_userPos.lat, _userPos.lon], {
|
||||
_userMarker = UI.map.circleMarker(_userPos.lat, _userPos.lon, {
|
||||
radius : 9,
|
||||
fillColor : '#3498db',
|
||||
color : '#fff',
|
||||
|
|
@ -201,7 +197,7 @@ window.Page_poison = (() => {
|
|||
// KARTEN-MARKER
|
||||
// ----------------------------------------------------------
|
||||
function _renderMarkers() {
|
||||
if (!_map || !window.L) return;
|
||||
if (!_map) return;
|
||||
_markers.forEach(m => _map.removeLayer(m));
|
||||
_markers = [];
|
||||
|
||||
|
|
@ -302,7 +298,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" onclick="event.stopPropagation()">
|
||||
title="Notiz">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#note-pencil"></use></svg> Notiz
|
||||
</button>
|
||||
</div>` : ''}
|
||||
|
|
@ -654,6 +650,9 @@ window.Page_poison = (() => {
|
|||
// ----------------------------------------------------------
|
||||
// PUBLIC
|
||||
// ----------------------------------------------------------
|
||||
return { init, refresh, openNew, openDetail: _openDetail };
|
||||
// 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 };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ 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
|
||||
|
|
@ -127,6 +128,7 @@ 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) {
|
||||
|
|
@ -149,7 +151,7 @@ window.Page_routes = (() => {
|
|||
btnRow.innerHTML = `
|
||||
<button id="rk-filter-btn" style="${_btnStyle()}position:relative">
|
||||
${UI.icon('gear')} Filter
|
||||
<span class="rk-filter-badge" id="rk-filter-badge" class="hidden"></span>
|
||||
<span class="rk-filter-badge hidden" id="rk-filter-badge"></span>
|
||||
</button>
|
||||
<label id="rk-imp-wrap" title="GPX / KML / TCX importieren" style="${_btnStyle()}">
|
||||
${UI.icon('download-simple')} Import
|
||||
|
|
@ -169,6 +171,14 @@ 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
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -213,7 +223,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" id="rk-filter-badge" class="hidden"></span>
|
||||
<span class="rk-filter-badge hidden" id="rk-filter-badge"></span>
|
||||
</button>
|
||||
<label id="rk-imp-wrap" title="GPX / KML / TCX importieren" style="${_btnStyle()}">
|
||||
${UI.icon('download-simple')} Import
|
||||
|
|
@ -221,7 +231,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" id="rk-filter-panel" class="hidden">
|
||||
<div class="rk-filter-panel hidden" id="rk-filter-panel">
|
||||
<div class="rk-filters" id="rk-filters">
|
||||
<div class="rk-filter-group">
|
||||
<div class="rk-filter-label">Schwierigkeit</div>
|
||||
|
|
@ -612,12 +622,11 @@ window.Page_routes = (() => {
|
|||
zoomControl: false, attributionControl: false,
|
||||
});
|
||||
_suggestMap.scrollWheelZoom.disable();
|
||||
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);
|
||||
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);
|
||||
_addRouteArrows(_suggestMap, track, '#3b82f6');
|
||||
_suggestMap.fitBounds(poly.getBounds(), { padding: [16, 16] });
|
||||
setTimeout(() => _suggestMap?.invalidateSize(), 120);
|
||||
_fitRouteMap(_suggestMap, mapEl, () => poly.getBounds());
|
||||
};
|
||||
_initMap();
|
||||
|
||||
|
|
@ -659,6 +668,26 @@ 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;
|
||||
|
|
@ -730,7 +759,7 @@ window.Page_routes = (() => {
|
|||
center: [pos.lat, pos.lon], zoom: 15,
|
||||
zoomControl: false, attributionControl: false,
|
||||
});
|
||||
_recLocMarker = L.circleMarker([pos.lat, pos.lon], {
|
||||
_recLocMarker = UI.map.circleMarker([pos.lat, pos.lon], {
|
||||
radius: 8, color: '#fff', weight: 2.5, fillColor: '#3b82f6', fillOpacity: 1
|
||||
}).addTo(_recMap);
|
||||
} catch {
|
||||
|
|
@ -752,10 +781,30 @@ window.Page_routes = (() => {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
async function _startRecInOvl() {
|
||||
// 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) {
|
||||
if (!navigator.geolocation) { UI.toast.error('GPS nicht verfügbar.'); return; }
|
||||
window._byRecording = true; // Guard: Update-Reload wird aufgeschoben
|
||||
_recActive = true;
|
||||
_recTrack = []; _recDistKm = 0; _recStartTime = Date.now();
|
||||
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)) {
|
||||
|
|
@ -815,9 +864,17 @@ window.Page_routes = (() => {
|
|||
btn.addEventListener('pointercancel', cancelHold);
|
||||
document.getElementById('rk-rec-stats-bar').style.display = '';
|
||||
|
||||
if (_recMap && window.L) {
|
||||
_recPolyline = L.polyline([], { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(_recMap);
|
||||
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 (resume) { _updateRecStats(); _persistRec(true); }
|
||||
|
||||
await _recAcquireWakeLock();
|
||||
document.addEventListener('visibilitychange', _recOnVisibility);
|
||||
|
|
@ -832,6 +889,7 @@ 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);
|
||||
|
|
@ -940,12 +998,14 @@ window.Page_routes = (() => {
|
|||
_recOvl?.removeEventListener('touchstart', _onRecOvlTouch);
|
||||
_recOvl?.removeEventListener('pointerdown', _onRecOvlTouch);
|
||||
|
||||
if (!save) { _closeRecOvlClean(); return; }
|
||||
if (!save) { _closeRecOvlClean(); _recDone(); 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.'); return; }
|
||||
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.
|
||||
_showRecSaveModal(track, distKm, dauMin);
|
||||
}
|
||||
|
||||
|
|
@ -1048,7 +1108,7 @@ window.Page_routes = (() => {
|
|||
document.getElementById('rk-rms-paw-val').value = btn.dataset.val;
|
||||
});
|
||||
|
||||
document.getElementById('rk-rms-discard')?.addEventListener('click', () => UI.modal.close());
|
||||
document.getElementById('rk-rms-discard')?.addEventListener('click', () => { UI.modal.close(); _recDone(); });
|
||||
|
||||
document.getElementById('rk-rms-form')?.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
|
@ -1072,12 +1132,14 @@ 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();
|
||||
});
|
||||
|
|
@ -1191,7 +1253,7 @@ window.Page_routes = (() => {
|
|||
}
|
||||
|
||||
function _renderRoutesOnMap() {
|
||||
if (!_searchMap || !window.L) return;
|
||||
if (!_searchMap) return;
|
||||
|
||||
// Alte Linien entfernen
|
||||
_searchLines.forEach(({ line }) => line.remove());
|
||||
|
|
@ -1203,15 +1265,15 @@ window.Page_routes = (() => {
|
|||
const pts = (route.preview_track || []).map(p => [p.lat, p.lon]);
|
||||
if (pts.length < 2) return;
|
||||
|
||||
const line = L.polyline(pts, {
|
||||
const line = UI.map.polyline(pts, {
|
||||
color: '#C4843A', weight: 4, opacity: 0.75,
|
||||
}).addTo(_searchMap);
|
||||
|
||||
// Start-/End-Marker
|
||||
const startM = L.circleMarker(pts[0], {
|
||||
const startM = UI.map.circleMarker(pts[0], {
|
||||
radius: 6, color: '#22C55E', fillColor: '#22C55E', fillOpacity: 1, weight: 1.5
|
||||
}).addTo(_searchMap);
|
||||
const endM = L.circleMarker(pts[pts.length - 1], {
|
||||
const endM = UI.map.circleMarker(pts[pts.length - 1], {
|
||||
radius: 6, color: '#EF4444', fillColor: '#EF4444', fillOpacity: 1, weight: 1.5
|
||||
}).addTo(_searchMap);
|
||||
|
||||
|
|
@ -1240,7 +1302,7 @@ window.Page_routes = (() => {
|
|||
if (_data.length && _searchLines.size && !_userPos) {
|
||||
const allPts = [..._searchLines.values()].flatMap(({ line }) => line.getLatLngs());
|
||||
if (allPts.length) {
|
||||
try { _searchMap.fitBounds(L.latLngBounds(allPts), { padding: [20, 20], maxZoom: 14 }); }
|
||||
try { _searchMap.fitBounds(allPts, { padding: [20, 20], maxZoom: 14 }); }
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1509,33 +1571,24 @@ window.Page_routes = (() => {
|
|||
document.querySelectorAll('.rk-mini-map').forEach(el => obs.observe(el));
|
||||
};
|
||||
|
||||
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);
|
||||
init();
|
||||
}
|
||||
|
||||
// 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 || '[]');
|
||||
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);
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -1668,14 +1721,25 @@ window.Page_routes = (() => {
|
|||
stroke-linejoin="round"
|
||||
stroke-linecap="round"/>
|
||||
</svg>
|
||||
<!-- 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>
|
||||
<div style="font-size:11px;opacity:.3;margin-top:8px">2 Sek. halten</div>
|
||||
<!-- 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">
|
||||
<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>
|
||||
`;
|
||||
document.body.appendChild(ovl);
|
||||
|
|
@ -1723,8 +1787,8 @@ window.Page_routes = (() => {
|
|||
_navMap.invalidateSize();
|
||||
|
||||
// Route-Polylines: erledigt (grün) + ausstehend (orange)
|
||||
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);
|
||||
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);
|
||||
_navMap.fitBounds(remainLine.getBounds(), { padding: [20, 20] });
|
||||
_addRouteArrows(_navMap, track, '#3b82f6');
|
||||
|
||||
|
|
@ -1737,7 +1801,7 @@ window.Page_routes = (() => {
|
|||
}, 250);
|
||||
|
||||
// Start/End-Marker (als Variable damit Reverse sie neu setzen kann)
|
||||
const mkPin = (p, color) => L.circleMarker([p.lat, p.lon], {
|
||||
const mkPin = (p, color) => UI.map.circleMarker([p.lat, p.lon], {
|
||||
radius: 8, color: '#fff', weight: 2, fillColor: color, fillOpacity: 1
|
||||
}).addTo(_navMap);
|
||||
let startPin = mkPin(track[0], '#22c55e');
|
||||
|
|
@ -1752,19 +1816,14 @@ window.Page_routes = (() => {
|
|||
pois.forEach(poi => {
|
||||
const svgIcon = poi._svgIcon || 'map-pin';
|
||||
const color = poi._color || '#6b7280';
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="background:${color};color:#fff;width:32px;height:32px;border-radius:50%;
|
||||
const 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>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
});
|
||||
L.marker([poi.lat, poi.lon], { icon })
|
||||
.bindTooltip(poi.name || poi._label, { direction: 'top', offset: [0, -16] })
|
||||
</svg></div>`;
|
||||
UI.map.svgMarker(poi.lat, poi.lon, html, { size: 32 })
|
||||
.bindTooltip(poi.name || poi._label)
|
||||
.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)}` : ''}`)
|
||||
|
|
@ -1859,7 +1918,7 @@ window.Page_routes = (() => {
|
|||
_navWatchId = navigator.geolocation.watchPosition(pos => {
|
||||
const { latitude: lat, longitude: lon } = pos.coords;
|
||||
if (!locMarker) {
|
||||
locMarker = L.circleMarker([lat, lon], {
|
||||
locMarker = UI.map.circleMarker([lat, lon], {
|
||||
radius: 10, color: '#fff', weight: 3, fillColor: '#3b82f6', fillOpacity: 1,
|
||||
className: 'rk-nav-loc-pulse'
|
||||
}).addTo(_navMap);
|
||||
|
|
@ -1896,23 +1955,32 @@ 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'; }
|
||||
};
|
||||
dim.addEventListener('pointerdown', e => {
|
||||
navUnlock.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);
|
||||
});
|
||||
dim.addEventListener('pointerup', cancelLp);
|
||||
dim.addEventListener('pointercancel', cancelLp);
|
||||
dim.addEventListener('pointerleave', cancelLp);
|
||||
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(); });
|
||||
|
||||
// Aktions-Buttons
|
||||
document.getElementById('rk-nav-back').addEventListener('click', _closeNav);
|
||||
|
|
@ -1946,7 +2014,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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button type="button" class="btn btn-secondary flex-1" data-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 () => {
|
||||
|
|
@ -1987,7 +2055,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" onclick="UI.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" data-modal-close>Schließen</button>` });
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2185,11 +2253,11 @@ window.Page_routes = (() => {
|
|||
});
|
||||
|
||||
// Marker & Polylines
|
||||
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);
|
||||
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);
|
||||
|
||||
const mkMarker = (lat, lon, color) => L.circleMarker([lat, lon], {
|
||||
const mkMarker = (lat, lon, color) => UI.map.circleMarker([lat, lon], {
|
||||
radius: 9, color: '#fff', weight: 2.5, fillColor: color, fillOpacity: 1
|
||||
}).addTo(trimMap);
|
||||
|
||||
|
|
@ -2217,13 +2285,13 @@ window.Page_routes = (() => {
|
|||
· <span class="text-muted">Original: ${origKm.toFixed(2)} km · ${origMin} min (bleibt angerechnet)</span>`;
|
||||
};
|
||||
update();
|
||||
trimMap.fitBounds(L.polyline(fullTrack.map(p => [p.lat, p.lon])).getBounds(), { padding: [20, 20] });
|
||||
trimMap.fitBounds(UI.map.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, L.latLng(p.lat, p.lon));
|
||||
const d = trimMap.distance(latlng, { lat: p.lat, lng: p.lon });
|
||||
if (d < bestD) { bestD = d; best = i; }
|
||||
});
|
||||
return best;
|
||||
|
|
@ -2299,7 +2367,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" onclick="window.open('${UI.escape(u)}','_blank')">`).join('')}
|
||||
${photos.map(u => `<img src="${UI.escape(u)}" class="rk-photo-thumb" data-open-url="${UI.escape(u)}">`).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">
|
||||
|
|
@ -2375,7 +2443,12 @@ window.Page_routes = (() => {
|
|||
</div>
|
||||
`;
|
||||
|
||||
UI.modal.open({ title: `🥾 ${UI.escape(route.name)}`, body, footer });
|
||||
// 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.ratingStars({
|
||||
containerId: `rk-rating-${route.id}`,
|
||||
|
|
@ -2493,8 +2566,8 @@ window.Page_routes = (() => {
|
|||
UI.noteModal('route', route.id, label, null);
|
||||
});
|
||||
|
||||
// Mini-Map
|
||||
let _detailMap = null;
|
||||
// Mini-Map (modulweite _detailMap → wird beim Schließen im onClose freigegeben)
|
||||
if (_detailMap) { try { _detailMap.remove(); } catch (e) {} _detailMap = null; }
|
||||
setTimeout(async () => {
|
||||
const el = document.getElementById('rk-detail-map');
|
||||
if (!el || !track.length) return;
|
||||
|
|
@ -2601,33 +2674,61 @@ window.Page_routes = (() => {
|
|||
for (let i = 1; i < track.length - 1; i++) {
|
||||
if (cum[i] >= next) {
|
||||
const deg = brng(track[i-1], track[i]);
|
||||
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"
|
||||
// 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)"
|
||||
fill="${color}" fill-opacity="0.85"
|
||||
stroke="rgba(0,0,0,0.25)" stroke-width="1" stroke-linejoin="round"/>
|
||||
</svg>`,
|
||||
iconSize: [20, 20], iconAnchor: [10, 10],
|
||||
});
|
||||
L.marker([track[i].lat, track[i].lon], { icon, interactive: false, zIndexOffset: -100 }).addTo(map);
|
||||
</svg>`;
|
||||
UI.map.svgMarker(track[i].lat, track[i].lon, html, { size: 20 }).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 = L.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.85 }).addTo(m);
|
||||
const poly = UI.map.polyline(lls, { color: '#C4843A', weight: 4, opacity: 0.85 }).addTo(m);
|
||||
_addRouteArrows(m, track, '#3b82f6');
|
||||
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] });
|
||||
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());
|
||||
return m;
|
||||
}
|
||||
|
||||
|
|
@ -3101,11 +3202,9 @@ window.Page_routes = (() => {
|
|||
|
||||
const friendRows = friends.map(f => {
|
||||
const initial = (f.name || '?')[0].toUpperCase();
|
||||
return `<div class="rk-friend-row" data-id="${f.id}" data-name="${UI.escape(f.name || 'Anonym')}"
|
||||
return `<div class="rk-friend-row by-hover-surface2" 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);transition:background .15s"
|
||||
onmouseover="this.style.background='var(--c-surface-2)'"
|
||||
onmouseout="this.style.background=''">
|
||||
cursor:pointer;border-radius:var(--radius-md)">
|
||||
<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>
|
||||
|
|
@ -3148,6 +3247,6 @@ window.Page_routes = (() => {
|
|||
// Notiz-Modal (Custom DOM — kein UI.modal um Konflikte zu vermeiden)
|
||||
// ----------------------------------------------------------
|
||||
|
||||
return { init, refresh, onDogChange };
|
||||
return { init, refresh, onDogChange, destroy };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
onclick="App.navigate('agb')">AGB</span> gelesen und stimme ihnen zu.
|
||||
data-page="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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
<button class="btn btn-primary" form="${id}" type="submit">Link senden</button>`,
|
||||
});
|
||||
document.getElementById(id)?.addEventListener('submit', async e => {
|
||||
|
|
@ -2485,13 +2485,27 @@ 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 sessionStorage gesetzt
|
||||
const stored = sessionStorage.getItem('by_ref_code') || '';
|
||||
// Vorausfüllen falls via Referral-Link gesetzt (localStorage, überlebt App-Schließen)
|
||||
const stored = _storedRefCode();
|
||||
if (stored) partnerInput.value = stored;
|
||||
|
||||
let _debounce = null;
|
||||
|
|
@ -2539,10 +2553,10 @@ window.Page_settings = (() => {
|
|||
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const partnerCode = (fd.partner_code || '').trim().toUpperCase() || undefined;
|
||||
const refCode = sessionStorage.getItem('by_ref_code') || '';
|
||||
const refCode = _storedRefCode();
|
||||
const finalCode = partnerCode || refCode || undefined;
|
||||
const result = await API.auth.register(fd.email, fd.password, fd.name.trim(), finalCode);
|
||||
if (refCode) sessionStorage.removeItem('by_ref_code');
|
||||
if (refCode) _clearRefCode();
|
||||
|
||||
if (result.pending_verification) {
|
||||
_renderVerifyPending(fd.email);
|
||||
|
|
|
|||
|
|
@ -139,8 +139,7 @@ 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)"
|
||||
onclick="event.stopPropagation()">
|
||||
title="Notiz" style="color:var(--c-text-muted);margin-top:var(--space-1)">
|
||||
${UI.icon('note-pencil')}</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -324,7 +323,7 @@ window.Page_sitting = (() => {
|
|||
</form>
|
||||
`;
|
||||
const footer = `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-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 });
|
||||
|
|
@ -410,7 +409,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" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
</div>
|
||||
`;
|
||||
UI.modal.open({ title: s ? 'Sitter-Profil bearbeiten' : 'Sitter-Profil erstellen', body, footer });
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
onerror="this.style.display='none'">` : '<span style="font-size:2.2em">🐶</span>'}
|
||||
data-fb="hide">` : '<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"
|
||||
onerror="this.style.display='none'">
|
||||
data-fb="hide">
|
||||
</div>` : ''}
|
||||
|
||||
${_resultBlock('📝 Caption', data.caption, true)}
|
||||
|
|
|
|||
|
|
@ -351,8 +351,7 @@ 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)"
|
||||
onclick="event.stopPropagation()">
|
||||
title="Notiz" style="color:var(--c-text-muted);font-size:var(--text-xs)">
|
||||
${UI.icon('note-pencil')}</button>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
|
|
@ -370,7 +369,7 @@ window.Page_walks = (() => {
|
|||
}
|
||||
|
||||
function _renderMarkers() {
|
||||
if (!_map || !window.L) return;
|
||||
if (!_map) return;
|
||||
_markers.forEach(m => m.remove());
|
||||
_markers = [];
|
||||
_data.forEach(w => {
|
||||
|
|
@ -387,8 +386,8 @@ window.Page_walks = (() => {
|
|||
if (_markers.length === 1) {
|
||||
_map.setView(_markers[0].getLatLng(), 13);
|
||||
} else if (_markers.length > 1) {
|
||||
const group = L.featureGroup(_markers);
|
||||
_map.fitBounds(group.getBounds().pad(0.2));
|
||||
const group = UI.map.featureGroup(_markers);
|
||||
_map.fitBounds(group, { padding: 50 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -554,7 +553,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"
|
||||
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(p.url)}' }], 0)">
|
||||
data-lightbox-url="${UI.escape(p.url)}">
|
||||
${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>
|
||||
|
|
@ -637,7 +636,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"
|
||||
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(photo.url)}' }], 0)">
|
||||
data-lightbox-url="${UI.escape(photo.url)}">
|
||||
<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);
|
||||
|
|
@ -1107,8 +1106,8 @@ window.Page_walks = (() => {
|
|||
return `
|
||||
<div class="challenge-sub-card">
|
||||
<img src="${UI.escape(s.foto_url)}" alt="Challenge-Foto" loading="lazy"
|
||||
onerror="this.src='/icons/icon-192.png'"
|
||||
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(s.foto_url)}' }], 0)">
|
||||
data-fb-src="/icons/icon-192.png"
|
||||
data-lightbox-url="${UI.escape(s.foto_url)}">
|
||||
<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>
|
||||
|
|
@ -1131,7 +1130,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" onerror="this.src='/icons/icon-192.png'">
|
||||
<img src="${UI.escape(w.winner.foto_url)}" alt="Gewinner" data-fb-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>
|
||||
|
|
@ -1169,7 +1168,7 @@ window.Page_walks = (() => {
|
|||
</form>
|
||||
`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
<button class="btn btn-primary" id="challenge-submit-ok">Einreichen</button>
|
||||
`,
|
||||
});
|
||||
|
|
@ -1366,7 +1365,7 @@ window.Page_walks = (() => {
|
|||
</form>
|
||||
`,
|
||||
footer: `
|
||||
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
|
||||
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
|
||||
<button class="btn btn-primary" id="gz-save-btn">Speichern</button>
|
||||
`,
|
||||
});
|
||||
|
|
@ -1397,6 +1396,8 @@ window.Page_walks = (() => {
|
|||
}
|
||||
|
||||
|
||||
return { init, refresh, onDogChange, openNew, openDetail: _openDetail };
|
||||
function _destroy() { try { _map && _map.remove(); } catch (e) {} _map = null; _markers = []; }
|
||||
|
||||
return { init, refresh, onDogChange, openNew, openDetail: _openDetail, destroy: _destroy };
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
|
||||
action: `<button class="btn btn-primary" data-page="dog-profile">Profil erstellen</button>`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,19 @@ 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();
|
||||
}
|
||||
|
||||
|
|
@ -180,11 +193,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"
|
||||
onclick="Page_wiki._approveSubmission(${s.id})">
|
||||
data-wiki-action="approve" data-wiki-id="${s.id}">
|
||||
${UI.icon('check')} Freischalten
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm flex-1"
|
||||
onclick="Page_wiki._rejectSubmission(${s.id})">
|
||||
data-wiki-action="reject" data-wiki-id="${s.id}">
|
||||
${UI.icon('x')} Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -390,7 +403,7 @@ window.Page_wiki = (() => {
|
|||
: fotoUrl;
|
||||
const photoHtml = fotoUrl
|
||||
? `<img class="wiki-breed-photo" src="${UI.escape(srcUrl)}" loading="lazy" alt="${UI.escape(r.name)}"
|
||||
onerror="if(this.src.includes('_preview')){this.src='${UI.escape(fotoUrl)}'}else{this.style.display='none';this.nextElementSibling.style.display='flex'}">`
|
||||
data-fb="sibling" data-fb-src="${UI.escape(fotoUrl)}">`
|
||||
: '';
|
||||
const fallbackHtml = `<div class="wiki-breed-photo-fallback" style="${fotoUrl ? 'display:none' : ''}">${_DOG_SILHOUETTE}</div>`;
|
||||
|
||||
|
|
@ -744,7 +757,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)}"
|
||||
onerror="this.style.display='none';document.getElementById('wiki-photo-fallback').style.display='flex'">
|
||||
data-fb="show-el" data-fb-el="wiki-photo-fallback">
|
||||
<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">
|
||||
|
|
@ -753,7 +766,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"
|
||||
onerror="if(this.src.includes('_preview')){this.src='${UI.escape(f.foto_url)}'}else{this.style.display='none'}">
|
||||
data-fb-src="${UI.escape(f.foto_url)}">
|
||||
${f.user_name ? `<span class="wiki-gallery-thumb-label">von ${UI.escape(f.user_name)}</span>` : ''}
|
||||
</button>`).join('')}
|
||||
</div>` : ''}
|
||||
|
|
@ -1225,7 +1238,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)}" onerror="this.style.display='none'">`
|
||||
? `<img class="wiki-quiz-result-photo" src="${UI.escape(r.foto_url)}" loading="lazy" alt="${UI.escape(r.name)}" data-fb="hide">`
|
||||
: `<div class="wiki-quiz-result-photo-fallback">${UI.icon('dog')}</div>`;
|
||||
return `
|
||||
<div class="wiki-quiz-result-card">
|
||||
|
|
@ -1400,7 +1413,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" onclick="UI.modal.close()">Schließen</button>`,
|
||||
footer: `<button class="btn btn-secondary" data-modal-close>Schließen</button>`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,8 +118,9 @@ 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" onclick="history.back()">Zurück</button>
|
||||
<button class="btn btn-secondary" id="zp-back-btn">Zurück</button>
|
||||
</div>`;
|
||||
_container.querySelector('#zp-back-btn')?.addEventListener('click', () => history.back());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
onerror="this.style.display='none'">`
|
||||
data-fb="hide">`
|
||||
: `<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"
|
||||
onerror="this.parentElement.parentElement.style.opacity='.4'">
|
||||
data-fb="dim-grandparent">
|
||||
</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>` : ''}
|
||||
|
|
|
|||
11
backend/static/js/presse.js
Normal file
11
backend/static/js/presse.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
23
backend/static/js/ui-vector-test.js
Normal file
23
backend/static/js/ui-vector-test.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// 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);
|
||||
}
|
||||
})();
|
||||
})();
|
||||
|
|
@ -439,7 +439,6 @@ const UI = (() => {
|
|||
OSM_MAX_ZOOM: 19,
|
||||
|
||||
async create(containerId, options = {}) {
|
||||
await loadLeaflet();
|
||||
const {
|
||||
center = [51.1657, 10.4515],
|
||||
zoom = 6,
|
||||
|
|
@ -447,11 +446,47 @@ 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);
|
||||
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)';
|
||||
|
||||
// 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
|
||||
|
|
@ -462,6 +497,7 @@ 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,
|
||||
|
|
@ -470,6 +506,51 @@ 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); },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
@ -813,6 +894,187 @@ 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:
|
||||
|
|
@ -827,9 +1089,10 @@ 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: `<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>`,
|
||||
className: '', html,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
});
|
||||
|
|
@ -1251,6 +1514,7 @@ 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');
|
||||
|
|
@ -1289,6 +1553,24 @@ 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' : ''})`
|
||||
|
|
@ -1307,6 +1589,7 @@ const UI = (() => {
|
|||
${rateHint}
|
||||
</div>
|
||||
${_widgetOpen ? _renderWidget() : ''}
|
||||
${_renderRatingsList()}
|
||||
`;
|
||||
|
||||
// Events
|
||||
|
|
@ -1365,13 +1648,11 @@ const UI = (() => {
|
|||
if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = '…'; }
|
||||
const komm = document.getElementById(`rw-komm-${containerId}`)?.value?.trim() || null;
|
||||
try {
|
||||
const res = await API.ratings.rate(targetType, targetId, _myStars, komm);
|
||||
_avgStars = res.bewertung;
|
||||
_anzahl = res.anz_bewertungen;
|
||||
await API.ratings.rate(targetType, targetId, _myStars, komm);
|
||||
_myKommentar = komm || '';
|
||||
_widgetOpen = false;
|
||||
_hoverStar = 0;
|
||||
_render();
|
||||
await _load(); // frische Liste + Durchschnitt inkl. eigener Bewertung
|
||||
toast.success('Bewertung gespeichert!');
|
||||
} catch (err) {
|
||||
toast.error(err?.message || 'Fehler beim Speichern.');
|
||||
|
|
@ -1388,6 +1669,7 @@ 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) {
|
||||
|
|
@ -1518,6 +1800,7 @@ const UI = (() => {
|
|||
escape, escHtml, help, pageInfo,
|
||||
saveToAlbum,
|
||||
loadLeaflet,
|
||||
loadMapLibreUI,
|
||||
leafletMarker,
|
||||
locationPicker,
|
||||
map,
|
||||
|
|
|
|||
1
backend/static/js/vendor/maplibre-gl.css
vendored
Normal file
1
backend/static/js/vendor/maplibre-gl.css
vendored
Normal file
File diff suppressed because one or more lines are too long
59
backend/static/js/vendor/maplibre-gl.js
vendored
Normal file
59
backend/static/js/vendor/maplibre-gl.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1738
backend/static/js/vendor/pmtiles.js
vendored
Normal file
1738
backend/static/js/vendor/pmtiles.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
9
backend/static/js/vendor/protomaps-leaflet.js
vendored
Normal file
9
backend/static/js/vendor/protomaps-leaflet.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -713,6 +713,7 @@ 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)' };
|
||||
|
|
@ -774,7 +775,8 @@ 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 & ziehen zum Verschieben. ✕ zum Entfernen.
|
||||
Lang drücken & ziehen zum Verschieben. ✕ blendet aus (löscht nicht) —
|
||||
ausgeblendete Funktionen bleiben über „Weitere Funktionen" abrufbar.
|
||||
</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;
|
||||
|
|
@ -869,7 +871,14 @@ 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);
|
||||
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.');
|
||||
}
|
||||
}
|
||||
_render();
|
||||
});
|
||||
});
|
||||
|
|
@ -1399,8 +1408,9 @@ 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" onclick="Worlds.navigateTo('settings')">Anmelden</button>
|
||||
<button class="btn btn-primary" id="world-login-btn">Anmelden</button>
|
||||
</div>`;
|
||||
el.querySelector('#world-login-btn')?.addEventListener('click', () => navigateTo('settings'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
6
backend/static/js/zuechter.js
Normal file
6
backend/static/js/zuechter.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// 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')));
|
||||
});
|
||||
|
|
@ -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=1161"></script>
|
||||
<script src="/js/landing-init.js?v=1219"></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">
|
||||
|
|
|
|||
23
backend/static/leaflet-vector-test.html
Normal file
23
backend/static/leaflet-vector-test.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!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>
|
||||
28
backend/static/maplibre-markers-test.html
Normal file
28
backend/static/maplibre-markers-test.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<!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>
|
||||
23
backend/static/maplibre-perf-test.html
Normal file
23
backend/static/maplibre-perf-test.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!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>
|
||||
37
backend/static/maplibre-test.html
Normal file
37
backend/static/maplibre-test.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<!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>
|
||||
|
|
@ -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" onclick="copyBoilerplate()">Kopieren</button>
|
||||
<button class="copy-btn">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,16 +386,7 @@
|
|||
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<script src="/js/presse.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
============================================================ */
|
||||
|
||||
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
|
||||
const VER = '1161';
|
||||
const VER = '1219';
|
||||
const CACHE_VERSION = `by-v${VER}`;
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
|
|
|||
18
backend/static/ui-vector-test.html
Normal file
18
backend/static/ui-vector-test.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!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>
|
||||
|
|
@ -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" onclick="sessionStorage.setItem('by_stay_in_app','1')">Jetzt als Züchter registrieren</a>
|
||||
<a href="/#register?rolle=breeder" class="cta-btn" data-stay-in-app>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="/" 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>
|
||||
<a href="/" data-stay-in-app>Zur App</a>
|
||||
<a href="/#register?rolle=breeder" class="nav-cta" data-stay-in-app>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" onclick="sessionStorage.setItem('by_stay_in_app','1')">Als Züchter registrieren</a>
|
||||
<a href="/#register?rolle=breeder" class="cta-btn" data-stay-in-app>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,5 +599,6 @@
|
|||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/js/zuechter.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
precip = (daily.get('precipitation_probability_max') or [None])[0]
|
||||
_daily_precip_max = (daily.get('precipitation_probability_max') or [None])[0] # Fallback
|
||||
uv = (daily.get('uv_index_max') or [None])[0]
|
||||
|
||||
desc, icon = _WMO.get(wcode, ('Unbekannt', 'cloud'))
|
||||
|
|
@ -115,6 +115,12 @@ 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):
|
||||
|
|
|
|||
56
docs/DWD_RAIN_FORECAST_PLAN.md
Normal file
56
docs/DWD_RAIN_FORECAST_PLAN.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# 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 z0–9** (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:** z0–9 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 (z0–9) + 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`.
|
||||
148
docs/OFFLINE_MAPS_PLAN.md
Normal file
148
docs/OFFLINE_MAPS_PLAN.md
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
# 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
|
||||
z0–14 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 ≈ 40–120 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 ~8–10 km** Radius ab (mehr Reichweite genau dort wo die Funklöcher sind). Glyphs (~1–2 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. 1–2 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 ~8–10 km.
|
||||
Optional Stufe „Groß ~16 MB" (Stadt ~10 km / Land ~18–22 km) für Wandertage.
|
||||
- Zoom z0–14 (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)`).
|
||||
|
||||
189
docs/TILE_SERVER_HANDOVER.md
Normal file
189
docs/TILE_SERVER_HANDOVER.md
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
# Ü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:369–372`). 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 ~2–3 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).
|
||||
61
tests/test_account_deletion.py
Normal file
61
tests/test_account_deletion.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""
|
||||
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
|
||||
60
tiles/progress.sh
Executable file
60
tiles/progress.sh
Executable file
|
|
@ -0,0 +1,60 @@
|
|||
#!/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)"
|
||||
Loading…
Add table
Add a link
Reference in a new issue