Sprint 14: Map-Fixes, City-Prewarm, Dog-Animation, Scan-Flash
Karte: - Frankfurt-Fallback (Zoom 10→14 flyTo) mit _frankfurtTimer-Cancel wenn echter Standort eintrifft - OSM-Tile-Fetch parallelisiert (asyncio.Semaphore(3)) - Bounds-Fix: invalidateSize() + pad(0.15) vor getBounds() - map-pin-slash Icon für gesperrten Standort - Scan-Done-Flash: Statusbar-Pill grün bei 100% - Schnüffelhund: outer div (by-wander X) + inner SVG (by-sniff Y) für natürlichere zweiachsige Bewegung Backend: - City-Prewarm-Job: ~70 deutsche Großstädte beim Start (+90s) und wöchentlich (So 01:00), Fortschritts-Mails alle 5h an ADMIN_EMAIL - ADMIN_EMAIL Env-Var in .env.example dokumentiert Bugfixes: - Profil-Edit: /api/profile → /profile (doppelter Prefix) - Friends: Mobile-Portrait-Layout (flex-wrap, overflow-x:hidden) - Trainingspläne: Pills text-wrap (flex + white-space:normal)
This commit is contained in:
parent
cd3f118113
commit
6fcf841594
10 changed files with 340 additions and 32 deletions
|
|
@ -25,3 +25,6 @@ KI_CLOUD_MODEL=claude-opus-4-6
|
|||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_CONTACT=mailto:admin@banyaro.app
|
||||
|
||||
# Admin-Benachrichtigungen (z.B. Prewarm-Fortschritt)
|
||||
ADMIN_EMAIL=
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ Cacht OSM-Daten lokal, erlaubt Nutzern eigene Marker und Meldungen.
|
|||
"""
|
||||
|
||||
import math
|
||||
import asyncio
|
||||
import httpx
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
|
@ -142,8 +143,11 @@ async def get_pois(
|
|||
stale = _stale_tiles(type, tiles)
|
||||
|
||||
if stale and not fast:
|
||||
for (x, y) in stale:
|
||||
await _fetch_and_store_tile(type, x, y)
|
||||
sem = asyncio.Semaphore(3)
|
||||
async def _limited(x, y):
|
||||
async with sem:
|
||||
await _fetch_and_store_tile(type, x, y)
|
||||
await asyncio.gather(*[_limited(x, y) for (x, y) in stale])
|
||||
fetched_fresh = True
|
||||
|
||||
with db() as conn:
|
||||
|
|
@ -309,9 +313,16 @@ async def analyze_region(
|
|||
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):
|
||||
sem = asyncio.Semaphore(3)
|
||||
async def _limited(poi_type, x, y):
|
||||
async with sem:
|
||||
await _fetch_and_store_tile(poi_type, x, y)
|
||||
tasks = [
|
||||
_limited(pt, x, y)
|
||||
for pt in OSM_QUERIES
|
||||
for (x, y) in _stale_tiles(pt, tiles)
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
background_tasks.add_task(_warmup)
|
||||
return {'status': 'gestartet', 'tiles': len(tiles), 'types': list(OSM_QUERIES.keys())}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,13 @@ def start():
|
|||
replace_existing=True,
|
||||
misfire_grace_time=7200,
|
||||
)
|
||||
_scheduler.add_job(
|
||||
_job_prewarm_cities,
|
||||
CronTrigger(day_of_week='sun', hour=1), # jeden Sonntag 01:00 Uhr
|
||||
id="prewarm_cities",
|
||||
replace_existing=True,
|
||||
misfire_grace_time=7200,
|
||||
)
|
||||
# Einmalig beim Start (nach 10s Verzögerung) für sofortige Befüllung
|
||||
_scheduler.add_job(
|
||||
_job_import_events,
|
||||
|
|
@ -61,6 +68,14 @@ def start():
|
|||
id="import_events_startup",
|
||||
replace_existing=True,
|
||||
)
|
||||
# Einmalig beim Start (nach 90s) — OSM-Tiles für Großstädte vorwärmen
|
||||
_scheduler.add_job(
|
||||
_job_prewarm_cities,
|
||||
'date',
|
||||
run_date=datetime.now() + timedelta(seconds=90),
|
||||
id="prewarm_cities_startup",
|
||||
replace_existing=True,
|
||||
)
|
||||
# Einmalig beim Start (nach 15s Verzögerung) — Rassen aus TheDogAPI befüllen
|
||||
_scheduler.add_job(
|
||||
_job_seed_breeds,
|
||||
|
|
@ -384,6 +399,178 @@ async def _job_seed_wikidata_breeds():
|
|||
logger.error(f"Wikidata-Seed: Fehler: {e}")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# JOB: OSM-Tiles für deutsche Großstädte vorwärmen
|
||||
# Läuft einmalig 90s nach Start + wöchentlich So 01:00 Uhr.
|
||||
# Nur stale Tiles werden abgerufen (bereits gecachte werden übersprungen).
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Deutsche Städte mit >50.000 Einwohnern (lat, lon, name)
|
||||
_CITIES_DE = [
|
||||
(52.5200, 13.4050, "Berlin"),
|
||||
(53.5753, 10.0153, "Hamburg"),
|
||||
(48.1372, 11.5755, "München"),
|
||||
(51.2217, 6.7762, "Düsseldorf"),
|
||||
(50.9333, 6.9500, "Köln"),
|
||||
(50.1109, 8.6821, "Frankfurt"),
|
||||
(48.7775, 9.1800, "Stuttgart"),
|
||||
(51.4566, 7.0116, "Dortmund"),
|
||||
(51.5136, 7.4653, "Dortmund-Ost"),
|
||||
(51.4508, 7.0131, "Essen"),
|
||||
(51.3388, 12.3799, "Leipzig"),
|
||||
(51.2254, 6.7762, "Düsseldorf"),
|
||||
(51.0534, 13.7373, "Dresden"),
|
||||
(52.3759, 9.7320, "Hannover"),
|
||||
(51.4818, 7.2162, "Bochum"),
|
||||
(51.9607, 7.6261, "Münster"),
|
||||
(51.3670, 7.4595, "Hagen"),
|
||||
(50.7753, 6.0839, "Aachen"),
|
||||
(51.2563, 7.1500, "Wuppertal"),
|
||||
(49.4521, 11.0767, "Nürnberg"),
|
||||
(53.0758, 8.8072, "Bremen"),
|
||||
(50.7323, 7.0955, "Bonn"),
|
||||
(49.0069, 8.4037, "Karlsruhe"),
|
||||
(51.9607, 7.6261, "Münster"),
|
||||
(51.4344, 6.7623, "Duisburg"),
|
||||
(51.6667, 6.1667, "Moers"),
|
||||
(48.3705, 10.8978, "Augsburg"),
|
||||
(52.2689, 10.5268, "Braunschweig"),
|
||||
(50.9287, 11.5861, "Jena"),
|
||||
(53.8655, 10.6866, "Lübeck"),
|
||||
(54.3233, 10.1394, "Kiel"),
|
||||
(53.1435, 8.2146, "Oldenburg"),
|
||||
(52.0302, 8.5325, "Bielefeld"),
|
||||
(51.3167, 9.5000, "Kassel"),
|
||||
(50.0000, 8.2731, "Mainz"),
|
||||
(49.8728, 8.6512, "Darmstadt"),
|
||||
(49.0047, 12.0949, "Regensburg"),
|
||||
(48.9960, 8.4025, "Pforzheim"),
|
||||
(53.4706, 9.9817, "Hamburg-Süd"),
|
||||
(50.8283, 12.9209, "Chemnitz"),
|
||||
(51.7227, 8.7559, "Paderborn"),
|
||||
(52.1205, 11.6276, "Magdeburg"),
|
||||
(52.6367, 11.8683, "Magdeburg-Ost"),
|
||||
(50.3569, 7.5890, "Koblenz"),
|
||||
(48.4010, 9.9876, "Ulm"),
|
||||
(51.0504, 13.7373, "Dresden-Mitte"),
|
||||
(49.4875, 8.4660, "Mannheim"),
|
||||
(49.2354, 7.0038, "Kaiserslautern"),
|
||||
(50.1155, 8.6782, "Frankfurt-Mitte"),
|
||||
(50.0782, 8.2398, "Wiesbaden"),
|
||||
(52.4227, 10.7865, "Wolfsburg"),
|
||||
(51.9607, 8.8693, "Gütersloh"),
|
||||
(53.5753, 9.8500, "Hamburg-West"),
|
||||
(48.5216, 9.0576, "Reutlingen"),
|
||||
(48.9522, 9.4358, "Heilbronn"),
|
||||
(49.4478, 7.7691, "Kaiserslautern-W"),
|
||||
(53.6333, 9.9833, "Hamburg-Nord"),
|
||||
(52.3905, 13.0645, "Potsdam"),
|
||||
(54.0924, 12.1407, "Rostock"),
|
||||
(53.4339, 14.5508, "Szczecin-grenze"),
|
||||
(51.7563, 14.3329, "Cottbus"),
|
||||
(50.4782, 12.3598, "Zwickau"),
|
||||
(53.5507, 9.9967, "Hamburg-Mitte"),
|
||||
(51.8127, 10.3354, "Goslar"),
|
||||
(48.6843, 9.0061, "Böblingen"),
|
||||
(48.7761, 9.1775, "Stuttgart-Mitte"),
|
||||
(49.4521, 8.4660, "Heidelberg"),
|
||||
(50.8088, 8.7667, "Marburg"),
|
||||
(51.9607, 7.6261, "Münster-Mitte"),
|
||||
(52.2763, 8.0479, "Osnabrück"),
|
||||
(53.8755, 10.7000, "Lübeck-Ost"),
|
||||
(51.9333, 6.8667, "Borken"),
|
||||
]
|
||||
|
||||
async def _job_prewarm_cities():
|
||||
import os, asyncio, time
|
||||
from routes.osm import _covering_tiles, _stale_tiles, _fetch_and_store_tile, OSM_QUERIES, CACHE_ZOOM
|
||||
from mailer import send_email
|
||||
|
||||
ADMIN = os.getenv("ADMIN_EMAIL", "")
|
||||
REPORT_INTERVAL = 5 * 3600 # alle 5 Stunden
|
||||
|
||||
logger.info("City-Prewarm Job startet…")
|
||||
sem = asyncio.Semaphore(2)
|
||||
total_fetched = 0
|
||||
cities_done = 0
|
||||
start_time = time.monotonic()
|
||||
last_report = start_time
|
||||
|
||||
async def _fetch(poi_type, x, y):
|
||||
nonlocal total_fetched
|
||||
async with sem:
|
||||
await _fetch_and_store_tile(poi_type, x, y)
|
||||
total_fetched += 1
|
||||
await asyncio.sleep(1.5)
|
||||
|
||||
async def _send_progress(subject_prefix, cities_done, total_cities, eta_str=""):
|
||||
if not ADMIN:
|
||||
return
|
||||
elapsed = int(time.monotonic() - start_time)
|
||||
h, m = divmod(elapsed // 60, 60)
|
||||
elapsed_str = f"{h}h {m:02d}min" if h else f"{m}min"
|
||||
pct = round(cities_done / total_cities * 100)
|
||||
body_plain = (
|
||||
f"City-Prewarm Fortschritt\n\n"
|
||||
f"Städte: {cities_done}/{total_cities} ({pct}%)\n"
|
||||
f"Tiles geladen: {total_fetched}\n"
|
||||
f"Laufzeit: {elapsed_str}\n"
|
||||
f"{('Verbleibend (ca.): ' + eta_str) if eta_str else ''}"
|
||||
)
|
||||
body_html = f"""\
|
||||
<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px">
|
||||
<h2 style="color:#C4843A;margin:0 0 16px">🗺️ City-Prewarm {subject_prefix}</h2>
|
||||
<table style="border-collapse:collapse;width:100%">
|
||||
<tr><td style="padding:6px 0;color:#666">Städte:</td>
|
||||
<td style="padding:6px 0;font-weight:600">{cities_done} / {total_cities} ({pct}%)</td></tr>
|
||||
<tr><td style="padding:6px 0;color:#666">Tiles geladen:</td>
|
||||
<td style="padding:6px 0;font-weight:600">{total_fetched}</td></tr>
|
||||
<tr><td style="padding:6px 0;color:#666">Laufzeit:</td>
|
||||
<td style="padding:6px 0;font-weight:600">{elapsed_str}</td></tr>
|
||||
{f'<tr><td style="padding:6px 0;color:#666">Verbleibend (ca.):</td><td style="padding:6px 0;font-weight:600">{eta_str}</td></tr>' if eta_str else ''}
|
||||
</table>
|
||||
</div>"""
|
||||
try:
|
||||
await send_email(ADMIN, f"Ban Yaro — City-Prewarm {subject_prefix}", body_html, body_plain)
|
||||
except Exception as e:
|
||||
logger.warning(f"City-Prewarm Mail fehlgeschlagen: {e}")
|
||||
|
||||
total_cities = len(_CITIES_DE)
|
||||
for lat, lon, city in _CITIES_DE:
|
||||
dlat = 0.18
|
||||
dlon = 0.25
|
||||
south, west, north, east = lat - dlat, lon - dlon, lat + dlat, lon + dlon
|
||||
tiles = _covering_tiles(south, west, north, east, CACHE_ZOOM)
|
||||
|
||||
tasks = []
|
||||
for poi_type in OSM_QUERIES:
|
||||
stale = _stale_tiles(poi_type, tiles)
|
||||
for (x, y) in stale:
|
||||
tasks.append(_fetch(poi_type, x, y))
|
||||
|
||||
if tasks:
|
||||
logger.info(f"City-Prewarm: {city} — {len(tasks)} Tiles zu laden")
|
||||
await asyncio.gather(*tasks)
|
||||
else:
|
||||
logger.debug(f"City-Prewarm: {city} — alle Tiles frisch")
|
||||
|
||||
cities_done += 1
|
||||
|
||||
# Fortschritts-Mail alle 5 Stunden
|
||||
now = time.monotonic()
|
||||
if ADMIN and (now - last_report) >= REPORT_INTERVAL:
|
||||
elapsed = now - start_time
|
||||
rate = cities_done / elapsed if elapsed > 0 else 0
|
||||
remaining = int((total_cities - cities_done) / rate) if rate > 0 else 0
|
||||
rh, rm = divmod(remaining // 60, 60)
|
||||
eta_str = f"{rh}h {rm:02d}min" if rh else f"{rm}min"
|
||||
await _send_progress("Fortschritt", cities_done, total_cities, eta_str)
|
||||
last_report = now
|
||||
|
||||
logger.info(f"City-Prewarm Job fertig — {total_fetched} Tiles geladen.")
|
||||
await _send_progress("abgeschlossen ✓", cities_done, total_cities)
|
||||
|
||||
|
||||
def _compute_milestone(today: date, bday: date, dog_name: str):
|
||||
"""
|
||||
Gibt (titel, text) zurück wenn heute ein Meilenstein-Tag ist,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
<symbol id="lock-open" viewBox="0 0 256 256"><path d="M208,80H96V56a32,32,0,0,1,32-32c15.37,0,29.2,11,32.16,25.59a8,8,0,0,0,15.68-3.18C171.32,24.15,151.2,8,128,8A48.05,48.05,0,0,0,80,56V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80Zm0,128H48V96H208V208Zm-68-56a12,12,0,1,1-12-12A12,12,0,0,1,140,152Z"/></symbol>
|
||||
<symbol id="magnifying-glass" viewBox="0 0 256 256"><path d="M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/></symbol>
|
||||
<symbol id="map-pin" viewBox="0 0 256 256"><path d="M128,64a40,40,0,1,0,40,40A40,40,0,0,0,128,64Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,128Zm0-112a88.1,88.1,0,0,0-88,88c0,31.4,14.51,64.68,42,96.25a254.19,254.19,0,0,0,41.45,38.3,8,8,0,0,0,9.18,0A254.19,254.19,0,0,0,174,200.25c27.45-31.57,42-64.85,42-96.25A88.1,88.1,0,0,0,128,16Zm0,206c-16.53-13-72-60.75-72-118a72,72,0,0,1,144,0C200,161.23,144.53,209,128,222Z"/></symbol>
|
||||
<symbol id="map-pin-slash" viewBox="0 0 256 256"><path d="M128,64a40,40,0,1,0,40,40A40,40,0,0,0,128,64Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,128Zm0-112a88.1,88.1,0,0,0-88,88c0,31.4,14.51,64.68,42,96.25a254.19,254.19,0,0,0,41.45,38.3,8,8,0,0,0,9.18,0A254.19,254.19,0,0,0,174,200.25c27.45-31.57,42-64.85,42-96.25A88.1,88.1,0,0,0,128,16Zm0,206c-16.53-13-72-60.75-72-118a72,72,0,0,1,144,0C200,161.23,144.53,209,128,222Z"/><path d="M213.66,42.34a8,8,0,0,0-11.32,0L42.34,202.34a8,8,0,0,0,11.32,11.32L213.66,53.66A8,8,0,0,0,213.66,42.34Z"/></symbol>
|
||||
<symbol id="map-trifold" viewBox="0 0 256 256"><path d="M228.92,49.69a8,8,0,0,0-6.86-1.45L160.93,63.52,99.58,32.84a8,8,0,0,0-5.52-.6l-64,16A8,8,0,0,0,24,56V200a8,8,0,0,0,9.94,7.76l61.13-15.28,61.35,30.68A8.15,8.15,0,0,0,160,224a8,8,0,0,0,1.94-.24l64-16A8,8,0,0,0,232,200V56A8,8,0,0,0,228.92,49.69ZM104,52.94l48,24V203.06l-48-24ZM40,62.25l48-12v127.5l-48,12Zm176,131.5-48,12V78.25l48-12Z"/></symbol>
|
||||
<symbol id="path" viewBox="0 0 256 256"><path d="M200,168a32.06,32.06,0,0,0-31,24H72a32,32,0,0,1,0-64h96a40,40,0,0,0,0-80H72a8,8,0,0,0,0,16h96a24,24,0,0,1,0,48H72a48,48,0,0,0,0,96h97a32,32,0,1,0,31-40Zm0,48a16,16,0,1,1,16-16A16,16,0,0,1,200,216Z"/></symbol>
|
||||
<symbol id="paw-print" viewBox="0 0 256 256"><path d="M212,80a28,28,0,1,0,28,28A28,28,0,0,0,212,80Zm0,40a12,12,0,1,1,12-12A12,12,0,0,1,212,120ZM72,108a28,28,0,1,0-28,28A28,28,0,0,0,72,108ZM44,120a12,12,0,1,1,12-12A12,12,0,0,1,44,120ZM92,88A28,28,0,1,0,64,60,28,28,0,0,0,92,88Zm0-40A12,12,0,1,1,80,60,12,12,0,0,1,92,48Zm72,40a28,28,0,1,0-28-28A28,28,0,0,0,164,88Zm0-40a12,12,0,1,1-12,12A12,12,0,0,1,164,48Zm23.12,100.86a35.3,35.3,0,0,1-16.87-21.14,44,44,0,0,0-84.5,0A35.25,35.25,0,0,1,69,148.82,40,40,0,0,0,88,224a39.48,39.48,0,0,0,15.52-3.13,64.09,64.09,0,0,1,48.87,0,40,40,0,0,0,34.73-72ZM168,208a24,24,0,0,1-9.45-1.93,80.14,80.14,0,0,0-61.19,0,24,24,0,0,1-20.71-43.26,51.22,51.22,0,0,0,24.46-30.67,28,28,0,0,1,53.78,0,51.27,51.27,0,0,0,24.53,30.71A24,24,0,0,1,168,208Z"/></symbol>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '98'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '116'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
|
||||
const App = (() => {
|
||||
|
||||
|
|
@ -261,6 +261,7 @@ const App = (() => {
|
|||
const item = e.target.closest('[data-page]');
|
||||
if (item) {
|
||||
navigate(item.dataset.page);
|
||||
if (item.closest('#sidebar')) _closeSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -274,6 +275,7 @@ const App = (() => {
|
|||
// Sidebar-User (kein data-page, damit keine Aktiv-Markierung)
|
||||
if (e.target.closest('#sidebar-user')) {
|
||||
navigate('settings');
|
||||
_closeSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ window.Page_friends = (() => {
|
|||
const myLink = `${location.origin}/#friends?suche=${encodeURIComponent(myName)}`;
|
||||
|
||||
_container.innerHTML = `
|
||||
<div style="max-width:520px;margin:0 auto;padding:var(--space-4)">
|
||||
<div style="max-width:520px;margin:0 auto;padding:var(--space-4);overflow-x:hidden">
|
||||
|
||||
<!-- Mein Freundes-Link -->
|
||||
<div class="card" style="margin-bottom:var(--space-5);padding:var(--space-4)">
|
||||
|
|
@ -186,18 +186,20 @@ window.Page_friends = (() => {
|
|||
${list.map(r => `
|
||||
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3);
|
||||
border-left:3px solid var(--c-primary)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3);flex-wrap:wrap">
|
||||
${_userAvatar(r.requester_name, r.dogs?.[0], r.avatar_url)}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:var(--weight-semibold);color:var(--c-text)">
|
||||
<div style="flex:1;min-width:120px">
|
||||
<div style="font-weight:var(--weight-semibold);color:var(--c-text);
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||
${_esc(r.requester_name)}
|
||||
</div>
|
||||
${_dogPills(r.dogs, 2)}
|
||||
</div>
|
||||
<div style="display:flex;gap:var(--space-2);flex-shrink:0">
|
||||
<div style="display:flex;gap:var(--space-2);margin-left:auto">
|
||||
<button class="btn btn-primary btn-sm"
|
||||
onclick="Page_friends._accept(${r.id})" title="Annehmen">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#check"></use></svg>
|
||||
Annehmen
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm"
|
||||
onclick="Page_friends._decline(${r.id})" title="Ablehnen">
|
||||
|
|
@ -426,7 +428,7 @@ window.Page_friends = (() => {
|
|||
</div>`);
|
||||
}
|
||||
if (profile.social_link) {
|
||||
parts.push(`<div style="font-size:var(--text-xs)">
|
||||
parts.push(`<div style="font-size:var(--text-xs);word-break:break-all">
|
||||
<a href="${_esc(profile.social_link)}" target="_blank" rel="noopener noreferrer"
|
||||
style="color:var(--c-primary)">${_esc(profile.social_link)}</a>
|
||||
</div>`);
|
||||
|
|
@ -508,10 +510,9 @@ window.Page_friends = (() => {
|
|||
</div>`
|
||||
: ''}
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm fr-add-btn"
|
||||
<button class="btn btn-primary btn-sm fr-add-btn" title="Anfrage senden"
|
||||
data-user-id="${u.id}" data-user-name="${_esc(u.name)}">
|
||||
<svg class="ph-icon"><use href="/icons/phosphor.svg#user-plus"></use></svg>
|
||||
Anfrage
|
||||
</button>
|
||||
</div>
|
||||
`).join('')}
|
||||
|
|
|
|||
|
|
@ -121,6 +121,8 @@ window.Page_map = (() => {
|
|||
|
||||
let _overpassTimer = null;
|
||||
let _overpassActive = false;
|
||||
let _ringClosing = false;
|
||||
let _frankfurtTimer = null;
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// INIT
|
||||
|
|
@ -133,11 +135,23 @@ window.Page_map = (() => {
|
|||
// 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();
|
||||
_initMap(); // sofort mit Deutschland-Mitte starten
|
||||
_startLocationTracking();
|
||||
_loadAll();
|
||||
// Standort im Hintergrund holen — bei Erfolg zur Position fliegen
|
||||
API.getLocation().then(pos => {
|
||||
_userPos = pos;
|
||||
if (_frankfurtTimer) { clearTimeout(_frankfurtTimer); _frankfurtTimer = null; }
|
||||
_map?.flyTo([pos.lat, pos.lon], 14, { duration: 1.2 });
|
||||
}).catch(() => {
|
||||
const btn = document.getElementById('map-locate-btn');
|
||||
if (btn) {
|
||||
btn.title = 'Standort nicht verfügbar';
|
||||
btn.style.opacity = '0.55';
|
||||
btn.innerHTML = '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#map-pin-slash"></use></svg>';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refresh() { _loadAll(); }
|
||||
|
|
@ -302,11 +316,15 @@ window.Page_map = (() => {
|
|||
const el = document.getElementById('central-map');
|
||||
if (!el || !window.L || _map) return;
|
||||
|
||||
const center = _userPos ? [_userPos.lat, _userPos.lon] : [51.1657, 10.4515];
|
||||
const zoom = _userPos ? 14 : 6;
|
||||
const center = _userPos ? [_userPos.lat, _userPos.lon] : [50.1109, 8.6821]; // Frankfurt
|
||||
const zoom = _userPos ? 14 : 10;
|
||||
|
||||
_map = L.map('central-map', { zoomControl: true, attributionControl: false })
|
||||
.setView(center, zoom);
|
||||
|
||||
if (!_userPos) {
|
||||
_frankfurtTimer = setTimeout(() => _map.flyTo(center, 14, { duration: 2.5 }), 1200);
|
||||
}
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(_map);
|
||||
|
||||
setTimeout(() => _map.invalidateSize(), 100);
|
||||
|
|
@ -403,9 +421,89 @@ window.Page_map = (() => {
|
|||
}
|
||||
|
||||
function _setOsmStatus(text, pct = null) {
|
||||
const el = document.getElementById('map-osm-status');
|
||||
const el = document.getElementById('map-osm-status');
|
||||
const statusbar = document.getElementById('map-statusbar');
|
||||
if (el) el.textContent = text;
|
||||
_updateScanRing(text ? pct : null);
|
||||
_updateScanDog(text ? pct : null);
|
||||
|
||||
if (pct === 100 && statusbar) {
|
||||
statusbar.classList.add('scan-done');
|
||||
setTimeout(() => statusbar.classList.remove('scan-done'), 2200);
|
||||
}
|
||||
}
|
||||
|
||||
function _injectDogStyles() {
|
||||
if (document.getElementById('by-dog-style')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'by-dog-style';
|
||||
s.textContent = [
|
||||
'@keyframes by-sniff{0%,100%{transform:translateY(0) rotate(0deg)}30%{transform:translateY(2.5px) rotate(-1.5deg)}70%{transform:translateY(1px) rotate(1deg)}}',
|
||||
'@keyframes by-wander{0%,100%{transform:translateX(0)}20%{transform:translateX(-7px)}45%{transform:translateX(5px)}68%{transform:translateX(-5px)}85%{transform:translateX(7px)}}',
|
||||
'@keyframes by-wag{0%,100%{transform:rotate(-22deg)}50%{transform:rotate(22deg)}}',
|
||||
'#map-scan-dog{animation:by-wander 1.75s ease-in-out infinite;transition:opacity .5s ease;color:#C4843A;position:absolute;pointer-events:none;z-index:1003;width:42px;height:32px}',
|
||||
'#map-scan-dog svg{display:block;animation:by-sniff .42s ease-in-out infinite}',
|
||||
'#map-scan-dog .by-tail{transform-box:fill-box;transform-origin:0% 100%;animation:by-wag .32s ease-in-out infinite}',
|
||||
'#map-statusbar{transition:background .35s ease,color .35s ease,border-color .35s ease}',
|
||||
'#map-statusbar.scan-done{background:#22C55E!important;color:#fff!important;border-color:#16A34A!important}',
|
||||
].join('');
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function _updateScanDog(pct) {
|
||||
_injectDogStyles();
|
||||
const statusbar = document.getElementById('map-statusbar');
|
||||
if (!statusbar) return;
|
||||
const mapEl = statusbar.closest('.map-main') || statusbar.parentElement;
|
||||
if (!mapEl) return;
|
||||
|
||||
let dog = document.getElementById('map-scan-dog');
|
||||
|
||||
if (pct === null) {
|
||||
if (_ringClosing) return;
|
||||
if (dog) { dog.style.opacity = '0'; setTimeout(() => dog?.remove(), 550); }
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dog) {
|
||||
dog = document.createElement('div');
|
||||
dog.id = 'map-scan-dog';
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', '42');
|
||||
svg.setAttribute('height', '32');
|
||||
svg.setAttribute('viewBox', '0 0 54 40');
|
||||
svg.innerHTML = `
|
||||
<ellipse cx="33" cy="22" rx="14" ry="8" fill="currentColor"/>
|
||||
<ellipse cx="16" cy="20" rx="6" ry="7" fill="currentColor"/>
|
||||
<ellipse cx="8" cy="27" rx="7" ry="6" fill="currentColor"/>
|
||||
<ellipse cx="14" cy="16" rx="3" ry="5" fill="currentColor" transform="rotate(15,14,16)" opacity=".85"/>
|
||||
<ellipse cx="3" cy="30" rx="3.5" ry="2.5" fill="currentColor"/>
|
||||
<ellipse cx="1.5" cy="29" rx="2" ry="1.5" fill="#7a4f2a"/>
|
||||
<circle cx="9" cy="24" r="1.3" fill="white" opacity=".9"/>
|
||||
<rect x="22" y="28" width="3" height="10" rx="1.5" fill="currentColor"/>
|
||||
<rect x="29" y="29" width="3" height="9" rx="1.5" fill="currentColor"/>
|
||||
<rect x="37" y="28" width="3" height="9" rx="1.5" fill="currentColor"/>
|
||||
<rect x="42" y="27" width="3" height="9" rx="1.5" fill="currentColor"/>
|
||||
<g class="by-tail" transform="translate(47,17)">
|
||||
<path d="M0,0 Q6,-10 4,-18" stroke="currentColor" stroke-width="3" fill="none" stroke-linecap="round"/>
|
||||
</g>
|
||||
`;
|
||||
dog.appendChild(svg);
|
||||
mapEl.appendChild(dog);
|
||||
}
|
||||
|
||||
const sr = statusbar.getBoundingClientRect();
|
||||
const mr = mapEl.getBoundingClientRect();
|
||||
dog.style.left = (sr.left - mr.left + sr.width - 36) + 'px';
|
||||
dog.style.top = (sr.top - mr.top - 35) + 'px';
|
||||
dog.style.opacity = '1';
|
||||
|
||||
if (pct >= 100) {
|
||||
setTimeout(() => {
|
||||
const d = document.getElementById('map-scan-dog');
|
||||
if (d) { d.style.opacity = '0'; setTimeout(() => d?.remove(), 550); }
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function _updateScanRing(pct) {
|
||||
|
|
@ -418,6 +516,7 @@ window.Page_map = (() => {
|
|||
|
||||
// Ring ausblenden / entfernen
|
||||
if (pct === null) {
|
||||
if (_ringClosing) return;
|
||||
if (svg) { svg.style.opacity = '0'; setTimeout(() => svg?.remove(), 600); }
|
||||
statusbar.style.border = '';
|
||||
return;
|
||||
|
|
@ -448,12 +547,13 @@ window.Page_map = (() => {
|
|||
const p = 2; // Abstand zur inneren Kante
|
||||
|
||||
// Umfang der Pill: gerades Stück + zwei Halbkreise
|
||||
const perim = 2 * (w - h) + Math.PI * h;
|
||||
// Stroke beginnt oben-links und läuft im Uhrzeigersinn
|
||||
// Um bei 12 Uhr zu starten: Offset um das linke Halbkreis-Viertel + halbe Geraden verschieben
|
||||
const startShift = (w - h) / 2 + (Math.PI * h) / 4;
|
||||
const progress = Math.min(100, Math.max(0, pct));
|
||||
const dashOffset = perim * (1 - progress / 100) + startShift;
|
||||
const perim = 2 * (w - h) + Math.PI * h;
|
||||
// Natürlicher SVG-Start: linkes Ende der oberen Geraden
|
||||
// 12-Uhr-Position: Mitte der oberen Geraden → Abstand = (w-h)/2
|
||||
// dashoffset = perim - S verschiebt den Dash-Start genau dorthin
|
||||
const S = (w - h) / 2;
|
||||
const progress = Math.min(100, Math.max(0, pct));
|
||||
const progressLen = progress * perim / 100;
|
||||
|
||||
svg.style.left = (sr.left - mr.left - p) + 'px';
|
||||
svg.style.top = (sr.top - mr.top - p) + 'px';
|
||||
|
|
@ -468,18 +568,19 @@ window.Page_map = (() => {
|
|||
rect.setAttribute('height', String(h));
|
||||
rect.setAttribute('rx', String(r));
|
||||
rect.setAttribute('ry', String(r));
|
||||
rect.setAttribute('stroke-dasharray', perim.toFixed(2));
|
||||
rect.setAttribute('stroke-dashoffset', dashOffset.toFixed(2));
|
||||
rect.setAttribute('stroke-dasharray', `${progressLen.toFixed(2)} ${(perim - progressLen).toFixed(2)}`);
|
||||
rect.setAttribute('stroke-dashoffset', (perim - S).toFixed(2));
|
||||
|
||||
// Original-Rahmen verstecken während Ring aktiv ist
|
||||
statusbar.style.border = 'none';
|
||||
|
||||
if (progress >= 100) {
|
||||
_ringClosing = true;
|
||||
setTimeout(() => {
|
||||
const s = document.getElementById('map-scan-ring');
|
||||
if (s) s.style.opacity = '0';
|
||||
statusbar.style.border = '';
|
||||
setTimeout(() => s?.remove(), 600);
|
||||
setTimeout(() => { s?.remove(); _ringClosing = false; }, 600);
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
|
@ -517,7 +618,8 @@ window.Page_map = (() => {
|
|||
}
|
||||
|
||||
_overpassActive = true;
|
||||
const b = _map.getBounds();
|
||||
_map.invalidateSize();
|
||||
const b = _map.getBounds().pad(0.15);
|
||||
const bbox = { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() };
|
||||
|
||||
// Welche Layer bei diesem Zoom geladen werden
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ window.Page_settings = (() => {
|
|||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
const res = await API.post('/api/profile/avatar', fd);
|
||||
const res = await API.post('/profile/avatar', fd);
|
||||
_appState.user.avatar_url = res.avatar_url;
|
||||
UI.toast.success('Avatar aktualisiert.');
|
||||
_render();
|
||||
|
|
@ -326,7 +326,7 @@ window.Page_settings = (() => {
|
|||
const btn = document.querySelector('[form="profile-form"]');
|
||||
const fd = UI.formData(e.target);
|
||||
await UI.asyncButton(btn, async () => {
|
||||
const updated = await API.patch('/api/profile', {
|
||||
const updated = await API.patch('/profile', {
|
||||
bio: fd.bio || '',
|
||||
wohnort: fd.wohnort || '',
|
||||
erfahrung: fd.erfahrung || '',
|
||||
|
|
|
|||
|
|
@ -124,7 +124,8 @@ window.Page_trainingsplaene = (() => {
|
|||
];
|
||||
const btns = plans.map(p => `
|
||||
<button class="by-tab${_activePlan === p.id ? ' active' : ''}" data-plan="${p.id}"
|
||||
style="flex:1;min-width:90px;flex-direction:column;align-items:center;gap:4px">
|
||||
style="flex:1;min-width:90px;display:flex;flex-direction:column;align-items:center;
|
||||
justify-content:center;gap:2px;white-space:normal;text-align:center;line-height:1.2">
|
||||
<span style="font-size:1.1rem">${p.label}</span>
|
||||
<span style="font-size:var(--text-xs);opacity:0.8">${_esc(p.sub)}</span>
|
||||
</button>`).join('');
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v123';
|
||||
const CACHE_VERSION = 'by-v141';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue