Compare commits

...

78 commits

Author SHA1 Message Date
72ee339860 Docs: DWD-Regen-Vorhersage-Pipeline gescoppt (Datenformat verifiziert)
DWD RV (composite/rv, kostenlos, alle 5 Min, 25 Frames 0-120min). Format
verifiziert: 194-Byte-ASCII-Header + 1200×1100 uint16 LE, Wert=&0x0FFF×0.01mm,
&0x2000=kein-Daten (PoC: Decode trivial, kein wradlib nötig). Pipeline:
fetch→decode→kolorieren→reprojizieren(DE1200→3857)→Kacheln→PMTiles/Cron 5min;
Frontend hängt Forecast-Frames rechts von 'jetzt' in die Timeline. Knackpunkt:
Georeferenzierung (PoC nötig).
2026-06-05 20:53:51 +02:00
ac187dc740 Radar-Timeline: Slider-Scrubbing gefixt + Breite/Höhe an Status-Pill
- Scrub-Bug: _radarPause() setzte slider.value zurück, BEVOR der gezogene Wert
  gelesen wurde → sprang immer auf 'jetzt'. Jetzt Wert zuerst lesen. Scrubben
  stoppt Play + zeigt den Frame der Position (verifiziert: Klick 20%→Frame 2,
  Setter→5→Frame 5).
- Breite per JS an .map-statusbar angeglichen (gleiche linke + rechte Kante),
  Höhe/Optik an die Pill (kleinerer Play-Button, flacher).
2026-06-05 20:39:41 +02:00
ea2cdd4f89 Radar-Timeline: Optik an Status-Pill angeglichen (hell/Border/Blur, Dark-Mode) + tiefer (direkt über die Pill) 2026-06-05 20:23:32 +02:00
22b8ccb784 Radar-Timeline: rechts kürzen (Platz für Ecken-FABs) + tiefer setzen
Lief rechts unter die Speed-Dial/Zurück-FABs (Zeit-Text verdeckt). Jetzt
left:12/right:88 (statt zentriert) → endet vor den FABs; bottom 92→60px.
2026-06-05 20:18:02 +02:00
bcbf9a9645 Regenradar: abspielbare Zeitleiste (RainViewer ~2h Verlauf + Nowcast)
Bisher nur der neueste Frame; jetzt alle ~13-16 RainViewer-Frames (past+nowcast,
10-Min-Schritte) mit Play/Pause + Slider + Zeitstempel (jetzt / +N / -N Min,
Vorhersage-Frames bläulich) — RainToday-artig. Frame-Wechsel smooth via
raster setTiles (kein Flackern), Loop, _radarNowIdx = letzter Vergangenheits-Frame.
Timeline unten-mittig, wird bei Radar-AUS entfernt. Pro-Feature wie das Radar.
Headless verifiziert: 13 Frames, Play scrubbt (12→0→1→2), keine Fehler.
2026-06-05 20:12:30 +02:00
aefdac87ad Wetter-Pill: Niederschlag = Höchstwert der nächsten 3 Std statt Tages-Max
weather.py get_weather_for_location: precip = max(h_precip[now_h:now_h+3])
(Fallback Tages-Max). map.js Pill zeigt '💧 X% (3h)'. Gilt auch fürs
Welten-Banner (geteiltes precip_prob-Feld) — behebt das 'ganzer Tag'-Problem
überall. Gespeicherte Tagebuch-Snapshots unberührt (historisch).
2026-06-05 20:01:32 +02:00
fd6be50762 Offline-Plan: Umsetzungsstand (Kern fertig + verifiziert, Follow-ups offen) 2026-06-05 19:54:29 +02:00
5337ddfa05 Offline-Karten: Welten-FAB Segment 5 + Download-Trigger (flag-gated)
offline-indicator.js: im GL-Offline-Modus (by_offline_tiles) prüft Segment 5
'Karten-Kacheln' jetzt eine gespeicherte Vektor-Region in IndexedDB (statt des
OSM-Raster-Counts, den die GL-Karte nicht nutzt → war falsch-grün). 'Fehlende
nachladen' (Segment 5) stößt im GL-Modus MapOffline.downloadAround(GPS, 5km) an.
- _offlineRegionStored legt dasselbe IDB-Schema/Version an wie map-offline.js
  (sonst bricht ein versionsloses open() die Store-Erstellung)
- UI.loadMapLibreUI exportiert (für den FAB-Download)
Headless verifiziert: Flag an, keine Fehler; Segment 5 vor Download grau (0),
nach Download grün (97 Tiles).
2026-06-05 19:53:55 +02:00
8f13f4d38d Offline-Karten: Kern implementiert (Region-Download → IndexedDB → Offline-Render)
map-offline.js (window.MapOffline): lädt Vektorkacheln eines Bereichs via pmtiles.getZxy
in IndexedDB + cacht die Glyphs mit (KRITISCH: ohne Glyphs lässt MapLibre offline die
ganze Kachel fallen). byt://-Protokoll bedient MapLibre IndexedDB-first, remote-Fallback.
- map-gl-style.js: build({offline}) nutzt byt-Source statt pmtiles:// (Flag by_offline_tiles,
  Default AUS bis gerätegetestet); glyphs bleiben /fonts (SW-gecacht)
- ui.js + map.js: map-offline.js mitladen + byt-Protokoll registrieren
- getZxy liefert bereits dekomprimierte MVT (kein gunzip) → ~15 MB/5km in IndexedDB

