Über 20x in Europa
Finde den TopFit Club in deiner Nähe
`;
}
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 `
${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 = `
`;
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();
})();