Entdecke die TopFit Fitnessclubs und trainiere ab 8,00 € pro Woche*. Are you ready for the coolest way?

Über 20x in Europa Finde den TopFit Club in deiner Nähe

Suche Clubs...
`; } function getReviewCardHtml(s) { // FIX 1: pageLanguage statt activeCountry const t = i18n[pageLanguage]; const gmbUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(s.name + ' ' + s.city)}`; const score = s.rating ? s.rating.toFixed(1).replace('.', ',') : '–'; const count = s.review_count ? t.reviews(s.review_count) : ''; const stars = buildStars(s.rating); const googleLogoLarge = ``; const testimonialBlock = (s.review_text) ? `
${(s.review_author || t.googleUser).charAt(0).toUpperCase()}
${s.review_author || t.googleUser}
${t.reviewSource}
"${s.review_text}"
` : ''; return `
${googleLogoLarge}
${score}/5
${count}
${stars}
${testimonialBlock}
`; } function getDistance(lat1, lon1, lat2, lon2) { const R = 6371; const dLat = (lat2 - lat1) * Math.PI / 180; const dLon = (lon2 - lon1) * Math.PI / 180; const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2); return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); } function centerOnPopup(marker, zoomLevel) { // FIX Mobile-Popup: Mindestens Zoom 13 erzwingen. // Kombiniert mit disableClusteringAtZoom: 13 in markerClusterGroup stellt das sicher, // dass der angeklickte Marker garantiert NICHT mehr im Cluster steckt – // sonst würde das Cluster-Plugin den Marker beim Popup-Öffnen wieder einsammeln // und das Popup nach ~1 Sekunde wieder schließen (Mobile-Bug). const targetZoom = Math.max(zoomLevel || splitMap.getZoom(), 13); splitMap.setView(marker.getLatLng(), targetZoom, { animate: false }); marker.openPopup(); // FIX Popup-verschwindet: Erst NACH openPopup setzen. openPopup schliesst ein // evtl. vorher offenes Popup (loest popupclose aus) - wuerde der Schluessel // davor gesetzt, wuerde dieses Event ihn sofort wieder auf null setzen. activePopupKey = marker._studioKey || null; // FIX Mobile-Popup: Auf Mobil zur Karte scrollen, da das Layout dort // column-reverse ist (Liste UNTER der Karte). Ohne Scroll bleibt der User // unten in der Liste und sieht das geöffnete Popup oben nicht. if (window.innerWidth <= 900) { const mapContainer = document.querySelector('.tf-map-container-split'); if (mapContainer) { mapContainer.scrollIntoView({ behavior: 'smooth', block: 'start' }); } } setTimeout(() => { const popup = marker.getPopup(); if (popup && popup._container) { const h = popup._container.offsetHeight; const point = splitMap.project(marker.getLatLng(), targetZoom); point.y -= (h / 2); splitMap.panTo(splitMap.unproject(point, targetZoom), { animate: true }); } }, 200); } // FIX 4: Hilfsfunktion – erkennt, ob die Sucheingabe eine PLZ ist function isZipQuery(q) { return /^\d{4,5}$/.test(q); } // FIX 3 + FIX 4: Berechnet Direkt-Treffer mit PLZ-Präfix-Matching function getDirectHits(allStudios, q) { if (!q) return []; return allStudios.filter(function(s) { if (s.name.toLowerCase().includes(q)) return true; if (s.city.toLowerCase().includes(q)) return true; // FIX 4: Bei 5-stelligen PLZ die ersten 4 Stellen vergleichen, // damit z.B. "73430" auch "73431" findet (beide Aalen-Studios). if (isZipQuery(q)) { return s.zip.startsWith(q.slice(0, 4)); } return s.zip.includes(q); }); } function geocodeAndSearch(query) { // FIX 1: pageLanguage für UI-Texte, activeCountry für Nominatim-Ländercode const t = i18n[pageLanguage]; const tCountry = i18n[activeCountry]; const hint = document.getElementById('tf-geocode-hint'); // FIX 3: Direkt-Treffer prüfen, um Hint-Anzeige zu steuern const countryStudios = studios.filter(function(s) { return s.country === activeCountry; }); const hasDirectHits = getDirectHits(countryStudios, query.toLowerCase()).length > 0; // FIX 2: Viewbox aus den aktuellen Kartengrenzen als geografischen Bias übergeben. // bounded=0 = weiches Limit (bevorzugt die Region, schließt sie aber nicht hart ein). // Dadurch findet Nominatim bei "Waldhausen" bevorzugt das Waldhausen nahe Aalen // statt eines gleichnamigen Orts in einer anderen Region. let viewboxParam = ''; if (splitMapInitialized) { const b = splitMap.getBounds(); viewboxParam = '&viewbox=' + b.getWest() + ',' + b.getNorth() + ',' + b.getEast() + ',' + b.getSouth() + '&bounded=0'; } const url = 'https://nominatim.openstreetmap.org/search?q=' + encodeURIComponent(query) + '&format=json&limit=1&countrycodes=' + tCountry.countrycodes + '&accept-language=' + tCountry.countrycodes + viewboxParam; fetch(url, { headers: { 'Accept-Language': tCountry.countrycodes } }) .then(r => r.json()) .then(results => { if (results && results.length > 0) { geocodeCoords = { lat: parseFloat(results[0].lat), lng: parseFloat(results[0].lon) }; // FIX 3: Geocode-Hinweis nur anzeigen, wenn keine Direkt-Treffer vorhanden – // bei Direkt-Treffern werden die Geocode-Koordinaten nur still für die // Entfernungsberechnung genutzt. if (!hasDirectHits) { const placeName = results[0].display_name.split(',')[0]; hint.innerHTML = t.geocodeFound(placeName); hint.style.display = 'block'; } } else { geocodeCoords = null; if (!hasDirectHits) { hint.innerHTML = t.geocodeNotFound(query); hint.style.display = 'block'; } } renderAll(); }) .catch(() => { geocodeCoords = null; renderAll(); }); } function init() { // TYPO3-Kompatibilität: Daten kommen aus der JS-Variable STUDIO_DATA. try { studios = STUDIO_DATA; // FIX 1: pageLanguage einmalig aus dem initial aktiven Land-Button ableiten. // Er ändert sich danach nicht mehr, auch nicht beim Länderwechsel. pageLanguage = activeCountry; rebuildCitySelect(); document.getElementById('tf-search-field').addEventListener('input', function() { const val = this.value.trim(); document.getElementById('tf-clear-btn').style.display = val ? 'block' : 'none'; geocodeCoords = null; document.getElementById('tf-geocode-hint').style.display = 'none'; clearTimeout(geocodeTimer); if (val.length >= 3) { // FIX 3 + FIX 4: Geocoding immer starten (nicht nur wenn keine Direkt-Treffer). // So stehen Koordinaten für die Entfernungsberechnung zur Verfügung, // auch wenn die Suche einen Studio-Namen oder eine PLZ direkt trifft. // // FIX "0 Studios"-Flash: renderAll() sofort aufrufen NUR wenn es Direkt-Treffer // gibt. Ohne Treffer würde renderAll() sofort "0 Studios" zeigen, bevor das // Geocoding antwortet. In diesem Fall startet das Geocoding still im Hintergrund // und renderAll() wird erst nach dessen Abschluss aufgerufen – genauso wie im // Originalcode. const countryStudios = studios.filter(function(s) { return s.country === activeCountry; }); if (getDirectHits(countryStudios, val.toLowerCase()).length > 0) { renderAll(); // Direkt-Treffer vorhanden → sofort anzeigen } geocodeTimer = setTimeout(function() { geocodeAndSearch(val); }, 600); } else { renderAll(); // val < 3 (inkl. leeres Feld): sofort rendern wie im Original } }); document.getElementById('tf-clear-btn').addEventListener('click', () => { document.getElementById('tf-search-field').value = ''; document.getElementById('tf-clear-btn').style.display = 'none'; geocodeCoords = null; document.getElementById('tf-geocode-hint').style.display = 'none'; clearTimeout(geocodeTimer); renderAll(); }); document.getElementById('tf-reset-btn').addEventListener('click', resetFilter); document.getElementById('tf-studio-select').addEventListener('change', renderAll); document.getElementById('geo-trigger').addEventListener('click', handleGeoClick); // NEU: Länder-Buttons verdrahten document.querySelectorAll('.tf-country-btn').forEach(function(btn) { btn.addEventListener('click', function() { setCountry(btn.dataset.country); }); }); startWithMapView(); // Sicherheitsnetz: Studios sofort nach Karteninitialisierung anzeigen. // Verhindert, dass die Liste leer bleibt, wenn Geolocation-Callbacks nicht // feuern (z.B. ausstehende Berechtigungsabfrage oder Sandbox-Umgebung). setTimeout(function() { splitMap.invalidateSize(); renderAll(); }, 300); if (navigator.geolocation) { navigator.geolocation.getCurrentPosition(pos => { userCoords = { lat: pos.coords.latitude, lng: pos.coords.longitude }; setTimeout(() => { splitMap.invalidateSize(); const detected = detectCountry(pos.coords.latitude, pos.coords.longitude); if (detected !== activeCountry) { setCountry(detected); } else { renderAll(); } }, 300); }, () => { setTimeout(() => { splitMap.invalidateSize(); renderAll(); }, 300); }); } else { setTimeout(() => { splitMap.invalidateSize(); renderAll(); }, 300); } } catch (e) { console.error("Data Error:", e); renderAll(); } } function handleGeoClick() { // FIX 1: pageLanguage statt activeCountry const t = i18n[pageLanguage]; if (!navigator.geolocation) return alert(t.geoNotSupported); navigator.geolocation.getCurrentPosition(pos => { userCoords = { lat: pos.coords.latitude, lng: pos.coords.longitude }; isGeoFilterActive = true; document.getElementById('geo-trigger').classList.add('active'); renderAll(); }, () => alert(t.geoDenied)); } function resetFilter() { document.getElementById('tf-search-field').value = ''; document.getElementById('tf-studio-select').value = ''; document.getElementById('tf-clear-btn').style.display = 'none'; document.getElementById('tf-geocode-hint').style.display = 'none'; geocodeCoords = null; isGeoFilterActive = false; clearTimeout(geocodeTimer); document.getElementById('geo-trigger').classList.remove('active'); renderAll(); } function renderAll() { // FIX 1: pageLanguage statt activeCountry für alle UI-Texte const t = i18n[pageLanguage]; const q = document.getElementById('tf-search-field').value.toLowerCase().trim(); const f = document.getElementById('tf-studio-select').value; // Nur Clubs des aktiven Landes let filtered = studios.filter(function(s) { return s.country === activeCountry; }); let infoText = ""; // FIX 3 + FIX 4: Direkt-Treffer mit PLZ-Präfix-Matching berechnen const directHits = getDirectHits(filtered, q); const hasDirectHits = directHits.length > 0; if (geocodeCoords && !hasDirectHits) { // FIX 2 + FIX 3: Geocode-Näherungssuche nur wenn keine Direkt-Treffer. // FIX 2: Top 5 nach Entfernung statt alle innerhalb 50 km – verhindert, // dass bei einem falsch aufgelösten Ort weit entfernte Studios erscheinen. filtered.forEach(s => { s.dist = getDistance(geocodeCoords.lat, geocodeCoords.lng, s.lat, s.lng); }); filtered.sort((a, b) => a.dist - b.dist); filtered = filtered.slice(0, 5); infoText = t.nearbyGeocode(filtered.length); } else if (isGeoFilterActive && userCoords) { filtered.forEach(s => { s.dist = getDistance(userCoords.lat, userCoords.lng, s.lat, s.lng); }); filtered.sort((a, b) => a.dist - b.dist); let radiusFiltered = filtered.filter(s => s.dist <= 25); if (radiusFiltered.length > 0) { infoText = t.nearby25(radiusFiltered.length); filtered = radiusFiltered; } else { radiusFiltered = filtered.filter(s => s.dist <= 50); if (radiusFiltered.length > 0) { infoText = t.nearby50(radiusFiltered.length); filtered = radiusFiltered; } else { infoText = t.nearest3; filtered = filtered.slice(0, 3); } } } else { // Text-Filter-Modus // FIX 4: directHits enthält bereits PLZ-Präfix-Matches if (q) { filtered = directHits; } filtered = filtered.filter(s => !f || s.city === f); // FIX 3: Entfernung vom gesuchten Ort (geocodeCoords) berechnen, falls vorhanden – // andernfalls vom GPS-Standort. So zeigt z.B. die Suche nach "Aalen" die Entfernung // ab Aalen, nicht ab dem aktuellen GPS-Standort des Nutzers. const distCoords = geocodeCoords || userCoords; if (distCoords) { filtered.forEach(s => { s.dist = getDistance(distCoords.lat, distCoords.lng, s.lat, s.lng); }); } if (!q && !f) filtered.sort((a, b) => a.name.localeCompare(b.name)); infoText = t.studiosFound(filtered.length); } document.getElementById('tf-results-info').innerText = infoText; if (splitMapInitialized) updateMapContent(filtered); } function updateMapContent(filteredData) { // FIX 1: pageLanguage statt activeCountry const t = i18n[pageLanguage]; const listContainer = document.getElementById('studio-list'); listContainer.innerHTML = ''; // FIX Popup-verschwindet: Vor dem Abriss merken, welches Popup offen war. const restoreKey = activePopupKey; isRebuildingMap = true; if (markerClusterGroup) { splitMap.removeLayer(markerClusterGroup); } markerClusterGroup = L.markerClusterGroup({ showCoverageOnHover: false, maxClusterRadius: 50, // FIX Popup-verschwindet: Cluster-Animation deaktivieren. Beim Zoom-Sprung // auf Zoom 13 wuerde die Aufsplitt-Animation Marker kurz aus- und wieder // einhaengen und koennte dabei das soeben geoeffnete Popup schliessen. animate: false, // FIX Popup-verschwindet (Mobile, EIGENTLICHE URSACHE): Marker NIE wegen // Verlassen des Sichtbereichs entfernen. Das markercluster-Plugin nutzt auf // Mobilgeraeten exakt den Viewport ohne Puffer (_getExpandedVisibleBounds). // Da das Popup hoeher als die 450px-Karte ist, schiebt centerOnPopup() den // Marker beim Zentrieren aus dem Viewport - das Plugin raeumte ihn dann beim // naechsten moveend ab und das Popup verschwand nach ~1 Sekunde. removeOutsideVisibleBounds: false, // FIX Mobile-Popup: Ab Zoom 13 wird KEIN Clustering mehr angewendet. // Ohne diese Option würden nah beieinanderliegende Marker (z.B. die zwei // Aalen-Studios) bei Zoom 13 noch geclustert. Beim Klick auf eine Liste-Kachel // hat das Cluster-Plugin den Marker dann nach dem Öffnen des Popups wieder // eingesammelt → Popup verschwand nach ~1 Sekunde (Mobile-Bug). disableClusteringAtZoom: 13, iconCreateFunction: function(cluster) { return L.divIcon({ html: `
${cluster.getChildCount()}
`, className: '', iconSize: [36, 36] }); } }); splitMarkers = []; const activeCoords = geocodeCoords || userCoords; // FIX Popup-Groesse: Auf Mobilgeraeten (Karte nur ~450px hoch) das Popup in der // Hoehe begrenzen. Leaflet macht den Inhalt dann scrollbar, statt ihn ueber die // Karte hinaus zu zeichnen / abzuschneiden. const isMobileView = window.innerWidth <= 900; const popupMaxHeight = isMobileView ? Math.max(200, splitMap.getSize().y - 90) : null; filteredData.forEach(s => { if (!s.lat || !s.lng) return; const zeiten = s.hours || "24/7"; const naviUrl = `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(s.street + ', ' + s.zip + ' ' + s.city)}`; const ratingHtml = getRatingHtml(s); const reviewCardHtml = getReviewCardHtml(s); const popupHtml = `