Headless bewiesen: Download 97 Tiles (5km München) → Netz AUS → 1903 Features gerendert,
nicht geladene Gegend (Paris) korrekt leer. Offen: Download-Button/FAB-Segment-5-Verdrahtung,
adaptives Lernen, Bereichsauswahl/Routen-Korridor (siehe docs/OFFLINE_MAPS_PLAN.md).
2026-06-05 19:46:18 +02:00
2a809a9a0b Fix: Tiles-Cache-Bust — versionierte PMTiles-URL + version-bewusstes Caching
'nur DACH auf Staging' Ursache: serve_tile schickte Cache-Control max-age=86400
OHNE Validator → Browser lieferte bis 24h die ALTEN PMTiles-Bytes (altes Directory)
trotz Datei-Swap. Fix:
- map-gl-style.js: tilesUrl() hängt ?v=TILES_VER an (Cache-Bust bei Tile-Deploy)
- serve_tile: ?v vorhanden → max-age=1y immutable; ohne → max-age=60 (self-heal) + ETag
- Makefile tiles-deploy zählt TILES_VER automatisch hoch + erinnert an Frontend-Deploy
2026-06-05 19:18:43 +02:00
80b56c32ab Offline-Plan: Routen-Korridor an Routen-Feature anbinden (fehlende Verbindung)
Einstieg AUS der Route: Button 'Route offline speichern' im Routen-Detail
(_openDetail) + Navi-Ansicht, nutzt route.gps_track → Korridor = Tiles im Puffer
um den Track. Plus 'Aus meinen Routen wählen' im Offline-Dialog. War als Modus
genannt, aber ohne Einstiegspunkt/Routen-Quelle.
2026-06-05 18:03:53 +02:00
f38301a391 Offline-Plan: 'Offline-Inhalte laden' mit Bereichsauswahl für Mehrtages-Wanderungen
Zwei Modi: Aktuelle Gegend (budget-getrieben, Gassi) + Bereich auswählen (Mehrtagestour) —
Karten-Viewport/Rechteck/Routen-Korridor, Größen-Vorschau vor Download (PMTiles-Directory
aufsummieren), Liste gespeicherter Gebiete. Budget gilt im Bereichs-Modus nicht (bewusste Wahl).
2026-06-05 18:00:59 +02:00
27e7590eed Offline-Plan: Pfoten-Indikator (Welten-FAB) Segment 5 als Kopplung festgehalten
Segment 5 'Karten-Kacheln' misst aktuell OSM-Raster-Cache (offline-indicator.js,
CACHE_TILES) — GL nutzt das nicht → falsches 'Karte offline bereit'. Bei GL-Region-
Download umdefinieren (region.pmtiles + Glyphs prüfen, offline-fill-btn stößt Region-
Download an). An Offline-Strategie gekoppelt.
2026-06-05 17:55:33 +02:00
d1e44ebfb9 Offline-Plan: Referenz-Radius 10→5 km (gemessen 6,4 MB Stadt / 2,6 MB Land), Budget ~7 MB 2026-06-05 17:52:20 +02:00
daa44946f1 Docs/Karten-Plan: Wetter-Chip Niederschlag auf nächste 3h-Max umstellen
Karten-Chip zeigt aktuell Tages-Max (weather.py:98 daily.precipitation_probability_max[0]);
soll Höchstwert der nächsten 3 Std zeigen (stündliche Daten h_precip schon vorhanden:
max(h_precip[now_idx:now_idx+3])). Als abgegrenzte 'Weitere Karten-To-Dos'-Sektion festgehalten.
2026-06-05 17:48:59 +02:00
827ea95191 Tiles-Progress: Stufe 4 zeigt echte planetiler-Phase statt Müll-ETA 2026-06-05 17:21:08 +02:00
43b1e8026f Docs: Offline-Karten-Plan (GL/Vektor) — budget-getrieben + adaptives Funkloch-Lernen
Festgehalten als Feature-Plan: Region-Extract per pmtiles (budget-getrieben statt
Radius, ~16 MB Default, gemessen 15 MB für 10km München / ~18-22km Land), Client
IndexedDB-Blob + MapLibre lokal/remote, adaptives Lernen (rollendes Vorausladen
beim Aufzeichnen + Funkloch-Gedächtnis aus echten Fetch-Fehlern, alles lokal),
manuelles Vorab-Laden, Budget+LRU. Umsetzung nach Produktions-Rollout.
2026-06-05 17:16:16 +02:00
29076bcdff Tiles: Fortschritts-Snapshot-Skript (Stufe/Balken/ETA) für Build-Monitoring + .gitignore 2026-06-05 16:50:52 +02:00
d11794355c Tiles: DACH + alle Anrainer (15 Länder) + Einzel-PBFs nach Merge freigeben
TILES_REGIONS auf germany/austria/switzerland + france/italy/czech-republic/
poland/slovakia/hungary/slovenia/netherlands/belgium/luxembourg/denmark/
liechtenstein erweitert. Output bleibt dach.pmtiles (Frontend-Name stabil).
Nach time-filter werden History + Einzel-PBFs gelöscht → ~27 GB weniger
Spitzen-Plattenplatz vor planetiler.
2026-06-05 16:15:20 +02:00
c7201aa07b Karten-Attribution: standardmäßig eingeklappt (nur ⓘ) + doppelten Hinweis entfernt
Punkt 6: MapLibre rendert die Compact-Attribution offen (maplibregl-compact-show
+ open) → voller Text '© OpenStreetMap contributors' immer sichtbar. Neuer Helper
MapGLStyle.collapseAttribution() entfernt die Klasse/open nach dem Hinzufügen →
nur noch das ⓘ, der Text erscheint erst auf Klick (rechtlich nach ODbL ausreichend).
In map-gl-mini.js (Seitenkarten) + map.js (zentrale Karte) verdrahtet.

