/** * Track Editor for StreetRacer Web Dashboard * Allows VIP users to create and edit challenge tracks via Mapbox */ class TrackEditor { constructor() { this.map = null; this.isInitialized = false; this.currentTool = 'select'; this.trackPoints = []; // Raw waypoints clicked by user this.routeCoordinates = []; // Full route from Directions API (road-snapped) this.checkpoints = []; this.startPoint = null; this.finishPoint = null; this.markers = []; this.waypointMarkers = []; // Markers for waypoints (draggable) this.trackLine = null; this.currentTrackId = null; this.isLoadingRoute = false; this.showExistingChallenges = false; // Toggle for showing existing challenges // Mapbox token (same as mobile app) this.mapboxToken = 'pk.eyJ1IjoiZnJhbmNvaXNyZWluZXJ0IiwiYSI6ImNtazBja2liYjBvbnozZnM4bWE2OGF0b3UifQ.3k-VptMsIpvD0ys4igFWeg'; console.log('TrackEditor constructed'); } // Reset the editor (call if initialization failed) reset() { if (this.map) { this.map.remove(); this.map = null; } this.isInitialized = false; this.trackPoints = []; this.routeCoordinates = []; this.checkpoints = []; this.markers = []; this.waypointMarkers = []; this.startPoint = null; this.finishPoint = null; } initialize() { console.log('Track Editor initialize called, isInitialized:', this.isInitialized); // If already initialized, just resize the map if (this.isInitialized && this.map) { console.log('Map already initialized, resizing...'); this.map.resize(); setTimeout(() => this.map.resize(), 100); setTimeout(() => this.map.resize(), 300); return; } console.log('Initializing Track Editor...'); // Wait for container to be visible and have dimensions let attempts = 0; const maxAttempts = 30; const checkAndInit = () => { attempts++; const container = document.getElementById('editor-map'); const tabContent = document.getElementById('tab-track-editor'); const isTabVisible = tabContent && !tabContent.classList.contains('hidden'); // Force compute dimensions const width = container?.getBoundingClientRect().width || 0; const height = container?.getBoundingClientRect().height || 0; console.log(`Attempt ${attempts}: Container ${width}x${height}, Tab visible: ${isTabVisible}`); if (container && width > 100 && height > 100 && isTabVisible) { console.log('Container ready, initializing map...'); this.initializeMap(); this.setupToolbar(); this.loadMyTracks(); // Don't auto-load challenge markers - user must toggle them on } else if (attempts < maxAttempts) { console.log('Waiting for map container to be ready...'); setTimeout(checkAndInit, 150); } else { console.error('Map container not ready after', maxAttempts, 'attempts'); console.log('Final container state:', { width, height, isTabVisible }); if (container) { // Try one more time with forced dimensions container.style.width = '100%'; container.style.height = '500px'; container.style.minHeight = '500px'; console.log('Forcing dimensions and retrying...'); setTimeout(() => { this.initializeMap(); this.setupToolbar(); this.loadMyTracks(); // Don't auto-load challenge markers - user must toggle them on }, 100); } } }; // Give the DOM a moment to update after tab switch setTimeout(checkAndInit, 50); } initializeMap() { const container = document.getElementById('editor-map'); if (!container) { console.error('Map container not found'); return; } if (typeof mapboxgl === 'undefined') { console.error('Mapbox GL JS not loaded'); container.innerHTML = '
Mapbox konnte nicht geladen werden. Bitte Seite neu laden.
'; return; } try { console.log('Creating Mapbox map...'); console.log('Container dimensions:', container.offsetWidth, 'x', container.offsetHeight); // Ensure container has explicit dimensions if (container.offsetWidth === 0 || container.offsetHeight === 0) { console.warn('Container has no dimensions, setting explicit size'); container.style.width = '100%'; container.style.height = '500px'; } mapboxgl.accessToken = this.mapboxToken; this.map = new mapboxgl.Map({ container: 'editor-map', style: 'mapbox://styles/mapbox/dark-v11', center: [10.0, 51.0], // Germany center zoom: 6, attributionControl: true, failIfMajorPerformanceCaveat: false }); this.map.on('load', () => { console.log('Track Editor map loaded successfully'); this.isInitialized = true; // Add track line source this.map.addSource('track-line', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } } }); // Add track line layer this.map.addLayer({ id: 'track-line-layer', type: 'line', source: 'track-line', paint: { 'line-color': '#00d9ff', 'line-width': 4, 'line-opacity': 0.8 } }); // Get user location this.centerOnUserLocation(); // Initialize toolbar state (disable start/checkpoint/finish initially) this.updateToolbarState(); // Force multiple resize calls to ensure proper rendering this.map.resize(); setTimeout(() => this.map.resize(), 100); setTimeout(() => this.map.resize(), 500); setTimeout(() => this.map.resize(), 1000); }); // Also resize when style is loaded this.map.on('style.load', () => { console.log('Map style loaded'); this.map.resize(); }); this.map.on('error', (e) => { console.error('Mapbox error:', e); if (e.error && e.error.status === 401) { container.innerHTML = '
Mapbox Token ungültig. Bitte Administrator kontaktieren.
'; } }); // Debug: Log when map is idle (fully rendered) this.map.on('idle', () => { console.log('Map is idle (fully rendered)'); }); // Map click handler this.map.on('click', (e) => this.handleMapClick(e)); // Resize on window resize window.addEventListener('resize', () => { if (this.map) this.map.resize(); }); } catch (error) { console.error('Error initializing map:', error); container.innerHTML = '
Fehler beim Laden der Karte: ' + error.message + '
'; } } setupToolbar() { // Tool buttons document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => { btn.addEventListener('click', (e) => { // Don't activate if disabled if (btn.classList.contains('disabled')) { const tool = e.currentTarget.dataset.tool; const canActivate = this.canActivateTool(tool); if (!canActivate.allowed) { this.showToolMessage(canActivate.message); } return; } const tool = e.currentTarget.dataset.tool; this.setTool(tool); }); }); // Action buttons document.getElementById('btn-undo')?.addEventListener('click', () => this.undo()); document.getElementById('btn-clear')?.addEventListener('click', () => this.clearTrack()); document.getElementById('btn-save-track')?.addEventListener('click', () => this.saveTrack()); document.getElementById('btn-new-track')?.addEventListener('click', () => this.newTrack()); const toggleBtn = document.getElementById('btn-toggle-challenges'); console.log('🔧 Toggle button found:', !!toggleBtn); if (toggleBtn) { toggleBtn.addEventListener('click', () => { console.log('🔧 Toggle button clicked!'); this.toggleExistingChallenges(); }); } // Initial toolbar state this.updateToolbarState(); } // Delete a challenge async deleteTrack(trackId, trackName) { if (!confirm(`Challenge "${trackName}" wirklich löschen?\n\nDiese Aktion kann nicht rückgängig gemacht werden.`)) { return; } try { const challengeRef = database.ref(`challenges/${trackId}`); await challengeRef.remove(); // If this was the currently loaded track, clear the editor if (this.currentTrackId === trackId) { this.newTrack(); } // Refresh lists this.loadMyTracks(); this.loadExistingChallengesOnMap(); console.log(`🗑️ Challenge "${trackName}" deleted`); } catch (error) { console.error('Error deleting challenge:', error); alert('Fehler beim Löschen: ' + error.message); } } // Start a new challenge (clear everything) newTrack() { // Simply clear and start fresh - no confirmation needed // Old track is either saved already or user consciously clicks + for new this.clearTrack(true); this.currentTrackId = null; // Clear form document.getElementById('track-name').value = ''; document.getElementById('track-description').value = ''; document.getElementById('track-type').value = 'checkpoint'; // Deselect any active track in the list document.querySelectorAll('.track-item').forEach(item => { item.classList.remove('active'); }); console.log('🆕 New track started'); } setTool(tool) { // Check if tool can be activated const canActivate = this.canActivateTool(tool); if (!canActivate.allowed) { this.showToolMessage(canActivate.message); return; } this.currentTool = tool; // Update UI document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => { btn.classList.toggle('active', btn.dataset.tool === tool); }); // Change cursor const cursors = { select: 'grab', draw: 'crosshair', checkpoint: 'crosshair', start: 'crosshair', finish: 'crosshair' }; if (this.map) { this.map.getCanvas().style.cursor = cursors[tool] || 'default'; } } canActivateTool(tool) { const pointCount = this.trackPoints.length; switch (tool) { case 'start': case 'checkpoint': if (pointCount < 1) { return { allowed: false, message: 'Zuerst einen Streckenpunkt anlegen' }; } return { allowed: true }; case 'finish': if (pointCount < 2) { return { allowed: false, message: 'Mindestens 2 Streckenpunkte erforderlich' }; } return { allowed: true }; default: return { allowed: true }; } } showToolMessage(message) { // Show temporary message near toolbar const existingMsg = document.querySelector('.tool-message'); if (existingMsg) existingMsg.remove(); const msgEl = document.createElement('div'); msgEl.className = 'tool-message'; msgEl.textContent = message; msgEl.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(255, 100, 100, 0.95); color: white; padding: 12px 24px; border-radius: 8px; font-weight: bold; z-index: 10000; box-shadow: 0 4px 12px rgba(0,0,0,0.3); animation: fadeInOut 2s ease-in-out forwards; `; document.body.appendChild(msgEl); // Add animation style if not exists if (!document.getElementById('tool-message-style')) { const style = document.createElement('style'); style.id = 'tool-message-style'; style.textContent = ` @keyframes fadeInOut { 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); } 15% { opacity: 1; transform: translate(-50%, -50%) scale(1); } 85% { opacity: 1; transform: translate(-50%, -50%) scale(1); } 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.8); } } `; document.head.appendChild(style); } setTimeout(() => msgEl.remove(), 2000); } updateToolbarState() { // Update toolbar buttons based on current state document.querySelectorAll('.tool-btn[data-tool]').forEach(btn => { const tool = btn.dataset.tool; const canActivate = this.canActivateTool(tool); btn.classList.toggle('disabled', !canActivate.allowed); btn.title = canActivate.allowed ? '' : canActivate.message; }); } handleMapClick(e) { const { lng, lat } = e.lngLat; switch (this.currentTool) { case 'draw': this.addTrackPoint(lng, lat); break; case 'checkpoint': this.addCheckpoint(lng, lat); break; case 'start': this.setStartPoint(lng, lat); break; case 'finish': this.setFinishPoint(lng, lat); break; } } async addTrackPoint(lng, lat) { // Add waypoint this.trackPoints.push([lng, lat]); // Add waypoint marker (numbered, draggable) const pointNum = this.trackPoints.length; const el = document.createElement('div'); el.className = 'waypoint-marker'; el.innerHTML = `${pointNum}`; el.style.cssText = ` background: #00d9ff; color: #000; width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 12px; border: 2px solid white; box-shadow: 0 2px 6px rgba(0,0,0,0.3); cursor: move; `; const marker = new mapboxgl.Marker({ element: el, draggable: true }) .setLngLat([lng, lat]) .addTo(this.map); // Handle drag end to recalculate route marker.on('dragend', () => { const lngLat = marker.getLngLat(); const index = this.waypointMarkers.findIndex(m => m.marker === marker); if (index !== -1) { this.trackPoints[index] = [lngLat.lng, lngLat.lat]; this.calculateRoute(); } }); this.waypointMarkers.push({ marker, coords: [lng, lat] }); this.markers.push({ type: 'track', marker, coords: [lng, lat] }); // Calculate route via roads await this.calculateRoute(); // Update toolbar state (enable Start/Checkpoint/Finish as appropriate) this.updateToolbarState(); this.updateTrackInfo(); } async calculateRoute() { if (this.trackPoints.length < 2) { // Single point - just show it, no route yet this.routeCoordinates = this.trackPoints.length === 1 ? [this.trackPoints[0]] : []; this.updateTrackLine(); return; } this.isLoadingRoute = true; this.showRouteLoading(true); try { // Build Mapbox Directions API URL // Max 25 waypoints per request const waypoints = this.trackPoints.map(p => p.join(',')).join(';'); const url = `https://api.mapbox.com/directions/v5/mapbox/driving/${waypoints}?` + `geometries=geojson&overview=full&access_token=${this.mapboxToken}`; const response = await fetch(url); const data = await response.json(); if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) { console.warn('Directions API error:', data); // Fallback to straight lines this.routeCoordinates = [...this.trackPoints]; this.showToolMessage('Route konnte nicht berechnet werden - Luftlinie wird verwendet'); } else { // Use the route geometry this.routeCoordinates = data.routes[0].geometry.coordinates; console.log(`Route calculated: ${this.routeCoordinates.length} points`); } } catch (error) { console.error('Error calculating route:', error); // Fallback to straight lines this.routeCoordinates = [...this.trackPoints]; } this.isLoadingRoute = false; this.showRouteLoading(false); this.updateTrackLine(); this.updateTrackInfo(); } showRouteLoading(show) { let indicator = document.getElementById('route-loading'); if (show) { if (!indicator) { indicator = document.createElement('div'); indicator.id = 'route-loading'; indicator.innerHTML = '🛣️ Route wird berechnet...'; indicator.style.cssText = ` position: absolute; top: 10px; left: 50%; transform: translateX(-50%); background: rgba(0, 217, 255, 0.9); color: #000; padding: 8px 16px; border-radius: 20px; font-weight: bold; font-size: 12px; z-index: 1000; box-shadow: 0 2px 8px rgba(0,0,0,0.3); `; document.getElementById('editor-map')?.appendChild(indicator); } } else if (indicator) { indicator.remove(); } } // Add marker only without calculating route (for legacy challenges) addWaypointMarkerOnly(lng, lat, num) { const el = document.createElement('div'); el.className = 'waypoint-marker'; el.innerHTML = `${num}`; el.style.cssText = ` background: #00d9ff; color: #000; width: 24px; height: 24px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; font-size: 12px; border: 2px solid white; box-shadow: 0 2px 6px rgba(0,0,0,0.3); `; const marker = new mapboxgl.Marker({ element: el }) .setLngLat([lng, lat]) .addTo(this.map); this.waypointMarkers.push({ marker, coords: [lng, lat] }); this.markers.push({ type: 'track', marker, coords: [lng, lat] }); } addCheckpoint(lng, lat, skipSnap = false) { // Snap to nearest point on route (unless loading from saved data) const coords = skipSnap ? { lng, lat } : this.snapToRoute(lng, lat); const checkpointNum = this.checkpoints.length + 1; const el = document.createElement('div'); el.className = 'checkpoint-marker'; el.innerHTML = `${checkpointNum}`; el.style.cssText = ` background: #00ff88; color: #000; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: bold; border: 2px solid white; box-shadow: 0 2px 6px rgba(0,0,0,0.3); `; const marker = new mapboxgl.Marker({ element: el }) .setLngLat([coords.lng, coords.lat]) .addTo(this.map); this.checkpoints.push({ lng: coords.lng, lat: coords.lat, marker }); this.markers.push({ type: 'checkpoint', marker, coords: [coords.lng, coords.lat] }); this.updateTrackInfo(); // Switch back to select tool after placing (only when user places manually) if (!skipSnap) { this.setTool('select'); } } setStartPoint(lng, lat, skipSnap = false) { // Snap to nearest point on route (unless loading from saved data) const coords = skipSnap ? { lng, lat } : this.snapToRoute(lng, lat); // Remove existing start marker if (this.startPoint?.marker) { this.startPoint.marker.remove(); } const el = document.createElement('div'); el.innerHTML = '🏁'; el.style.fontSize = '32px'; el.style.filter = 'drop-shadow(0 2px 4px rgba(0,0,0,0.5))'; const marker = new mapboxgl.Marker({ element: el }) .setLngLat([coords.lng, coords.lat]) .addTo(this.map); this.startPoint = { lng: coords.lng, lat: coords.lat, marker }; // Switch back to select tool after placing (only when user places manually) if (!skipSnap) { this.setTool('select'); } } setFinishPoint(lng, lat, skipSnap = false) { // Snap to nearest point on route (unless loading from saved data) const coords = skipSnap ? { lng, lat } : this.snapToRoute(lng, lat); // Remove existing finish marker if (this.finishPoint?.marker) { this.finishPoint.marker.remove(); } const el = document.createElement('div'); el.innerHTML = '🎯'; el.style.fontSize = '32px'; el.style.filter = 'drop-shadow(0 2px 4px rgba(0,0,0,0.5))'; const marker = new mapboxgl.Marker({ element: el }) .setLngLat([coords.lng, coords.lat]) .addTo(this.map); this.finishPoint = { lng: coords.lng, lat: coords.lat, marker }; // Switch back to select tool after placing (only when user places manually) if (!skipSnap) { this.setTool('select'); } } // Snap a point to the nearest position on the route snapToRoute(lng, lat) { const route = this.routeCoordinates.length > 0 ? this.routeCoordinates : this.trackPoints; if (route.length === 0) { return { lng, lat }; } let nearestPoint = { lng: route[0][0], lat: route[0][1] }; let minDistance = Infinity; // Find nearest point on route for (const [routeLng, routeLat] of route) { const dist = this.calculateDistance(lat, lng, routeLat, routeLng); if (dist < minDistance) { minDistance = dist; nearestPoint = { lng: routeLng, lat: routeLat }; } } // Also check for nearest point on line segments (more accurate) for (let i = 1; i < route.length; i++) { const projected = this.projectPointOnSegment( lng, lat, route[i - 1][0], route[i - 1][1], route[i][0], route[i][1] ); const dist = this.calculateDistance(lat, lng, projected.lat, projected.lng); if (dist < minDistance) { minDistance = dist; nearestPoint = projected; } } return nearestPoint; } // Project a point onto a line segment projectPointOnSegment(px, py, x1, y1, x2, y2) { const dx = x2 - x1; const dy = y2 - y1; const lengthSq = dx * dx + dy * dy; if (lengthSq === 0) { return { lng: x1, lat: y1 }; } // Calculate projection parameter let t = ((px - x1) * dx + (py - y1) * dy) / lengthSq; t = Math.max(0, Math.min(1, t)); // Clamp to segment return { lng: x1 + t * dx, lat: y1 + t * dy }; } updateTrackLine() { if (!this.map || !this.map.getSource('track-line')) return; // Use road-snapped route coordinates const coordinates = this.routeCoordinates.length > 0 ? this.routeCoordinates : this.trackPoints; this.map.getSource('track-line').setData({ type: 'Feature', geometry: { type: 'LineString', coordinates: coordinates } }); } updateTrackInfo() { // Calculate track length from actual route (not straight line) const coordinates = this.routeCoordinates.length > 0 ? this.routeCoordinates : this.trackPoints; let totalLength = 0; for (let i = 1; i < coordinates.length; i++) { totalLength += this.calculateDistance( coordinates[i - 1][1], coordinates[i - 1][0], coordinates[i][1], coordinates[i][0] ); } document.getElementById('track-waypoints').textContent = this.trackPoints.length; document.getElementById('track-length').textContent = `${(totalLength / 1000).toFixed(2)} km`; document.getElementById('track-checkpoints').textContent = this.checkpoints.length; // Update toolbar state this.updateToolbarState(); } calculateDistance(lat1, lon1, lat2, lon2) { const R = 6371000; // Earth radius in meters 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); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } async undo() { if (this.markers.length === 0) return; const last = this.markers.pop(); last.marker.remove(); if (last.type === 'track') { this.trackPoints.pop(); this.waypointMarkers.pop(); // Recalculate route await this.calculateRoute(); } else if (last.type === 'checkpoint') { this.checkpoints.pop(); // Renumber remaining checkpoints this.renumberCheckpoints(); } this.updateTrackInfo(); } renumberCheckpoints() { // Update checkpoint marker numbers this.checkpoints.forEach((cp, index) => { const el = cp.marker.getElement(); if (el) { const span = el.querySelector('span'); if (span) span.textContent = index + 1; } }); } clearTrack(skipConfirm = false) { if (!skipConfirm && !confirm('Strecke wirklich löschen?')) return; // Remove all markers this.markers.forEach(m => m.marker.remove()); this.markers = []; this.waypointMarkers = []; if (this.startPoint?.marker) this.startPoint.marker.remove(); if (this.finishPoint?.marker) this.finishPoint.marker.remove(); this.trackPoints = []; this.routeCoordinates = []; this.checkpoints = []; this.startPoint = null; this.finishPoint = null; this.currentTrackId = null; this.updateTrackLine(); this.updateTrackInfo(); this.updateToolbarState(); // Clear form document.getElementById('track-name').value = ''; document.getElementById('track-description').value = ''; } async saveTrack() { const user = auth.currentUser; if (!user) { alert('Nicht angemeldet'); return; } const name = document.getElementById('track-name').value.trim(); if (!name) { alert('Bitte Streckenname eingeben'); return; } if (this.trackPoints.length < 2) { alert('Strecke muss mindestens 2 Punkte haben'); return; } const challengeType = document.getElementById('track-type').value; const lengthKm = parseFloat(document.getElementById('track-length').textContent) || 0; if (lengthKm < 0.5) { alert('Strecke muss mindestens 0.5 km lang sein'); return; } // Determine start and finish points const startPoint = this.startPoint ? { lat: this.startPoint.lat, lng: this.startPoint.lng } : { lat: this.trackPoints[0][1], lng: this.trackPoints[0][0] }; const finishPoint = this.finishPoint ? { lat: this.finishPoint.lat, lng: this.finishPoint.lng } : { lat: this.trackPoints[this.trackPoints.length - 1][1], lng: this.trackPoints[this.trackPoints.length - 1][0] }; // Calculate reference time based on 10 km/h average speed // Time in ms = (distance in km / speed in km/h) * 3600 * 1000 const referenceSpeedKmh = 10; const referenceTimeMs = Math.round((lengthKm / referenceSpeedKmh) * 3600 * 1000); // Credit costs for creating challenges const CREDIT_COSTS = { checkpoint: 500, lap: 500, sprint: 200 // Ghost-like }; const creditCost = CREDIT_COSTS[challengeType] || 500; // Get user nickname let nickname = 'Web-Editor'; try { const userRef = database.ref(`cars/${user.uid}/nickname`); const nicknameSnap = await userRef.once('value'); nickname = nicknameSnap.val() || 'Web-Editor'; } catch (e) { console.warn('Could not load nickname:', e); } // Check and deduct credits try { const creditsRef = database.ref(`credits/${user.uid}`); const creditsSnap = await creditsRef.once('value'); const creditsData = creditsSnap.val() || { balance: 0 }; if ((creditsData.balance || 0) < creditCost) { alert(`Nicht genügend Punkte!\n\nBenötigt: ${creditCost} Punkte\nVorhanden: ${creditsData.balance || 0} Punkte`); return; } // Confirm creation const confirmMsg = `Challenge erstellen?\n\n` + `Name: ${name}\n` + `Typ: ${challengeType}\n` + `Länge: ${lengthKm.toFixed(2)} km\n` + `Checkpoints: ${this.checkpoints.length}\n` + `Referenzzeit (10 km/h): ${this.formatTime(referenceTimeMs)}\n\n` + `Kosten: ${creditCost} Punkte`; if (!confirm(confirmMsg)) { return; } // Deduct credits const now = Date.now(); await creditsRef.update({ balance: (creditsData.balance || 0) - creditCost, totalSpent: (creditsData.totalSpent || 0) + creditCost, lastUpdated: now, [`transactions/${now}`]: { type: 'challenge_create', amount: -creditCost, description: `${challengeType} Challenge erstellt (Web)`, timestamp: now } }); } catch (error) { console.error('Error checking credits:', error); alert('Fehler beim Prüfen der Punkte: ' + error.message); return; } // Calculate checkpoint reference times (distributed evenly based on distance) const checkpointsWithTimes = this.checkpoints.map((cp, index) => { // Estimate distance to this checkpoint const distanceRatio = (index + 1) / (this.checkpoints.length + 1); const checkpointTimeMs = Math.round(referenceTimeMs * distanceRatio); return { index, lat: cp.lat, lng: cp.lng, referenceTime: checkpointTimeMs, distanceFromStart: lengthKm * distanceRatio }; }); // Build challenge data (format that app expects) const challengeData = { // Basic info type: challengeType === 'sprint' ? 'checkpoint' : challengeType, name: name, description: document.getElementById('track-description').value.trim(), createdBy: user.uid, creatorId: user.uid, creatorNickname: nickname, createdAt: Date.now(), createdVia: 'web-editor', status: 'active', // Track geometry startPoint: startPoint, finishPoint: finishPoint, totalDistance: lengthKm, // Reference (based on 10 km/h) referenceTime: referenceTimeMs, referenceHolder: user.uid, referenceNickname: nickname + ' (10 km/h)', // Checkpoints checkpoints: checkpointsWithTimes, // Stats completionCount: 0, freeAttemptsUsed: 0, creatorBonusPaid: false, // Store the road-snapped trail for display (not raw waypoints) trail: (this.routeCoordinates.length > 0 ? this.routeCoordinates : this.trackPoints) .map(([lng, lat]) => ({ lat, lng })), // Also store original waypoints for editing waypoints: this.trackPoints.map(([lng, lat]) => ({ lat, lng })), // Leaderboard (for lap challenges) leaderboard: [{ userId: user.uid, nickname: nickname + ' (10 km/h)', time: referenceTimeMs, date: Date.now(), isPlaceholder: true }] }; try { let challengeRef; if (this.currentTrackId) { // Update existing challenge challengeRef = database.ref(`challenges/${this.currentTrackId}`); challengeData.updatedAt = Date.now(); await challengeRef.update(challengeData); } else { // Create new challenge challengeRef = database.ref('challenges').push(); challengeData.id = challengeRef.key; await challengeRef.set(challengeData); this.currentTrackId = challengeRef.key; } // Update user stats const userStatsRef = database.ref(`users/${user.uid}/challengeStats`); const statsSnap = await userStatsRef.once('value'); const stats = statsSnap.val() || { challengesCreated: 0 }; await userStatsRef.update({ challengesCreated: (stats.challengesCreated || 0) + 1 }); alert(`Challenge "${name}" erfolgreich erstellt!\n\nDie Challenge ist jetzt in der App verfügbar.\nErster echter Fahrer wird die 10 km/h Referenzzeit schlagen!`); this.loadMyTracks(); this.loadExistingChallengesOnMap(); // Clear and start new track immediately after saving this.newTrack(); } catch (error) { console.error('Error saving challenge:', error); alert('Fehler beim Speichern: ' + error.message); } } formatTime(ms) { const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); return `${minutes}:${seconds.toString().padStart(2, '0')}`; } async loadMyTracks() { const user = auth.currentUser; if (!user) return; const container = document.getElementById('my-tracks-list'); container.innerHTML = '
Lade...
'; try { // Load challenges created via web editor const challengesRef = database.ref('challenges'); const snapshot = await challengesRef.orderByChild('createdBy').equalTo(user.uid).once('value'); const challenges = snapshot.val(); if (!challenges) { container.innerHTML = '
Keine Challenges
'; return; } const challengeList = Object.entries(challenges) .map(([id, data]) => ({ id, ...data })) .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); container.innerHTML = challengeList.map(challenge => `
${challenge.name}
${challenge.totalDistance?.toFixed(2) || '?'} km · ${challenge.checkpoints?.length || 0} Checkpoints ${challenge.createdVia === 'web-editor' ? ' · Web' : ''}
Ref: ${this.formatTime(challenge.referenceTime || 0)}
`).join(''); // Add click handlers for track items container.querySelectorAll('.track-item').forEach(item => { item.addEventListener('click', (e) => { // Ignore if clicking delete button if (e.target.closest('.btn-delete-track')) return; const trackId = item.dataset.trackId; // Highlight selected container.querySelectorAll('.track-item').forEach(i => i.classList.remove('active')); item.classList.add('active'); this.loadTrack(trackId); }); }); // Add click handlers for delete buttons container.querySelectorAll('.btn-delete-track').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const trackId = btn.dataset.trackId; const trackName = btn.dataset.trackName; this.deleteTrack(trackId, trackName); }); }); } catch (error) { console.error('Error loading challenges:', error); container.innerHTML = '
Fehler
'; } } // Toggle visibility of existing challenges on map toggleExistingChallenges() { console.log('🔄 Toggle challenges called, current state:', this.showExistingChallenges); this.showExistingChallenges = !this.showExistingChallenges; console.log('🔄 New state:', this.showExistingChallenges); // Update button state const btn = document.getElementById('btn-toggle-challenges'); if (btn) { btn.classList.toggle('active', this.showExistingChallenges); const icon = btn.querySelector('.material-icons'); if (icon) { icon.textContent = this.showExistingChallenges ? 'visibility' : 'visibility_off'; } } if (this.showExistingChallenges) { console.log('🔄 Loading challenges on map...'); this.loadExistingChallengesOnMap(); } else { console.log('🔄 Hiding challenges, markers count:', this.existingChallengeMarkers?.length || 0); // Hide all challenge markers if (this.existingChallengeMarkers) { this.existingChallengeMarkers.forEach(m => m.remove()); this.existingChallengeMarkers = []; } } } // Load and display existing challenges as markers on the map async loadExistingChallengesOnMap() { console.log('📍 loadExistingChallengesOnMap called'); console.log('📍 map exists:', !!this.map); console.log('📍 showExistingChallenges:', this.showExistingChallenges); if (!this.map) { console.log('📍 No map, returning'); return; } if (!this.showExistingChallenges) { console.log('📍 Toggle is off, returning'); return; // Only load if toggle is active } const user = auth.currentUser; console.log('📍 User:', user?.uid); if (!user) { console.log('📍 No user, returning'); return; } // Store existing challenge markers for cleanup if (!this.existingChallengeMarkers) { this.existingChallengeMarkers = []; } // Remove old markers this.existingChallengeMarkers.forEach(m => m.remove()); this.existingChallengeMarkers = []; try { const challengesRef = database.ref('challenges'); const snapshot = await challengesRef.orderByChild('createdBy').equalTo(user.uid).once('value'); const challenges = snapshot.val(); if (!challenges) return; Object.entries(challenges).forEach(([id, challenge]) => { if (!challenge.startPoint && (!challenge.trail || challenge.trail.length === 0)) return; const lat = challenge.startPoint?.lat || challenge.trail[0].lat; const lng = challenge.startPoint?.lng || challenge.trail[0].lng; // Create marker element - gold/orange with trophy icon (same as app) const el = document.createElement('div'); el.className = 'existing-challenge-marker'; el.innerHTML = `${challenge.type === 'lap' ? 'refresh' : 'emoji_events'}`; el.style.cssText = `cursor: pointer; background: #FF9500; padding: 8px; border-radius: 8px; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 6px rgba(0,0,0,0.3);`; el.title = challenge.name || 'Challenge'; // Add click handler to load this challenge el.addEventListener('click', (e) => { e.stopPropagation(); this.loadTrack(id); // Highlight in the list document.querySelectorAll('.track-item').forEach(item => { item.classList.toggle('active', item.dataset.trackId === id); }); }); const marker = new mapboxgl.Marker({ element: el }) .setLngLat([lng, lat]) .addTo(this.map); this.existingChallengeMarkers.push(marker); }); console.log(`🗺️ Loaded ${this.existingChallengeMarkers.length} challenge markers on map`); } catch (error) { console.error('Error loading challenges on map:', error); } } async loadTrack(trackId) { try { // Load from /challenges/ path const challengeRef = database.ref(`challenges/${trackId}`); const snapshot = await challengeRef.once('value'); const challenge = snapshot.val(); if (!challenge) { alert('Challenge nicht gefunden'); return; } // Clear current without confirmation this.markers.forEach(m => m.marker.remove()); this.markers = []; this.waypointMarkers = []; if (this.startPoint?.marker) this.startPoint.marker.remove(); if (this.finishPoint?.marker) this.finishPoint.marker.remove(); this.trackPoints = []; this.routeCoordinates = []; this.checkpoints = []; this.startPoint = null; this.finishPoint = null; this.updateTrackLine(); this.currentTrackId = trackId; // Load data document.getElementById('track-name').value = challenge.name || ''; document.getElementById('track-description').value = challenge.description || ''; document.getElementById('track-type').value = challenge.type || 'checkpoint'; // Load waypoints (if available) or fall back to trail const waypoints = challenge.waypoints || challenge.trail; if (waypoints && Array.isArray(waypoints)) { // If we have many trail points but no waypoints, // just load first and last as waypoints (legacy challenges) if (!challenge.waypoints && waypoints.length > 10) { // Legacy challenge - just load trail directly without routing waypoints.forEach(point => { this.trackPoints.push([point.lng, point.lat]); }); this.routeCoordinates = [...this.trackPoints]; // Add markers for first and last only this.addWaypointMarkerOnly(waypoints[0].lng, waypoints[0].lat, 1); this.addWaypointMarkerOnly(waypoints[waypoints.length - 1].lng, waypoints[waypoints.length - 1].lat, 2); this.updateTrackLine(); } else { // New challenge with waypoints - recalculate route for (const point of waypoints) { await this.addTrackPoint(point.lng, point.lat); } } } // Load checkpoints (skip snapping since they're already saved at correct positions) if (challenge.checkpoints && Array.isArray(challenge.checkpoints)) { challenge.checkpoints.forEach(cp => { this.addCheckpoint(cp.lng, cp.lat, true); }); } // Load start/finish (skip snapping since they're already saved at correct positions) if (challenge.startPoint) { this.setStartPoint(challenge.startPoint.lng, challenge.startPoint.lat, true); } if (challenge.finishPoint) { this.setFinishPoint(challenge.finishPoint.lng, challenge.finishPoint.lat, true); } // Center map on track if (challenge.trail && challenge.trail.length > 0) { const bounds = new mapboxgl.LngLatBounds(); challenge.trail.forEach(point => bounds.extend([point.lng, point.lat])); this.map.fitBounds(bounds, { padding: 50 }); } this.updateTrackInfo(); } catch (error) { console.error('Error loading challenge:', error); alert('Fehler beim Laden der Challenge'); } } centerOnUserLocation() { if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( (position) => { this.map.flyTo({ center: [position.coords.longitude, position.coords.latitude], zoom: 14 }); }, (error) => { console.log('Geolocation error:', error); } ); } } } // Initialize track editor window.trackEditor = new TrackEditor();