diff --git a/Makefile b/Makefile index b41be52..65fb5b7 100644 --- a/Makefile +++ b/Makefile @@ -107,10 +107,9 @@ deploy-clean: check-ssh @ssh $(DS_HOST) "$(DOCKER) logs $(CONTAINER) --tail=15" # ---------------------------------------------------------- -# SYNC — nur Dateien übertragen, kein Docker -# ACHTUNG: Statische Dateien (CSS/JS/HTML) sind ins Image gebacken! -# sync+restart reicht NUR für Python-Änderungen (routes/*.py etc.) -# Für Frontend-Änderungen immer: make deploy +# SYNC — nur Dateien zur DS übertragen, kein Docker-Rebuild +# ACHTUNG: ALLE Dateien (CSS/JS/HTML/Python) sind ins Image gebacken! +# sync+restart reicht für NICHTS — immer: make deploy # ---------------------------------------------------------- sync: check-ssh @echo "→ Sync zu DS..." @@ -125,7 +124,7 @@ push: # ---------------------------------------------------------- # RESTART — kein Rebuild, nur Container neu starten -# Nach sync von reinen Frontend-Änderungen ausreichend +# Reicht nur für Umgebungsvariablen-Änderungen (.env) # ---------------------------------------------------------- restart: check-ssh @ssh $(DS_HOST) " \ diff --git a/backend/database.py b/backend/database.py index e2712ce..4eab006 100644 --- a/backend/database.py +++ b/backend/database.py @@ -295,6 +295,50 @@ def init_db(): created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + -- OSM POI-Cache (Mülleimer, Hundewiesen, Wasserstellen aus OpenStreetMap) + CREATE TABLE IF NOT EXISTS osm_pois ( + osm_id INTEGER NOT NULL, + type TEXT NOT NULL, + lat REAL NOT NULL, + lon REAL NOT NULL, + name TEXT, + cached_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (osm_id, type) + ); + CREATE INDEX IF NOT EXISTS idx_osm_pois_loc ON osm_pois(type, lat, lon); + + -- OSM Tile-Cache: welche Kacheln wurden schon geladen? + CREATE TABLE IF NOT EXISTS osm_tiles ( + type TEXT NOT NULL, + tile_key TEXT NOT NULL, -- "zoom_x_y" + cached_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (type, tile_key) + ); + + -- Community-Pins: von Nutzern gesetzte Karten-Marker + CREATE TABLE IF NOT EXISTS user_map_pois ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + type TEXT NOT NULL, -- waste_basket | drinking_water | dog_park | sonstiges + lat REAL NOT NULL, + lon REAL NOT NULL, + name TEXT, + notiz TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_user_pois_loc ON user_map_pois(type, lat, lon); + + -- Meldungen: ungültige OSM- oder Community-Marker + CREATE TABLE IF NOT EXISTS osm_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + osm_id INTEGER, -- gesetzt wenn OSM-Marker gemeldet + user_poi_id INTEGER, -- gesetzt wenn Community-Marker gemeldet + type TEXT, + grund TEXT NOT NULL, -- existiert_nicht | falsche_position | spam | sonstiges + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + -- TIERÄRZTE (user-level, nie löschen — Historien-Erhalt bei Umzug) CREATE TABLE IF NOT EXISTS tieraerzte ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -349,6 +393,14 @@ def _migrate(conn_factory): ("tieraerzte", "ort", "TEXT"), # Gesundheit: Erinnerungsintervall für wiederkehrende Einträge ("health", "intervall_tage", "INTEGER"), + # Routen: neue Felder + ("routes", "is_public", "INTEGER NOT NULL DEFAULT 1"), + ("routes", "hunde_tauglichkeit", "TEXT"), + ("routes", "foto_urls", "TEXT NOT NULL DEFAULT '[]'"), + # OSM POIs: Kontaktdaten + ("osm_pois", "opening_hours", "TEXT"), + ("osm_pois", "phone", "TEXT"), + ("osm_pois", "website", "TEXT"), ] with conn_factory() as conn: for table, column, col_type in migrations: diff --git a/backend/main.py b/backend/main.py index 72e0c35..9970fa3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -62,6 +62,7 @@ from routes.routen import router as routen_router from routes.walks import router as walks_router from routes.events import router as events_router from routes.sitting import router as sitting_router +from routes.osm import router as osm_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -76,6 +77,7 @@ app.include_router(routen_router, prefix="/api/routes", tags=["Routen"]) app.include_router(walks_router, prefix="/api/walks", tags=["Gassi-Treffen"]) app.include_router(events_router, prefix="/api/events", tags=["Events"]) app.include_router(sitting_router, prefix="/api/sitting", tags=["Sitting"]) +app.include_router(osm_router, prefix="/api/osm", tags=["OSM"]) # ------------------------------------------------------------------ @@ -129,49 +131,6 @@ async def share_target(request: Request): headers={"Cache-Control": "no-cache"} ) -# Cache-Reset-Seite — löscht SW + Caches, leitet zur App weiter -@app.get("/update") -async def force_update(): - from fastapi.responses import HTMLResponse - html = """ - - - - - Ban Yaro — Aktualisieren - - - -
-

Ban Yaro

-

App wird aktualisiert…