Punkt 7: poison.js + lost.js hatten UNTER der Karte zusätzlich ein hartkodiertes
'© OpenStreetMap-Mitwirkende' — doppelt zum Karten-ⓘ. Entfernt (+ ungenutzte
.lost-map-attribution CSS-Klasse). Verifiziert: osmTextLeafCount 2-3 → 1, compactShown true → false.
2026-06-05 15:48:11 +02:00
da6451a1c7 Karten: Mitglieder-Karte (Forum) auf GL + verwaiste Orte-Seite gelöscht
Mitglieder-Karte (forum.js): L.map/L.tileLayer(OSM) → UI.map.create, Cluster
+ Marker über die Facade (UI.map.clusterGroup/svgMarker), eigenes Leaflet-/
MarkerCluster-Nachladen raus. destroy() gibt Karte+Gruppe beim Verlassen frei.
Headless verifiziert: GL-Canvas, 2 Mitglieder-Marker, keine Fehler.

places.js (separate 'Hundefreundliche Orte'-Seite) war verwaist — in keiner
Navigation/keinem pages-Registry, nicht erreichbar. Die hundefreundlichen Orte
laufen längst als POI-Marker auf der zentralen GL-Karte (map.js). Auf Renés
Entscheidung gelöscht (JS + CSS-Block in components.css).

Damit laufen ALLE erreichbaren Karten der App auf MapLibre GL.
2026-06-05 15:28:51 +02:00
720971d252 Routen-Detailkarte: WebGL-Kontext-Leak gefixt → bleibt GL + zoomt auf Route
Eigentliche Ursache von 'Detailkarte zoomt nicht auf die Route': die Karte war
auf dem Gerät gar keine GL-Karte mehr, sondern der Leaflet+OSM-RASTER-Fallback.
Grund: _detailMap (GL-Kontext) wurde beim Schließen des Modals NIE freigegeben —
jede geöffnete Route leakte einen WebGL-Kontext. Nach ~8 wirft MapLibre, und
UI.map.create fällt auf Leaflet+OSM zurück. Genau die Mapnik-Kacheln aus Renés
Screenshots (und die OSM-Attribution, die wir doch loswerden wollten).

Fixes:
- _detailMap modulweit + im onClose des Detail-Modals freigeben.
- routes.js destroy(): _detailMap/_suggestMap/_searchMap + Mini-Maps beim
  Verlassen der Seite freigeben.
- ui.js: Offscreen-Snapshot-Kontext nach 15s Leerlauf freigeben (hielt dauerhaft
  einen Kontext; Cache bleibt → kein Neu-Rendern).
- _fitRouteMap fittet jetzt aufs 'load'/'idle'-Event der Karte (iOS verwirft ein
  fitBounds VOR dem ersten Render) statt nur auf feste Timeouts.

Verifiziert (headless): 12 Detail-Öffnungen in Folge bleiben ALLE GL
(Leaflet:false), GL-Canvas-Zahl bleibt bei 1–2 statt zu wachsen. Vorher leakte
jede Öffnung einen Kontext.
2026-06-05 15:10:12 +02:00
d203ab17a8 Routen: Detail/Vorschlag-Zoom robust (ResizeObserver) + Navi-Sperrbildschirm nur per Fingerabdruck
Punkt 3 (Zoom auf die Route): feste Timeouts (0/200/500ms) griffen auf iOS oft
zu früh — der Modal-Container war noch nicht final vermessen, die Karte blieb
beim Start-Zoom (zoom 14, center=Start) hängen statt auf die ganze Route zu
zoomen. Jetzt _fitRouteMap mit ResizeObserver: fittet erneut, SOBALD der
Container seine endgültige Größe hat (Detail + Vorschläge). Facade-fitBounds
prüft jetzt auch clientHeight>0 (0-Höhe ergab schlechten Fit).

Punkt 5 (Navigations-Sperrbildschirm): der 2-Sek-Halten-Handler hing am ganzen
Dim-Overlay → Halten IRGENDWO entsperrte. Jetzt ein eigener Fingerabdruck-Knopf
(rk-nav-unlock-btn) wie beim Aufzeichnen-Dim; nur dort entsperrt es, mit
setPointerCapture. Tippen daneben tut bewusst nichts.

Verifiziert (headless): Detail fittet die ganze Route (v1204, 0 Fehler);
Dim-Hintergrund 2,2s halten → bleibt gesperrt, Knopf 2,2s halten → entsperrt.
2026-06-05 14:47:15 +02:00
285928f6f7 Karten: Routen-Übersichtskarte klickbar + Tagebuch-Karten auf GL
Punkt 2 (Routen-Übersicht 'Karte'): _renderRoutesOnMap crashte, weil die
Polyline-Facade kein bindTooltip/on/setStyle/getLatLngs kannte. In
map-gl-mini.js ergänzt — inkl. breiter, fast unsichtbarer Hit-Linie, damit
Routen auf dem Handy gut antippbar sind (Klick → Detail). Hover-Tooltip
(Name+km) + Hover-Highlight.

Punkt 4 (Tagebuch): beide Leaflet/OSM-Karten (Standort-Übersicht +
Einzeleintrag) auf UI.map.create + Facade-Marker migriert. popupopen-Wiring
(kennt die GL-Facade nicht) → Klick-Delegation auf dem Karten-Container.
Karten-Instanzen werden beim View-Wechsel/Verlassen freigegeben (destroy +
_clearDiaryMaps) gegen WebGL-Kontext-Leak. Detail/Übersicht fitten mehrfach
(Container-Timing).

Nebenbei: _loadPraise warf NotFoundError (insertBefore) — #diary-list liegt
in #diary-view-content, nicht direkt in _container. Jetzt vor der Liste in
deren echtem Elternknoten einfügen.