${s.name}

${reviewCardHtml}

${icons.pin} ${s.street}
${s.zip} ${s.city}

${icons.time} ${t.hours}
${zeiten}

`; const m = L.marker([s.lat, s.lng], { icon: studioIcon }); m.bindPopup(popupHtml, { className: 'tf-custom-popup', minWidth: 280, autoPan: false, maxHeight: popupMaxHeight }); // FIX Popup-verschwindet: Eindeutige Studio-Kennung am Marker ablegen, // damit das Popup nach einem Rebuild dem richtigen Marker zugeordnet wird. m._studioKey = s.link; m.on('click', (e) => centerOnPopup(e.target)); markerClusterGroup.addLayer(m); splitMarkers.push(m); const card = document.createElement('div'); card.className = 'tf-card'; card.innerHTML = `

${s.name}

${ratingHtml}

${icons.pin} ${s.street}
${s.zip} ${s.city}
${icons.tel} ${s.phone || '-'} ${icons.time} ${zeiten} ${(s.dist && activeCoords) ? `${icons.dist} ${s.dist.toFixed(1)} km ${t.distAway}` : ''}

`; card.onclick = () => centerOnPopup(m, 13); listContainer.appendChild(card); }); splitMap.addLayer(markerClusterGroup); // FIX Popup-verschwindet: War vor dem Rebuild ein Popup offen, dieses auf dem // neuen Marker wiederherstellen, statt die Karte per fitBounds zurueckzusetzen. // Ohne das wuerde z. B. eine spaet eintreffende Geocoding-Antwort das gerade // vom Nutzer geoeffnete Popup nach ~1 Sekunde wieder schliessen. let restoredMarker = null; if (restoreKey) { restoredMarker = splitMarkers.find(function(mk) { return mk._studioKey === restoreKey; }); } if (restoredMarker) { restoredMarker.openPopup(); } else { activePopupKey = null; if (filteredData.length > 0) { const group = new L.featureGroup(splitMarkers); splitMap.fitBounds(group.getBounds().pad(0.1)); } } isRebuildingMap = false; } function startWithMapView() { // FIX 1: Kartenposition richtet sich nach activeCountry (geografisch korrekt) const t = i18n[activeCountry]; document.getElementById('tf-map-split-view').style.display = 'block'; splitMap = L.map('map').setView(t.mapCenter, t.mapZoom); L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '© CARTO' }).addTo(splitMap); // FIX Popup-verschwindet: Schliesst der Nutzer das Popup selbst, wird kein // Studio mehr fuer die Wiederherstellung gemerkt. Waehrend eines Rebuilds // (isRebuildingMap) wird popupclose ignoriert, da das Popup dort nur kurz // technisch entfernt und sofort wieder geoeffnet wird. splitMap.on('popupclose', function() { if (!isRebuildingMap) activePopupKey = null; }); splitMapInitialized = true; } init(); })();
 the  coolest  way
 the  coolest  way
 the  coolest  way
 the  coolest  way
 the  coolest  way

2x TopFit auf Madeira