-
-
- - -""" - return HTMLResponse(html, headers={"Cache-Control": "no-store"}) - - # SPA Fallback — ALLE nicht-API-Routen gehen zur index.html @app.get("/{full_path:path}") async def spa_fallback(full_path: str): diff --git a/backend/routes/osm.py b/backend/routes/osm.py new file mode 100644 index 0000000..3056824 --- /dev/null +++ b/backend/routes/osm.py @@ -0,0 +1,317 @@ +""" +BAN YARO — OSM/Overpass POI-Cache + Community-Pins +Cacht OSM-Daten lokal, erlaubt Nutzern eigene Marker und Meldungen. +""" + +import math +import httpx +import logging +from typing import Optional +from fastapi import APIRouter, Query, BackgroundTasks, Depends, HTTPException +from pydantic import BaseModel +from database import db +from auth import get_current_user, get_current_user_optional as get_optional_user + +logger = logging.getLogger(__name__) +router = APIRouter() + +CACHE_ZOOM = 12 +CACHE_DAYS = 14 +OVERPASS_URL = 'https://overpass-api.de/api/interpreter' + +OSM_QUERIES = { + 'waste_basket': '[out:json][timeout:20];node["amenity"="waste_basket"]({bbox});out;', + 'dog_park': '[out:json][timeout:25];(way["leisure"="dog_park"]({bbox});node["leisure"="dog_park"]({bbox});way["leisure"="park"]["dog"="yes"]({bbox});node["leisure"="park"]["dog"="yes"]({bbox}););out center;', + 'drinking_water': '[out:json][timeout:20];node["amenity"="drinking_water"]({bbox});out;', + 'tierarzt': '[out:json][timeout:25];(node["amenity"="veterinary"]({bbox});way["amenity"="veterinary"]({bbox}););out center;', + 'shop': '[out:json][timeout:25];(node["shop"="pet"]({bbox});way["shop"="pet"]({bbox}););out center;', + 'restaurant': '[out:json][timeout:35];(node["amenity"="restaurant"]({bbox});way["amenity"="restaurant"]({bbox});node["amenity"="cafe"]({bbox});way["amenity"="cafe"]({bbox});node["amenity"="biergarten"]({bbox});way["amenity"="biergarten"]({bbox}););out center;', + 'bank': '[out:json][timeout:20];node["amenity"="bench"]({bbox});out;', +} + +# Ab dieser Anzahl Meldungen wird ein Marker ausgeblendet +REPORT_THRESHOLD = 3 + + +# ------------------------------------------------------------------ +# Tile-Mathematik +# ------------------------------------------------------------------ +def _lat_lon_to_tile(lat, lon, zoom): + n = 2 ** zoom + x = int((lon + 180) / 360 * n) + y = int((1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n) + return x, y + +def _tile_to_bbox(x, y, zoom): + n = 2 ** zoom + west = x / n * 360 - 180 + east = (x + 1) / n * 360 - 180 + north = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / n)))) + south = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / n)))) + return south, west, north, east + +def _covering_tiles(south, west, north, east, zoom): + x0, y0 = _lat_lon_to_tile(north, west, zoom) + x1, y1 = _lat_lon_to_tile(south, east, zoom) + return [(x, y) for x in range(x0, x1 + 1) for y in range(y0, y1 + 1)] + + +# ------------------------------------------------------------------ +# Overpass-Fetch + Cache +# ------------------------------------------------------------------ +async def _fetch_overpass(query): + async with httpx.AsyncClient(timeout=40) as client: + r = await client.post(OVERPASS_URL, data={'data': query}) + r.raise_for_status() + return r.json().get('elements', []) + +def _stale_tiles(poi_type, tiles): + stale = [] + with db() as conn: + for (x, y) in tiles: + key = f"{CACHE_ZOOM}_{x}_{y}" + row = conn.execute( + """SELECT 1 FROM osm_tiles WHERE type=? AND tile_key=? + AND cached_at > datetime('now', ?)""", + (poi_type, key, f'-{CACHE_DAYS} days') + ).fetchone() + if not row: + stale.append((x, y)) + return stale + +async def _fetch_and_store_tile(poi_type, x, y): + key = f"{CACHE_ZOOM}_{x}_{y}" + s, w, n, e = _tile_to_bbox(x, y, CACHE_ZOOM) + query = OSM_QUERIES[poi_type].format(bbox=f"{s},{w},{n},{e}") + try: + elements = await _fetch_overpass(query) + except Exception as exc: + logger.warning(f"Overpass Fehler {poi_type} Tile {key}: {exc}") + return + with db() as conn: + for el in elements: + osm_id = el.get('id') + lat = el.get('lat') or (el.get('center') or {}).get('lat') + lon = el.get('lon') or (el.get('center') or {}).get('lon') + if not (osm_id and lat and lon): + continue + tags = el.get('tags') or {} + name = tags.get('name') or tags.get('description') + opening_hours = tags.get('opening_hours') + phone = tags.get('phone') or tags.get('contact:phone') or tags.get('telephone') + website = tags.get('website') or tags.get('contact:website') or tags.get('url') + conn.execute(""" + INSERT INTO osm_pois (osm_id, type, lat, lon, name, opening_hours, phone, website, cached_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(osm_id, type) DO UPDATE SET + lat=excluded.lat, lon=excluded.lon, + name=excluded.name, + opening_hours=excluded.opening_hours, + phone=excluded.phone, + website=excluded.website, + cached_at=excluded.cached_at + """, (osm_id, poi_type, lat, lon, name, opening_hours, phone, website)) + conn.execute(""" + INSERT INTO osm_tiles (type, tile_key, cached_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(type, tile_key) DO UPDATE SET cached_at=excluded.cached_at + """, (poi_type, key)) + logger.info(f"OSM Tile {key} ({poi_type}): {len(elements)} POIs gecacht.") + + +# ------------------------------------------------------------------ +# GET /pois — OSM + Community-Pins +# fast=true: nur DB, kein Overpass-Fetch (sofortantwort) +# fast=false (default): fetcht Overpass wenn Tiles veraltet sind +# ------------------------------------------------------------------ +@router.get('/pois') +async def get_pois( + type: str = Query(...), + south: float = Query(...), + west: float = Query(...), + north: float = Query(...), + east: float = Query(...), + fast: bool = Query(False), + user = Depends(get_optional_user), +): + result = [] + fetched_fresh = False + + if type in OSM_QUERIES: + tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM) + stale = _stale_tiles(type, tiles) + + if stale and not fast: + for (x, y) in stale: + await _fetch_and_store_tile(type, x, y) + fetched_fresh = True + + with db() as conn: + reported = { + row[0] for row in conn.execute( + """SELECT osm_id FROM osm_reports + WHERE type=? AND osm_id IS NOT NULL + GROUP BY osm_id HAVING COUNT(*) >= ?""", + (type, REPORT_THRESHOLD) + ).fetchall() + } + rows = conn.execute(""" + SELECT osm_id, lat, lon, name, opening_hours, phone, website FROM osm_pois + WHERE type=? AND lat BETWEEN ? AND ? AND lon BETWEEN ? AND ? + """, (type, south, north, west, east)).fetchall() + + for r in rows: + if r['osm_id'] not in reported: + result.append({ + 'id': r['osm_id'], + 'lat': r['lat'], + 'lon': r['lon'], + 'name': r['name'], + 'opening_hours': r['opening_hours'], + 'phone': r['phone'], + 'website': r['website'], + 'source': 'osm', + 'fresh': fetched_fresh, + }) + + # Community-Pins: user_map_pois passend zum Typ + # type='sonstiges' → zeigt alle 'sonstiges'-Pins + # type='waste_basket' etc. → zeigt user-submitted POIs dieses Typs + user_poi_type = type # direkte Übereinstimmung + with db() as conn: + reported_user = { + row[0] for row in conn.execute( + """SELECT user_poi_id FROM osm_reports + WHERE user_poi_id IS NOT NULL + GROUP BY user_poi_id HAVING COUNT(*) >= ?""", + (REPORT_THRESHOLD,) + ).fetchall() + } + user_pois = conn.execute(""" + SELECT p.id, p.lat, p.lon, p.name, p.notiz, p.user_id, p.type, + u.name AS username + FROM user_map_pois p + LEFT JOIN users u ON u.id = p.user_id + WHERE p.type=? AND p.lat BETWEEN ? AND ? AND p.lon BETWEEN ? AND ? + """, (user_poi_type, south, north, west, east)).fetchall() + + user_id = user['id'] if user else None + for p in user_pois: + if p['id'] not in reported_user: + result.append({ + 'id': f"u{p['id']}", + 'user_poi_id': p['id'], + 'lat': p['lat'], + 'lon': p['lon'], + 'name': p['name'], + 'notiz': p['notiz'], + 'username': p['username'], + 'source': 'user', + 'own': p['user_id'] == user_id, + }) + + return result + + +# ------------------------------------------------------------------ +# POST /user-poi — Community-Marker setzen +# ------------------------------------------------------------------ +class UserPoiIn(BaseModel): + type: str + lat: float + lon: float + name: Optional[str] = None + notiz: Optional[str] = None + +ALLOWED_TYPES = { + 'waste_basket', 'drinking_water', 'dog_park', + 'giftkoeder', # Giftköder-Meldung (Community-Pin mit Radius) + 'kotbeutel', # Kotbeutelspender + 'gefahr', # Allgemeine Gefahr / Hinweis + 'parkplatz', # Hundefreundlicher Parkplatz + 'treffpunkt', # Treffpunkt für Hundehalter + 'sonstiges', +} + +@router.post('/user-poi') +async def add_user_poi(body: UserPoiIn, user = Depends(get_current_user)): + if body.type not in ALLOWED_TYPES: + raise HTTPException(400, 'Ungültiger Typ') + with db() as conn: + row = conn.execute(""" + INSERT INTO user_map_pois (user_id, type, lat, lon, name, notiz) + VALUES (?, ?, ?, ?, ?, ?) + """, (user['id'], body.type, body.lat, body.lon, body.name, body.notiz)) + new_id = row.lastrowid + return {'id': new_id, 'status': 'ok'} + + +# ------------------------------------------------------------------ +# DELETE /user-poi/{id} — eigenen Marker löschen +# ------------------------------------------------------------------ +@router.delete('/user-poi/{poi_id}') +async def delete_user_poi(poi_id: int, user = Depends(get_current_user)): + with db() as conn: + row = conn.execute( + "SELECT user_id FROM user_map_pois WHERE id=?", (poi_id,) + ).fetchone() + if not row: + raise HTTPException(404, 'Nicht gefunden') + if row['user_id'] != user['id']: + raise HTTPException(403, 'Nicht berechtigt') + conn.execute("DELETE FROM user_map_pois WHERE id=?", (poi_id,)) + return {'status': 'ok'} + + +# ------------------------------------------------------------------ +# POST /report — Marker als ungültig melden +# ------------------------------------------------------------------ +class ReportIn(BaseModel): + type: str + grund: str + osm_id: Optional[int] = None + user_poi_id: Optional[int] = None + +ALLOWED_GRUENDE = {'existiert_nicht', 'falsche_position', 'spam', 'sonstiges'} + +@router.post('/report') +async def report_poi(body: ReportIn, user = Depends(get_current_user)): + if not body.osm_id and not body.user_poi_id: + raise HTTPException(400, 'osm_id oder user_poi_id erforderlich') + if body.grund not in ALLOWED_GRUENDE: + raise HTTPException(400, 'Ungültiger Grund') + with db() as conn: + # Doppelmeldung vom selben User verhindern + existing = conn.execute(""" + SELECT 1 FROM osm_reports + WHERE user_id=? AND osm_id IS ? AND user_poi_id IS ? + """, (user['id'], body.osm_id, body.user_poi_id)).fetchone() + if existing: + return {'status': 'bereits_gemeldet'} + conn.execute(""" + INSERT INTO osm_reports (user_id, osm_id, user_poi_id, type, grund) + VALUES (?, ?, ?, ?, ?) + """, (user['id'], body.osm_id, body.user_poi_id, body.type, body.grund)) + return {'status': 'ok'} + + +# ------------------------------------------------------------------ +# POST /analyze — Cache-Warmup für alle Typen +# ------------------------------------------------------------------ +@router.post('/analyze') +async def analyze_region( + background_tasks: BackgroundTasks, + south: float = Query(...), + west: float = Query(...), + north: float = Query(...), + east: float = Query(...), +): + tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM) + + async def _warmup(): + for poi_type in OSM_QUERIES: + for (x, y) in _stale_tiles(poi_type, tiles): + await _fetch_and_store_tile(poi_type, x, y) + + background_tasks.add_task(_warmup) + return {'status': 'gestartet', 'tiles': len(tiles), 'types': list(OSM_QUERIES.keys())} diff --git a/backend/routes/routen.py b/backend/routes/routen.py index 6b13690..1b5a60c 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -1,11 +1,11 @@ """BAN YARO — Gassi-Routen""" -import json, math -from fastapi import APIRouter, Depends, HTTPException +import json, math, os, uuid +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from typing import Optional, List from database import db -from auth import get_current_user +from auth import get_current_user, get_current_user_optional router = APIRouter() @@ -37,6 +37,8 @@ class RouteCreate(BaseModel): untergrund: Optional[str] = None # wald | asphalt | wiese | mix schatten: Optional[bool] = None leine_empfohlen: Optional[bool] = None + is_public: Optional[bool] = True + hunde_tauglichkeit: Optional[str] = None # eingeschränkt | gut | sehr_gut | premium class RouteUpdate(BaseModel): name: Optional[str] = None @@ -45,15 +47,27 @@ class RouteUpdate(BaseModel): untergrund: Optional[str] = None schatten: Optional[bool] = None leine_empfohlen: Optional[bool] = None + is_public: Optional[bool] = None + hunde_tauglichkeit: Optional[str] = None + + +def _simplify_track(track: list, max_pts: int = 40) -> list: + """Reduziert GPS-Track auf max_pts Punkte für Vorschau.""" + if len(track) <= max_pts: + return track + step = len(track) / max_pts + return [track[round(i * step)] for i in range(max_pts)] def _parse(row) -> dict: d = dict(row) if isinstance(d.get('gps_track'), str): d['gps_track'] = json.loads(d['gps_track']) - for k in ('schatten', 'leine_empfohlen'): + for k in ('schatten', 'leine_empfohlen', 'is_public'): if d.get(k) is not None: d[k] = bool(d[k]) + if isinstance(d.get('foto_urls'), str): + d['foto_urls'] = json.loads(d['foto_urls']) return d @@ -65,6 +79,7 @@ async def list_routes( lat: Optional[float] = None, lon: Optional[float] = None, radius: int = 10000, + user = Depends(get_current_user_optional), ): with db() as conn: rows = conn.execute(""" @@ -72,11 +87,12 @@ async def list_routes( r.distanz_km, r.dauer_min, r.schwierigkeit, r.untergrund, r.schatten, r.leine_empfohlen, r.bewertung, r.anz_bewertungen, r.created_at, + r.gps_track, r.is_public, r.hunde_tauglichkeit, r.foto_urls, u.name AS user_name, json_extract(r.gps_track, '$[0].lat') AS start_lat, json_extract(r.gps_track, '$[0].lon') AS start_lon, - json_extract(r.gps_track, '$[-1].lat') AS end_lat, - json_extract(r.gps_track, '$[-1].lon') AS end_lon + json_extract(r.gps_track, '$[#-1].lat') AS end_lat, + json_extract(r.gps_track, '$[#-1].lon') AS end_lon FROM routes r LEFT JOIN users u ON u.id = r.user_id ORDER BY r.created_at DESC @@ -85,9 +101,14 @@ async def list_routes( result = [] for row in rows: d = dict(row) - for k in ('schatten', 'leine_empfohlen'): + for k in ('schatten', 'leine_empfohlen', 'is_public'): if d.get(k) is not None: d[k] = bool(d[k]) + if isinstance(d.get('foto_urls'), str): + d['foto_urls'] = json.loads(d['foto_urls']) + raw_track = json.loads(d.get('gps_track') or '[]') + d['preview_track'] = _simplify_track(raw_track, 40) + del d['gps_track'] result.append(d) if lat is not None and lon is not None: @@ -95,6 +116,9 @@ async def list_routes( r for r in result if r['start_lat'] and _haversine(lat, lon, r['start_lat'], r['start_lon']) <= radius ] + + user_id = user['id'] if user else None + result = [r for r in result if r.get('is_public', True) or r.get('user_id') == user_id] return result @@ -112,13 +136,15 @@ async def create_route(data: RouteCreate, user=Depends(get_current_user)): cur = conn.execute(""" INSERT INTO routes (user_id, name, beschreibung, gps_track, distanz_km, dauer_min, - schwierigkeit, untergrund, schatten, leine_empfohlen) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + schwierigkeit, untergrund, schatten, leine_empfohlen, is_public, hunde_tauglichkeit) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( user['id'], data.name, data.beschreibung, gps_json, data.distanz_km, data.dauer_min, data.schwierigkeit, data.untergrund, int(data.schatten) if data.schatten is not None else None, int(data.leine_empfohlen) if data.leine_empfohlen is not None else None, + int(data.is_public) if data.is_public is not None else 1, + data.hunde_tauglichkeit, )) row = conn.execute("SELECT * FROM routes WHERE id = ?", (cur.lastrowid,)).fetchone() return _parse(row) @@ -153,7 +179,7 @@ async def update_route(route_id: int, data: RouteUpdate, user=Depends(get_curren updates = data.model_dump(exclude_none=True) if updates: - for key in ('schatten', 'leine_empfohlen'): + for key in ('schatten', 'leine_empfohlen', 'is_public'): if key in updates: updates[key] = int(updates[key]) cols = ', '.join(f"{k} = ?" for k in updates) @@ -175,3 +201,61 @@ async def delete_route(route_id: int, user=Depends(get_current_user)): if row['user_id'] != user['id']: raise HTTPException(403, "Nicht berechtigt.") conn.execute("DELETE FROM routes WHERE id = ?", (route_id,)) + + +# ------------------------------------------------------------------ +# POST /api/routes/{id}/rate — Bewertung abgeben +# ------------------------------------------------------------------ +class RouteRate(BaseModel): + wertung: float # 1–5 + +@router.post("/{route_id}/rate") +async def rate_route(route_id: int, data: RouteRate, user=Depends(get_current_user)): + if not 1 <= data.wertung <= 5: + raise HTTPException(400, "Wertung muss zwischen 1 und 5 liegen.") + with db() as conn: + row = conn.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone() + if not row: + raise HTTPException(404, "Route nicht gefunden.") + r = dict(row) + n = (r['anz_bewertungen'] or 0) + 1 + avg = ((r['bewertung'] or 0) * (n - 1) + data.wertung) / n + conn.execute( + "UPDATE routes SET bewertung=?, anz_bewertungen=? WHERE id=?", + (round(avg, 2), n, route_id) + ) + return {'bewertung': round(avg, 2), 'anz_bewertungen': n} + + +# ------------------------------------------------------------------ +# POST /api/routes/{id}/photo — Foto hochladen +# ------------------------------------------------------------------ +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + +@router.post("/{route_id}/photo", status_code=201) +async def add_route_photo( + route_id: int, + file: UploadFile = File(...), + user = Depends(get_current_user), +): + with db() as conn: + row = conn.execute("SELECT * FROM routes WHERE id=?", (route_id,)).fetchone() + if not row: + raise HTTPException(404, "Route nicht gefunden.") + if dict(row)['user_id'] != user['id']: + raise HTTPException(403, "Nicht berechtigt.") + + ext = os.path.splitext(file.filename or "")[1] or ".jpg" + filename = f"route_{route_id}_{uuid.uuid4().hex[:8]}{ext}" + path = os.path.join(MEDIA_DIR, "routes", filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(await file.read()) + + foto_url = f"/media/routes/{filename}" + with db() as conn: + row = conn.execute("SELECT foto_urls FROM routes WHERE id=?", (route_id,)).fetchone() + urls = json.loads(dict(row)['foto_urls'] or '[]') + urls.append(foto_url) + conn.execute("UPDATE routes SET foto_urls=? WHERE id=?", (json.dumps(urls), route_id)) + return {'foto_url': foto_url, 'foto_urls': urls} diff --git a/backend/scheduler.py b/backend/scheduler.py index 09c7a85..ecd8bfa 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -22,10 +22,17 @@ def start(): CronTrigger(hour=8, minute=0), # täglich 08:00 Uhr id="health_reminders", replace_existing=True, - misfire_grace_time=3600, # bis zu 1h Verzug ok (z.B. nach Neustart) + misfire_grace_time=3600, + ) + _scheduler.add_job( + _job_poison_archive, + CronTrigger(hour=3, minute=0), # täglich 03:00 Uhr (ruhige Zeit) + id="poison_archive", + replace_existing=True, + misfire_grace_time=3600, ) _scheduler.start() - logger.info("Scheduler gestartet — Health-Reminder täglich 08:00 Uhr.") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00.") def stop(): @@ -87,3 +94,31 @@ async def _job_health_reminders(): logger.info(f"Reminder Push: user={r['user_id']} entry={r['id']} delta={delta}d") logger.info(f"Health-Reminder Job fertig — {len(rows)} Einträge, {sent_total} Push gesendet.") + + +# ------------------------------------------------------------------ +# JOB: Abgelaufene Giftköder-Meldungen archivieren +# Abgelaufene, aber noch nicht manuell aufgelöste Einträge werden +# sauber als geloest=1 markiert — für spätere KI-Musteranalyse. +# Die Zeilen selbst werden NIE gelöscht. +# ------------------------------------------------------------------ +async def _job_poison_archive(): + """ + Findet Giftköder-Meldungen deren expires_at verstrichen ist + und die noch nicht als geloest markiert wurden. + Setzt geloest=1, geloest_grund='automatisch_abgelaufen'. + """ + from datetime import datetime + now = datetime.utcnow().isoformat() + with db() as conn: + result = conn.execute(""" + UPDATE poison + SET geloest = 1, + geloest_at = datetime('now'), + geloest_grund = 'automatisch_abgelaufen' + WHERE geloest = 0 + AND expires_at < ? + """, (now,)) + count = result.rowcount + if count: + logger.info(f"Giftköder-Archiv: {count} abgelaufene Meldungen archiviert.") diff --git a/backend/static/css/MarkerCluster.Default.css b/backend/static/css/MarkerCluster.Default.css new file mode 100644 index 0000000..bbc8c9f --- /dev/null +++ b/backend/static/css/MarkerCluster.Default.css @@ -0,0 +1,60 @@ +.marker-cluster-small { + background-color: rgba(181, 226, 140, 0.6); + } +.marker-cluster-small div { + background-color: rgba(110, 204, 57, 0.6); + } + +.marker-cluster-medium { + background-color: rgba(241, 211, 87, 0.6); + } +.marker-cluster-medium div { + background-color: rgba(240, 194, 12, 0.6); + } + +.marker-cluster-large { + background-color: rgba(253, 156, 115, 0.6); + } +.marker-cluster-large div { + background-color: rgba(241, 128, 23, 0.6); + } + + /* IE 6-8 fallback colors */ +.leaflet-oldie .marker-cluster-small { + background-color: rgb(181, 226, 140); + } +.leaflet-oldie .marker-cluster-small div { + background-color: rgb(110, 204, 57); + } + +.leaflet-oldie .marker-cluster-medium { + background-color: rgb(241, 211, 87); + } +.leaflet-oldie .marker-cluster-medium div { + background-color: rgb(240, 194, 12); + } + +.leaflet-oldie .marker-cluster-large { + background-color: rgb(253, 156, 115); + } +.leaflet-oldie .marker-cluster-large div { + background-color: rgb(241, 128, 23); +} + +.marker-cluster { + background-clip: padding-box; + border-radius: 20px; + } +.marker-cluster div { + width: 30px; + height: 30px; + margin-left: 5px; + margin-top: 5px; + + text-align: center; + border-radius: 15px; + font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif; + } +.marker-cluster span { + line-height: 30px; + } \ No newline at end of file diff --git a/backend/static/css/MarkerCluster.css b/backend/static/css/MarkerCluster.css new file mode 100644 index 0000000..c60d71b --- /dev/null +++ b/backend/static/css/MarkerCluster.css @@ -0,0 +1,14 @@ +.leaflet-cluster-anim .leaflet-marker-icon, .leaflet-cluster-anim .leaflet-marker-shadow { + -webkit-transition: -webkit-transform 0.3s ease-out, opacity 0.3s ease-in; + -moz-transition: -moz-transform 0.3s ease-out, opacity 0.3s ease-in; + -o-transition: -o-transform 0.3s ease-out, opacity 0.3s ease-in; + transition: transform 0.3s ease-out, opacity 0.3s ease-in; +} + +.leaflet-cluster-spider-leg { + /* stroke-dashoffset (duration and function) should match with leaflet-marker-icon transform in order to track it exactly */ + -webkit-transition: -webkit-stroke-dashoffset 0.3s ease-out, -webkit-stroke-opacity 0.3s ease-in; + -moz-transition: -moz-stroke-dashoffset 0.3s ease-out, -moz-stroke-opacity 0.3s ease-in; + -o-transition: -o-stroke-dashoffset 0.3s ease-out, -o-stroke-opacity 0.3s ease-in; + transition: stroke-dashoffset 0.3s ease-out, stroke-opacity 0.3s ease-in; +} diff --git a/backend/static/css/components.css b/backend/static/css/components.css index cf0a83a..3deaa58 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -1439,126 +1439,329 @@ textarea.form-control { } /* ============================================================ - ROUTEN (routes.js) + ROUTEN — Komoot-Stil (routes.js) ============================================================ */ -.routes-layout { +.rk-layout { display: flex; flex-direction: column; height: 100%; overflow: hidden; + background: var(--c-bg); } -.routes-tabs { - display: flex; - border-bottom: 2px solid var(--c-border-light); - flex-shrink: 0; - background: var(--c-surface); -} -.routes-tab { - flex: 1; - padding: var(--space-3) var(--space-4); - font-size: var(--text-sm); - font-weight: var(--weight-medium); - color: var(--c-text-secondary); - background: none; - border: none; - cursor: pointer; - border-bottom: 3px solid transparent; - margin-bottom: -2px; - transition: all 0.15s; -} -.routes-tab.active { - color: var(--c-primary); - border-color: var(--c-primary); -} -.routes-tab-content { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} -.routes-map { - height: 40%; - min-height: 160px; +.rk-header { + background: var(--c-surface); + border-bottom: 1px solid var(--c-border-light); + padding: var(--space-3) var(--space-4); flex-shrink: 0; } -.routes-list { +.rk-search-row { + display: flex; + gap: var(--space-2); + margin-bottom: var(--space-3); + align-items: center; +} +/* Import-Label als Button */ +.rk-imp-btn { + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; +} +/* Import Modal */ +.rk-import-preview { + height: 160px; + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--c-surface-2); + margin-bottom: var(--space-3); +} +.rk-import-stats { + display: flex; + gap: var(--space-3); + flex-wrap: wrap; + align-items: center; + font-size: var(--text-sm); + color: var(--c-text-secondary); + margin-bottom: var(--space-2); +} +.rk-search { + flex: 1; + padding: var(--space-2) var(--space-3); + border: 1.5px solid var(--c-border); + border-radius: var(--radius-full); + font-size: var(--text-sm); + background: var(--c-bg); + color: var(--c-text); + outline: none; +} +.rk-search:focus { border-color: var(--c-primary); } +.rk-rec-btn { + white-space: nowrap; + flex-shrink: 0; +} +.rk-filters { + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.rk-filter-group { + display: flex; + gap: var(--space-1); + overflow-x: auto; + scrollbar-width: none; + flex-wrap: nowrap; +} +.rk-filter-group::-webkit-scrollbar { display: none; } +.rk-chip { + padding: 4px 10px; + border-radius: var(--radius-full); + border: 1.5px solid var(--c-border); + background: var(--c-bg); + color: var(--c-text-secondary); + font-size: var(--text-xs); + white-space: nowrap; + cursor: pointer; + transition: all 0.15s; +} +.rk-chip.active { + background: var(--c-primary); + border-color: var(--c-primary); + color: #fff; +} +.rk-grid { flex: 1; overflow-y: auto; padding: var(--space-3) var(--space-4); display: flex; flex-direction: column; - gap: var(--space-2); + gap: var(--space-3); scrollbar-width: thin; - scrollbar-color: var(--c-primary) var(--c-surface); } -.routes-card { +.rk-loading, .rk-empty { + text-align: center; + padding: var(--space-10) var(--space-4); + color: var(--c-text-secondary); +} +.rk-empty-icon { font-size: 3rem; margin-bottom: var(--space-3); } +.rk-empty--onboarding { padding: var(--space-6) var(--space-4); } +.rk-empty-title { font-size: var(--text-xl); font-weight: 700; color: var(--c-text-primary); margin: 0 0 var(--space-2); } +.rk-empty-text { color: var(--c-text-secondary); margin-bottom: var(--space-5); max-width: 320px; margin-left: auto; margin-right: auto; } +.rk-empty-features { + display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-2) var(--space-4); + max-width: 320px; margin: 0 auto var(--space-6); text-align: left; +} +.rk-empty-feature { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-sm); color: var(--c-text-secondary); } +.rk-empty-feature span:first-child { font-size: 1.1rem; flex-shrink: 0; } +.rk-card { display: flex; - align-items: flex-start; - gap: var(--space-3); - padding: var(--space-3); + flex-direction: column; background: var(--c-surface); border: 1.5px solid var(--c-border-light); - border-radius: var(--radius-lg); + border-radius: var(--radius-xl); + overflow: hidden; cursor: pointer; - transition: box-shadow 0.15s; + transition: box-shadow 0.15s, transform 0.15s; } -.routes-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); } -.routes-card-num { - width: 28px; - height: 28px; - border-radius: 50%; - color: #fff; - font-weight: var(--weight-bold); - font-size: var(--text-sm); - display: flex; - align-items: center; +.rk-card:hover { + box-shadow: 0 4px 16px rgba(0,0,0,0.1); + transform: translateY(-1px); +} +.rk-card-preview { + height: 140px; + overflow: hidden; + background: #e8f0e8; + flex-shrink: 0; +} +.rk-preview-empty { + display: flex; + align-items: center; justify-content: center; - flex-shrink: 0; + height: 100%; + font-size: 2.5rem; + color: var(--c-text-muted); + opacity: 0.4; } -.routes-card-body { flex: 1; min-width: 0; } -.routes-card-name { font-weight: var(--weight-semibold); color: var(--c-text); } -.routes-card-meta { font-size: var(--text-sm); color: var(--c-text-secondary); margin-top: 2px; } -.routes-card-tags { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-top: var(--space-2); } -.routes-badge { +.rk-card-body { + padding: var(--space-3) var(--space-4) var(--space-4); +} +.rk-card-name { + font-weight: var(--weight-semibold); + font-size: var(--text-base); + margin-bottom: var(--space-1); +} +.rk-card-stats { + display: flex; + gap: var(--space-3); + font-size: var(--text-sm); + color: var(--c-text-secondary); + margin-bottom: var(--space-2); +} +.rk-card-tags { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); + margin-bottom: var(--space-3); +} +.rk-badge { font-size: var(--text-xs); padding: 2px 8px; border-radius: var(--radius-full); background: var(--c-surface-2); color: var(--c-text-secondary); } -.routes-badge--leicht { background: #dcfce7; color: #15803d; } -.routes-badge--mittel { background: #fef9c3; color: #a16207; } -.routes-badge--anspruchsvoll{ background: #fee2e2; color: #b91c1c; } -.routes-badge--info { background: var(--c-primary-subtle); color: var(--c-primary-dark); } - -/* Aufzeichnungs-Panel */ -.routes-rec-panel { - padding: var(--space-5) var(--space-4); - background: var(--c-surface); - flex-shrink: 0; +.rk-badge--leicht { background: #dcfce7; color: #15803d; } +.rk-badge--mittel { background: #fef9c3; color: #a16207; } +.rk-badge--anspruchsvoll{ background: #fee2e2; color: #b91c1c; } +.rk-badge--info { background: var(--c-primary-subtle); color: var(--c-primary-dark); } +.rk-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); } -.routes-rec-stats { - display: flex; - gap: var(--space-4); - margin-bottom: var(--space-4); - justify-content: center; +.rk-stars { + display: flex; + align-items: center; + gap: 2px; } -.routes-rec-stat { - display: flex; - flex-direction: column; - align-items: center; - min-width: 70px; -} -.routes-rec-stat-val { - font-size: 1.8rem; - font-weight: var(--weight-bold); - color: var(--c-primary); +.rk-star { + font-size: 1.1rem; + cursor: pointer; + color: var(--c-border); + transition: color 0.1s, transform 0.1s; line-height: 1; } -.routes-rec-stat-lbl { - font-size: var(--text-xs); - color: var(--c-text-muted); - margin-top: 2px; +.rk-star.filled { color: #F59E0B; } +.rk-star:hover { color: #F59E0B; transform: scale(1.2); } +.rk-star-count { font-size: var(--text-xs); color: var(--c-text-muted); margin-left: 4px; } +.rk-card-actions { + display: flex; + align-items: center; + gap: var(--space-2); +} +.rk-card-author { font-size: var(--text-xs); color: var(--c-text-muted); } +.rk-dl-btn { + font-size: var(--text-xs); + padding: 4px 8px; + border-radius: var(--radius-md); + border: 1px solid var(--c-border); + background: var(--c-bg); + color: var(--c-text-secondary); + cursor: pointer; + white-space: nowrap; +} +.rk-dl-btn:hover { background: var(--c-surface-2); } + +/* Hundetauglichkeit-Badge */ +.rk-badge--dog { background: #fef3c7; color: #92400e; font-size: 1rem; } +.rk-badge--private { background: #f1f5f9; color: #64748b; } + +/* Foto-Galerie in Route-Detail */ +.rk-photo-gallery { + display: flex; + gap: var(--space-2); + overflow-x: auto; + margin: var(--space-2) 0; + scrollbar-width: thin; +} +.rk-photo-thumb { + width: 80px; + height: 80px; + object-fit: cover; + border-radius: var(--radius-md); + cursor: pointer; + flex-shrink: 0; + border: 2px solid var(--c-border-light); +} +.rk-photo-add { + display: flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; + border: 2px dashed var(--c-border); + border-radius: var(--radius-md); + cursor: pointer; + flex-shrink: 0; + font-size: 1.5rem; + color: var(--c-text-muted); +} +.rk-photo-add-empty { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-3); + border: 2px dashed var(--c-border); + border-radius: var(--radius-md); + cursor: pointer; + color: var(--c-text-secondary); + font-size: var(--text-sm); + margin: var(--space-2) 0; +} + +/* Nearby POIs */ +.rk-nearby-section { margin-top: var(--space-3); } +.rk-nearby-title { + font-weight: var(--weight-semibold); + margin-bottom: var(--space-2); + color: var(--c-text); +} +.rk-nearby-group { + margin-bottom: var(--space-3); +} +.rk-nearby-group-label { + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--c-text-secondary); + margin-bottom: var(--space-1); +} +.rk-nearby-item { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: baseline; + padding: var(--space-1) 0; + border-bottom: 1px solid var(--c-border-light); +} +.rk-nearby-item:last-child { border-bottom: none; } +.rk-nearby-name { font-size: var(--text-sm); color: var(--c-text); } +.rk-nearby-detail { font-size: var(--text-xs); color: var(--c-text-muted); } +.rk-nearby-phone { color: var(--c-primary); text-decoration: none; } + +/* Hundetauglichkeit-Auswahl im Formular */ +.rk-paw-select { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-1); +} +.rk-paw-btn { + padding: var(--space-2) var(--space-3); + border: 1.5px solid var(--c-border); + border-radius: var(--radius-full); + background: var(--c-bg); + color: var(--c-text-secondary); + font-size: var(--text-sm); + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.rk-paw-btn.selected { + border-color: var(--c-primary); + background: var(--c-primary-subtle); + color: var(--c-primary-dark); + font-weight: var(--weight-medium); +} + +/* Aufzeichnungs-FAB Zustand */ +.map-fab--rec.recording { + background: #EF4444; + border-color: #EF4444; + color: #fff; + animation: rec-pulse 1.2s ease-in-out infinite; +} +@keyframes rec-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.5); } + 50% { box-shadow: 0 0 0 8px rgba(239,68,68,0); } } /* ============================================================ @@ -1574,50 +1777,80 @@ textarea.form-control { z-index: 1; } @media (min-width: 768px) { - .map-full-layout { - top: 0; - left: var(--nav-sidebar-width); - bottom: 0; - } -} -.map-full { - width: 100%; - height: 100%; + .map-full-layout { top: 0; left: var(--nav-sidebar-width); bottom: 0; } } +.map-full { width: 100%; height: 100%; } + +/* Legende: horizontaler Scroll-Strip oben */ .map-legend { position: absolute; - top: var(--space-3); - left: 50%; - transform: translateX(-50%); + top: var(--space-2); + left: 42px; /* Zoom-Control (+/-) freilassen */ + right: 0; z-index: 1000; display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; gap: var(--space-1); - max-width: calc(100vw - var(--space-6)); - justify-content: center; + padding: 0 var(--space-3) 0 var(--space-1); + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + pointer-events: auto; } +.map-legend::-webkit-scrollbar { display: none; } + .map-legend-btn { - padding: var(--space-1) var(--space-2); + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 5px 10px; border-radius: var(--radius-full); - background: rgba(255,255,255,0.92); + background: rgba(255,255,255,0.93); border: 1.5px solid var(--layer-color, var(--c-border)); color: var(--c-text-secondary); - font-size: var(--text-xs); + font-size: 11px; + font-weight: var(--weight-semibold); cursor: pointer; - backdrop-filter: blur(4px); + backdrop-filter: blur(6px); transition: all 0.15s; box-shadow: 0 1px 4px rgba(0,0,0,0.15); + white-space: nowrap; } .map-legend-btn.active { - background: var(--layer-color, var(--c-primary)); - color: #fff; + background: var(--layer-color, var(--c-primary)); + color: #fff; border-color: var(--layer-color, var(--c-primary)); } -.map-locate-btn { - position: absolute; - bottom: var(--space-6); - right: var(--space-4); - z-index: 1000; +.map-legend-label { font-size: 10px; } +.map-legend-all { + font-size: 1rem; + min-width: 32px; + padding: 0 var(--space-2); + background: var(--c-surface-2); + border-color: var(--c-border); + color: var(--c-text-secondary); + font-weight: var(--weight-bold); +} +.map-legend-all.all-off { + background: #1e293b; + border-color: #1e293b; + color: #fff; +} + +/* FAB-Gruppe rechts unten */ +.map-fabs { + position: absolute; + bottom: var(--space-4); + right: var(--space-3); + z-index: 1000; + display: flex; + flex-direction: column; + gap: var(--space-2); + align-items: center; +} +.map-fab { width: 44px; height: 44px; border-radius: 50%; @@ -1625,11 +1858,247 @@ textarea.form-control { border: 1.5px solid var(--c-border); box-shadow: 0 2px 8px rgba(0,0,0,0.2); cursor: pointer; - font-size: 1.3rem; + font-size: 1.25rem; display: flex; align-items: center; justify-content: center; + transition: background 0.15s, border-color 0.15s; + -webkit-tap-highlight-color: transparent; } +.map-fab:hover { background: var(--c-surface-2); } +.map-fab--pin.active { + background: var(--c-danger); + border-color: var(--c-danger); + color: #fff; + font-size: 1rem; +} +.map-fab:disabled { opacity: 0.5; cursor: default; } +.map-fab--offline.loading { animation: fab-spin 1.2s linear infinite; pointer-events: none; } +@keyframes fab-spin { to { transform: rotate(360deg); } } + +/* Aufzeichnungs-Panel — schiebt sich von unten rein */ +.map-rec-panel { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(15, 23, 42, 0.95); + backdrop-filter: blur(8px); + color: #fff; + padding: var(--space-4) var(--space-5) calc(var(--space-4) + env(safe-area-inset-bottom, 0px)); + border-radius: var(--radius-xl) var(--radius-xl) 0 0; + transform: translateY(100%); + transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + z-index: 420; + pointer-events: none; +} +.map-rec-panel.active { + transform: translateY(0); + pointer-events: all; +} +.map-rec-stats { + display: flex; + justify-content: space-around; + margin-bottom: var(--space-4); +} +.map-rec-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.map-rec-stat--main .map-rec-val { font-size: 2.4rem; } +.map-rec-val { + font-size: 1.8rem; + font-weight: 800; + line-height: 1; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} +.map-rec-lbl { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255,255,255,0.55); +} +.map-rec-actions { + display: flex; + gap: var(--space-3); + margin-bottom: var(--space-3); +} +.map-rec-action-btn { flex: 1; } +.map-rec-hint { + text-align: center; + font-size: var(--text-xs); + color: rgba(255,255,255,0.45); +} +.map-rec-panel.paused .map-rec-val { color: #F59E0B; } +.map-rec-panel.paused .map-rec-hint::before { content: '⏸ Pausiert — '; } + +/* Fadenkreuz-Overlay */ +.map-crosshair { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -100%); + z-index: 410; + pointer-events: none; + display: none; + flex-direction: column; + align-items: center; +} +.map-crosshair.active { display: flex; } +.map-crosshair-pin { + font-size: 2.4rem; + line-height: 1; + filter: drop-shadow(0 3px 6px rgba(0,0,0,0.45)); + transition: transform 0.15s cubic-bezier(0.34,1.56,0.64,1); +} +.map-crosshair.dragging .map-crosshair-pin { + transform: translateY(-10px) scale(1.15); +} +.map-crosshair-shadow { + width: 10px; + height: 4px; + background: rgba(0,0,0,0.25); + border-radius: 50%; + margin-top: 1px; + transition: all 0.15s; +} +.map-crosshair.dragging .map-crosshair-shadow { + width: 6px; opacity: 0.4; +} + +/* Bestätigen-Leiste unten */ +.map-place-bar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: var(--c-surface); + border-top: 1px solid var(--c-border-light); + padding: var(--space-3) var(--space-4) calc(var(--space-3) + env(safe-area-inset-bottom, 0px)); + display: none; + flex-direction: column; + gap: var(--space-2); + z-index: 410; + box-shadow: 0 -4px 20px rgba(0,0,0,0.08); +} +.map-place-bar.active { display: flex; } +.map-place-hint { + text-align: center; + font-size: var(--text-sm); + color: var(--c-text-secondary); +} +.map-place-btns { + display: flex; + gap: var(--space-3); +} +.map-place-btns .btn { flex: 1; } + +/* Statusleiste: nur Info, unten links */ +.map-statusbar { + position: absolute; + bottom: var(--space-3); + left: var(--space-3); + z-index: 1000; + display: flex; + align-items: center; + gap: var(--space-2); + background: rgba(255,255,255,0.88); + backdrop-filter: blur(4px); + border: 1px solid var(--c-border-light); + border-radius: var(--radius-full); + padding: 4px 12px; + font-size: 11px; + color: var(--c-text-secondary); + box-shadow: 0 1px 4px rgba(0,0,0,0.1); + max-width: calc(100% - 80px); /* Platz für FABs */ + pointer-events: none; +} + +/* Giftköder-Marker — pulsierend, rot, sofort erkennbar */ +.poison-marker { + position: relative; + width: 48px; height: 48px; +} +.poison-dot { + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + width: 44px; height: 44px; + background: #DC2626; + border: 3px solid #fff; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-size: 20px; + box-shadow: 0 2px 10px rgba(220,38,38,0.7); + z-index: 2; +} +.poison-ring { + position: absolute; + top: 50%; left: 50%; + width: 44px; height: 44px; + border-radius: 50%; + background: rgba(220,38,38,0.35); + animation: poison-pulse 1.8s ease-out infinite; + z-index: 1; +} +.poison-ring:nth-child(2) { animation-delay: 0.6s; } +@keyframes poison-pulse { + 0% { transform: translate(-50%,-50%) scale(1); opacity: 0.8; } + 100% { transform: translate(-50%,-50%) scale(2.8); opacity: 0; } +} + +/* Pulsierender Standort-Marker */ +.loc-icon { position: relative; } +.loc-dot { + width: 16px; height: 16px; + background: #3B82F6; + border: 3px solid #fff; + border-radius: 50%; + box-shadow: 0 1px 4px rgba(0,0,0,0.3); + position: absolute; + top: 4px; left: 4px; +} +.loc-ring { + width: 24px; height: 24px; + background: rgba(59,130,246,0.3); + border-radius: 50%; + position: absolute; + top: 0; left: 0; + animation: loc-pulse 2s ease-out infinite; +} +@keyframes loc-pulse { + 0% { transform: scale(0.8); opacity: 1; } + 100% { transform: scale(2.2); opacity: 0; } +} + +/* Pin-Typ-Auswahl im Modal */ +.poi-type-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-2); +} +.poi-type-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 4px; + border: 2px solid var(--c-border); + border-radius: var(--radius-md); + background: var(--c-surface); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + -webkit-tap-highlight-color: transparent; +} +.poi-type-btn.selected { + border-color: var(--pt-color, var(--c-primary)); + background: color-mix(in srgb, var(--pt-color, var(--c-primary)) 12%, transparent); +} +.poi-type-icon { font-size: 22px; line-height: 1; } +.poi-type-label { font-size: 10px; color: var(--c-text-secondary); text-align: center; line-height: 1.2; } /* ------------------------------------------------------------ GASSI-TREFFEN (walks.js) diff --git a/backend/static/index.html b/backend/static/index.html index e6209f3..20b1b1d 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -22,8 +22,8 @@ - - + + @@ -55,10 +55,7 @@ - - @@ -246,8 +242,7 @@ const App = (() => { if (action === 'diary') { navigate('diary'); pages['diary'].module?.openNew?.(); } if (action === 'health') { navigate('health'); pages['health'].module?.openNew?.(); } if (action === 'poison') { navigate('poison'); pages['poison'].module?.openNew?.(); } - if (action === 'place') { navigate('places'); pages['places'].module?.openNew?.(); } - if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); } +if (action === 'walk') { navigate('walks'); pages['walks'].module?.openNew?.(); } }, 350); }, { once: true }); } diff --git a/backend/static/js/leaflet.markercluster.js b/backend/static/js/leaflet.markercluster.js new file mode 100644 index 0000000..66fe516 --- /dev/null +++ b/backend/static/js/leaflet.markercluster.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t(((e=e||self).Leaflet=e.Leaflet||{},e.Leaflet.markercluster={}))}(this,function(e){"use strict";var t=L.MarkerClusterGroup=L.FeatureGroup.extend({options:{maxClusterRadius:80,iconCreateFunction:null,clusterPane:L.Marker.prototype.options.pane,spiderfyOnEveryZoom:!1,spiderfyOnMaxZoom:!0,showCoverageOnHover:!0,zoomToBoundsOnClick:!0,singleMarkerMode:!1,disableClusteringAtZoom:null,removeOutsideVisibleBounds:!0,animate:!0,animateAddingMarkers:!1,spiderfyShapePositions:null,spiderfyDistanceMultiplier:1,spiderLegPolylineOptions:{weight:1.5,color:"#222",opacity:.5},chunkedLoading:!1,chunkInterval:200,chunkDelay:50,chunkProgress:null,polygonOptions:{}},initialize:function(e){L.Util.setOptions(this,e),this.options.iconCreateFunction||(this.options.iconCreateFunction=this._defaultIconCreateFunction),this._featureGroup=L.featureGroup(),this._featureGroup.addEventParent(this),this._nonPointGroup=L.featureGroup(),this._nonPointGroup.addEventParent(this),this._inZoomAnimation=0,this._needsClustering=[],this._needsRemoving=[],this._currentShownBounds=null,this._queue=[],this._childMarkerEventHandlers={dragstart:this._childMarkerDragStart,move:this._childMarkerMoved,dragend:this._childMarkerDragEnd};var t=L.DomUtil.TRANSITION&&this.options.animate;L.extend(this,t?this._withAnimation:this._noAnimation),this._markerCluster=t?L.MarkerCluster:L.MarkerClusterNonAnimated},addLayer:function(e){if(e instanceof L.LayerGroup)return this.addLayers([e]);if(!e.getLatLng)return this._nonPointGroup.addLayer(e),this.fire("layeradd",{layer:e}),this;if(!this._map)return this._needsClustering.push(e),this.fire("layeradd",{layer:e}),this;if(this.hasLayer(e))return this;this._unspiderfy&&this._unspiderfy(),this._addLayer(e,this._maxZoom),this.fire("layeradd",{layer:e}),this._topClusterLevel._recalculateBounds(),this._refreshClustersIcons();var t=e,i=this._zoom;if(e.__parent)for(;t.__parent._zoom>=i;)t=t.__parent;return this._currentShownBounds.contains(t.getLatLng())&&(this.options.animateAddingMarkers?this._animationAddLayer(e,t):this._animationAddLayerNonAnimated(e,t)),this},removeLayer:function(e){return e instanceof L.LayerGroup?this.removeLayers([e]):(e.getLatLng?this._map?e.__parent&&(this._unspiderfy&&(this._unspiderfy(),this._unspiderfyLayer(e)),this._removeLayer(e,!0),this.fire("layerremove",{layer:e}),this._topClusterLevel._recalculateBounds(),this._refreshClustersIcons(),e.off(this._childMarkerEventHandlers,this),this._featureGroup.hasLayer(e)&&(this._featureGroup.removeLayer(e),e.clusterShow&&e.clusterShow())):(!this._arraySplice(this._needsClustering,e)&&this.hasLayer(e)&&this._needsRemoving.push({layer:e,latlng:e._latlng}),this.fire("layerremove",{layer:e})):(this._nonPointGroup.removeLayer(e),this.fire("layerremove",{layer:e})),this)},addLayers:function(n,s){if(!L.Util.isArray(n))return this.addLayer(n);var o,a=this._featureGroup,h=this._nonPointGroup,l=this.options.chunkedLoading,u=this.options.chunkInterval,_=this.options.chunkProgress,d=n.length,p=0,c=!0;if(this._map){var f=(new Date).getTime(),m=L.bind(function(){var e=(new Date).getTime();for(this._map&&this._unspiderfy&&this._unspiderfy();p"+t+"",className:"marker-cluster"+i,iconSize:new L.Point(40,40)})},_bindEvents:function(){var e=this._map,t=this.options.spiderfyOnMaxZoom,i=this.options.showCoverageOnHover,r=this.options.zoomToBoundsOnClick,n=this.options.spiderfyOnEveryZoom;(t||r||n)&&this.on("clusterclick clusterkeypress",this._zoomOrSpiderfy,this),i&&(this.on("clustermouseover",this._showCoverage,this),this.on("clustermouseout",this._hideCoverage,this),e.on("zoomend",this._hideCoverage,this))},_zoomOrSpiderfy:function(e){var t=e.layer,i=t;if("clusterkeypress"!==e.type||!e.originalEvent||13===e.originalEvent.keyCode){for(;1===i._childClusters.length;)i=i._childClusters[0];i._zoom===this._maxZoom&&i._childCount===t._childCount&&this.options.spiderfyOnMaxZoom?t.spiderfy():this.options.zoomToBoundsOnClick&&t.zoomToBounds(),this.options.spiderfyOnEveryZoom&&t.spiderfy(),e.originalEvent&&13===e.originalEvent.keyCode&&this._map._container.focus()}},_showCoverage:function(e){var t=this._map;this._inZoomAnimation||(this._shownPolygon&&t.removeLayer(this._shownPolygon),2h._zoom;r--)u=new this._markerCluster(this,r,u),n[r].addObject(u,this._map.project(a.getLatLng(),r));return h._addChild(u),void this._removeFromGridUnclustered(a,t)}s[t].addObject(e,i)}this._topClusterLevel._addChild(e),e.__parent=this._topClusterLevel},_refreshClustersIcons:function(){this._featureGroup.eachLayer(function(e){e instanceof L.MarkerCluster&&e._iconNeedsUpdate&&e._updateIcon()})},_enqueue:function(e){this._queue.push(e),this._queueTimeout||(this._queueTimeout=setTimeout(L.bind(this._processQueue,this),300))},_processQueue:function(){for(var e=0;ee?(this._animationStart(),this._animationZoomOut(this._zoom,e)):this._moveEnd()},_getExpandedVisibleBounds:function(){return this.options.removeOutsideVisibleBounds?L.Browser.mobile?this._checkBoundsMaxLat(this._map.getBounds()):this._checkBoundsMaxLat(this._map.getBounds().pad(1)):this._mapBoundsInfinite},_checkBoundsMaxLat:function(e){var t=this._maxLat;return void 0!==t&&(e.getNorth()>=t&&(e._northEast.lat=1/0),e.getSouth()<=-t&&(e._southWest.lat=-1/0)),e},_animationAddLayerNonAnimated:function(e,t){if(t===e)this._featureGroup.addLayer(e);else if(2===t._childCount){t._addToMap();var i=t.getAllChildMarkers();this._featureGroup.removeLayer(i[0]),this._featureGroup.removeLayer(i[1])}else t._updateIcon()},_extractNonGroupLayers:function(e,t){var i,r=e.getLayers(),n=0;for(t=t||[];ni)&&(i=(o=d).lat),(!1===r||d.latn)&&(n=(h=d).lng),(!1===s||d.lng=this._circleSpiralSwitchover?this._generatePointsSpiral(t.length,i):(i.y+=10,this._generatePointsCircle(t.length,i)),this._animationSpiderfy(t,e)}},unspiderfy:function(e){this._group._inZoomAnimation||(this._animationUnspiderfy(e),this._group._spiderfied=null)},_generatePointsCircle:function(e,t){var i,r,n=this._group.options.spiderfyDistanceMultiplier*this._circleFootSeparation*(2+e)/this._2PI,s=this._2PI/e,o=[];for(n=Math.max(n,35),o.length=e,i=0;i { @@ -10,8 +11,30 @@ window.Page_map = (() => { let _map = null; let _leafletLoaded = false; let _userPos = null; + let _placingMarker = false; + let _tempMarker = null; - // Layer-Marker + // Standort-Tracking + let _locationMarker = null; + let _locationAccuracy = null; + let _watchId = null; + + // GPS-Aufzeichnung + let _recActive = false; + let _recPaused = false; + let _wakeLock = null; + let _recTrack = []; + let _recDistKm = 0; + let _recStartTime = null; + let _recTimerInt = null; + let _recPolyline = null; + let _recMarker = null; + let _recWatchId = null; + + // Cluster-Gruppen pro Layer (für OSM-Marker) + let _clusterGroups = {}; + + // Layer-Marker (Arrays von Leaflet-Markern) let _layers = { restaurant: [], freilauf: [], @@ -20,49 +43,98 @@ window.Page_map = (() => { tierarzt: [], hundeschule: [], poison: [], + muell: [], + dog_park: [], + wasser: [], + bank: [], + giftkoeder: [], + gefahr: [], + parkplatz: [], + treffpunkt: [], + community: [], }; - // Layer-Sichtbarkeit - let _visible = { - restaurant: true, - freilauf: true, - shop: true, - kotbeutel: true, - tierarzt: true, - hundeschule: true, - poison: true, - }; + const VISIBLE_KEY = 'by_map_visible_v1'; + let _visible = {}; + // Gespeicherten Zustand laden, Fallback: alles sichtbar + (() => { + const saved = (() => { try { return JSON.parse(localStorage.getItem(VISIBLE_KEY) || 'null'); } catch { return null; } })(); + Object.keys(_layers).forEach(k => { + _visible[k] = saved ? (saved[k] !== false) : true; + }); + })(); + + function _saveVisible() { + try { localStorage.setItem(VISIBLE_KEY, JSON.stringify(_visible)); } catch {} + } + + // z: zIndexOffset — höher = weiter oben bei Überlappung const TYPEN = { - restaurant: { icon: '🍽️', label: 'Restaurant', color: '#F97316' }, - freilauf: { icon: '🐕', label: 'Freilauf', color: '#22C55E' }, - shop: { icon: '🛒', label: 'Shop', color: '#3B82F6' }, - kotbeutel: { icon: '🧻', label: 'Kotbeutel', color: '#6B7280' }, - tierarzt: { icon: '🩺', label: 'Tierarzt', color: '#EF4444' }, - hundeschule: { icon: '🎓', label: 'Hundeschule', color: '#8B5CF6' }, - poison: { icon: '⚠️', label: 'Giftköder', color: '#DC2626' }, + restaurant: { icon: '🍽️', label: 'Restaurant', color: '#F97316', z: 10 }, + freilauf: { icon: '🐕', label: 'Freilauf', color: '#22C55E', z: 20 }, + shop: { icon: '🛒', label: 'Shop', color: '#3B82F6', z: 15 }, + kotbeutel: { icon: '🧻', label: 'Kotbeutel', color: '#6B7280', z: 5 }, + tierarzt: { icon: '🩺', label: 'Tierarzt', color: '#EF4444', z: 40 }, + hundeschule: { icon: '🎓', label: 'Hundeschule', color: '#8B5CF6', z: 30 }, + poison: { icon: '⚠️', label: 'Giftköder', color: '#DC2626', z: 100 }, + muell: { icon: '🗑️', label: 'Mülleimer', color: '#78716C', z: -20 }, + dog_park: { icon: '🌿', label: 'Hundewiese', color: '#15803D', z: 5 }, + wasser: { icon: '💧', label: 'Wasserstelle', color: '#0EA5E9', z: 35 }, + bank: { icon: '🪑', label: 'Bank', color: '#92400E', z: -30 }, + giftkoeder: { icon: '☠️', label: 'Giftköder', color: '#DC2626', z: 80 }, + gefahr: { icon: '⚠️', label: 'Gefahr', color: '#F59E0B', z: 60 }, + parkplatz: { icon: '🅿️', label: 'Parkplatz', color: '#2563EB', z: 5 }, + treffpunkt: { icon: '🤝', label: 'Treffpunkt', color: '#7C3AED', z: 25 }, + community: { icon: '📌', label: 'Sonstiges', color: '#F59E0B', z: 30 }, }; + // Frontend-Layer → Backend-Typ Mapping + const OSM_LAYER_MAP = { + muell: 'waste_basket', + dog_park: 'dog_park', + wasser: 'drinking_water', + tierarzt: 'tierarzt', + shop: 'shop', + restaurant: 'restaurant', + bank: 'bank', + giftkoeder: 'giftkoeder', + kotbeutel: 'kotbeutel', + gefahr: 'gefahr', + parkplatz: 'parkplatz', + treffpunkt: 'treffpunkt', + community: 'sonstiges', + }; + + // Gefahren-Radius-Kreis: prominente rote Fläche + const DANGER_RADIUS = { poison: 100, giftkoeder: 100 }; + + // Layer die schon ab Zoom 10 geladen werden (nicht erst ab 14) + const EARLY_LAYERS = new Set(['giftkoeder']); + const DANGER_CIRCLE_STYLE = { + color: '#DC2626', fillColor: '#DC2626', + fillOpacity: 0.12, weight: 2, dashArray: null, + interactive: false, + }; + + let _overpassTimer = null; + let _overpassActive = false; + // ---------------------------------------------------------- // INIT // ---------------------------------------------------------- async function init(container, appState) { _container = container; _appState = appState; - - // Map-Container braucht position:relative + kein Padding, - // damit .map-full-layout mit position:absolute;inset:0 korrekt füllt. - Object.assign(_container.style, { - padding: '0', - overflow: 'hidden', - position: 'relative', - gap: '0', - }); - + Object.assign(_container.style, { padding: '0', overflow: 'hidden', position: 'relative', gap: '0' }); _render(); + // Alle-Button Initialzustand + const anyOnInit = Object.entries(_visible).some(([k, v]) => v && k !== 'giftkoeder'); + document.getElementById('map-legend-all')?.classList.toggle('all-off', !anyOnInit); try { _userPos = await API.getLocation(); } catch {} await _loadLeaflet(); _initMap(); + _startLocationTracking(); _loadAll(); } @@ -76,59 +148,148 @@ window.Page_map = (() => { _container.innerHTML = `
-
- ${Object.entries(TYPEN).map(([k, t]) => ` - + ${Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').map(([k, t]) => ` + `).join('')}
-
- - + +
+
📍
+
+
+
+ Karte verschieben · Pin landet genau hier +
+ + +
+
+ +
+ + + + +
+ +
+ + +
+ + +
+
+
+ 0.00 + km +
+
+ 00:00 + Zeit +
+
+ –:–– + min/km +
+
+
+ + +
+
+ 📵 Bildschirm bleibt aktiv — GPS läuft +
+
`; - // Layer-Toggle document.getElementById('map-legend').addEventListener('click', e => { const btn = e.target.closest('.map-legend-btn'); if (!btn) return; + + // "Alle"-Button + if (btn.id === 'map-legend-all') { + const anyOn = Object.entries(_visible) + .some(([k, v]) => v && k !== 'giftkoeder'); + // Wenn irgendetwas sichtbar → alles aus; sonst alles an + const newState = !anyOn; + Object.entries(TYPEN).filter(([k]) => k !== 'giftkoeder').forEach(([k]) => { + _visible[k] = newState; + document.querySelector(`.map-legend-btn[data-layer="${k}"]`) + ?.classList.toggle('active', newState); + _applyVisibility(k); + }); + btn.classList.toggle('all-off', !newState); + _saveVisible(); + return; + } + const layer = btn.dataset.layer; _visible[layer] = !_visible[layer]; btn.classList.toggle('active', _visible[layer]); _applyVisibility(layer); + // Alle-Button-Zustand aktualisieren + const anyOn = Object.entries(_visible).some(([k, v]) => v && k !== 'giftkoeder'); + document.getElementById('map-legend-all')?.classList.toggle('all-off', !anyOn); + _saveVisible(); }); - // GPS-Locate - document.getElementById('map-locate-btn').addEventListener('click', async () => { - try { - _userPos = await API.getLocation({ enableHighAccuracy: true }); - _map?.setView([_userPos.lat, _userPos.lon], 14); - } catch { UI.toast.error('Standort konnte nicht ermittelt werden.'); } + document.getElementById('map-locate-btn').addEventListener('click', () => { + if (_userPos) { + _map?.setView([_userPos.lat, _userPos.lon], 16); + } else { + UI.toast.error('Standort noch nicht verfügbar.'); + } }); + + document.getElementById('map-pin-btn').addEventListener('click', _togglePlacementMode); + document.getElementById('map-offline-btn').addEventListener('click', _cacheTiles); + document.getElementById('map-rec-btn').addEventListener('click', _toggleRecording); } // ---------------------------------------------------------- - // Leaflet laden + // Leaflet + MarkerCluster laden // ---------------------------------------------------------- async function _loadLeaflet() { if (_leafletLoaded || window.L) { _leafletLoaded = true; return; } - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = '/css/leaflet.css'; - document.head.appendChild(link); + + // Leaflet CSS + const lCss = document.createElement('link'); + lCss.rel = 'stylesheet'; lCss.href = '/css/leaflet.css'; + document.head.appendChild(lCss); + + // Leaflet JS await new Promise(resolve => { - const s = document.createElement('script'); - s.src = '/js/leaflet.js'; - s.onload = resolve; + const s = document.createElement('script'); + s.src = '/js/leaflet.js'; s.onload = resolve; document.head.appendChild(s); }); + + // MarkerCluster CSS + ['MarkerCluster.css', 'MarkerCluster.Default.css'].forEach(f => { + const l = document.createElement('link'); + l.rel = 'stylesheet'; l.href = `/css/${f}`; + document.head.appendChild(l); + }); + + // MarkerCluster JS + await new Promise(resolve => { + const s = document.createElement('script'); + s.src = '/js/leaflet.markercluster.js'; s.onload = resolve; + document.head.appendChild(s); + }); + _leafletLoaded = true; } @@ -140,91 +301,543 @@ window.Page_map = (() => { if (!el || !window.L || _map) return; const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515]; - const zoom = _userPos ? 13 : 6; + const zoom = _userPos ? 14 : 6; _map = L.map('central-map', { zoomControl: true, attributionControl: false }) .setView(center, zoom); + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }) - .addTo(_map); - - // invalidateSize zweimal: einmal früh, einmal nach möglichen Layout-Delays setTimeout(() => _map.invalidateSize(), 100); setTimeout(() => _map.invalidateSize(), 600); window.addEventListener('resize', () => _map.invalidateSize()); + + _map.on('moveend zoomend', () => { _updateZoomDisplay(); _scheduleOsmLoad(); }); + setTimeout(() => { _updateZoomDisplay(); _scheduleOsmLoad(); }, 800); + + // Fadenkreuz-Animation beim Kartenverschieben + _map.on('movestart', () => { + document.getElementById('map-crosshair')?.classList.add('dragging'); + }); + _map.on('moveend', () => { + document.getElementById('map-crosshair')?.classList.remove('dragging'); + }); } // ---------------------------------------------------------- - // Alle Layer laden + // Standort-Tracking — pulsierender blauer Punkt + // ---------------------------------------------------------- + function _startLocationTracking() { + if (!navigator.geolocation || !_map || !window.L) return; + + const icon = L.divIcon({ + className: 'loc-icon', + html: '
', + iconSize: [24, 24], + iconAnchor: [12, 12], + }); + + _watchId = navigator.geolocation.watchPosition( + pos => { + const { latitude: lat, longitude: lon, accuracy: acc } = pos.coords; + _userPos = { lat, lon }; + if (_locationMarker) { + _locationMarker.setLatLng([lat, lon]); + _locationAccuracy?.setLatLng([lat, lon]).setRadius(acc); + } else { + _locationAccuracy = L.circle([lat, lon], { + radius: acc, color: '#3B82F6', fillColor: '#3B82F6', + fillOpacity: 0.1, weight: 1, interactive: false, + }).addTo(_map); + _locationMarker = L.marker([lat, lon], { + icon, zIndexOffset: 500, interactive: false, + }).addTo(_map); + } + }, + () => {}, + { enableHighAccuracy: true, maximumAge: 5000, timeout: 15000 } + ); + } + + // ---------------------------------------------------------- + // Cluster-Gruppe holen / erstellen + // ---------------------------------------------------------- + function _getCluster(layerKey) { + if (!_clusterGroups[layerKey]) { + _clusterGroups[layerKey] = L.markerClusterGroup({ + maxClusterRadius: 50, + spiderfyOnMaxZoom: true, + showCoverageOnHover: false, + animate: true, + chunkedLoading: true, + iconCreateFunction: cluster => { + const t = TYPEN[layerKey]; + const n = cluster.getChildCount(); + return L.divIcon({ + className: '', + html: `
${n}
`, + iconSize: [36, 36], iconAnchor: [18, 18], + }); + }, + }); + if (_visible[layerKey] !== false) { + _clusterGroups[layerKey].addTo(_map); + } + } + return _clusterGroups[layerKey]; + } + + function _updateZoomDisplay() { + if (!_map) return; + const z = Math.round(_map.getZoom()); + const el = document.getElementById('map-zoom-info'); + if (!el) return; + if (z < 10) { el.textContent = `Zoom ${z} · ab 10: Giftköder`; el.style.opacity = '0.5'; } + else if (z < 14) { el.textContent = `Zoom ${z} · ab 14: alle Layer`; el.style.opacity = '0.7'; } + else { el.textContent = `Zoom ${z}`; el.style.opacity = '1'; } + } + + function _setOsmStatus(text) { + const el = document.getElementById('map-osm-status'); + if (el) el.textContent = text; + } + + // ---------------------------------------------------------- + // OSM-Layer laden + // ---------------------------------------------------------- + function _scheduleOsmLoad() { + clearTimeout(_overpassTimer); + _overpassTimer = setTimeout(_loadOsmLayers, 600); + } + + async function _loadOsmLayers() { + if (!_map || !window.L || _overpassActive) return; + const zoom = _map.getZoom(); + + // Unter Zoom 10: alles ausblenden + if (zoom < 10) { + Object.keys(OSM_LAYER_MAP).forEach(k => { + _layers[k].filter(m => !m._ownPlace).forEach(m => m._dangerCircle?.remove()); + _clusterGroups[k]?.clearLayers(); + _layers[k] = _layers[k].filter(m => m._ownPlace); + }); + _setOsmStatus(''); + return; + } + + // Zoom 10–13: normale OSM-Layer ausblenden, EARLY_LAYERS behalten/laden + if (zoom < 14) { + Object.keys(OSM_LAYER_MAP).filter(k => !EARLY_LAYERS.has(k)).forEach(k => { + _layers[k].filter(m => !m._ownPlace).forEach(m => m._dangerCircle?.remove()); + _clusterGroups[k]?.clearLayers(); + _layers[k] = _layers[k].filter(m => m._ownPlace); + }); + } + + _overpassActive = true; + const b = _map.getBounds(); + const bbox = { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() }; + + // Welche Layer bei diesem Zoom geladen werden + const activeLayers = zoom >= 14 + ? Object.entries(OSM_LAYER_MAP) + : Object.entries(OSM_LAYER_MAP).filter(([k]) => EARLY_LAYERS.has(k)); + + // OSM-Marker eines Layers ersetzen, eigene Orte behalten + function _replaceOsmMarkers(layerKey, pois) { + const cluster = _getCluster(layerKey); + // Alte OSM-Marker entfernen + const oldOsm = _layers[layerKey].filter(m => !m._ownPlace); + oldOsm.forEach(m => m._dangerCircle?.remove()); + cluster.removeLayers(oldOsm); + _layers[layerKey] = _layers[layerKey].filter(m => m._ownPlace); + // Neue Marker erstellen und in Cluster packen + const t = TYPEN[layerKey]; + const newMarkers = pois.map(poi => _createOsmMarker(poi, layerKey, t)); + cluster.addLayers(newMarkers); + _layers[layerKey].push(...newMarkers); + } + + // Phase 1: sofort DB-Daten zeigen (fast=true) + _setOsmStatus('Lade\u2026'); + const fastTasks = activeLayers.map(async ([layerKey, osmType]) => { + const params = new URLSearchParams({ type: osmType, fast: 'true', ...bbox }); + try { + const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json()); + _replaceOsmMarkers(layerKey, pois); + return pois.length; + } catch { return 0; } + }); + const fastCounts = await Promise.all(fastTasks); + const fastTotal = fastCounts.reduce((a, b) => a + b, 0); + if (fastTotal > 0) _setOsmStatus(`${fastTotal} aus Datenbank`); + + // Phase 2: Overpass für fehlende Tiles — mit %-Fortschritt + let _done = 0; + const _total = activeLayers.length; + _setOsmStatus(fastTotal > 0 ? `${fastTotal} gefunden \u00b7 Scanne 0\u202f%` : 'Scanne 0\u202f%'); + + const freshTasks = activeLayers.map(async ([layerKey, osmType]) => { + const params = new URLSearchParams({ type: osmType, ...bbox }); + try { + const pois = await fetch(`/api/osm/pois?${params}`).then(r => r.json()); + const osmCount = _layers[layerKey].filter(m => !m._ownPlace).length; + if (pois.length !== osmCount) _replaceOsmMarkers(layerKey, pois); + _done++; + const pct = Math.round(_done / _total * 100); + const total = Object.values(_layers).flat().filter(m => !m._ownPlace).length; + _setOsmStatus(pct < 100 + ? `${total} gefunden \u00b7 Scanne ${pct}\u202f%` + : `${total} Marker` + ); + return pois.length; + } catch { + _done++; + return _layers[layerKey].filter(m => !m._ownPlace).length; + } + }); + await Promise.all(freshTasks); + _overpassActive = false; + } + + // ---------------------------------------------------------- + // Spezielles Giftköder-Icon (pulsierend) + // ---------------------------------------------------------- + function _poisonDivIcon() { + return L.divIcon({ + className: '', + html: `
+
+
+
☠️
+
`, + iconSize: [48, 48], + iconAnchor: [24, 24], + }); + } + + function _addDangerCircle(lat, lon) { + return L.circle([lat, lon], { radius: 100, ...DANGER_CIRCLE_STYLE }).addTo(_map); + } + + // ---------------------------------------------------------- + // OSM-Marker erstellen (geht in Cluster, NICHT direkt auf Karte) + // ---------------------------------------------------------- + function _createOsmMarker(poi, layerKey, t) { + const isPoison = DANGER_RADIUS[layerKey] !== undefined; + const icon = isPoison + ? _poisonDivIcon() + : L.divIcon({ + className: '', + html: `
${t.icon}
`, + iconSize: [32, 32], iconAnchor: [16, 16], + }); + + const label = poi.name || t.label; + const marker = L.marker([poi.lat, poi.lon], { icon, zIndexOffset: t.z ?? 0 }) + .bindTooltip(label, { direction: 'top', offset: [0, -16] }); + + marker.on('click', () => _showMarkerPopup(marker, poi, layerKey, t, label)); + + if (isPoison) { + marker._dangerCircle = _addDangerCircle(poi.lat, poi.lon); + } + + return marker; + } + + function _showMarkerPopup(marker, poi, layerKey, t, label) { + const isOwn = poi.source === 'user' && poi.own; + const isUser = poi.source === 'user'; + + const actionBtn = isOwn + ? `` + : ``; + + const openHours = poi.opening_hours + ? `
🕐 ${poi.opening_hours}
` : ''; + const phone = poi.phone + ? `` : ''; + const website = poi.website + ? `` : ''; + + marker.bindPopup(` +
+
${t.icon} ${label}
+ ${poi.notiz ? `
${poi.notiz}
` : ''} + ${openHours}${phone}${website} +
+ ${isUser + ? `📌 Community-Pin${poi.username ? ' · ' + poi.username + '' : ''}` + : '🗺️ OpenStreetMap'} +
+ ${actionBtn} +
+ `, { maxWidth: 260 }).openPopup(); + + setTimeout(() => { + document.getElementById('mp-action')?.addEventListener('click', () => { + marker.closePopup(); + if (isOwn) _deleteUserPoi(poi.user_poi_id, marker, layerKey); + else _showReportDialog(poi); + }); + }, 50); + } + + // ---------------------------------------------------------- + // Marker setzen (Placement-Mode) + // ---------------------------------------------------------- + function _togglePlacementMode() { + _placingMarker = !_placingMarker; + const btn = document.getElementById('map-pin-btn'); + if (_placingMarker) { + btn?.classList.add('active'); + btn && (btn.textContent = '\u2715'); + // Fadenkreuz + Bestätigen-Leiste einblenden + document.getElementById('map-crosshair')?.classList.add('active'); + document.getElementById('map-place-bar')?.classList.add('active'); + document.getElementById('map-place-confirm').onclick = () => { + const center = _map.getCenter(); + _exitPlacementMode(); + _confirmPlacement(center); + }; + document.getElementById('map-place-cancel').onclick = _exitPlacementMode; + } else { + _exitPlacementMode(); + } + } + + function _exitPlacementMode() { + _placingMarker = false; + const btn = document.getElementById('map-pin-btn'); + btn?.classList.remove('active'); + btn && (btn.textContent = '\uD83D\uDCCC'); + document.getElementById('map-crosshair')?.classList.remove('active', 'dragging'); + document.getElementById('map-place-bar')?.classList.remove('active'); + _tempMarker?.remove(); + _tempMarker = null; + } + + const PIN_TYPES = [ + { type: 'giftkoeder', icon: '☠️', label: 'Giftköder', color: '#DC2626' }, // ← wichtigster Typ, immer oben + { type: 'waste_basket', icon: '🗑️', label: 'Mülleimer', color: '#78716C' }, + { type: 'kotbeutel', icon: '🧻', label: 'Kotbeutel', color: '#6B7280' }, + { type: 'drinking_water', icon: '💧', label: 'Wasserstelle', color: '#0EA5E9' }, + { type: 'dog_park', icon: '🌿', label: 'Hundewiese', color: '#15803D' }, + { type: 'parkplatz', icon: '🅿️', label: 'Parkplatz', color: '#2563EB' }, + { type: 'treffpunkt', icon: '🤝', label: 'Treffpunkt', color: '#7C3AED' }, + { type: 'sonstiges', icon: '📌', label: 'Sonstiges', color: '#F59E0B' }, + ]; + + function _confirmPlacement(latlng) { + _tempMarker?.remove(); + _tempMarker = L.circleMarker([latlng.lat, latlng.lng], { + radius: 10, color: '#F59E0B', fillColor: '#F59E0B', fillOpacity: 0.6, + }).addTo(_map); + + let _selectedType = 'giftkoeder'; + + UI.modal.open({ + title: '📌 Marker setzen', + body: ` +
+
+ +
+ ${PIN_TYPES.map(p => ` + + `).join('')} +
+
+
+ + +
+
+ + +
+
+ `, + footer: ` + + + `, + }); + + document.querySelector('.poi-type-grid')?.addEventListener('click', e => { + const btn = e.target.closest('.poi-type-btn'); + if (!btn) return; + document.querySelectorAll('.poi-type-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + _selectedType = btn.dataset.type; + }); + + document.getElementById('poi-cancel')?.addEventListener('click', () => { + UI.modal.close(); + _exitPlacementMode(); + }); + + document.getElementById('poi-save')?.addEventListener('click', async () => { + const name = document.getElementById('poi-name').value.trim() || null; + const notiz = document.getElementById('poi-notiz').value.trim() || null; + UI.modal.close(); + await _saveUserPoi({ type: _selectedType, lat: latlng.lat, lon: latlng.lng, name, notiz }); + _exitPlacementMode(); + }); + } + + async function _saveUserPoi(data) { + try { + const res = await fetch('/api/osm/user-poi', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(data), + }); + if (res.status === 401) { UI.toast.error('Bitte erst anmelden.'); return; } + if (!res.ok) throw new Error(); + UI.toast.success('Marker gespeichert!'); + _scheduleOsmLoad(); + } catch { + UI.toast.error('Fehler beim Speichern.'); + } + } + + // ---------------------------------------------------------- + // Melden / Löschen + // ---------------------------------------------------------- + function _showReportDialog(poi) { + UI.modal.open({ + title: 'Marker melden', + body: ` +

Warum ist dieser Marker falsch?

+
+ + + + +
+ `, + }); + + document.getElementById('report-options')?.addEventListener('click', async e => { + const btn = e.target.closest('[data-grund]'); + if (!btn) return; + UI.modal.close(); + try { + const body = { + type: poi.source === 'user' ? 'user_poi' : 'osm', + grund: btn.dataset.grund, + osm_id: poi.source === 'osm' ? poi.id : undefined, + user_poi_id: poi.source === 'user' ? poi.user_poi_id : undefined, + }; + const res = await fetch('/api/osm/report', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'include', body: JSON.stringify(body), + }); + if (res.status === 401) { UI.toast.error('Bitte erst anmelden.'); return; } + const data = await res.json(); + if (data.status === 'bereits_gemeldet') { + UI.toast.info('Du hast diesen Marker bereits gemeldet.'); + } else { + UI.toast.success('Meldung eingereicht. Danke!'); + } + } catch { UI.toast.error('Fehler beim Melden.'); } + }); + } + + async function _deleteUserPoi(poiId, marker, layerKey) { + try { + const res = await fetch(`/api/osm/user-poi/${poiId}`, { + method: 'DELETE', credentials: 'include', + }); + if (!res.ok) throw new Error(); + _clusterGroups[layerKey]?.removeLayer(marker); + marker._dangerCircle?.remove(); + _layers[layerKey] = _layers[layerKey].filter(m => m !== marker); + UI.toast.success('Marker gelöscht.'); + } catch { UI.toast.error('Fehler beim Löschen.'); } + } + + // ---------------------------------------------------------- + // Eigene Orte + Giftköder laden // ---------------------------------------------------------- async function _loadAll() { - // Alles zurücksetzen - Object.values(_layers).flat().forEach(m => m.remove?.()); - _layers = { restaurant: [], freilauf: [], shop: [], kotbeutel: [], tierarzt: [], hundeschule: [], poison: [] }; + // Cluster-Gruppen leeren (OSM-Marker) + Object.values(_clusterGroups).forEach(cg => cg.clearLayers()); + // Eigene-Orte-Marker direkt von Karte entfernen + Object.values(_layers).flat().filter(m => m._ownPlace).forEach(m => { + m._dangerCircle?.remove(); + m.remove(); + }); + // Giftköder-Kreise + (_layers.poison || []).forEach(m => m._dangerCircle?.remove()); + Object.keys(_layers).forEach(k => { _layers[k] = []; }); - // Parallel laden const [places, poisonList] = await Promise.allSettled([ API.places.list(), - _userPos - ? API.poison.listNearby(_userPos.lat, _userPos.lon, 10000) - : Promise.resolve([]), + _userPos ? API.poison.listNearby(_userPos.lat, _userPos.lon, 10000) : Promise.resolve([]), ]); - if (places.status === 'fulfilled') _addPlaces(places.value); + if (places.status === 'fulfilled') _addPlaces(places.value); if (poisonList.status === 'fulfilled') _addPoison(poisonList.value); + _scheduleOsmLoad(); } - // ---------------------------------------------------------- - // Orte-Marker - // ---------------------------------------------------------- function _addPlaces(places) { if (!_map || !window.L) return; places.forEach(place => { - const t = TYPEN[place.typ]; + const t = TYPEN[place.typ]; if (!t) return; - const m = _createMarker(place.lat, place.lon, t, place.name, () => _showPlacePopup(place)); + const m = _createSimpleMarker(place.lat, place.lon, t, place.name, + () => UI.toast.info(`${t.icon} ${place.name}${place.adresse ? ' \u00b7 ' + place.adresse : ''}`)); + m._ownPlace = true; _layers[place.typ]?.push(m); if (!_visible[place.typ]) m.setOpacity(0); }); } - function _showPlacePopup(place) { - const t = TYPEN[place.typ] || { icon: '📍', label: place.typ }; - UI.toast.info(`${t.icon} ${place.name}${place.adresse ? ' · ' + place.adresse : ''}`); - } - - // ---------------------------------------------------------- - // Giftköder-Marker - // ---------------------------------------------------------- function _addPoison(items) { if (!_map || !window.L) return; items.forEach(p => { - const t = TYPEN.poison; - const m = _createMarker(p.lat, p.lon, t, `Giftköder-Alarm${p.beschreibung ? ': ' + p.beschreibung : ''}`, () => { - App.navigate('poison'); - }); + const tooltip = `Giftk\u00f6der-Alarm${p.beschreibung ? ': ' + p.beschreibung : ''}`; + const m = L.marker([p.lat, p.lon], { icon: _poisonDivIcon(), zIndexOffset: 100 }) + .addTo(_map) + .bindTooltip(tooltip, { direction: 'top', offset: [0, -24] }) + .on('click', () => App.navigate('poison')); + m._ownPlace = true; + m._dangerCircle = _addDangerCircle(p.lat, p.lon); _layers.poison.push(m); - if (!_visible.poison) m.setOpacity(0); + if (!_visible.poison) { + m.setOpacity(0); + m._dangerCircle.setStyle({ opacity: 0, fillOpacity: 0 }); + } }); } - // ---------------------------------------------------------- - // Marker-Hilfsfunktion - // ---------------------------------------------------------- - function _createMarker(lat, lon, t, tooltip, onClick) { + function _createSimpleMarker(lat, lon, t, tooltip, onClick) { const icon = L.divIcon({ className: '', - html: `
${t.icon}
`, - iconSize: [32, 32], - iconAnchor: [16, 16], + border:2px solid rgba(255,255,255,0.7)">${t.icon}`, + iconSize: [32, 32], iconAnchor: [16, 16], }); - return L.marker([lat, lon], { icon }) + return L.marker([lat, lon], { icon, zIndexOffset: t.z ?? 0 }) .addTo(_map) .bindTooltip(tooltip, { direction: 'top', offset: [0, -16] }) .on('click', onClick); @@ -234,12 +847,368 @@ window.Page_map = (() => { // Layer ein/ausblenden // ---------------------------------------------------------- function _applyVisibility(layer) { - (_layers[layer] || []).forEach(m => { - // Leaflet hat keine native hide — Opacity-Trick - if (m.setOpacity) m.setOpacity(_visible[layer] ? 1 : 0); + // poison-Toggle steuert auch giftkoeder-Community-Pins mit + const keys = layer === 'poison' ? ['poison', 'giftkoeder'] : [layer]; + keys.forEach(k => { + const on = _visible[layer]; + _visible[k] = on; + if (_clusterGroups[k]) { + on ? _clusterGroups[k].addTo(_map) : _clusterGroups[k].remove(); + } + (_layers[k] || []).forEach(m => { + if (m._ownPlace) m.setOpacity?.(on ? 1 : 0); + if (m._dangerCircle) { + m._dangerCircle.setStyle(on + ? { opacity: 1, fillOpacity: 0.12 } + : { opacity: 0, fillOpacity: 0 } + ); + } + }); }); } - return { init, refresh, onDogChange }; + // ---------------------------------------------------------- + // Offline-Kacheln vorladen + // ---------------------------------------------------------- + function _tileCoords(lat, lon, zoom) { + const n = Math.pow(2, zoom); + const x = Math.floor((lon + 180) / 360 * n); + const latRad = lat * Math.PI / 180; + const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * n); + return { x, y }; + } + + function _collectTileUrls(bounds, minZoom, maxZoom) { + const urls = []; + const subdomains = ['a', 'b', 'c']; + for (let z = minZoom; z <= maxZoom; z++) { + const sw = _tileCoords(bounds.getSouth(), bounds.getWest(), z); + const ne = _tileCoords(bounds.getNorth(), bounds.getEast(), z); + for (let x = sw.x; x <= ne.x; x++) { + for (let y = ne.y; y <= sw.y; y++) { + const s = subdomains[Math.abs(x + y) % 3]; + urls.push(`https://${s}.tile.openstreetmap.org/${z}/${x}/${y}.png`); + } + } + } + return urls; + } + + async function _cacheTiles() { + if (!_map) return; + if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) { + UI.toast.warning('Service Worker nicht bereit \u2014 bitte Seite neu laden.'); + return; + } + + const bounds = _map.getBounds(); + // Padding: 1 Kachel nach außen auf Zoom 14 + const padded = bounds.pad(0.15); + + // Zoom 12–15 innerhalb der aktuellen Kartenansicht + const urls = _collectTileUrls(padded, 12, 15); + + if (urls.length === 0) { + UI.toast.info('Keine Kacheln im Bereich.'); + return; + } + + if (urls.length > 800) { + UI.toast.warning(`Bereich zu groß (${urls.length} Kacheln). Bitte weiter reinzoomen.`); + return; + } + + const btn = document.getElementById('map-offline-btn'); + if (btn) btn.classList.add('loading'); + _setOsmStatus(`Offline: 0 / ${urls.length} Kacheln…`); + + // Progress via postMessage vom SW + const onMessage = evt => { + if (evt.data?.type !== 'CACHE_TILES_PROGRESS') return; + const { done, total } = evt.data; + if (done >= total) { + navigator.serviceWorker.removeEventListener('message', onMessage); + if (btn) btn.classList.remove('loading'); + _setOsmStatus(''); + UI.toast.success(`\u2705 ${total} Kacheln offline gespeichert!`); + } else { + _setOsmStatus(`Offline: ${done} / ${total} Kacheln…`); + } + }; + navigator.serviceWorker.addEventListener('message', onMessage); + + navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls }); + } + + // ---------------------------------------------------------- + // GPS-Aufzeichnung + // ---------------------------------------------------------- + function _toggleRecording() { + if (!_recActive) _startRecording(); + else _stopRecording(); + } + + async function _startRecording() { + if (!_appState.user) { + UI.toast.warning('Bitte zuerst anmelden.'); + App.navigate('settings'); + return; + } + if (!navigator.geolocation) { + UI.toast.error('GPS nicht verfügbar.'); + return; + } + _recActive = true; + _recPaused = false; + _recTrack = []; + _recDistKm = 0; + _recStartTime = Date.now(); + + // FAB umschalten + const btn = document.getElementById('map-rec-btn'); + if (btn) { btn.textContent = '⏹'; btn.classList.add('recording'); } + + // Aufzeichnungs-Panel einblenden + const panel = document.getElementById('map-rec-panel'); + if (panel) panel.classList.add('active'); + document.getElementById('rec-panel-pause').onclick = _togglePause; + document.getElementById('rec-panel-stop').onclick = _stopRecording; + + // Wake Lock — Bildschirm wach halten + await _acquireWakeLock(); + const hint = document.getElementById('map-rec-hint'); + if (hint) hint.textContent = _wakeLock + ? '📵 Bildschirm bleibt aktiv — GPS läuft' + : '⚠️ Bildschirm-Lock nicht unterstützt — Bildschirm aktiv lassen'; + + // Sichtbarkeit: Wake Lock bei Tab-Wechsel neu anfordern + document.addEventListener('visibilitychange', _onVisibilityChange); + + _recTimerInt = setInterval(_updateRecStatus, 1000); + + _recWatchId = navigator.geolocation.watchPosition( + pos => { + if (_recPaused) return; + const { latitude: lat, longitude: lon } = pos.coords; + if (_recTrack.length > 0) { + const prev = _recTrack[_recTrack.length - 1]; + const d = _haversineRec(prev.lat, prev.lon, lat, lon); + if (d < 3) return; + _recDistKm += d / 1000; + } + _recTrack.push({ lat, lon }); + _updateRecMap(lat, lon); + _updateRecStatus(); + }, + () => {}, + { enableHighAccuracy: true, maximumAge: 0, timeout: 10000 } + ); + UI.toast.success('Aufzeichnung gestartet — los geht\'s! 🐕'); + } + + async function _onVisibilityChange() { + if (_recActive && document.visibilityState === 'visible' && !_wakeLock) { + await _acquireWakeLock(); + } + } + + async function _acquireWakeLock() { + if (!('wakeLock' in navigator)) return; + try { + _wakeLock = await navigator.wakeLock.request('screen'); + _wakeLock.addEventListener('release', () => { _wakeLock = null; }); + } catch {} + } + + function _releaseWakeLock() { + _wakeLock?.release(); + _wakeLock = null; + } + + function _togglePause() { + _recPaused = !_recPaused; + const btn = document.getElementById('rec-panel-pause'); + if (btn) btn.textContent = _recPaused ? '▶ Weiter' : '⏸ Pause'; + const panel = document.getElementById('map-rec-panel'); + panel?.classList.toggle('paused', _recPaused); + } + + function _haversineRec(lat1, lon1, lat2, lon2) { + const R = 6371000; + const p1 = lat1 * Math.PI / 180, p2 = lat2 * Math.PI / 180; + const dp = (lat2 - lat1) * Math.PI / 180; + const dl = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(dp/2)**2 + Math.cos(p1)*Math.cos(p2)*Math.sin(dl/2)**2; + return 2 * R * Math.asin(Math.sqrt(a)); + } + + function _updateRecMap(lat, lon) { + if (!_map || !window.L) return; + const ll = [lat, lon]; + if (!_recPolyline) { + _recPolyline = L.polyline([ll], { color: '#EF4444', weight: 5, opacity: 0.9 }).addTo(_map); + } else { + _recPolyline.addLatLng(ll); + } + if (!_recMarker) { + _recMarker = L.circleMarker(ll, { + radius: 8, color: '#EF4444', fillColor: '#fff', fillOpacity: 1, weight: 3, + }).addTo(_map); + } else { + _recMarker.setLatLng(ll); + } + _map.panTo(ll); + } + + function _updateRecStatus() { + const secs = Math.floor((Date.now() - _recStartTime) / 1000); + const mm = String(Math.floor(secs / 60)).padStart(2, '0'); + const ss = String(secs % 60).padStart(2, '0'); + const pace = _recDistKm > 0.05 + ? (() => { const pSec = secs / _recDistKm / 60; const pm = Math.floor(pSec); const ps = String(Math.round((pSec-pm)*60)).padStart(2,'0'); return `${pm}:${ps}`; })() + : '–:––'; + + const distEl = document.getElementById('rec-stat-dist'); + const timeEl = document.getElementById('rec-stat-time'); + const paceEl = document.getElementById('rec-stat-pace'); + if (distEl) distEl.textContent = _recDistKm.toFixed(2); + if (timeEl) timeEl.textContent = `${mm}:${ss}`; + if (paceEl) paceEl.textContent = pace; + } + + function _stopRecording() { + if (_recWatchId !== null) { navigator.geolocation.clearWatch(_recWatchId); _recWatchId = null; } + if (_recTimerInt) { clearInterval(_recTimerInt); _recTimerInt = null; } + _recActive = false; + _releaseWakeLock(); + document.removeEventListener('visibilitychange', _onVisibilityChange); + + const btn = document.getElementById('map-rec-btn'); + if (btn) { btn.textContent = '🔴'; btn.classList.remove('recording'); } + + const panel = document.getElementById('map-rec-panel'); + if (panel) panel.classList.remove('active', 'paused'); + + if (_recTrack.length < 2) { + UI.toast.warning('Zu wenige GPS-Punkte — bitte etwas länger laufen.'); + if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; } + if (_recMarker) { _recMarker.remove(); _recMarker = null; } + return; + } + + const dauMin = Math.max(1, Math.floor((Date.now() - _recStartTime) / 1000 / 60)); + _showRecSaveModal(_recTrack, _recDistKm, dauMin); + } + + function _showRecSaveModal(track, distKm, dauMin) { + const body = ` +

+ 🎉 ${track.length} GPS-Punkte · ${distKm.toFixed(2)} km · ca. ${dauMin} min +

+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+ + + + +
+ +
+
+ + +
+
+ +
+
+ + +
+
+ `; + + const footer = ` + + + `; + + UI.modal.open({ title: '🥾 Route benennen', body, footer }); + + document.getElementById('rec-paw-select')?.addEventListener('click', e => { + const btn = e.target.closest('.rk-paw-btn'); + if (!btn) return; + document.querySelectorAll('.rk-paw-btn').forEach(b => b.classList.remove('selected')); + btn.classList.add('selected'); + document.getElementById('rec-paw-val').value = btn.dataset.val; + }); + + document.getElementById('rms-discard')?.addEventListener('click', () => { + UI.modal.close(); + if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; } + if (_recMarker) { _recMarker.remove(); _recMarker = null; } + }); + + document.getElementById('rec-save-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const btn = document.querySelector('[form="rec-save-form"][type="submit"]'); + const fd = UI.formData(e.target); + await UI.asyncButton(btn, async () => { + const saved = await API.routes.create({ + name: fd.name?.trim(), + beschreibung: fd.beschreibung || null, + gps_track: track, + distanz_km: Math.round(distKm * 100) / 100, + dauer_min: dauMin, + schwierigkeit: fd.schwierigkeit || 'leicht', + untergrund: fd.untergrund || null, + schatten: 'schatten' in fd, + leine_empfohlen: 'leine_empfohlen' in fd, + is_public: 'is_public' in fd, + hunde_tauglichkeit: fd.hunde_tauglichkeit || 'sehr_gut', + }); + UI.modal.close(); + if (_recPolyline) { _recPolyline.remove(); _recPolyline = null; } + if (_recMarker) { _recMarker.remove(); _recMarker = null; } + UI.toast.success(`Route „${saved.name}" gespeichert! 🎉`); + }); + }); + } + + return { init, refresh, onDogChange, startRecording: _startRecording }; })(); diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index d408e27..82c0423 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -1,335 +1,390 @@ /* ============================================================ - BAN YARO — Gassi-Routen - Routen entdecken (Karte + Liste) + GPS-Aufzeichnung + BAN YARO — Gassi-Routen (Komoot-Stil) + Sammlung, Suche, Bewertung, GPX-Download, Fotos, Nearby POIs ============================================================ */ window.Page_routes = (() => { - let _container = null; - let _appState = null; - let _map = null; - let _polylines = []; - let _data = []; - let _activeTab = 'entdecken'; // 'entdecken' | 'aufzeichnen' - let _leafletLoaded = false; - let _userPos = null; + let _container = null; + let _appState = null; + let _data = []; + let _filtered = []; + let _userPos = null; - // Aufzeichnung - let _recording = false; - let _watchId = null; - let _track = []; // [{lat, lon}] - let _distanceKm = 0; - let _startTime = null; - let _timerInt = null; - let _recPolyline = null; - let _recMarker = null; + let _search = ''; + let _difficulty = ''; + let _terrain = ''; + let _sortBy = 'newest'; + let _onlyMine = false; - const SCHWIERIGKEIT = ['leicht', 'mittel', 'anspruchsvoll']; - const UNTERGRUND = { wald: '🌲 Wald', asphalt: '🛣️ Asphalt', wiese: '🌿 Wiese', mix: '🔀 Mix' }; + const DIFFICULTY_LABEL = { leicht: '🟢 Leicht', mittel: '🟡 Mittel', anspruchsvoll: '🔴 Anspruchsvoll' }; + const TERRAIN_LABEL = { wald: '🌲 Wald', asphalt: '🛣️ Asphalt', wiese: '🌿 Wiese', mix: '🔀 Mix' }; + const HUNDE_LABEL = { eingeschränkt: '🐾', gut: '🐾🐾', sehr_gut: '🐾🐾🐾', premium: '🐾🐾🐾🐾' }; + + // POI-Typen die entlang einer Route gezeigt werden + const NEARBY_TYPES = [ + { type: 'restaurant', icon: '🍽️', label: 'Restaurant/Café' }, + { type: 'parkplatz', icon: '🅿️', label: 'Parkplatz' }, + { type: 'drinking_water', icon: '💧', label: 'Wasserstelle' }, + { type: 'bank', icon: '🪑', label: 'Bank' }, + ]; function _esc(s) { return String(s || '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } - function _haversine(lat1, lon1, lat2, lon2) { - const R = 6371000; - const p1 = lat1 * Math.PI / 180, p2 = lat2 * Math.PI / 180; - const dp = (lat2 - lat1) * Math.PI / 180; - const dl = (lon2 - lon1) * Math.PI / 180; - const a = Math.sin(dp/2)**2 + Math.cos(p1)*Math.cos(p2)*Math.sin(dl/2)**2; - return 2 * R * Math.asin(Math.sqrt(a)); - } - - // ---------------------------------------------------------- - // INIT - // ---------------------------------------------------------- async function init(container, appState) { _container = container; _appState = appState; _render(); - _loadData(); try { _userPos = await API.getLocation(); } catch {} + _loadData(); } function refresh() { _loadData(); } function onDogChange() {} // ---------------------------------------------------------- - // RENDER — Grundstruktur + // Render // ---------------------------------------------------------- function _render() { _container.innerHTML = ` -
- - -
- - -
- - -
-
-
-

Lädt…

+
+
+
+ + +
-
- - -