Verifiziert (headless, eingeloggt, echte Daten): Routenkarte 8 Marker klickbar
→ Detail; Detail+Vorschläge zoomen auf die Route; Tagebuch-Karte GL mit 108
Markern, Popup-Klick → Eintrag, keine Fehler.
2026-06-05 14:23:22 +02:00
1defeec537 Routen-Vorschau: echtes Karten-PNG (Basemap+Route) statt nackter SVG-Form
In der Routenliste fehlte der geografische Kontext — man sah nur die Routen-
form auf grünem Grund, nicht WO sie liegt oder wo sie entlangführt.

Lösung: UI.map.snapshot() rendert pro Track ein PNG aus EINEM geteilten
Offscreen-GL-Kontext (gleicher Style wie die echte Karte: Straßen, Orte,
Wald, Gewässer), zeichnet Route + Start/Ziel-Marker ein und cached das
Ergebnis. So bekommt jede Karte ihren Kontext, ohne bei vielen Listen-
einträgen das WebGL-Kontextlimit (iOS ~8) zu sprengen.

- ui.js: Offscreen-Singleton + serielle Render-Queue + Cache (_glSnapshot)
- routes.js: _buildMiniMap zeigt sofort SVG, upgradet dann aufs PNG
- GL aus → null → SVG-Platzhalter bleibt (Produktion/Flag aus unverändert)
2026-06-05 13:57:47 +02:00
a0d16ba800 Fix: Seiten-Crash bleibt nicht mehr für die ganze Session hängen
Ein transienter Init-Fehler (Netz-Blip, SW-Update mitten in der Navigation,
Race) setzte page.module={} — der Guard 'if (page.module)' lud die Seite
danach nie mehr nach. Auf einer iOS-PWA, die nie ganz neu lädt, blieb 'Die
Seite funktioniert nicht mehr' damit tagelang hängen, obwohl der eigentliche
Bug (Routen-GL) längst gefixt war.

- echten Fehler nicht mehr verschlucken (console.error)
- page.module bei Exception NICHT mehr tot stellen → nächster Aufruf versucht neu
- 'Erneut versuchen'-Button im Fehler-State
- Routen v1199 in Chromium+WebKit headless verifiziert (Liste/Entdecken/Detail ok)
2026-06-05 13:48:58 +02:00
d96fa9e24e Seitenkarten destroy(): GL-Karte beim Seitenwechsel freigeben (WebGL-Kontext-Leak)
poison/lost/walks/events: destroy() ruft _map.remove() → app.js gibt den WebGL-Kontext beim
Navigieren frei. Sonst akkumulieren Kontexte → iOS-Limit (~8) → neue GL-Karten (z.B. Routen-Detail)
scheitern → Leaflet-Raster-Fallback.
2026-06-05 13:16:38 +02:00
27a3f954a4 Routen-Fixes: Richtungspfeile (SVG-interne Rotation) + Filter standardmäßig zu
- Pfeile: rotate als SVG-Pfad-Attribut statt CSS-transform am Element (maplibregl.Marker
  überschrieb das transform → Pfeile zeigten alle nach Norden)
- Filter-Panel + Badge: doppeltes class-Attribut (class=X class=hidden) → Browser ignorierte
  'hidden' → Filter immer offen + roter Badge immer an. Zu 'X hidden' gemergt.
2026-06-05 13:13:14 +02:00
fbaf7c5409 Routen-GL-Fix: Detail/Suggest-Karte fittet Route korrekt (Modal-0×0-Timing)
- Facade fitBounds: try/catch + skip wenn Container 0×0 (sonst NaN-LngLat im Modal)
- createMap: mehrfaches resize() nach Erstellung (Modal-Animation)
- _buildDetailMap/_suggestMap: Re-Fit nach 200/500ms (Route ganz sichtbar, Pfeile)
- Facade: scrollWheelZoom-Stub (map.scrollZoom)
2026-06-05 13:01:19 +02:00
96119e02ef Seitenkarten GL Runde 2: Events, Gassi, Routen + Facade-Erweiterung
- Facade: Polyline (geojson line-source, addTo/setLatLngs/getBounds/remove), clusterGroup,
  marker.getLatLng, map.distance(Haversine), on('click') normalisiert e.latlng aus e.lngLat, _ll objekttauglich
- events: L.markerClusterGroup→UI.map.clusterGroup
- walks: window.L-Guard, L.featureGroup→UI.map.featureGroup, fitBounds ohne .pad
- routes: L.polyline/L.circleMarker→UI.map.*, navMap/Pfeil-Marker→svgMarker, latLngBounds→coords,
  trimMap distance/click, Mini-Vorschauen auf SVG (kein WebGL-Limit, kein OSM-Raster)
2026-06-05 12:48:09 +02:00
5844f1ef51 Seitenkarten auf MapLibre GL (Facade) — Runde 1: Giftköder + Verlorene
- map-gl-mini.js: Leaflet-kompatible MapLibre-Facade (createMap/svgMarker/circleMarker/
  featureGroup-Wrapper mit setView/fitBounds/invalidateSize/addTo/bindPopup/openPopup/on/remove)
- ui.js: UI.map.create/svgMarker/leafletMarker branchen auf GL (by_map_gl, Staging-Default),
  + UI.map.circleMarker/featureGroup, loadMapLibreUI
- poison.js/lost.js: window.L-Guards entfernt, L.circleMarker→UI.map.circleMarker
2026-06-05 12:33:01 +02:00
9c4b999331 GL-Style: Straßennummern (A9/B304/ST2078) + Straßenarten farblich
- road-refs-Layer: ref aus transportation_name entlang Autobahn/Bundes-/Landstraße (aufrecht)
- roads-Farbe per match(class): Autobahn rötlich, Trunk orange, primary gelb-orange, secondary blassgelb, Rest weiß
2026-06-05 12:05:01 +02:00
eaf7801e6b GL-Style: Schutzgebiete (park) als Umrandung statt Füllung + Wald dunkler
René: Ebersberger Forst in GL heller statt dunkler. Ursache: park-Layer (Naturpark/Schutzgebiet)
lag als flache hellgrüne Füllung ÜBER dem Wald → aufhellend. Jetzt dezente Füllung (0.18) +
grüne gestrichelte Umrandung (wie OSM), Wald-Farbe (landcover wood, dunkler #74b356) bleibt sichtbar.
2026-06-05 11:59:02 +02:00
04b2d8aeb8 GL-Style: Landbedeckung nach Klasse (Wald/Wiese/Moor unterscheidbar)
landcover-Fill per match(class): Wald (wood) dunkler Grün, Wiese (grass) heller,
Moor/Feuchtgebiet (wetland) eigene teal-grüne Farbe (Ufer-/Moorzonen), Farmland/Sand abgesetzt.
Vorher flach einfarbig → Wald nicht von Wiese unterscheidbar.
2026-06-05 11:53:14 +02:00
cc1fdb00b1 GL-Style: kräftigere Schrift (Open Sans Semibold, self-hosted), sattere Farben, Bahntrassen
- Labels + Cluster-Zahlen auf Open Sans Semibold (Glyphs gehostet) — Schrift war zu dünn
- Farben gesättigt: Grün/Park/Wasser kräftiger, Füll-Deckkraft 0.55→0.8 (wirkten blass)
- Bahn-Layer (class rail/transit): Basis-Linie + Schwellen-Effekt (fehlten ganz)
2026-06-05 11:47:52 +02:00
fc9cac410c GL-Style: Pfade von Straßen trennen + Infodichte (Hausnummern, POI-Namen)
- transportation nach class: Pfade/Tracks dünn+gestrichelt; Straßen weiß, Breite nach Klasse
  (motorway/trunk breit … minor schmal) — Pfade sehen nicht mehr wie Straßen aus
- neue Label-Layer: poi (Kinderspielplatz/Schule… ab Z15) + housenumber (ab Z17), name:de
- Label-Reihenfolge = Kollisions-Priorität (Orte zuerst)
2026-06-05 11:35:24 +02:00
3523a44a0b MapLibre: GL als Staging-Default + Feinschliff (Cluster-Zahlen, Theme-Robustheit)
- _useGL: Staging default-AN (Prod aus, ?mapgl=0 überschreibt) → Breitentest
- Cluster zeigen ZAHL (point_count) statt Icon (Glyphs vorhanden)
- Theme-Wechsel: Wetter-Raster + Rec-Track nach setStyle neu anlegen; Click-Handler nur einmal binden (keine doppelten Popups)
2026-06-05 11:26:55 +02:00
425f99effb MapLibre-Port 3b+3c: Wetter + GPS-Aufzeichnung für GL entgaten
3b Wetter: engine-neutrale Helfer (_wxAddRaster→raster-source unter den Markern, _mapOnMove,
   _wxAddTempMarker→maplibregl.Marker). _toggleRadar/_loadRadar/_toggleTemp/_loadTempLabels gebrancht.
3c GPS: _recTrackGL (geojson line-source), _updateRecMarker (maplibregl.Marker), _recCleanupMap.
   _updateRecMap/_startRecording-Resume/Cleanup gebrancht, Gates entfernt. panTo [lng,lat] für GL.
2026-06-05 11:19:40 +02:00
9c959dd632 GL-Karte: Ortsnamen-Labels (Glyphs self-hosted) + ScaleControl raus (lag unter der Status-Pill)
- main.py: /fonts-Mount (Glyph-PBFs aus data/tiles/fonts), Open Sans Regular self-hosted
- map-gl-style.js: glyphs-URL + Label-Layer (Ortsnamen/Straßen/Gewässer, name:de)
- map.js _initMapGL: ScaleControl entfernt (überdeckte die Zoom/Wetter/Zecken-Pill)
Ortsnamen für Orientierung (René), auch bei kleinem Zoom.
2026-06-05 11:09:08 +02:00
4d0cd0f460 Karten-Fix: fraktionalen MapLibre-Zoom für Scan-Schwelle runden
MapLibre-Zoom ist kontinuierlich (13.7); Statusleiste rundet (zeigt Z14), Scan verglich roh
→ bei angezeigtem Z14 (echt 13.x) galt zoom<14 → Marker gecleart, erst ab Z15 sichtbar.
Jetzt Math.round(getZoom()) für die 10/14-Schwellen (wie die Anzeige). Leaflet unverändert (ganzzahlig).
2026-06-05 11:01:09 +02:00
980338d7f1 Karten-Fix: Scan-Race bei schnellen Zoom-Folgen (Z16→Z13→Z14 → keine Marker)
_loadOsmLayers verwarf Scan-Anfragen, die während eines laufenden Scans kamen (return).
Bei langsamem Overpass (Handy) ging so der finale Z14-Scan verloren → Marker leer.
Jetzt: _scanQueued merkt die Anfrage vor, finally holt sie nach → letzte Ansicht wird garantiert gescannt.
2026-06-05 10:56:08 +02:00
d447de2b8d GL-Cluster: weißes Kategorie-Icon mittig auf Cluster-Kreis (clsym-Layer + cli-Icon-Variante)
René: 'geclusterte Marker haben kein Symbol'. Jeder Cluster ist genau eine Kategorie →
weißes Phosphor-Icon in der Cluster-Mitte (icon-size skaliert mit point_count). Keine Glyphs nötig.
2026-06-05 10:47:36 +02:00
2ccf75e076 Karten-Fix: Overlay-Button-Zonen click-through (blockierten Karten-Pannen)
René: 'wo die Karten-Buttons auftauchen ist eine Zone die Kartenbedienung verhindert'.
.map-statusbar/.map-speed-dial/.map-crosshair/.map-search-wrap(inactive)/.map-rec-panel(inactive)
fingen Touch in ihrer Bounding-Box → tote Zonen. Jetzt pointer-events:none, nur Buttons auto.
Erklärt die 'reagiert nach kurzer Zeit'-Verzögerung (Drag startet tot, greift erst im Karten-Bereich).
Gilt für beide Engines (verbessert auch Leaflet).
2026-06-05 10:43:17 +02:00
2d7eca16a7 MapLibre-Port 3a-Fix: Scanner-Endlosschleife (resize im Scan) + Pinch-Page-Zoom + Rotation
- _loadOsmLayers: kein _map.resize() für GL (löste move→moveend→scan-Loop aus, 'pausenlos')
- _initMapGL: touchZoomRotate.disableRotation() + touchPitch.disable() + container touch-action:none
  → Pinch=reines Zoom, bleibt in der Karte (kein iOS-Page-Zoom), keine ungewollte Drehung
2026-06-05 10:26:35 +02:00
ef16ec92ba MapLibre-Port 3a: ?mapgl=1/0 früh in boot.js erfassen + GL-App-Pfad headless end-to-end verifiziert (2936 reale Marker, Popup, null Fehler) 2026-06-05 10:16:15 +02:00
542106e77b MapLibre-Port Runde 3a: MapGLMarkers in map.js verdrahtet (flag-gated)
- _loadOsmLayers/_loadAll/_addPlaces/_addPoison/_addBreeders/_applyVisibility/_deleteUserPoi
  engine-neutral (GL: POI-DATEN statt Marker, MapGLMarkers.setLayer/setVisible)
- Standort-Dot, Place-Picker-Temp-Marker, Such-Marker als maplibregl.Marker
- engine-spez. Aufrufe über Facade (_mapFlyTo/_mapSetView/_mapResize)
- Wetter + GPS-Recording für GL vorerst gegated (Port in 3b/3c)
- Flag 'by_map_gl'/?mapgl=1, default AUS
2026-06-05 10:06:54 +02:00
11922c1d22 MapLibre-Port Runde 2: GL-Marker-Subsystem (map-gl-markers.js) + headless Test-Harness
Eigenständiges Modul: per-Kategorie GeoJSON-Cluster, rasterisierte Phosphor-Icons,
Danger-Polygone, Sichtbarkeit, Click→Popup. /maplibre-markers-test zum headless-Verifizieren
VOR dem Einbau in map.js (auth-gated).
2026-06-05 09:52:45 +02:00
63c9be68c6 MapLibre-Port Runde 1: Engine-Fundament (flag-gated, Default Leaflet)
map.js: _useGL()/loadMapLibre()/_initMapGL() + engine-neutrale Facade
(_mapFlyTo/_mapSetView/_mapGetZoom/_mapResize/_mapGetCenter/_mapPaddedBounds, kapselt
[lat,lon]↔[lng,lat]). init() verzweigt auf GL bei Flag 'by_map_gl'/?mapgl=1. Basemap+
Controls+Dark(setStyle)+Scan-Wiring+Crosshair. POI-Layer/Marker = Runde 2. Flag default AUS.
2026-06-05 09:37:59 +02:00
a27695d9c6 MapLibre-Perf-Test: /maplibre-perf-test (Basemap + 600 Cluster-Marker, GPU) — Handy-Proof vor dem Umbau 2026-06-05 09:24:13 +02:00
5e354f7e8e MapLibre-Migration M1: Geometrie-Style-Modul (MapGLStyle, Light+Dark, kein Glyph) für zentrale Karte 2026-06-05 09:20:41 +02:00
7d761bb342 NOTAUS: Vektor-Basemap hart deaktiviert — protomaps-leaflet hängt App auf dem Handy auf
Main-Thread-Rendering von protomaps-leaflet + App-Map-Logik blockiert UI-Thread.
Greift auch bei localStorage-Flag=1. Performance erst lösen, dann reaktivieren.
2026-06-05 09:12:01 +02:00
1d64dc5d70 Fix: zentrale Karte nutzte window.UI (undefined) statt bare UI → immer Raster-Branch
ui.js exponiert 'const UI' = globales lexikalisches Binding (bare UI), NICHT window.UI.
map.js _addBasemap prüfte window.UI → immer falsy → Vektor-Pfad nie genommen.
Jetzt: typeof UI-Check. (window.UI?.toast in boot/api/app sind separat tote No-Ops.)
2026-06-05 09:06:09 +02:00
5cb7c3091d Diagnose: /ui-vector-test — testet echten ui.js-Vektor-Pfad (UI.map.create) ohne Auth 2026-06-05 09:01:58 +02:00
b0fece16c8 Fix: CSP worker-src 'self' blob: (SW-Registrierung war durch blob:-only blockiert) + Vektor-Basemap auf Staging default-an
- worker-src blob: hatte sw.js (same-origin) blockiert → SW-Registrierung schlug app-weit fehl
  → alter SW servierte stale ui.js → UI.map.vectorLayer undefined → stiller Raster-Fallback
- _vectorMapEnabled: Staging default AN (Reifephase), Prod AUS bis Freigabe, Flag überschreibt
2026-06-05 08:52:36 +02:00
736c326635 Tile-Server: Isolations-Testseite /leaflet-vector-test (protomaps-leaflet + DACH, ohne App-Shell) 2026-06-05 08:39:43 +02:00
647aa684db Vektor-Basemap: zentrale Karte (pages/map.js) integrieren — sie umging UI.map.create
- map.js _addBasemap: Vektor-Layer (Flag) mit Raster-Fallback, eigener Basemap-Code
- Theme-Wechsel baut Vektor-Layer mit passendem Flavor neu (kein CSS-Filter bei Vektor)
- ui.js: UI.map.vectorEnabled()/vectorLayer() exponiert für Karten mit eigenem Layer-Mgmt
- APP_VER bump
2026-06-05 08:28:11 +02:00
b2262a8e86 Vektor-Basemap: ?vectormap=1/0 früh in boot.js erfassen (überlebt Query-Stripping beim Boot) + APP_VER bump 2026-06-05 07:57:21 +02:00
9006c85434 Bump APP_VER 1173→1174 (Vektor-Basemap-Integration) 2026-06-04 22:26:42 +02:00
2b5afcf0ae Tile-Server: Vektor-Basemap in PWA integrieren (protomaps-leaflet, Feature-Flag)
- ui.js Map.create: Basemap-Swap OSM-Raster→PMTiles-Vektorlayer hinter Flag
  'by_vector_map' (?vectormap=1/0). Leaflet+markercluster+Marker unverändert,
  sauberer Raster-Fallback bei Fehler. Attribution Pflicht eingeblendet.
- map-vector.js: protomaps-leaflet paintRules/labelRules für OpenMapTiles-Schema
  (Light+Dark), Labels per Canvas-Text → keine Glyphs nötig. Quelle /tiles/dach.pmtiles.
- protomaps-leaflet 4.0.1 vendored.
- Makefile: 'make tiles' (download→merge -H+time-filter dedup→planetiler) + 'make tiles-deploy'
  (atomarer Swap, ENV=prod für Produktion).
2026-06-04 21:53:07 +02:00
a561759034 Tile-Server-Spike: MapLibre-Testseite /maplibre-test (vendored maplibre-gl+pmtiles)
- /maplibre-test rendert bayern.pmtiles per pmtiles-Protokoll, minimaler
  Geometrie-Style (OpenMapTiles-Layer, keine Glyphs), Touren-Demo als GeoJSON-Line
- maplibre-gl 4.7.1 + pmtiles 3.2.1 lokal vendored (CSP script-src 'self')
- CSP: worker-src blob: (MapLibre-Worker)
2026-06-04 20:42:35 +02:00
e5a2953a80 Tile-Server: HEAD-Support für /tiles (Dateigröße-Probe ohne 405) 2026-06-04 20:35:06 +02:00
bdadde8b98 Tile-Server: eigene Range-fähige /tiles-Route (StaticFiles liefert hinter BaseHTTPMiddleware kein 206)
Spike-Befund: app-weit kommen nur 200 ohne Accept-Ranges zurück, FileResponse-Range
wird von der BaseHTTPMiddleware gebrochen. MapLibre/pmtiles braucht aber Byte-Ranges.
Route gibt 206 als normales Response (Byte-Slice) zurück. Produktion: nginx/NPM direkt.
2026-06-04 20:31:57 +02:00
d9ecdb15fb Tile-Server-Spike: /tiles StaticFiles-Mount + tiles/ vom Tar/Git ausschließen
- main.py: geschützter /tiles-Mount (TILES_DIR, No-Op ohne Verzeichnis), Range-Requests via Starlette FileResponse
- Makefile: tiles/ aus TAR_EXCLUDE (546MB pmtiles nicht ins Staging-Tar)
- .gitignore: *.pmtiles/*.osm.pbf/tiles-build ausschließen
2026-06-04 20:27:33 +02:00
cde019cacf Docs: Übergabe Tile-Server (planetiler→PMTiles→MapLibre, Staging-Spike-Plan) 2026-06-04 19:57:59 +02:00
545b57c723 Fix: Account-Löschung FK-sicher über alle Tabellen (defer_foreign_keys + Introspektion)
Der alte delete_account löschte nur ~6 Tabellen und scheiterte am finalen
DELETE FROM users, sobald der User Zeilen in Non-Cascade-Tabellen hatte
(routes, places, walks, events, forum_threads, invoices …). Jetzt:
PRAGMA defer_foreign_keys + foreign_key_list-Introspektion (CASCADE automatisch,
NO-ACTION-Eigentum löschen, Actor/SET-NULL-Spalten nullen) plus user_id/owner_id-
Scan für Tabellen ohne formale FK. Regressionstest test_account_deletion.py.

Relevant für App-Store-Gl. 4 (In-App-Konto-Löschung muss zuverlässig funktionieren).
2026-06-04 19:21:18 +02:00
1448782564 UX: Welten-Editor — Hinweis dass ✕ ausblendet (nicht löscht)
Statischer Hinweis präzisiert + Toast beim ersten Ausblenden: Funktion bleibt
über 'Weitere Funktionen' (Ausgeblendete Funktionen) abrufbar, wird nicht gelöscht. SW v1173
2026-06-04 18:36:42 +02:00
258ccf84ee Feature: Alter fließt in Kalorienbedarf ein (Lebensphasen-Faktoren)
Welpe <4 Mon ×3.0, Junghund 4-12 Mon ×2.0 (Wachstum, überschreibt Aktivität),
Senior 7+ ×0.90, Hochbetagt 11+ ×0.85 des Erwachsenen-Faktors. Lebensphase wird
im Ergebnis angezeigt. SW v1172
2026-06-04 18:04:16 +02:00
cca9a9c70f Fix: Ernährungs-Rechner übernimmt Gewicht+Alter aus Hundeprofil
Falsche Feldnamen: dog.gewicht/dog.alter existieren nicht. Korrekt: gewicht_kg,
und Alter aus geburtstag berechnen (_alterJahre). Beide werden jetzt im Kalorien-
Rechner vorbefüllt. SW v1171
2026-06-04 17:59:34 +02:00
0e77c04eee Fix: gespeichertes Futter-Profil beim Öffnen sichtbar (war hinter Berechnung versteckt)
ernaehrung.js: Das Profil (Marke, Portionen, Notizen, Futter-Typ) wird beim Laden
direkt angezeigt, wenn gespeicherte Daten existieren — vorher nur nach Klick auf
'Kalorienbedarf berechnen'. Gespeicherter Tagesbedarf (kcal_tag) wird 1:1 wieder
gezeigt (kein Neu-Rechnen). _berechne → _showResult refaktoriert. SW v1170
2026-06-04 17:53:03 +02:00
3513aeadb0 Fix: Referral-Code überlebt App-Schließen (localStorage statt sessionStorage)
Werben-Zuordnung ging verloren, wenn die geworbene Person den ?ref=-Link öffnete,
die App schloss und sich erst später registrierte (sessionStorage flüchtig, v.a.
iOS-PWA). Jetzt: Code früh in boot.js nach localStorage (vor evtl. SW-Reload, der
die URL ersetzt), 30-Tage-Ablauf, Löschung nach Registrierung. SW v1169

Datenkorrektur separat: nacho_sarah Angie (id=4) als 2. Werbung zugeordnet.
2026-06-04 17:38:14 +02:00
7945087a6c Fix: Routen-Bewertungen (Kommentare) waren für niemanden sichtbar
UI.ratingStars lud die Bewertungen via API, speicherte/renderte das ratings-Array
aber nie — nur Durchschnitt+Anzahl. Jetzt wird die Liste aller Bewertungen mit
Kommentar (Name + Sterne + Text) angezeigt; nach dem Speichern neu geladen.
Backend war korrekt. SW v1168
2026-06-04 17:25:40 +02:00
78866206b4 Feature: Routenaufzeichnung übersteht App-Updates (Guard + Persistenz)
Stufe 1 (Guard): Während aktiver Aufzeichnung wird der SW-/Force-Update-Reload
aufgeschoben (window._byRecording → boot.js/_bySwReload + app.js force-update);
nach Stop/Speichern via window._byReloadIfPending() nachgeholt.

Stufe 2 (Persistenz): Track wird gedrosselt nach localStorage (RecStore) gesichert
und beim nächsten Öffnen der Karten-/Routen-Seite als 'Aufzeichnung fortsetzen?'
angeboten (Resume seedet Track+km+Startzeit). Schützt auch bei Crash/OS-Kill/
manuellem Reload. Greift in map.js UND routes.js. SW v1167
2026-06-04 17:13:23 +02:00
ddfb9474ef Bump auf v1166 (Versionen synchron halten) 2026-06-04 16:53:26 +02:00
959fd81a9b Fix: Forum-Cooldown blockierte JEDEN Post (Zeitzonen-Bug)
_check_post_limits verglich datetime.utcnow() (UTC) mit created_at, das aber
als Client-Lokalzeit (CEST=UTC+2) gespeichert wird → diff negativ → immer 429.
Jetzt rechnen Cooldown + Stunden-Limit in derselben Zeitbasis (Client-Zeit des
Requests). Backend-only, kein SW-Bump.
2026-06-04 16:50:45 +02:00
c07b1cc01b Fix: restliche CSP-blockierte Inline-Handler — Bild-Fallbacks (globaler data-fb Error-Handler) + Hover-Effekte (CSS-Utilities + data-hover-play)
App ist jetzt vollständig frei von Inline-Event-Handlern (onerror/onmouseenter/etc.).
data-fb Modi: hide/hide-parent/dim-grandparent/sibling/show-el/emoji/initials + data-fb-src.
Hover: .by-hover-lift/-surface2/-surface3 in utilities.css. SW v1165
2026-06-04 16:22:43 +02:00
2ddd8ac350 Fix: alle funktionalen Inline-Event-Handler → addEventListener/Delegation (von CSP-Härtung 65cfa25 app-weit blockiert)
Chat (senden/öffnen/löschen/Foto), Tagebuch-Buch, KI-Berichte, Wiki-Moderation,
Events-Detail, Walks-Lightbox, Routen-Foto, Navigations-CTAs (data-page),
Presse-Copy + Züchter-Landing (externes JS). 35x UI.modal.close → data-modal-close,
28x totes event.stopPropagation entfernt. Verbleibend: kosmetische onerror/Hover. SW v1164
2026-06-04 13:59:27 +02:00
152fde716c Fix: Freunde Annehmen/Ablehnen/Chat per Event-Delegation statt Inline-onclick (von CSP-Härtung 65cfa25 blockiert), SW v1163 2026-06-04 13:35:53 +02:00
55b354e865 Freunde: Annehmen/Ablehnen-Buttons mit Text-Label (Icon-only war für Nutzer nicht erkennbar) + Fix /apifriends/same-breed Slash-Bug, SW v1162 2026-06-04 12:27:01 +02:00
77 changed files with 5861 additions and 1247 deletions

8
.gitignore vendored
View file

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

View file

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

View file

@ -1 +1 @@
1161
1219

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

59
backend/static/js/vendor/maplibre-gl.js vendored Normal file

File diff suppressed because one or more lines are too long

1738
backend/static/js/vendor/pmtiles.js vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1161"></script>
<script src="/js/landing-init.js?v=1219"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

148
docs/OFFLINE_MAPS_PLAN.md Normal file
View file

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

View file

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

View file

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

60
tiles/progress.sh Executable file
View file

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